diff --git a/src/core/Oidc.ts b/src/core/Oidc.ts index 1e1fed6f..11ac4e6a 100644 --- a/src/core/Oidc.ts +++ b/src/core/Oidc.ts @@ -1,8 +1,9 @@ import type { OidcInitializationError } from "./OidcInitializationError"; export declare type Oidc< - DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_OidcCoreSpec -> = Oidc.LoggedIn | Oidc.NotLoggedIn; + DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_OidcCoreSpec, + User = never +> = Oidc.LoggedIn | Oidc.NotLoggedIn; export declare namespace Oidc { export type Common = { @@ -36,59 +37,67 @@ export declare namespace Oidc { initializationError: OidcInitializationError | undefined; }; - export type LoggedIn = Record> = - Common & { - isUserLoggedIn: true; - renewTokens(params?: { - extraTokenParams?: Record; - }): Promise; - getTokens: () => Promise>; - subscribeToTokensChange: (onTokenChange: (tokens: Tokens) => void) => { - unsubscribeFromTokensChange: () => void; - }; - getDecodedIdToken: () => DecodedIdToken; - logout: ( - params: - | { redirectTo: "home" | "current page" } - | { redirectTo: "specific url"; url: string } - ) => Promise; - goToAuthServer: (params: { - extraQueryParams?: Record; - redirectUrl?: string; - transformUrlBeforeRedirect?: (url: string) => string; - }) => Promise; - subscribeToAutoLogoutCountdown: ( - tickCallback: (params: { secondsLeft: number | undefined }) => void - ) => { unsubscribeFromAutoLogoutCountdown: () => void }; - /** - * If you called `goToAuthServer` or `login` with extraQueryParams, this object let you know the outcome of the - * of the action that was intended. - * - * For example, on a Keycloak server, if you called `goToAuthServer({ extraQueryParams: { kc_action: "UPDATE_PASSWORD" } })` - * you'll get back: `{ extraQueryParams: { kc_action: "UPDATE_PASSWORD" }, result: { kc_action_status: "success" } }` (or "cancelled") - */ - backFromAuthServer: - | { - extraQueryParams: Record; - result: Record; - } - | undefined; - /** - * This is true when the user has just returned from the login pages. - * This is also true when the user navigate to your app and was able to be silently signed in because there was still a valid session. - * This false however when the use just reload the page. - * - * This can be used to perform some action related to session initialization - * but avoiding doing it repeatedly every time the user reload the page. - * - * Note that this is referring to the browser session and not the OIDC session - * on the server side. - * - * If you want to perform an action only when a new OIDC session is created - * you can test oidc.isNewBrowserSession && oidc.backFromAuthServer !== undefined - */ - isNewBrowserSession: boolean; + export type LoggedIn< + DecodedIdToken extends Record = Record, + User = never + > = Common & { + isUserLoggedIn: true; + renewTokens(params?: { extraTokenParams?: Record }): Promise; + getTokens: () => Promise>; + subscribeToTokensChange: (onTokenChange: (tokens: Tokens) => void) => { + unsubscribeFromTokensChange: () => void; }; + getDecodedIdToken: () => DecodedIdToken; + logout: ( + params: { redirectTo: "home" | "current page" } | { redirectTo: "specific url"; url: string } + ) => Promise; + goToAuthServer: (params: { + extraQueryParams?: Record; + redirectUrl?: string; + transformUrlBeforeRedirect?: (url: string) => string; + }) => Promise; + subscribeToAutoLogoutCountdown: ( + tickCallback: (params: { secondsLeft: number | undefined }) => void + ) => { unsubscribeFromAutoLogoutCountdown: () => void }; + /** + * If you called `goToAuthServer` or `login` with extraQueryParams, this object let you know the outcome of the + * of the action that was intended. + * + * For example, on a Keycloak server, if you called `goToAuthServer({ extraQueryParams: { kc_action: "UPDATE_PASSWORD" } })` + * you'll get back: `{ extraQueryParams: { kc_action: "UPDATE_PASSWORD" }, result: { kc_action_status: "success" } }` (or "cancelled") + */ + backFromAuthServer: + | { + extraQueryParams: Record; + result: Record; + } + | undefined; + /** + * This is true when the user has just returned from the login pages. + * This is also true when the user navigate to your app and was able to be silently signed in because there was still a valid session. + * This false however when the use just reload the page. + * + * This can be used to perform some action related to session initialization + * but avoiding doing it repeatedly every time the user reload the page. + * + * Note that this is referring to the browser session and not the OIDC session + * on the server side. + * + * If you want to perform an action only when a new OIDC session is created + * you can test oidc.isNewBrowserSession && oidc.backFromAuthServer !== undefined + */ + isNewBrowserSession: boolean; + + getUser: () => Promise<{ + user: User; + subscribeToUserChange: ( + onUserChange: (params: { user: User; user_previous: User | undefined }) => void + ) => { + unsubscribeFromUserChange: () => void; + }; + refreshUser: () => Promise; + }>; + }; export type Tokens< DecodedIdToken extends Record = Tokens.DecodedIdToken_OidcCoreSpec diff --git a/src/core/createGetUser.ts b/src/core/createGetUser.ts new file mode 100644 index 00000000..316a4b77 --- /dev/null +++ b/src/core/createGetUser.ts @@ -0,0 +1,188 @@ +import type { Oidc } from "./Oidc"; +import { id } from "../tools/tsafe/id"; +import { assert } from "../tools/tsafe/assert"; +import type { MaybeAsync } from "../tools/MaybeAsync"; +import type { NonPostableEvt } from "../tools/Evt"; +import { decodeJwt } from "../tools/decodeJwt"; + +export function createGetUser(params: { + issuerUri: string; + createUser: + | ((params: { + decodedIdToken: Oidc.Tokens.DecodedIdToken_OidcCoreSpec; + accessToken: string; + fetchUserInfo: () => Promise<{ + [key: string]: unknown; + sub: string; + }>; + issuerUri: string; + }) => MaybeAsync) + | undefined; + getCurrentTokens: () => Oidc.Tokens; + evtTokensChange: NonPostableEvt; + renewTokens(): Promise; + oidcMetadata: { + userinfo_endpoint?: string; + }; +}) { + const { issuerUri, createUser, getCurrentTokens, evtTokensChange, renewTokens, oidcMetadata } = + params; + + type GetUser = Oidc.LoggedIn["getUser"]; + + type R_GetUser = Awaited>; + + async function fetchUserInfo(params: { accessToken: string }) { + const { accessToken } = params; + + const { userinfo_endpoint } = oidcMetadata; + + if (!userinfo_endpoint) { + // TODO: Make a class for this error + throw new Error("oidc-spa: AS does not expose a userinfo endpoint"); + } + + const r = await fetch(userinfo_endpoint, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + return r.json(); + } + + let state: { prUser: Promise; hash: string } | undefined = undefined; + + const onUserChanges = new Set<(params: { user: User; user_previous: User | undefined }) => void>(); + + const subscribeToUserChange: R_GetUser["subscribeToUserChange"] = onUserChange => { + onUserChanges.add(onUserChange); + + return { + unsubscribeFromUserChange: () => { + onUserChanges.delete(onUserChange); + } + }; + }; + + function __updatePrUserIfHashChanged() { + assert(createUser !== undefined, "94302"); + + const hash_current = state?.hash; + + const tokens = getCurrentTokens(); + + const hash_new = computeHash({ + accessToken: tokens.accessToken, + decodedIdToken: tokens.decodedIdToken_original + }); + + const prUser_new = (async () => { + const prUser_current = state?.prUser; + + if (hash_current === hash_new) { + assert(prUser_current !== undefined); + return prUser_current; + } + + const user_new = await createUser({ + accessToken: tokens.accessToken, + decodedIdToken: tokens.decodedIdToken_original, + issuerUri, + fetchUserInfo: () => fetchUserInfo({ accessToken: tokens.accessToken }) + }); + + { + const user_current = await prUser_current; + + onUserChanges.forEach(onUserChange => + onUserChange({ + user: user_new, + user_previous: user_current + }) + ); + } + + return user_new; + })(); + + state = { + hash: hash_new, + prUser: prUser_new + }; + } + + const refreshUser: R_GetUser["refreshUser"] = async () => { + if (state !== undefined) { + state.hash = ""; + } + + await renewTokens(); + + assert(state !== undefined); + + return state.prUser; + }; + + evtTokensChange.subscribe(() => { + __updatePrUserIfHashChanged(); + }); + + const getUser: GetUser = async () => { + if (createUser === undefined) { + throw new Error("oidc-spa: createUser not provided"); + } + + if (state === undefined) { + __updatePrUserIfHashChanged(); + + assert(state !== undefined); + } + + const user = await state.prUser; + + return id({ + user, + refreshUser, + subscribeToUserChange + }); + }; + + return { getUser }; +} + +function computeHash(params: { + decodedIdToken: Oidc.Tokens.DecodedIdToken_OidcCoreSpec; + accessToken: string; +}): string { + const { decodedIdToken, accessToken } = params; + + const decodedIdToken_stableish = (() => { + const { exp, iat, nonce, auth_time, amr, acr, ...rest } = decodedIdToken; + + return rest; + })(); + + const decodedAccessToken_stableish = (() => { + let decodedAccessToken: Record; + + try { + decodedAccessToken = decodeJwt(accessToken); + } catch { + return undefined; + } + + const { exp, iat, jti, nbf, cnf, ...rest } = decodedAccessToken; + + return rest; + })(); + + const stringify = (obj: Record) => + JSON.stringify(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))); + + return [ + stringify(decodedIdToken_stableish), + "|", + decodedAccessToken_stableish === undefined ? "" : stringify(decodedAccessToken_stableish) + ].join(""); +} diff --git a/src/core/createOidc.ts b/src/core/createOidc.ts index 88e2ace8..2408393f 100644 --- a/src/core/createOidc.ts +++ b/src/core/createOidc.ts @@ -64,14 +64,27 @@ import { import type { Evt } from "../tools/Evt"; import type { ParamsOfCreateGetServerDateNow } from "../tools/getServerDateNow"; import { SESSION_STORAGE_GLOBAL_PREFIX } from "../tools/lazySessionStorage"; +import type { MaybeAsync } from "../tools/MaybeAsync"; +import { createGetUser } from "./createGetUser"; // NOTE: Replaced at build time const VERSION = "{{OIDC_SPA_VERSION}}"; export type ParamsOfCreateOidc< DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_OidcCoreSpec, - AutoLogin extends boolean = false + AutoLogin extends boolean = false, + User = never > = { + createUser?: (params: { + decodedIdToken: Oidc.Tokens.DecodedIdToken_OidcCoreSpec; + accessToken: string; + fetchUserInfo: () => Promise<{ + [key: string]: unknown; + sub: string; + }>; + issuerUri: string; + }) => MaybeAsync; + /** * See: https://docs.oidc-spa.dev/v/v10/providers-configuration/provider-configuration */ @@ -449,7 +462,8 @@ export async function createOidc_nonMemoized< __metadata, disableDPoP: disableDPoP_params = false, sessionRestorationMethod: sessionRestorationMethod_params, - BASE_URL: BASE_URL_params + BASE_URL: BASE_URL_params, + createUser } = params; const exports_earlyInit = await (async () => { @@ -1434,6 +1448,295 @@ export async function createOidc_nonMemoized< decodedIdToken_previous: undefined }); + const onTokenChanges = new Set<(tokens: Oidc.Tokens) => void>(); + + const renewTokens = ((): Oidc.LoggedIn["renewTokens"] => { + // NOTE: Cannot throw (or if it does it's our fault) + async function renewTokens_nonMutexed(params: { + extraTokenParams: Record; + }) { + const { extraTokenParams } = params; + + const fallbackToFullPageReload = async (): Promise => { + persistAuthState({ configId, state: undefined }); + + await waitForAllOtherOngoingLoginOrRefreshProcessesToComplete({ + prUnlock: new Promise(() => {}) + }); + + await loginOrGoToAuthServer({ + action: "login", + redirectUrl: window.location.href, + doForceReloadOnBfCache: true, + extraQueryParams_local: undefined, + transformUrlBeforeRedirect_local: undefined, + doNavigateBackToLastPublicUrlIfTheTheUserNavigateBack: true, + interaction: "directly redirect if active session show login otherwise", + preRedirectHook: undefined + }); + assert(false, "136134"); + }; + + if (!currentTokens.hasRefreshToken && !canUseIframe) { + log?.( + [ + "Unable to refresh tokens without a full app reload,", + "because no refresh token is available", + "and your app setup prevents silent sign-in via iframe.", + "Your only option to refresh tokens is to call `window.location.reload()`" + ].join(" ") + ); + + await fallbackToFullPageReload(); + + assert(false, "136135"); + } + + log?.("Renewing tokens"); + + const { completeLoginOrRefreshProcess } = await startLoginOrRefreshProcess(); + + const result_loginSilent = await loginSilent({ + extraTokenParams + }); + + if (result_loginSilent.outcome === "timeout") { + log?.( + [ + `Silent refresh of the token failed the iframe didn't post a response (timeout).`, + `This isn't recoverable, reloading the page.` + ].join(" ") + ); + window.location.reload(); + await new Promise(() => {}); + assert(false); + } + + const clearPersistedTokensIfSessionStorageIfAny = () => { + let hasRemoved = false; + + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + assert(key !== null, "323303"); + if (key.startsWith(SESSION_STORAGE_GLOBAL_PREFIX)) { + hasRemoved = true; + sessionStorage.removeItem(key); + } + } + + if (hasRemoved) { + log?.("The persisted session in sessionStorage was probably no longer valid"); + } + }; + + let oidcClientTsUser: OidcClientTsUser; + + switch (result_loginSilent.outcome) { + case "token refreshed using refresh token": + { + log?.("Refresh token used"); + oidcClientTsUser = result_loginSilent.oidcClientTsUser; + } + break; + case "got error auth response using refresh token": + { + const { authResponse } = result_loginSilent; + + log?.( + [ + "Got error response trying to refresh tokens using the refresh token,", + "token endpoint response:", + JSON.stringify(authResponse, null, 2) + ].join(" ") + ); + + clearPersistedTokensIfSessionStorageIfAny(); + + completeLoginOrRefreshProcess(); + + await fallbackToFullPageReload(); + + assert(false, "136135"); + } + break; + case "got auth response from iframe": + { + const { authResponse } = result_loginSilent; + + clearStateDataCookie({ stateUrlParamValue: authResponse.state }); + + const authResponse_error = authResponse.error; + + if (authResponse_error === undefined) { + log?.( + [ + "Tokens refreshed using iframe, authorization endpoint response: ", + JSON.stringify(authResponse, null, 2) + ].join(" ") + ); + } else { + log?.( + [ + "Got error response trying to refresh tokens using iframe,", + "Authorization endpoint response:", + JSON.stringify(authResponse, null, 2) + ].join(" ") + ); + } + + let oidcClientTsUser_scope: OidcClientTsUser | undefined = undefined; + + try { + oidcClientTsUser_scope = + await oidcClientTsUserManager.signinRedirectCallback( + authResponseToUrl(authResponse) + ); + } catch (error) { + if (authResponse_error === undefined) { + console.error(error); + assert(false, `This is a bug in oidc-spa, please report.`); + } + } + + if (oidcClientTsUser_scope === undefined) { + clearPersistedTokensIfSessionStorageIfAny(); + + completeLoginOrRefreshProcess(); + + log?.( + [ + "The user is probably not logged in anymore,", + "need to redirect to login pages" + ].join(" ") + ); + + await fallbackToFullPageReload(); + + assert(false, "136135"); + } + + oidcClientTsUser = oidcClientTsUser_scope; + } + break; + default: + assert>(false); + break; + } + + currentTokens = oidcClientTsUserToTokens({ + oidcClientTsUser, + decodedIdToken_previous: currentTokens.decodedIdToken + }); + + if (getPersistedAuthState({ configId }) !== undefined) { + persistAuthState({ + configId, + state: { + stateDescription: "logged in", + refreshTokenExpirationTime: currentTokens.refreshTokenExpirationTime, + serverDateNow: currentTokens.getServerDateNow(), + idleSessionLifetimeInSeconds + } + }); + } + + Array.from(onTokenChanges).forEach(onTokenChange => onTokenChange(currentTokens)); + + completeLoginOrRefreshProcess(); + } + + let ongoingCall: + | { + pr: Promise; + extraTokenParams: Record; + } + | undefined = undefined; + + function handleThen() { + assert(ongoingCall !== undefined, "131276"); + + const { pr } = ongoingCall; + + pr.then(() => { + assert(ongoingCall !== undefined, "549462"); + + if (ongoingCall.pr !== pr) { + return; + } + + ongoingCall = undefined; + }); + } + + async function renewTokens_mutexed(params: { + extraTokenParams?: Record; + }) { + const { extraTokenParams: extraTokenParams_local } = params; + + const extraTokenParams = { + ...getExtraTokenParams?.(), + ...extraTokenParams_local + }; + + if (ongoingCall === undefined) { + ongoingCall = { + pr: renewTokens_nonMutexed({ extraTokenParams }), + extraTokenParams + }; + + handleThen(); + + return ongoingCall.pr; + } + + if (JSON.stringify(extraTokenParams) === JSON.stringify(ongoingCall.extraTokenParams)) { + return ongoingCall.pr; + } + + ongoingCall = { + pr: (async () => { + await ongoingCall.pr; + + return renewTokens_nonMutexed({ extraTokenParams }); + })(), + extraTokenParams + }; + + handleThen(); + + return ongoingCall.pr; + } + + return params => { + const { extraTokenParams } = params ?? {}; + + prOngoingTokenRenewal = renewTokens_mutexed({ extraTokenParams }); + + prOngoingTokenRenewal.then(() => { + prOngoingTokenRenewal = undefined; + }); + + return prOngoingTokenRenewal; + }; + })(); + + const { getUser } = createGetUser({ + createUser, + evtTokensChange: (() => { + const evtTokensChange = createEvt(); + + onTokenChanges.add(() => { + evtTokensChange.post(); + }); + + return evtTokensChange; + })(), + getCurrentTokens: () => currentTokens, + issuerUri, + oidcMetadata, + renewTokens: () => renewTokens() + }); + detect_useless_idleSessionLifetimeInSeconds: { if (idleSessionLifetimeInSeconds === undefined) { break detect_useless_idleSessionLifetimeInSeconds; @@ -1477,8 +1780,6 @@ export async function createOidc_nonMemoized< (params: { secondsLeft: number | undefined }) => void >(); - const onTokenChanges = new Set<(tokens: Oidc.Tokens) => void>(); - const { sid: sessionId, sub: subjectId } = currentTokens.decodedIdToken_original; assert(subjectId !== undefined, "The 'sub' claim is missing from the id token"); @@ -1621,275 +1922,7 @@ export async function createOidc_nonMemoized< return new Promise(() => {}); }, - renewTokens: (() => { - // NOTE: Cannot throw (or if it does it's our fault) - async function renewTokens_nonMutexed(params: { - extraTokenParams: Record; - }) { - const { extraTokenParams } = params; - - const fallbackToFullPageReload = async (): Promise => { - persistAuthState({ configId, state: undefined }); - - await waitForAllOtherOngoingLoginOrRefreshProcessesToComplete({ - prUnlock: new Promise(() => {}) - }); - - await loginOrGoToAuthServer({ - action: "login", - redirectUrl: window.location.href, - doForceReloadOnBfCache: true, - extraQueryParams_local: undefined, - transformUrlBeforeRedirect_local: undefined, - doNavigateBackToLastPublicUrlIfTheTheUserNavigateBack: true, - interaction: "directly redirect if active session show login otherwise", - preRedirectHook: undefined - }); - assert(false, "136134"); - }; - - if (!currentTokens.hasRefreshToken && !canUseIframe) { - log?.( - [ - "Unable to refresh tokens without a full app reload,", - "because no refresh token is available", - "and your app setup prevents silent sign-in via iframe.", - "Your only option to refresh tokens is to call `window.location.reload()`" - ].join(" ") - ); - - await fallbackToFullPageReload(); - - assert(false, "136135"); - } - - log?.("Renewing tokens"); - - const { completeLoginOrRefreshProcess } = await startLoginOrRefreshProcess(); - - const result_loginSilent = await loginSilent({ - extraTokenParams - }); - - if (result_loginSilent.outcome === "timeout") { - log?.( - [ - `Silent refresh of the token failed the iframe didn't post a response (timeout).`, - `This isn't recoverable, reloading the page.` - ].join(" ") - ); - window.location.reload(); - await new Promise(() => {}); - assert(false); - } - - const clearPersistedTokensIfSessionStorageIfAny = () => { - let hasRemoved = false; - - for (let i = sessionStorage.length - 1; i >= 0; i--) { - const key = sessionStorage.key(i); - assert(key !== null, "323303"); - if (key.startsWith(SESSION_STORAGE_GLOBAL_PREFIX)) { - hasRemoved = true; - sessionStorage.removeItem(key); - } - } - - if (hasRemoved) { - log?.("The persisted session in sessionStorage was probably no longer valid"); - } - }; - - let oidcClientTsUser: OidcClientTsUser; - - switch (result_loginSilent.outcome) { - case "token refreshed using refresh token": - { - log?.("Refresh token used"); - oidcClientTsUser = result_loginSilent.oidcClientTsUser; - } - break; - case "got error auth response using refresh token": - { - const { authResponse } = result_loginSilent; - - log?.( - [ - "Got error response trying to refresh tokens using the refresh token,", - "token endpoint response:", - JSON.stringify(authResponse, null, 2) - ].join(" ") - ); - - clearPersistedTokensIfSessionStorageIfAny(); - - completeLoginOrRefreshProcess(); - - await fallbackToFullPageReload(); - - assert(false, "136135"); - } - break; - case "got auth response from iframe": - { - const { authResponse } = result_loginSilent; - - clearStateDataCookie({ stateUrlParamValue: authResponse.state }); - - const authResponse_error = authResponse.error; - - if (authResponse_error === undefined) { - log?.( - [ - "Tokens refreshed using iframe, authorization endpoint response: ", - JSON.stringify(authResponse, null, 2) - ].join(" ") - ); - } else { - log?.( - [ - "Got error response trying to refresh tokens using iframe,", - "Authorization endpoint response:", - JSON.stringify(authResponse, null, 2) - ].join(" ") - ); - } - - let oidcClientTsUser_scope: OidcClientTsUser | undefined = undefined; - - try { - oidcClientTsUser_scope = - await oidcClientTsUserManager.signinRedirectCallback( - authResponseToUrl(authResponse) - ); - } catch (error) { - if (authResponse_error === undefined) { - console.error(error); - assert(false, `This is a bug in oidc-spa, please report.`); - } - } - - if (oidcClientTsUser_scope === undefined) { - clearPersistedTokensIfSessionStorageIfAny(); - - completeLoginOrRefreshProcess(); - - log?.( - [ - "The user is probably not logged in anymore,", - "need to redirect to login pages" - ].join(" ") - ); - - await fallbackToFullPageReload(); - - assert(false, "136135"); - } - - oidcClientTsUser = oidcClientTsUser_scope; - } - break; - default: - assert>(false); - break; - } - - currentTokens = oidcClientTsUserToTokens({ - oidcClientTsUser, - decodedIdToken_previous: currentTokens.decodedIdToken - }); - - if (getPersistedAuthState({ configId }) !== undefined) { - persistAuthState({ - configId, - state: { - stateDescription: "logged in", - refreshTokenExpirationTime: currentTokens.refreshTokenExpirationTime, - serverDateNow: currentTokens.getServerDateNow(), - idleSessionLifetimeInSeconds - } - }); - } - - Array.from(onTokenChanges).forEach(onTokenChange => onTokenChange(currentTokens)); - - completeLoginOrRefreshProcess(); - } - - let ongoingCall: - | { - pr: Promise; - extraTokenParams: Record; - } - | undefined = undefined; - - function handleThen() { - assert(ongoingCall !== undefined, "131276"); - - const { pr } = ongoingCall; - - pr.then(() => { - assert(ongoingCall !== undefined, "549462"); - - if (ongoingCall.pr !== pr) { - return; - } - - ongoingCall = undefined; - }); - } - - async function renewTokens_mutexed(params: { - extraTokenParams?: Record; - }) { - const { extraTokenParams: extraTokenParams_local } = params; - - const extraTokenParams = { - ...getExtraTokenParams?.(), - ...extraTokenParams_local - }; - - if (ongoingCall === undefined) { - ongoingCall = { - pr: renewTokens_nonMutexed({ extraTokenParams }), - extraTokenParams - }; - - handleThen(); - - return ongoingCall.pr; - } - - if (JSON.stringify(extraTokenParams) === JSON.stringify(ongoingCall.extraTokenParams)) { - return ongoingCall.pr; - } - - ongoingCall = { - pr: (async () => { - await ongoingCall.pr; - - return renewTokens_nonMutexed({ extraTokenParams }); - })(), - extraTokenParams - }; - - handleThen(); - - return ongoingCall.pr; - } - - return params => { - const { extraTokenParams } = params ?? {}; - - prOngoingTokenRenewal = renewTokens_mutexed({ extraTokenParams }); - - prOngoingTokenRenewal.then(() => { - prOngoingTokenRenewal = undefined; - }); - - return prOngoingTokenRenewal; - }; - })(), + renewTokens, subscribeToTokensChange: onTokenChange => { onTokenChanges.add(onTokenChange); @@ -1925,7 +1958,8 @@ export async function createOidc_nonMemoized< log?.(`isNewBrowserSession: ${value}`); return value; - })() + })(), + getUser }); if (resultOfLoginProcess.isRestoredFromSessionStorage) { diff --git a/src/react-spa/createOidcSpaUtils.ts b/src/react-spa/createOidcSpaUtils.ts index 5b61c113..6a16e787 100644 --- a/src/react-spa/createOidcSpaUtils.ts +++ b/src/react-spa/createOidcSpaUtils.ts @@ -18,18 +18,24 @@ import { createStatefulEvt } from "../tools/StatefulEvt"; import { id } from "../tools/tsafe/id"; import { toFullyQualifiedUrl } from "../tools/toFullyQualifiedUrl"; import { setDesiredPostLoginRedirectUrl } from "../core/desiredPostLoginRedirectUrl"; +import type { MaybeAsync } from "../tools/MaybeAsync"; export function createOidcSpaUtils< AutoLogin extends boolean, - DecodedIdToken extends Record + DecodedIdToken extends Record, + User >(params: { autoLogin: AutoLogin; decodedIdTokenSchema: | ZodSchemaLike | undefined; decodedIdToken_mock: DecodedIdToken | undefined; -}): OidcSpaUtils { - const { autoLogin, decodedIdTokenSchema, decodedIdToken_mock } = params; + createUser: + | ((params: { decodedIdToken: DecodedIdToken; accessToken: string }) => MaybeAsync) + | undefined; + user_mock: User | undefined; +}): OidcSpaUtils { + const { autoLogin, decodedIdTokenSchema, decodedIdToken_mock, createUser, user_mock } = params; const dParamsOfBootstrap = new Deferred>(); @@ -98,7 +104,7 @@ export function createOidcSpaUtils< function useOidc(params?: { assert?: "user logged in" | "user not logged in"; - }): UseOidc.Oidc { + }): UseOidc.Oidc { const { assert: assert_params } = params ?? {}; if (!isBrowser) { @@ -272,7 +278,7 @@ export function createOidcSpaUtils< async function getOidc(params?: { assert?: "user logged in" | "user not logged in"; - }): Promise> { + }): Promise> { if (!isBrowser) { throw new Error("oidc-spa: getOidc() can't be used on the server"); } diff --git a/src/react-spa/types.ts b/src/react-spa/types.ts index b3a17daf..272f0975 100644 --- a/src/react-spa/types.ts +++ b/src/react-spa/types.ts @@ -2,16 +2,16 @@ import type { ReactNode, ComponentType } from "react"; import type { Oidc as Oidc_core, OidcInitializationError } from "../core"; import type { OidcMetadata } from "../core/OidcMetadata"; -export type UseOidc = { - (params?: { assert?: undefined }): UseOidc.Oidc; - (params: { assert: "user logged in" }): UseOidc.Oidc.LoggedIn; +export type UseOidc = { + (params?: { assert?: undefined }): UseOidc.Oidc; + (params: { assert: "user logged in" }): UseOidc.Oidc.LoggedIn; (params: { assert: "user not logged in" }): UseOidc.Oidc.NotLoggedIn; }; export namespace UseOidc { - export type WithAutoLogin = () => Oidc.LoggedIn; + export type WithAutoLogin = () => Oidc.LoggedIn; - export type Oidc = + export type Oidc = | (Oidc.NotLoggedIn & { decodedIdToken?: never; logout?: never; @@ -19,8 +19,10 @@ export namespace UseOidc { goToAuthServer?: never; backFromAuthServer?: never; isNewBrowserSession?: never; + user?: never; + refreshUser?: never; }) - | (Oidc.LoggedIn & { + | (Oidc.LoggedIn & { login?: never; initializationError?: never; }); @@ -42,7 +44,7 @@ export namespace UseOidc { initializationError: OidcInitializationError | undefined; }; - export type LoggedIn = { + export type LoggedIn = { issuerUri: string; clientId: string; validRedirectUri: string; @@ -61,22 +63,24 @@ export namespace UseOidc { | { shouldDisplayWarning: false; }; + user: User; + refreshUser: () => Promise; }; } } -export type GetOidc = { - (params?: { assert?: undefined }): Promise>; - (params: { assert: "user logged in" }): Promise>; +export type GetOidc = { + (params?: { assert?: undefined }): Promise>; + (params: { assert: "user logged in" }): Promise>; (params: { assert: "user not logged in" }): Promise; }; export namespace GetOidc { - export type WithAutoLogin = (params?: { + export type WithAutoLogin = (params?: { assert: "user logged in"; - }) => Promise>; + }) => Promise>; - export type Oidc = + export type Oidc = | (Oidc.NotLoggedIn & { getAccessToken?: never; subscribeToAccessTokenRotation?: never; @@ -88,8 +92,9 @@ export namespace GetOidc { backFromAuthServer?: never; isNewBrowserSession?: never; subscribeToAutoLogoutState?: never; + getUser?: never; }) - | (Oidc.LoggedIn & { + | (Oidc.LoggedIn & { initializationError?: never; login?: never; }); @@ -98,7 +103,6 @@ export namespace GetOidc { type Common = { issuerUri: string; clientId: string; - validRedirectUri: string; }; export type NotLoggedIn = Common & { @@ -107,7 +111,7 @@ export namespace GetOidc { login: Oidc_core.NotLoggedIn["login"]; }; - export type LoggedIn = Common & { + export type LoggedIn = Common & { isUserLoggedIn: true; getAccessToken: () => Promise; subscribeToAccessTokenRotation: (next: (accessToken: string) => void) => { @@ -134,13 +138,20 @@ export namespace GetOidc { } ) => void ) => { unsubscribeFromAutoLogoutState: () => void }; + getUser: () => Promise<{ + user: User; + subscribeToUserChange: (onUserChange: (user: User) => void) => { + unsubscribeFromUserChange: () => void; + }; + refreshUser: () => void; + }>; }; } } -export type ParamsOfBootstrap = +export type ParamsOfBootstrap = | ParamsOfBootstrap.Real - | ParamsOfBootstrap.Mock; + | ParamsOfBootstrap.Mock; export namespace ParamsOfBootstrap { export type Real = { @@ -308,11 +319,12 @@ export namespace ParamsOfBootstrap { disableDPoP?: true; } & (AutoLogin extends true ? {} : {}); - export type Mock = { + export type Mock = { implementation: "mock"; issuerUri_mock?: string; clientId_mock?: string; decodedIdToken_mock?: DecodedIdToken; + user_mock?: User; /** * Let's you override the params passed to @@ -328,10 +340,14 @@ export namespace ParamsOfBootstrap { }); } -export type OidcSpaUtils = { - bootstrapOidc: (params: ParamsOfBootstrap) => Promise; - useOidc: AutoLogin extends true ? UseOidc.WithAutoLogin : UseOidc; - getOidc: AutoLogin extends true ? GetOidc.WithAutoLogin : GetOidc; +export type OidcSpaUtils = { + bootstrapOidc: (params: ParamsOfBootstrap) => Promise; + useOidc: AutoLogin extends true + ? UseOidc.WithAutoLogin + : UseOidc; + getOidc: AutoLogin extends true + ? GetOidc.WithAutoLogin + : GetOidc; OidcInitializationGate: (props: { fallback?: ReactNode; children: ReactNode }) => ReactNode; } & (AutoLogin extends true ? { diff --git a/src/react-spa/utilsBuilder.ts b/src/react-spa/utilsBuilder.ts index 1794aad4..e1e690c7 100644 --- a/src/react-spa/utilsBuilder.ts +++ b/src/react-spa/utilsBuilder.ts @@ -2,18 +2,25 @@ import type { OidcSpaUtils } from "./types"; import type { Oidc as Oidc_core } from "../core"; import type { ZodSchemaLike } from "../tools/ZodSchemaLike"; import { createOidcSpaUtils } from "./createOidcSpaUtils"; +import type { MaybeAsync } from "../tools/MaybeAsync"; export type OidcSpaUtilsBuilder< AutoLogin extends boolean = false, DecodedIdToken extends Record = Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec, + User = never, ExcludedMethod extends | "withAutoLogin" | "withExpectedDecodedIdTokenShape" - | "withAccessTokenValidation" + | "withUserAbstraction" | "createUtils" = never > = Omit< { - withAutoLogin: () => OidcSpaUtilsBuilder; + withAutoLogin: () => OidcSpaUtilsBuilder< + true, + DecodedIdToken, + User, + ExcludedMethod | "withAutoLogin" + >; withExpectedDecodedIdTokenShape: >(params: { decodedIdTokenSchema: ZodSchemaLike< Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec, @@ -23,42 +30,81 @@ export type OidcSpaUtilsBuilder< }) => OidcSpaUtilsBuilder< AutoLogin, DecodedIdToken, + User, ExcludedMethod | "withExpectedDecodedIdTokenShape" >; - - createUtils: () => OidcSpaUtils; + withUser: (params: { + createUser: (params: { + decodedIdToken: Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec; + accessToken: string; + fetchUserInfo: () => Promise<{ + [key: string]: unknown; + sub: string; + }>; + issuerUri: string; + }) => MaybeAsync; + user_mock?: NoInfer; + }) => OidcSpaUtilsBuilder< + AutoLogin, + DecodedIdToken, + User, + ExcludedMethod | "withUserAbstraction" + >; + createUtils: () => OidcSpaUtils; }, ExcludedMethod >; function createOidcSpaUtilsBuilder< AutoLogin extends boolean = false, - DecodedIdToken extends Record = Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec + DecodedIdToken extends Record = Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec, + User = never >(params: { autoLogin: AutoLogin; decodedIdTokenSchema: | ZodSchemaLike | undefined; decodedIdToken_mock: DecodedIdToken | undefined; -}): OidcSpaUtilsBuilder { + createUser: + | ((params: { decodedIdToken: DecodedIdToken; accessToken: string }) => MaybeAsync) + | undefined; + user_mock: User | undefined; +}): OidcSpaUtilsBuilder { return { withAutoLogin: () => createOidcSpaUtilsBuilder({ autoLogin: true, decodedIdTokenSchema: params.decodedIdTokenSchema, - decodedIdToken_mock: params.decodedIdToken_mock + decodedIdToken_mock: params.decodedIdToken_mock, + createUser: params.createUser, + user_mock: params.user_mock }), - withExpectedDecodedIdTokenShape: ({ decodedIdTokenSchema, decodedIdToken_mock }) => - createOidcSpaUtilsBuilder({ + withExpectedDecodedIdTokenShape: ({ decodedIdTokenSchema, decodedIdToken_mock }) => { + return createOidcSpaUtilsBuilder({ autoLogin: params.autoLogin, decodedIdTokenSchema, - decodedIdToken_mock: decodedIdToken_mock - }), + decodedIdToken_mock, + createUser: params.createUser, + user_mock: params.user_mock + }); + }, + withUser: ({ createUser, user_mock }) => { + return createOidcSpaUtilsBuilder({ + autoLogin: params.autoLogin, + decodedIdTokenSchema: params.decodedIdTokenSchema, + decodedIdToken_mock: params.decodedIdToken_mock, + // @ts-expect-error + createUser, + user_mock + }); + }, createUtils: () => - createOidcSpaUtils({ + createOidcSpaUtils({ autoLogin: params.autoLogin, decodedIdTokenSchema: params.decodedIdTokenSchema, - decodedIdToken_mock: params.decodedIdToken_mock + decodedIdToken_mock: params.decodedIdToken_mock, + createUser: params.createUser, + user_mock: params.user_mock }) }; } @@ -66,5 +112,7 @@ function createOidcSpaUtilsBuilder< export const oidcSpaUtilsBuilder = createOidcSpaUtilsBuilder({ autoLogin: false, decodedIdToken_mock: undefined, - decodedIdTokenSchema: undefined + decodedIdTokenSchema: undefined, + createUser: undefined, + user_mock: undefined });