diff --git a/examples/tanstack-router-file-router/src/components/userPictureFallback.svg b/examples/tanstack-router-file-router/src/assets/avatarFallback.svg similarity index 100% rename from examples/tanstack-router-file-router/src/components/userPictureFallback.svg rename to examples/tanstack-router-file-router/src/assets/avatarFallback.svg diff --git a/examples/tanstack-router-file-router/src/components/Header.tsx b/examples/tanstack-router-file-router/src/components/Header.tsx index 2e049c34..61675798 100644 --- a/examples/tanstack-router-file-router/src/components/Header.tsx +++ b/examples/tanstack-router-file-router/src/components/Header.tsx @@ -2,8 +2,6 @@ import { Link } from "@tanstack/react-router"; import { useOidc } from "#/oidc"; import { isKeycloak, createKeycloakUtils } from "oidc-spa/keycloak"; -import userPictureFallback from "./userPictureFallback.svg"; - export function Header() { return (
@@ -54,17 +52,12 @@ const primaryButtonClasses = "inline-flex items-center rounded-full bg-white/90 px-4 py-2 text-sm font-semibold text-slate-900 transition-colors hover:bg-white"; function LoggedInAuthButtons() { - const { decodedIdToken, logout, issuerUri, clientId, validRedirectUri } = useOidc({ + const { user, logout, issuerUri, clientId, validRedirectUri } = useOidc({ assert: "user logged in" }); const keycloakUtils = !isKeycloak({ issuerUri }) ? undefined : createKeycloakUtils({ issuerUri }); - const profileImageSrc = - decodedIdToken.picture && decodedIdToken.picture.trim().length > 0 - ? decodedIdToken.picture - : userPictureFallback; - return (
{`${decodedIdToken.name}'s @@ -116,13 +109,13 @@ function NotLoggedInAuthButtons() { } function AdminOnlyNavLink() { - const { isUserLoggedIn, decodedIdToken } = useOidc(); + const { isUserLoggedIn, user } = useOidc(); if (!isUserLoggedIn) { return null; } - if (!decodedIdToken.realm_access?.roles.includes("realm-admin")) { + if (!user.isRealmAdmin) { return null; } diff --git a/examples/tanstack-router-file-router/src/oidc.ts b/examples/tanstack-router-file-router/src/oidc.ts index 5c4db0a7..65c26131 100644 --- a/examples/tanstack-router-file-router/src/oidc.ts +++ b/examples/tanstack-router-file-router/src/oidc.ts @@ -1,51 +1,23 @@ import { oidcSpa } from "oidc-spa/react-spa"; -import { z } from "zod"; +import { type User, createUser, user_mock } from "./oidc.user"; -export const { - bootstrapOidc, - useOidc, - getOidc, - enforceLogin, - // Wrap your whole application within this component in the root route - // Non blocking rendering is possible, see: https://docs.oidc-spa.dev/v/v10/features/non-blocking-rendering#react-spas - OidcInitializationGate -} = oidcSpa - .withExpectedDecodedIdTokenShape({ - // Describe the expected shape of the ID Token. - // Think of `decodedIdToken` as your “user” object. - decodedIdTokenSchema: z.object({ - sub: z.string(), - name: z.string(), - picture: z.string().optional(), - email: z.string().email().optional(), - preferred_username: z.string().optional(), - realm_access: z.object({ roles: z.array(z.string()) }).optional() - }), - // The mock user returned when the mock implementation is enabled. - decodedIdToken_mock: { - sub: "mock-user", - name: "John Doe", - preferred_username: "john.doe", - realm_access: { - roles: ["realm-admin"] - } - } - }) +export const { bootstrapOidc, useOidc, getOidc, enforceLogin, OidcInitializationGate } = oidcSpa + .withUser({ createUser, user_mock }) // See: https://docs.oidc-spa.dev/v/v10/features/auto-login#react-spa //.withAutoLogin() .createUtils(); /** - * This can be called immediately or after you've fetched some remote params. - * If you call this more than once the subsequent calls will be ignored. + * Call this immediately, or after you fetch remote configuration. + * If you call it more than once, the later calls are ignored. */ bootstrapOidc( import.meta.env.VITE_OIDC_USE_MOCK === "true" ? { - // Mock mode: no requests to an auth server are made. + // Mock mode: no requests are sent to the auth server. implementation: "mock", isUserInitiallyLoggedIn: true - // You can also override mock user data here. + // You can also override `user_mock` here. } : { implementation: "real", @@ -53,13 +25,19 @@ bootstrapOidc( issuerUri: import.meta.env.VITE_OIDC_ISSUER_URI, clientId: import.meta.env.VITE_OIDC_CLIENT_ID, // Enable for detailed initialization and token lifecycle logs. - debugLogs: true + debugLogs: false } ); /** * A convenience wrapper around `fetch()` that automatically * attaches the access token as an Authorization header when the user is logged in. + * + * Usage: + * const response = await fetchWithAuth("/api/data"); + * + * If you need to talk to more than one resource server read this: + * https://docs.oidc-spa.dev/v/v10/talking-to-multiple-apis-with-different-access-tokens */ export const fetchWithAuth: typeof fetch = async (input, init) => { const oidc = await getOidc(); diff --git a/examples/tanstack-router-file-router/src/oidc.user.ts b/examples/tanstack-router-file-router/src/oidc.user.ts new file mode 100644 index 00000000..996848c8 --- /dev/null +++ b/examples/tanstack-router-file-router/src/oidc.user.ts @@ -0,0 +1,100 @@ +import type { CreateUser } from "oidc-spa/core"; +import { z } from "zod"; +import avatarFallbackSvgUrl from "./assets/avatarFallback.svg"; + +// App-level user shape exposed by `useOidc()`. +// You decide what an user should looks like! +export type User = { + id: string; + username: string; + displayName: string; + email: string | undefined; + avatarImgUrl: string; + isRealmAdmin: boolean; + userInfo: { + sub: string; + [claim: string]: unknown; + }; + keycloakUserProfile?: import("oidc-spa/keycloak").KeycloakProfile; +}; + +// The function that oidc-spa will call to create the user object, +// gathering information from different sources depending of what you need. +export const createUser: CreateUser = async ({ + decodedIdToken: decodedIdToken_generic, + accessToken, + fetchUserInfo, + issuerUri +}) => { + /* ================= Possible source: ID token claims. ====================== */ + + const DecodedIdToken = z.object({ + sub: z.string(), + name: z.string(), + picture: z.string().optional(), + email: z.string().email().optional(), + preferred_username: z.string().optional() + }); + + const decodedIdToken = DecodedIdToken.parse(decodedIdToken_generic); + + /* ================== Possible source: access token claims. ================== */ + // This is pragmatic, but not textbook OIDC: clients should usually + // treat access tokens as opaque, and some providers do not issue JWTs. + + const DecodedAccessToken = z.object({ + realm_access: z.object({ roles: z.array(z.string()) }).optional() + }); + + const { decodeJwt } = await import("oidc-spa/decode-jwt"); + const { isKeycloak } = await import("oidc-spa/keycloak"); + + const decodedAccessToken = !isKeycloak({ issuerUri }) + ? undefined + : DecodedAccessToken.parse(decodeJwt(accessToken)); + + /* ================= Possible source: your own API. ========================= */ + + // const { fetchWithAuth } = await import("./oidc"); + // const userFromApi = await fetchWithAuth("/api/user").then(r => r.json()); + + /* ================= Possible source: the standard OIDC UserInfo endpoint. == */ + + const userInfo = await fetchUserInfo(); + + /* ================= Possible source: provider-specific endpoints. ========== */ + const { createKeycloakUtils } = await import("oidc-spa/keycloak"); + + const keycloakUtils = isKeycloak({ issuerUri }) ? createKeycloakUtils({ issuerUri }) : undefined; + + const keycloakUserProfile = await keycloakUtils?.fetchUserProfile({ accessToken }); + + /* ================== Merging =============================================== */ + // Merge whichever sources you decided to use into the single + // `User` shape consumed by the rest of the app. + + const user: User = { + id: decodedIdToken.sub, + username: decodedIdToken.preferred_username ?? decodedIdToken.sub, + displayName: decodedIdToken.name, + avatarImgUrl: decodedIdToken.picture || avatarFallbackSvgUrl, + email: decodedIdToken.email, + isRealmAdmin: decodedAccessToken?.realm_access?.roles.includes("realm-admin") ?? false, + userInfo, + keycloakUserProfile + }; + + return user; +}; + +// App-level user returned when the mock implementation is enabled. +export const user_mock: User = { + id: "mock-user", + username: "john.doe", + displayName: "John Doe", + email: undefined, + avatarImgUrl: avatarFallbackSvgUrl, + isRealmAdmin: true, + userInfo: { sub: "1234" }, + keycloakUserProfile: undefined +}; diff --git a/examples/tanstack-router-file-router/src/routes/admin-only.tsx b/examples/tanstack-router-file-router/src/routes/admin-only.tsx index 921877ef..f455d5db 100644 --- a/examples/tanstack-router-file-router/src/routes/admin-only.tsx +++ b/examples/tanstack-router-file-router/src/routes/admin-only.tsx @@ -7,7 +7,9 @@ export const Route = createFileRoute("/admin-only")({ loader: async () => { const oidc = await getOidc({ assert: "user logged in" }); - if (!oidc.getDecodedIdToken().realm_access?.roles.includes("realm-admin")) { + const { user } = await oidc.getUser(); + + if (!user.isRealmAdmin) { throw new Error("unauthorized"); } }, @@ -37,7 +39,8 @@ function AdminOnly() {

Administration Page

- Access is granted because your ID token includes the realm-admin role. + Access is granted because your access token includes the realm-admin{" "} + role.

diff --git a/examples/tanstack-router-file-router/src/routes/protected.tsx b/examples/tanstack-router-file-router/src/routes/protected.tsx index bc383b93..08893db5 100644 --- a/examples/tanstack-router-file-router/src/routes/protected.tsx +++ b/examples/tanstack-router-file-router/src/routes/protected.tsx @@ -19,8 +19,9 @@ export const Route = createFileRoute("/protected")({ function Protected() { // Safe to assume user is logged in here. - const { decodedIdToken, goToAuthServer, backFromAuthServer, issuerUri, clientId, validRedirectUri } = - useOidc({ assert: "user logged in" }); + const { user, goToAuthServer, backFromAuthServer, issuerUri, clientId, validRedirectUri } = useOidc({ + assert: "user logged in" + }); const keycloakUtils = isKeycloak({ issuerUri }) ? createKeycloakUtils({ issuerUri }) : undefined; @@ -30,7 +31,7 @@ function Protected() {

Protected content

-

Hello {decodedIdToken.name}

+

Hello {user.displayName}

These actions come directly from your identity provider via oidc-spa.

@@ -38,11 +39,9 @@ function Protected() {
- {decodedIdToken.sub} - {decodedIdToken.email && {decodedIdToken.email}} - {decodedIdToken.preferred_username && ( - {decodedIdToken.preferred_username} - )} + {user.id} + {user.email && {user.email}} + {user.username && {user.username}}
{keycloakUtils && ( diff --git a/examples/tanstack-start/src/components/Header.tsx b/examples/tanstack-start/src/components/Header.tsx index dc27f6c2..0e621690 100644 --- a/examples/tanstack-start/src/components/Header.tsx +++ b/examples/tanstack-start/src/components/Header.tsx @@ -5,8 +5,6 @@ import { ChevronDown, ChevronRight, Home, Menu, Server, X } from "lucide-react"; import { useOidc } from "#/oidc"; import { isKeycloak, createKeycloakUtils } from "oidc-spa/keycloak"; -import userPictureFallback from "./userPictureFallback.svg"; - export default function Header() { const [isOpen, setIsOpen] = useState(false); const [groupedExpanded, setGroupedExpanded] = useState>({}); @@ -189,7 +187,7 @@ function AuthButtons(props: { className?: string }) { } function LoggedInAuthButton() { - const { decodedIdToken, logout } = useOidc({ assert: "user logged in" }); + const { user, logout } = useOidc({ assert: "user logged in" }); return (
@@ -198,8 +196,8 @@ function LoggedInAuthButton() { className="flex items-center gap-3 text-white font-semibold hover:text-cyan-300 transition-colors" > {`${decodedIdToken.name}'s @@ -246,13 +244,13 @@ function NotLoggedInAuthButton() { function AdminOnlyNavLink(props: { onClick: () => void }) { const { onClick } = props; - const { isUserLoggedIn, decodedIdToken } = useOidc(); + const { isUserLoggedIn, user } = useOidc(); if (!isUserLoggedIn) { return null; } - if (!decodedIdToken.realm_access?.roles.includes("realm-admin")) { + if (!user.isKeycloakRealmAdmin) { return null; } diff --git a/examples/tanstack-start/src/oidc.ts b/examples/tanstack-start/src/oidc.ts index 5c0d5ad6..19e9ba50 100644 --- a/examples/tanstack-start/src/oidc.ts +++ b/examples/tanstack-start/src/oidc.ts @@ -1,5 +1,6 @@ import { oidcSpa } from "oidc-spa/react-tanstack-start"; import { z } from "zod"; +import { type User, createUser, user_mock } from "./oidc.user"; export const { bootstrapOidc, @@ -12,22 +13,7 @@ export const { oidcFnMiddleware, oidcRequestMiddleware } = oidcSpa - .withExpectedDecodedIdTokenShape({ - decodedIdTokenSchema: z.object({ - name: z.string(), - picture: z.string().optional(), - email: z.email().optional(), - preferred_username: z.string().optional(), - realm_access: z.object({ roles: z.array(z.string()) }).optional() - }), - decodedIdToken_mock: { - name: "John Doe", - preferred_username: "john.doe", - realm_access: { - roles: ["realm-admin"] - } - } - }) + .withUser({ createUser, user_mock }) .withAccessTokenValidation({ type: "RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens", expectedAudience: (/*{ paramsOfBootstrap, process }*/) => "account", @@ -36,7 +22,7 @@ export const { realm_access: z.object({ roles: z.array(z.string()) }).optional() }), accessTokenClaims_mock: { - sub: "u123", + sub: "mock-user-id", realm_access: { roles: ["realm-admin"] } diff --git a/examples/tanstack-start/src/oidc.user.ts b/examples/tanstack-start/src/oidc.user.ts new file mode 100644 index 00000000..bbc52534 --- /dev/null +++ b/examples/tanstack-start/src/oidc.user.ts @@ -0,0 +1,57 @@ +import type { CreateUser } from "oidc-spa/core"; +import { z } from "zod"; +import avatarFallbackSvgUrl from "./components/userPictureFallback.svg"; + +// App-level user shape exposed by `useOidc()`. +// You decide what an user should looks like! +export type User = { + username: string; + displayName: string; + email: string | undefined; + avatarImgUrl: string; + isKeycloakRealmAdmin: boolean; +}; + +// The function that oidc-spa will call to create the user object, +// gathering information from different sources depending of what you need. +export const createUser: CreateUser = async ({ + decodedIdToken: decodedIdToken_generic, + accessToken +}) => { + const DecodedIdToken = z.object({ + sub: z.string(), + name: z.string(), + picture: z.string().optional(), + email: z.string().optional(), + preferred_username: z.string().optional() + }); + + const decodedIdToken = DecodedIdToken.parse(decodedIdToken_generic); + + const DecodedAccessToken = z.object({ + realm_access: z.object({ roles: z.array(z.string()) }).optional() + }); + + const { decodeJwt } = await import("oidc-spa/decode-jwt"); + + const decodedAccessToken = DecodedAccessToken.parse(decodeJwt(accessToken)); + + const user: User = { + username: decodedIdToken.preferred_username ?? decodedIdToken.sub, + displayName: decodedIdToken.name, + avatarImgUrl: decodedIdToken.picture || avatarFallbackSvgUrl, + email: decodedIdToken.email, + isKeycloakRealmAdmin: decodedAccessToken?.realm_access?.roles.includes("realm-admin") ?? false + }; + + return user; +}; + +// App-level user returned when the mock implementation is enabled. +export const user_mock: User = { + username: "john.doe", + displayName: "John Doe", + email: undefined, + avatarImgUrl: avatarFallbackSvgUrl, + isKeycloakRealmAdmin: true +}; diff --git a/examples/tanstack-start/src/routes/account.tsx b/examples/tanstack-start/src/routes/account.tsx index 8ff7dc55..cc86ac07 100644 --- a/examples/tanstack-start/src/routes/account.tsx +++ b/examples/tanstack-start/src/routes/account.tsx @@ -10,8 +10,9 @@ export const Route = createFileRoute("/account")({ function Account() { // Here we can safely assume that the user is logged in. - const { decodedIdToken, goToAuthServer, backFromAuthServer, issuerUri, clientId, validRedirectUri } = - useOidc({ assert: "user logged in" }); + const { user, goToAuthServer, backFromAuthServer, issuerUri, clientId, validRedirectUri } = useOidc({ + assert: "user logged in" + }); // Since oidc-spa is a generic adapter, all Keycloak specific features are provided via a standalone // util. And since this example should run as well with other provider we first test if we are integrating @@ -24,11 +25,8 @@ function Account() {

Your Account

- {decodedIdToken.name} - {decodedIdToken.email && {decodedIdToken.email}} - {decodedIdToken.preferred_username && ( - {decodedIdToken.preferred_username} - )} + {user.username} + {user.email && {user.email}}
{keycloakUtils && ( diff --git a/examples/tanstack-start/src/routes/demo/start.admin-only.tsx b/examples/tanstack-start/src/routes/demo/start.admin-only.tsx index ff0a8c5c..561889bf 100644 --- a/examples/tanstack-start/src/routes/demo/start.admin-only.tsx +++ b/examples/tanstack-start/src/routes/demo/start.admin-only.tsx @@ -27,12 +27,13 @@ export const Route = createFileRoute("/demo/start/admin-only")({ component: AdminOnly, loader: async () => { const oidc = await getOidc({ assert: "user logged in" }); + const { user } = await oidc.getUser(); // NOTE: This is just cosmetic, it doesn't actually protect anything. // It's very important that you implement hasRequired claim in the server // function and request middleware to check that the user actually have the required // authorization. - if (!oidc.getDecodedIdToken().realm_access?.roles.includes("realm-admin")) { + if (!user.isKeycloakRealmAdmin) { throw new Error("unauthorized"); } @@ -87,7 +88,7 @@ function AdminOnly() {

Administration Page

- Access granted. Your ID token includes the + Access granted. Your user has the realm-admin diff --git a/examples/tanstack-start/src/routes/index.tsx b/examples/tanstack-start/src/routes/index.tsx index 7c3d8939..d25431f4 100644 --- a/examples/tanstack-start/src/routes/index.tsx +++ b/examples/tanstack-start/src/routes/index.tsx @@ -267,7 +267,7 @@ function App() { } function Greeting() { - const { isOidcReady, isUserLoggedIn, decodedIdToken } = useOidc(); + const { isOidcReady, isUserLoggedIn, user } = useOidc(); if (!isOidcReady) { return <> ; @@ -275,7 +275,7 @@ function Greeting() { return ( - {isUserLoggedIn ? `Welcome back ${decodedIdToken.name}` : `Hello anonymous visitor!`} + {isUserLoggedIn ? `Welcome back ${user.displayName}` : `Hello anonymous visitor!`} ); } diff --git a/package.json b/package.json index 75be83b2..36a01ad0 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oidc-spa", - "version": "10.2.11", + "version": "10.3.0-rc.4", "description": "OpenID Connect / OAuth2 solution for client-first Web Applications", "repository": { "type": "git", diff --git a/src/angular.ts b/src/angular.ts index 659a1174..506bb320 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -186,7 +186,7 @@ assert< Omit, Omit< ParamsOfCreateOidc, - "homeUrl" | "BASE_URL" | "noIframe" | "decodedIdTokenSchema" + "homeUrl" | "BASE_URL" | "noIframe" | "decodedIdTokenSchema" | "createUser" > > >; 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..8c115288 --- /dev/null +++ b/src/core/createGetUser.ts @@ -0,0 +1,248 @@ +import type { Oidc } from "./Oidc"; +import { id } from "../tools/tsafe/id"; +import { assert } from "../tools/tsafe/assert"; +import type { NonPostableEvt } from "../tools/Evt"; +import { decodeJwt } from "../tools/decodeJwt"; +import type { ParamsOfCreateOidc } from "./createOidc"; + +export function createGetUser(params: { + issuerUri: string; + clientId: string; + validRedirectUri: string; + createUser: ParamsOfCreateOidc.CreateUser | undefined; + getCurrentTokens: () => Oidc.Tokens; + evtTokensChange: NonPostableEvt; + renewTokens(): Promise; + oidcMetadata: { + userinfo_endpoint?: string; + }; +}) { + const { + issuerUri, + clientId, + validRedirectUri, + 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_current = await prUser_current; + + let user_new: User; + + try { + user_new = await createUser({ + accessToken: tokens.accessToken, + decodedIdToken: tokens.decodedIdToken_original, + issuerUri, + clientId, + validRedirectUri, + fetchUserInfo: () => fetchUserInfo({ accessToken: tokens.accessToken }), + user_current + }); + } catch (error) { + if (user_current !== undefined) { + console.error( + `oidc-spa: Subsequent calls to createUser threw, skipping user refresh.`, + error + ); + if (state !== undefined && state.hash === hash_new) { + assert(hash_current !== undefined); + state.hash = hash_current; + } + return user_current; + } + + // NOTE: This will be handled as an initialization error by the + // higher level adapters. + throw error; + } + + 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(); + }); + + let callCount_getUser = 0; + + const getUser: GetUser = async () => { + if (createUser === undefined) { + throw new Error("oidc-spa: createUser not provided"); + } + + let timer_cycleDetection: ReturnType | undefined = undefined; + + if (state === undefined) { + __updatePrUserIfHashChanged(); + + assert(state !== undefined); + } + + cycle_detection: { + callCount_getUser++; + + if (callCount_getUser !== 1) { + break cycle_detection; + } + + const callCount_getUser_before = callCount_getUser; + + const setTimer = () => { + timer_cycleDetection = setTimeout(() => { + if (callCount_getUser_before === callCount_getUser) { + setTimer(); + return; + } + + console.warn( + [ + "oidc-spa: Potential deadlock detected.", + "createUser() might be awaiting getUser(), which causes a deadlock", + "because getUser() is already waiting for createUser() to finish.", + "Update createUser() to build the user without (directly or indirectly) calling", + "getUser() from inside createUser()." + ].join(" ") + ); + }, 3_000); + }; + + setTimer(); + } + + const user = await state.prUser; + + if (timer_cycleDetection !== undefined) { + clearTimeout(timer_cycleDetection); + } + + 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/createMockOidc.ts b/src/core/createMockOidc.ts index 69bae7b1..fcf4b2d8 100644 --- a/src/core/createMockOidc.ts +++ b/src/core/createMockOidc.ts @@ -9,13 +9,15 @@ import { getBASE_URL_earlyInit } from "../core/earlyInit_BASE_URL"; export type ParamsOfCreateMockOidc< DecodedIdToken extends Record = Record, - AutoLogin extends boolean = false + AutoLogin extends boolean = false, + User = never > = { mockedParams?: { issuerUri?: string; clientId?: string; }; mockedTokens?: Partial>; + mockedUser?: User; /** * The URL of the home page of your app. * We need to know this so we know where to redirect when you call `logout({ redirectTo: "home"})`. @@ -38,16 +40,18 @@ const locationHref_moduleEvalTime = location.href; export async function createMockOidc< DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_OidcCoreSpec, - AutoLogin extends boolean = false + AutoLogin extends boolean = false, + User = never >( - params: ParamsOfCreateMockOidc -): Promise : Oidc> { + params: ParamsOfCreateMockOidc +): Promise : Oidc> { const { isUserInitiallyLoggedIn = true, mockedParams = {}, mockedTokens = {}, autoLogin = false, - postLoginRedirectUrl + postLoginRedirectUrl, + mockedUser } = params; const BASE_URL_params = params.BASE_URL; @@ -143,7 +147,7 @@ export async function createMockOidc< return oidc; } - const oidc: Oidc.LoggedIn = { + const oidc: Oidc.LoggedIn = { ...common, isUserLoggedIn: true, renewTokens: async () => {}, @@ -226,7 +230,20 @@ export async function createMockOidc< }), goToAuthServer: async ({ redirectUrl }) => loginOrGoToAuthServer({ redirectUrl }), isNewBrowserSession: false, - backFromAuthServer: undefined + backFromAuthServer: undefined, + getUser: () => { + if (mockedUser === undefined) { + throw new Error("oidc-spa: No mock user provided"); + } + + return Promise.resolve({ + refreshUser: () => Promise.resolve(mockedUser), + subscribeToUserChange: () => { + return { unsubscribeFromUserChange: () => {} }; + }, + user: mockedUser + }); + } }; return oidc; diff --git a/src/core/createOidc.ts b/src/core/createOidc.ts index eed9d566..92a50961 100644 --- a/src/core/createOidc.ts +++ b/src/core/createOidc.ts @@ -64,14 +64,19 @@ 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?: ParamsOfCreateOidc.CreateUser; + /** * See: https://docs.oidc-spa.dev/v/v10/providers-configuration/provider-configuration */ @@ -266,8 +271,23 @@ export type ParamsOfCreateOidc< disableDPoP?: true; }; +export namespace ParamsOfCreateOidc { + export type CreateUser = (params: { + decodedIdToken: Oidc.Tokens.DecodedIdToken_OidcCoreSpec; + accessToken: string; + fetchUserInfo: () => Promise<{ + [key: string]: unknown; + sub: string; + }>; + issuerUri: string; + clientId: string; + validRedirectUri: string; + user_current: User | undefined; + }) => MaybeAsync; +} + const globalContext = { - prOidcByConfigId: new Map>>(), + prOidcByConfigId: new Map>>(), hasLogoutBeenCalled: id(false), dExports_earlyInit: new Deferred(), dExports_tokenSubstitution: new Deferred(), @@ -345,10 +365,11 @@ export function registerExports_DPoP(exports: Exports_DPoP): void { /** @see: https://docs.oidc-spa.dev/v/v10/usage */ export async function createOidc< DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_OidcCoreSpec, - AutoLogin extends boolean = false + AutoLogin extends boolean = false, + User = never >( - params: ParamsOfCreateOidc -): Promise : Oidc> { + params: ParamsOfCreateOidc +): Promise : Oidc> { for (const name of ["issuerUri", "clientId"] as const) { const value = params[name]; if (!value) { @@ -407,7 +428,7 @@ export async function createOidc< return prOidc; } - const dOidc = new Deferred>(); + const dOidc = new Deferred>(); prOidcByConfigId.set(configId, dOidc.pr); @@ -425,16 +446,20 @@ export async function createOidc< export async function createOidc_nonMemoized< DecodedIdToken extends Record, - AutoLogin extends boolean + AutoLogin extends boolean, + User >( - params: Omit, "issuerUri" | "clientId" | "debugLogs">, + params: Omit< + ParamsOfCreateOidc, + "issuerUri" | "clientId" | "debugLogs" + >, preProcessedParams: { issuerUri: string; clientId: string; configId: string; log: typeof console.log | undefined; } -): Promise : Oidc> { +): Promise : Oidc> { const { transformUrlBeforeRedirect, extraQueryParams: extraQueryParamsOrGetter, @@ -449,7 +474,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 () => { @@ -1459,6 +1485,316 @@ 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": + case "other error": + { + switch (result_loginSilent.outcome) { + 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(" ") + ); + } + break; + case "other error": + { + const { error } = result_loginSilent; + + log?.( + `Got an unexpected error trying to refresh token: ${error.message}` + ); + } + break; + default: + assert>(false); + break; + } + + 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, + clientId, + validRedirectUri: homeUrlAndRedirectUri, + oidcMetadata, + renewTokens: () => renewTokens() + }); + detect_useless_idleSessionLifetimeInSeconds: { if (idleSessionLifetimeInSeconds === undefined) { break detect_useless_idleSessionLifetimeInSeconds; @@ -1483,7 +1819,7 @@ export async function createOidc_nonMemoized< "oidc-spa: You've specified idleSessionLifetimeInSeconds,", "but your auth server issues a refresh_token with a known expiration time.", "idleSessionLifetimeInSeconds should only be used as a fallback", - "for auth servers that don't specify when an inactive session expires (Auth0, Clerk, WorkOS).", + "for auth servers that don't specify when an inactive session expires.", "The auth server, not your code, is the source of truth.", "See: https://docs.oidc-spa.dev/v/v10/auto-logout" ].join(" ") @@ -1512,8 +1848,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"); @@ -1523,7 +1857,7 @@ export async function createOidc_nonMemoized< let prOngoingTokenRenewal: Promise | undefined = undefined; - const oidc_loggedIn = id>({ + const oidc_loggedIn = id>({ ...oidc_common, isUserLoggedIn: true, getTokens: async () => { @@ -1656,294 +1990,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": - case "other error": - { - switch (result_loginSilent.outcome) { - 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(" ") - ); - } - break; - case "other error": - { - const { error } = result_loginSilent; - - log?.( - `Got an unexpected error trying to refresh token: ${error.message}` - ); - } - break; - default: - assert>(false); - break; - } - - 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); @@ -1979,7 +2026,8 @@ export async function createOidc_nonMemoized< log?.(`isNewBrowserSession: ${value}`); return value; - })() + })(), + getUser }); if (resultOfLoginProcess.isRestoredFromSessionStorage) { diff --git a/src/core/index.ts b/src/core/index.ts index 42a5f6d3..0177ecd1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,3 +2,4 @@ export type { Oidc } from "./Oidc"; export { createOidc, type ParamsOfCreateOidc } from "./createOidc"; export { OidcInitializationError } from "./OidcInitializationError"; export { oidcEarlyInit } from "./earlyInit"; +export type CreateUser = import("./createOidc").ParamsOfCreateOidc.CreateUser; diff --git a/src/keycloak/index.ts b/src/keycloak/index.ts index 1098b614..7a6a3674 100644 --- a/src/keycloak/index.ts +++ b/src/keycloak/index.ts @@ -3,6 +3,6 @@ export type { KeycloakIssuerUriParsed } from "./keycloakIssuerUriParsed"; export { type KeycloakUtils, KeycloakProfile, - KeycloakUserInfo, + UserInfo as KeycloakUserInfo, createKeycloakUtils } from "./keycloakUtils"; diff --git a/src/keycloak/keycloakUtils.ts b/src/keycloak/keycloakUtils.ts index 76054305..a2e2904e 100644 --- a/src/keycloak/keycloakUtils.ts +++ b/src/keycloak/keycloakUtils.ts @@ -7,24 +7,174 @@ export type KeycloakUtils = { adminConsoleUrl_master: string; getAccountUrl: (params: { clientId: string; validRedirectUri: string; locale?: string }) => string; fetchUserProfile: (params: { accessToken: string }) => Promise; - fetchUserInfo: (params: { accessToken: string }) => Promise; + fetchUserInfo: (params: { accessToken: string }) => Promise; transformUrlBeforeRedirectForRegister: (authorizationUrl: string) => string; }; -export type KeycloakProfile = { - id?: string; - username?: string; - email?: string; - firstName?: string; - lastName?: string; - enabled?: boolean; - emailVerified?: boolean; - totp?: boolean; - createdTimestamp?: number; - attributes?: Record; +export type KeycloakProfile = KeycloakProfile.AttributeValuesMap & { + attributes: Record; + userProfileMetadata: { + attributes: KeycloakProfile.Attribute[]; + groups: KeycloakProfile.Group[]; + }; }; -export type KeycloakUserInfo = { +export namespace KeycloakProfile { + export type AttributeValuesMap = { + id: string; + [attributeName: string]: unknown; + + // Attributes usually present + username?: string; + email?: string; + firstName?: string; + lastName?: string; + enabled?: boolean; + emailVerified?: boolean; + totp?: boolean; + }; + + export type Attribute = { + name: string; + displayName?: string; + required: boolean; + group?: { + annotations: Record; + html5DataAnnotations: Record; + displayHeader?: string; + name: string; + displayDescription?: string; + }; + html5DataAnnotations?: { + kcNumberFormat?: string; + kcNumberUnFormat?: string; + }; + readOnly: boolean; + validators: Validators; + annotations: { + inputType?: string; + inputTypeSize?: `${number}` | number; + inputOptionsFromValidation?: string; + inputOptionLabels?: Record; + inputOptionLabelsI18nPrefix?: string; + inputTypeCols?: `${number}` | number; + inputTypeRows?: `${number}` | number; + inputTypeMaxlength?: `${number}` | number; + inputHelperTextBefore?: string; + inputHelperTextAfter?: string; + inputTypePlaceholder?: string; + inputTypePattern?: string; + inputTypeMinlength?: `${number}` | number; + inputTypeMax?: string; + inputTypeMin?: string; + inputTypeStep?: string; + }; + multivalued?: boolean; + autocomplete?: + | "on" + | "off" + | "name" + | "honorific-prefix" + | "given-name" + | "additional-name" + | "family-name" + | "honorific-suffix" + | "nickname" + | "email" + | "username" + | "new-password" + | "current-password" + | "one-time-code" + | "organization-title" + | "organization" + | "street-address" + | "address-line1" + | "address-line2" + | "address-line3" + | "address-level4" + | "address-level3" + | "address-level2" + | "address-level1" + | "country" + | "country-name" + | "postal-code" + | "cc-name" + | "cc-given-name" + | "cc-additional-name" + | "cc-family-name" + | "cc-number" + | "cc-exp" + | "cc-exp-month" + | "cc-exp-year" + | "cc-csc" + | "cc-type" + | "transaction-currency" + | "transaction-amount" + | "language" + | "bday" + | "bday-day" + | "bday-month" + | "bday-year" + | "sex" + | "tel" + | "tel-country-code" + | "tel-national" + | "tel-area-code" + | "tel-local" + | "tel-extension" + | "impp" + | "url" + | "photo"; + }; + export type Group = { + name: string; + displayHeader: string; + displayDescription: string; + }; + export type Validators = { + length?: Validators.DoIgnoreEmpty & Validators.Range; + integer?: Validators.DoIgnoreEmpty & Validators.Range; + email?: Validators.DoIgnoreEmpty; + pattern?: Validators.DoIgnoreEmpty & Validators.ErrorMessage & { pattern: string }; + options?: Validators.Options; + multivalued?: Validators.DoIgnoreEmpty & Validators.Range; + // NOTE: Following are the validators for which we don't implement client side validation yet + // or for which the validation can't be performed on the client side. + double?: Validators.DoIgnoreEmpty & Validators.Range; + "up-immutable-attribute"?: {}; + "up-attribute-required-by-metadata-value"?: {}; + "up-username-has-value"?: {}; + "up-duplicate-username"?: {}; + "up-username-mutation"?: {}; + "up-email-exists-as-username"?: {}; + "up-blank-attribute-value"?: Validators.ErrorMessage & { "fail-on-null": boolean }; + "up-duplicate-email"?: {}; + "local-date"?: Validators.DoIgnoreEmpty; + "person-name-prohibited-characters"?: Validators.DoIgnoreEmpty & Validators.ErrorMessage; + uri?: Validators.DoIgnoreEmpty; + "username-prohibited-characters"?: Validators.DoIgnoreEmpty & Validators.ErrorMessage; + }; + + export declare namespace Validators { + export type DoIgnoreEmpty = { + "ignore.empty.value"?: boolean; + }; + + export type ErrorMessage = { + "error-message"?: string; + }; + + export type Range = { + min?: `${number}` | number; + max?: `${number}` | number; + }; + export type Options = { + options: string[]; + }; + } +} + +export type UserInfo = { sub: string; [key: string]: any; }; diff --git a/src/react-spa/createOidcSpaUtils.ts b/src/react-spa/createOidcSpaUtils.ts index 5b61c113..1afa50a5 100644 --- a/src/react-spa/createOidcSpaUtils.ts +++ b/src/react-spa/createOidcSpaUtils.ts @@ -6,14 +6,18 @@ import { type ReactNode, type ComponentType } from "react"; -import type { UseOidc, OidcSpaUtils, GetOidc, ParamsOfBootstrap } from "./types"; +import type { UseOidc, OidcSpaUtils, GetOidc, ParamsOfBootstrap, CreateUser } from "./types"; import type { ZodSchemaLike } from "../tools/ZodSchemaLike"; import type { Oidc as Oidc_core } from "../core"; import { OidcInitializationError } from "../core/OidcInitializationError"; import { Deferred } from "../tools/Deferred"; import { isBrowser } from "../tools/isBrowser"; import { assert, type Equals } from "../tools/tsafe/assert"; -import { createObjectThatThrowsIfAccessed } from "../tools/createObjectThatThrowsIfAccessed"; +import { + createObjectThatThrowsIfAccessed, + createObjectWithSomePropertiesThatThrowIfAccessed, + THROW_IF_ACCESSED +} from "../tools/createObjectThatThrowsIfAccessed"; import { createStatefulEvt } from "../tools/StatefulEvt"; import { id } from "../tools/tsafe/id"; import { toFullyQualifiedUrl } from "../tools/toFullyQualifiedUrl"; @@ -21,27 +25,42 @@ import { setDesiredPostLoginRedirectUrl } from "../core/desiredPostLoginRedirect 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; - - const dParamsOfBootstrap = new Deferred>(); + createUser: CreateUser | undefined; + user_mock: User | undefined; +}): OidcSpaUtils { + const { + autoLogin, + decodedIdTokenSchema, + decodedIdToken_mock, + createUser, + user_mock: user_mock_static + } = params; + + const dParamsOfBootstrap = new Deferred>(); const dOidcCoreOrInitializationError = new Deferred< - Oidc_core | OidcInitializationError + Oidc_core | OidcInitializationError + >(); + + const dResultOfGetUserOrInitializationErrorOrUndefined = new Deferred< + | Awaited["getUser"]>> + | OidcInitializationError + | undefined >(); - const evtAutoLogoutState = createStatefulEvt["autoLogoutState"]>( - () => ({ - shouldDisplayWarning: false - }) - ); + const evtAutoLogoutState = createStatefulEvt< + UseOidc.Oidc.LoggedIn["autoLogoutState"] + >(() => ({ + shouldDisplayWarning: false + })); dOidcCoreOrInitializationError.pr.then(oidcCoreOrInitializationError => { const { hasResolved, value: paramsOfBootstrap } = dParamsOfBootstrap.getState(); @@ -69,7 +88,7 @@ export function createOidcSpaUtils< } oidcCore.subscribeToAutoLogoutCountdown(({ secondsLeft }) => { - const newState: UseOidc.Oidc.LoggedIn["autoLogoutState"] = (() => { + const newState: UseOidc.Oidc.LoggedIn["autoLogoutState"] = (() => { if (secondsLeft === undefined) { return { shouldDisplayWarning: false @@ -98,7 +117,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) { @@ -118,7 +137,8 @@ export function createOidcSpaUtils< } if (oidcCore instanceof OidcInitializationError) { - throw oidcCore; + const initializationError = oidcCore; + throw initializationError; } check_assertion: { @@ -149,6 +169,73 @@ export function createOidcSpaUtils< } } + const resultOfGetUserOrUndefined = (() => { + const { hasResolved, value: resultOfGetUserOrInitializationErrorOrUndefined } = + dResultOfGetUserOrInitializationErrorOrUndefined.getState(); + + if (!hasResolved) { + throw dResultOfGetUserOrInitializationErrorOrUndefined.pr; + } + + if (resultOfGetUserOrInitializationErrorOrUndefined instanceof OidcInitializationError) { + const initializationError = resultOfGetUserOrInitializationErrorOrUndefined; + throw initializationError; + } + + const resultOfGetUserOrUndefined = resultOfGetUserOrInitializationErrorOrUndefined; + + return resultOfGetUserOrUndefined; + })(); + + const [, reRenderIfUserChanged] = useState(() => { + if (resultOfGetUserOrUndefined === undefined) { + return undefined; + } + const resultOfGetUser = resultOfGetUserOrUndefined; + return resultOfGetUser.user; + }); + + const [evtIsUserUsed] = useState(() => createStatefulEvt(() => false)); + + useEffect(() => { + if (resultOfGetUserOrUndefined === undefined) { + return; + } + const resultOfGetUser = resultOfGetUserOrUndefined; + + let isActive = true; + let unsubscribe: (() => void) | undefined = undefined; + + (async () => { + if (!evtIsUserUsed.current) { + const dUserUsed = new Deferred(); + + const { unsubscribe: unsubscribe_scope } = evtIsUserUsed.subscribe(() => { + unsubscribe_scope(); + dUserUsed.resolve(); + }); + unsubscribe = unsubscribe_scope; + + await dUserUsed.pr; + + if (!isActive) { + return; + } + } + + reRenderIfUserChanged(resultOfGetUser.user); + + unsubscribe = resultOfGetUser.subscribeToUserChange(({ user }) => { + reRenderIfUserChanged(user); + }).unsubscribeFromUserChange; + })(); + + return () => { + isActive = false; + unsubscribe?.(); + }; + }, []); + const [, reRenderIfDecodedIdTokenChanged] = useState(() => { if (!oidcCore.isUserLoggedIn) { return undefined; @@ -249,30 +336,72 @@ export function createOidcSpaUtils< }); } - return id>({ - isUserLoggedIn: true, - get decodedIdToken() { - evtIsDecodedIdTokenUsed.current = true; - return oidcCore.getDecodedIdToken(); + const oidc = createObjectWithSomePropertiesThatThrowIfAccessed< + UseOidc.Oidc.LoggedIn + >( + { + isUserLoggedIn: true, + decodedIdToken: null as any, + logout: oidcCore.logout, + renewTokens: oidcCore.renewTokens, + goToAuthServer: oidcCore.goToAuthServer, + backFromAuthServer: oidcCore.backFromAuthServer, + isNewBrowserSession: oidcCore.isNewBrowserSession, + autoLogoutState: null as any, + issuerUri: oidcCore.issuerUri, + clientId: oidcCore.clientId, + validRedirectUri: oidcCore.validRedirectUri, + user: resultOfGetUserOrUndefined === undefined ? THROW_IF_ACCESSED : (null as any), + refreshUser: (() => { + if (resultOfGetUserOrUndefined === undefined) { + return THROW_IF_ACCESSED; + } + + const resultOfGetUser = resultOfGetUserOrUndefined; + + return resultOfGetUser.refreshUser; + })() }, - logout: oidcCore.logout, - renewTokens: oidcCore.renewTokens, - goToAuthServer: oidcCore.goToAuthServer, - backFromAuthServer: oidcCore.backFromAuthServer, - isNewBrowserSession: oidcCore.isNewBrowserSession, - get autoLogoutState() { - evtIsAutoLogoutStateUsed.current = true; - return evtAutoLogoutState.current; + [ + "oidc-spa: You must use oidcSpa.withUser() to implement the user abstraction", + "See: https://docs.oidc-spa.dev/v/v10/features/user" + ].join(" ") + ); + + Object.defineProperties(oidc, { + decodedIdToken: { + enumerable: true, + get: () => { + evtIsDecodedIdTokenUsed.current = true; + return oidcCore.getDecodedIdToken(); + } }, - issuerUri: oidcCore.issuerUri, - clientId: oidcCore.clientId, - validRedirectUri: oidcCore.validRedirectUri + autoLogoutState: { + enumerable: true, + get: () => { + evtIsAutoLogoutStateUsed.current = true; + return evtAutoLogoutState.current; + } + } }); + + if (resultOfGetUserOrUndefined !== undefined) { + const resultOfGetUser = resultOfGetUserOrUndefined; + Object.defineProperty(oidc, "user", { + enumerable: true, + get: () => { + evtIsUserUsed.current = true; + return resultOfGetUser.user; + } + }); + } + + return oidc; } 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"); } @@ -307,7 +436,7 @@ export function createOidcSpaUtils< }; return oidcCore.isUserLoggedIn - ? id>({ + ? id>({ ...common, isUserLoggedIn: true, getAccessToken: async () => { @@ -350,7 +479,8 @@ export function createOidcSpaUtils< const { unsubscribe } = evtAutoLogoutState.subscribe(next); return { unsubscribeFromAutoLogoutState: unsubscribe }; - } + }, + getUser: oidcCore.getUser }) : id({ ...common, @@ -365,7 +495,7 @@ export function createOidcSpaUtils< let bootstrapOidc_prResolved: Promise | undefined = undefined; const bootstrapOidc = ( - paramsOfBootstrap: ParamsOfBootstrap + paramsOfBootstrap: ParamsOfBootstrap ): Promise => { if (bootstrapOidc_prResolved !== undefined) { return bootstrapOidc_prResolved; @@ -387,6 +517,8 @@ export function createOidcSpaUtils< { const { createMockOidc } = await import("../core/createMockOidc"); + const user_mock = paramsOfBootstrap.user_mock ?? user_mock_static; + const oidcCore = await createMockOidc({ BASE_URL: paramsOfBootstrap.BASE_URL, // NOTE: The `as false` is lying here, it's just to preserve some level of type-safety. @@ -409,10 +541,27 @@ export function createOidcSpaUtils< "specify decodedIdToken_mock when calling bootstrapOidc()" ].join(" ") }) - } + }, + mockedUser: user_mock }); dOidcCoreOrInitializationError.resolve(oidcCore); + + set_result_of_getUser: { + if (user_mock === undefined) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(undefined); + break set_result_of_getUser; + } + + if (!oidcCore.isUserLoggedIn) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(undefined); + break set_result_of_getUser; + } + + dResultOfGetUserOrInitializationErrorOrUndefined.resolve( + await oidcCore.getUser() + ); + } } break; case "real": @@ -420,7 +569,7 @@ export function createOidcSpaUtils< const { createOidc } = await prModuleCore; let oidcCoreOrInitializationError: - | Oidc_core + | Oidc_core | OidcInitializationError; try { @@ -443,17 +592,64 @@ export function createOidcSpaUtils< __unsafe_useIdTokenAsAccessToken: paramsOfBootstrap.__unsafe_useIdTokenAsAccessToken, autoLogoutParams: paramsOfBootstrap.autoLogoutParams, - disableDPoP: paramsOfBootstrap.disableDPoP + disableDPoP: paramsOfBootstrap.disableDPoP, + createUser }); } catch (error) { - if (!(error instanceof OidcInitializationError)) { - throw error; - } + assert( + error instanceof OidcInitializationError, + `129322 This is a bug in oidc-spa, please report: ${error}` + ); + dOidcCoreOrInitializationError.resolve(error); + return; } dOidcCoreOrInitializationError.resolve(oidcCoreOrInitializationError); + + set_result_of_getUser: { + if (createUser === undefined) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(undefined); + break set_result_of_getUser; + } + + if (!oidcCoreOrInitializationError.isUserLoggedIn) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(undefined); + break set_result_of_getUser; + } + + let resultOfGetUser: Awaited< + ReturnType["getUser"]> + >; + + try { + resultOfGetUser = await oidcCoreOrInitializationError.getUser(); + } catch (error) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve( + new OidcInitializationError({ + isAuthServerLikelyDown: false, + messageOrCause: new Error( + "The initial invocation of createUser threw an error", + // @ts-expect-error + { + cause: + error instanceof Error + ? error + : new Error(`${error}`) + } + ) + }) + ); + break set_result_of_getUser; + } + + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(resultOfGetUser); + + resultOfGetUser.subscribeToUserChange(({ user }) => { + resultOfGetUser.user = user; + }); + } } break; } @@ -560,6 +756,26 @@ export function createOidcSpaUtils< return createElement(ErrorComponent, { oidcInitializationError }); } + user: { + const { hasResolved, value: resultOfGetUserOrInitializationErrorOrUndefined } = + dResultOfGetUserOrInitializationErrorOrUndefined.getState(); + + if (!hasResolved) { + throw dResultOfGetUserOrInitializationErrorOrUndefined.pr; + } + if (resultOfGetUserOrInitializationErrorOrUndefined === undefined) { + break user; + } + + if (resultOfGetUserOrInitializationErrorOrUndefined instanceof OidcInitializationError) { + const oidcInitializationError = resultOfGetUserOrInitializationErrorOrUndefined; + + return createElement(ErrorComponent, { + oidcInitializationError + }); + } + } + return children; } @@ -571,12 +787,26 @@ export function createOidcSpaUtils< useEffect(() => { let isActive = true; - dOidcCoreOrInitializationError.pr.then(() => { + (async () => { + const oidcCoreOrInitializationError = await dOidcCoreOrInitializationError.pr; + if (!isActive) { return; } + + if (oidcCoreOrInitializationError instanceof OidcInitializationError) { + readyToRender(); + return; + } + + await dResultOfGetUserOrInitializationErrorOrUndefined.pr; + + if (!isActive) { + return; + } + readyToRender(); - }); + })(); return () => { isActive = false; @@ -596,18 +826,10 @@ export function createOidcSpaUtils< const Component = component; function ComponentWithLoginEnforced(props: Props) { - const { hasResolved, value: oidcCore } = dOidcCoreOrInitializationError.getState(); - - if (!hasResolved) { - throw dOidcCoreOrInitializationError.pr; - } + const { isUserLoggedIn, login } = useOidc(); - if (oidcCore instanceof OidcInitializationError) { - throw oidcCore; - } - - if (!oidcCore.isUserLoggedIn) { - throw oidcCore.login({ doesCurrentHrefRequiresAuth: true }); + if (!isUserLoggedIn) { + throw login({ doesCurrentHrefRequiresAuth: true }); } return createElement(Component, props); diff --git a/src/react-spa/types.ts b/src/react-spa/types.ts index 10f22ce6..ccaa9a08 100644 --- a/src/react-spa/types.ts +++ b/src/react-spa/types.ts @@ -1,17 +1,19 @@ import type { ReactNode, ComponentType } from "react"; -import type { Oidc as Oidc_core, OidcInitializationError } from "../core"; +import type { Oidc as Oidc_core, OidcInitializationError, ParamsOfCreateOidc } from "../core"; import type { OidcMetadata } from "../core/OidcMetadata"; +import { assert, type Equals } from "../tools/tsafe/assert"; +import type { MaybeAsync } from "../tools/MaybeAsync"; -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 +21,10 @@ export namespace UseOidc { goToAuthServer?: never; backFromAuthServer?: never; isNewBrowserSession?: never; + user?: never; + refreshUser?: never; }) - | (Oidc.LoggedIn & { + | (Oidc.LoggedIn & { login?: never; initializationError?: never; }); @@ -43,7 +47,7 @@ export namespace UseOidc { initializationError: OidcInitializationError | undefined; }; - export type LoggedIn = { + export type LoggedIn = { issuerUri: string; clientId: string; validRedirectUri: string; @@ -62,22 +66,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; @@ -89,8 +95,9 @@ export namespace GetOidc { backFromAuthServer?: never; isNewBrowserSession?: never; subscribeToAutoLogoutState?: never; + getUser?: never; }) - | (Oidc.LoggedIn & { + | (Oidc.LoggedIn & { initializationError?: never; login?: never; }); @@ -99,7 +106,6 @@ export namespace GetOidc { type Common = { issuerUri: string; clientId: string; - validRedirectUri: string; }; export type NotLoggedIn = Common & { @@ -108,7 +114,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) => { @@ -135,13 +141,22 @@ export namespace GetOidc { } ) => void ) => { unsubscribeFromAutoLogoutState: () => void }; + getUser: () => Promise<{ + user: User; + subscribeToUserChange: ( + onUserChange: (params: { user: User; user_previous: User | undefined }) => void + ) => { + unsubscribeFromUserChange: () => void; + }; + refreshUser: () => Promise; + }>; }; } } -export type ParamsOfBootstrap = +export type ParamsOfBootstrap = | ParamsOfBootstrap.Real - | ParamsOfBootstrap.Mock; + | ParamsOfBootstrap.Mock; export namespace ParamsOfBootstrap { export type Real = { @@ -309,11 +324,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 @@ -329,10 +345,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 ? { @@ -356,3 +376,18 @@ export type OidcSpaUtils = { component: ComponentType ) => (props: Props) => ReactNode; }); + +export type CreateUser = (params: { + decodedIdToken: Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec; + accessToken: string; + fetchUserInfo: () => Promise<{ + [key: string]: unknown; + sub: string; + }>; + issuerUri: string; + clientId: string; + validRedirectUri: string; + user_current: User | undefined; +}) => MaybeAsync; + +assert, ParamsOfCreateOidc.CreateUser<{ _brand: string }>>>; diff --git a/src/react-spa/utilsBuilder.ts b/src/react-spa/utilsBuilder.ts index 1794aad4..f2276172 100644 --- a/src/react-spa/utilsBuilder.ts +++ b/src/react-spa/utilsBuilder.ts @@ -1,4 +1,4 @@ -import type { OidcSpaUtils } from "./types"; +import type { OidcSpaUtils, CreateUser } from "./types"; import type { Oidc as Oidc_core } from "../core"; import type { ZodSchemaLike } from "../tools/ZodSchemaLike"; import { createOidcSpaUtils } from "./createOidcSpaUtils"; @@ -6,14 +6,20 @@ import { createOidcSpaUtils } from "./createOidcSpaUtils"; export type OidcSpaUtilsBuilder< AutoLogin extends boolean = false, DecodedIdToken extends Record = Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec, + User = never, ExcludedMethod extends | "withAutoLogin" | "withExpectedDecodedIdTokenShape" - | "withAccessTokenValidation" + | "withUser" | "createUtils" = never > = Omit< { - withAutoLogin: () => OidcSpaUtilsBuilder; + withAutoLogin: () => OidcSpaUtilsBuilder< + true, + DecodedIdToken, + User, + ExcludedMethod | "withAutoLogin" + >; withExpectedDecodedIdTokenShape: >(params: { decodedIdTokenSchema: ZodSchemaLike< Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec, @@ -23,42 +29,65 @@ export type OidcSpaUtilsBuilder< }) => OidcSpaUtilsBuilder< AutoLogin, DecodedIdToken, + User, ExcludedMethod | "withExpectedDecodedIdTokenShape" >; - - createUtils: () => OidcSpaUtils; + withUser: (params: { + createUser: CreateUser; + user_mock?: NoInfer; + }) => OidcSpaUtilsBuilder; + 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: CreateUser | 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, + 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 +95,7 @@ function createOidcSpaUtilsBuilder< export const oidcSpaUtilsBuilder = createOidcSpaUtilsBuilder({ autoLogin: false, decodedIdToken_mock: undefined, - decodedIdTokenSchema: undefined + decodedIdTokenSchema: undefined, + createUser: undefined, + user_mock: undefined }); diff --git a/src/tanstack-start/react/createOidcSpaUtils.ts b/src/tanstack-start/react/createOidcSpaUtils.ts index 11025718..dc68ce59 100644 --- a/src/tanstack-start/react/createOidcSpaUtils.ts +++ b/src/tanstack-start/react/createOidcSpaUtils.ts @@ -5,7 +5,8 @@ import type { UseOidc, GetOidc, ParamsOfBootstrap, - OidcServerContext + OidcServerContext, + CreateUser } from "./types"; import type { ZodSchemaLike } from "../../tools/ZodSchemaLike"; import type { Oidc as Oidc_core } from "../../core"; @@ -13,7 +14,11 @@ import { OidcInitializationError } from "../../core/OidcInitializationError"; import { Deferred } from "../../tools/Deferred"; import { isBrowser } from "../../tools/isBrowser"; import { assert, type Equals, is } from "../../tools/tsafe/assert"; -import { createObjectThatThrowsIfAccessed } from "../../tools/createObjectThatThrowsIfAccessed"; +import { + createObjectThatThrowsIfAccessed, + createObjectWithSomePropertiesThatThrowIfAccessed, + THROW_IF_ACCESSED +} from "../../tools/createObjectThatThrowsIfAccessed"; import { createStatefulEvt } from "../../tools/StatefulEvt"; import { id } from "../../tools/tsafe/id"; import { typeGuard } from "../../tools/tsafe/typeGuard"; @@ -31,6 +36,7 @@ import { enableStateDataCookie } from "../../core/StateDataCookie"; export function createOidcSpaUtils< AutoLogin extends boolean, DecodedIdToken extends Record, + User, AccessTokenClaims extends Record | undefined >(params: { autoLogin: AutoLogin; @@ -41,27 +47,37 @@ export function createOidcSpaUtils< createValidateAndGetAccessTokenClaims: | CreateValidateAndGetAccessTokenClaims | undefined; -}): OidcSpaUtils { + createUser: CreateUser | undefined; + user_mock: User | undefined; +}): OidcSpaUtils { const { autoLogin, decodedIdTokenSchema, decodedIdToken_mock, - createValidateAndGetAccessTokenClaims + createValidateAndGetAccessTokenClaims, + createUser, + user_mock: user_mock_static } = params; const dParamsOfBootstrap = new Deferred< - ParamsOfBootstrap + ParamsOfBootstrap >(); const dOidcCoreOrInitializationError = new Deferred< - Oidc_core | OidcInitializationError + Oidc_core | OidcInitializationError >(); - const evtAutoLogoutState = createStatefulEvt["autoLogoutState"]>( - () => ({ - shouldDisplayWarning: false - }) - ); + const dResultOfGetUserOrInitializationErrorOrUndefined = new Deferred< + | Awaited["getUser"]>> + | OidcInitializationError + | undefined + >(); + + const evtAutoLogoutState = createStatefulEvt< + UseOidc.Oidc.LoggedIn["autoLogoutState"] + >(() => ({ + shouldDisplayWarning: false + })); dOidcCoreOrInitializationError.pr.then(oidcCoreOrInitializationError => { const { hasResolved, value: paramsOfBootstrap } = dParamsOfBootstrap.getState(); @@ -89,7 +105,7 @@ export function createOidcSpaUtils< } oidcCore.subscribeToAutoLogoutCountdown(({ secondsLeft }) => { - const newState: UseOidc.Oidc.LoggedIn["autoLogoutState"] = (() => { + const newState: UseOidc.Oidc.LoggedIn["autoLogoutState"] = (() => { if (secondsLeft === undefined) { return { shouldDisplayWarning: false @@ -118,18 +134,49 @@ export function createOidcSpaUtils< function useOidc(params?: { assert?: "user logged in" | "user not logged in" | "ready"; - }): UseOidc.Oidc { + }): UseOidc.Oidc { const { assert: assert_params } = params ?? {}; - const { hasResolved, value: oidcCoreOrInitializationError } = - dOidcCoreOrInitializationError.getState(); + const { + hasResolved, + oidcCoreOrInitializationError, + resultOfGetUserOrInitializationErrorOrUndefined + } = (() => { + const { hasResolved: hasResolved_oidcCore, value: oidcCoreOrInitializationError } = + dOidcCoreOrInitializationError.getState(); + + const { + hasResolved: hasResolved_resultOfGetUser, + value: resultOfGetUserOrInitializationErrorOrUndefined + } = dResultOfGetUserOrInitializationErrorOrUndefined.getState(); + + if (!hasResolved_resultOfGetUser) { + return { + hasResolved: false as const, + oidcCoreOrInitializationError: undefined, + resultOfGetUserOrInitializationErrorOrUndefined: undefined + }; + } + + assert(hasResolved_oidcCore); + + return { + hasResolved: true as const, + oidcCoreOrInitializationError, + resultOfGetUserOrInitializationErrorOrUndefined + }; + })(); check_assertion: { if (assert_params === undefined) { break check_assertion; } - if (!hasResolved || oidcCoreOrInitializationError instanceof Error) { + if ( + !hasResolved || + oidcCoreOrInitializationError instanceof Error || + resultOfGetUserOrInitializationErrorOrUndefined instanceof Error + ) { throw new Error( [ "oidc-spa: There is a logic error in the application.", @@ -174,94 +221,166 @@ export function createOidcSpaUtils< } } - const [, reRender] = useReducer(n => n + 1, 0); + { + const [, reRender] = useReducer(n => n + 1, 0); - useEffect(() => { - if (hasResolved) { - return; - } + useEffect(() => { + if (hasResolved) { + return; + } - let isActive = true; + let isActive = true; - dOidcCoreOrInitializationError.pr.then(() => { - if (!isActive) { - return; + dResultOfGetUserOrInitializationErrorOrUndefined.pr.then(() => { + if (!isActive) { + return; + } + reRender(); + }); + + return () => { + isActive = false; + }; + }, []); + } + + const [evtIsUserUsed] = useState(() => createStatefulEvt(() => false)); + { + const [, reRenderIfUserChanged] = useState(() => { + if (!hasResolved) { + return undefined; } - reRender(); + + if (resultOfGetUserOrInitializationErrorOrUndefined === undefined) { + return undefined; + } + + if (resultOfGetUserOrInitializationErrorOrUndefined instanceof Error) { + return undefined; + } + + return resultOfGetUserOrInitializationErrorOrUndefined.user; }); - return () => { - isActive = false; - }; - }, []); + useEffect(() => { + if (!hasResolved) { + return; + } - const [, reRenderIfDecodedIdTokenChanged] = useState(() => { - if (!hasResolved) { - return undefined; - } + if (resultOfGetUserOrInitializationErrorOrUndefined instanceof Error) { + return; + } - if (oidcCoreOrInitializationError instanceof Error) { - return undefined; - } + if (resultOfGetUserOrInitializationErrorOrUndefined === undefined) { + return; + } - const oidcCore = oidcCoreOrInitializationError; + const resultOfGetUser = resultOfGetUserOrInitializationErrorOrUndefined; - if (!oidcCore.isUserLoggedIn) { - return undefined; - } - return oidcCore.getDecodedIdToken(); - }); + let isActive = true; + let unsubscribe: (() => void) | undefined = undefined; - const [evtIsDecodedIdTokenUsed] = useState(() => createStatefulEvt(() => false)); + (async () => { + if (!evtIsUserUsed.current) { + const dUserUsed = new Deferred(); - useEffect(() => { - if (!hasResolved) { - return; - } + const { unsubscribe: unsubscribe_scope } = evtIsUserUsed.subscribe(() => { + unsubscribe_scope(); + dUserUsed.resolve(); + }); + unsubscribe = unsubscribe_scope; - if (oidcCoreOrInitializationError instanceof Error) { - return; - } + await dUserUsed.pr; - const oidcCore = oidcCoreOrInitializationError; + if (!isActive) { + return; + } + } - if (!oidcCore.isUserLoggedIn) { - return; - } + reRenderIfUserChanged(resultOfGetUser.user); - let isActive = true; + unsubscribe = resultOfGetUser.subscribeToUserChange(({ user }) => { + reRenderIfUserChanged(user); + }).unsubscribeFromUserChange; + })(); - let unsubscribe: (() => void) | undefined = undefined; + return () => { + isActive = false; + unsubscribe?.(); + }; + }, [hasResolved]); + } - (async () => { - if (!evtIsDecodedIdTokenUsed.current) { - const dDecodedIdTokenUsed = new Deferred(); + const [evtIsDecodedIdTokenUsed] = useState(() => createStatefulEvt(() => false)); + { + const [, reRenderIfDecodedIdTokenChanged] = useState(() => { + if (!hasResolved) { + return undefined; + } - const { unsubscribe: unsubscribe_scope } = evtIsDecodedIdTokenUsed.subscribe(() => { - unsubscribe_scope(); - dDecodedIdTokenUsed.resolve(); - }); - unsubscribe = unsubscribe_scope; + if (oidcCoreOrInitializationError instanceof Error) { + return undefined; + } - await dDecodedIdTokenUsed.pr; + const oidcCore = oidcCoreOrInitializationError; - if (!isActive) { - return; - } + if (!oidcCore.isUserLoggedIn) { + return undefined; + } + return oidcCore.getDecodedIdToken(); + }); + + useEffect(() => { + if (!hasResolved) { + return; + } + + if (oidcCoreOrInitializationError instanceof Error) { + return; } - reRenderIfDecodedIdTokenChanged(oidcCore.getDecodedIdToken()); + const oidcCore = oidcCoreOrInitializationError; + + if (!oidcCore.isUserLoggedIn) { + return; + } + + let isActive = true; + + let unsubscribe: (() => void) | undefined = undefined; + + (async () => { + if (!evtIsDecodedIdTokenUsed.current) { + const dDecodedIdTokenUsed = new Deferred(); + + const { unsubscribe: unsubscribe_scope } = evtIsDecodedIdTokenUsed.subscribe( + () => { + unsubscribe_scope(); + dDecodedIdTokenUsed.resolve(); + } + ); + unsubscribe = unsubscribe_scope; + + await dDecodedIdTokenUsed.pr; + + if (!isActive) { + return; + } + } - unsubscribe = oidcCore.subscribeToTokensChange(() => { reRenderIfDecodedIdTokenChanged(oidcCore.getDecodedIdToken()); - }).unsubscribeFromTokensChange; - })(); - return () => { - isActive = false; - unsubscribe?.(); - }; - }, [hasResolved]); + unsubscribe = oidcCore.subscribeToTokensChange(() => { + reRenderIfDecodedIdTokenChanged(oidcCore.getDecodedIdToken()); + }).unsubscribeFromTokensChange; + })(); + + return () => { + isActive = false; + unsubscribe?.(); + }; + }, [hasResolved]); + } const [evtIsAutoLogoutStateUsed] = useState(() => createStatefulEvt(() => false)); @@ -311,7 +430,12 @@ export function createOidcSpaUtils< setHasHydratedToTrue(); }, []); - if (!hasResolved || oidcCoreOrInitializationError instanceof Error || hasHydrated === false) { + if ( + !hasResolved || + oidcCoreOrInitializationError instanceof Error || + resultOfGetUserOrInitializationErrorOrUndefined instanceof Error || + hasHydrated === false + ) { return id({ isOidcReady: false, autoLogoutState: { @@ -321,13 +445,22 @@ export function createOidcSpaUtils< if (!hasHydrated) { return undefined; } - if (!hasResolved) { - return undefined; - } - if (!(oidcCoreOrInitializationError instanceof Error)) { - return undefined; + + if (hasResolved) { + if (oidcCoreOrInitializationError instanceof OidcInitializationError) { + const error = oidcCoreOrInitializationError; + return error; + } + if ( + resultOfGetUserOrInitializationErrorOrUndefined instanceof + OidcInitializationError + ) { + const error = resultOfGetUserOrInitializationErrorOrUndefined; + return error; + } } - return oidcCoreOrInitializationError; + + return undefined; })() }); } @@ -351,31 +484,75 @@ export function createOidcSpaUtils< }); } - return id>({ - isOidcReady: true, - isUserLoggedIn: true, - get decodedIdToken() { - evtIsDecodedIdTokenUsed.current = true; - return oidcCore.getDecodedIdToken(); + const resultOfGetUserOrUndefined = resultOfGetUserOrInitializationErrorOrUndefined; + + const oidc = createObjectWithSomePropertiesThatThrowIfAccessed< + UseOidc.Oidc.LoggedIn + >( + { + isOidcReady: true, + isUserLoggedIn: true, + decodedIdToken: null as any, + logout: oidcCore.logout, + renewTokens: oidcCore.renewTokens, + goToAuthServer: oidcCore.goToAuthServer, + backFromAuthServer: oidcCore.backFromAuthServer, + isNewBrowserSession: oidcCore.isNewBrowserSession, + autoLogoutState: null as any, + issuerUri: oidcCore.issuerUri, + clientId: oidcCore.clientId, + validRedirectUri: oidcCore.validRedirectUri, + user: resultOfGetUserOrUndefined === undefined ? THROW_IF_ACCESSED : (null as any), + refreshUser: (() => { + if (resultOfGetUserOrUndefined === undefined) { + return THROW_IF_ACCESSED; + } + + const resultOfGetUser = resultOfGetUserOrUndefined; + + return resultOfGetUser.refreshUser; + })() }, - logout: oidcCore.logout, - renewTokens: oidcCore.renewTokens, - goToAuthServer: oidcCore.goToAuthServer, - backFromAuthServer: oidcCore.backFromAuthServer, - isNewBrowserSession: oidcCore.isNewBrowserSession, - get autoLogoutState() { - evtIsAutoLogoutStateUsed.current = true; - return evtAutoLogoutState.current; + [ + "oidc-spa: You must use oidcSpa.withUser() to implement the user abstraction", + "See: https://docs.oidc-spa.dev/v/v10/features/user" + ].join(" ") + ); + + Object.defineProperties(oidc, { + decodedIdToken: { + enumerable: true, + get: () => { + evtIsDecodedIdTokenUsed.current = true; + return oidcCore.getDecodedIdToken(); + } }, - issuerUri: oidcCore.issuerUri, - clientId: oidcCore.clientId, - validRedirectUri: oidcCore.validRedirectUri + autoLogoutState: { + enumerable: true, + get: () => { + evtIsAutoLogoutStateUsed.current = true; + return evtAutoLogoutState.current; + } + } }); + + if (resultOfGetUserOrUndefined !== undefined) { + const resultOfGetUser = resultOfGetUserOrUndefined; + Object.defineProperty(oidc, "user", { + enumerable: true, + get: () => { + evtIsUserUsed.current = true; + return resultOfGetUser.user; + } + }); + } + + return oidc; } async function getOidc(params?: { assert?: "user logged in" | "user not logged in" | "init completed"; - }): Promise> { + }): Promise> { if (!isBrowser) { throw new Error( [ @@ -410,7 +587,7 @@ export function createOidcSpaUtils< } return oidcCore.isUserLoggedIn - ? id>({ + ? id>({ issuerUri: oidcCore.issuerUri, clientId: oidcCore.clientId, validRedirectUri: oidcCore.validRedirectUri, @@ -455,7 +632,8 @@ export function createOidcSpaUtils< const { unsubscribe } = evtAutoLogoutState.subscribe(next); return { unsubscribeFromAutoLogoutState: unsubscribe }; - } + }, + getUser: oidcCore.getUser }) : id({ issuerUri: oidcCore.issuerUri, @@ -474,7 +652,7 @@ export function createOidcSpaUtils< const bootstrapOidc = ( getParamsOfBootstrapOrDirectValue: GetterOrDirectValue< { process: { env: Record } }, - ParamsOfBootstrap + ParamsOfBootstrap > ) => { if (hasBootstrapBeenCalled) { @@ -576,6 +754,7 @@ export function createOidcSpaUtils< paramsOfBootstrap: ParamsOfBootstrap< AutoLogin, DecodedIdToken, + User, AccessTokenClaims >; } @@ -639,6 +818,8 @@ export function createOidcSpaUtils< { const { createMockOidc } = await import("../../core/createMockOidc"); + const user_mock = paramsOfBootstrap.user_mock ?? user_mock_static; + const oidcCore = await createMockOidc({ // NOTE: The `as false` is lying here, it's just to preserve some level of type-safety. autoLogin: autoLogin as false, @@ -660,10 +841,27 @@ export function createOidcSpaUtils< "specify decodedIdToken_mock when calling bootstrapOidc()" ].join(" ") }) - } + }, + mockedUser: user_mock }); dOidcCoreOrInitializationError.resolve(oidcCore); + + set_result_of_getUser: { + if (user_mock === undefined) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(undefined); + break set_result_of_getUser; + } + + if (!oidcCore.isUserLoggedIn) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(undefined); + break set_result_of_getUser; + } + + dResultOfGetUserOrInitializationErrorOrUndefined.resolve( + await oidcCore.getUser() + ); + } } break; case "real": @@ -673,7 +871,7 @@ export function createOidcSpaUtils< const { createOidc } = await prModuleCore; let oidcCoreOrInitializationError: - | Oidc_core + | Oidc_core | OidcInitializationError; try { @@ -695,17 +893,62 @@ export function createOidcSpaUtils< __unsafe_useIdTokenAsAccessToken: paramsOfBootstrap.__unsafe_useIdTokenAsAccessToken, autoLogoutParams: paramsOfBootstrap.autoLogoutParams, - disableDPoP: paramsOfBootstrap.disableDPoP + disableDPoP: paramsOfBootstrap.disableDPoP, + createUser }); } catch (error) { if (!(error instanceof OidcInitializationError)) { throw error; } dOidcCoreOrInitializationError.resolve(error); + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(error); return; } dOidcCoreOrInitializationError.resolve(oidcCoreOrInitializationError); + + set_result_of_getUser: { + if (createUser === undefined) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(undefined); + break set_result_of_getUser; + } + + if (!oidcCoreOrInitializationError.isUserLoggedIn) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(undefined); + break set_result_of_getUser; + } + + let resultOfGetUser: Awaited< + ReturnType["getUser"]> + >; + + try { + resultOfGetUser = await oidcCoreOrInitializationError.getUser(); + } catch (error) { + dResultOfGetUserOrInitializationErrorOrUndefined.resolve( + new OidcInitializationError({ + isAuthServerLikelyDown: false, + messageOrCause: new Error( + "The initial invocation of createUser threw an error", + // @ts-expect-error + { + cause: + error instanceof Error + ? error + : new Error(`${error}`) + } + ) + }) + ); + break set_result_of_getUser; + } + + dResultOfGetUserOrInitializationErrorOrUndefined.resolve(resultOfGetUser); + + resultOfGetUser.subscribeToUserChange(({ user }) => { + resultOfGetUser.user = user; + }); + } } break; } diff --git a/src/tanstack-start/react/types.ts b/src/tanstack-start/react/types.ts index db2486ca..9b3cf69a 100644 --- a/src/tanstack-start/react/types.ts +++ b/src/tanstack-start/react/types.ts @@ -1,21 +1,22 @@ -import type { Oidc as Oidc_core, OidcInitializationError } from "../../core"; +import type { Oidc as Oidc_core, OidcInitializationError, ParamsOfCreateOidc } from "../../core"; import type { FunctionMiddlewareAfterServer, RequestMiddlewareAfterServer } from "@tanstack/react-start"; import type { GetterOrDirectValue } from "../../tools/GetterOrDirectValue"; import type { OidcMetadata } from "../../core/OidcMetadata"; import type { MaybeAsync } from "../../tools/MaybeAsync"; +import { assert, type Equals } from "../../tools/tsafe/assert"; -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 = (params?: { + export type WithAutoLogin = (params?: { assert: "ready"; - }) => Oidc.LoggedIn; + }) => Oidc.LoggedIn; - export type Oidc = + export type Oidc = | (Oidc.NotReady & { isUserLoggedIn?: never; issuerUri?: never; @@ -30,6 +31,8 @@ export namespace UseOidc { isNewBrowserSession?: never; login?: never; + user?: never; + refreshUser?: never; }) | (Oidc.NotLoggedIn & { decodedIdToken?: never; @@ -38,8 +41,10 @@ export namespace UseOidc { goToAuthServer?: never; backFromAuthServer?: never; isNewBrowserSession?: never; + user?: never; + refreshUser?: never; }) - | (Oidc.LoggedIn & { + | (Oidc.LoggedIn & { login?: never; oidcInitializationError?: never; }); @@ -70,7 +75,7 @@ export namespace UseOidc { oidcInitializationError: OidcInitializationError | undefined; }; - export type LoggedIn = { + export type LoggedIn = { isOidcReady: true; isUserLoggedIn: true; issuerUri: string; @@ -90,22 +95,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; @@ -117,8 +124,9 @@ export namespace GetOidc { backFromAuthServer?: never; isNewBrowserSession?: never; subscribeToAutoLogoutState?: never; + getUser?: never; }) - | (Oidc.LoggedIn & { + | (Oidc.LoggedIn & { initializationError?: never; login?: never; }); @@ -136,7 +144,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) => { @@ -163,6 +171,15 @@ export namespace GetOidc { } ) => void ) => { unsubscribeFromAutoLogoutState: () => void }; + getUser: () => Promise<{ + user: User; + subscribeToUserChange: ( + onUserChange: (params: { user: User; user_previous: User | undefined }) => void + ) => { + unsubscribeFromUserChange: () => void; + }; + refreshUser: () => Promise; + }>; }; } } @@ -258,9 +275,9 @@ export namespace OidcRequestMiddleware { export type TanstackRequestMiddleware = RequestMiddlewareAfterServer<{}, undefined, T>; } -export type ParamsOfBootstrap = +export type ParamsOfBootstrap = | ParamsOfBootstrap.Real - | ParamsOfBootstrap.Mock; + | ParamsOfBootstrap.Mock; export namespace ParamsOfBootstrap { export type Real = { @@ -422,11 +439,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; } & (AccessTokenClaims extends undefined ? {} : { @@ -441,15 +459,19 @@ export namespace ParamsOfBootstrap { }); } -export type OidcSpaUtils = { +export type OidcSpaUtils = { bootstrapOidc: ( params: GetterOrDirectValue< { process: { env: Record } }, - ParamsOfBootstrap + ParamsOfBootstrap > ) => void; - useOidc: AutoLogin extends true ? UseOidc.WithAutoLogin : UseOidc; - getOidc: AutoLogin extends true ? GetOidc.WithAutoLogin : GetOidc; + useOidc: AutoLogin extends true + ? UseOidc.WithAutoLogin + : UseOidc; + getOidc: AutoLogin extends true + ? GetOidc.WithAutoLogin + : GetOidc; } & (AccessTokenClaims extends undefined ? {} : { @@ -472,7 +494,7 @@ export type OidcSpaUtils = { }); export type CreateValidateAndGetAccessTokenClaims = (params: { - paramsOfBootstrap: ParamsOfBootstrap, AccessTokenClaims>; + paramsOfBootstrap: ParamsOfBootstrap, unknown, AccessTokenClaims>; }) => { validateAndGetAccessTokenClaims: ValidateAndGetAccessTokenClaims; }; @@ -500,3 +522,18 @@ export namespace ValidateAndGetAccessTokenClaims { }; } } + +export type CreateUser = (params: { + decodedIdToken: Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec; + accessToken: string; + fetchUserInfo: () => Promise<{ + [key: string]: unknown; + sub: string; + }>; + issuerUri: string; + clientId: string; + validRedirectUri: string; + user_current: User | undefined; +}) => MaybeAsync; + +assert, ParamsOfCreateOidc.CreateUser<{ _brand: string }>>>; diff --git a/src/tanstack-start/react/utilsBuilder.ts b/src/tanstack-start/react/utilsBuilder.ts index bbe5bb4e..7186abfc 100644 --- a/src/tanstack-start/react/utilsBuilder.ts +++ b/src/tanstack-start/react/utilsBuilder.ts @@ -1,4 +1,9 @@ -import type { OidcSpaUtils, CreateValidateAndGetAccessTokenClaims, ParamsOfBootstrap } from "./types"; +import type { + OidcSpaUtils, + CreateValidateAndGetAccessTokenClaims, + ParamsOfBootstrap, + CreateUser +} from "./types"; import type { DecodedAccessToken_RFC9068 as AccessTokenClaims_RFC9068 } from "../../server"; import type { Oidc as Oidc_core } from "../../core"; import { assert, type Equals } from "../../tools/tsafe/assert"; @@ -9,10 +14,12 @@ import { createOidcSpaUtils } from "./createOidcSpaUtils"; export type OidcSpaUtilsBuilder< AutoLogin extends boolean = false, DecodedIdToken extends Record = Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec, + User = never, AccessTokenClaims extends Record | undefined = undefined, ExcludedMethod extends | "withAutoLogin" | "withExpectedDecodedIdTokenShape" + | "withUser" | "withAccessTokenValidation" | "createUtils" = never > = Omit< @@ -20,6 +27,7 @@ export type OidcSpaUtilsBuilder< withAutoLogin: () => OidcSpaUtilsBuilder< true, DecodedIdToken, + User, AccessTokenClaims, ExcludedMethod | "withAutoLogin" >; @@ -32,9 +40,20 @@ export type OidcSpaUtilsBuilder< }) => OidcSpaUtilsBuilder< AutoLogin, DecodedIdToken, + User, AccessTokenClaims, ExcludedMethod | "withExpectedDecodedIdTokenShape" >; + withUser: (params: { + createUser: CreateUser; + user_mock?: NoInfer; + }) => OidcSpaUtilsBuilder< + AutoLogin, + DecodedIdToken, + User, + AccessTokenClaims, + ExcludedMethod | "withUser" + >; withAccessTokenValidation: { = AccessTokenClaims_RFC9068>(params: { type: "RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens"; @@ -48,6 +67,7 @@ export type OidcSpaUtilsBuilder< }): OidcSpaUtilsBuilder< AutoLogin, DecodedIdToken, + User, AccessTokenClaims, ExcludedMethod | "withAccessTokenValidation" >; @@ -57,11 +77,12 @@ export type OidcSpaUtilsBuilder< }): OidcSpaUtilsBuilder< AutoLogin, DecodedIdToken, + User, AccessTokenClaims, ExcludedMethod | "withAccessTokenValidation" >; }; - createUtils: () => OidcSpaUtils; + createUtils: () => OidcSpaUtils; }, ExcludedMethod >; @@ -69,6 +90,7 @@ export type OidcSpaUtilsBuilder< function createOidcSpaUtilsBuilder< AutoLogin extends boolean = false, DecodedIdToken extends Record = Oidc_core.Tokens.DecodedIdToken_OidcCoreSpec, + User = never, AccessTokenClaims extends Record | undefined = undefined >(params: { autoLogin: AutoLogin; @@ -79,27 +101,44 @@ function createOidcSpaUtilsBuilder< createValidateAndGetAccessTokenClaims: | CreateValidateAndGetAccessTokenClaims | undefined; -}): OidcSpaUtilsBuilder { + createUser: CreateUser | undefined; + user_mock: User | undefined; +}): OidcSpaUtilsBuilder { return { withAutoLogin: () => createOidcSpaUtilsBuilder({ autoLogin: true, decodedIdTokenSchema: params.decodedIdTokenSchema, decodedIdToken_mock: params.decodedIdToken_mock, - createValidateAndGetAccessTokenClaims: params.createValidateAndGetAccessTokenClaims + createValidateAndGetAccessTokenClaims: params.createValidateAndGetAccessTokenClaims, + createUser: params.createUser, + user_mock: params.user_mock }), withExpectedDecodedIdTokenShape: ({ decodedIdTokenSchema, decodedIdToken_mock }) => createOidcSpaUtilsBuilder({ autoLogin: params.autoLogin, decodedIdTokenSchema, decodedIdToken_mock: decodedIdToken_mock, - createValidateAndGetAccessTokenClaims: params.createValidateAndGetAccessTokenClaims + createValidateAndGetAccessTokenClaims: params.createValidateAndGetAccessTokenClaims, + createUser: params.createUser, + user_mock: params.user_mock + }), + withUser: ({ createUser, user_mock }) => + createOidcSpaUtilsBuilder({ + autoLogin: params.autoLogin, + decodedIdTokenSchema: params.decodedIdTokenSchema, + decodedIdToken_mock: params.decodedIdToken_mock, + createValidateAndGetAccessTokenClaims: params.createValidateAndGetAccessTokenClaims, + createUser, + user_mock }), withAccessTokenValidation: params_scope => createOidcSpaUtilsBuilder({ autoLogin: params.autoLogin, decodedIdTokenSchema: params.decodedIdTokenSchema, decodedIdToken_mock: params.decodedIdToken_mock, + createUser: params.createUser, + user_mock: params.user_mock, createValidateAndGetAccessTokenClaims: ((): any => { switch (params_scope.type) { case "RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens": { @@ -128,11 +167,13 @@ function createOidcSpaUtilsBuilder< })() }), createUtils: () => - createOidcSpaUtils({ + createOidcSpaUtils({ autoLogin: params.autoLogin, decodedIdTokenSchema: params.decodedIdTokenSchema, decodedIdToken_mock: params.decodedIdToken_mock, - createValidateAndGetAccessTokenClaims: params.createValidateAndGetAccessTokenClaims + createValidateAndGetAccessTokenClaims: params.createValidateAndGetAccessTokenClaims, + createUser: params.createUser, + user_mock: params.user_mock }) }; } @@ -140,6 +181,8 @@ function createOidcSpaUtilsBuilder< export const oidcSpaUtilsBuilder = createOidcSpaUtilsBuilder({ autoLogin: false, createValidateAndGetAccessTokenClaims: undefined, + createUser: undefined, + user_mock: undefined, decodedIdToken_mock: undefined, decodedIdTokenSchema: undefined }); diff --git a/src/tools/createObjectThatThrowsIfAccessed.ts b/src/tools/createObjectThatThrowsIfAccessed.ts index cec342a2..31e0b397 100644 --- a/src/tools/createObjectThatThrowsIfAccessed.ts +++ b/src/tools/createObjectThatThrowsIfAccessed.ts @@ -16,18 +16,14 @@ export function createObjectThatThrowsIfAccessed(params?: { const get: NonNullable["get"]> = (...args) => { const [, prop] = args; - if (isPropertyWhitelisted(prop)) { + if (isPropertyWhitelisted(prop) || prop === keyIsTrapped) { return Reflect.get(...args); } - if (prop === keyIsTrapped) { - return true; - } - throw new AccessError(`Cannot access ${String(prop)} yet ${debugMessage}`); }; - const trappedObject = new Proxy({} as any, { + const trappedObject = new Proxy({ [keyIsTrapped]: true } as any, { get, set: get }); @@ -35,6 +31,53 @@ export function createObjectThatThrowsIfAccessed(params?: { return trappedObject; } +export function createObjectThatThrowsIfAccessedFactory(params: { + isPropertyWhitelisted?: (prop: string | number | symbol) => boolean; +}) { + const { isPropertyWhitelisted } = params; + + return { + createObjectThatThrowsIfAccessed: (params?: { debugMessage?: string }) => { + const { debugMessage } = params ?? {}; + + return createObjectThatThrowsIfAccessed({ + debugMessage, + isPropertyWhitelisted + }); + } + }; +} + export function isObjectThatThrowIfAccessed(obj: object) { return (obj as any)[keyIsTrapped] === true; } + +export const THROW_IF_ACCESSED = { + __brand: "THROW_IF_ACCESSED" +}; + +export function createObjectWithSomePropertiesThatThrowIfAccessed>( + obj: { [K in keyof T]: T[K] | typeof THROW_IF_ACCESSED }, + debugMessage?: string +): T { + return Object.defineProperties( + obj, + Object.fromEntries( + Object.entries(obj) + .filter(([, value]) => value === THROW_IF_ACCESSED) + .map(([key]) => { + const getAndSet = () => { + throw new AccessError(`Cannot access ${key} yet ${debugMessage ?? ""}`); + }; + + const pd = { + get: getAndSet, + set: getAndSet, + enumerable: true + }; + + return [key, pd]; + }) + ) + ) as any; +}