@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
JavaScript
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
};