From 54b17fe600c81b704cdde3a37052bc954c014c79 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Sun, 2 Feb 2025 11:30:00 +0530 Subject: [PATCH] Federated connections implementation from ciamshrek simplify session stores, add documentation FCAT implementation update remove readonly qualifiers remove extra docs from abstract session store revert stateful session store changes remove use of audience for caching FCATs, add wrapper method in auth-clint.ts to avoid passing this.clientAuth callback to federatedConnectionsTokenExchange and instead pass a concrete value; linting fixes; remove redundant code as pointed out in review revert uses of nullish coalescing operator move existing tokenset check logic to within exchange code fix: compilation issues fix: ensure we await setSessionStore fix: decrypt cookie using secret fix: return undefined from findFederatedToken when no token in cache fix: ensure token expiresAt is checked correctly linting changes changed field and method names to be more descriptive improved jsdocs --- src/errors/index.ts | 43 +++ src/server/auth-client.test.ts | 118 +++++++- src/server/auth-client.ts | 89 +++--- src/server/authServerMetadata.ts | 97 +++++++ src/server/client.ts | 274 +++++++++++------- src/server/cookies.ts | 64 ++++ src/server/federatedConnections/exchange.ts | 183 ++++++++++++ src/server/federatedConnections/serializer.ts | 205 +++++++++++++ src/server/session/abstract-session-store.ts | 2 +- src/server/session/stateful-session-store.ts | 2 +- src/server/session/stateless-session-store.ts | 63 ++-- src/types/index.ts | 5 +- 12 files changed, 959 insertions(+), 186 deletions(-) create mode 100644 src/server/authServerMetadata.ts create mode 100644 src/server/federatedConnections/exchange.ts create mode 100644 src/server/federatedConnections/serializer.ts diff --git a/src/errors/index.ts b/src/errors/index.ts index b19e84afd..aa554119d 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -98,3 +98,46 @@ export class AccessTokenError extends SdkError { this.code = code } } + +/** + * Enum representing error codes related to federated connection access tokens. + */ +export enum FederatedConnectionAccessTokenErrorCode { + /** + * The session is missing. + */ + MISSING_SESSION = "missing_session", + + /** + * The refresh token is missing. + */ + MISSING_REFRESH_TOKEN = "missing_refresh_token", + + /** + * Failed to exchange the refresh token. + */ + FAILED_TO_EXCHANGE = "failed_to_exchange_refresh_token", +} + +/** + * Error class representing an access token error for federated connections. + * Extends the `SdkError` class. + */ +export class FederatedConnectionsAccessTokenError extends SdkError { + /** + * The error code associated with the access token error. + */ + public code: string; + + /** + * Constructs a new `FederatedConnectionsAccessTokenError` instance. + * + * @param code - The error code. + * @param message - The error message. + */ + constructor(code: string, message: string) { + super(message); + this.name = "FederatedConnectionAccessTokenError"; + this.code = code; + } +} diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 8bce44230..a70db8703 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -4012,7 +4012,123 @@ ca/T0LLtgmbMmxSv/MmzIg== }) }) }) - }) + }); + + describe("federatedConnectionAccessToken", async () => { + + it("Should exchange a refresh token for an access token", async () => { + const secret = await generateSecret(32) + const transactionStore = new TransactionStore({ + secret, + }) + const sessionStore = new StatelessSessionStore({ + secret, + }) + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer({ + tokenEndpointResponse: { + token_type: "Bearer", + access_token: DEFAULT.accessToken, + expires_in: 86400, // expires in 10 days + } as oauth.TokenEndpointResponse, + }), + }) + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60 // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt, + }; + + const response = await authClient.federatedConnectionTokenExchange({ tokenSet, connection: "google-oauth2" }); + const [error, federatedConnectionTokenSet] = response; + expect(error).toBe(null); + expect(federatedConnectionTokenSet).toEqual({ + accessToken: DEFAULT.accessToken, + expiresAt: expect.any(Number), + }) + }) + + it("should return an error if the discovery endpoint could not be fetched", async () => { + const secret = await generateSecret(32) + const transactionStore = new TransactionStore({ + secret, + }) + const sessionStore = new StatelessSessionStore({ + secret, + }) + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer({ + discoveryResponse: new Response(null, { status: 500 }), + }), + }) + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60 // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt, + } + + const [error, federatedConnectionTokenSet] = await authClient.federatedConnectionTokenExchange({ tokenSet, connection: "google-oauth2" }) + expect(error?.code).toEqual("discovery_error") + expect(federatedConnectionTokenSet).toBeNull() + }) + + it("should return an error if the token set does not contain a refresh token", async () => { + const secret = await generateSecret(32) + const transactionStore = new TransactionStore({ + secret, + }) + const sessionStore = new StatelessSessionStore({ + secret, + }) + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer(), + }) + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60 // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + expiresAt, + } + + const [error, federatedConnectionTokenSet] = await authClient.federatedConnectionTokenExchange({ tokenSet, connection: "google-oauth2" }) + expect(error?.code).toEqual("missing_refresh_token") + expect(federatedConnectionTokenSet).toBeNull() + }) + }); }) const _authorizationServerMetadata = { diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index ab52d4d95..7f7ffc045 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -8,13 +8,20 @@ import { AuthorizationCodeGrantError, AuthorizationError, BackchannelLogoutError, - DiscoveryError, InvalidStateError, MissingStateError, OAuth2Error, SdkError, } from "../errors" import { LogoutToken, SessionData, TokenSet } from "../types" +import { + AuthServerMetadata, + MetadataDiscoverOptions, +} from "./authServerMetadata" +import FederatedConnections, { + FederatedConnectionTokenExchangeOptions, + FederatedConnectionTokenExchangeOutput, +} from "./federatedConnections/exchange" import { AbstractSessionStore } from "./session/abstract-session-store" import { TransactionState, TransactionStore } from "./transaction-store" import { filterClaims } from "./user" @@ -109,7 +116,6 @@ export interface AuthClientOptions { export class AuthClient { private transactionStore: TransactionStore private sessionStore: AbstractSessionStore - private clientMetadata: oauth.Client private clientSecret?: string private clientAssertionSigningKey?: string | CryptoKey @@ -117,24 +123,24 @@ export class AuthClient { private domain: string private authorizationParameters: AuthorizationParameters private pushedAuthorizationRequests: boolean - private appBaseUrl: string private signInReturnToPath: string - private beforeSessionSaved?: BeforeSessionSavedHook private onCallback: OnCallbackHook - private routes: Routes - private fetch: typeof fetch private jwksCache: jose.JWKSCacheInput private allowInsecureRequests: boolean private httpOptions: () => oauth.HttpRequestOptions<"GET" | "POST"> - - private authorizationServerMetadata?: oauth.AuthorizationServer + private authServerMetadata: AuthServerMetadata + private metadataDiscoverOptions: MetadataDiscoverOptions + public federatedConnections: FederatedConnections constructor(options: AuthClientOptions) { // dependencies + + this.authServerMetadata = new AuthServerMetadata() + this.fetch = options.fetch || fetch this.jwksCache = options.jwksCache || {} this.allowInsecureRequests = options.allowInsecureRequests ?? false @@ -219,6 +225,18 @@ export class AuthClient { process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token", ...options.routes, } + + this.metadataDiscoverOptions = { + allowInsecureRequests: this.allowInsecureRequests, + fetch: this.fetch, + httpOptions: this.httpOptions, + issuer: this.issuer, + } + + this.federatedConnections = new FederatedConnections({ + metadataDiscoverOptions: this.metadataDiscoverOptions, + clientMetadata: this.clientMetadata, + }) } async handler(req: NextRequest): Promise { @@ -326,7 +344,7 @@ export class AuthClient { async handleLogout(req: NextRequest): Promise { const session = await this.sessionStore.get(req.cookies) const [discoveryError, authorizationServerMetadata] = - await this.discoverAuthorizationServerMetadata() + await this.authServerMetadata.discover(this.metadataDiscoverOptions) if (discoveryError) { return new NextResponse( @@ -383,7 +401,7 @@ export class AuthClient { } const [discoveryError, authorizationServerMetadata] = - await this.discoverAuthorizationServerMetadata() + await this.authServerMetadata.discover(this.metadataDiscoverOptions) if (discoveryError) { return this.onCallback(discoveryError, onCallbackCtx, null) @@ -463,6 +481,7 @@ export class AuthClient { sid: idTokenClaims.sid as string, createdAt: Math.floor(Date.now() / 1000), }, + federatedConnectionsMap: {}, } const res = await this.onCallback(null, onCallbackCtx, session) @@ -605,7 +624,7 @@ export class AuthClient { // the access token has expired and we have a refresh token if (tokenSet.refreshToken && tokenSet.expiresAt <= Date.now() / 1000) { const [discoveryError, authorizationServerMetadata] = - await this.discoverAuthorizationServerMetadata() + await this.authServerMetadata.discover(this.metadataDiscoverOptions) if (discoveryError) { console.error(discoveryError) @@ -665,41 +684,6 @@ export class AuthClient { return [null, tokenSet] } - private async discoverAuthorizationServerMetadata(): Promise< - [null, oauth.AuthorizationServer] | [SdkError, null] - > { - if (this.authorizationServerMetadata) { - return [null, this.authorizationServerMetadata] - } - - const issuer = new URL(this.issuer) - - try { - const authorizationServerMetadata = await oauth - .discoveryRequest(issuer, { - ...this.httpOptions(), - [oauth.customFetch]: this.fetch, - [oauth.allowInsecureRequests]: this.allowInsecureRequests, - }) - .then((response) => oauth.processDiscoveryResponse(issuer, response)) - - this.authorizationServerMetadata = authorizationServerMetadata - - return [null, authorizationServerMetadata] - } catch (e) { - console.error( - `An error occured while performing the discovery request. Please make sure the AUTH0_DOMAIN environment variable is correctly configured — the format must be 'example.us.auth0.com'. issuer=${issuer.toString()}, error:`, - e - ) - return [ - new DiscoveryError( - "Discovery failed for the OpenID Connect configuration." - ), - null, - ] - } - } - private async defaultOnCallback( error: SdkError | null, ctx: OnCallbackContext, @@ -722,7 +706,7 @@ export class AuthClient { logoutToken: string ): Promise<[null, LogoutToken] | [SdkError, null]> { const [discoveryError, authorizationServerMetadata] = - await this.discoverAuthorizationServerMetadata() + await this.authServerMetadata.discover(this.metadataDiscoverOptions) if (discoveryError) { return [discoveryError, null] @@ -814,7 +798,7 @@ export class AuthClient { params: URLSearchParams ): Promise<[null, URL] | [Error, null]> { const [discoveryError, authorizationServerMetadata] = - await this.discoverAuthorizationServerMetadata() + await this.authServerMetadata.discover(this.metadataDiscoverOptions) if (discoveryError) { return [discoveryError, null] } @@ -917,6 +901,15 @@ export class AuthClient { ? this.domain : `https://${this.domain}` } + + public federatedConnectionTokenExchange = async ( + options: FederatedConnectionTokenExchangeOptions + ): Promise => { + return this.federatedConnections.federatedConnectionTokenExchange( + options, + await this.getClientAuth() + ) + } } const encodeBase64 = (input: string) => { diff --git a/src/server/authServerMetadata.ts b/src/server/authServerMetadata.ts new file mode 100644 index 000000000..78d4d6dd6 --- /dev/null +++ b/src/server/authServerMetadata.ts @@ -0,0 +1,97 @@ +import * as oauth from "oauth4webapi" + +import { DiscoveryError, SdkError } from "../errors" + +/** + * Options for discovering metadata for authentication. + */ +export type MetadataDiscoverOptions = { + /** + * The issuer URL or string. + */ + issuer: string | URL + + /** + * A function that returns HTTP request options for the OAuth requests. + */ + httpOptions: () => oauth.HttpRequestOptions<"GET" | "POST"> + + /** + * A fetch function to make HTTP requests. + * + * @param input - The URL or request object. + * @param init - Optional request initialization options. + * @returns A promise that resolves to a Response object. + */ + fetch: ( + input: string | URL | globalThis.Request, + init?: any + ) => Promise + + /** + * A flag to allow insecure requests. + */ + allowInsecureRequests: boolean +} + +export type MetadataDiscoverResult = + | [null, oauth.AuthorizationServer] + | [SdkError, null] + +export class AuthServerMetadata { + private authorizationServerMetadata?: oauth.AuthorizationServer + + /** + * Discovers the authorization server metadata. + * + * @param {MetadataDiscoverOptions} options - The options for metadata discovery. + * @returns {Promise} A promise that resolves to a tuple containing either an error or the authorization server metadata. + * + * @throws {DiscoveryError} If the discovery request fails. + * + * @example + * ```typescript + * const [error, metadata] = await discover(options); + * if (error) { + * console.error('Discovery failed:', error); + * } else { + * console.log('Authorization Server Metadata:', metadata); + * } + * ``` + */ + async discover({ + issuer, + httpOptions, + allowInsecureRequests, + fetch, + }: MetadataDiscoverOptions): Promise { + try { + if (this.authorizationServerMetadata) { + return [null, this.authorizationServerMetadata] + } + const issuerUrl = typeof issuer === "string" ? new URL(issuer) : issuer + + const authorizationServerMetadata = await oauth + .discoveryRequest(issuerUrl, { + ...httpOptions(), + [oauth.customFetch]: fetch, + [oauth.allowInsecureRequests]: allowInsecureRequests, + }) + .then((response) => oauth.processDiscoveryResponse(issuerUrl, response)) + + this.authorizationServerMetadata = authorizationServerMetadata + return [null, authorizationServerMetadata] + } catch (e) { + console.error( + `An error occurred while performing the discovery request. Please make sure the AUTH0_DOMAIN environment variable is correctly configured — the format must be 'example.us.auth0.com'. issuer=${issuer.toString()}, error:`, + e + ) + return [ + new DiscoveryError( + "Discovery failed for the OpenID Connect configuration." + ), + null, + ] + } + } +} diff --git a/src/server/client.ts b/src/server/client.ts index bf2c415cd..3b003786e 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -3,7 +3,12 @@ import { cookies } from "next/headers" import { NextRequest, NextResponse } from "next/server" import { NextApiRequest, NextApiResponse } from "next/types" -import { AccessTokenError, AccessTokenErrorCode } from "../errors" +import { + AccessTokenError, + AccessTokenErrorCode, + FederatedConnectionAccessTokenErrorCode, + FederatedConnectionsAccessTokenError, +} from "../errors" import { SessionData, SessionDataStore } from "../types" import { AuthClient, @@ -12,7 +17,16 @@ import { OnCallbackHook, RoutesOptions, } from "./auth-client" -import { RequestCookies, ResponseCookies } from "./cookies" +import { + ReadonlyRequestCookies, + RequestCookies, + ResponseCookies, +} from "./cookies" +import { + addOrUpdateFederatedTokenToSession, + FederatedConnectionTokenSet, + findFederatedToken, +} from "./federatedConnections/serializer" import { AbstractSessionStore, SessionConfiguration, @@ -148,9 +162,9 @@ type PagesRouterRequest = IncomingMessage | NextApiRequest type PagesRouterResponse = ServerResponse | NextApiResponse export class Auth0Client { - private transactionStore: TransactionStore - private sessionStore: AbstractSessionStore - private authClient: AuthClient + private readonly transactionStore: TransactionStore + private readonly sessionStore: AbstractSessionStore + private readonly authClient: AuthClient constructor(options: Auth0ClientOptions = {}) { const domain = (options.domain || process.env.AUTH0_DOMAIN) as string @@ -308,20 +322,7 @@ export class Auth0Client { req?: PagesRouterRequest | NextRequest, res?: PagesRouterResponse | NextResponse ): Promise<{ token: string; expiresAt: number; scope?: string }> { - let session: SessionData | null = null - - if (req) { - if (req instanceof NextRequest) { - // middleware usage - session = await this.sessionStore.get(req.cookies) - } else { - // pages router usage - session = await this.sessionStore.get(this.createRequestCookies(req)) - } - } else { - // app router usage: Server Components, Server Actions, Route Handlers - session = await this.sessionStore.get(await cookies()) - } + const session = req ? await this.getSession(req) : await this.getSession() if (!session) { throw new AccessTokenError( @@ -343,47 +344,14 @@ export class Auth0Client { tokenSet.expiresAt !== session.tokenSet.expiresAt || tokenSet.refreshToken !== session.tokenSet.refreshToken ) { - if (req && res) { - if (req instanceof NextRequest && res instanceof NextResponse) { - // middleware usage - await this.sessionStore.set(req.cookies, res.cookies, { - ...session, - tokenSet, - }) - } else { - // pages router usage - const resHeaders = new Headers() - const resCookies = new ResponseCookies(resHeaders) - const pagesRouterRes = res as PagesRouterResponse - - await this.sessionStore.set( - this.createRequestCookies(req as PagesRouterRequest), - resCookies, - { - ...session, - tokenSet, - } - ) - - for (const [key, value] of resHeaders.entries()) { - pagesRouterRes.setHeader(key, value) - } - } - } else { - // app router usage: Server Components, Server Actions, Route Handlers - try { - await this.sessionStore.set(await cookies(), await cookies(), { - ...session, - tokenSet, - }) - } catch (e) { - if (process.env.NODE_ENV === "development") { - console.warn( - "Failed to persist the updated token set. `getAccessToken()` was likely called from a Server Component which cannot set cookies." - ) - } - } - } + await this.setSessionStore( + { + ...session, + tokenSet, + }, + req, + res + ) } return { @@ -393,6 +361,37 @@ export class Auth0Client { } } + private async setSessionStore( + data: SessionData, + req?: PagesRouterRequest | NextRequest, + res?: PagesRouterResponse | NextResponse + ) { + if (req && res) { + if (req instanceof NextRequest && res instanceof NextResponse) { + await this.sessionStore.set(req.cookies, res.cookies, data) + } else { + const resHeaders = new Headers() + const resCookies = new ResponseCookies(resHeaders) + const reqCookies = this.createRequestCookies(req as PagesRouterRequest) + await this.sessionStore.set(reqCookies, resCookies, data) + for (const [key, value] of resHeaders.entries()) { + ;(res as PagesRouterResponse).setHeader(key, value) + } + } + } else { + // app router usage: Server Components, Server Actions, Route Handlers + try { + await this.sessionStore.set(await cookies(), await cookies(), data) + } catch (e) { + if (process.env.NODE_ENV === "development") { + console.warn( + "Failed to persist the updated token set. `getAccessToken()` was likely called from a Server Component which cannot set cookies." + ) + } + } + } + } + /** * updateSession updates the session of the currently authenticated user. If the user does not have a session, an error is thrown. * @@ -419,72 +418,108 @@ export class Auth0Client { res?: PagesRouterResponse | NextResponse, sessionData?: SessionData ) { + let params: SessionStoreParams + if (!res) { // app router: Server Actions, Route Handlers - const existingSession = await this.getSession() - - if (!existingSession) { - throw new Error("The user is not authenticated.") + params = { + reqCookies: await cookies(), + resCookies: await cookies(), + updatedSession: reqOrSession as SessionData, + existingSession: await this.getSession(), } - - const updatedSession = reqOrSession as SessionData - if (!updatedSession) { - throw new Error("The session data is missing.") - } - - await this.sessionStore.set(await cookies(), await cookies(), { - ...updatedSession, - internal: { - ...existingSession.internal, - }, - }) } else { const req = reqOrSession as PagesRouterRequest | NextRequest - if (!sessionData) { - throw new Error("The session data is missing.") - } - if (req instanceof NextRequest && res instanceof NextResponse) { // middleware usage - const existingSession = await this.getSession(req) - - if (!existingSession) { - throw new Error("The user is not authenticated.") + params = { + reqCookies: req.cookies, + resCookies: res.cookies, + updatedSession: sessionData, + existingSession: await this.getSession(req), } - - await this.sessionStore.set(req.cookies, res.cookies, { - ...sessionData, - internal: { - ...existingSession.internal, - }, - }) } else { // pages router usage - const existingSession = await this.getSession(req as PagesRouterRequest) - - if (!existingSession) { - throw new Error("The user is not authenticated.") - } - const resHeaders = new Headers() - const resCookies = new ResponseCookies(resHeaders) - const updatedSession = sessionData as SessionData - const reqCookies = this.createRequestCookies(req as PagesRouterRequest) const pagesRouterRes = res as PagesRouterResponse - await this.sessionStore.set(reqCookies, resCookies, { - ...updatedSession, - internal: { - ...existingSession.internal, - }, - }) + params = { + reqCookies: this.createRequestCookies(req as PagesRouterRequest), + resCookies: new ResponseCookies(resHeaders), + updatedSession: sessionData, + existingSession: await this.getSession(req as PagesRouterRequest), + } for (const [key, value] of resHeaders.entries()) { pagesRouterRes.setHeader(key, value) } } } + + await this.updateExistingSession(params) + } + + /** + * Retrieves a federated connection access token for a specified connection. + * + * @param connection - The name of the federated connection. + * @param login_hint - Optional login hint to be used during the token exchange. + * @param req - Optional request object containing the session information. + * @returns A promise that resolves to a `FederatedConnectionTokenSet`. + * @throws {FederatedConnectionsAccessTokenError} If the user does not have an active session. + * @throws {Error} If there is an error during the federated connection token exchange. + * + * @example + * ```typescript + * async function exampleUsage() { + * try { + * const connection = "example-connection"; + * const loginHint = "example-login-hint"; + * const federatedTokenSet = await auth0Client.getFederatedConnectionAccessToken(connection, loginHint); + * console.log("Federated Token Set:", federatedTokenSet); + * } catch (error) { + * console.error("Error retrieving federated connection access token:", error); + * } + * } + * ``` + */ + async getFederatedConnectionAccessToken( + connection: string, + login_hint?: string, + req?: PagesRouterRequest | NextRequest, + res?: PagesRouterResponse | NextResponse + ): Promise { + const session = req ? await this.getSession(req) : await this.getSession() + + if (!session) { + throw new FederatedConnectionsAccessTokenError( + FederatedConnectionAccessTokenErrorCode.MISSING_SESSION, + "The user does not have an active session." + ) + } + + const existingTokenSet = findFederatedToken(session, connection) + + const [error, federatedTokenSet] = + await this.authClient.federatedConnectionTokenExchange({ + connection, + tokenSet: session.tokenSet, + login_hint, + existingTokenSet, + }) + + if (error !== null) { + throw error + } + + await this.setSessionStore( + addOrUpdateFederatedTokenToSession(session, federatedTokenSet), + req, + res + ) + + return federatedTokenSet } private createRequestCookies(req: PagesRouterRequest) { @@ -502,4 +537,31 @@ export class Auth0Client { return new RequestCookies(headers) } + + private async updateExistingSession({ + reqCookies, + resCookies, + updatedSession, + existingSession, + }: SessionStoreParams) { + if (!existingSession) { + throw new Error("The user is not authenticated.") + } + if (!updatedSession) { + throw new Error("The session data is missing.") + } + await this.sessionStore.set(reqCookies, resCookies, { + ...updatedSession, + internal: { + ...existingSession.internal, + }, + }) + } +} + +type SessionStoreParams = { + reqCookies: RequestCookies | ReadonlyRequestCookies + resCookies: ResponseCookies + updatedSession?: SessionData | null + existingSession?: SessionData | null } diff --git a/src/server/cookies.ts b/src/server/cookies.ts index 5ad081368..a9fc478d1 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -53,3 +53,67 @@ export type ReadonlyRequestCookies = Omit< Pick export { ResponseCookies } export { RequestCookies } + +export interface EncryptAndSetCookieOptions { + reqCookies: RequestCookies; + resCookies: ResponseCookies; + payload: jose.JWTPayload; + cookieName: string; + maxAge: number; + cookieOptions: CookieOptions; + secret: string; +} + +export const encryptAndSet = async({ + reqCookies, + resCookies, + payload, + cookieName, + maxAge, + cookieOptions, + secret, +}: EncryptAndSetCookieOptions) => { + const jwe = await encrypt(payload, secret) + const value = jwe.toString() + + resCookies.set(cookieName, value, { + ...cookieOptions, + maxAge, + }) + // to enable read-after-write in the same request for middleware + reqCookies.set(cookieName, value) + + // check if the cookie size exceeds 4096 bytes, and if so, log a warning + const cookieJarSizeTest = new ResponseCookies(new Headers()) + cookieJarSizeTest.set(cookieName, value, { + ...cookieOptions, + maxAge, + }) + if (new TextEncoder().encode(cookieJarSizeTest.toString()).length >= 4096) { + console.warn( + "The session cookie size exceeds 4096 bytes, which may cause issues in some browsers. " + + "Consider removing any unnecessary custom claims from the access token or the user profile. " + + "Alternatively, you can use a stateful session implementation to store the session data in a data store." + ) + } +} + +export type DecryptAndGetCookieOptions = { + reqCookies: RequestCookies | ReadonlyRequestCookies; + cookieName: string; + secret: string; +}; + +export const decryptAndGet = async ({ + reqCookies, + cookieName, + secret, +}: DecryptAndGetCookieOptions): Promise => { + const cookieValue = reqCookies.get(cookieName)?.value; + + if (!cookieValue) { + return null; + } + + return decrypt(cookieValue, secret); +}; \ No newline at end of file diff --git a/src/server/federatedConnections/exchange.ts b/src/server/federatedConnections/exchange.ts new file mode 100644 index 000000000..2c82a72df --- /dev/null +++ b/src/server/federatedConnections/exchange.ts @@ -0,0 +1,183 @@ +import * as oauth from "oauth4webapi" + +import { + FederatedConnectionAccessTokenErrorCode, + FederatedConnectionsAccessTokenError, + SdkError, +} from "../../errors" +import { TokenSet } from "../../types" +import { + AuthServerMetadata, + MetadataDiscoverOptions, +} from "../authServerMetadata" +import { FederatedConnectionTokenSet } from "./serializer" + +/** + * A constant representing the grant type for federated connection access token exchange. + * + * This grant type is used in OAuth token exchange scenarios where a federated connection + * access token is required. It is specific to Auth0's implementation and follows the + * "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" format. + */ +export const GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN: string = + "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" + +/** + * Constant representing the subject type for a refresh token. + * This is used in OAuth 2.0 token exchange to specify that the token being exchanged is a refresh token. + * + * @see {@link https://tools.ietf.org/html/rfc8693#section-3.1 RFC 8693 Section 3.1} + */ +export const SUBJECT_TYPE_REFRESH_TOKEN: string = + "urn:ietf:params:oauth:token-type:refresh_token" + +/** + * A constant representing the token type for federated connection access tokens. + * This is used to specify the type of token being requested from Auth0. + * + * @constant + * @type {string} + */ +export const REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN: string = + "http://auth0.com/oauth/token-type/federated-connection-access-token" + +export type FederatedConnectionTokenExchangeOptions = { + /** + * The set of tokens to be exchanged. + */ + tokenSet: TokenSet + /** + * The name of the federated connection. + */ + connection: string + /** + * Optional hint for login. + */ + login_hint?: string, + existingTokenSet?: FederatedConnectionTokenSet +} + +export type FederatedConnectionsOptions = { + /** + * Options for metadata discovery. + */ + metadataDiscoverOptions: MetadataDiscoverOptions + /** + * Metadata for the OAuth client. + */ + clientMetadata: oauth.Client, +} + +/** + * Represents the output of a federated connection token exchange operation. + * + * This type is a union of two possible tuples: + * - A tuple containing an `SdkError` and `null`, indicating an error occurred. + * - A tuple containing `null` and a `FederatedConnectionAccessTokenResponse`, indicating a successful token exchange. + */ +export type FederatedConnectionTokenExchangeOutput = + | [SdkError, null] + | [null, FederatedConnectionTokenSet] + +export default class FederatedConnections { + private readonly authServerMetadata: AuthServerMetadata + private readonly federatedConnectionsOptions: FederatedConnectionsOptions + constructor(federatedConnectionsOptions: FederatedConnectionsOptions) { + this.authServerMetadata = new AuthServerMetadata() + this.federatedConnectionsOptions = federatedConnectionsOptions + } + + async federatedConnectionTokenExchange( + { + tokenSet, + connection, + login_hint, + existingTokenSet + }: FederatedConnectionTokenExchangeOptions, + clientAuth: oauth.ClientAuth + ): Promise { + if(existingTokenSet){ + return [null, existingTokenSet]; + } + if (!tokenSet.refreshToken) { + return [ + new FederatedConnectionsAccessTokenError( + FederatedConnectionAccessTokenErrorCode.MISSING_REFRESH_TOKEN, + "A refresh token was not present, Federated Connection Access Token requires a refresh token. The user needs to re-authenticate." + ), + null, + ] + } + + const params = new URLSearchParams() + + params.append("connection", connection) + params.append("subject_token_type", SUBJECT_TYPE_REFRESH_TOKEN) + params.append("subject_token", tokenSet.refreshToken) + + params.append( + "requested_token_type", + REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN + ) + + if (login_hint) { + params.append("login_hint", login_hint) + } + const [discoveryError, authorizationServerMetadata] = + await this.authServerMetadata.discover( + this.federatedConnectionsOptions.metadataDiscoverOptions + ) + + if (discoveryError) { + console.error(discoveryError) + return [discoveryError, null] + } + + const { clientMetadata } = this.federatedConnectionsOptions + + const httpResponse = await oauth.genericTokenEndpointRequest( + authorizationServerMetadata, + clientMetadata, + clientAuth, + GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, + params, + { + [oauth.customFetch]: + this.federatedConnectionsOptions.metadataDiscoverOptions.fetch, + [oauth.allowInsecureRequests]: + this.federatedConnectionsOptions.metadataDiscoverOptions + .allowInsecureRequests, + } + ) + + let tokenEndpointResponse: oauth.TokenEndpointResponse + try { + tokenEndpointResponse = await oauth.processGenericTokenEndpointResponse( + authorizationServerMetadata, + clientMetadata, + httpResponse + ) + } catch (err) { + console.error(err) + return [ + new FederatedConnectionsAccessTokenError( + FederatedConnectionAccessTokenErrorCode.FAILED_TO_EXCHANGE, + "There was an error trying to exchange the refresh token for a federated connection access token. Check the server logs for more information." + ), + null, + ] + } + + return [ + null, + { + accessToken: tokenEndpointResponse.access_token, + expiresAt: + Math.floor(Date.now() / 1000) + + Number(tokenEndpointResponse.expires_in), + scope: tokenEndpointResponse.scope, + connection, + }, + ] + } +} diff --git a/src/server/federatedConnections/serializer.ts b/src/server/federatedConnections/serializer.ts new file mode 100644 index 000000000..c6783b157 --- /dev/null +++ b/src/server/federatedConnections/serializer.ts @@ -0,0 +1,205 @@ +import { SessionData } from "../../types" +import { + RequestCookies, + ResponseCookies, + encryptAndSet, + EncryptAndSetCookieOptions, + decrypt, +} from "../cookies" + +export const FCAT_PREFIX = "__FC" +export const FCAT_DELIMITER = "|" + +/** + * Represents the response containing an access token from a federated connection. + */ +export interface FederatedConnectionTokenSet { + /** + * The access token issued by the federated connection. + */ + accessToken: string + /** + * The timestamp (in seconds since epoch) when the access token expires. + */ + expiresAt: number + /** + * Optional. The scope of the access token. + */ + scope?: string + /** + * The name of the federated connection. + */ + connection: string +} + +/** + * Generates the FCAT cookie name based on the provided provider. + * + * @param provider - The name of the provider. + * @returns The generated FCAT cookie name. + */ +export const getFCCookieName = (provider: string): string => { + return [FCAT_PREFIX, provider].join(FCAT_DELIMITER) +} + +/** + * Adds or updates a federated token in the session data. + * + * @param session - The session data object where the federated token will be added or updated. + * @param fcat - The federated connection token set containing the access token, expiration time, and scope. + * @returns The updated session data object. + */ +export const addOrUpdateFederatedTokenToSession = ( + session: SessionData, + fcat: FederatedConnectionTokenSet +): SessionData => { + if (!session.federatedConnectionTokenSets) { + session.federatedConnectionTokenSets = {} + } + + const serializedFCTokenSet: SerializedFCTokenSet = { + accessToken: fcat.accessToken, + expiresAt: fcat.expiresAt, + scope: fcat.scope, + } + + if (!session.federatedConnectionsMap) { + session.federatedConnectionsMap = {} + } + + session.federatedConnectionsMap[fcat.connection] = serializedFCTokenSet + return session +} + +/** + * We use a mapping where each provider maps to a set of FCATs. + */ +export type FederatedConnectionMap = { + [connection: string]: SerializedFCTokenSet +} + +export type SerializedFCTokenSet = Omit< + FederatedConnectionTokenSet, + "connection" +> + +/** + * Serializes federated tokens and stores them in cookies. + * + * @param fcTokenSetMap - A map of federated connection token sets. + * @param options - Options for storing the tokens in cookies. + * + * @returns A promise that resolves when all tokens have been stored in cookies. + */ +export const serializeFederatedTokens = async ( + fcTokenSetMap: FederatedConnectionMap, + options: EncryptAndSetCookieOptions +): Promise => { + for (const [key, tokenSet] of Object.entries(fcTokenSetMap)) { + await encryptAndSet({ + ...options, + payload: tokenSet, + cookieName: getFCCookieName(key), + maxAge: tokenSet.expiresAt, + }) + } +} + +/** + * Deserializes federated tokens from cookies and returns a map of federated connections. + * + * @param cookies - The cookies object, which can be either `RequestCookies` or `ResponseCookies`. + * @returns A promise that resolves to a `FederatedConnectionMap` containing the deserialized federated tokens. + */ +export const deserializeFederatedTokens = async ( + cookies: RequestCookies | ResponseCookies, + secret: string +): Promise => { + /** + * Represents a mapping for federated connections. + * + * @typedef {Object} FCMapping + * @property {string} provider - The name of the federated provider. + * @property {SerializedFCTokenSet} tokenSet - The serialized token set associated with the federated connection. + */ + type FCMapping = { + provider: string + tokenSet: SerializedFCTokenSet + } + + /** + * Reduces an array of federated connection mappings into a map of token sets. + * + * @param acc - The accumulator object that holds the federated connection map. + * @param param1 - An object containing the provider and token set. + * @param param1.provider - The provider for the token set. + * @param param1.tokenSet - The token set to be added to the map. + * @returns The updated federated connection map with the new token set added. + */ + const reduceFCKVToTokenSetMap = ( + acc: FederatedConnectionMap, + { provider, tokenSet }: FCMapping + ) => { + acc[provider] = tokenSet + return acc + } + + /** + * Maps a cookie object to an FCMapping object. + * + * @param cookie - An object representing a cookie with `name` and `value` properties. + * @returns An FCMapping object containing the provider and tokenSet. + * + * The `name` property of the cookie is expected to be a string with segments separated by `FCAT_DELIMITER`. + * The first segment is ignored, the second segment is used as the provider. + * The `value` property of the cookie is parsed as JSON to obtain the tokenSet. + */ + const cookieToFCKVMapper = async (cookie: { + name: string + value: string + }): Promise => { + const [_, provider] = cookie.name.split(FCAT_DELIMITER) + return { + provider, + tokenSet: await decrypt( + cookie.value, + secret + ), + } + } + + const allCookies = await Promise.all(cookies + .getAll() // Get all cookies + .filter((cookie) => cookie.name.startsWith(FCAT_PREFIX)) // Filter cookies that start with the FCAT prefix + .map(cookieToFCKVMapper)) + + return allCookies// Map each cookie to an FCMapping object + .filter((FCMapping) => !isTokenSetExpired(FCMapping.tokenSet)) // Filter out expired token sets + .reduce(reduceFCKVToTokenSetMap, {} as FederatedConnectionMap) // Reduce the array of FCMapping objects into a FederatedConnectionMap +} + +/** + * Checks if the given token set is expired. + * + * @param tokenSet - The token set to check for expiration. + * @returns `true` if the token set is expired or not provided, `false` otherwise. + */ +export const isTokenSetExpired = ( + tokenSet: FederatedConnectionTokenSet | SerializedFCTokenSet +): boolean => { + return !tokenSet || tokenSet.expiresAt <= (Date.now() / 1000) +} + +export const findFederatedToken = ( + session: SessionData, + provider: string +): FederatedConnectionTokenSet | undefined => { + const partialTokenSet = session.federatedConnectionsMap?.[provider] + + if (partialTokenSet) { + return { + ...partialTokenSet, + connection: provider, + } as FederatedConnectionTokenSet + } +} diff --git a/src/server/session/abstract-session-store.ts b/src/server/session/abstract-session-store.ts index 70182a1ec..ce63fc1db 100644 --- a/src/server/session/abstract-session-store.ts +++ b/src/server/session/abstract-session-store.ts @@ -151,4 +151,4 @@ export abstract class AbstractSessionStore { return maxAge > 0 ? maxAge : 0 } -} +} \ No newline at end of file diff --git a/src/server/session/stateful-session-store.ts b/src/server/session/stateful-session-store.ts index beffddd7d..f3c9c0f7e 100644 --- a/src/server/session/stateful-session-store.ts +++ b/src/server/session/stateful-session-store.ts @@ -132,4 +132,4 @@ export class StatefulSessionStore extends AbstractSessionStore { await this.store.delete(id) } -} +} \ No newline at end of file diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index c49d9fbc3..fe39c8c09 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -1,5 +1,10 @@ import { SessionData } from "../../types" import * as cookies from "../cookies" +import { EncryptAndSetCookieOptions } from "../cookies" +import { + deserializeFederatedTokens, + serializeFederatedTokens, +} from "../federatedConnections/serializer" import { AbstractSessionStore, SessionCookieOptions, @@ -32,14 +37,22 @@ export class StatelessSessionStore extends AbstractSessionStore { }) } - async get(reqCookies: cookies.RequestCookies) { - const cookieValue = reqCookies.get(this.sessionCookieName)?.value - - if (!cookieValue) { - return null + /** + * Retrieves the session data from the request cookies. + * + * @param reqCookies - The cookies from the request. + * @returns A promise that resolves to the session data or null if no session data is found. + */ + async get(reqCookies: cookies.RequestCookies): Promise { + const session = await cookies.decryptAndGet({ + reqCookies, + cookieName: this.sessionCookieName, + secret: this.secret, + }) + if (session) { + session.federatedConnectionsMap = (await deserializeFederatedTokens(reqCookies, this.secret)) ?? {} } - - return cookies.decrypt(cookieValue, this.secret) + return session } /** @@ -51,29 +64,23 @@ export class StatelessSessionStore extends AbstractSessionStore { session: SessionData, _isNew?: boolean ) { - const jwe = await cookies.encrypt(session, this.secret) - const maxAge = this.calculateMaxAge(session.internal.createdAt) - const cookieValue = jwe.toString() + const { federatedConnectionsMap: fcMap, ...originalSession } = session - resCookies.set(this.sessionCookieName, jwe.toString(), { - ...this.cookieConfig, - maxAge, - }) - // to enable read-after-write in the same request for middleware - reqCookies.set(this.sessionCookieName, cookieValue) + const maxAge = this.calculateMaxAge(originalSession.internal.createdAt) - // check if the session cookie size exceeds 4096 bytes, and if so, log a warning - const cookieJarSizeTest = new cookies.ResponseCookies(new Headers()) - cookieJarSizeTest.set(this.sessionCookieName, cookieValue, { - ...this.cookieConfig, + const setCookieOptions: EncryptAndSetCookieOptions = { + reqCookies, + resCookies, + payload: originalSession, + cookieName: this.sessionCookieName, maxAge, - }) - if (new TextEncoder().encode(cookieJarSizeTest.toString()).length >= 4096) { - console.warn( - "The session cookie size exceeds 4096 bytes, which may cause issues in some browsers. " + - "Consider removing any unnecessary custom claims from the access token or the user profile. " + - "Alternatively, you can use a stateful session implementation to store the session data in a data store." - ) + cookieOptions: this.cookieConfig, + secret: this.secret, + } + + await cookies.encryptAndSet(setCookieOptions) + if(fcMap){ + await serializeFederatedTokens(fcMap, setCookieOptions) } } @@ -81,6 +88,6 @@ export class StatelessSessionStore extends AbstractSessionStore { _reqCookies: cookies.RequestCookies, resCookies: cookies.ResponseCookies ) { - await resCookies.delete(this.sessionCookieName) + resCookies.delete(this.sessionCookieName) } } diff --git a/src/types/index.ts b/src/types/index.ts index b52cd7945..c1cc3f702 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,5 @@ +import { FederatedConnectionMap } from "../server/federatedConnections/serializer" + export interface TokenSet { accessToken: string scope?: string @@ -13,7 +15,8 @@ export interface SessionData { sid: string // the time at which the session was created in seconds since epoch createdAt: number - } + }, + federatedConnectionsMap?: FederatedConnectionMap [key: string]: unknown }