From 6adeffc66c4baef7612edddc778defafe0b342c6 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 29 Jul 2026 18:25:37 +0530 Subject: [PATCH 1/7] feat: add connected-accounts disconnect and listing with orphan cookie cleanup --- src/errors/index.ts | 4 +- src/errors/my-account-errors.ts | 46 + src/server/auth-client.test.ts | 319 +++++- src/server/auth-client.ts | 216 +++- src/server/client.test.ts | 958 ++++++++++++++++++ src/server/client.ts | 291 +++++- .../session/stateless-session-store.test.ts | 175 ++++ src/server/session/stateless-session-store.ts | 44 +- src/types/connected-accounts.ts | 52 + src/types/index.ts | 7 +- src/types/token-vault.ts | 3 + 11 files changed, 2095 insertions(+), 20 deletions(-) diff --git a/src/errors/index.ts b/src/errors/index.ts index 6dc92b2ac..9de588ba8 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -29,7 +29,9 @@ export { MtlsError, MtlsErrorCode } from "./mtls-errors.js"; export { MyAccountApiError, ConnectAccountError, - ConnectAccountErrorCodes + ConnectAccountErrorCodes, + DisconnectAccountError, + DisconnectAccountErrorCodes } from "./my-account-errors.js"; export { diff --git a/src/errors/my-account-errors.ts b/src/errors/my-account-errors.ts index e4b599508..71c384522 100644 --- a/src/errors/my-account-errors.ts +++ b/src/errors/my-account-errors.ts @@ -101,3 +101,49 @@ export class ConnectAccountError extends SdkError { this.cause = cause; } } + +/** + * Enum representing error codes related to the disconnect account flow. + */ +export enum DisconnectAccountErrorCodes { + /** + * The session is missing. + */ + MISSING_SESSION = "missing_session", + + /** + * Failed to list the connected accounts. + */ + FAILED_TO_LIST = "failed_to_list", + + /** + * Failed to delete the connected account. + */ + FAILED_TO_DELETE = "failed_to_delete" +} + +/** + * Error class representing a disconnect account error. + */ +export class DisconnectAccountError extends SdkError { + /** + * The error code associated with the disconnect account error. + */ + public code: string; + public cause?: MyAccountApiError; + + constructor({ + code, + message, + cause + }: { + code: string; + message: string; + cause?: MyAccountApiError; + }) { + super(message); + this.name = "DisconnectAccountError"; + this.code = code; + this.cause = cause; + } +} diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index f8382cf7d..6ae90e3e6 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -18,6 +18,7 @@ import { BackchannelAuthenticationError, ConnectAccountError, ConnectAccountErrorCodes, + DisconnectAccountError, InvalidConfigurationError, MyAccountApiError, TokenRevocationError, @@ -118,7 +119,11 @@ ca/T0LLtgmbMmxSv/MmzIg== onCompleteConnectAccountRequest, completeConnectAccountErrorResponse, onRevocationRequest, - revocationErrorResponse + revocationErrorResponse, + onListConnectedAccountsRequest, + listConnectedAccountsResponses, + onDeleteConnectedAccountRequest, + deleteConnectedAccountErrorResponse }: { tokenEndpointResponse?: oauth.TokenEndpointResponse | oauth.OAuth2Error; tokenEndpointErrorResponse?: oauth.OAuth2Error; @@ -134,7 +139,14 @@ ca/T0LLtgmbMmxSv/MmzIg== completeConnectAccountErrorResponse?: Response; onRevocationRequest?: (request: Request) => Promise; revocationErrorResponse?: Response; + onListConnectedAccountsRequest?: (request: Request) => Promise; + // Successive responses for the paginated list endpoint. Each call consumes + // the next entry; the last entry is reused once exhausted. + listConnectedAccountsResponses?: Response[]; + onDeleteConnectedAccountRequest?: (request: Request) => Promise; + deleteConnectedAccountErrorResponse?: Response; } = {}) { + let listConnectedAccountsCallCount = 0; // this function acts as a mock authorization server return vi.fn( async ( @@ -284,6 +296,41 @@ ca/T0LLtgmbMmxSv/MmzIg== ); } + // List connected accounts + if ( + url.pathname === "/me/v1/connected-accounts/accounts" && + (init?.method ?? "GET") === "GET" + ) { + if (onListConnectedAccountsRequest) { + await onListConnectedAccountsRequest(new Request(input, init)); + } + + if (listConnectedAccountsResponses?.length) { + const index = Math.min( + listConnectedAccountsCallCount, + listConnectedAccountsResponses.length - 1 + ); + listConnectedAccountsCallCount++; + return listConnectedAccountsResponses[index]; + } + + return Response.json({ accounts: [] }, { status: 200 }); + } + + // Delete connected account + if ( + url.pathname.startsWith("/me/v1/connected-accounts/accounts/") && + init?.method === "DELETE" + ) { + if (onDeleteConnectedAccountRequest) { + await onDeleteConnectedAccountRequest(new Request(input, init)); + } + if (deleteConnectedAccountErrorResponse) { + return deleteConnectedAccountErrorResponse; + } + return new Response(null, { status: 204 }); + } + // Revocation endpoint if (url.pathname === "/oauth/revoke") { if (onRevocationRequest) { @@ -9506,7 +9553,9 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(connectionTokenSet).toEqual({ accessToken: DEFAULT.accessToken, connection: "google-oauth2", - expiresAt: expect.any(Number) + expiresAt: expect.any(Number), + scope: undefined, + loginHint: "000100123" }); }); @@ -9609,7 +9658,9 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(connectionTokenSet).toEqual({ accessToken: DEFAULT.accessToken, connection: "google-oauth2", - expiresAt: expect.any(Number) + expiresAt: expect.any(Number), + scope: undefined, + loginHint: "000100123" }); expect(fetchSpy).toHaveBeenCalled(); }); @@ -10055,6 +10106,268 @@ ca/T0LLtgmbMmxSv/MmzIg== }); }); + describe("listConnectedAccounts", async () => { + function buildAuthClient(fetchSpy: any) { + return new AuthClient({ + transactionStore: new TransactionStore({ secret: "secret" }), + sessionStore: new StatelessSessionStore({ secret: "secret" }), + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + secret: "secret", + appBaseUrl: DEFAULT.appBaseUrl, + routes: getDefaultRoutes(), + fetch: fetchSpy + }); + } + + const tokenSet = { + accessToken: "my-account-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600 + }; + + it("returns the mapped connected accounts from a single page", async () => { + const fetchSpy = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { + accounts: [ + { + id: "cac_1", + connection: "google-oauth2", + access_type: "offline", + scopes: ["email"], + created_at: "2024-01-01T00:00:00.000Z", + expires_at: "2024-02-01T00:00:00.000Z", + org_id: "org_123" + } + ] + }, + { status: 200 } + ) + ] + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, accounts] = + await authClient.listConnectedAccounts(tokenSet); + + expect(error).toBeNull(); + expect(accounts).toEqual([ + { + id: "cac_1", + connection: "google-oauth2", + accessType: "offline", + scopes: ["email"], + createdAt: "2024-01-01T00:00:00.000Z", + expiresAt: "2024-02-01T00:00:00.000Z", + orgId: "org_123" + } + ]); + }); + + it("maps orgId as undefined for accounts not bound to an organization", async () => { + const fetchSpy = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { + accounts: [{ id: "cac_1", connection: "google-oauth2" }] + }, + { status: 200 } + ) + ] + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, accounts] = + await authClient.listConnectedAccounts(tokenSet); + + expect(error).toBeNull(); + expect(accounts?.[0].orgId).toBeUndefined(); + }); + + it("follows pagination via the next token", async () => { + const listConnectedAccountsResponses = [ + Response.json( + { + accounts: [{ id: "cac_1", connection: "google-oauth2" }], + next: "page-2" + }, + { status: 200 } + ), + Response.json( + { accounts: [{ id: "cac_2", connection: "github" }] }, + { status: 200 } + ) + ]; + const nextParams: (string | null)[] = []; + const fetchSpy = getMockAuthorizationServer({ + listConnectedAccountsResponses, + onListConnectedAccountsRequest: async (req) => { + nextParams.push(new URL(req.url).searchParams.get("next")); + } + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, accounts] = + await authClient.listConnectedAccounts(tokenSet); + + expect(error).toBeNull(); + expect(accounts?.map((a) => a.id)).toEqual(["cac_1", "cac_2"]); + // First request has no next param, second passes the token from page 1. + expect(nextParams).toEqual([null, "page-2"]); + }); + + it("returns a FAILED_TO_LIST error on a non-ok response", async () => { + const fetchSpy = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { + type: "https://auth0.com/errors", + title: "Forbidden", + detail: "insufficient scope" + }, + { status: 403 } + ) + ] + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, accounts] = + await authClient.listConnectedAccounts(tokenSet); + + expect(accounts).toBeNull(); + expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error?.code).toBe("failed_to_list"); + expect(error?.cause?.status).toBe(403); + }); + }); + + describe("disconnectAccount", async () => { + function buildAuthClient(fetchSpy: any) { + return new AuthClient({ + transactionStore: new TransactionStore({ secret: "secret" }), + sessionStore: new StatelessSessionStore({ secret: "secret" }), + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + secret: "secret", + appBaseUrl: DEFAULT.appBaseUrl, + routes: getDefaultRoutes(), + fetch: fetchSpy + }); + } + + const tokenSet = { + accessToken: "my-account-token", + expiresAt: Math.floor(Date.now() / 1000) + 3600 + }; + + it("deletes every account matching the connection", async () => { + const deletedIds: string[] = []; + const fetchSpy = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { + accounts: [ + { id: "cac_1", connection: "google-oauth2" }, + { id: "cac_2", connection: "github" }, + { id: "cac_3", connection: "google-oauth2" } + ] + }, + { status: 200 } + ) + ], + onDeleteConnectedAccountRequest: async (req) => { + deletedIds.push(new URL(req.url).pathname.split("/").pop()!); + } + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, removed] = await authClient.disconnectAccount( + tokenSet, + "google-oauth2" + ); + + expect(error).toBeNull(); + expect(deletedIds).toEqual(["cac_1", "cac_3"]); + expect(removed?.map((a) => a.id)).toEqual(["cac_1", "cac_3"]); + }); + + it("is idempotent when no account matches the connection", async () => { + const deletedIds: string[] = []; + const fetchSpy = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { accounts: [{ id: "cac_2", connection: "github" }] }, + { status: 200 } + ) + ], + onDeleteConnectedAccountRequest: async (req) => { + deletedIds.push(new URL(req.url).pathname.split("/").pop()!); + } + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, removed] = await authClient.disconnectAccount( + tokenSet, + "google-oauth2" + ); + + expect(error).toBeNull(); + expect(deletedIds).toEqual([]); + expect(removed).toEqual([]); + }); + + it("propagates a list error without attempting deletes", async () => { + const deletedIds: string[] = []; + const fetchSpy = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json({ title: "Nope", detail: "no" }, { status: 401 }) + ], + onDeleteConnectedAccountRequest: async (req) => { + deletedIds.push(new URL(req.url).pathname.split("/").pop()!); + } + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, removed] = await authClient.disconnectAccount( + tokenSet, + "google-oauth2" + ); + + expect(removed).toBeNull(); + expect(error?.code).toBe("failed_to_list"); + expect(deletedIds).toEqual([]); + }); + + it("returns a FAILED_TO_DELETE error when a delete fails", async () => { + const fetchSpy = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { accounts: [{ id: "cac_1", connection: "google-oauth2" }] }, + { status: 200 } + ) + ], + deleteConnectedAccountErrorResponse: Response.json( + { title: "Too many", detail: "rate limited" }, + { status: 429 } + ) + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, removed] = await authClient.disconnectAccount( + tokenSet, + "google-oauth2" + ); + + expect(removed).toBeNull(); + expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error?.code).toBe("failed_to_delete"); + expect(error?.cause?.status).toBe(429); + }); + }); + describe("backchannelAuthentication", async () => { it("should return an error if backchannel authentication is not enabled", async () => { const secret = await generateSecret(32); diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 65f69021e..00fc8dc3c 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -20,6 +20,8 @@ import { ConnectAccountErrorCodes, CustomTokenExchangeError, CustomTokenExchangeErrorCode, + DisconnectAccountError, + DisconnectAccountErrorCodes, DiscoveryError, DPoPError, DPoPErrorCode, @@ -58,7 +60,8 @@ import { CompleteConnectAccountResponse, ConnectAccountOptions, ConnectAccountRequest, - ConnectAccountResponse + ConnectAccountResponse, + ConnectedAccount } from "../types/connected-accounts.js"; import { DpopKeyPair, DpopOptions } from "../types/dpop.js"; import { @@ -3452,7 +3455,8 @@ export class AuthClient { Math.floor(Date.now() / 1000) + Number(tokenEndpointResponse.expires_in), scope: tokenEndpointResponse.scope, - connection: options.connection + connection: options.connection, + ...(options.login_hint ? { loginHint: options.login_hint } : {}) } ]; } @@ -4331,6 +4335,178 @@ export class AuthClient { } } + /** + * Lists the connected accounts for the current user via the My Account API. + * + * Handles pagination transparently (the endpoint returns at most 20 accounts + * per page and an optional `next` token) so the full set is always returned. + * + * @see https://auth0.com/docs/api/myaccount/connected-accounts/get-connected-accounts + */ + async listConnectedAccounts( + tokenSet: TokenSet + ): Promise<[null, ConnectedAccount[]] | [DisconnectAccountError, null]> { + try { + const fetcher = await this.fetcherFactory({ + useDPoP: this.useDPoP, + getAccessToken: async () => ({ + accessToken: tokenSet.accessToken, + expiresAt: tokenSet.expiresAt || 0, + scope: tokenSet.scope, + token_type: tokenSet.token_type + }), + fetch: this.fetch + }); + + const accounts: ConnectedAccount[] = []; + let next: string | undefined; + + do { + const url = new URL("/me/v1/connected-accounts/accounts", this.issuer); + if (next) { + url.searchParams.set("next", next); + } + + const res = await fetcher.fetchWithAuth(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + if (!res.ok) { + return buildDisconnectAccountErrorResponse( + res, + DisconnectAccountErrorCodes.FAILED_TO_LIST + ); + } + + const body = await res.json(); + for (const account of body.accounts ?? []) { + accounts.push({ + id: account.id, + connection: account.connection, + accessType: account.access_type, + scopes: account.scopes, + createdAt: account.created_at, + expiresAt: account.expires_at, + orgId: account.org_id + }); + } + next = body.next; + } while (next); + + return [null, accounts]; + } catch (e: any) { + let message = + "An unexpected error occurred while trying to list the connected accounts."; + if (e instanceof DPoPError) { + message = e.message; + } + return [ + new DisconnectAccountError({ + code: DisconnectAccountErrorCodes.FAILED_TO_LIST, + message + }), + null + ]; + } + } + + /** + * Deletes a single connected account by its id via the My Account API. + * + * @see https://auth0.com/docs/api/myaccount/connected-accounts/delete-connected-account + */ + private async deleteConnectedAccount( + tokenSet: TokenSet, + id: string + ): Promise<[null, null] | [DisconnectAccountError, null]> { + try { + const url = new URL( + `/me/v1/connected-accounts/accounts/${encodeURIComponent(id)}`, + this.issuer + ); + + const fetcher = await this.fetcherFactory({ + useDPoP: this.useDPoP, + getAccessToken: async () => ({ + accessToken: tokenSet.accessToken, + expiresAt: tokenSet.expiresAt || 0, + scope: tokenSet.scope, + token_type: tokenSet.token_type + }), + fetch: this.fetch + }); + + const res = await fetcher.fetchWithAuth(url.toString(), { + method: "DELETE", + headers: { + "Content-Type": "application/json" + } + }); + + if (!res.ok) { + return buildDisconnectAccountErrorResponse( + res, + DisconnectAccountErrorCodes.FAILED_TO_DELETE + ); + } + + return [null, null]; + } catch (e: any) { + let message = + "An unexpected error occurred while trying to delete the connected account."; + if (e instanceof DPoPError) { + message = e.message; + } + return [ + new DisconnectAccountError({ + code: DisconnectAccountErrorCodes.FAILED_TO_DELETE, + message + }), + null + ]; + } + } + + /** + * Disconnects all connected accounts for the given connection. + * + * Resolves the connection name to the connected-account id(s) via the list + * endpoint (the delete endpoint is keyed by id), then deletes each. The + * operation is idempotent: if the server reports no accounts for the + * connection, it returns the empty list without error so callers can still + * reconcile local state. + */ + async disconnectAccount( + tokenSet: TokenSet, + connection: string + ): Promise<[null, ConnectedAccount[]] | [DisconnectAccountError, null]> { + const [listError, accounts] = await this.listConnectedAccounts(tokenSet); + if (listError) { + return [listError, null]; + } + + const matching = accounts.filter( + (account) => account.connection === connection + ); + + const removed: ConnectedAccount[] = []; + for (const account of matching) { + const [deleteError] = await this.deleteConnectedAccount( + tokenSet, + account.id + ); + if (deleteError) { + return [deleteError, null]; + } + removed.push(account); + } + + return [null, removed]; + } + private async getOpenIdClientConfig(): Promise< [null, client.Configuration] | [SdkError, null] > { @@ -6700,6 +6876,42 @@ export async function buildConnectAccountErrorResponse( } } +export async function buildDisconnectAccountErrorResponse( + res: Response, + errorCode: DisconnectAccountErrorCodes +): Promise<[DisconnectAccountError, null]> { + const actionVerb = + errorCode === DisconnectAccountErrorCodes.FAILED_TO_LIST + ? "list the connected accounts" + : "delete the connected account"; + + try { + const errorBody = await res.json(); + return [ + new DisconnectAccountError({ + code: errorCode, + message: `The request to ${actionVerb} failed with status ${res.status}.`, + cause: new MyAccountApiError({ + type: errorBody.type, + title: errorBody.title, + detail: errorBody.detail, + status: res.status, + validationErrors: errorBody.validation_errors + }) + }), + null + ]; + } catch (e) { + return [ + new DisconnectAccountError({ + code: errorCode, + message: `The request to ${actionVerb} failed with status ${res.status}.` + }), + null + ]; + } +} + /** * Creates a NextResponse for BCLO error cases. * Centralizes the response format (text/plain content type) for all BCLO error branches. diff --git a/src/server/client.test.ts b/src/server/client.test.ts index e92c16629..5bb7f5454 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1,4 +1,6 @@ +import { cookies as nextCookies } from "next/headers.js"; import { NextRequest, NextResponse } from "next/server.js"; +import { ResponseCookies } from "@edge-runtime/cookies"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -579,6 +581,962 @@ describe("Auth0Client", () => { }); }); + describe("getAccessTokenForConnection (login_hint multi-account)", () => { + const baseSession = ( + connectionTokenSets: SessionData["connectionTokenSets"] + ): SessionData => ({ + user: { sub: "user123" }, + tokenSet: { + accessToken: "access_token", + refreshToken: "refresh_token", + expiresAt: Date.now() / 1000 + 3600 + }, + internal: { + sid: "mock_sid", + createdAt: Date.now() / 1000 + }, + connectionTokenSets + }); + + let client: Auth0Client; + + beforeEach(() => { + process.env[ENV_VARS.DOMAIN] = "test.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "test_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test"; + process.env[ENV_VARS.SECRET] = "test_secret"; + + client = new Auth0Client(); + }); + + // Wires up the auth client so getConnectionTokenSet returns `minted` and + // records the `existingTokenSet` it was called with (the match the SDK found). + function mockAuthClient( + session: SessionData, + minted: any, + getConnectionTokenSet = vi.fn().mockResolvedValue([null, minted]) + ) { + const mockAuthClient = { + getSessionWithDomainCheck: vi + .fn() + .mockResolvedValue({ session, error: null }), + getConnectionTokenSet + }; + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue( + mockAuthClient + ); + return { getConnectionTokenSet }; + } + + it("appends a second entry for the same connection with a different login hint", async () => { + const session = baseSession([ + { + connection: "google-oauth2", + accessToken: "fc_alice", + expiresAt: 999, + loginHint: "alice@example.com" + } + ]); + const minted = { + connection: "google-oauth2", + accessToken: "fc_bob", + expiresAt: 1000, + loginHint: "bob@example.com" + }; + const { getConnectionTokenSet } = mockAuthClient(session, minted); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + + const result = await client.getAccessTokenForConnection({ + connection: "google-oauth2", + login_hint: "bob@example.com" + }); + + // Alice's entry was not treated as a match, so a fresh exchange happened + // with no existing token set. + expect(getConnectionTokenSet).toHaveBeenCalledWith( + session.tokenSet, + undefined, + expect.objectContaining({ login_hint: "bob@example.com" }) + ); + // Both accounts are now stored under the same connection. + const saved = saveToSession.mock.calls[0][0] as SessionData; + expect(saved.connectionTokenSets).toEqual([ + expect.objectContaining({ loginHint: "alice@example.com" }), + expect.objectContaining({ loginHint: "bob@example.com" }) + ]); + expect(result.token).toBe("fc_bob"); + }); + + it("reuses the entry matching the provided login hint", async () => { + const fresh = Math.floor(Date.now() / 1000) + 3600; + const session = baseSession([ + { + connection: "google-oauth2", + accessToken: "fc_alice", + expiresAt: fresh, + loginHint: "alice@example.com" + }, + { + connection: "google-oauth2", + accessToken: "fc_bob", + expiresAt: fresh, + loginHint: "bob@example.com" + } + ]); + // The auth client, given a still-valid existing token set, returns it as-is. + const getConnectionTokenSet = vi.fn( + async (_tokenSet: any, existing: any) => [null, existing] + ); + mockAuthClient(session, undefined, getConnectionTokenSet as any); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + + const result = await client.getAccessTokenForConnection({ + connection: "google-oauth2", + login_hint: "bob@example.com" + }); + + // Bob's entry (not Alice's) was passed as the existing token set. + expect(getConnectionTokenSet).toHaveBeenCalledWith( + session.tokenSet, + expect.objectContaining({ loginHint: "bob@example.com" }), + expect.objectContaining({ login_hint: "bob@example.com" }) + ); + // Nothing changed, so no save. + expect(saveToSession).not.toHaveBeenCalled(); + expect(result.token).toBe("fc_bob"); + }); + + it("matches on connection alone when no login hint is provided (back-compat)", async () => { + const fresh = Math.floor(Date.now() / 1000) + 3600; + const session = baseSession([ + { + connection: "google-oauth2", + accessToken: "fc_g", + expiresAt: fresh + } + ]); + const getConnectionTokenSet = vi.fn( + async (_tokenSet: any, existing: any) => [null, existing] + ); + mockAuthClient(session, undefined, getConnectionTokenSet as any); + vi.spyOn(client as any, "saveToSession").mockResolvedValue(undefined); + + await client.getAccessTokenForConnection({ connection: "google-oauth2" }); + + expect(getConnectionTokenSet).toHaveBeenCalledWith( + session.tokenSet, + expect.objectContaining({ accessToken: "fc_g" }), + expect.objectContaining({ connection: "google-oauth2" }) + ); + }); + }); + + describe("connectAccount", () => { + const sessionWith = ( + connectionTokenSets?: SessionData["connectionTokenSets"] + ): SessionData => ({ + user: { sub: "user123" }, + tokenSet: { + accessToken: "access_token", + idToken: "id_token", + refreshToken: "refresh_token", + expiresAt: Date.now() / 1000 + 3600 + }, + internal: { + sid: "mock_sid", + createdAt: Date.now() / 1000 + }, + connectionTokenSets + }); + + let client: Auth0Client; + + beforeEach(() => { + process.env[ENV_VARS.DOMAIN] = "test.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "test_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test"; + process.env[ENV_VARS.SECRET] = "test_secret"; + + client = new Auth0Client(); + }); + + function mockAuthClientWith( + connectAccount: ReturnType, + issuer = "https://test.auth0.com/" + ) { + const mockAuthClient = { issuer, connectAccount }; + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue( + mockAuthClient + ); + return mockAuthClient; + } + + it("throws ConnectAccountError when there is no session", async () => { + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + null + ); + const connect = vi.fn(); + mockAuthClientWith(connect); + + await expect( + client.connectAccount({ connection: "google-oauth2" }) + ).rejects.toMatchObject({ code: "missing_session" }); + expect(connect).not.toHaveBeenCalled(); + }); + + it("mints a create-scoped My Account token and returns the redirect response", async () => { + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + sessionWith() + ); + const getAccessToken = vi + .spyOn(client as any, "getAccessToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + const redirect = NextResponse.redirect("https://test.auth0.com/connect"); + const connect = vi.fn().mockResolvedValue([null, redirect]); + mockAuthClientWith(connect); + + const result = await client.connectAccount({ + connection: "google-oauth2" + }); + + expect(result).toBe(redirect); + expect(getAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + audience: "https://test.auth0.com/me/", + scope: "create:me:connected_accounts" + }) + ); + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ + connection: "google-oauth2", + tokenSet: expect.objectContaining({ accessToken: "my_account_token" }) + }), + undefined + ); + }); + + it("threads a NextRequest through session resolution and to the auth client", async () => { + const getSessionFromAuthClient = vi + .spyOn(client as any, "getSessionFromAuthClient") + .mockResolvedValue(sessionWith()); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + const redirect = NextResponse.redirect("https://test.auth0.com/connect"); + const connect = vi.fn().mockResolvedValue([null, redirect]); + mockAuthClientWith(connect); + + const req = new NextRequest("https://myapp.test/api/connect"); + + await client.connectAccount({ connection: "google-oauth2" }, req); + + expect(getSessionFromAuthClient).toHaveBeenCalledWith( + expect.anything(), + req + ); + // The request is forwarded so appBaseUrl can be resolved dynamically. + expect(connect).toHaveBeenCalledWith(expect.anything(), req); + }); + + it("propagates the error from the auth client", async () => { + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + sessionWith() + ); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + const connectError = new Error("connect failed"); + mockAuthClientWith(vi.fn().mockResolvedValue([connectError, null])); + + await expect( + client.connectAccount({ connection: "google-oauth2" }) + ).rejects.toThrow("connect failed"); + }); + }); + + describe("disconnectAccount", () => { + const sessionWith = ( + connectionTokenSets: SessionData["connectionTokenSets"] + ): SessionData => ({ + user: { sub: "user123" }, + tokenSet: { + accessToken: "access_token", + idToken: "id_token", + refreshToken: "refresh_token", + expiresAt: Date.now() / 1000 + 3600 + }, + internal: { + sid: "mock_sid", + createdAt: Date.now() / 1000 + }, + connectionTokenSets + }); + + let client: Auth0Client; + + beforeEach(() => { + process.env[ENV_VARS.DOMAIN] = "test.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "test_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test"; + process.env[ENV_VARS.SECRET] = "test_secret"; + + client = new Auth0Client(); + }); + + function mockAuthClientWith( + disconnectAccount: ReturnType, + issuer = "https://test.auth0.com/" + ) { + const mockAuthClient = { issuer, disconnectAccount }; + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue( + mockAuthClient + ); + return mockAuthClient; + } + + it("throws DisconnectAccountError when there is no session", async () => { + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + null + ); + const disconnect = vi.fn(); + mockAuthClientWith(disconnect); + + await expect( + client.disconnectAccount({ connection: "google-oauth2" }) + ).rejects.toMatchObject({ + code: "missing_session" + }); + expect(disconnect).not.toHaveBeenCalled(); + }); + + it("mints a My Account token, disconnects, and prunes cached tokens", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, + { connection: "github", accessToken: "fc_gh", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + const getAccessToken = vi + .spyOn(client as any, "getAccessToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + const disconnect = vi.fn().mockResolvedValue([null, []]); + mockAuthClientWith(disconnect); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + // Correct My Account audience + scopes were requested. + expect(getAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + audience: "https://test.auth0.com/me/", + scope: "read:me:connected_accounts delete:me:connected_accounts" + }) + ); + // The connection name (not id) was passed to the auth client. + expect(disconnect).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "my_account_token" }), + "google-oauth2" + ); + // Only the disconnected connection was pruned; github remains. + // (App Router path: req/res are undefined trailing args.) + expect(saveToSession).toHaveBeenCalledWith( + expect.objectContaining({ + connectionTokenSets: [ + expect.objectContaining({ connection: "github" }) + ] + }), + undefined, + undefined + ); + }); + + it("omits connectionTokenSets when the last account is removed", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + const savedSession = saveToSession.mock.calls[0][0] as SessionData; + expect(savedSession.connectionTokenSets).toBeUndefined(); + }); + + it("does not save the session when no cached token matches", async () => { + const session = sessionWith([ + { connection: "github", accessToken: "fc_gh", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + expect(saveToSession).not.toHaveBeenCalled(); + }); + + it("propagates the error from the auth client and does not save", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + const disconnectError = new Error("delete failed"); + mockAuthClientWith(vi.fn().mockResolvedValue([disconnectError, null])); + + await expect( + client.disconnectAccount({ connection: "google-oauth2" }) + ).rejects.toThrow("delete failed"); + expect(saveToSession).not.toHaveBeenCalled(); + }); + + it("Pages Router: threads req/res through session read, token mint, and save", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, + { connection: "github", accessToken: "fc_gh", expiresAt: 999 } + ]); + const getSessionFromAuthClient = vi + .spyOn(client as any, "getSessionFromAuthClient") + .mockResolvedValue(session); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + const getAccessToken = vi + .spyOn(client as any, "getAccessToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); + + const req = { headers: { cookie: "" } } as any; + const res = { setHeader: vi.fn(), appendHeader: vi.fn() } as any; + + await client.disconnectAccount({ connection: "google-oauth2" }, req, res); + + // The session is resolved from the request context. + expect(getSessionFromAuthClient).toHaveBeenCalledWith( + expect.anything(), + req + ); + // The My Account token is minted through the Pages Router + // getAccessToken(req, res, options) overload so refreshed tokens persist. + expect(getAccessToken).toHaveBeenCalledWith( + req, + res, + expect.objectContaining({ + scope: "read:me:connected_accounts delete:me:connected_accounts" + }) + ); + // The pruned session is written back to the Pages Router response. + expect(saveToSession).toHaveBeenCalledWith( + expect.objectContaining({ + connectionTokenSets: [ + expect.objectContaining({ connection: "github" }) + ] + }), + req, + res + ); + }); + }); + + describe("getConnectedAccounts", () => { + const sessionWith = ( + connectionTokenSets: SessionData["connectionTokenSets"] + ): SessionData => ({ + user: { sub: "user123" }, + tokenSet: { + accessToken: "access_token", + idToken: "id_token", + refreshToken: "refresh_token", + expiresAt: Date.now() / 1000 + 3600 + }, + internal: { + sid: "mock_sid", + createdAt: Date.now() / 1000 + }, + connectionTokenSets + }); + + let client: Auth0Client; + + beforeEach(() => { + process.env[ENV_VARS.DOMAIN] = "test.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "test_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test"; + process.env[ENV_VARS.SECRET] = "test_secret"; + + client = new Auth0Client(); + }); + + function mockAuthClientWith( + listConnectedAccounts: ReturnType, + issuer = "https://test.auth0.com/" + ) { + const mockAuthClient = { issuer, listConnectedAccounts }; + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue( + mockAuthClient + ); + return mockAuthClient; + } + + it("throws DisconnectAccountError when there is no session", async () => { + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + null + ); + const list = vi.fn(); + mockAuthClientWith(list); + + await expect(client.getConnectedAccounts()).rejects.toMatchObject({ + code: "missing_session" + }); + expect(list).not.toHaveBeenCalled(); + }); + + it("returns the accounts and requests the read scope", async () => { + const session = sessionWith(undefined); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + vi.spyOn(client as any, "saveToSession").mockResolvedValue(undefined); + const getAccessToken = vi + .spyOn(client as any, "getAccessToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + const accounts = [ + { id: "cac_1", connection: "google-oauth2" }, + { id: "cac_2", connection: "github" } + ]; + mockAuthClientWith(vi.fn().mockResolvedValue([null, accounts])); + + const result = await client.getConnectedAccounts(); + + expect(result).toEqual(accounts); + expect(getAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + audience: "https://test.auth0.com/me/", + scope: "read:me:connected_accounts" + }) + ); + }); + + it("prunes cached tokens whose connection is no longer present server-side", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, + { connection: "slack", accessToken: "fc_s", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + // Server only knows about google-oauth2; slack was disconnected elsewhere. + mockAuthClientWith( + vi + .fn() + .mockResolvedValue([ + null, + [{ id: "cac_1", connection: "google-oauth2" }] + ]) + ); + + await client.getConnectedAccounts(); + + expect(saveToSession).toHaveBeenCalledWith( + expect.objectContaining({ + connectionTokenSets: [ + expect.objectContaining({ connection: "google-oauth2" }) + ] + }), + undefined, + undefined + ); + }); + + it("does not save the session when nothing is stale", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + mockAuthClientWith( + vi + .fn() + .mockResolvedValue([ + null, + [{ id: "cac_1", connection: "google-oauth2" }] + ]) + ); + + await client.getConnectedAccounts(); + + expect(saveToSession).not.toHaveBeenCalled(); + }); + + it("propagates the error from the auth client", async () => { + const session = sessionWith(undefined); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + const listError = new Error("list failed"); + mockAuthClientWith(vi.fn().mockResolvedValue([listError, null])); + + await expect(client.getConnectedAccounts()).rejects.toThrow( + "list failed" + ); + }); + + it("Pages Router: threads req/res through session read, token mint, and reconcile save", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, + { connection: "slack", accessToken: "fc_s", expiresAt: 999 } + ]); + const getSessionFromAuthClient = vi + .spyOn(client as any, "getSessionFromAuthClient") + .mockResolvedValue(session); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + const getAccessToken = vi + .spyOn(client as any, "getAccessToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + // Server only knows about google-oauth2; slack was disconnected elsewhere. + mockAuthClientWith( + vi + .fn() + .mockResolvedValue([ + null, + [{ id: "cac_1", connection: "google-oauth2" }] + ]) + ); + + const req = { headers: { cookie: "" } } as any; + const res = { setHeader: vi.fn(), appendHeader: vi.fn() } as any; + + const result = await client.getConnectedAccounts(req, res); + + expect(result).toEqual([{ id: "cac_1", connection: "google-oauth2" }]); + // The session is resolved from the request context. + expect(getSessionFromAuthClient).toHaveBeenCalledWith( + expect.anything(), + req + ); + // The My Account token is minted through the Pages Router + // getAccessToken(req, res, options) overload. + expect(getAccessToken).toHaveBeenCalledWith( + req, + res, + expect.objectContaining({ scope: "read:me:connected_accounts" }) + ); + // The reconciled (pruned) session is written back to the Pages Router response. + expect(saveToSession).toHaveBeenCalledWith( + expect.objectContaining({ + connectionTokenSets: [ + expect.objectContaining({ connection: "google-oauth2" }) + ] + }), + req, + res + ); + }); + }); + + // Regression coverage for gh-2450: disconnecting / reconciling connected + // accounts must actually shrink the cookie jar (emit `Set-Cookie` deletions + // for orphaned `__FC_i` cookies), otherwise the stale connection-token + // cookies accumulate and eventually trip an HTTP 431 (Request Header Fields + // Too Large). These tests exercise the real StatelessSessionStore end-to-end, + // asserting on the emitted Set-Cookie headers rather than mocking saveToSession. + describe("connected-account cookie reclamation (gh-2450)", () => { + // `ResponseCookies` dedupes headers by name in place, so `getSetCookie()` + // reflects the *final* state of each cookie (one header per name). A cookie + // is considered deleted when its final header has an empty value and + // `Max-Age=0` (how `deleteCookie` reclaims it); otherwise it is a live + // rewrite. Returns a map of cookie name -> { deleted, value }. + function finalCookieState( + headers: Headers + ): Map { + const state = new Map(); + for (const raw of headers.getSetCookie()) { + const [pair, ...attrs] = raw.split(";").map((s) => s.trim()); + const eq = pair.indexOf("="); + const name = pair.slice(0, eq); + const value = pair.slice(eq + 1); + const maxAge0 = attrs.some((a) => a.toLowerCase() === "max-age=0"); + state.set(name, { deleted: value === "" && maxAge0, value }); + } + return state; + } + + // Names of `__FC_i` cookies that ended up deleted (reclaimed). + function deletedConnectionCookies(headers: Headers): string[] { + return [...finalCookieState(headers).entries()] + .filter(([name, s]) => name.startsWith("__FC") && s.deleted) + .map(([name]) => name); + } + + // Names of `__FC_i` cookies that ended up rewritten with a live value. + function rewrittenConnectionCookies(headers: Headers): string[] { + return [...finalCookieState(headers).entries()] + .filter(([name, s]) => name.startsWith("__FC") && !s.deleted) + .map(([name]) => name); + } + + const sessionWith = ( + connectionTokenSets: SessionData["connectionTokenSets"] + ): SessionData => ({ + user: { sub: "user123" }, + tokenSet: { + accessToken: "access_token", + idToken: "id_token", + refreshToken: "refresh_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600 + }, + internal: { + sid: "mock_sid", + createdAt: Math.floor(Date.now() / 1000) + }, + connectionTokenSets + }); + + let client: Auth0Client; + + beforeEach(() => { + process.env[ENV_VARS.DOMAIN] = "test.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "test_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test"; + process.env[ENV_VARS.SECRET] = "test_secret"; + + client = new Auth0Client(); + }); + + // Points the mocked `cookies()` (used by the app-router saveToSession path) + // at a real ResponseCookies jar seeded with the given connection cookies. + // The app-router path passes the same jar as both request and response + // cookies, so seeds written here are both readable (via getAll) by the + // store's cleanup loop and observable as emitted Set-Cookie headers. + // Returns the underlying Headers; since ResponseCookies dedupes by name, + // the final header per `__FC_i` reflects whether the store rewrote or + // deleted it (see finalCookieState). + function seedAppRouterCookies(connectionCount: number): Headers { + const headers = new Headers(); + const jar = new ResponseCookies(headers); + for (let i = 0; i < connectionCount; i++) { + jar.set(`__FC_${i}`, `seed_fc_${i}`); + } + // A session cookie is present in any real request; its exact value is + // irrelevant here since getSession is mocked. + jar.set("__session", "seed_session"); + vi.mocked(nextCookies).mockResolvedValue(jar as any); + return headers; + } + + it("emits Set-Cookie deletions for orphaned __FC cookies on disconnect", async () => { + // Session has three connected accounts; we disconnect the middle one. + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, + { connection: "github", accessToken: "fc_gh", expiresAt: 999 }, + { connection: "slack", accessToken: "fc_s", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + audience: "https://test.auth0.com/me/" + }); + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue({ + issuer: "https://test.auth0.com/", + disconnectAccount: vi.fn().mockResolvedValue([null, []]) + }); + + const headers = seedAppRouterCookies(3); + + await client.disconnectAccount({ connection: "github" }); + + // After disconnect, two accounts remain -> __FC_0, __FC_1 are rewritten + // and __FC_2 must be deleted so the cookie jar shrinks. + const deletedNames = deletedConnectionCookies(headers); + const rewrittenNames = rewrittenConnectionCookies(headers); + expect(deletedNames).toContain("__FC_2"); + expect(rewrittenNames).toContain("__FC_0"); + expect(rewrittenNames).toContain("__FC_1"); + expect(deletedNames).not.toContain("__FC_0"); + expect(deletedNames).not.toContain("__FC_1"); + }); + + it("emits Set-Cookie deletions for every __FC cookie when the last account is disconnected", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + audience: "https://test.auth0.com/me/" + }); + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue({ + issuer: "https://test.auth0.com/", + disconnectAccount: vi.fn().mockResolvedValue([null, []]) + }); + + const headers = seedAppRouterCookies(1); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + // No accounts remain -> __FC_0 must be deleted. + expect(deletedConnectionCookies(headers)).toContain("__FC_0"); + expect(rewrittenConnectionCookies(headers)).toEqual([]); + }); + + it("emits Set-Cookie deletions for stale __FC cookies during getConnectedAccounts reconciliation", async () => { + // Two accounts cached locally, but the server only knows about one. + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, + { connection: "slack", accessToken: "fc_s", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + audience: "https://test.auth0.com/me/" + }); + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue({ + issuer: "https://test.auth0.com/", + listConnectedAccounts: vi + .fn() + .mockResolvedValue([ + null, + [{ id: "cac_1", connection: "google-oauth2" }] + ]) + }); + + const headers = seedAppRouterCookies(2); + + await client.getConnectedAccounts(); + + // slack was pruned -> one account remains -> __FC_1 must be deleted, + // __FC_0 rewritten. + const deletedNames = deletedConnectionCookies(headers); + expect(deletedNames).toContain("__FC_1"); + expect(deletedNames).not.toContain("__FC_0"); + expect(rewrittenConnectionCookies(headers)).toContain("__FC_0"); + }); + + it("does not delete any __FC cookies when nothing was disconnected", async () => { + const session = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 } + ]); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + audience: "https://test.auth0.com/me/" + }); + // Disconnect a connection the user does not have -> no local change. + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue({ + issuer: "https://test.auth0.com/", + disconnectAccount: vi.fn().mockResolvedValue([null, []]) + }); + + const headers = seedAppRouterCookies(1); + + await client.disconnectAccount({ connection: "not-connected" }); + + // saveToSession is skipped entirely, so no __FC deletions are emitted. + expect(deletedConnectionCookies(headers)).toEqual([]); + }); + }); + describe("revokeRefreshToken", () => { let client: Auth0Client; diff --git a/src/server/client.ts b/src/server/client.ts index 7e3b3c848..0339a91f5 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -11,6 +11,8 @@ import { AccessTokenForConnectionErrorCode, ConnectAccountError, ConnectAccountErrorCodes, + DisconnectAccountError, + DisconnectAccountErrorCodes, InvalidConfigurationError, MfaRequiredError, TokenRevocationError, @@ -22,8 +24,10 @@ import { AuthorizationParameters, BackchannelAuthenticationOptions, ConnectAccountOptions, + ConnectedAccount, CustomTokenExchangeOptions, CustomTokenExchangeResponse, + DisconnectAccountOptions, GetAccessTokenOptions, LogoutStrategy, SessionData, @@ -1143,9 +1147,14 @@ export class Auth0Client { ); } - // Find the connection token set in the session + // Find the connection token set in the session. When a login hint is + // provided, match on it too so multiple accounts can be connected for the + // same connection. Without a login hint, match on connection alone + // (preserving existing behaviour). const existingTokenSet = session.connectionTokenSets?.find( - (tokenSet) => tokenSet.connection === options.connection + (tokenSet) => + tokenSet.connection === options.connection && + (!options.login_hint || tokenSet.loginHint === options.login_hint) ); const [error, retrievedTokenSet] = await authClient.getConnectionTokenSet( @@ -1175,7 +1184,8 @@ export class Auth0Client { // If not, we need to add it. if (existingTokenSet) { tokenSets = session.connectionTokenSets?.map((tokenSet) => - tokenSet.connection === options.connection + tokenSet.connection === options.connection && + (!options.login_hint || tokenSet.loginHint === options.login_hint) ? retrievedTokenSet : tokenSet ); @@ -1702,6 +1712,23 @@ export class Auth0Client { return { authClient }; } + /** + * Mints a My Account API access token, dispatching to the correct + * `getAccessToken` overload so the refreshed token set is persisted in both + * the App Router (no req/res) and Pages Router / middleware (req + res) paths. + * @internal + */ + private async getMyAccountAccessToken( + options: GetAccessTokenOptions & { audience: string; scope: string }, + req?: NextRequest | PagesRouterRequest, + res?: PagesRouterResponse | NextResponse + ): Promise<{ token: string; expiresAt: number; audience?: string }> { + if (req && res) { + return this.getAccessToken(req, res, options); + } + return this.getAccessToken(options); + } + async startInteractiveLogin( options: StartInteractiveLoginOptions = {} ): Promise { @@ -1732,6 +1759,26 @@ export class Auth0Client { return response; } + /** + * Initiates the Connect Account flow to connect a third-party account to the user's profile. + * + * This method can be used in Server Actions and Route Handlers in the **App Router**. + */ + async connectAccount(options: ConnectAccountOptions): Promise; + + /** + * Initiates the Connect Account flow to connect a third-party account to the user's profile. + * + * Pass the `req` object when calling from middleware, or when `APP_BASE_URL` + * is configured dynamically (as an array of allowed origins), so the redirect + * and session are resolved from the request context. The returned + * `NextResponse` carries the redirect and the transaction cookies. + */ + async connectAccount( + options: ConnectAccountOptions, + req: NextRequest | Request + ): Promise; + /** * Initiates the Connect Account flow to connect a third-party account to the user's profile. * If the user does not have an active session, a `ConnectAccountError` is thrown. @@ -1741,13 +1788,26 @@ export class Auth0Client { * * The user will then be redirected to authorize the connection with the third-party provider. * + * Pass the `req` object when calling from middleware or when `APP_BASE_URL` is + * configured dynamically (as an array of allowed origins), so the redirect and + * session are resolved from the request context. In App Router Server Actions + * and Route Handlers with a static `APP_BASE_URL`, omit it. Because this + * returns a redirect `NextResponse` (rather than writing to a passed-in + * response), a Pages Router `ServerResponse` is not accepted here; forward the + * returned response's `Location` and `Set-Cookie` headers onto your response. + * * You must enable `Offline Access` from the Connection Permissions settings to be able to use the connection with Connected Accounts. */ - async connectAccount(options: ConnectAccountOptions): Promise { - const reqHeaders = await getHeaders(); - const authClient = await this.provider.forRequest(reqHeaders, undefined); + async connectAccount( + options: ConnectAccountOptions, + req?: NextRequest | Request + ): Promise { + const { authClient, normalizedReq } = await this.resolveRequestContext(req); - const session = await this.getSession(); + const session = await this.getSessionFromAuthClient( + authClient, + normalizedReq + ); if (!session) { throw new ConnectAccountError({ @@ -1766,21 +1826,230 @@ export class Auth0Client { const accessToken = await this.getAccessToken(getMyAccountTokenOpts); - const [error, connectAccountResponse] = await authClient.connectAccount({ - ...options, - tokenSet: { + const [error, connectAccountResponse] = await authClient.connectAccount( + { + ...options, + tokenSet: { + accessToken: accessToken.token, + expiresAt: accessToken.expiresAt, + scope: getMyAccountTokenOpts.scope, + audience: accessToken.audience + } + }, + normalizedReq instanceof NextRequest ? normalizedReq : undefined + ); + + if (error) { + throw error; + } + + return connectAccountResponse; + } + + /** + * Disconnects (unlinks) all connected accounts for the given connection. + * + * This method can be used in Server Actions and Route Handlers in the **App Router**. + */ + async disconnectAccount(options: DisconnectAccountOptions): Promise; + + /** + * Disconnects (unlinks) all connected accounts for the given connection. + * + * This method can be used in middleware and API routes in the **Pages Router**. + */ + async disconnectAccount( + options: DisconnectAccountOptions, + req: PagesRouterRequest | NextRequest | Request, + res: PagesRouterResponse | NextResponse + ): Promise; + + /** + * Disconnects (unlinks) all connected accounts for the given connection. + * + * This revokes the connection server-side via the My Account API and removes + * the corresponding cached connection tokens from the session so they are not + * re-assembled on subsequent reads. + * + * If the user does not have an active session, a `DisconnectAccountError` is thrown. + * + * In the Pages Router (or middleware), pass the `req` and `res` objects so the + * pruned session can be persisted to the response cookies. In App Router Server + * Actions and Route Handlers, omit them. + * + * Note: disconnect is connection-scoped. All accounts connected through the + * given connection are disconnected. Per-account disconnect is not currently + * supported because the My Account API keys connected accounts by id and does + * not expose the login hint used to disambiguate multiple accounts on the same + * connection. + */ + async disconnectAccount( + options: DisconnectAccountOptions, + req?: PagesRouterRequest | NextRequest | Request, + res?: PagesRouterResponse | NextResponse + ): Promise { + const { authClient, normalizedReq } = await this.resolveRequestContext(req); + + const session = await this.getSessionFromAuthClient( + authClient, + normalizedReq + ); + + if (!session) { + throw new DisconnectAccountError({ + code: DisconnectAccountErrorCodes.MISSING_SESSION, + message: "The user does not have an active session." + }); + } + + // Use the full issuer URL from authClient (including any path component for + // providers like Okta custom authorization servers, e.g. + // https://myorg.okta.com/oauth2/default/) so the audience is correct. + const getMyAccountTokenOpts = { + audience: `${authClient.issuer}me/`, + scope: "read:me:connected_accounts delete:me:connected_accounts" + }; + + const accessToken = await this.getMyAccountAccessToken( + getMyAccountTokenOpts, + normalizedReq, + res + ); + + const [error] = await authClient.disconnectAccount( + { accessToken: accessToken.token, expiresAt: accessToken.expiresAt, scope: getMyAccountTokenOpts.scope, audience: accessToken.audience + }, + options.connection + ); + + if (error) { + throw error; + } + + // Remove the cached connection tokens for this connection so they are not + // re-assembled into the session on the next read. When no entries remain, + // omit the property entirely (mirroring how the store persists it). + if (session.connectionTokenSets?.length) { + const remaining = session.connectionTokenSets.filter( + (tokenSet) => tokenSet.connection !== options.connection + ); + + if (remaining.length !== session.connectionTokenSets.length) { + const { connectionTokenSets: _removed, ...rest } = session; + await this.saveToSession( + remaining.length ? { ...rest, connectionTokenSets: remaining } : rest, + normalizedReq, + res + ); } + } + } + + /** + * Lists the connected accounts for the current user from the My Account API. + * + * This method can be used in Server Actions and Route Handlers in the **App Router**. + */ + async getConnectedAccounts(): Promise; + + /** + * Lists the connected accounts for the current user from the My Account API. + * + * This method can be used in middleware and API routes in the **Pages Router**. + */ + async getConnectedAccounts( + req: PagesRouterRequest | NextRequest | Request, + res: PagesRouterResponse | NextResponse + ): Promise; + + /** + * Lists the connected accounts for the current user from the My Account API. + * + * The My Account API is the source of truth, so this also reconciles the + * session: any locally cached connection tokens (`connectionTokenSets`) whose + * connection is no longer present server-side are pruned, so they are not + * re-assembled into the session on subsequent reads. Because this reconciles + * (and may write cookies), call it from a context that can set cookies. + * + * In the Pages Router (or middleware), pass the `req` and `res` objects so the + * reconciled session can be persisted to the response cookies. In App Router + * Route Handlers and Server Actions, omit them (a Server Component cannot set + * cookies, so reconciliation there will not be persisted). + * + * If the user does not have an active session, a `DisconnectAccountError` is thrown. + * + * Note: reconciliation is connection-scoped. If a user has multiple accounts + * on the same connection and only some are disconnected server-side, the + * connection still appears in the list, so the local tokens are retained. This + * mirrors the connection-scoped behaviour of {@link disconnectAccount}. + */ + async getConnectedAccounts( + req?: PagesRouterRequest | NextRequest | Request, + res?: PagesRouterResponse | NextResponse + ): Promise { + const { authClient, normalizedReq } = await this.resolveRequestContext(req); + + const session = await this.getSessionFromAuthClient( + authClient, + normalizedReq + ); + + if (!session) { + throw new DisconnectAccountError({ + code: DisconnectAccountErrorCodes.MISSING_SESSION, + message: "The user does not have an active session." + }); + } + + // Use the full issuer URL from authClient (including any path component for + // providers like Okta custom authorization servers) so the audience is correct. + const getMyAccountTokenOpts = { + audience: `${authClient.issuer}me/`, + scope: "read:me:connected_accounts" + }; + + const accessToken = await this.getMyAccountAccessToken( + getMyAccountTokenOpts, + normalizedReq, + res + ); + + const [error, accounts] = await authClient.listConnectedAccounts({ + accessToken: accessToken.token, + expiresAt: accessToken.expiresAt, + scope: getMyAccountTokenOpts.scope, + audience: accessToken.audience }); if (error) { throw error; } - return connectAccountResponse; + // Reconcile: drop cached connection tokens whose connection is no longer + // present server-side. + if (session.connectionTokenSets?.length) { + const serverConnections = new Set( + accounts.map((account) => account.connection) + ); + const remaining = session.connectionTokenSets.filter((tokenSet) => + serverConnections.has(tokenSet.connection) + ); + + if (remaining.length !== session.connectionTokenSets.length) { + const { connectionTokenSets: _removed, ...rest } = session; + await this.saveToSession( + remaining.length ? { ...rest, connectionTokenSets: remaining } : rest, + normalizedReq, + res + ); + } + } + + return accounts; } // Pages Router overload - no arguments diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 52586042f..70184b1af 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -949,6 +949,181 @@ describe("Stateless Session Store", async () => { } ); }); + + describe("__FC connection-token orphan cleanup", async () => { + const baseSession = ( + connectionTokenSets: SessionData["connectionTokenSets"] + ): SessionData => ({ + user: { sub: "user_123" }, + tokenSet: { + accessToken: "at_123", + refreshToken: "rt_123", + expiresAt: 123456 + }, + internal: { + sid: "auth0-sid", + createdAt: Math.floor(Date.now() / 1000) + }, + connectionTokenSets + }); + + // Seeds `count` __FC_i cookies on the request, as a prior write would have. + async function seedConnectionCookies( + store: StatelessSessionStore, + requestCookies: RequestCookies, + responseCookies: ResponseCookies, + connections: string[] + ) { + await store.set( + requestCookies, + responseCookies, + baseSession( + connections.map((connection) => ({ + connection, + accessToken: `at_${connection}`, + expiresAt: 999999 + })) + ) + ); + } + + // A `__FC` cookie is "deleted" via `resCookies.set(name, "", {maxAge:0})`, + // so a deletion is a `set` call with an empty value and `maxAge` 0. + function deletedCookieNames(setSpy: { + mock: { calls: unknown[][] }; + }): string[] { + return setSpy.mock.calls + .filter( + (call) => + call[1] === "" && + typeof call[2] === "object" && + call[2] !== null && + (call[2] as { maxAge?: number }).maxAge === 0 + ) + .map((call) => call[0] as string); + } + + it("deletes trailing __FC cookies when the array shrinks", async () => { + const secret = await generateSecret(32); + const store = new StatelessSessionStore({ secret }); + const requestCookies = new RequestCookies(new Headers()); + const responseCookies = new ResponseCookies(new Headers()); + + // Start with three connections -> __FC_0, __FC_1, __FC_2 + await seedConnectionCookies(store, requestCookies, responseCookies, [ + "google-oauth2", + "github", + "slack" + ]); + expect(requestCookies.get("__FC_0")).toBeDefined(); + expect(requestCookies.get("__FC_1")).toBeDefined(); + expect(requestCookies.get("__FC_2")).toBeDefined(); + + // Now write a session with a single connection. + const setSpy = vi.spyOn(responseCookies, "set"); + await store.set( + requestCookies, + responseCookies, + baseSession([ + { + connection: "google-oauth2", + accessToken: "at_google-oauth2", + expiresAt: 999999 + } + ]) + ); + + // __FC_1 and __FC_2 must be deleted; __FC_0 stays (overwritten). + const deletedNames = deletedCookieNames(setSpy); + expect(deletedNames).toContain("__FC_1"); + expect(deletedNames).toContain("__FC_2"); + expect(deletedNames).not.toContain("__FC_0"); + }); + + it("does not delete any __FC cookies when the array grows", async () => { + const secret = await generateSecret(32); + const store = new StatelessSessionStore({ secret }); + const requestCookies = new RequestCookies(new Headers()); + const responseCookies = new ResponseCookies(new Headers()); + + await seedConnectionCookies(store, requestCookies, responseCookies, [ + "google-oauth2" + ]); + + const setSpy = vi.spyOn(responseCookies, "set"); + await store.set( + requestCookies, + responseCookies, + baseSession([ + { + connection: "google-oauth2", + accessToken: "at_google-oauth2", + expiresAt: 999999 + }, + { + connection: "github", + accessToken: "at_github", + expiresAt: 999999 + } + ]) + ); + + const deletedFcNames = deletedCookieNames(setSpy).filter((name) => + name.startsWith("__FC") + ); + expect(deletedFcNames).toEqual([]); + }); + + it("deletes all __FC cookies when the array becomes empty", async () => { + const secret = await generateSecret(32); + const store = new StatelessSessionStore({ secret }); + const requestCookies = new RequestCookies(new Headers()); + const responseCookies = new ResponseCookies(new Headers()); + + await seedConnectionCookies(store, requestCookies, responseCookies, [ + "google-oauth2", + "github" + ]); + + const setSpy = vi.spyOn(responseCookies, "set"); + await store.set( + requestCookies, + responseCookies, + baseSession(undefined) + ); + + const deletedNames = deletedCookieNames(setSpy); + expect(deletedNames).toContain("__FC_0"); + expect(deletedNames).toContain("__FC_1"); + }); + + it("leaves non-indexed __FC-prefixed cookies untouched", async () => { + const secret = await generateSecret(32); + const store = new StatelessSessionStore({ secret }); + + // A legacy/foreign cookie that does not match the `__FC_` shape. + const headers = new Headers(); + headers.append("cookie", "__FCcustom=preserve-me"); + const requestCookies = new RequestCookies(headers); + const responseCookies = new ResponseCookies(new Headers()); + + const setSpy = vi.spyOn(responseCookies, "set"); + await store.set( + requestCookies, + responseCookies, + baseSession([ + { + connection: "google-oauth2", + accessToken: "at_google-oauth2", + expiresAt: 999999 + } + ]) + ); + + const deletedNames = deletedCookieNames(setSpy); + expect(deletedNames).not.toContain("__FCcustom"); + }); + }); }); describe("delete", async () => { diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index e8c0a5077..a17515788 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -126,9 +126,10 @@ export class StatelessSessionStore extends AbstractSessionStore { ); // Store connection access tokens, each in its own cookie - if (connectionTokenSets?.length) { + const connectionTokenSetCount = connectionTokenSets?.length ?? 0; + if (connectionTokenSetCount) { await Promise.all( - connectionTokenSets.map((connectionTokenSet, index) => + connectionTokenSets!.map((connectionTokenSet, index) => this.storeInCookie( reqCookies, resCookies, @@ -140,6 +141,26 @@ export class StatelessSessionStore extends AbstractSessionStore { ); } + // The connection token cookies are indexed positionally (`__FC_0..n-1`). If + // the array has shrunk since it was last written (e.g. an account was + // disconnected), the trailing higher-index cookies would otherwise linger as + // orphans and be re-assembled into the session on the next read. Delete any + // `__FC_i` present in the request whose index is beyond the current length. + for (const cookie of this.getConnectionTokenSetsCookies(reqCookies)) { + const index = this.parseConnectionTokenSetCookieIndex(cookie.name); + // Only reconcile cookies this store wrote (`__FC_`). Any other + // `__FC`-prefixed cookie is left untouched. + if (index !== null && index >= connectionTokenSetCount) { + cookies.deleteCookie(resCookies, cookie.name, { + domain: this.cookieConfig.domain, + path: this.cookieConfig.path, + secure: this.cookieConfig.secure, + sameSite: this.cookieConfig.sameSite, + httpOnly: this.cookieConfig.httpOnly + }); + } + } + // Any existing v3 cookie can be deleted as soon as we have set a v4 cookie. // In stateless sessions, we do have to ensure we delete all chunks. // Only delete legacy cookies if they actually exist in the request. @@ -254,4 +275,23 @@ export class StatelessSessionStore extends AbstractSessionStore { cookie.name.startsWith(this.connectionTokenSetsCookieName) ); } + + /** + * Parses the positional index out of a connection token set cookie name + * (e.g. `__FC_2` -> `2`). Returns `null` when the name does not match the + * expected `_` shape. + */ + private parseConnectionTokenSetCookieIndex( + cookieName: string + ): number | null { + const prefix = `${this.connectionTokenSetsCookieName}_`; + if (!cookieName.startsWith(prefix)) { + return null; + } + const suffix = cookieName.slice(prefix.length); + if (!/^\d+$/.test(suffix)) { + return null; + } + return Number(suffix); + } } diff --git a/src/types/connected-accounts.ts b/src/types/connected-accounts.ts index 8eaae85bd..43a9bf6ec 100644 --- a/src/types/connected-accounts.ts +++ b/src/types/connected-accounts.ts @@ -140,3 +140,55 @@ export interface CompleteConnectAccountResponse { */ expiresAt?: string; } + +/** + * Options to disconnect (unlink) a connected account using the My Account API. + * @see https://auth0.com/docs/api/myaccount/connected-accounts/delete-connected-account + */ +export interface DisconnectAccountOptions { + /** + * The name of the connection to disconnect (e.g., 'google-oauth2', 'facebook'). + * + * All connected accounts for this connection are disconnected. Per-account + * disconnect is not currently supported because the My Account API keys + * connected accounts by id and does not expose the login hint used to + * disambiguate multiple accounts on the same connection. + */ + connection: string; +} + +/** + * A connected account as returned by the My Account API list endpoint. + * @see https://auth0.com/docs/api/myaccount/connected-accounts/get-connected-accounts + */ +export interface ConnectedAccount { + /** + * The unique identifier of the connected account (e.g., 'cac_...'). + */ + id: string; + /** + * The name of the connection associated with the connected account. + */ + connection: string; + /** + * The access type, always 'offline'. + */ + accessType?: string; + /** + * Array of scopes granted for this connected account. + */ + scopes?: string[]; + /** + * ISO date string of when the connected account was created. + */ + createdAt?: string; + /** + * ISO date string of when the connected account expires (optional). + */ + expiresAt?: string; + /** + * The organization ID this connected account is scoped to. Only present for + * accounts bound to an organization. + */ + orgId?: string; +} diff --git a/src/types/index.ts b/src/types/index.ts index 12f9cc7ee..ee71a7d67 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -242,7 +242,12 @@ export { SUBJECT_TOKEN_TYPES, TOKEN_TYPES } from "./token-vault.js"; -export { ConnectAccountOptions, RESPONSE_TYPES } from "./connected-accounts.js"; +export { + ConnectAccountOptions, + ConnectedAccount, + DisconnectAccountOptions, + RESPONSE_TYPES +} from "./connected-accounts.js"; export type { ChallengeWithPopupOptions } from "../client/mfa/index.js"; export type { AccessTokenResponse } from "../client/helpers/get-access-token.js"; export type { AuthCompleteMessage } from "../utils/popup-helpers.js"; diff --git a/src/types/token-vault.ts b/src/types/token-vault.ts index 522baec70..b537017bd 100644 --- a/src/types/token-vault.ts +++ b/src/types/token-vault.ts @@ -70,6 +70,9 @@ export interface ConnectionTokenSet { scope?: string; expiresAt: number; // the time at which the access token expires in seconds since epoch connection: string; + // The login hint used to obtain this token, if any. Allows multiple accounts + // to be connected for the same connection (disambiguated by login hint). + loginHint?: string; [key: string]: unknown; } From 0360ffcdae16fdc7f533107f6f728654195aa250 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 29 Jul 2026 19:58:55 +0530 Subject: [PATCH 2/7] fix: clear stale connection-token cookie when refresh-token exchange fails and docs update --- EXAMPLES.md | 168 +++++++++++++++++++++++++++++++++++++- src/server/client.test.ts | 102 +++++++++++++++++++++++ src/server/client.ts | 22 +++++ 3 files changed, 290 insertions(+), 2 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 72aad1a86..b14505880 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -156,6 +156,8 @@ - [Connected Accounts](#connected-accounts) - [`onCallback` hook](#oncallback-hook) - [`connectAccount` method](#connectaccount-method) + - [`getConnectedAccounts` method](#getconnectedaccounts-method) + - [`disconnectAccount` method](#disconnectaccount-method) - [Back-Channel Logout](#back-channel-logout) - [Session Expiry from the Upstream IdP](#session-expiry-from-the-upstream-idp) - [Combining middleware](#combining-middleware) @@ -4277,7 +4279,14 @@ export const auth0 = new Auth0Client({ ### `connectAccount` method -In case you'd like to have more control over the connected accounts flow, a `connectAccount` method is also available on the Auth0 client instance. For example, you could mount a custom route to start the connected accounts flow, like so: +In case you'd like to have more control over the connected accounts flow, a `connectAccount` method is also available on the Auth0 client instance. It accepts an object with the following properties: + +- `connection`: (required) the name of the connection to link the account with (e.g., `google-oauth2`, `facebook`). +- `scopes`: (optional) the scopes to request from the Identity Provider during the connect flow. +- `authorizationParams`: (optional) additional parameters passed to the authorization server. This is where a `login_hint` is supplied to pre-select which upstream account to connect (see below). +- `returnTo`: (optional) the URL to redirect to after the account is connected. + +The method returns a `NextResponse` that carries the redirect and transaction cookies. For example, you could mount a custom route to start the connected accounts flow, like so: ```ts import { auth0 } from "@/lib/auth0"; @@ -4297,9 +4306,144 @@ export async function GET() { } ``` +#### Connecting a specific account with `login_hint` + +To connect a specific upstream account (for example, when a user wants to link more than one account on the same connection), pass a `login_hint` through `authorizationParams`. It is forwarded to the authorization server so the correct account is pre-selected during the connect flow: + +```ts +import { auth0 } from "@/lib/auth0"; + +export async function GET() { + const res = await auth0.connectAccount({ + connection: "google-oauth2", + scopes: ["openid", "profile", "offline_access"], + authorizationParams: { + login_hint: "alice@example.com" + }, + returnTo: "/connected" + }); + + return res; +} +``` + +> [!NOTE] +> The `login_hint` on `connectAccount` (an authorization-request parameter passed via `authorizationParams`) is distinct from the top-level `login_hint` on [`getAccessTokenForConnection`](#getting-access-tokens-for-connections) (a token-exchange parameter). Connecting an account and later retrieving a token for it are separate operations, so the hint is supplied in the place appropriate to each. + +#### Middleware and dynamic base URLs + +When calling from middleware, or when `APP_BASE_URL` is configured dynamically (as an array of allowed origins), pass the `req` object so the redirect and session are resolved from the request context: + +```ts +import { NextRequest } from "next/server"; + +import { auth0 } from "@/lib/auth0"; + +export async function middleware(request: NextRequest) { + const res = await auth0.connectAccount( + { connection: "google-oauth2", returnTo: "/connected" }, + request + ); + + return res; +} +``` + > [!IMPORTANT] > You must enable `Offline Access` from the Connection Permissions settings to be able to use the connection with Connected Accounts. +### `getConnectedAccounts` method + +The `getConnectedAccounts` method lists the current user's connected accounts from the [My Account API](https://auth0.com/docs/manage-users/my-account-api). It returns an array of `ConnectedAccount` objects, each with the following shape: + +- `id`: the unique identifier of the connected account (e.g., `cac_...`). +- `connection`: the name of the connection the account is linked through. +- `accessType`: the access type (typically `offline`). +- `scopes`: the scopes granted for the connected account. +- `createdAt`: ISO date string of when the account was connected. +- `expiresAt`: (optional) ISO date string of when the connected account expires. +- `orgId`: (optional) the organization ID the connected account is scoped to. Only present for accounts bound to an organization. + +Because the My Account API is the source of truth, this method also reconciles the session: any locally cached connection tokens whose connection is no longer present server-side are pruned, so stale tokens are not re-assembled on subsequent reads. As this may write cookies, call it from a context that can set them. + +#### On the server (App Router) + +```ts +import { NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; + +export async function GET() { + const accounts = await auth0.getConnectedAccounts(); + + return NextResponse.json({ accounts }); +} +``` + +> [!IMPORTANT] +> Server Components cannot set cookies. Reconciliation performed while calling `getConnectedAccounts()` in a Server Component will not be persisted. Call it from a Route Handler, Server Action, or middleware if you need the reconciled session to be saved. + +#### On the server (Pages Router) and middleware + +Pass the `req` and `res` objects so the reconciled session can be persisted to the response cookies: + +```ts +import type { NextApiRequest, NextApiResponse } from "next"; + +import { auth0 } from "@/lib/auth0"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const accounts = await auth0.getConnectedAccounts(req, res); + + res.status(200).json({ accounts }); +} +``` + +### `disconnectAccount` method + +The `disconnectAccount` method disconnects (unlinks) connected accounts for a given connection via the [My Account API](https://auth0.com/docs/manage-users/my-account-api). It revokes the connection server-side and removes the corresponding cached connection tokens from the session so they are not re-assembled on subsequent reads. It accepts an object with the following property: + +- `connection`: (required) the name of the connection to disconnect (e.g., `google-oauth2`, `facebook`). + +> [!NOTE] +> Disconnect is connection-scoped: **all** accounts connected through the given connection are disconnected. Per-account disconnect is not currently supported because the My Account API keys connected accounts by `id` and does not expose the login hint used to disambiguate multiple accounts on the same connection. + +#### On the server (App Router) + +```ts +import { NextResponse } from "next/server"; + +import { auth0 } from "@/lib/auth0"; + +export async function POST() { + await auth0.disconnectAccount({ connection: "google-oauth2" }); + + return NextResponse.json({ message: "Disconnected!" }); +} +``` + +#### On the server (Pages Router) and middleware + +Pass the `req` and `res` objects so the pruned session can be persisted to the response cookies: + +```ts +import type { NextApiRequest, NextApiResponse } from "next"; + +import { auth0 } from "@/lib/auth0"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + await auth0.disconnectAccount({ connection: "google-oauth2" }, req, res); + + res.status(200).json({ message: "Disconnected!" }); +} +``` + ## Back-Channel Logout The SDK can be configured to listen to [Back-Channel Logout](https://auth0.com/docs/authenticate/login/logout/back-channel-logout) events. By default, a route will be mounted `/auth/backchannel-logout` which will verify the logout token and call the `deleteByLogoutToken` method of your session store implementation to allow you to remove the session. @@ -4675,7 +4819,27 @@ export const GET = async (req: NextRequest) => { You can retrieve an access token for a connection using the `getAccessTokenForConnection()` method, which accepts an object with the following properties: - `connection`: The federated connection for which an access token should be retrieved. -- `login_hint`: The optional login_hint parameter to pass to the `/authorize` endpoint. +- `login_hint`: (optional) The login hint identifying which connected account to retrieve a token for. Provide it when a user has connected more than one account on the same connection so the correct one is selected; the token is then cached per `connection` + `login_hint`. When omitted, the token is matched and cached by `connection` alone (the default behavior). + +Without a login hint (default, single account per connection): + +```ts +const token = await auth0.getAccessTokenForConnection({ + connection: "google-oauth2" +}); +``` + +With a login hint (to target a specific account among several on the same connection): + +```ts +const token = await auth0.getAccessTokenForConnection({ + connection: "google-oauth2", + login_hint: "alice@example.com" +}); +``` + +> [!NOTE] +> If the underlying refresh-token exchange fails (for example, the upstream refresh token was revoked), the stale cached connection token for that account is cleared from the session before the error is thrown, so it is not left behind on subsequent requests. ### On the server (App Router) diff --git a/src/server/client.test.ts b/src/server/client.test.ts index 5bb7f5454..38d35e7a2 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -4,6 +4,8 @@ import { ResponseCookies } from "@edge-runtime/cookies"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + AccessTokenForConnectionError, + AccessTokenForConnectionErrorCode, DomainResolutionError, InvalidConfigurationError, TokenRevocationError, @@ -734,6 +736,106 @@ describe("Auth0Client", () => { expect.objectContaining({ connection: "google-oauth2" }) ); }); + + it("clears the cached connection token when the exchange fails, then rethrows", async () => { + const session = baseSession([ + { + connection: "google-oauth2", + accessToken: "fc_dead", + expiresAt: 999 + } + ]); + const exchangeError = new AccessTokenForConnectionError( + AccessTokenForConnectionErrorCode.FAILED_TO_EXCHANGE, + "Failed to exchange the refresh token." + ); + const getConnectionTokenSet = vi + .fn() + .mockResolvedValue([exchangeError, null]); + mockAuthClient(session, undefined, getConnectionTokenSet as any); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + + await expect( + client.getAccessTokenForConnection({ connection: "google-oauth2" }) + ).rejects.toBe(exchangeError); + + // The dead connection token set was the only entry, so the property is + // omitted entirely (which deletes the orphaned `__FC` cookie). + expect(saveToSession).toHaveBeenCalledTimes(1); + const saved = saveToSession.mock.calls[0][0] as SessionData; + expect(saved.connectionTokenSets).toBeUndefined(); + }); + + it("clears only the matching account on exchange failure, keeping siblings", async () => { + const session = baseSession([ + { + connection: "google-oauth2", + accessToken: "fc_alice", + expiresAt: 999, + loginHint: "alice@example.com" + }, + { + connection: "google-oauth2", + accessToken: "fc_bob", + expiresAt: 999, + loginHint: "bob@example.com" + } + ]); + const exchangeError = new AccessTokenForConnectionError( + AccessTokenForConnectionErrorCode.FAILED_TO_EXCHANGE, + "Failed to exchange the refresh token." + ); + const getConnectionTokenSet = vi + .fn() + .mockResolvedValue([exchangeError, null]); + mockAuthClient(session, undefined, getConnectionTokenSet as any); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + + await expect( + client.getAccessTokenForConnection({ + connection: "google-oauth2", + login_hint: "bob@example.com" + }) + ).rejects.toBe(exchangeError); + + // Only Bob's dead entry is pruned; Alice's cached token survives. + const saved = saveToSession.mock.calls[0][0] as SessionData; + expect(saved.connectionTokenSets).toEqual([ + expect.objectContaining({ loginHint: "alice@example.com" }) + ]); + }); + + it("does not touch the session on a non-exchange error", async () => { + const session = baseSession([ + { + connection: "google-oauth2", + accessToken: "fc_g", + expiresAt: 999 + } + ]); + const otherError = new AccessTokenForConnectionError( + AccessTokenForConnectionErrorCode.MISSING_REFRESH_TOKEN, + "The refresh token is missing." + ); + const getConnectionTokenSet = vi + .fn() + .mockResolvedValue([otherError, null]); + mockAuthClient(session, undefined, getConnectionTokenSet as any); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + + await expect( + client.getAccessTokenForConnection({ connection: "google-oauth2" }) + ).rejects.toBe(otherError); + + // A transient/other error must not nuke a potentially valid cached token. + expect(saveToSession).not.toHaveBeenCalled(); + }); }); describe("connectAccount", () => { diff --git a/src/server/client.ts b/src/server/client.ts index 0339a91f5..c6b0ce63a 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1164,6 +1164,28 @@ export class Auth0Client { ); if (error !== null) { + // The refresh-token -> connection-token exchange failed (e.g. the upstream + // refresh token was revoked or expired). Any connection token we had + // cached for this account is now dead, so drop it from the session and + // persist the change. This deletes the orphaned `__FC` cookie via the + // stateless session store, preventing it from lingering and contributing + // to request-header bloat (see gh-2450). Then rethrow the original error. + if ( + existingTokenSet && + error.code === AccessTokenForConnectionErrorCode.FAILED_TO_EXCHANGE && + session.connectionTokenSets?.length + ) { + const remaining = session.connectionTokenSets.filter( + (tokenSet) => tokenSet !== existingTokenSet + ); + const { connectionTokenSets: _removed, ...rest } = session; + await this.saveToSession( + remaining.length ? { ...rest, connectionTokenSets: remaining } : rest, + normalizedReq, + res + ); + } + throw error; } From 59d1a2632cb80051ca57970e5556161dde60d1c7 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 30 Jul 2026 11:07:43 +0530 Subject: [PATCH 3/7] fix: addressing coderabbit review comments --- src/server/auth-client.test.ts | 197 +++++++++++++++++- src/server/auth-client.ts | 12 ++ src/server/client.test.ts | 48 ++++- src/server/client.ts | 45 ++-- src/server/session/stateless-session-store.ts | 4 + 5 files changed, 292 insertions(+), 14 deletions(-) diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 6ae90e3e6..d3ef3f2a3 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -19,6 +19,8 @@ import { ConnectAccountError, ConnectAccountErrorCodes, DisconnectAccountError, + DPoPError, + DPoPErrorCode, InvalidConfigurationError, MyAccountApiError, TokenRevocationError, @@ -33,7 +35,7 @@ import { SUBJECT_TOKEN_TYPES } from "../types/index.js"; import { DEFAULT_SCOPES } from "../utils/constants.js"; -import { AuthClient } from "./auth-client.js"; +import { AuthClient, buildConnectAccountErrorResponse } from "./auth-client.js"; import { decrypt, encrypt } from "./cookies.js"; import { DiscoveryCache } from "./discovery-cache.js"; import { StatefulSessionStore } from "./session/stateful-session-store.js"; @@ -10241,6 +10243,85 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(error?.code).toBe("failed_to_list"); expect(error?.cause?.status).toBe(403); }); + + it("stops paginating when the server repeats the same next token", async () => { + // A server that echoes the same `next` token on every page would loop + // forever without the cycle guard. We stop as soon as a token repeats. + // Each call returns a fresh Response (bodies can only be read once). + let requests = 0; + const base = getMockAuthorizationServer(); + const fetchSpy = vi.fn(async (input: any, init?: any) => { + const url = new URL(input instanceof Request ? input.url : input); + if ( + url.pathname === "/me/v1/connected-accounts/accounts" && + (init?.method ?? "GET") === "GET" + ) { + requests++; + return Response.json( + { + accounts: [{ id: "cac_1", connection: "google-oauth2" }], + next: "same-token" + }, + { status: 200 } + ); + } + return base(input, init); + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, accounts] = + await authClient.listConnectedAccounts(tokenSet); + + expect(error).toBeNull(); + // First page (no token) + one page for "same-token", then the repeat is + // detected before a third request is issued. + expect(requests).toBe(2); + expect(accounts?.map((a) => a.id)).toEqual(["cac_1", "cac_1"]); + }); + + it("returns a FAILED_TO_LIST error when the fetch throws", async () => { + // A transport-level failure (e.g. network error) is thrown, not returned + // as a non-ok Response, and must be caught and surfaced as a typed error. + const fetchSpy = vi.fn(async () => { + throw new TypeError("network down"); + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, accounts] = + await authClient.listConnectedAccounts(tokenSet); + + expect(accounts).toBeNull(); + expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error?.code).toBe("failed_to_list"); + expect(error?.message).toBe( + "An unexpected error occurred while trying to list the connected accounts." + ); + }); + + it("surfaces a DPoPError message when listing throws one", async () => { + // A DPoP failure throws a DPoPError; its message is passed through rather + // than the generic fallback. Discovery must succeed first (it also uses + // this.fetch), so only the accounts request throws. + const base = getMockAuthorizationServer(); + const fetchSpy = vi.fn(async (input: any, init?: any) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.pathname === "/me/v1/connected-accounts/accounts") { + throw new DPoPError( + DPoPErrorCode.DPOP_CONFIGURATION_ERROR, + "DPoP keypair is missing." + ); + } + return base(input, init); + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, accounts] = + await authClient.listConnectedAccounts(tokenSet); + + expect(accounts).toBeNull(); + expect(error?.code).toBe("failed_to_list"); + expect(error?.message).toBe("DPoP keypair is missing."); + }); }); describe("disconnectAccount", async () => { @@ -10366,6 +10447,120 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(error?.code).toBe("failed_to_delete"); expect(error?.cause?.status).toBe(429); }); + + it("returns a FAILED_TO_DELETE error when the delete fetch throws", async () => { + // List succeeds, but the DELETE transport call throws (e.g. network + // error). The thrown error must be caught and surfaced as a typed error. + const base = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { accounts: [{ id: "cac_1", connection: "google-oauth2" }] }, + { status: 200 } + ) + ] + }); + const fetchSpy = vi.fn(async (input: any, init?: any) => { + const url = new URL(input instanceof Request ? input.url : input); + if ( + url.pathname.startsWith("/me/v1/connected-accounts/accounts/") && + init?.method === "DELETE" + ) { + throw new TypeError("network down"); + } + return base(input, init); + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, removed] = await authClient.disconnectAccount( + tokenSet, + "google-oauth2" + ); + + expect(removed).toBeNull(); + expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error?.code).toBe("failed_to_delete"); + expect(error?.message).toBe( + "An unexpected error occurred while trying to delete the connected account." + ); + }); + + it("surfaces a DPoPError message when the delete throws one", async () => { + // List succeeds; the DELETE throws a DPoPError whose message is passed + // through rather than the generic fallback. + const base = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { accounts: [{ id: "cac_1", connection: "google-oauth2" }] }, + { status: 200 } + ) + ] + }); + const fetchSpy = vi.fn(async (input: any, init?: any) => { + const url = new URL(input instanceof Request ? input.url : input); + if ( + url.pathname.startsWith("/me/v1/connected-accounts/accounts/") && + init?.method === "DELETE" + ) { + throw new DPoPError( + DPoPErrorCode.DPOP_CONFIGURATION_ERROR, + "DPoP keypair is missing." + ); + } + return base(input, init); + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, removed] = await authClient.disconnectAccount( + tokenSet, + "google-oauth2" + ); + + expect(removed).toBeNull(); + expect(error?.code).toBe("failed_to_delete"); + expect(error?.message).toBe("DPoP keypair is missing."); + }); + }); + + describe("buildConnectAccountErrorResponse", async () => { + it("falls back to a plain error when the response body is not JSON", async () => { + // Some error responses (e.g. an upstream proxy 502) carry a non-JSON + // body; `res.json()` then throws and we must still return a typed error + // without a MyAccountApiError cause. + const res = new Response("Bad Gateway", { + status: 502, + headers: { "content-type": "text/html" } + }); + + const [error, result] = await buildConnectAccountErrorResponse( + res, + ConnectAccountErrorCodes.FAILED_TO_INITIATE + ); + + expect(result).toBeNull(); + expect(error).toBeInstanceOf(ConnectAccountError); + expect(error?.code).toBe(ConnectAccountErrorCodes.FAILED_TO_INITIATE); + expect(error?.message).toBe( + "The request to initiate the connect account flow failed with status 502." + ); + // No parseable body means no MyAccountApiError cause is attached. + expect(error?.cause).toBeUndefined(); + }); + + it("uses the complete verb for a FAILED_TO_COMPLETE code", async () => { + const res = new Response("not json", { + status: 500, + headers: { "content-type": "text/plain" } + }); + + const [error] = await buildConnectAccountErrorResponse( + res, + ConnectAccountErrorCodes.FAILED_TO_COMPLETE + ); + + expect(error?.message).toBe( + "The request to complete the connect account flow failed with status 500." + ); + }); }); describe("backchannelAuthentication", async () => { diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 00fc8dc3c..d682527e0 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -4360,8 +4360,20 @@ export class AuthClient { const accounts: ConnectedAccount[] = []; let next: string | undefined; + // `next` is server-controlled. Guard against a server that echoes the same + // token or cycles, which would otherwise loop and grow `accounts` without + // bound on the request thread. + const seenNext = new Set(); + const MAX_PAGES = 100; + let pages = 0; do { + if (++pages > MAX_PAGES || (next && seenNext.has(next))) { + break; + } + if (next) { + seenNext.add(next); + } const url = new URL("/me/v1/connected-accounts/accounts", this.issuer); if (next) { url.searchParams.set("next", next); diff --git a/src/server/client.test.ts b/src/server/client.test.ts index 38d35e7a2..64bd03dcb 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1193,6 +1193,43 @@ describe("Auth0Client", () => { res ); }); + + it("prunes from the session re-read after the mint, preserving a rotated refresh token", async () => { + // Pre-mint snapshot has the old refresh token. + const preMint = sessionWith([ + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, + { connection: "github", accessToken: "fc_gh", expiresAt: 999 } + ]); + // Minting the My Account token refreshes the primary token, rotating the + // refresh token and persisting a new session. The re-read must see this. + const postMint = { + ...preMint, + tokenSet: { ...preMint.tokenSet, refreshToken: "rotated_refresh_token" } + }; + // First call (initial read) -> preMint; second call (re-read) -> postMint. + vi.spyOn(client as any, "getSessionFromAuthClient") + .mockResolvedValueOnce(preMint) + .mockResolvedValueOnce(postMint); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/" + }); + mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + const saved = saveToSession.mock.calls[0][0] as SessionData; + // The rotated refresh token survives (not clobbered by the stale snapshot) + expect(saved.tokenSet.refreshToken).toBe("rotated_refresh_token"); + // ...and the disconnected connection is still pruned. + expect(saved.connectionTokenSets).toEqual([ + expect.objectContaining({ connection: "github" }) + ]); + }); }); describe("getConnectedAccounts", () => { @@ -1445,8 +1482,17 @@ describe("Auth0Client", () => { const eq = pair.indexOf("="); const name = pair.slice(0, eq); const value = pair.slice(eq + 1); + // A cookie is reclaimed either via `Max-Age=0` (how `deleteCookie` + // writes it) or via a past `Expires` date (how `ResponseCookies.delete` + // writes it when the orphan deletion is mirrored onto a shared jar). const maxAge0 = attrs.some((a) => a.toLowerCase() === "max-age=0"); - state.set(name, { deleted: value === "" && maxAge0, value }); + const expiredEpoch = attrs.some( + (a) => a.toLowerCase() === "expires=thu, 01 jan 1970 00:00:00 gmt" + ); + state.set(name, { + deleted: value === "" && (maxAge0 || expiredEpoch), + value + }); } return state; } diff --git a/src/server/client.ts b/src/server/client.ts index c6b0ce63a..29db3d73c 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1205,11 +1205,12 @@ export class Auth0Client { // we need to update the item in the array // If not, we need to add it. if (existingTokenSet) { + // Replace only the specific entry we matched, by identity. Matching on + // connection (+ optional login_hint) again would, when no login_hint is + // supplied, overwrite every entry for the connection with this single + // token set, collapsing distinct per-account tokens into duplicates. tokenSets = session.connectionTokenSets?.map((tokenSet) => - tokenSet.connection === options.connection && - (!options.login_hint || tokenSet.loginHint === options.login_hint) - ? retrievedTokenSet - : tokenSet + tokenSet === existingTokenSet ? retrievedTokenSet : tokenSet ); } else { tokenSets = [...(session.connectionTokenSets || []), retrievedTokenSet]; @@ -1955,13 +1956,23 @@ export class Auth0Client { // Remove the cached connection tokens for this connection so they are not // re-assembled into the session on the next read. When no entries remain, // omit the property entirely (mirroring how the store persists it). - if (session.connectionTokenSets?.length) { - const remaining = session.connectionTokenSets.filter( + // + // Re-read the session before pruning: minting the My Account token above can + // refresh the primary token, which rotates the refresh token and persists a + // new session. Writing back the pre-mint snapshot would clobber that rotated + // refresh token (and the freshly cached token), logging the user out on the + // next refresh. Prune from the latest persisted session instead. + const latestSession = + (await this.getSessionFromAuthClient(authClient, normalizedReq)) ?? + session; + + if (latestSession.connectionTokenSets?.length) { + const remaining = latestSession.connectionTokenSets.filter( (tokenSet) => tokenSet.connection !== options.connection ); - if (remaining.length !== session.connectionTokenSets.length) { - const { connectionTokenSets: _removed, ...rest } = session; + if (remaining.length !== latestSession.connectionTokenSets.length) { + const { connectionTokenSets: _removed, ...rest } = latestSession; await this.saveToSession( remaining.length ? { ...rest, connectionTokenSets: remaining } : rest, normalizedReq, @@ -2053,16 +2064,26 @@ export class Auth0Client { // Reconcile: drop cached connection tokens whose connection is no longer // present server-side. - if (session.connectionTokenSets?.length) { + // + // Re-read the session before pruning: minting the My Account token above can + // refresh the primary token, which rotates the refresh token and persists a + // new session. Writing back the pre-mint snapshot would clobber that rotated + // refresh token (and the freshly cached token), logging the user out on the + // next refresh. Prune from the latest persisted session instead. + const latestSession = + (await this.getSessionFromAuthClient(authClient, normalizedReq)) ?? + session; + + if (latestSession.connectionTokenSets?.length) { const serverConnections = new Set( accounts.map((account) => account.connection) ); - const remaining = session.connectionTokenSets.filter((tokenSet) => + const remaining = latestSession.connectionTokenSets.filter((tokenSet) => serverConnections.has(tokenSet.connection) ); - if (remaining.length !== session.connectionTokenSets.length) { - const { connectionTokenSets: _removed, ...rest } = session; + if (remaining.length !== latestSession.connectionTokenSets.length) { + const { connectionTokenSets: _removed, ...rest } = latestSession; await this.saveToSession( remaining.length ? { ...rest, connectionTokenSets: remaining } : rest, normalizedReq, diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index a17515788..793d57406 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -158,6 +158,10 @@ export class StatelessSessionStore extends AbstractSessionStore { sameSite: this.cookieConfig.sameSite, httpOnly: this.cookieConfig.httpOnly }); + // Mirror the deletion into reqCookies so a subsequent get()/set() in the + // same request does not re-assemble the orphaned `__FC_i` into the + // session (storeInCookie writes reqCookies for read-after-write too). + reqCookies.delete(cookie.name); } } From eb5b1c46c21b8673f6b7a3fb9a2deb2b0ff415db Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 31 Jul 2026 16:53:52 +0530 Subject: [PATCH 4/7] fix: persist My Account token rotation with a single session write across connect/disconnect/list --- src/server/auth-client.test.ts | 16 +- src/server/auth-client.ts | 37 +++- src/server/client.test.ts | 325 ++++++++++++++++++++++++++------- src/server/client.ts | 177 +++++++++++++++--- 4 files changed, 452 insertions(+), 103 deletions(-) diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index d3ef3f2a3..f27d3c05b 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -10244,10 +10244,12 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(error?.cause?.status).toBe(403); }); - it("stops paginating when the server repeats the same next token", async () => { + it("fails with FAILED_TO_LIST when the server repeats the same next token", async () => { // A server that echoes the same `next` token on every page would loop - // forever without the cycle guard. We stop as soon as a token repeats. - // Each call returns a fresh Response (bodies can only be read once). + // forever without the cycle guard. Returning the partial list as success + // is unsafe (callers reconcile against it destructively), so we surface a + // FAILED_TO_LIST error instead. Each call returns a fresh Response + // (bodies can only be read once). let requests = 0; const base = getMockAuthorizationServer(); const fetchSpy = vi.fn(async (input: any, init?: any) => { @@ -10272,11 +10274,15 @@ ca/T0LLtgmbMmxSv/MmzIg== const [error, accounts] = await authClient.listConnectedAccounts(tokenSet); - expect(error).toBeNull(); + expect(accounts).toBeNull(); + expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error?.code).toBe("failed_to_list"); + expect(error?.message).toBe( + "Connected-account pagination did not terminate safely." + ); // First page (no token) + one page for "same-token", then the repeat is // detected before a third request is issued. expect(requests).toBe(2); - expect(accounts?.map((a) => a.id)).toEqual(["cac_1", "cac_1"]); }); it("returns a FAILED_TO_LIST error when the fetch throws", async () => { diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index d682527e0..3bc2c2f1e 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -4250,7 +4250,7 @@ export class AuthClient { } catch (e: any) { let message = "An unexpected error occurred while trying to initiate the connect account flow."; - if (e instanceof DPoPError) { + if (isDPoPError(e)) { message = e.message; } return [ @@ -4369,7 +4369,18 @@ export class AuthClient { do { if (++pages > MAX_PAGES || (next && seenNext.has(next))) { - break; + // The server returned a non-terminating cursor (page cap exceeded or + // a repeated `next` token). Returning the partial list as success is + // unsafe: callers reconcile against it destructively (pruning cached + // tokens for omitted accounts, and disconnectAccount would leave + // additional matching accounts linked). Fail loudly instead. + return [ + new DisconnectAccountError({ + code: DisconnectAccountErrorCodes.FAILED_TO_LIST, + message: "Connected-account pagination did not terminate safely." + }), + null + ]; } if (next) { seenNext.add(next); @@ -4412,7 +4423,7 @@ export class AuthClient { } catch (e: any) { let message = "An unexpected error occurred while trying to list the connected accounts."; - if (e instanceof DPoPError) { + if (isDPoPError(e)) { message = e.message; } return [ @@ -4469,7 +4480,7 @@ export class AuthClient { } catch (e: any) { let message = "An unexpected error occurred while trying to delete the connected account."; - if (e instanceof DPoPError) { + if (isDPoPError(e)) { message = e.message; } return [ @@ -6924,6 +6935,24 @@ export async function buildDisconnectAccountErrorResponse( } } +/** + * Identifies a DPoP failure by its `code` rather than an `instanceof` check. + * `instanceof` is unreliable across module/realm boundaries (duplicate copies + * of the error class), so we match on the well-known DPoP error codes instead. + * + * @internal + */ +function isDPoPError( + e: unknown +): e is { code: DPoPErrorCode; message: string } { + return ( + typeof e === "object" && + e !== null && + "code" in e && + Object.values(DPoPErrorCode).includes((e as { code: DPoPErrorCode }).code) + ); +} + /** * Creates a NextResponse for BCLO error cases. * Centralizes the response format (text/plain content type) for all BCLO error branches. diff --git a/src/server/client.test.ts b/src/server/client.test.ts index 64bd03dcb..b953afa5d 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -893,15 +893,17 @@ describe("Auth0Client", () => { }); it("mints a create-scoped My Account token and returns the redirect response", async () => { + const session = sessionWith(); vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( - sessionWith() + session ); - const getAccessToken = vi - .spyOn(client as any, "getAccessToken") + const mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") .mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); const redirect = NextResponse.redirect("https://test.auth0.com/connect"); const connect = vi.fn().mockResolvedValue([null, redirect]); @@ -912,11 +914,14 @@ describe("Auth0Client", () => { }); expect(result).toBe(redirect); - expect(getAccessToken).toHaveBeenCalledWith( + expect(mintMyAccountToken).toHaveBeenCalledWith( expect.objectContaining({ audience: "https://test.auth0.com/me/", scope: "create:me:connected_accounts" - }) + }), + undefined, + undefined, + { persist: false } ); expect(connect).toHaveBeenCalledWith( expect.objectContaining({ @@ -931,10 +936,11 @@ describe("Auth0Client", () => { const getSessionFromAuthClient = vi .spyOn(client as any, "getSessionFromAuthClient") .mockResolvedValue(sessionWith()); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session: sessionWith() }); const redirect = NextResponse.redirect("https://test.auth0.com/connect"); const connect = vi.fn().mockResolvedValue([null, redirect]); @@ -956,10 +962,11 @@ describe("Auth0Client", () => { vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( sessionWith() ); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session: sessionWith() }); const connectError = new Error("connect failed"); mockAuthClientWith(vi.fn().mockResolvedValue([connectError, null])); @@ -968,6 +975,67 @@ describe("Auth0Client", () => { client.connectAccount({ connection: "google-oauth2" }) ).rejects.toThrow("connect failed"); }); + + it("persists a rotated refresh token onto the redirect response", async () => { + const session = sessionWith(); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + // The mint refreshed the primary token, rotating the refresh token. + const rotated = { + ...session, + tokenSet: { ...session.tokenSet, refreshToken: "rotated_refresh_token" } + }; + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session: rotated, + sessionChanged: true + }); + const redirect = NextResponse.redirect("https://test.auth0.com/connect"); + mockAuthClientWith(vi.fn().mockResolvedValue([null, redirect])); + const set = vi + .spyOn(client["sessionStore"] as any, "set") + .mockResolvedValue(undefined); + + const result = await client.connectAccount({ + connection: "google-oauth2" + }); + + expect(result).toBe(redirect); + // The rotated session is written onto the redirect response's cookies so + // it is not dropped (which would trigger reuse-detection on next refresh). + expect(set).toHaveBeenCalledTimes(1); + const [, resCookies, savedSession] = set.mock.calls[0]; + expect(resCookies).toBe(redirect.cookies); + expect((savedSession as SessionData).tokenSet.refreshToken).toBe( + "rotated_refresh_token" + ); + }); + + it("does not write the session when the mint did not rotate the token", async () => { + const session = sessionWith(); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session, + sessionChanged: false + }); + const redirect = NextResponse.redirect("https://test.auth0.com/connect"); + mockAuthClientWith(vi.fn().mockResolvedValue([null, redirect])); + const set = vi + .spyOn(client["sessionStore"] as any, "set") + .mockResolvedValue(undefined); + + await client.connectAccount({ connection: "google-oauth2" }); + + expect(set).not.toHaveBeenCalled(); + }); }); describe("disconnectAccount", () => { @@ -1037,12 +1105,13 @@ describe("Auth0Client", () => { const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - const getAccessToken = vi - .spyOn(client as any, "getAccessToken") + const mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") .mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); const disconnect = vi.fn().mockResolvedValue([null, []]); mockAuthClientWith(disconnect); @@ -1050,11 +1119,14 @@ describe("Auth0Client", () => { await client.disconnectAccount({ connection: "google-oauth2" }); // Correct My Account audience + scopes were requested. - expect(getAccessToken).toHaveBeenCalledWith( + expect(mintMyAccountToken).toHaveBeenCalledWith( expect.objectContaining({ audience: "https://test.auth0.com/me/", scope: "read:me:connected_accounts delete:me:connected_accounts" - }) + }), + undefined, + undefined, + { persist: false } ); // The connection name (not id) was passed to the auth client. expect(disconnect).toHaveBeenCalledWith( @@ -1084,10 +1156,11 @@ describe("Auth0Client", () => { const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); @@ -1107,10 +1180,11 @@ describe("Auth0Client", () => { const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); @@ -1129,10 +1203,11 @@ describe("Auth0Client", () => { const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); const disconnectError = new Error("delete failed"); mockAuthClientWith(vi.fn().mockResolvedValue([disconnectError, null])); @@ -1154,12 +1229,13 @@ describe("Auth0Client", () => { const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - const getAccessToken = vi - .spyOn(client as any, "getAccessToken") + const mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") .mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); @@ -1173,14 +1249,15 @@ describe("Auth0Client", () => { expect.anything(), req ); - // The My Account token is minted through the Pages Router - // getAccessToken(req, res, options) overload so refreshed tokens persist. - expect(getAccessToken).toHaveBeenCalledWith( - req, - res, + // mintMyAccountToken is called with req/res so the rotated refresh token + // persists to the Pages Router response. + expect(mintMyAccountToken).toHaveBeenCalledWith( expect.objectContaining({ scope: "read:me:connected_accounts delete:me:connected_accounts" - }) + }), + req, + res, + { persist: false } ); // The pruned session is written back to the Pages Router response. expect(saveToSession).toHaveBeenCalledWith( @@ -1194,29 +1271,32 @@ describe("Auth0Client", () => { ); }); - it("prunes from the session re-read after the mint, preserving a rotated refresh token", async () => { + it("prunes from the session the mint persisted, preserving a rotated refresh token", async () => { // Pre-mint snapshot has the old refresh token. const preMint = sessionWith([ { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, { connection: "github", accessToken: "fc_gh", expiresAt: 999 } ]); // Minting the My Account token refreshes the primary token, rotating the - // refresh token and persisting a new session. The re-read must see this. + // refresh token. mintMyAccountToken returns the persisted snapshot so + // pruning does not clobber the rotated token via a stale re-read. const postMint = { ...preMint, tokenSet: { ...preMint.tokenSet, refreshToken: "rotated_refresh_token" } }; - // First call (initial read) -> preMint; second call (re-read) -> postMint. - vi.spyOn(client as any, "getSessionFromAuthClient") - .mockResolvedValueOnce(preMint) - .mockResolvedValueOnce(postMint); + // Initial session read returns preMint (used only for MISSING_SESSION guard). + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + preMint + ); const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + // mintMyAccountToken returns postMint as the persisted session. + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session: postMint }); mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); @@ -1230,6 +1310,66 @@ describe("Auth0Client", () => { expect.objectContaining({ connection: "github" }) ]); }); + + it("persists a rotated session even when there is nothing to prune", async () => { + // No cached connection tokens, so nothing is pruned, but the mint rotated + // the refresh token: it must still be persisted (single write). + const session = sessionWith(undefined); + const rotated = { + ...session, + tokenSet: { ...session.tokenSet, refreshToken: "rotated_refresh_token" } + }; + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session: rotated, + sessionChanged: true + }); + mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + // A single write persists the rotated token; no double write. + expect(saveToSession).toHaveBeenCalledTimes(1); + expect(saveToSession).toHaveBeenCalledWith( + expect.objectContaining({ + tokenSet: expect.objectContaining({ + refreshToken: "rotated_refresh_token" + }) + }), + undefined, + undefined + ); + }); + + it("does not write the session when the mint did not rotate and nothing is pruned", async () => { + const session = sessionWith(undefined); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session, + sessionChanged: false + }); + mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + expect(saveToSession).not.toHaveBeenCalled(); + }); }); describe("getConnectedAccounts", () => { @@ -1292,12 +1432,13 @@ describe("Auth0Client", () => { session ); vi.spyOn(client as any, "saveToSession").mockResolvedValue(undefined); - const getAccessToken = vi - .spyOn(client as any, "getAccessToken") + const mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") .mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); const accounts = [ { id: "cac_1", connection: "google-oauth2" }, @@ -1308,11 +1449,14 @@ describe("Auth0Client", () => { const result = await client.getConnectedAccounts(); expect(result).toEqual(accounts); - expect(getAccessToken).toHaveBeenCalledWith( + expect(mintMyAccountToken).toHaveBeenCalledWith( expect.objectContaining({ audience: "https://test.auth0.com/me/", scope: "read:me:connected_accounts" - }) + }), + undefined, + undefined, + { persist: false } ); }); @@ -1327,10 +1471,11 @@ describe("Auth0Client", () => { const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); // Server only knows about google-oauth2; slack was disconnected elsewhere. mockAuthClientWith( @@ -1365,10 +1510,11 @@ describe("Auth0Client", () => { const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); mockAuthClientWith( vi @@ -1389,10 +1535,11 @@ describe("Auth0Client", () => { vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( session ); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); const listError = new Error("list failed"); mockAuthClientWith(vi.fn().mockResolvedValue([listError, null])); @@ -1413,12 +1560,13 @@ describe("Auth0Client", () => { const saveToSession = vi .spyOn(client as any, "saveToSession") .mockResolvedValue(undefined); - const getAccessToken = vi - .spyOn(client as any, "getAccessToken") + const mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") .mockResolvedValue({ token: "my_account_token", expiresAt: 12345, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); // Server only knows about google-oauth2; slack was disconnected elsewhere. mockAuthClientWith( @@ -1441,12 +1589,13 @@ describe("Auth0Client", () => { expect.anything(), req ); - // The My Account token is minted through the Pages Router - // getAccessToken(req, res, options) overload. - expect(getAccessToken).toHaveBeenCalledWith( + // mintMyAccountToken is called with req/res so the rotated refresh token + // persists to the Pages Router response. + expect(mintMyAccountToken).toHaveBeenCalledWith( + expect.objectContaining({ scope: "read:me:connected_accounts" }), req, res, - expect.objectContaining({ scope: "read:me:connected_accounts" }) + { persist: false } ); // The reconciled (pruned) session is written back to the Pages Router response. expect(saveToSession).toHaveBeenCalledWith( @@ -1459,6 +1608,50 @@ describe("Auth0Client", () => { res ); }); + + it("persists a rotated session even when nothing needs reconciling", async () => { + // No cached connection tokens, so nothing is reconciled, but the mint + // rotated the refresh token: it must still be persisted (single write). + const session = sessionWith(undefined); + const rotated = { + ...session, + tokenSet: { ...session.tokenSet, refreshToken: "rotated_refresh_token" } + }; + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session: rotated, + sessionChanged: true + }); + mockAuthClientWith( + vi + .fn() + .mockResolvedValue([ + null, + [{ id: "cac_1", connection: "google-oauth2" }] + ]) + ); + + await client.getConnectedAccounts(); + + expect(saveToSession).toHaveBeenCalledTimes(1); + expect(saveToSession).toHaveBeenCalledWith( + expect.objectContaining({ + tokenSet: expect.objectContaining({ + refreshToken: "rotated_refresh_token" + }) + }), + undefined, + undefined + ); + }); }); // Regression coverage for gh-2450: disconnecting / reconciling connected @@ -1571,10 +1764,11 @@ describe("Auth0Client", () => { vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( session ); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: Math.floor(Date.now() / 1000) + 3600, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue({ issuer: "https://test.auth0.com/", @@ -1603,10 +1797,11 @@ describe("Auth0Client", () => { vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( session ); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: Math.floor(Date.now() / 1000) + 3600, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue({ issuer: "https://test.auth0.com/", @@ -1631,10 +1826,11 @@ describe("Auth0Client", () => { vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( session ); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: Math.floor(Date.now() / 1000) + 3600, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue({ issuer: "https://test.auth0.com/", @@ -1665,10 +1861,11 @@ describe("Auth0Client", () => { vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( session ); - vi.spyOn(client as any, "getAccessToken").mockResolvedValue({ + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ token: "my_account_token", expiresAt: Math.floor(Date.now() / 1000) + 3600, - audience: "https://test.auth0.com/me/" + audience: "https://test.auth0.com/me/", + session }); // Disconnect a connection the user does not have -> no local change. vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue({ diff --git a/src/server/client.ts b/src/server/client.ts index 29db3d73c..06b51e306 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1736,20 +1736,93 @@ export class Auth0Client { } /** - * Mints a My Account API access token, dispatching to the correct - * `getAccessToken` overload so the refreshed token set is persisted in both - * the App Router (no req/res) and Pages Router / middleware (req + res) paths. + * Mints a My Account API access token. Minting can silently refresh the + * primary token, which rotates (and invalidates) the shared refresh token, so + * that rotation must be persisted or the next refresh triggers reuse-detection + * and logs the user out. + * + * Returns the access token, the session reflecting any rotation, and a + * `sessionChanged` flag indicating whether the mint rotated the token set. + * + * By default the rotated session is persisted here. Callers that write the + * session again afterwards (e.g. to prune connectionTokenSets) should pass + * `persist: false` and perform a single write from the returned `session`, + * so the response does not carry two conflicting `Set-Cookie` writes for the + * same session and connection cookies. Such callers must start from the + * returned `session` (not a fresh cookie re-read): in the Pages Router, + * request cookies are rebuilt from the original request headers and never + * reflect writes made during this request, so a re-read would clobber the + * rotated refresh token. When `persist` is false and the caller ends up not + * writing (nothing to prune), it must still persist the rotated session when + * `sessionChanged` is true. * @internal */ - private async getMyAccountAccessToken( + private async mintMyAccountToken( options: GetAccessTokenOptions & { audience: string; scope: string }, req?: NextRequest | PagesRouterRequest, - res?: PagesRouterResponse | NextResponse - ): Promise<{ token: string; expiresAt: number; audience?: string }> { - if (req && res) { - return this.getAccessToken(req, res, options); + res?: PagesRouterResponse | NextResponse, + { persist = true }: { persist?: boolean } = {} + ): Promise<{ + token: string; + expiresAt: number; + audience?: string; + session: SessionData; + sessionChanged: boolean; + }> { + const { authClient, normalizedReq } = await this.resolveRequestContext(req); + + const session = await this.getSessionFromAuthClient( + authClient, + normalizedReq + ); + if (!session) { + throw new AccessTokenError( + AccessTokenErrorCode.MISSING_SESSION, + "The user does not have an active session." + ); + } + + const [error, tokenSetResponse] = await authClient.getTokenSet( + session, + options + ); + if (error) { + throw error; + } + + const { tokenSet, idTokenClaims } = tokenSetResponse; + const sessionChanges = getSessionChangesAfterGetAccessToken( + session, + tokenSet, + { + scope: this.#options.authorizationParameters?.scope ?? DEFAULT_SCOPES, + audience: this.#options.authorizationParameters?.audience + } + ); + + let persistedSession: SessionData = session; + const sessionChanged = !!sessionChanges; + if (sessionChanges) { + if (idTokenClaims) { + session.user = idTokenClaims as User; + } + const finalSession = await authClient.finalizeSession( + { ...session, ...sessionChanges }, + tokenSet.idToken + ); + persistedSession = finalSession; + if (persist) { + await this.saveToSession(finalSession, req, res); + } } - return this.getAccessToken(options); + + return { + token: tokenSet.accessToken, + expiresAt: tokenSet.expiresAt, + audience: tokenSet.audience, + session: persistedSession, + sessionChanged + }; } async startInteractiveLogin( @@ -1847,7 +1920,16 @@ export class Auth0Client { scope: "create:me:connected_accounts" }; - const accessToken = await this.getAccessToken(getMyAccountTokenOpts); + // Defer persistence: connectAccount returns its own redirect response, so a + // rotated session must be written onto that response's cookies below (an + // ambient cookies() write would not attach to the returned redirect, and in + // middleware/Pages there is no response to write to here at all). + const accessToken = await this.mintMyAccountToken( + getMyAccountTokenOpts, + normalizedReq, + undefined, + { persist: false } + ); const [error, connectAccountResponse] = await authClient.connectAccount( { @@ -1866,6 +1948,23 @@ export class Auth0Client { throw error; } + // If the mint rotated the refresh token, persist it onto the redirect + // response so the rotated token is not dropped (which would trigger + // reuse-detection and log the user out on the next refresh). Writing to the + // response cookies works across App Router, middleware, and Pages Router + // since the caller forwards this response's Set-Cookie headers. + if (accessToken.sessionChanged) { + const reqCookies = + normalizedReq instanceof NextRequest + ? normalizedReq.cookies + : await cookies(); + await this.sessionStore.set( + reqCookies, + connectAccountResponse.cookies, + accessToken.session + ); + } + return connectAccountResponse; } @@ -1933,10 +2032,14 @@ export class Auth0Client { scope: "read:me:connected_accounts delete:me:connected_accounts" }; - const accessToken = await this.getMyAccountAccessToken( + // Defer persistence: this method may write the session again below to prune + // connectionTokenSets. Writing once (from the mint's rotated session) avoids + // emitting two conflicting `Set-Cookie` writes for the same cookies. + const accessToken = await this.mintMyAccountToken( getMyAccountTokenOpts, normalizedReq, - res + res, + { persist: false } ); const [error] = await authClient.disconnectAccount( @@ -1957,15 +2060,13 @@ export class Auth0Client { // re-assembled into the session on the next read. When no entries remain, // omit the property entirely (mirroring how the store persists it). // - // Re-read the session before pruning: minting the My Account token above can - // refresh the primary token, which rotates the refresh token and persists a - // new session. Writing back the pre-mint snapshot would clobber that rotated - // refresh token (and the freshly cached token), logging the user out on the - // next refresh. Prune from the latest persisted session instead. - const latestSession = - (await this.getSessionFromAuthClient(authClient, normalizedReq)) ?? - session; + // Prune from the session the mint produced, not a fresh cookie re-read. In + // the Pages Router, request cookies are rebuilt from the original request + // headers and never reflect writes made during the mint, so a re-read would + // clobber the rotated refresh token. + const latestSession = accessToken.session; + let pruned = false; if (latestSession.connectionTokenSets?.length) { const remaining = latestSession.connectionTokenSets.filter( (tokenSet) => tokenSet.connection !== options.connection @@ -1978,8 +2079,15 @@ export class Auth0Client { normalizedReq, res ); + pruned = true; } } + + // Nothing was pruned, but the mint may have rotated the token set. Persist + // it here since mintMyAccountToken was told not to (persist: false). + if (!pruned && accessToken.sessionChanged) { + await this.saveToSession(latestSession, normalizedReq, res); + } } /** @@ -2045,10 +2153,14 @@ export class Auth0Client { scope: "read:me:connected_accounts" }; - const accessToken = await this.getMyAccountAccessToken( + // Defer persistence: this method may write the session again below to prune + // stale connectionTokenSets. Writing once (from the mint's rotated session) + // avoids emitting two conflicting `Set-Cookie` writes for the same cookies. + const accessToken = await this.mintMyAccountToken( getMyAccountTokenOpts, normalizedReq, - res + res, + { persist: false } ); const [error, accounts] = await authClient.listConnectedAccounts({ @@ -2065,15 +2177,13 @@ export class Auth0Client { // Reconcile: drop cached connection tokens whose connection is no longer // present server-side. // - // Re-read the session before pruning: minting the My Account token above can - // refresh the primary token, which rotates the refresh token and persists a - // new session. Writing back the pre-mint snapshot would clobber that rotated - // refresh token (and the freshly cached token), logging the user out on the - // next refresh. Prune from the latest persisted session instead. - const latestSession = - (await this.getSessionFromAuthClient(authClient, normalizedReq)) ?? - session; + // Prune from the session the mint produced, not a fresh cookie re-read. In + // the Pages Router, request cookies are rebuilt from the original request + // headers and never reflect writes made during the mint, so a re-read would + // clobber the rotated refresh token. + const latestSession = accessToken.session; + let reconciled = false; if (latestSession.connectionTokenSets?.length) { const serverConnections = new Set( accounts.map((account) => account.connection) @@ -2089,9 +2199,16 @@ export class Auth0Client { normalizedReq, res ); + reconciled = true; } } + // Nothing was reconciled, but the mint may have rotated the token set. + // Persist it here since mintMyAccountToken was told not to (persist: false). + if (!reconciled && accessToken.sessionChanged) { + await this.saveToSession(latestSession, normalizedReq, res); + } + return accounts; } From b54a6cf5bb291c0b31e1ccd29884a2de1c9caf87 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 12 Aug 2026 15:14:45 +0530 Subject: [PATCH 5/7] fix: addressing review comments --- src/server/auth-client.ts | 2 + src/server/client.test.ts | 155 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 3bc2c2f1e..457129640 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -6949,6 +6949,8 @@ function isDPoPError( typeof e === "object" && e !== null && "code" in e && + "message" in e && + typeof (e as { message: unknown }).message === "string" && Object.values(DPoPErrorCode).includes((e as { code: DPoPErrorCode }).code) ); } diff --git a/src/server/client.test.ts b/src/server/client.test.ts index b953afa5d..36c00903f 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -4,6 +4,8 @@ import { ResponseCookies } from "@edge-runtime/cookies"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + AccessTokenError, + AccessTokenErrorCode, AccessTokenForConnectionError, AccessTokenForConnectionErrorCode, DomainResolutionError, @@ -838,6 +840,159 @@ describe("Auth0Client", () => { }); }); + describe("mintMyAccountToken (private)", () => { + const baseSession = (): SessionData => ({ + user: { sub: "user123" }, + tokenSet: { + accessToken: "access_token", + idToken: "id_token", + refreshToken: "refresh_token", + expiresAt: Date.now() / 1000 + 3600 + }, + internal: { sid: "mock_sid", createdAt: Date.now() / 1000 } + }); + + let client: Auth0Client; + + beforeEach(() => { + process.env[ENV_VARS.DOMAIN] = "test.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "test_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test"; + process.env[ENV_VARS.SECRET] = "test_secret"; + client = new Auth0Client(); + }); + + function mockAuthClient( + session: SessionData | null, + tokenSetResponse: { tokenSet: any; idTokenClaims?: any } | null, + error: Error | null = null + ) { + const mockClient = { + issuer: "https://test.auth0.com/", + getTokenSet: vi + .fn() + .mockResolvedValue(error ? [error, null] : [null, tokenSetResponse]), + finalizeSession: vi.fn().mockImplementation(async (s: SessionData) => s) + }; + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue( + mockClient + ); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + return mockClient; + } + + it("throws MISSING_SESSION when there is no active session", async () => { + mockAuthClient(null, null); + + await expect( + (client as any).mintMyAccountToken({ + audience: "https://test.auth0.com/me/", + scope: "read:me:connected_accounts" + }) + ).rejects.toMatchObject({ + code: AccessTokenErrorCode.MISSING_SESSION + }); + }); + + it("returns the token and the session from getTokenSet", async () => { + const session = baseSession(); + const tokenSet = { + accessToken: "my_account_token", + expiresAt: 9999999999, + scope: "read:me:connected_accounts", + audience: "https://test.auth0.com/me/" + }; + mockAuthClient(session, { tokenSet }); + vi.spyOn(client as any, "saveToSession").mockResolvedValue(undefined); + + const result = await (client as any).mintMyAccountToken({ + audience: "https://test.auth0.com/me/", + scope: "read:me:connected_accounts" + }); + + expect(result.token).toBe("my_account_token"); + expect(result.expiresAt).toBe(9999999999); + expect(result.audience).toBe("https://test.auth0.com/me/"); + }); + + it("returns sessionChanged=true and persists when persist:true (default) and token set changed", async () => { + const session = baseSession(); + // A refreshed tokenSet with a new accessToken triggers sessionChanges. + const tokenSet = { + accessToken: "new_access_token", + refreshToken: "new_refresh_token", + idToken: "new_id_token", + expiresAt: 9999999999, + scope: "openid profile email" + }; + mockAuthClient(session, { tokenSet, idTokenClaims: { sub: "user123" } }); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + + const result = await (client as any).mintMyAccountToken( + { + audience: "https://test.auth0.com/me/", + scope: "read:me:connected_accounts" + }, + undefined, + undefined, + { persist: true } + ); + + expect(result.sessionChanged).toBe(true); + expect(saveToSession).toHaveBeenCalledOnce(); + }); + + it("does not persist when persist:false even if the token set changed", async () => { + const session = baseSession(); + const tokenSet = { + accessToken: "new_access_token", + refreshToken: "new_refresh_token", + idToken: "new_id_token", + expiresAt: 9999999999, + scope: "openid profile email" + }; + mockAuthClient(session, { tokenSet, idTokenClaims: { sub: "user123" } }); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + + const result = await (client as any).mintMyAccountToken( + { + audience: "https://test.auth0.com/me/", + scope: "read:me:connected_accounts" + }, + undefined, + undefined, + { persist: false } + ); + + expect(result.sessionChanged).toBe(true); + // persist:false — caller is responsible for saving. + expect(saveToSession).not.toHaveBeenCalled(); + }); + + it("rethrows the error from getTokenSet", async () => { + const session = baseSession(); + const tokenError = new AccessTokenError( + AccessTokenErrorCode.MISSING_REFRESH_TOKEN, + "No refresh token." + ); + mockAuthClient(session, null, tokenError); + + await expect( + (client as any).mintMyAccountToken({ + audience: "https://test.auth0.com/me/", + scope: "read:me:connected_accounts" + }) + ).rejects.toBe(tokenError); + }); + }); + describe("connectAccount", () => { const sessionWith = ( connectionTokenSets?: SessionData["connectionTokenSets"] From dee141f669d4cba6f2ad30a04143e436ebc6c862 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 21 Aug 2026 10:50:42 +0530 Subject: [PATCH 6/7] fix: addressing review comments --- EXAMPLES.md | 10 ++- src/errors/index.ts | 4 +- src/errors/my-account-errors.ts | 14 ++-- src/server/auth-client.test.ts | 62 ++++++++++++-- src/server/auth-client.ts | 82 +++++++++++-------- src/server/client.test.ts | 71 ++++++++++++++-- src/server/client.ts | 67 +++++++++------ .../session/stateless-session-store.test.ts | 4 +- src/server/session/stateless-session-store.ts | 17 +++- src/types/connected-accounts.ts | 10 ++- 10 files changed, 250 insertions(+), 91 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index b14505880..27b748e0e 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4358,7 +4358,7 @@ The `getConnectedAccounts` method lists the current user's connected accounts fr - `id`: the unique identifier of the connected account (e.g., `cac_...`). - `connection`: the name of the connection the account is linked through. -- `accessType`: the access type (typically `offline`). +- `accessType` (optional): the access type. Currently returned as `"offline"` by the My Account API when present. - `scopes`: the scopes granted for the connected account. - `createdAt`: ISO date string of when the account was connected. - `expiresAt`: (optional) ISO date string of when the connected account expires. @@ -4381,7 +4381,7 @@ export async function GET() { ``` > [!IMPORTANT] -> Server Components cannot set cookies. Reconciliation performed while calling `getConnectedAccounts()` in a Server Component will not be persisted. Call it from a Route Handler, Server Action, or middleware if you need the reconciled session to be saved. +> Do not call `getConnectedAccounts()` from a React Server Component. Minting the My Account access token can rotate the refresh token, and Server Components cannot write cookies — the rotated token is silently dropped. On the next request the browser still sends the old refresh token, which the authorization server rejects as replay and logs the user out. Call from a Route Handler, Server Action, API route, or middleware. #### On the server (Pages Router) and middleware @@ -4819,9 +4819,11 @@ export const GET = async (req: NextRequest) => { You can retrieve an access token for a connection using the `getAccessTokenForConnection()` method, which accepts an object with the following properties: - `connection`: The federated connection for which an access token should be retrieved. -- `login_hint`: (optional) The login hint identifying which connected account to retrieve a token for. Provide it when a user has connected more than one account on the same connection so the correct one is selected; the token is then cached per `connection` + `login_hint`. When omitted, the token is matched and cached by `connection` alone (the default behavior). +- `login_hint`: (optional) The login hint identifying which connected account to retrieve a token for. Provide it when a user has connected more than one account on the same connection so the correct one is selected; the token is then cached per `connection` + `login_hint`. -Without a login hint (default, single account per connection): +**Multi-account note.** The cache key is `connection` + `login_hint`. A call **with** a hint matches only entries stamped with the same hint. A call **without** a hint matches only entries that also have no hint — it does **not** match hinted entries. This isolation means an unhinted call cannot select and later overwrite a hinted entry, so cached per-account tokens for a connection are preserved across mixed hinted/unhinted usage. Existing sessions written before multi-account support have no hint stamped on any entry, so unhinted calls continue to match them as before (back-compat). + +Without a login hint (single account per connection, or explicitly unhinted flow): ```ts const token = await auth0.getAccessTokenForConnection({ diff --git a/src/errors/index.ts b/src/errors/index.ts index 9de588ba8..decb84359 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -30,8 +30,8 @@ export { MyAccountApiError, ConnectAccountError, ConnectAccountErrorCodes, - DisconnectAccountError, - DisconnectAccountErrorCodes + ConnectedAccountsError, + ConnectedAccountsErrorCodes } from "./my-account-errors.js"; export { diff --git a/src/errors/my-account-errors.ts b/src/errors/my-account-errors.ts index 71c384522..b1cd83301 100644 --- a/src/errors/my-account-errors.ts +++ b/src/errors/my-account-errors.ts @@ -103,9 +103,10 @@ export class ConnectAccountError extends SdkError { } /** - * Enum representing error codes related to the disconnect account flow. + * Enum representing error codes for connected-accounts operations + * (listing and disconnecting). */ -export enum DisconnectAccountErrorCodes { +export enum ConnectedAccountsErrorCodes { /** * The session is missing. */ @@ -123,11 +124,12 @@ export enum DisconnectAccountErrorCodes { } /** - * Error class representing a disconnect account error. + * Error class representing a connected-accounts operation error (listing or + * disconnecting). */ -export class DisconnectAccountError extends SdkError { +export class ConnectedAccountsError extends SdkError { /** - * The error code associated with the disconnect account error. + * The error code associated with the connected-accounts error. */ public code: string; public cause?: MyAccountApiError; @@ -142,7 +144,7 @@ export class DisconnectAccountError extends SdkError { cause?: MyAccountApiError; }) { super(message); - this.name = "DisconnectAccountError"; + this.name = "ConnectedAccountsError"; this.code = code; this.cause = cause; } diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index f27d3c05b..3f0b0e6d4 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -18,7 +18,7 @@ import { BackchannelAuthenticationError, ConnectAccountError, ConnectAccountErrorCodes, - DisconnectAccountError, + ConnectedAccountsError, DPoPError, DPoPErrorCode, InvalidConfigurationError, @@ -10239,7 +10239,7 @@ ca/T0LLtgmbMmxSv/MmzIg== await authClient.listConnectedAccounts(tokenSet); expect(accounts).toBeNull(); - expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error).toBeInstanceOf(ConnectedAccountsError); expect(error?.code).toBe("failed_to_list"); expect(error?.cause?.status).toBe(403); }); @@ -10275,7 +10275,7 @@ ca/T0LLtgmbMmxSv/MmzIg== await authClient.listConnectedAccounts(tokenSet); expect(accounts).toBeNull(); - expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error).toBeInstanceOf(ConnectedAccountsError); expect(error?.code).toBe("failed_to_list"); expect(error?.message).toBe( "Connected-account pagination did not terminate safely." @@ -10297,7 +10297,7 @@ ca/T0LLtgmbMmxSv/MmzIg== await authClient.listConnectedAccounts(tokenSet); expect(accounts).toBeNull(); - expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error).toBeInstanceOf(ConnectedAccountsError); expect(error?.code).toBe("failed_to_list"); expect(error?.message).toBe( "An unexpected error occurred while trying to list the connected accounts." @@ -10449,7 +10449,57 @@ ca/T0LLtgmbMmxSv/MmzIg== ); expect(removed).toBeNull(); - expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error).toBeInstanceOf(ConnectedAccountsError); + expect(error?.code).toBe("failed_to_delete"); + expect(error?.cause?.status).toBe(429); + }); + + it("returns a FAILED_TO_DELETE error even when the first delete succeeded (partial failure)", async () => { + // Two accounts for the same connection: cac_1 unlinks successfully, cac_2 + // fails with 429. The error surfaces so the caller knows the disconnect + // did not fully complete. The caller-side (client.ts) prunes connection + // -scoped cached tokens regardless, so orphaned __FC cookies are cleaned up. + const base = getMockAuthorizationServer({ + listConnectedAccountsResponses: [ + Response.json( + { + accounts: [ + { id: "cac_1", connection: "google-oauth2" }, + { id: "cac_2", connection: "google-oauth2" } + ] + }, + { status: 200 } + ) + ] + }); + const fetchSpy = vi.fn(async (input: any, init?: any) => { + const url = new URL(input instanceof Request ? input.url : input); + if ( + url.pathname === "/me/v1/connected-accounts/accounts/cac_1" && + init?.method === "DELETE" + ) { + return new Response(null, { status: 204 }); + } + if ( + url.pathname === "/me/v1/connected-accounts/accounts/cac_2" && + init?.method === "DELETE" + ) { + return Response.json( + { title: "Too many", detail: "rate limited" }, + { status: 429 } + ); + } + return base(input, init); + }); + const authClient = buildAuthClient(fetchSpy); + + const [error, removed] = await authClient.disconnectAccount( + tokenSet, + "google-oauth2" + ); + + expect(removed).toBeNull(); + expect(error).toBeInstanceOf(ConnectedAccountsError); expect(error?.code).toBe("failed_to_delete"); expect(error?.cause?.status).toBe(429); }); @@ -10483,7 +10533,7 @@ ca/T0LLtgmbMmxSv/MmzIg== ); expect(removed).toBeNull(); - expect(error).toBeInstanceOf(DisconnectAccountError); + expect(error).toBeInstanceOf(ConnectedAccountsError); expect(error?.code).toBe("failed_to_delete"); expect(error?.message).toBe( "An unexpected error occurred while trying to delete the connected account." diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 457129640..f5d5df5e4 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -18,10 +18,10 @@ import { BackchannelLogoutError, ConnectAccountError, ConnectAccountErrorCodes, + ConnectedAccountsError, + ConnectedAccountsErrorCodes, CustomTokenExchangeError, CustomTokenExchangeErrorCode, - DisconnectAccountError, - DisconnectAccountErrorCodes, DiscoveryError, DPoPError, DPoPErrorCode, @@ -4345,7 +4345,7 @@ export class AuthClient { */ async listConnectedAccounts( tokenSet: TokenSet - ): Promise<[null, ConnectedAccount[]] | [DisconnectAccountError, null]> { + ): Promise<[null, ConnectedAccount[]] | [ConnectedAccountsError, null]> { try { const fetcher = await this.fetcherFactory({ useDPoP: this.useDPoP, @@ -4375,8 +4375,8 @@ export class AuthClient { // tokens for omitted accounts, and disconnectAccount would leave // additional matching accounts linked). Fail loudly instead. return [ - new DisconnectAccountError({ - code: DisconnectAccountErrorCodes.FAILED_TO_LIST, + new ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.FAILED_TO_LIST, message: "Connected-account pagination did not terminate safely." }), null @@ -4398,9 +4398,9 @@ export class AuthClient { }); if (!res.ok) { - return buildDisconnectAccountErrorResponse( + return buildConnectedAccountsErrorResponse( res, - DisconnectAccountErrorCodes.FAILED_TO_LIST + ConnectedAccountsErrorCodes.FAILED_TO_LIST ); } @@ -4427,8 +4427,8 @@ export class AuthClient { message = e.message; } return [ - new DisconnectAccountError({ - code: DisconnectAccountErrorCodes.FAILED_TO_LIST, + new ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.FAILED_TO_LIST, message }), null @@ -4439,29 +4439,24 @@ export class AuthClient { /** * Deletes a single connected account by its id via the My Account API. * + * Accepts a pre-built fetcher so callers (e.g. `disconnectAccount`) can reuse + * a single fetcher across multiple deletes. With DPoP enabled, a new fetcher + * starts without a nonce and pays a `use_dpop_nonce` rejection + retry on the + * first request; reusing the fetcher amortises that to one round-trip total + * instead of one per account. + * * @see https://auth0.com/docs/api/myaccount/connected-accounts/delete-connected-account */ private async deleteConnectedAccount( - tokenSet: TokenSet, + fetcher: Fetcher, id: string - ): Promise<[null, null] | [DisconnectAccountError, null]> { + ): Promise<[null, null] | [ConnectedAccountsError, null]> { try { const url = new URL( `/me/v1/connected-accounts/accounts/${encodeURIComponent(id)}`, this.issuer ); - const fetcher = await this.fetcherFactory({ - useDPoP: this.useDPoP, - getAccessToken: async () => ({ - accessToken: tokenSet.accessToken, - expiresAt: tokenSet.expiresAt || 0, - scope: tokenSet.scope, - token_type: tokenSet.token_type - }), - fetch: this.fetch - }); - const res = await fetcher.fetchWithAuth(url.toString(), { method: "DELETE", headers: { @@ -4470,9 +4465,9 @@ export class AuthClient { }); if (!res.ok) { - return buildDisconnectAccountErrorResponse( + return buildConnectedAccountsErrorResponse( res, - DisconnectAccountErrorCodes.FAILED_TO_DELETE + ConnectedAccountsErrorCodes.FAILED_TO_DELETE ); } @@ -4484,8 +4479,8 @@ export class AuthClient { message = e.message; } return [ - new DisconnectAccountError({ - code: DisconnectAccountErrorCodes.FAILED_TO_DELETE, + new ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.FAILED_TO_DELETE, message }), null @@ -4505,7 +4500,7 @@ export class AuthClient { async disconnectAccount( tokenSet: TokenSet, connection: string - ): Promise<[null, ConnectedAccount[]] | [DisconnectAccountError, null]> { + ): Promise<[null, ConnectedAccount[]] | [ConnectedAccountsError, null]> { const [listError, accounts] = await this.listConnectedAccounts(tokenSet); if (listError) { return [listError, null]; @@ -4515,10 +4510,29 @@ export class AuthClient { (account) => account.connection === connection ); + if (matching.length === 0) { + return [null, []]; + } + + // Build the fetcher once and reuse it across every DELETE. With DPoP + // enabled, per-account fetchers would each start with an empty nonce cache + // and pay a `use_dpop_nonce` rejection + retry — turning N deletes into 2N + // requests. `listConnectedAccounts` already does this for pagination. + const fetcher = await this.fetcherFactory({ + useDPoP: this.useDPoP, + getAccessToken: async () => ({ + accessToken: tokenSet.accessToken, + expiresAt: tokenSet.expiresAt || 0, + scope: tokenSet.scope, + token_type: tokenSet.token_type + }), + fetch: this.fetch + }); + const removed: ConnectedAccount[] = []; for (const account of matching) { const [deleteError] = await this.deleteConnectedAccount( - tokenSet, + fetcher, account.id ); if (deleteError) { @@ -6899,19 +6913,19 @@ export async function buildConnectAccountErrorResponse( } } -export async function buildDisconnectAccountErrorResponse( +export async function buildConnectedAccountsErrorResponse( res: Response, - errorCode: DisconnectAccountErrorCodes -): Promise<[DisconnectAccountError, null]> { + errorCode: ConnectedAccountsErrorCodes +): Promise<[ConnectedAccountsError, null]> { const actionVerb = - errorCode === DisconnectAccountErrorCodes.FAILED_TO_LIST + errorCode === ConnectedAccountsErrorCodes.FAILED_TO_LIST ? "list the connected accounts" : "delete the connected account"; try { const errorBody = await res.json(); return [ - new DisconnectAccountError({ + new ConnectedAccountsError({ code: errorCode, message: `The request to ${actionVerb} failed with status ${res.status}.`, cause: new MyAccountApiError({ @@ -6926,7 +6940,7 @@ export async function buildDisconnectAccountErrorResponse( ]; } catch (e) { return [ - new DisconnectAccountError({ + new ConnectedAccountsError({ code: errorCode, message: `The request to ${actionVerb} failed with status ${res.status}.` }), diff --git a/src/server/client.test.ts b/src/server/client.test.ts index 36c00903f..601522616 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -715,6 +715,56 @@ describe("Auth0Client", () => { expect(result.token).toBe("fc_bob"); }); + it("no-hint call does not match hinted entries (multi-account isolation)", async () => { + // Regression: an unhinted call must not select a hinted entry and then + // overwrite it with an unhinted token, which would erase the hint. If no + // hinted-less entry exists, treat as a cache miss (undefined) → fresh exchange. + const fresh = Math.floor(Date.now() / 1000) + 3600; + const session = baseSession([ + { + connection: "google-oauth2", + accessToken: "fc_alice", + expiresAt: fresh, + loginHint: "alice@example.com" + }, + { + connection: "google-oauth2", + accessToken: "fc_bob", + expiresAt: fresh, + loginHint: "bob@example.com" + } + ]); + const getConnectionTokenSet = vi.fn(async () => [ + null, + { + connection: "google-oauth2", + accessToken: "fc_new", + expiresAt: fresh + } + ]); + mockAuthClient(session, undefined, getConnectionTokenSet as any); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + + await client.getAccessTokenForConnection({ connection: "google-oauth2" }); + + // No hinted-less entry existed, so getConnectionTokenSet must have been + // called with `undefined` (cache miss) rather than one of the hinted entries. + expect(getConnectionTokenSet).toHaveBeenCalledWith( + session.tokenSet, + undefined, + expect.objectContaining({ connection: "google-oauth2" }) + ); + // The new unhinted token is appended; Alice and Bob are preserved intact. + const saved = saveToSession.mock.calls[0][0] as SessionData; + expect(saved.connectionTokenSets).toEqual([ + expect.objectContaining({ loginHint: "alice@example.com" }), + expect.objectContaining({ loginHint: "bob@example.com" }), + expect.objectContaining({ accessToken: "fc_new" }) + ]); + }); + it("matches on connection alone when no login hint is provided (back-compat)", async () => { const fresh = Math.floor(Date.now() / 1000) + 3600; const session = baseSession([ @@ -1234,7 +1284,7 @@ describe("Auth0Client", () => { return mockAuthClient; } - it("throws DisconnectAccountError when there is no session", async () => { + it("throws ConnectedAccountsError when there is no session", async () => { vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( null ); @@ -1348,9 +1398,14 @@ describe("Auth0Client", () => { expect(saveToSession).not.toHaveBeenCalled(); }); - it("propagates the error from the auth client and does not save", async () => { + it("prunes cached connection tokens then rethrows when the disconnect partially fails", async () => { + // When multiple accounts share a connection and only some unlink before + // an error, the server-side state is partially disconnected while cached + // tokens are now stale. Prune connection-scoped local state so we don't + // leak orphaned __FC cookies, then rethrow so the caller sees the error. const session = sessionWith([ - { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 } + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, + { connection: "github", accessToken: "fc_gh", expiresAt: 999 } ]); vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( session @@ -1370,7 +1425,13 @@ describe("Auth0Client", () => { await expect( client.disconnectAccount({ connection: "google-oauth2" }) ).rejects.toThrow("delete failed"); - expect(saveToSession).not.toHaveBeenCalled(); + + // Session was pruned: google-oauth2 entry gone, github survives. + expect(saveToSession).toHaveBeenCalledOnce(); + const saved = saveToSession.mock.calls[0][0] as SessionData; + expect(saved.connectionTokenSets).toEqual([ + expect.objectContaining({ connection: "github" }) + ]); }); it("Pages Router: threads req/res through session read, token mint, and save", async () => { @@ -1568,7 +1629,7 @@ describe("Auth0Client", () => { return mockAuthClient; } - it("throws DisconnectAccountError when there is no session", async () => { + it("throws ConnectedAccountsError when there is no session", async () => { vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( null ); diff --git a/src/server/client.ts b/src/server/client.ts index 06b51e306..b822386d5 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -11,8 +11,8 @@ import { AccessTokenForConnectionErrorCode, ConnectAccountError, ConnectAccountErrorCodes, - DisconnectAccountError, - DisconnectAccountErrorCodes, + ConnectedAccountsError, + ConnectedAccountsErrorCodes, InvalidConfigurationError, MfaRequiredError, TokenRevocationError, @@ -1147,14 +1147,19 @@ export class Auth0Client { ); } - // Find the connection token set in the session. When a login hint is - // provided, match on it too so multiple accounts can be connected for the - // same connection. Without a login hint, match on connection alone - // (preserving existing behaviour). + // Find the connection token set in the session. Treat "no hint" as its own + // key rather than a wildcard: a call without `login_hint` matches only + // entries that also have no `loginHint`. This preserves multi-account + // isolation — an unhinted call cannot select a hinted entry and later + // overwrite it with an unhinted token, which would erase the hint from the + // session. Sessions written before the multi-account feature have no + // `loginHint` on any entry, so unhinted calls still match them (back-compat). const existingTokenSet = session.connectionTokenSets?.find( (tokenSet) => tokenSet.connection === options.connection && - (!options.login_hint || tokenSet.loginHint === options.login_hint) + (options.login_hint + ? tokenSet.loginHint === options.login_hint + : !tokenSet.loginHint) ); const [error, retrievedTokenSet] = await authClient.getConnectionTokenSet( @@ -1993,7 +1998,7 @@ export class Auth0Client { * the corresponding cached connection tokens from the session so they are not * re-assembled on subsequent reads. * - * If the user does not have an active session, a `DisconnectAccountError` is thrown. + * If the user does not have an active session, a `ConnectedAccountsError` is thrown. * * In the Pages Router (or middleware), pass the `req` and `res` objects so the * pruned session can be persisted to the response cookies. In App Router Server @@ -2018,8 +2023,8 @@ export class Auth0Client { ); if (!session) { - throw new DisconnectAccountError({ - code: DisconnectAccountErrorCodes.MISSING_SESSION, + throw new ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.MISSING_SESSION, message: "The user does not have an active session." }); } @@ -2052,13 +2057,15 @@ export class Auth0Client { options.connection ); - if (error) { - throw error; - } - - // Remove the cached connection tokens for this connection so they are not - // re-assembled into the session on the next read. When no entries remain, - // omit the property entirely (mirroring how the store persists it). + // Prune cached connection tokens for this connection regardless of whether + // the loop over accounts fully succeeded. When multiple accounts share a + // connection and only some are unlinked before an error (e.g. rate limit + // on the second DELETE), the server-side state is partially disconnected + // while our cached tokens for the connection are now stale. Pruning is + // connection-scoped, so it's safe to prune all local state for the + // connection: a subsequent getAccessTokenForConnection would fail-exchange + // and prune anyway. Rethrow the error after pruning so the caller sees the + // partial failure. // // Prune from the session the mint produced, not a fresh cookie re-read. In // the Pages Router, request cookies are rebuilt from the original request @@ -2083,6 +2090,10 @@ export class Auth0Client { } } + if (error) { + throw error; + } + // Nothing was pruned, but the mint may have rotated the token set. Persist // it here since mintMyAccountToken was told not to (persist: false). if (!pruned && accessToken.sessionChanged) { @@ -2113,15 +2124,19 @@ export class Auth0Client { * The My Account API is the source of truth, so this also reconciles the * session: any locally cached connection tokens (`connectionTokenSets`) whose * connection is no longer present server-side are pruned, so they are not - * re-assembled into the session on subsequent reads. Because this reconciles - * (and may write cookies), call it from a context that can set cookies. + * re-assembled into the session on subsequent reads. * - * In the Pages Router (or middleware), pass the `req` and `res` objects so the - * reconciled session can be persisted to the response cookies. In App Router - * Route Handlers and Server Actions, omit them (a Server Component cannot set - * cookies, so reconciliation there will not be persisted). + * **Do not call this from a React Server Component.** Minting the My Account + * access token can rotate the refresh token, and Server Components cannot + * write cookies — the write is silently dropped (only warned in + * `NODE_ENV=development`). On the next request the browser still sends the + * old refresh token, which the authorization server rejects as replay and + * logs the user out. Call from a Route Handler, Server Action, API route, + * or middleware. In the Pages Router (or middleware), pass the `req` and + * `res` objects so the reconciled session can be persisted to the response + * cookies. * - * If the user does not have an active session, a `DisconnectAccountError` is thrown. + * If the user does not have an active session, a `ConnectedAccountsError` is thrown. * * Note: reconciliation is connection-scoped. If a user has multiple accounts * on the same connection and only some are disconnected server-side, the @@ -2140,8 +2155,8 @@ export class Auth0Client { ); if (!session) { - throw new DisconnectAccountError({ - code: DisconnectAccountErrorCodes.MISSING_SESSION, + throw new ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.MISSING_SESSION, message: "The user does not have an active session." }); } diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 70184b1af..7b536354f 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -360,7 +360,7 @@ describe("Stateless Session Store", async () => { const headers = new Headers(); headers.append( "cookie", - `__session=${encryptedCookieValue};__FC.0=${encryptedGoogleConnectionCookieValue}` + `__session=${encryptedCookieValue};__FC_0=${encryptedGoogleConnectionCookieValue}` ); const requestCookies = new RequestCookies(headers); @@ -418,7 +418,7 @@ describe("Stateless Session Store", async () => { const headers = new Headers(); headers.append( "cookie", - `__session=${encryptedCookieValue};__FC.0=${encryptedGoogleConnectionCookieValue};__FC.1=${encryptedGithubConnectionCookieValue}` + `__session=${encryptedCookieValue};__FC_0=${encryptedGoogleConnectionCookieValue};__FC_1=${encryptedGithubConnectionCookieValue}` ); const requestCookies = new RequestCookies(headers); diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index 793d57406..a4522cf11 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -146,6 +146,13 @@ export class StatelessSessionStore extends AbstractSessionStore { // disconnected), the trailing higher-index cookies would otherwise linger as // orphans and be re-assembled into the session on the next read. Delete any // `__FC_i` present in the request whose index is beyond the current length. + // + // Deliberately snapshot-scan here rather than deleting a deterministic + // `__FC_0..MAX-1` range (as `__session__*` does): `__FC` has no hard cap on + // the number of connected accounts a user can have, and the count we're + // shrinking to is authoritative from the server-side delete/list rather + // than a local decision. Deleting an arbitrary "safety" range would either + // cap accounts or emit meaningless tombstones on every write. for (const cookie of this.getConnectionTokenSetsCookies(reqCookies)) { const index = this.parseConnectionTokenSetCookieIndex(cookie.name); // Only reconcile cookies this store wrote (`__FC_`). Any other @@ -273,10 +280,16 @@ export class StatelessSessionStore extends AbstractSessionStore { private getConnectionTokenSetsCookies( cookies: cookies.RequestCookies | cookies.ResponseCookies ) { + // Match the exact `_` shape this store writes, not a loose + // `startsWith(prefix)`. Keeps `get()`, `delete()`, and the orphan sweep in + // agreement on what counts as one of ours — otherwise a stray `__FCcustom` + // cookie set by other code could be read into `connectionTokenSets`, + // rewritten into an indexed slot, but never cleaned. return cookies .getAll() - .filter((cookie) => - cookie.name.startsWith(this.connectionTokenSetsCookieName) + .filter( + (cookie) => + this.parseConnectionTokenSetCookieIndex(cookie.name) !== null ); } diff --git a/src/types/connected-accounts.ts b/src/types/connected-accounts.ts index 43a9bf6ec..e284cd28d 100644 --- a/src/types/connected-accounts.ts +++ b/src/types/connected-accounts.ts @@ -124,9 +124,9 @@ export interface CompleteConnectAccountResponse { */ connection: string; /** - * The access type, always 'offline'. + * The access type, always `"offline"` for the Connect Account response. */ - accessType: string; + accessType: "offline"; /** * Array of scopes granted. */ @@ -171,9 +171,11 @@ export interface ConnectedAccount { */ connection: string; /** - * The access type, always 'offline'. + * The access type. Currently always returned as `"offline"` by the My + * Account API, but typed as optional since the field is not guaranteed on + * the response. */ - accessType?: string; + accessType?: "offline"; /** * Array of scopes granted for this connected account. */ From d7e56c092e6a0c5f6a379e5852c955a2e1d5bbc0 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 21 Aug 2026 21:23:40 +0530 Subject: [PATCH 7/7] fix: addressed review findings --- src/server/client.test.ts | 143 ++++++++++++++++++++++++++++++++++++++ src/server/client.ts | 61 ++++++++++++++-- 2 files changed, 197 insertions(+), 7 deletions(-) diff --git a/src/server/client.test.ts b/src/server/client.test.ts index 601522616..c90d96542 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1181,6 +1181,48 @@ describe("Auth0Client", () => { ).rejects.toThrow("connect failed"); }); + it("persists a rotated token set before rethrowing when connect fails", async () => { + // There is no redirect response on the error path, so the rotated session + // is written best-effort via saveToSession (App Router ambient cookies). + // Without this the rotation is dropped and the next refresh logs the user + // out. + const session = sessionWith(); + const rotated = { + ...session, + tokenSet: { ...session.tokenSet, refreshToken: "rotated_refresh_token" } + }; + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session: rotated, + sessionChanged: true + }); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + mockAuthClientWith( + vi.fn().mockResolvedValue([new Error("connect failed"), null]) + ); + + await expect( + client.connectAccount({ connection: "google-oauth2" }) + ).rejects.toThrow("connect failed"); + + expect(saveToSession).toHaveBeenCalledWith( + expect.objectContaining({ + tokenSet: expect.objectContaining({ + refreshToken: "rotated_refresh_token" + }) + }), + undefined, + undefined + ); + }); + it("persists a rotated refresh token onto the redirect response", async () => { const session = sessionWith(); vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( @@ -1434,6 +1476,49 @@ describe("Auth0Client", () => { ]); }); + it("persists a rotated token set before rethrowing when there is nothing to prune", async () => { + // No cached tokens for the connection, so pruning writes nothing. The mint + // still rotated the refresh token, and the disconnect then failed. The + // rotation must be persisted before the rethrow, otherwise the next + // refresh replays the old token and the user is logged out. + const session = sessionWith(undefined); + const rotated = { + ...session, + tokenSet: { ...session.tokenSet, refreshToken: "rotated_refresh_token" } + }; + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session: rotated, + sessionChanged: true + }); + mockAuthClientWith( + vi.fn().mockResolvedValue([new Error("delete failed"), null]) + ); + + await expect( + client.disconnectAccount({ connection: "google-oauth2" }) + ).rejects.toThrow("delete failed"); + + expect(saveToSession).toHaveBeenCalledTimes(1); + expect(saveToSession).toHaveBeenCalledWith( + expect.objectContaining({ + tokenSet: expect.objectContaining({ + refreshToken: "rotated_refresh_token" + }) + }), + undefined, + undefined + ); + }); + it("Pages Router: threads req/res through session read, token mint, and save", async () => { const session = sessionWith([ { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, @@ -1765,6 +1850,64 @@ describe("Auth0Client", () => { ); }); + it("persists a rotated token set before rethrowing when the list fails", async () => { + // The first list call in a session always rotates the refresh token (the + // My Account audience is not cached yet). If the list then fails, the + // rotation must still be persisted, otherwise the next refresh replays the + // old token and the user is logged out. + const session = sessionWith(undefined); + const rotatedSession = sessionWith(undefined); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session: rotatedSession, + sessionChanged: true + }); + const listError = new Error("list failed"); + mockAuthClientWith(vi.fn().mockResolvedValue([listError, null])); + + await expect(client.getConnectedAccounts()).rejects.toThrow( + "list failed" + ); + expect(saveToSession).toHaveBeenCalledWith( + rotatedSession, + undefined, + undefined + ); + }); + + it("does not persist on a failed list when the token was not rotated", async () => { + const session = sessionWith(undefined); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const saveToSession = vi + .spyOn(client as any, "saveToSession") + .mockResolvedValue(undefined); + vi.spyOn(client as any, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session, + sessionChanged: false + }); + mockAuthClientWith( + vi.fn().mockResolvedValue([new Error("list failed"), null]) + ); + + await expect(client.getConnectedAccounts()).rejects.toThrow( + "list failed" + ); + expect(saveToSession).not.toHaveBeenCalled(); + }); + it("Pages Router: threads req/res through session read, token mint, and reconcile save", async () => { const session = sessionWith([ { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 999 }, diff --git a/src/server/client.ts b/src/server/client.ts index b822386d5..86fbbb391 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1897,6 +1897,14 @@ export class Auth0Client { * response), a Pages Router `ServerResponse` is not accepted here; forward the * returned response's `Location` and `Set-Cookie` headers onto your response. * + * Minting the My Account access token can rotate the refresh token. On success + * that rotation is written onto the returned redirect response's cookies, so + * forwarding its `Set-Cookie` headers (as above) persists it. On the error path + * there is no response to attach to, so the rotation is persisted best-effort + * via ambient cookies, which a React Server Component cannot write; prefer a + * Route Handler, Server Action, API route, or middleware so a failed connect + * does not drop the rotated token and log the user out on the next refresh. + * * You must enable `Offline Access` from the Connection Permissions settings to be able to use the connection with Connected Accounts. */ async connectAccount( @@ -1950,6 +1958,15 @@ export class Auth0Client { ); if (error) { + // The mint may have rotated the refresh token before connectAccount + // failed. There is no redirect response to attach cookies to on the error + // path, so persist the rotated session best-effort (App Router ambient + // cookies; a no-op in middleware/Pages, where there is no response to + // write to here). This avoids dropping the rotation, which would trigger + // reuse-detection and log the user out on the next refresh. + if (accessToken.sessionChanged) { + await this.saveToSession(accessToken.session, undefined, undefined); + } throw error; } @@ -1998,7 +2015,24 @@ export class Auth0Client { * the corresponding cached connection tokens from the session so they are not * re-assembled on subsequent reads. * - * If the user does not have an active session, a `ConnectedAccountsError` is thrown. + * If the user does not have an active session, a `ConnectedAccountsError` is + * thrown with code `MISSING_SESSION`. If the server-side revoke fails, a + * `ConnectedAccountsError` with code `FAILED_TO_DELETE` is thrown. + * + * **Do not call this from a React Server Component.** Minting the My Account + * access token can rotate the refresh token, and Server Components cannot + * write cookies, so the write is silently dropped (only warned in + * `NODE_ENV=development`). On the next request the browser still sends the old + * refresh token, which the authorization server rejects as replay and logs the + * user out. Call from a Route Handler, Server Action, API route, or middleware. + * + * Partial-failure contract: the local cached connection tokens + * (`connectionTokenSets`) for the connection are pruned from the session + * regardless of whether the server-side revoke succeeded, and any rotated + * refresh token is persisted, before the error (if any) is rethrown. So on a + * `FAILED_TO_DELETE` the server-side state may be partially disconnected while + * the local state is fully cleaned; a subsequent `getAccessTokenForConnection` + * would fail-exchange and prune anyway. * * In the Pages Router (or middleware), pass the `req` and `res` objects so the * pruned session can be persisted to the response cookies. In App Router Server @@ -2090,15 +2124,20 @@ export class Auth0Client { } } - if (error) { - throw error; - } - - // Nothing was pruned, but the mint may have rotated the token set. Persist - // it here since mintMyAccountToken was told not to (persist: false). + // If nothing was pruned, the mint may still have rotated the token set. + // Persist it here since mintMyAccountToken was told not to (persist: false). + // This runs before the rethrow below so a rotated refresh token is never + // dropped on the error path (which would trigger reuse-detection and log the + // user out on the next refresh). The prune write above already carries the + // rotation, so at most one write happens. if (!pruned && accessToken.sessionChanged) { await this.saveToSession(latestSession, normalizedReq, res); } + + // Rethrow after persisting so the caller sees the partial failure. + if (error) { + throw error; + } } /** @@ -2186,6 +2225,14 @@ export class Auth0Client { }); if (error) { + // The mint may have rotated the refresh token before the list failed + // (the first call in a session always refreshes, since the My Account + // audience is not cached yet). Persist the rotation before rethrowing so + // it is not dropped, which would trigger reuse-detection and log the user + // out on the next refresh. + if (accessToken.sessionChanged) { + await this.saveToSession(accessToken.session, normalizedReq, res); + } throw error; }