diff --git a/EXAMPLES.md b/EXAMPLES.md index 72aad1a86..27b748e0e 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` (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. +- `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] +> 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 + +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,29 @@ 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`. + +**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({ + 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/errors/index.ts b/src/errors/index.ts index 6dc92b2ac..decb84359 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, + 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 e4b599508..b1cd83301 100644 --- a/src/errors/my-account-errors.ts +++ b/src/errors/my-account-errors.ts @@ -101,3 +101,51 @@ export class ConnectAccountError extends SdkError { this.cause = cause; } } + +/** + * Enum representing error codes for connected-accounts operations + * (listing and disconnecting). + */ +export enum ConnectedAccountsErrorCodes { + /** + * 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 connected-accounts operation error (listing or + * disconnecting). + */ +export class ConnectedAccountsError extends SdkError { + /** + * The error code associated with the connected-accounts error. + */ + public code: string; + public cause?: MyAccountApiError; + + constructor({ + code, + message, + cause + }: { + code: string; + message: string; + cause?: MyAccountApiError; + }) { + super(message); + 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 f8382cf7d..3f0b0e6d4 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -18,6 +18,9 @@ import { BackchannelAuthenticationError, ConnectAccountError, ConnectAccountErrorCodes, + ConnectedAccountsError, + DPoPError, + DPoPErrorCode, InvalidConfigurationError, MyAccountApiError, TokenRevocationError, @@ -32,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"; @@ -118,7 +121,11 @@ ca/T0LLtgmbMmxSv/MmzIg== onCompleteConnectAccountRequest, completeConnectAccountErrorResponse, onRevocationRequest, - revocationErrorResponse + revocationErrorResponse, + onListConnectedAccountsRequest, + listConnectedAccountsResponses, + onDeleteConnectedAccountRequest, + deleteConnectedAccountErrorResponse }: { tokenEndpointResponse?: oauth.TokenEndpointResponse | oauth.OAuth2Error; tokenEndpointErrorResponse?: oauth.OAuth2Error; @@ -134,7 +141,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 +298,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 +9555,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 +9660,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 +10108,517 @@ 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(ConnectedAccountsError); + expect(error?.code).toBe("failed_to_list"); + expect(error?.cause?.status).toBe(403); + }); + + 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. 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) => { + 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(accounts).toBeNull(); + expect(error).toBeInstanceOf(ConnectedAccountsError); + 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); + }); + + 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(ConnectedAccountsError); + 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 () => { + 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(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); + }); + + 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(ConnectedAccountsError); + 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 () => { 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..f5d5df5e4 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -18,6 +18,8 @@ import { BackchannelLogoutError, ConnectAccountError, ConnectAccountErrorCodes, + ConnectedAccountsError, + ConnectedAccountsErrorCodes, CustomTokenExchangeError, CustomTokenExchangeErrorCode, DiscoveryError, @@ -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 } : {}) } ]; } @@ -4246,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 [ @@ -4331,6 +4335,215 @@ 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[]] | [ConnectedAccountsError, 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; + // `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))) { + // 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 ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.FAILED_TO_LIST, + message: "Connected-account pagination did not terminate safely." + }), + null + ]; + } + if (next) { + seenNext.add(next); + } + 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 buildConnectedAccountsErrorResponse( + res, + ConnectedAccountsErrorCodes.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 (isDPoPError(e)) { + message = e.message; + } + return [ + new ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.FAILED_TO_LIST, + message + }), + null + ]; + } + } + + /** + * 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( + fetcher: Fetcher, + id: string + ): Promise<[null, null] | [ConnectedAccountsError, null]> { + try { + const url = new URL( + `/me/v1/connected-accounts/accounts/${encodeURIComponent(id)}`, + this.issuer + ); + + const res = await fetcher.fetchWithAuth(url.toString(), { + method: "DELETE", + headers: { + "Content-Type": "application/json" + } + }); + + if (!res.ok) { + return buildConnectedAccountsErrorResponse( + res, + ConnectedAccountsErrorCodes.FAILED_TO_DELETE + ); + } + + return [null, null]; + } catch (e: any) { + let message = + "An unexpected error occurred while trying to delete the connected account."; + if (isDPoPError(e)) { + message = e.message; + } + return [ + new ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.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[]] | [ConnectedAccountsError, null]> { + const [listError, accounts] = await this.listConnectedAccounts(tokenSet); + if (listError) { + return [listError, null]; + } + + const matching = accounts.filter( + (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( + fetcher, + account.id + ); + if (deleteError) { + return [deleteError, null]; + } + removed.push(account); + } + + return [null, removed]; + } + private async getOpenIdClientConfig(): Promise< [null, client.Configuration] | [SdkError, null] > { @@ -6700,6 +6913,62 @@ export async function buildConnectAccountErrorResponse( } } +export async function buildConnectedAccountsErrorResponse( + res: Response, + errorCode: ConnectedAccountsErrorCodes +): Promise<[ConnectedAccountsError, null]> { + const actionVerb = + errorCode === ConnectedAccountsErrorCodes.FAILED_TO_LIST + ? "list the connected accounts" + : "delete the connected account"; + + try { + const errorBody = await res.json(); + return [ + new ConnectedAccountsError({ + 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 ConnectedAccountsError({ + code: errorCode, + message: `The request to ${actionVerb} failed with status ${res.status}.` + }), + null + ]; + } +} + +/** + * 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 && + "message" in e && + typeof (e as { message: unknown }).message === "string" && + 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 e92c16629..c90d96542 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1,7 +1,13 @@ +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 { + AccessTokenError, + AccessTokenErrorCode, + AccessTokenForConnectionError, + AccessTokenForConnectionErrorCode, DomainResolutionError, InvalidConfigurationError, TokenRevocationError, @@ -579,6 +585,1662 @@ 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("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([ + { + 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" }) + ); + }); + + 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("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"] + ): 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 () => { + const session = sessionWith(); + vi.spyOn(client as any, "getSessionFromAuthClient").mockResolvedValue( + session + ); + const mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + 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(mintMyAccountToken).toHaveBeenCalledWith( + expect.objectContaining({ + audience: "https://test.auth0.com/me/", + scope: "create:me:connected_accounts" + }), + undefined, + undefined, + { persist: false } + ); + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session: sessionWith() + }); + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session: sessionWith() + }); + const connectError = new Error("connect failed"); + mockAuthClientWith(vi.fn().mockResolvedValue([connectError, null])); + + await expect( + client.connectAccount({ connection: "google-oauth2" }) + ).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( + 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", () => { + 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 ConnectedAccountsError 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 mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + const disconnect = vi.fn().mockResolvedValue([null, []]); + mockAuthClientWith(disconnect); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + // Correct My Account audience + scopes were requested. + 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( + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + mockAuthClientWith(vi.fn().mockResolvedValue([null, []])); + + await client.disconnectAccount({ connection: "google-oauth2" }); + + expect(saveToSession).not.toHaveBeenCalled(); + }); + + 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: "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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + const disconnectError = new Error("delete failed"); + mockAuthClientWith(vi.fn().mockResolvedValue([disconnectError, null])); + + await expect( + client.disconnectAccount({ connection: "google-oauth2" }) + ).rejects.toThrow("delete failed"); + + // 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("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 }, + { 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 mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + 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 + ); + // 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( + expect.objectContaining({ + connectionTokenSets: [ + expect.objectContaining({ connection: "github" }) + ] + }), + req, + res + ); + }); + + 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. 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" } + }; + // 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); + // 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/", + session: postMint + }); + 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" }) + ]); + }); + + 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", () => { + 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 ConnectedAccountsError 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 mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + 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(mintMyAccountToken).toHaveBeenCalledWith( + expect.objectContaining({ + audience: "https://test.auth0.com/me/", + scope: "read:me:connected_accounts" + }), + undefined, + undefined, + { persist: false } + ); + }); + + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + // 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + const listError = new Error("list failed"); + mockAuthClientWith(vi.fn().mockResolvedValue([listError, null])); + + await expect(client.getConnectedAccounts()).rejects.toThrow( + "list failed" + ); + }); + + 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 }, + { 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 mintMyAccountToken = vi + .spyOn(client as any, "mintMyAccountToken") + .mockResolvedValue({ + token: "my_account_token", + expiresAt: 12345, + audience: "https://test.auth0.com/me/", + session + }); + // 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 + ); + // 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, + { persist: false } + ); + // 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 + ); + }); + + 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 + // 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); + // 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"); + 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; + } + + // 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + audience: "https://test.auth0.com/me/", + session + }); + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + audience: "https://test.auth0.com/me/", + session + }); + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + audience: "https://test.auth0.com/me/", + session + }); + 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, "mintMyAccountToken").mockResolvedValue({ + token: "my_account_token", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + 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({ + 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..86fbbb391 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -11,6 +11,8 @@ import { AccessTokenForConnectionErrorCode, ConnectAccountError, ConnectAccountErrorCodes, + ConnectedAccountsError, + ConnectedAccountsErrorCodes, InvalidConfigurationError, MfaRequiredError, TokenRevocationError, @@ -22,8 +24,10 @@ import { AuthorizationParameters, BackchannelAuthenticationOptions, ConnectAccountOptions, + ConnectedAccount, CustomTokenExchangeOptions, CustomTokenExchangeResponse, + DisconnectAccountOptions, GetAccessTokenOptions, LogoutStrategy, SessionData, @@ -1143,9 +1147,19 @@ export class Auth0Client { ); } - // Find the connection token set in the session + // 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 + (tokenSet) => + tokenSet.connection === options.connection && + (options.login_hint + ? tokenSet.loginHint === options.login_hint + : !tokenSet.loginHint) ); const [error, retrievedTokenSet] = await authClient.getConnectionTokenSet( @@ -1155,6 +1169,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; } @@ -1174,10 +1210,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 - ? retrievedTokenSet - : tokenSet + tokenSet === existingTokenSet ? retrievedTokenSet : tokenSet ); } else { tokenSets = [...(session.connectionTokenSets || []), retrievedTokenSet]; @@ -1702,6 +1740,96 @@ export class Auth0Client { return { authClient }; } + /** + * 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 mintMyAccountToken( + options: GetAccessTokenOptions & { audience: string; scope: string }, + req?: NextRequest | PagesRouterRequest, + 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 { + token: tokenSet.accessToken, + expiresAt: tokenSet.expiresAt, + audience: tokenSet.audience, + session: persistedSession, + sessionChanged + }; + } + async startInteractiveLogin( options: StartInteractiveLoginOptions = {} ): Promise { @@ -1732,6 +1860,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 +1889,34 @@ 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. + * + * 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(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({ @@ -1764,23 +1933,345 @@ 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({ - ...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) { + // 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; + } + + // 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; + } + + /** + * 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 `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 + * 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 ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.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" + }; + + // 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, + { persist: false } + ); + + const [error] = await authClient.disconnectAccount( + { accessToken: accessToken.token, expiresAt: accessToken.expiresAt, scope: getMyAccountTokenOpts.scope, audience: accessToken.audience + }, + options.connection + ); + + // 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 + // 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 + ); + + if (remaining.length !== latestSession.connectionTokenSets.length) { + const { connectionTokenSets: _removed, ...rest } = latestSession; + await this.saveToSession( + remaining.length ? { ...rest, connectionTokenSets: remaining } : rest, + normalizedReq, + res + ); + pruned = true; } + } + + // 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; + } + } + + /** + * 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. + * + * **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 `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 + * 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 ConnectedAccountsError({ + code: ConnectedAccountsErrorCodes.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" + }; + + // 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, + { persist: false } + ); + + const [error, accounts] = await authClient.listConnectedAccounts({ + accessToken: accessToken.token, + expiresAt: accessToken.expiresAt, + scope: getMyAccountTokenOpts.scope, + audience: accessToken.audience }); 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; } - return connectAccountResponse; + // Reconcile: drop cached connection tokens whose connection is no longer + // present server-side. + // + // 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) + ); + const remaining = latestSession.connectionTokenSets.filter((tokenSet) => + serverConnections.has(tokenSet.connection) + ); + + if (remaining.length !== latestSession.connectionTokenSets.length) { + const { connectionTokenSets: _removed, ...rest } = latestSession; + await this.saveToSession( + remaining.length ? { ...rest, connectionTokenSets: remaining } : rest, + 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; } // 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..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); @@ -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..a4522cf11 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,37 @@ 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. + // + // 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 + // `__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 + }); + // 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); + } + } + // 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. @@ -248,10 +280,35 @@ 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 ); } + + /** + * 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..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. */ @@ -140,3 +140,57 @@ 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. Currently always returned as `"offline"` by the My + * Account API, but typed as optional since the field is not guaranteed on + * the response. + */ + accessType?: "offline"; + /** + * 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; }