diff --git a/EXAMPLES.md b/EXAMPLES.md index c4ec210c..882078d2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -40,6 +40,7 @@ - [Transaction Cookie Configuration](#transaction-cookie-configuration) - [Database sessions](#database-sessions) - [Back-Channel Authentication](#back-channel-authentication) +- [Connected Accounts](#connected-accounts) - [Back-Channel Logout](#back-channel-logout) - [Combining middleware](#combining-middleware) - [ID Token claims and the user object](#id-token-claims-and-the-user-object) @@ -1331,6 +1332,84 @@ const tokenResponse = await auth0.getTokenByBackchannelAuth({ > Using Client-Initiated Backchannel Authentication requires the feature to be enabled in the Auth0 dashboard. > Read [the Auth0 docs](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-initiated-backchannel-authentication-flow) to learn more about Client-Initiated Backchannel Authentication. +## Connected Accounts + +The SDK can be configured to mount an endpoint to facilitate the connected accounts flow. To mount this route, set the `enableConnectAccountEndpoint` option to `true` when instantiating the Auth0 client, like so: + +```ts +// ./lib/auth0.ts +import { Auth0Client } from "@auth0/nextjs-auth0/server"; + +export const auth0 = new Auth0Client({ + enableConnectAccountEndpoint: true +}); +``` + +By default, the route will be mounted at `/auth/connect`. You can customize this path by specifying a `routes.connectAccount` option, like so: + +```ts +// ./lib/auth0.ts +import { Auth0Client } from "@auth0/nextjs-auth0/server"; + +export const auth0 = new Auth0Client({ + enableConnectAccountEndpoint: true, + routes: { + connectAccount: "/auth/connect" + } +}); +``` + +The connect endpoint (`/auth/connect` or your custom path) accepts the following query parameters: + +- `connection`: (required) the name of the connection to use for linking the account +- `returnTo`: (optional) the URL to redirect the user to after they have completed the connection flow. +- Any additional parameters will be passed as the `authorizationParams` in the call to `/me/v1/connected-accounts/connect`. + +### `onCallback` hook + +When a user is redirected back to your application after completing the connected accounts flow, the `onCallback` hook will be called. You can use this hook to run custom logic after the user has connected their account, like so: + +```ts +import { NextResponse } from "next/server"; +import { Auth0Client } from "@auth0/nextjs-auth0/server"; + +export const auth0 = new Auth0Client({ + async onCallback(err, ctx, session) { + // `ctx` will contain the following properties when handling a connected account callback: + // - `connectedAccount`: the connected account object (`CompleteConnectAccountResponse`) if the connection was successful + // - `responseType`: will be set to `connect_code` when handling a connected accounts callback (`RESPONSE_TYPES.ConnectCode`) + // - `returnTo`: the returnTo URL specified when calling the connect endpoint (if any) + + return NextResponse.redirect( + new URL(ctx.returnTo ?? "/", process.env.APP_BASE_URL) + ); + }, + enableConnectAccountEndpoint: true +}); +``` + +### `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: + +```ts +import { auth0 } from "@/lib/auth0"; + +export async function GET() { + const res = await auth0.connectAccount({ + connection: "my-connection", + authorizationParams: { + scope: "openid profile offline_access read:something", + prompt: "consent", + audience: "https://myapi.com" + }, + returnTo: "/connected" + }); + + return res; +} +``` + ## 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. diff --git a/src/errors/index.ts b/src/errors/index.ts index e389e4b8..4f28757a 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -12,7 +12,7 @@ export class OAuth2Error extends SdkError { constructor({ code, message }: { code: string; message?: string }) { super( message ?? - "An error occured while interacting with the authorization server." + "An error occurred while interacting with the authorization server." ); this.name = "OAuth2Error"; this.code = code; @@ -60,7 +60,7 @@ export class AuthorizationError extends SdkError { public cause: OAuth2Error; constructor({ cause, message }: { cause: OAuth2Error; message?: string }) { - super(message ?? "An error occured during the authorization flow."); + super(message ?? "An error occurred during the authorization flow."); this.cause = cause; this.name = "AuthorizationError"; } @@ -72,7 +72,7 @@ export class AuthorizationCodeGrantRequestError extends SdkError { constructor(message?: string) { super( message ?? - "An error occured while preparing or performing the authorization code grant request." + "An error occurred while preparing or performing the authorization code grant request." ); this.name = "AuthorizationCodeGrantRequestError"; } @@ -85,7 +85,7 @@ export class AuthorizationCodeGrantError extends SdkError { constructor({ cause, message }: { cause: OAuth2Error; message?: string }) { super( message ?? - "An error occured while trying to exchange the authorization code." + "An error occurred while trying to exchange the authorization code." ); this.cause = cause; this.name = "AuthorizationCodeGrantError"; @@ -98,7 +98,7 @@ export class BackchannelLogoutError extends SdkError { constructor(message?: string) { super( message ?? - "An error occured while completing the backchannel logout request." + "An error occurred while completing the backchannel logout request." ); this.name = "BackchannelLogoutError"; } @@ -191,3 +191,105 @@ export class AccessTokenForConnectionError extends SdkError { this.cause = cause; } } + +/** + * Error class representing a connect account request error. + */ +export class MyAccountApiError extends SdkError { + public name: string = "MyAccountApiError"; + public code: string = "my_account_api_error"; + public type: string; + public title: string; + public detail: string; + public status: number; + public validationErrors?: Array<{ + /** + * A human-readable description of the specific error. Required. + */ + detail: string; + /** + * The name of the invalid parameter. Optional. + */ + field?: string; + /** + * A JSON Pointer that points to the exact location of the error in a JSON document being validated. Optional. + */ + pointer?: string; + /** + * Specifies the source of the error (e.g., body, query, or header in an HTML message). Optional. + */ + source?: string; + }>; + + constructor({ + type, + title, + detail, + status, + validationErrors + }: { + type: string; + title: string; + detail: string; + status: number; + validationErrors?: Array<{ + detail: string; + field?: string; + pointer?: string; + source?: string; + }>; + }) { + super(`${title}: ${detail}`); + this.type = type; + this.title = title; + this.detail = detail; + this.status = status; + this.validationErrors = validationErrors; + } +} + +/** + * Enum representing error codes related to the connect account flow. + */ +export enum ConnectAccountErrorCodes { + /** + * The session is missing. + */ + MISSING_SESSION = "missing_session", + + /** + * Failed to initiate the connect account flow. + */ + FAILED_TO_INITIATE = "failed_to_initiate", + + /** + * Failed to complete the connect account flow. + */ + FAILED_TO_COMPLETE = "failed_to_complete" +} + +/** + * Error class representing a connect account error. + */ +export class ConnectAccountError extends SdkError { + /** + * The error code associated with the connect account error. + */ + public code: string; + public cause?: MyAccountApiError; + + constructor({ + code, + message, + cause + }: { + code: string; + message: string; + cause?: MyAccountApiError; + }) { + super(message); + this.name = "ConnectAccountError"; + this.code = code; + this.cause = cause; + } +} diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index a9920aad..b253d45b 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -3,11 +3,19 @@ import * as jose from "jose"; import * as oauth from "oauth4webapi"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { BackchannelAuthenticationError } from "../errors/index.js"; +import { + AccessTokenError, + AccessTokenErrorCode, + BackchannelAuthenticationError, + ConnectAccountError, + ConnectAccountErrorCodes, + MyAccountApiError +} from "../errors/index.js"; import { getDefaultRoutes } from "../test/defaults.js"; import { generateSecret } from "../test/utils.js"; import { AccessTokenSet, + RESPONSE_TYPES, SessionData, SUBJECT_TOKEN_TYPES } from "../types/index.js"; @@ -73,7 +81,14 @@ ukIdiJtMNPwePfsT/2KqrbnftQnAKNnhsgcYGo8NAvntX4FokOAEdunyYmm85mLp BGKYgVXJqnm6+TJyCRac1ro3noG898P/LZ8MOBoaYQtWeWRpDc46jPrA0FqUJy+i ca/T0LLtgmbMmxSv/MmzIg== -----END PRIVATE KEY-----`, - requestUri: "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + requestUri: "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c", + connectAccount: { + ticket: "5ea12747-406c-4945-abc7-232086d9a3f0", + authSession: + "gcPQw7YPOD0mHiSVxOSbmZmMfTckA9o3CZQyeAf1C6guAiZzXiSnU2tEws9IQNUi", + expiresIn: 300, + connection: "google-oauth2" + } }; function getMockAuthorizationServer({ @@ -85,7 +100,10 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce, keyPair = DEFAULT.keyPair, onParRequest, - onBackchannelAuthRequest + onBackchannelAuthRequest, + onConnectAccountRequest, + onCompleteConnectAccountRequest, + completeConnectAccountErrorResponse }: { tokenEndpointResponse?: oauth.TokenEndpointResponse | oauth.OAuth2Error; tokenEndpointErrorResponse?: oauth.OAuth2Error; @@ -96,6 +114,9 @@ ca/T0LLtgmbMmxSv/MmzIg== keyPair?: jose.GenerateKeyPairResult; onParRequest?: (request: Request) => Promise; onBackchannelAuthRequest?: (request: Request) => Promise; + onConnectAccountRequest?: (request: Request) => Promise; + onCompleteConnectAccountRequest?: (request: Request) => Promise; + completeConnectAccountErrorResponse?: Response; } = {}) { // this function acts as a mock authorization server return vi.fn( @@ -180,6 +201,52 @@ ca/T0LLtgmbMmxSv/MmzIg== } ); } + // Connect Account + if (url.pathname === "/me/v1/connected-accounts/connect") { + if (onConnectAccountRequest) { + await onConnectAccountRequest(new Request(input, init)); + } + + return Response.json( + { + connect_uri: `https://${DEFAULT.domain}/connect`, + auth_session: DEFAULT.connectAccount.authSession, + connect_params: { + ticket: DEFAULT.connectAccount.ticket + }, + expires_in: 300 + }, + { + status: 201 + } + ); + } + // Connect Account complete + if (url.pathname === "/me/v1/connected-accounts/complete") { + if (onCompleteConnectAccountRequest) { + await onCompleteConnectAccountRequest(new Request(input, init)); + } + + if (completeConnectAccountErrorResponse) { + return completeConnectAccountErrorResponse; + } + + return Response.json( + { + id: "cac_abc123", + connection: DEFAULT.connectAccount.connection, + access_type: "offline", + scopes: ["openid", "profile", "email"], + created_at: new Date().toISOString(), + expires_at: new Date( + Date.now() + 1000 * 60 * 60 * 24 * 30 + ).toISOString() // 30 days + }, + { + status: 201 + } + ); + } return new Response(null, { status: 404 }); } @@ -1116,7 +1183,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect.objectContaining({ nonce: authorizationUrl.searchParams.get("nonce"), codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: authorizationUrl.searchParams.get("state"), returnTo: "/" }) @@ -1248,7 +1315,7 @@ ca/T0LLtgmbMmxSv/MmzIg== const response = await authClient.handleLogin(request); expect(response.status).toEqual(500); expect(await response.text()).toContain( - "An error occured while trying to initiate the login request." + "An error occurred while trying to initiate the login request." ); }); @@ -1334,7 +1401,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect.objectContaining({ nonce: authorizationUrl.searchParams.get("nonce"), codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: authorizationUrl.searchParams.get("state"), returnTo: "/" }) @@ -1686,7 +1753,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: authorizationUrl.searchParams.get("nonce"), maxAge: 3600, codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: authorizationUrl.searchParams.get("state"), returnTo: "/" }) @@ -1741,7 +1808,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect.objectContaining({ nonce: authorizationUrl.searchParams.get("nonce"), codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: authorizationUrl.searchParams.get("state"), returnTo: "/dashboard" }) @@ -1796,7 +1863,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect.objectContaining({ nonce: authorizationUrl.searchParams.get("nonce"), codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: authorizationUrl.searchParams.get("state"), returnTo: "/" }) @@ -1849,7 +1916,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(response.status).toEqual(500); expect(await response.text()).toEqual( - "An error occured while trying to initiate the login request." + "An error occurred while trying to initiate the login request." ); }); @@ -1940,7 +2007,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect.objectContaining({ nonce: expect.any(String), codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/" }) @@ -2104,7 +2171,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect.objectContaining({ nonce: expect.any(String), codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/" }) @@ -2188,7 +2255,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect.objectContaining({ nonce: expect.any(String), codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/" }) @@ -2749,7 +2816,7 @@ ca/T0LLtgmbMmxSv/MmzIg== const response = await authClient.handleLogout(request); expect(response.status).toEqual(500); expect(await response.text()).toEqual( - "An error occured while trying to initiate the logout request." + "An error occurred while trying to initiate the logout request." ); }); @@ -3195,7 +3262,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3297,7 +3364,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3383,7 +3450,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3511,7 +3578,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3567,7 +3634,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3585,7 +3652,7 @@ ca/T0LLtgmbMmxSv/MmzIg== const response = await authClient.handleCallback(request); expect(response.status).toEqual(500); expect(await response.text()).toEqual( - "An error occured during the authorization flow." + "An error occurred during the authorization flow." ); }); @@ -3630,7 +3697,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3648,7 +3715,7 @@ ca/T0LLtgmbMmxSv/MmzIg== const response = await authClient.handleCallback(request); expect(response.status).toEqual(500); expect(await response.text()).toEqual( - "An error occured while trying to exchange the authorization code." + "An error occurred while trying to exchange the authorization code." ); }); @@ -3690,7 +3757,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3757,7 +3824,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3795,6 +3862,7 @@ ca/T0LLtgmbMmxSv/MmzIg== } }; const expectedContext = { + responseType: RESPONSE_TYPES.CODE, returnTo: transactionState.returnTo }; @@ -3919,7 +3987,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -3998,7 +4066,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -4024,6 +4092,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(mockOnCallback).toHaveBeenCalledWith( expect.any(Error), { + responseType: RESPONSE_TYPES.CODE, returnTo: transactionState.returnTo }, null @@ -4083,7 +4152,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -4109,6 +4178,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(mockOnCallback).toHaveBeenCalledWith( expect.any(Error), { + responseType: RESPONSE_TYPES.CODE, returnTo: transactionState.returnTo }, null @@ -4167,7 +4237,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -4193,6 +4263,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(mockOnCallback).toHaveBeenCalledWith( expect.any(Error), { + responseType: RESPONSE_TYPES.CODE, returnTo: transactionState.returnTo }, null @@ -4255,7 +4326,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -4339,7 +4410,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -4477,7 +4548,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce: "nonce-value", maxAge: 3600, codeVerifier: "code-verifier", - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: state, returnTo: "/dashboard" }; @@ -4528,149 +4599,479 @@ ca/T0LLtgmbMmxSv/MmzIg== ); }); }); - }); - describe("handleAccessToken", async () => { - it("should return the access token if the user has a session", async () => { - const currentAccessToken = DEFAULT.accessToken; - const newAccessToken = "at_456"; + describe("connect account callback", async () => { + it("should complete the connect account flow and call onCallback hook", async () => { + const state = "transaction-state"; + const connectCode = "connect-code"; - const secret = await generateSecret(32); - const transactionStore = new TransactionStore({ - secret - }); - const sessionStore = new StatelessSessionStore({ - secret - }); - const authClient = new AuthClient({ - transactionStore, - sessionStore, + const mockOnCallback = vi + .fn() + .mockResolvedValue( + NextResponse.redirect(new URL("/dashboard", DEFAULT.appBaseUrl)) + ); - domain: DEFAULT.domain, - clientId: DEFAULT.clientId, - clientSecret: DEFAULT.clientSecret, + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, - secret, - appBaseUrl: DEFAULT.appBaseUrl, + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, - routes: getDefaultRoutes(), + secret, + appBaseUrl: DEFAULT.appBaseUrl, - fetch: getMockAuthorizationServer({ - tokenEndpointResponse: { - token_type: "Bearer", - access_token: newAccessToken, + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer({ + onCompleteConnectAccountRequest: async (req) => { + const completeConnectAccountRequestBody = await req.json(); + expect(completeConnectAccountRequestBody).toEqual( + expect.objectContaining({ + auth_session: DEFAULT.connectAccount.authSession, + connect_code: connectCode, + redirect_uri: `${DEFAULT.appBaseUrl}/auth/callback`, + code_verifier: "code-verifier" + }) + ); + } + }), + + onCallback: mockOnCallback + }); + + const url = new URL("/auth/callback", DEFAULT.appBaseUrl); + url.searchParams.set("connect_code", connectCode); + url.searchParams.set("state", state); + + const headers = new Headers(); + const transactionState: TransactionState = { + maxAge: 3600, + codeVerifier: "code-verifier", + responseType: RESPONSE_TYPES.CONNECT_CODE, + state: state, + returnTo: "/dashboard", + authSession: DEFAULT.connectAccount.authSession + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + headers.set( + "cookie", + `__txn_${state}=${await encrypt(transactionState, secret, expiration)}` + ); + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: DEFAULT.accessToken, scope: "openid profile email", - expires_in: 86400 // expires in 10 days - } as oauth.TokenEndpointResponse - }) - }); + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60 // expires in 10 days + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const sessionCookie = await encrypt(session, secret, expiration); + headers.append("cookie", `__session=${sessionCookie}`); - // we want to ensure the session is expired to return the refreshed access token - const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago - const session: SessionData = { - user: { - sub: DEFAULT.sub, - name: "John Doe", - email: "john@example.com", - picture: "https://example.com/john.jpg" - }, - tokenSet: { - accessToken: currentAccessToken, - scope: "openid profile email", - refreshToken: DEFAULT.refreshToken, - expiresAt - }, - internal: { - sid: DEFAULT.sid, - createdAt: Math.floor(Date.now() / 1000) - } - }; - const maxAge = 60 * 60; // 1 hour - const expiration = Math.floor(Date.now() / 1000 + maxAge); - const sessionCookie = await encrypt(session, secret, expiration); - const headers = new Headers(); - headers.append("cookie", `__session=${sessionCookie}`); - const request = new NextRequest( - new URL("/auth/access-token", DEFAULT.appBaseUrl), - { + const request = new NextRequest(url, { method: "GET", headers - } - ); + }); - const response = await authClient.handleAccessToken(request); - expect(response.status).toEqual(200); - expect(await response.json()).toEqual({ - token: newAccessToken, - scope: "openid profile email", - expires_at: expect.any(Number) - }); + const response = await authClient.handleCallback(request); + expect(response.status).toEqual(307); + expect(response.headers.get("Location")).not.toBeNull(); - // validate that the session cookie has been updated - const updatedSessionCookie = response.cookies.get("__session"); - const { payload: updatedSession } = (await decrypt( - updatedSessionCookie!.value, - secret - )) as jose.JWTDecryptResult; - expect(updatedSession.tokenSet.accessToken).toEqual(newAccessToken); - }); + const redirectUrl = new URL(response.headers.get("Location")!); + expect(redirectUrl.pathname).toEqual("/dashboard"); - it("should return a 401 if the user does not have a session", async () => { - const secret = await generateSecret(32); - const transactionStore = new TransactionStore({ - secret - }); - const sessionStore = new StatelessSessionStore({ - secret + // validate the transaction cookie has been removed + const transactionCookie = response.cookies.get(`__txn_${state}`); + expect(transactionCookie).toBeDefined(); + expect(transactionCookie!.value).toEqual(""); + expect(transactionCookie!.maxAge).toEqual(0); + + // validate that onCallback has been called with the connected account + const expectedSession = expect.objectContaining({ + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: expect.any(Number), + scope: "openid profile email" + }, + internal: { + sid: expect.any(String), + createdAt: expect.any(Number) + } + }); + const expectedContext = expect.objectContaining({ + responseType: RESPONSE_TYPES.CONNECT_CODE, + returnTo: transactionState.returnTo, + connectedAccount: { + accessType: "offline", + connection: "google-oauth2", + createdAt: expect.any(String), + expiresAt: expect.any(String), + id: "cac_abc123", + scopes: ["openid", "profile", "email"] + } + }); + + expect(mockOnCallback).toHaveBeenCalledWith( + null, + expectedContext, + expectedSession + ); }); - const authClient = new AuthClient({ - transactionStore, - sessionStore, - domain: DEFAULT.domain, - clientId: DEFAULT.clientId, - clientSecret: DEFAULT.clientSecret, + it("should call handleCallbackError with an error if the user does not have a session", async () => { + const state = "transaction-state"; + const connectCode = "connect-code"; - secret, - appBaseUrl: DEFAULT.appBaseUrl, + const mockOnCallback = vi + .fn() + .mockResolvedValue( + NextResponse.redirect(new URL("/dashboard", DEFAULT.appBaseUrl)) + ); - routes: getDefaultRoutes(), + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, - fetch: getMockAuthorizationServer() - }); + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, - const request = new NextRequest( - new URL("/auth/access-token", DEFAULT.appBaseUrl), - { - method: "GET" - } - ); + secret, + appBaseUrl: DEFAULT.appBaseUrl, - const response = await authClient.handleAccessToken(request); - expect(response.status).toEqual(401); - expect(await response.json()).toEqual({ - error: { - message: "The user does not have an active session.", - code: "missing_session" - } - }); + routes: getDefaultRoutes(), - // validate that the session cookie has not been set - const sessionCookie = response.cookies.get("__session"); - expect(sessionCookie).toBeUndefined(); - }); + fetch: getMockAuthorizationServer(), - it("should return an error if obtaining a token set failed", async () => { - const secret = await generateSecret(32); - const transactionStore = new TransactionStore({ - secret - }); - const sessionStore = new StatelessSessionStore({ - secret - }); - const authClient = new AuthClient({ - transactionStore, - sessionStore, + onCallback: mockOnCallback + }); + + const url = new URL("/auth/callback", DEFAULT.appBaseUrl); + url.searchParams.set("connect_code", connectCode); + url.searchParams.set("state", state); + + const headers = new Headers(); + const transactionState: TransactionState = { + maxAge: 3600, + codeVerifier: "code-verifier", + responseType: RESPONSE_TYPES.CONNECT_CODE, + state: state, + returnTo: "/dashboard", + authSession: DEFAULT.connectAccount.authSession + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + headers.set( + "cookie", + `__txn_${state}=${await encrypt(transactionState, secret, expiration)}` + ); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + const response = await authClient.handleCallback(request); + expect(response.status).toEqual(307); + expect(response.headers.get("Location")).not.toBeNull(); + + expect(mockOnCallback).toHaveBeenCalledWith( + expect.any(Error), + { + responseType: RESPONSE_TYPES.CONNECT_CODE, + returnTo: transactionState.returnTo + }, + null + ); + expect(mockOnCallback.mock.calls[0][0].code).toEqual( + ConnectAccountErrorCodes.MISSING_SESSION + ); + }); + + it("should call handleCallbackError with an error if there was an error fetching the token set", async () => { + const state = "transaction-state"; + const connectCode = "connect-code"; + + const mockOnCallback = vi + .fn() + .mockResolvedValue( + NextResponse.redirect(new URL("/dashboard", DEFAULT.appBaseUrl)) + ); + + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer(), + + onCallback: mockOnCallback + }); + + const url = new URL("/auth/callback", DEFAULT.appBaseUrl); + url.searchParams.set("connect_code", connectCode); + url.searchParams.set("state", state); + + const headers = new Headers(); + const transactionState: TransactionState = { + maxAge: 3600, + codeVerifier: "code-verifier", + responseType: RESPONSE_TYPES.CONNECT_CODE, + state: state, + returnTo: "/dashboard", + authSession: DEFAULT.connectAccount.authSession + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + headers.set( + "cookie", + `__txn_${state}=${await encrypt(transactionState, secret, expiration)}` + ); + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: DEFAULT.accessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60 // expires in 10 days + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const sessionCookie = await encrypt(session, secret, expiration); + headers.append("cookie", `__session=${sessionCookie}`); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + authClient.getTokenSet = vi + .fn() + .mockResolvedValue([ + new AccessTokenError( + AccessTokenErrorCode.MISSING_REFRESH_TOKEN, + "No access token found and a refresh token was not provided. The user needs to re-authenticate." + ) + ]); + + const response = await authClient.handleCallback(request); + expect(response.status).toEqual(307); + expect(response.headers.get("Location")).not.toBeNull(); + + const redirectUrl = new URL(response.headers.get("Location")!); + expect(redirectUrl.pathname).toEqual("/dashboard"); + + // validate the transaction cookie has been removed + const transactionCookie = response.cookies.get(`__txn_${state}`); + expect(transactionCookie).toBeDefined(); + expect(transactionCookie!.value).toEqual(""); + expect(transactionCookie!.maxAge).toEqual(0); + + expect(mockOnCallback).toHaveBeenCalledWith( + expect.any(Error), + { + responseType: RESPONSE_TYPES.CONNECT_CODE, + returnTo: transactionState.returnTo + }, + null + ); + expect(mockOnCallback.mock.calls[0][0].code).toEqual( + AccessTokenErrorCode.MISSING_REFRESH_TOKEN + ); + }); + + it("should call handleCallbackError with an error if there was an error while calling the complete connect account endpoint", async () => { + const state = "transaction-state"; + const connectCode = "connect-code"; + + const mockOnCallback = vi + .fn() + .mockResolvedValue( + NextResponse.redirect(new URL("/dashboard", DEFAULT.appBaseUrl)) + ); + + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer({ + completeConnectAccountErrorResponse: Response.json( + { + title: "Not Found", + type: "https://auth0.com/api-errors/A0E-404-0001", + detail: "Invalid or expired session", + status: 404 + }, + { + status: 404 + } + ) + }), + + onCallback: mockOnCallback + }); + + const url = new URL("/auth/callback", DEFAULT.appBaseUrl); + url.searchParams.set("connect_code", connectCode); + url.searchParams.set("state", state); + + const headers = new Headers(); + const transactionState: TransactionState = { + maxAge: 3600, + codeVerifier: "code-verifier", + responseType: RESPONSE_TYPES.CONNECT_CODE, + state: state, + returnTo: "/dashboard", + authSession: DEFAULT.connectAccount.authSession + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + headers.set( + "cookie", + `__txn_${state}=${await encrypt(transactionState, secret, expiration)}` + ); + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: DEFAULT.accessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60 // expires in 10 days + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const sessionCookie = await encrypt(session, secret, expiration); + headers.append("cookie", `__session=${sessionCookie}`); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + const response = await authClient.handleCallback(request); + expect(response.status).toEqual(307); + expect(response.headers.get("Location")).not.toBeNull(); + + const redirectUrl = new URL(response.headers.get("Location")!); + expect(redirectUrl.pathname).toEqual("/dashboard"); + + // validate the transaction cookie has been removed + const transactionCookie = response.cookies.get(`__txn_${state}`); + expect(transactionCookie).toBeDefined(); + expect(transactionCookie!.value).toEqual(""); + expect(transactionCookie!.maxAge).toEqual(0); + + expect(mockOnCallback).toHaveBeenCalledWith( + expect.any(Error), + { + responseType: RESPONSE_TYPES.CONNECT_CODE, + returnTo: transactionState.returnTo + }, + null + ); + expect(mockOnCallback.mock.calls[0][0].code).toEqual( + ConnectAccountErrorCodes.FAILED_TO_COMPLETE + ); + }); + }); + }); + + describe("handleAccessToken", async () => { + it("should return the access token if the user has a session", async () => { + const currentAccessToken = DEFAULT.accessToken; + const newAccessToken = "at_456"; + + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, @@ -4681,10 +5082,18 @@ ca/T0LLtgmbMmxSv/MmzIg== routes: getDefaultRoutes(), - fetch: getMockAuthorizationServer() + fetch: getMockAuthorizationServer({ + tokenEndpointResponse: { + token_type: "Bearer", + access_token: newAccessToken, + scope: "openid profile email", + expires_in: 86400 // expires in 10 days + } as oauth.TokenEndpointResponse + }) }); - const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expires in 10 days + // we want to ensure the session is expired to return the refreshed access token + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago const session: SessionData = { user: { sub: DEFAULT.sub, @@ -4693,8 +5102,126 @@ ca/T0LLtgmbMmxSv/MmzIg== picture: "https://example.com/john.jpg" }, tokenSet: { - accessToken: DEFAULT.accessToken, - // missing refresh token + accessToken: currentAccessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const sessionCookie = await encrypt(session, secret, expiration); + const headers = new Headers(); + headers.append("cookie", `__session=${sessionCookie}`); + const request = new NextRequest( + new URL("/auth/access-token", DEFAULT.appBaseUrl), + { + method: "GET", + headers + } + ); + + const response = await authClient.handleAccessToken(request); + expect(response.status).toEqual(200); + expect(await response.json()).toEqual({ + token: newAccessToken, + scope: "openid profile email", + expires_at: expect.any(Number) + }); + + // validate that the session cookie has been updated + const updatedSessionCookie = response.cookies.get("__session"); + const { payload: updatedSession } = (await decrypt( + updatedSessionCookie!.value, + secret + )) as jose.JWTDecryptResult; + expect(updatedSession.tokenSet.accessToken).toEqual(newAccessToken); + }); + + it("should return a 401 if the user does not have a session", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer() + }); + + const request = new NextRequest( + new URL("/auth/access-token", DEFAULT.appBaseUrl), + { + method: "GET" + } + ); + + const response = await authClient.handleAccessToken(request); + expect(response.status).toEqual(401); + expect(await response.json()).toEqual({ + error: { + message: "The user does not have an active session.", + code: "missing_session" + } + }); + + // validate that the session cookie has not been set + const sessionCookie = response.cookies.get("__session"); + expect(sessionCookie).toBeUndefined(); + }); + + it("should return an error if obtaining a token set failed", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer() + }); + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expires in 10 days + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: DEFAULT.accessToken, + // missing refresh token expiresAt }, internal: { @@ -5282,6 +5809,604 @@ ca/T0LLtgmbMmxSv/MmzIg== }); }); + describe("handleConnectAccount", async () => { + it("should create a connected account request, persist the transaction state, and redirect the user", async () => { + const currentAccessToken = DEFAULT.accessToken; + const newAccessToken = "at_456"; + const secret = await generateSecret(32); + let connectAccountRequestBody: any; + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer({ + tokenEndpointResponse: { + token_type: "Bearer", + access_token: newAccessToken, + scope: + "openid profile email offline_access create:me:connected_accounts", + expires_in: 86400 // expires in 10 days + } as oauth.TokenEndpointResponse, + onConnectAccountRequest: async (req) => { + connectAccountRequestBody = await req.json(); + expect(connectAccountRequestBody).toEqual( + expect.objectContaining({ + connection: DEFAULT.connectAccount.connection, + redirect_uri: `${DEFAULT.appBaseUrl}/auth/callback`, + state: expect.any(String), + code_challenge: expect.any(String), + code_challenge_method: "S256", + authorization_params: expect.objectContaining({ + audience: "urn:some-audience", + scope: "openid profile email offline_access read:messages" + }) + }) + ); + } + }), + + enableConnectAccountEndpoint: true + }); + + const expiresAt = Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60; // expires in 10 days + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: currentAccessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const sessionCookie = await encrypt(session, secret, expiration); + const headers = new Headers(); + headers.append("cookie", `__session=${sessionCookie}`); + const url = new URL("/auth/connect", DEFAULT.appBaseUrl); + url.searchParams.append("connection", DEFAULT.connectAccount.connection); + url.searchParams.append("returnTo", "/some-url"); + url.searchParams.append("audience", "urn:some-audience"); + url.searchParams.append( + "scope", + "openid profile email offline_access read:messages" + ); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + const response = await authClient.handler(request); + expect(response.status).toEqual(307); + const connectUrl = new URL(response.headers.get("location")!); + expect(connectUrl.origin).toEqual(`https://${DEFAULT.domain}`); + expect(connectUrl.pathname).toEqual("/connect"); + expect(connectUrl.searchParams.get("ticket")).toEqual( + DEFAULT.connectAccount.ticket + ); + + // transaction state + const transactionCookie = response.cookies.get( + `__txn_${connectAccountRequestBody.state}` + ); + expect(transactionCookie).toBeDefined(); + expect( + ( + (await decrypt( + transactionCookie!.value, + secret + )) as jose.JWTDecryptResult + ).payload + ).toEqual( + expect.objectContaining({ + responseType: RESPONSE_TYPES.CONNECT_CODE, + state: connectAccountRequestBody?.state, + returnTo: "/some-url", + codeVerifier: expect.any(String), + authSession: DEFAULT.connectAccount.authSession + }) + ); + + // validate that the session cookie has been updated + const updatedSessionCookie = response.cookies.get("__session"); + const { payload: updatedSession } = (await decrypt( + updatedSessionCookie!.value, + secret + )) as jose.JWTDecryptResult; + const mrrtTokenSet = updatedSession.accessTokens?.find( + (at) => at.audience === `https://${DEFAULT.domain}/me/` + ); + expect(mrrtTokenSet).toBeDefined(); + expect(mrrtTokenSet?.accessToken).toEqual(newAccessToken); + expect(mrrtTokenSet?.requestedScope).toEqual( + "openid profile email offline_access create:me:connected_accounts" + ); + expect(mrrtTokenSet?.scope).toEqual( + "openid profile email offline_access create:me:connected_accounts" + ); + }); + + it("should sanitize the returnTo URL", async () => { + const currentAccessToken = DEFAULT.accessToken; + const newAccessToken = "at_456"; + const secret = await generateSecret(32); + let connectAccountRequestBody: any; + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer({ + tokenEndpointResponse: { + token_type: "Bearer", + access_token: newAccessToken, + scope: "openid profile email", + expires_in: 86400 // expires in 10 days + } as oauth.TokenEndpointResponse, + onConnectAccountRequest: async (req) => { + connectAccountRequestBody = await req.json(); + expect(connectAccountRequestBody).toEqual( + expect.objectContaining({ + connection: "some-connection", + redirect_uri: `${DEFAULT.appBaseUrl}/auth/callback`, + state: expect.any(String), + code_challenge: expect.any(String), + code_challenge_method: "S256", + authorization_params: expect.objectContaining({ + audience: "urn:some-audience", + scope: "openid profile email offline_access read:messages" + }) + }) + ); + } + }), + + enableConnectAccountEndpoint: true + }); + + const expiresAt = Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60; // expires in 10 days + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: currentAccessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const sessionCookie = await encrypt(session, secret, expiration); + const headers = new Headers(); + headers.append("cookie", `__session=${sessionCookie}`); + const url = new URL("/auth/connect", DEFAULT.appBaseUrl); + url.searchParams.append("connection", "some-connection"); + url.searchParams.append("returnTo", "https://google.com/some-url"); + url.searchParams.append("audience", "urn:some-audience"); + url.searchParams.append( + "scope", + "openid profile email offline_access read:messages" + ); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + const response = await authClient.handler(request); + expect(response.status).toEqual(307); + const connectUrl = new URL(response.headers.get("location")!); + expect(connectUrl.origin).toEqual(`https://${DEFAULT.domain}`); + expect(connectUrl.pathname).toEqual("/connect"); + expect(connectUrl.searchParams.get("ticket")).toEqual( + DEFAULT.connectAccount.ticket + ); + + // transaction state + const transactionCookie = response.cookies.get( + `__txn_${connectAccountRequestBody.state}` + ); + expect(transactionCookie).toBeDefined(); + expect( + ( + (await decrypt( + transactionCookie!.value, + secret + )) as jose.JWTDecryptResult + ).payload + ).toEqual( + expect.objectContaining({ + responseType: RESPONSE_TYPES.CONNECT_CODE, + state: connectAccountRequestBody?.state, + returnTo: "/", + codeVerifier: expect.any(String), + authSession: DEFAULT.connectAccount.authSession + }) + ); + }); + + it("should not call the connect account handler if the endpoint is not enabled", async () => { + const currentAccessToken = DEFAULT.accessToken; + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer(), + + enableConnectAccountEndpoint: false + }); + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: currentAccessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const sessionCookie = await encrypt(session, secret, expiration); + const headers = new Headers(); + headers.append("cookie", `__session=${sessionCookie}`); + const url = new URL("/auth/connect", DEFAULT.appBaseUrl); + url.searchParams.append("connection", "some-connection"); + url.searchParams.append("returnTo", "/some-url"); + url.searchParams.append("audience", "urn:some-audience"); + url.searchParams.append( + "scope", + "openid profile email offline_access read:messages" + ); + + authClient.handleConnectAccount = vi.fn(); + expect(authClient.handleConnectAccount).not.toHaveBeenCalled(); + }); + + it("should return a 401 if the user does not have a session", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer(), + + enableConnectAccountEndpoint: true + }); + + const headers = new Headers(); + const url = new URL("/auth/connect", DEFAULT.appBaseUrl); + url.searchParams.append("connection", "some-connection"); + url.searchParams.append("returnTo", "/some-url"); + url.searchParams.append("audience", "urn:some-audience"); + url.searchParams.append( + "scope", + "openid profile email offline_access read:messages" + ); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + const response = await authClient.handler(request); + expect(response.status).toEqual(401); + }); + + it("should return a 400 if the connection query parameter is missing", async () => { + const currentAccessToken = DEFAULT.accessToken; + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer(), + + enableConnectAccountEndpoint: true + }); + + const expiresAt = Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60; // expires in 10 days + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: currentAccessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const sessionCookie = await encrypt(session, secret, expiration); + const headers = new Headers(); + headers.append("cookie", `__session=${sessionCookie}`); + const url = new URL("/auth/connect", DEFAULT.appBaseUrl); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + const response = await authClient.handler(request); + expect(response.status).toEqual(400); + }); + + it("should return a 401 if obtaining a token set failed", async () => { + const currentAccessToken = DEFAULT.accessToken; + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer(), + + enableConnectAccountEndpoint: true + }); + + const expiresAt = Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60; // expires in 10 days + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: currentAccessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const sessionCookie = await encrypt(session, secret, expiration); + const headers = new Headers(); + headers.append("cookie", `__session=${sessionCookie}`); + const url = new URL("/auth/connect", DEFAULT.appBaseUrl); + url.searchParams.append("connection", "some-connection"); + url.searchParams.append("returnTo", "/some-url"); + url.searchParams.append("audience", "urn:some-audience"); + url.searchParams.append( + "scope", + "openid profile email offline_access read:messages" + ); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + authClient.getTokenSet = vi + .fn() + .mockResolvedValue([new Error("some error"), null]); + + const response = await authClient.handler(request); + expect(response.status).toEqual(401); + }); + + it("should forward the My Account API status code if an error occurs calling connectAccount", async () => { + const currentAccessToken = DEFAULT.accessToken; + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer(), + + enableConnectAccountEndpoint: true + }); + + const expiresAt = Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60; // expires in 10 days + const session: SessionData = { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg" + }, + tokenSet: { + accessToken: currentAccessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000) + } + }; + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const sessionCookie = await encrypt(session, secret, expiration); + const headers = new Headers(); + headers.append("cookie", `__session=${sessionCookie}`); + const url = new URL("/auth/connect", DEFAULT.appBaseUrl); + url.searchParams.append("connection", "some-connection"); + url.searchParams.append("returnTo", "/some-url"); + url.searchParams.append("audience", "urn:some-audience"); + url.searchParams.append( + "scope", + "openid profile email offline_access read:messages" + ); + + const request = new NextRequest(url, { + method: "GET", + headers + }); + + authClient.connectAccount = vi.fn().mockResolvedValue([ + new ConnectAccountError({ + code: ConnectAccountErrorCodes.FAILED_TO_INITIATE, + message: "some message", + cause: new MyAccountApiError({ + title: "Validation Error", + type: "https://auth0.com/api-errors/A0E-400-0003", + detail: "Invalid request payload input", + status: 400, + validationErrors: [ + { + pointer: "", + detail: "data must have required property 'connection'" + } + ] + }) + }), + null + ]); + + const response = await authClient.handler(request); + expect(response.status).toEqual(400); + }); + }); + describe("getTokenSet", async () => { it("should return the access token if it has not expired", async () => { const secret = await generateSecret(32); @@ -6312,7 +7437,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect.objectContaining({ nonce: expect.any(String), codeVerifier: expect.any(String), - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state: expect.any(String), returnTo: "/custom-path" }) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 4fe2345b..04fbb967 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -15,12 +15,22 @@ import { BackchannelAuthenticationError, BackchannelAuthenticationNotSupportedError, BackchannelLogoutError, + ConnectAccountError, + ConnectAccountErrorCodes, DiscoveryError, InvalidStateError, MissingStateError, + MyAccountApiError, OAuth2Error, SdkError } from "../errors/index.js"; +import { + CompleteConnectAccountRequest, + CompleteConnectAccountResponse, + ConnectAccountOptions, + ConnectAccountRequest, + ConnectAccountResponse +} from "../types/connected-accounts.js"; import { AccessTokenForConnectionOptions, AccessTokenSet, @@ -30,6 +40,7 @@ import { ConnectionTokenSet, LogoutStrategy, LogoutToken, + RESPONSE_TYPES, SessionData, StartInteractiveLoginOptions, SUBJECT_TOKEN_TYPES, @@ -67,7 +78,19 @@ export type BeforeSessionSavedHook = ( ) => Promise; export type OnCallbackContext = { + /** + * The type of response expected from the authorization server. + * One of {@link RESPONSE_TYPES} + */ + responseType?: RESPONSE_TYPES; + /** + * The URL or path the user should be redirected to after completing the transaction. + */ returnTo?: string; + /** + * The connected account information when the responseType is {@link RESPONSE_TYPES.CONNECT_CODE} + */ + connectedAccount?: CompleteConnectAccountResponse; }; export type OnCallbackHook = ( error: SdkError | null, @@ -113,9 +136,13 @@ export interface Routes { profile: string; accessToken: string; backChannelLogout: string; + connectAccount: string; } export type RoutesOptions = Partial< - Pick + Pick< + Routes, + "login" | "callback" | "logout" | "backChannelLogout" | "connectAccount" + > >; export interface AuthClientOptions { @@ -149,6 +176,7 @@ export interface AuthClientOptions { enableTelemetry?: boolean; enableAccessTokenEndpoint?: boolean; noContentProfileResponseWhenUnauthenticated?: boolean; + enableConnectAccountEndpoint?: boolean; } function createRouteUrl(path: string, baseUrl: string) { @@ -184,12 +212,13 @@ export class AuthClient { private jwksCache: jose.JWKSCacheInput; private allowInsecureRequests: boolean; private httpTimeout: number; - private httpOptions: () => oauth.HttpRequestOptions<"GET" | "POST">; + private httpOptions: () => { signal: AbortSignal; headers: Headers }; private authorizationServerMetadata?: oauth.AuthorizationServer; private readonly enableAccessTokenEndpoint: boolean; private readonly noContentProfileResponseWhenUnauthenticated: boolean; + private readonly enableConnectAccountEndpoint: boolean; constructor(options: AuthClientOptions) { // dependencies @@ -288,6 +317,8 @@ export class AuthClient { this.enableAccessTokenEndpoint = options.enableAccessTokenEndpoint ?? true; this.noContentProfileResponseWhenUnauthenticated = options.noContentProfileResponseWhenUnauthenticated ?? false; + this.enableConnectAccountEndpoint = + options.enableConnectAccountEndpoint ?? false; } async handler(req: NextRequest): Promise { @@ -314,6 +345,12 @@ export class AuthClient { sanitizedPathname === this.routes.backChannelLogout ) { return this.handleBackChannelLogout(req); + } else if ( + method === "GET" && + sanitizedPathname === this.routes.connectAccount && + this.enableConnectAccountEndpoint + ) { + return this.handleConnectAccount(req); } else { // no auth handler found, simply touch the sessions // TODO: this should only happen if rolling sessions are enabled. Also, we should @@ -374,7 +411,7 @@ export class AuthClient { ); authorizationParams.set("client_id", this.clientMetadata.client_id); authorizationParams.set("redirect_uri", redirectUri.toString()); - authorizationParams.set("response_type", "code"); + authorizationParams.set("response_type", RESPONSE_TYPES.CODE); authorizationParams.set("code_challenge", codeChallenge); authorizationParams.set("code_challenge_method", codeChallengeMethod); authorizationParams.set("state", state); @@ -385,7 +422,7 @@ export class AuthClient { nonce, maxAge: this.authorizationParameters.max_age, codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo, scope: authorizationParams.get("scope") || undefined, @@ -397,7 +434,7 @@ export class AuthClient { await this.authorizationUrl(authorizationParams); if (error) { return new NextResponse( - "An error occured while trying to initiate the login request.", + "An error occurred while trying to initiate the login request.", { status: 500 } @@ -439,7 +476,7 @@ export class AuthClient { if (discoveryError) { // Clean up session on discovery error const errorResponse = new NextResponse( - "An error occured while trying to initiate the logout request.", + "An error occurred while trying to initiate the logout request.", { status: 500 } @@ -547,9 +584,78 @@ export class AuthClient { const transactionState = transactionStateCookie.payload; const onCallbackCtx: OnCallbackContext = { + responseType: transactionState.responseType, returnTo: transactionState.returnTo }; + if (transactionState.responseType === RESPONSE_TYPES.CONNECT_CODE) { + const session = await this.sessionStore.get(req.cookies); + + if (!session) { + return this.handleCallbackError( + new ConnectAccountError({ + code: ConnectAccountErrorCodes.MISSING_SESSION, + message: "The user does not have an active session." + }), + onCallbackCtx, + req, + state + ); + } + + // get an access token for connected accounts + const [tokenSetError, tokenSetResponse] = await this.getTokenSet( + session, + { + audience: `${this.issuer}/me/`, + scope: "create:me:connected_accounts" + } + ); + + if (tokenSetError) { + return this.handleCallbackError( + tokenSetError, + onCallbackCtx, + req, + state + ); + } + + const [completeConnectAccountError, connectedAccount] = + await this.completeConnectAccount({ + accessToken: tokenSetResponse.tokenSet.accessToken, + authSession: transactionState.authSession!, + connectCode: req.nextUrl.searchParams.get("connect_code")!, + redirectUri: createRouteUrl( + this.routes.callback, + this.appBaseUrl + ).toString(), + codeVerifier: transactionState.codeVerifier + }); + + if (completeConnectAccountError) { + return this.handleCallbackError( + completeConnectAccountError, + onCallbackCtx, + req, + state + ); + } + + const res = await this.onCallback( + null, + { + ...onCallbackCtx, + connectedAccount + }, + session + ); + + await this.transactionStore.delete(res.cookies, state); + + return res; + } + const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata(); @@ -802,6 +908,92 @@ export class AuthClient { }); } + async handleConnectAccount(req: NextRequest): Promise { + const session = await this.sessionStore.get(req.cookies); + + // pass all query params except `connection` and `returnTo` as authorization params + const connection = req.nextUrl.searchParams.get("connection"); + const returnTo = req.nextUrl.searchParams.get("returnTo") ?? undefined; + const authorizationParams = Object.fromEntries( + [...req.nextUrl.searchParams.entries()].filter( + ([key]) => key !== "connection" && key !== "returnTo" + ) + ); + + if (!connection) { + return new NextResponse("A connection is required.", { + status: 400 + }); + } + + if (!session) { + return new NextResponse("The user does not have an active session.", { + status: 401 + }); + } + + const [getTokenSetError, getTokenSetResponse] = await this.getTokenSet( + session, + { + scope: "create:me:connected_accounts", + audience: `${this.issuer}/me/` + } + ); + + if (getTokenSetError) { + return new NextResponse( + "Failed to retrieve a connected account access token.", + { + status: 401 + } + ); + } + + const { tokenSet, idTokenClaims } = getTokenSetResponse; + const [connectAccountError, connectAccountResponse] = + await this.connectAccount({ + accessToken: tokenSet.accessToken, + connection, + authorizationParams, + returnTo + }); + + if (connectAccountError) { + return new NextResponse(connectAccountError.message, { + status: connectAccountError.cause?.status ?? 500 + }); + } + + // update the session with the new token set, if necessary + const sessionChanges = getSessionChangesAfterGetAccessToken( + session, + tokenSet, + { + scope: this.authorizationParameters?.scope ?? DEFAULT_SCOPES, + audience: this.authorizationParameters?.audience + } + ); + + if (sessionChanges) { + if (idTokenClaims) { + session.user = idTokenClaims as User; + } + // call beforeSessionSaved callback if present + // if not then filter id_token claims with default rules + const finalSession = await this.finalizeSession( + session, + tokenSet.idToken + ); + await this.sessionStore.set(req.cookies, connectAccountResponse.cookies, { + ...finalSession, + ...sessionChanges + }); + addCacheControlHeadersForSession(connectAccountResponse); + } + + return connectAccountResponse; + } + /** * Retrieves the token set from the session data, considering optional audience and scope parameters. * When audience and scope are provided, it checks if they match the global ones defined in the authorization parameters. @@ -1143,7 +1335,7 @@ export class AuthClient { return [null, authorizationServerMetadata]; } catch (e) { console.error( - `An error occured while performing the discovery request. issuer=${issuer.toString()}, error:`, + `An error occurred while performing the discovery request. issuer=${issuer.toString()}, error:`, e ); return [ @@ -1339,7 +1531,8 @@ export class AuthClient { code: e.error, message: e.error_description }), - message: "An error occured while pushing the authorization request." + message: + "An error occurred while pushing the authorization request." }), null ]; @@ -1541,6 +1734,230 @@ export class AuthClient { return session; } + /** + * Initiates the connect account flow for linking a third-party account to the user's profile. + * The user will be redirected to authorize the connection. + */ + async connectAccount( + options: ConnectAccountOptions & { accessToken: string } + ): Promise<[ConnectAccountError, null] | [null, NextResponse]> { + const redirectUri = createRouteUrl(this.routes.callback, this.appBaseUrl); + let returnTo = this.signInReturnToPath; + + // Validate returnTo parameter + if (options.returnTo) { + const safeBaseUrl = new URL( + (this.authorizationParameters.redirect_uri as string | undefined) || + this.appBaseUrl + ); + const sanitizedReturnTo = toSafeRedirect(options.returnTo, safeBaseUrl); + + if (sanitizedReturnTo) { + returnTo = + sanitizedReturnTo.pathname + + sanitizedReturnTo.search + + sanitizedReturnTo.hash; + } + } + + // Generate PKCE challenges + const codeChallengeMethod = "S256"; + const codeVerifier = oauth.generateRandomCodeVerifier(); + const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier); + const state = oauth.generateRandomState(); + + const [error, connectAccountResponse] = + await this.createConnectAccountTicket({ + accessToken: options.accessToken, + connection: options.connection, + redirectUri: redirectUri.toString(), + state, + codeChallenge, + codeChallengeMethod, + authorizationParams: options.authorizationParams + }); + + if (error) { + return [error, null]; + } + + const transactionState: TransactionState = { + codeVerifier, + responseType: RESPONSE_TYPES.CONNECT_CODE, + state, + returnTo, + authSession: connectAccountResponse.authSession + }; + + const res = NextResponse.redirect( + `${connectAccountResponse.connectUri}?ticket=${encodeURIComponent(connectAccountResponse.connectParams.ticket)}` + ); + + await this.transactionStore.save(res.cookies, transactionState); + + return [null, res]; + } + + private async createConnectAccountTicket( + options: ConnectAccountRequest + ): Promise<[null, ConnectAccountResponse] | [ConnectAccountError, null]> { + try { + const connectAccountUrl = new URL( + "/me/v1/connected-accounts/connect", + this.issuer + ); + + const httpOptions = this.httpOptions(); + const headers = new Headers(httpOptions.headers); + headers.set("Content-Type", "application/json"); + headers.set("Authorization", `Bearer ${options.accessToken}`); + + const res = await this.fetch(connectAccountUrl, { + method: "POST", + headers, + body: JSON.stringify({ + connection: options.connection, + redirect_uri: options.redirectUri, + state: options.state, + code_challenge: options.codeChallenge, + code_challenge_method: options.codeChallengeMethod, + authorization_params: options.authorizationParams + }), + signal: httpOptions.signal + }); + + if (!res.ok) { + try { + const errorBody = await res.json(); + return [ + new ConnectAccountError({ + code: ConnectAccountErrorCodes.FAILED_TO_INITIATE, + message: `The request to initiate the connect account flow 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 ConnectAccountError({ + code: ConnectAccountErrorCodes.FAILED_TO_INITIATE, + message: `The request to initiate the connect account flow failed with status ${res.status}.` + }), + null + ]; + } + } + + const { connect_uri, connect_params, auth_session, expires_in } = + await res.json(); + + return [ + null, + { + connectUri: connect_uri, + connectParams: connect_params, + authSession: auth_session, + expiresIn: expires_in + } + ]; + } catch (e: any) { + return [ + new ConnectAccountError({ + code: ConnectAccountErrorCodes.FAILED_TO_INITIATE, + message: + "An unexpected error occurred while trying to initiate the connect account flow." + }), + null + ]; + } + } + + private async completeConnectAccount( + options: CompleteConnectAccountRequest + ): Promise<[null, CompleteConnectAccountResponse] | [SdkError, null]> { + const completeConnectAccountUrl = new URL( + "/me/v1/connected-accounts/complete", + this.issuer + ); + + try { + const httpOptions = this.httpOptions(); + const headers = new Headers(httpOptions.headers); + headers.set("Content-Type", "application/json"); + headers.set("Authorization", `Bearer ${options.accessToken}`); + + const res = await this.fetch(completeConnectAccountUrl, { + method: "POST", + headers, + body: JSON.stringify({ + auth_session: options.authSession, + connect_code: options.connectCode, + redirect_uri: options.redirectUri, + code_verifier: options.codeVerifier + }), + signal: httpOptions.signal + }); + + if (!res.ok) { + try { + const errorBody = await res.json(); + return [ + new ConnectAccountError({ + code: ConnectAccountErrorCodes.FAILED_TO_COMPLETE, + message: `The request to complete the connect account flow 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 ConnectAccountError({ + code: ConnectAccountErrorCodes.FAILED_TO_COMPLETE, + message: `The request to complete the connect account flow failed with status ${res.status}.` + }), + null + ]; + } + } + + const { id, connection, access_type, scopes, created_at, expires_at } = + await res.json(); + + return [ + null, + { + id, + connection, + accessType: access_type, + scopes, + createdAt: created_at, + expiresAt: expires_at + } + ]; + } catch (e: any) { + return [ + new ConnectAccountError({ + code: ConnectAccountErrorCodes.FAILED_TO_COMPLETE, + message: + "An unexpected error occurred while trying to complete the connect account flow." + }), + null + ]; + } + } + private async getOpenIdClientConfig(): Promise< [null, client.Configuration] | [SdkError, null] > { diff --git a/src/server/client.ts b/src/server/client.ts index 6b3efdc5..d71befaa 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -7,12 +7,15 @@ import { AccessTokenError, AccessTokenErrorCode, AccessTokenForConnectionError, - AccessTokenForConnectionErrorCode + AccessTokenForConnectionErrorCode, + ConnectAccountError, + ConnectAccountErrorCodes } from "../errors/index.js"; import { AccessTokenForConnectionOptions, AuthorizationParameters, BackchannelAuthenticationOptions, + ConnectAccountOptions, LogoutStrategy, SessionData, SessionDataStore, @@ -221,6 +224,11 @@ export interface Auth0ClientOptions { noContentProfileResponseWhenUnauthenticated?: boolean; enableParallelTransactions?: boolean; + + /** + * If true, the `/auth/connect` endpoint will be mounted to enable users to connect additional accounts. + */ + enableConnectAccountEndpoint?: boolean; } export type PagesRouterRequest = IncomingMessage | NextApiRequest; @@ -233,6 +241,7 @@ export class Auth0Client { private sessionStore: AbstractSessionStore; private authClient: AuthClient; private routes: Routes; + private domain: string; #options: Auth0ClientOptions; // Cache for in-flight token requests to prevent race conditions @@ -249,6 +258,7 @@ export class Auth0Client { secret, clientAssertionSigningKey } = this.validateAndExtractRequiredOptions(options); + this.domain = domain; const clientAssertionSigningAlg = options.clientAssertionSigningAlg || @@ -301,6 +311,7 @@ export class Auth0Client { profile: process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile", accessToken: process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token", + connectAccount: "/auth/connect", ...options.routes }; @@ -352,7 +363,8 @@ export class Auth0Client { enableTelemetry: options.enableTelemetry, enableAccessTokenEndpoint: options.enableAccessTokenEndpoint, noContentProfileResponseWhenUnauthenticated: - options.noContentProfileResponseWhenUnauthenticated + options.noContentProfileResponseWhenUnauthenticated, + enableConnectAccountEndpoint: options.enableConnectAccountEndpoint }); } @@ -810,6 +822,45 @@ export class Auth0Client { return response; } + /** + * 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. + * + * This method first attempts to obtain an access token with the `create:me:connected_accounts` scope + * for the My Account API to create a connected account for the user. + * + * The user will then be redirected to authorize the connection with the third-party provider. + */ + async connectAccount(options: ConnectAccountOptions): Promise { + const session = await this.getSession(); + + if (!session) { + throw new ConnectAccountError({ + code: ConnectAccountErrorCodes.MISSING_SESSION, + message: "The user does not have an active session." + }); + } + + const getMyAccountTokenOpts = { + audience: `${this.issuer}/me/`, + scope: "create:me:connected_accounts" + }; + + const accessToken = await this.getAccessToken(getMyAccountTokenOpts); + + const [error, connectAccountResponse] = + await this.authClient.connectAccount({ + ...options, + accessToken: accessToken.token + }); + + if (error) { + throw error; + } + + return connectAccountResponse; + } + withPageAuthRequired( fnOrOpts?: WithPageAuthRequiredPageRouterOptions | AppRouterPageRoute, opts?: WithPageAuthRequiredAppRouterOptions @@ -969,6 +1020,13 @@ export class Auth0Client { [K in keyof typeof result]: NonNullable<(typeof result)[K]>; }; } + + private get issuer(): string { + return this.domain.startsWith("http://") || + this.domain.startsWith("https://") + ? this.domain + : `https://${this.domain}`; + } } export type GetAccessTokenOptions = { diff --git a/src/server/transaction-store.test.ts b/src/server/transaction-store.test.ts index bcdcd7b0..a8446d9d 100644 --- a/src/server/transaction-store.test.ts +++ b/src/server/transaction-store.test.ts @@ -3,6 +3,7 @@ import * as oauth from "oauth4webapi"; import { describe, expect, it } from "vitest"; import { generateSecret } from "../test/utils.js"; +import { RESPONSE_TYPES } from "../types/connected-accounts.js"; import { decrypt, encrypt, @@ -22,7 +23,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; @@ -56,7 +57,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; @@ -90,7 +91,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; @@ -125,7 +126,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, returnTo: "/dashboard", state: "" // missing state }; @@ -151,7 +152,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; @@ -190,7 +191,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; @@ -229,7 +230,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; @@ -264,7 +265,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; @@ -303,7 +304,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; @@ -346,7 +347,7 @@ describe("Transaction Store", async () => { nonce, maxAge: 3600, codeVerifier: codeVerifier, - responseType: "code", + responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 177d52ea..370d7efc 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -1,16 +1,18 @@ import type * as jose from "jose"; +import { RESPONSE_TYPES } from "../types/index.js"; import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; export interface TransactionState extends jose.JWTPayload { - nonce: string; codeVerifier: string; - responseType: string; + responseType: RESPONSE_TYPES; state: string; // the state parameter passed to the authorization server returnTo: string; // the URL to redirect to after login + nonce?: string; // A string value used to associate a client session with an ID Token, and to mitigate replay attacks. codeVerifier: string; maxAge?: number; // the maximum age of the authentication session + authSession?: string; // the auth session ID for connect accounts /** * The scope requested for this transaction. */ diff --git a/src/test/defaults.ts b/src/test/defaults.ts index c68a8076..20558e59 100644 --- a/src/test/defaults.ts +++ b/src/test/defaults.ts @@ -8,6 +8,7 @@ export function getDefaultRoutes(): Routes { backChannelLogout: "/auth/backchannel-logout", profile: process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile", accessToken: - process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token" + process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token", + connectAccount: "/auth/connect" }; } diff --git a/src/types/authorize.ts b/src/types/authorize.ts new file mode 100644 index 00000000..5b330a19 --- /dev/null +++ b/src/types/authorize.ts @@ -0,0 +1,40 @@ +export interface StartInteractiveLoginOptions { + /** + * Authorization parameters to be passed to the authorization server. + */ + authorizationParameters?: AuthorizationParameters; + /** + * The URL to redirect to after a successful login. + */ + returnTo?: string; +} + +export interface AuthorizationParameters { + /** + * The scope of the access request, expressed as a list of space-delimited, case-sensitive strings. + * Defaults to `"openid profile email offline_access"`. + */ + scope?: string | null | { [key: string]: string }; + /** + * The unique identifier of the target API you want to access. + */ + audience?: string | null; + /** + * The URL to which the authorization server will redirect the user after granting authorization. + */ + redirect_uri?: string | null; + /** + * The maximum amount of time, in seconds, after which a user must reauthenticate. + */ + max_age?: number; + /** + * The unique identifier of the organization that the user should be logged into. + * When specified, the user will be prompted to log in to this specific organization. + * The organization ID will be included in the user's session after successful authentication. + */ + organization?: string; + /** + * Additional authorization parameters. + */ + [key: string]: unknown; +} diff --git a/src/types/connected-accounts.ts b/src/types/connected-accounts.ts new file mode 100644 index 00000000..3095767e --- /dev/null +++ b/src/types/connected-accounts.ts @@ -0,0 +1,133 @@ +import { AuthorizationParameters } from "./authorize.js"; + +/** + * Options to initiate a connect account flow using the My Account API. + * @see https://auth0.com/docs/manage-users/my-account-api + */ +export interface ConnectAccountOptions { + /** + * The name of the connection to link the account with (e.g., 'google-oauth2', 'facebook'). + */ + connection: string; + /** + * Authorization parameters to be passed to the authorization server. + */ + authorizationParams?: AuthorizationParameters; + /** + * The URL to redirect to after successfully connecting the account. + */ + returnTo?: string; +} + +export enum RESPONSE_TYPES { + /** + * Authorization Code flow. + */ + CODE = "code", + /** + * Connect Account flow. + */ + CONNECT_CODE = "connect_code" +} + +export interface ConnectAccountRequest { + /** + * The access token with the `create:me:connected_accounts` scope. + */ + accessToken: string; + /** + * The name of the connection to link the account with (e.g., 'google-oauth2', 'facebook'). + */ + connection: string; + /** + * The URI to redirect to after the connection process completes. + */ + redirectUri: string; + /** + * An opaque value used to maintain state between the request and callback. + */ + state?: string; + /** + * The PKCE code challenge derived from the code verifier. + */ + codeChallenge?: string; + /** + * The method used to derive the code challenge. Required when code_challenge is provided. + */ + codeChallengeMethod?: string; + /** + * Authorization parameters to be sent to the underlying Identity Provider (IdP) + */ + authorizationParams?: AuthorizationParameters; +} + +export interface ConnectAccountResponse { + /** + * The URI to redirect the user to for connecting their account. + */ + connectUri: string; + /** + * Parameters required for the connection process, including a ticket. + */ + connectParams: { + ticket: string; + }; + /** + * The authentication session identifier. + */ + authSession: string; + /** + * The lifetime in seconds of the connect account session. + */ + expiresIn: number; +} + +export interface CompleteConnectAccountRequest { + /** + * The access token with the `create:me:connected_accounts` scope. + */ + accessToken: string; + /** + * The authentication session identifier. + */ + authSession: string; + /** + * The authorization code returned from the connect flow. + */ + connectCode: string; + /** + * The redirect URI used in the original request. + */ + redirectUri: string; + /** + * The PKCE code verifier. + */ + codeVerifier?: string; +} + +export interface CompleteConnectAccountResponse { + /** + * The unique identifier of the connected account. + */ + id: string; + /** + * The name of the connection associated with the connected account. + */ + connection: string; + /** + * The access type, always 'offline'. + */ + accessType: string; + /** + * Array of scopes granted. + */ + scopes: string[]; + /** + * ISO date string of when the connected account was created. + */ + createdAt: string; + /** + * ISO date string of when the refresh token expires (optional). + */ + expiresAt?: string; +} diff --git a/src/types/index.ts b/src/types/index.ts index 4b370b2b..102c1f17 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,6 @@ +import { AuthorizationParameters } from "./authorize.js"; +import { ConnectionTokenSet } from "./token-vault.js"; + export interface TokenSet { accessToken: string; idToken?: string; @@ -8,14 +11,6 @@ export interface TokenSet { audience?: string; } -export interface ConnectionTokenSet { - accessToken: string; - scope?: string; - expiresAt: number; // the time at which the access token expires in seconds since epoch - connection: string; - [key: string]: unknown; -} - export interface AccessTokenSet { accessToken: string; scope?: string; @@ -113,89 +108,6 @@ export type { TransactionState } from "../server/transaction-store.js"; -export interface StartInteractiveLoginOptions { - /** - * Authorization parameters to be passed to the authorization server. - */ - authorizationParameters?: AuthorizationParameters; - /** - * The URL to redirect to after a successful login. - */ - returnTo?: string; -} - -export interface AuthorizationParameters { - /** - * The scope of the access request, expressed as a list of space-delimited, case-sensitive strings. - * Defaults to `"openid profile email offline_access"`. - */ - scope?: string | null | { [key: string]: string }; - /** - * The unique identifier of the target API you want to access. - */ - audience?: string | null; - /** - * The URL to which the authorization server will redirect the user after granting authorization. - */ - redirect_uri?: string | null; - /** - * The maximum amount of time, in seconds, after which a user must reauthenticate. - */ - max_age?: number; - /** - * The unique identifier of the organization that the user should be logged into. - * When specified, the user will be prompted to log in to this specific organization. - * The organization ID will be included in the user's session after successful authentication. - */ - organization?: string; - /** - * Additional authorization parameters. - */ - [key: string]: unknown; -} - -export enum SUBJECT_TOKEN_TYPES { - /** - * Indicates that the token is an OAuth 2.0 refresh token issued by the given authorization server. - * - * @see {@link https://datatracker.ietf.org/doc/html/rfc8693#section-3-3.4 RFC 8693 Section 3-3.4} - */ - SUBJECT_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token", - - /** - * Indicates that the token is an OAuth 2.0 access token issued by the given authorization server. - * - * @see {@link https://datatracker.ietf.org/doc/html/rfc8693#section-3-3.2 RFC 8693 Section 3-3.2} - */ - SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" -} - -/** - * Options for retrieving a connection access token. - */ -export interface AccessTokenForConnectionOptions { - /** - * The connection name for while you want to retrieve the access token. - */ - connection: string; - - /** - * An optional login hint to pass to the authorization server. - */ - login_hint?: string; - - /** - * The type of token that is being exchanged. - * - * Uses the {@link SUBJECT_TOKEN_TYPES} enum with the following allowed values: - * - `SUBJECT_TYPE_REFRESH_TOKEN`: `"urn:ietf:params:oauth:token-type:refresh_token"` - * - `SUBJECT_TYPE_ACCESS_TOKEN`: `"urn:ietf:params:oauth:token-type:access_token"` - * - * Defaults to `SUBJECT_TYPE_REFRESH_TOKEN`. - */ - subject_token_type?: SUBJECT_TOKEN_TYPES; -} - /** * Logout strategy options for controlling logout endpoint selection. */ @@ -241,3 +153,14 @@ export interface AuthorizationDetails { readonly type: string; readonly [parameter: string]: unknown; } + +export { + AuthorizationParameters, + StartInteractiveLoginOptions +} from "./authorize.js"; +export { + AccessTokenForConnectionOptions, + ConnectionTokenSet, + SUBJECT_TOKEN_TYPES +} from "./token-vault.js"; +export { ConnectAccountOptions, RESPONSE_TYPES } from "./connected-accounts.js"; diff --git a/src/types/token-vault.ts b/src/types/token-vault.ts new file mode 100644 index 00000000..7fe8d6be --- /dev/null +++ b/src/types/token-vault.ts @@ -0,0 +1,49 @@ +export enum SUBJECT_TOKEN_TYPES { + /** + * Indicates that the token is an OAuth 2.0 refresh token issued by the given authorization server. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc8693#section-3-3.4 RFC 8693 Section 3-3.4} + */ + SUBJECT_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token", + + /** + * Indicates that the token is an OAuth 2.0 access token issued by the given authorization server. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc8693#section-3-3.2 RFC 8693 Section 3-3.2} + */ + SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" +} + +/** + * Options for retrieving a connection access token. + */ +export interface AccessTokenForConnectionOptions { + /** + * The connection name for while you want to retrieve the access token. + */ + connection: string; + + /** + * An optional login hint to pass to the authorization server. + */ + login_hint?: string; + + /** + * The type of token that is being exchanged. + * + * Uses the {@link SUBJECT_TOKEN_TYPES} enum with the following allowed values: + * - `SUBJECT_TYPE_REFRESH_TOKEN`: `"urn:ietf:params:oauth:token-type:refresh_token"` + * - `SUBJECT_TYPE_ACCESS_TOKEN`: `"urn:ietf:params:oauth:token-type:access_token"` + * + * Defaults to `SUBJECT_TYPE_REFRESH_TOKEN`. + */ + subject_token_type?: SUBJECT_TOKEN_TYPES; +} + +export interface ConnectionTokenSet { + accessToken: string; + scope?: string; + expiresAt: number; // the time at which the access token expires in seconds since epoch + connection: string; + [key: string]: unknown; +}