UNPKG

@i-novus/oidc-pkce-public-client

Version:

OIDC public client with PKCE for react with tokens sharing by localStorage. React context included

457 lines (456 loc) 16.7 kB
var O = Object.defineProperty; var L = (n, e, o) => e in n ? O(n, e, { enumerable: !0, configurable: !0, writable: !0, value: o }) : n[e] = o; var w = (n, e, o) => (L(n, typeof e != "symbol" ? e + "" : e, o), o); import { jwtDecode as C } from "jwt-decode"; import { v4 as I } from "uuid"; import N from "crypto-js/sha256"; import R from "crypto-js/enc-base64url"; import { jsx as j } from "react/jsx-runtime"; import { createContext as K, useState as V, useEffect as G, useContext as B } from "react"; function P(n) { try { return C(n); } catch (e) { throw new Error(`JWT DECODE: ${e}`); } } const F = 365 * 24 * 60 * 60 * 1e3; function M(n) { const e = Date.now(); Object.keys(localStorage).filter((o) => o.startsWith(n.storeKeyPrefix)).forEach((o) => { let t = !1; try { const s = window.localStorage.getItem(o); if (!s) { t = !0; return; } const r = JSON.parse(s); e > r.expireIn && (t = !0); } catch (s) { console.error(s), t = !0; } finally { t && window.localStorage.removeItem(o); } }), setTimeout(() => { M(n); }, 60 * 1e3); } function _(n, e = {}) { const { storeKeyPrefix: o, authority: t, clientId: s } = n; return `${o}${e.keyPostfix ?? ""}${t}/:${s}`; } function y(n, e, o = {}) { const t = _(n, o), r = { expireIn: o.expireIn ?? Date.now() + F, value: e }; window.localStorage.setItem(t, JSON.stringify(r)); } function D(n, e = {}) { const o = _(n, e); window.localStorage.removeItem(o); } function m(n, e = {}) { try { const o = _(n, e), t = window.localStorage.getItem(o); if (!t) return { expireIn: 0, value: null }; const s = JSON.parse(t); return Date.now() >= s.expireIn ? (window.localStorage.removeItem(o), { expireIn: 0, value: null }) : s; } catch (o) { return console.error(o), { expireIn: 0, value: null }; } } function v(n, e) { const { storeKeyPrefix: o } = n, { id: t } = e; return `${o}${t}`; } function q(n, e, o) { const t = v(n, o); window.sessionStorage.setItem(t, JSON.stringify(e)); } function H(n, e) { try { const o = v(n, e), t = window.sessionStorage.getItem(o); return t ? JSON.parse(t) : null; } catch (o) { return console.error(o), null; } } const A = "access_token:", S = "refresh_token:", U = "id_token:", E = 1e3, J = 60 * E, z = 60 * J; class T { constructor(e) { /** * Client configuration */ w(this, "config"); /** * Признак завершенной инициализации клиента. События будут генерироваться только если инициализация завершена */ w(this, "initialised", !1); w(this, "events", { login: [], logout: [], accessTokenUpdated: [] }); /** * Делает login, если пользователь не авторизован * @param redirectUri */ w(this, "login", async (e = window.location.href) => { const { responseId: o, responseCode: t } = this.getLoginCallbackData(); if (o || t) { console.error("Login cant be done cause code or state params present in url"); return; } const { accessToken: s, refreshToken: r } = this.getUserTokens(); if (s || r) return; const { clientId: a, responseType: l, scope: i, responseMode: p, pkce: u } = this.config, { authorization_endpoint: g } = await this.getMetadataConfig(), f = I(), d = I() + I() + I(), c = new URL(g); if (c.searchParams.append("client_id", a), c.searchParams.append("redirect_uri", e), c.searchParams.append("response_type", l), c.searchParams.append("response_mode", p), c.searchParams.append("scope", i), c.searchParams.append("state", f), u) { const k = N(d), x = R.stringify(k); c.searchParams.append("code_challenge", x), c.searchParams.append("code_challenge_method", "S256"); } const h = { action: "login", id: f, codeVerifier: d, redirectUri: e }; q(this.config, h, { id: f }), window.location.assign(c.href), await new Promise(() => { }); }); /** * Делает logout, если пользователь авторизован */ w(this, "logout", async (e = {}) => { const { idToken: o } = this.getUserTokens(); if (!o) return; e != null && e.beforeLogout && await (e == null ? void 0 : e.beforeLogout(this)); const { logoutBackUrl: t } = this.config, { end_session_endpoint: s } = await this.getMetadataConfig(), r = new URL(s); r.searchParams.append("id_token_hint", o), r.searchParams.append("post_logout_redirect_uri", t), this.removeUserTokens(), window.location.assign(r.href), await new Promise(() => { }); }); /** * В бесконечном цикле проверяет, нужно ли обновить токены */ w(this, "refreshAccessTokenLoop", async () => { try { const { accessTokenExpireIn: e, refreshToken: o } = this.getUserTokens(); if (e - Date.now() <= 30 * E && o && !this.isRefreshRequestsPending()) { const { token_endpoint: r } = await this.getMetadataConfig(), { clientId: a, scope: l } = this.config, i = new URLSearchParams(); i.append("grant_type", "refresh_token"), i.append("refresh_token", o), i.append("scope", l), i.append("client_id", a); const p = await fetch( r, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: i.toString() } ); if (p.ok && p.status === 200) { const u = await p.json(); this.setUserTokens(u); } else this.removeUserTokens(); } } catch (e) { console.error(`REFRESH TOKEN: ${e}`); } setTimeout(this.refreshAccessTokenLoop, 10 * E); }); e ? this.config = T.getConfig(e) : this.config = null; } on(e, o) { const t = this.events[e]; return t.indexOf(o) === -1 && t.push(o), this; } off(e, o) { const t = this.events[e].indexOf(o); return t >= 0 && this.events[e].splice(t, 1), this; } emit(e, ...o) { return this.events[e].forEach((s) => s(...o)), this; } /** * Возвращает полный конфиг на основе пользовательского * @param config */ static getConfig(e) { const o = ["query", "fragment"], t = { subjectType: "public", authority: e.authority.trim().replace(/\/+$/, ""), clientId: e.clientId, responseType: "code", responseMode: e.responseMode ?? "query", grantType: "authorization_code", scope: e.scope ?? "openid", logoutBackUrl: e.logoutBackUrl ?? window.location.href, storeKeyPrefix: e.storeKeyPrefix ?? "OIDC-MIF:", pkce: e.pkce ?? !0 }; if (!t.authority) throw new Error("CONFIG: authority is not set"); if (!t.clientId) throw new Error("CONFIG: clientId is not set"); if (!t.responseMode) throw new Error("CONFIG: responseMode is not set"); if (!o.includes(t.responseMode)) throw new Error(`CONFIG: responseMode is "${t.responseMode}", but accepted only one of "${o.join('", "')}"`); if (!t.storeKeyPrefix) throw new Error("CONFIG: storeKeyPrefix is not set"); return t; } /** * Возвращает конфиг oauth сервера */ async getMetadataConfig() { const e = { keyPostfix: "METADATA:", expireIn: Date.now() + 12 * z }, { value: o } = m(this.config, e); if (o !== null) return o; const { authority: t, grantType: s, responseType: r, subjectType: a, responseMode: l } = this.config, i = await fetch(`${t}/.well-known/openid-configuration`); if (!i.ok || i.status !== 200) throw new Error(`METADATA: Getting OpenID configuration is failed. Error (${i.status}): ${await i.text()}`); const p = await i.json(), { authorization_endpoint: u, token_endpoint: g, end_session_endpoint: f, grant_types_supported: d, response_types_supported: c, subject_types_supported: h, response_modes_supported: k } = p; if (!u) throw new Error("METADATA: authorization_endpoint is not set"); if (!g) throw new Error("METADATA: token_endpoint is not set"); if (!f) throw new Error("METADATA: end_session_endpoint is not set"); if (!(d != null && d.length)) throw new Error("METADATA: grant_types_supported is not set or empty"); if (!d.includes(s)) throw new Error(`METADATA: grant type "${s}" is not supported. Available only "${d.join('", "')}"`); if (!(c != null && c.length)) throw new Error("METADATA: response_types_supported is not set or empty"); if (!c.includes(r)) throw new Error(`METADATA: response type "${r}" is not supported. Available only "${c.join('", "')}"`); if (!(h != null && h.length)) throw new Error("METADATA: subject_types_supported is not set or empty"); if (!h.includes(a)) throw new Error(`METADATA: subject type "${a}" is not supported. Available only "${h.join('", "')}"`); if (!(k != null && k.length)) throw new Error("METADATA: response_modes_supported is not set or empty"); const x = ["query", "fragment"].filter(($) => k.includes($)); if (!x.includes(l)) throw new Error(`METADATA: response mode "${l}" is not supported. Available only "${x.join('", "')}"`); return y(this.config, p, e), p; } /** * Возвращает промежуточные данные авторизации, если произошел редирект с oauth сервера. Опирается на url */ getLoginCallbackData() { const { responseMode: e } = this.config, o = {}; let t = null, s = null; switch (e) { case "query": { const { searchParams: r } = new URL(window.location.href); for (const a of r.keys()) o[a] = r.get(a); break; } case "fragment": { let { hash: r } = window.location; r != null && r.startsWith("#") && (r = decodeURIComponent(r.substring(1)), r.split("&").forEach((l) => { const [i, ...p] = l.split("="); o[i] = p.join("="); })); break; } default: throw new Error(`INIT: Unknown response mode type "${e}"`); } if (o.code && o.state) t = o.state || null, s = o.code || null; else if (o.error && o.state) throw new Error(`${o.error}: ${o.error_description}`); return { responseId: t, responseCode: s }; } /** * Инициализирует клиент */ async init({ oidcClientConfig: e, afterLogin: o } = {}) { if (e && (this.config = T.getConfig(e)), !this.config) throw new Error("OIDC Config is not initialised"); M(this.config); const { responseId: t, responseCode: s } = this.getLoginCallbackData(), r = t && s ? H(this.config, { id: t }) : null; if (t && s && !r && (console.error('Authorisation error: "responseId" and "responseCode" is set, but "loginData" is not found'), await new Promise((a) => { setTimeout(a, 5e3); }), window.location.assign(window.location.pathname), await new Promise(() => { })), t && s && r) { const { grantType: a, clientId: l, pkce: i } = this.config, { token_endpoint: p } = await this.getMetadataConfig(), { codeVerifier: u, redirectUri: g } = r, f = new URLSearchParams(); f.append("grant_type", a), f.append("redirect_uri", g), f.append("code", s), f.append("client_id", l), i && f.append("code_verifier", u); const d = await fetch( p, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: f.toString() } ); if (!d.ok || d.status !== 200) throw new Error(`LOGIN CALLBACK: Getting authorization token is failed. Error (${d.status}): ${await d.text()}`); const c = await d.json(); if (!["bearer", "Bearer"].includes(c.token_type)) throw new Error(`LOGIN CALLBACK: Token is not bearer type. Actual: ${c.token_type}`); this.setUserTokens(c), o && await o(this), window.location.assign(g), await new Promise(() => { }); } await this.refreshAccessTokenLoop(), this.initialised = !0, window.addEventListener("storage", (a) => { if (a.type === "storage" && a.storageArea === window.localStorage && _(this.config, { keyPostfix: A }) === a.key) { const { accessToken: i } = this.getUserTokens(); !a.oldValue && i ? this.emit("login", i) : a.oldValue && !i ? this.emit("logout") : a.oldValue && i && a.oldValue !== i && this.emit("accessTokenUpdated", i, a.oldValue); } }); } /** * Устанавливает актуальные токены. Запускает события login или accessTokenUpdated, в зависимости от предыдущего состояния * @param tokenResponse объект с токенами */ setUserTokens(e) { const { access_token: o, refresh_token: t, id_token: s } = e, { accessToken: r } = this.getUserTokens(); y( this.config, o, { keyPostfix: A, expireIn: T.getTokenExpireIn(o) } ), y( this.config, t, { keyPostfix: S, expireIn: T.getTokenExpireIn(t) } ), y( this.config, s, { keyPostfix: U, expireIn: T.getTokenExpireIn(s) } ), this.initialised && (r ? r !== e.access_token && this.emit("accessTokenUpdated", e.access_token, r) : this.emit("login", e.access_token)); } /** * Возвращает timestamp, когда токен устареет * @param token */ static getTokenExpireIn(e) { return P(e).exp * E; } /** * Удаляет текущие токены, фактически делает logout */ removeUserTokens() { const { accessToken: e } = this.getUserTokens(); D(this.config, { keyPostfix: A }), D(this.config, { keyPostfix: S }), D(this.config, { keyPostfix: U }), this.initialised && e && this.emit("logout"); } /** * Получает текущие токены */ getUserTokens() { const { expireIn: e, value: o } = m(this.config, { keyPostfix: A }), { value: t } = m(this.config, { keyPostfix: S }), { value: s } = m(this.config, { keyPostfix: U }); return { accessTokenExpireIn: e, accessToken: o, refreshToken: t, idToken: s }; } /** * Проверяет, есть ли активные запросы обновления токена с других вкладок */ isRefreshRequestsPending() { const e = { keyPostfix: "refreshLock", expireIn: Date.now() + 10 * E }, { value: o } = m(this.config, e); return o === !0 ? !0 : (y(this.config, !0, e), !1); } } const b = K({ login: () => { throw new Error("Node not inside <OidcContextProvider>"); }, logout: () => { throw new Error("Node not inside <OidcContextProvider>"); }, accessToken: null, rawParsedToken: null }), te = ({ oidcClient: n, children: e }) => { const [o, t] = V(() => { const { accessToken: s } = n.getUserTokens(), r = s === null ? null : P(s); return { login: n.login, logout: n.logout, accessToken: s, rawParsedToken: r }; }); return G(() => { const s = (l) => { const i = P(l); t({ login: n.login, logout: n.logout, accessToken: l, rawParsedToken: i }); }, r = () => { t({ login: n.login, logout: n.logout, accessToken: null, rawParsedToken: null }); }, a = (l, i) => { if (l === i) return; const p = P(l); t({ login: n.login, logout: n.logout, accessToken: l, rawParsedToken: p }); }; return n.on("login", s), n.on("logout", r), n.on("accessTokenUpdated", a), () => { n.off("login", s), n.off("logout", r), n.off("accessTokenUpdated", a); }; }, [n]), /* @__PURE__ */ j(b.Provider, { value: o, children: e }); }, ne = () => B(b); export { T as OidcClient, te as OidcContextProvider, M as cleanerStart, m as getStoreData, _ as getStoreKey, H as getTabStoreData, v as getTabStoreKey, P as jwtDecode, b as oidcContext, D as removeStoreData, y as setStoreData, q as setTabStoreData, ne as useOidcContext };