From 3965c381ac4fed73e1e98c2fc69c5d307e734122 Mon Sep 17 00:00:00 2001 From: guabu <135956181+guabu@users.noreply.github.com> Date: Sun, 9 Nov 2025 12:23:45 +0100 Subject: [PATCH 1/2] feat: add support `scopes` parameter for connected accounts --- EXAMPLES.md | 179 +++++++++++++++++++------------- src/server/auth-client.test.ts | 74 ++++++++----- src/server/auth-client.ts | 9 +- src/types/connected-accounts.ts | 8 ++ 4 files changed, 166 insertions(+), 104 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index da333d76..7b1f16f8 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -212,7 +212,9 @@ By default, the logout endpoint only logs the user out from Auth0's session. To Logout from IdP -Logout from IdP +Logout from IdP ``` The `federated` parameter works with all logout strategies (`auto`, `oidc`, and `v2`) and is passed through to the appropriate Auth0 logout endpoint: @@ -291,6 +293,7 @@ The `useUser()` hook uses SWR (Stale-While-Revalidate) under the hood, which pro - **Event-driven revalidation**: Data automatically revalidates when you focus the browser tab, reconnect to the internet, or mount the component - **No background polling**: The hook does **not** make continuous background requests unless explicitly configured - **Cache-first approach**: Returns cached data immediately, then revalidates if needed + ### On the server (App Router) On the server, the `getSession()` helper can be used in Server Components, Server Routes, and Server Actions to get the session of the currently authenticated user and to protect resources, like so: @@ -862,7 +865,9 @@ import { auth0 } from "@/lib/auth0"; export async function GET() { try { // Force a refresh of the access token - const { token, expiresAt, scope } = await auth0.getAccessToken({ refresh: true }); + const { token, expiresAt, scope } = await auth0.getAccessToken({ + refresh: true + }); // Use the refreshed token // ... @@ -907,14 +912,12 @@ export default withApiAuthRequired(async function handler( By setting `{ refresh: true }`, you instruct the SDK to bypass the standard expiration check and request a new access token from the identity provider using the refresh token (if available and valid). The new token set (including the potentially updated access token, refresh token, and expiration time) will be saved back into the session automatically. This will in turn, update the `access_token`, `id_token` and `expires_at` fields of `tokenset` in the session. - ### Multi-Resource Refresh Tokens (MRRT) Multi-Resource Refresh Tokens allow using a single refresh token to obtain access tokens for multiple audiences, simplifying token management in applications that interact with multiple backend services. Read more about [Multi-Resource Refresh Tokens in the Auth0 documentation](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token). - > [!WARNING] > When using Multi-Resource Refresh Token Configuration (MRRT), **Refresh Token Policies** on your Application need to be configured with the audiences you want to support. See the [Auth0 MRRT documentation](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) for setup instructions. > @@ -948,9 +951,12 @@ export const auth0 = new Auth0Client({ authorizationParameters: { audience: "https://api.example.com", // Default audience scope: { - "https://api.example.com": "openid profile email offline_access read:products read:orders", - "https://analytics.example.com": "openid profile email offline_access read:analytics write:analytics", - "https://admin.example.com": "openid profile email offline_access read:admin write:admin delete:admin" + "https://api.example.com": + "openid profile email offline_access read:products read:orders", + "https://analytics.example.com": + "openid profile email offline_access read:analytics write:analytics", + "https://admin.example.com": + "openid profile email offline_access read:admin write:admin delete:admin" } } }); @@ -972,6 +978,7 @@ To retrieve access tokens for different audiences, use the `getAccessToken()` me ```typescript // app/api/data/route.ts import { NextResponse } from "next/server"; + import { auth0 } from "@/lib/auth0"; export async function GET() { @@ -1018,7 +1025,8 @@ export const auth0 = new Auth0Client({ authorizationParameters: { audience: "https://api.example.com", // Configure broad default scopes for most common operations - scope: "openid profile email offline_access read:products read:orders read:users" + scope: + "openid profile email offline_access read:products read:orders read:users" } }); ``` @@ -1065,7 +1073,7 @@ DPoP is an OAuth 2.0 extension that enhances security by binding access tokens t DPoP (Demonstrating Proof-of-Possession) provides application-level proof-of-possession security for OAuth 2.0. Key benefits include: - **Token Binding**: Access tokens are cryptographically bound to the client's key pair -- **Theft Protection**: Stolen tokens cannot be used without the corresponding private key +- **Theft Protection**: Stolen tokens cannot be used without the corresponding private key - **Replay Attack Prevention**: Each request includes a unique proof-of-possession signature - **Enhanced Security**: Complements OAuth 2.0 with additional cryptographic guarantees @@ -1121,7 +1129,7 @@ For generating keys and exporting them to environment variables: ```typescript import { generateDpopKeyPair } from "@auth0/nextjs-auth0/server"; -import { exportSPKI, exportPKCS8 } from "jose"; +import { exportPKCS8, exportSPKI } from "jose"; // Generate new key pair and export for environment variables const keyPair = await generateDpopKeyPair(); @@ -1146,8 +1154,8 @@ When you enable DPoP globally in your `Auth0Client`, all fetchers automatically ```typescript // lib/auth0.ts - Global DPoP configuration export const auth0 = new Auth0Client({ - useDPoP: true, // Enable DPoP globally - dpopKeyPair // Your key pair + useDPoP: true, // Enable DPoP globally + dpopKeyPair // Your key pair }); // Fetchers inherit DPoP settings automatically @@ -1165,19 +1173,20 @@ You can override the global DPoP setting for specific fetchers when needed: // Explicitly enable DPoP (when global setting is false) const dpopFetcher = await auth0.createFetcher(req, { baseUrl: "https://secure-api.example.com", - useDPoP: true // Override global setting + useDPoP: true // Override global setting }); // Explicitly disable DPoP (when global setting is true) const legacyFetcher = await auth0.createFetcher(req, { baseUrl: "https://legacy-api.example.com", - useDPoP: false // Override global setting for legacy API + useDPoP: false // Override global setting for legacy API }); ``` **Fallback Behavior** The DPoP configuration follows this precedence order: + 1. **Explicit fetcher option**: `options.useDPoP` (when specified) 2. **Global Auth0Client setting**: `auth0.useDPoP` (when fetcher option not specified) 3. **Default**: `false` (when neither is configured) @@ -1220,7 +1229,7 @@ export default async function handler(req, res) { // Create fetcher with explicit DPoP override for legacy API compatibility const fetcher = await auth0.createFetcher(req, { baseUrl: "https://api.example.com", - useDPoP: false // Explicitly disable DPoP for this legacy API + useDPoP: false // Explicitly disable DPoP for this legacy API }); try { @@ -1248,14 +1257,14 @@ export const auth0 = new Auth0Client({ dpopOptions: { // Clock tolerance: Allow up to 60 seconds difference between client/server clocks clockTolerance: 60, - + // Clock skew: Adjust if your server clock is consistently ahead/behind (rare) clockSkew: 0, - + // Retry configuration: Control behavior when DPoP nonce errors occur retry: { - delay: 200, // Wait 200ms before retry (prevents server overload) - jitter: true // Add randomness to prevent thundering herd effect + delay: 200, // Wait 200ms before retry (prevents server overload) + jitter: true // Add randomness to prevent thundering herd effect } } }); @@ -1289,9 +1298,10 @@ Handle DPoP-specific errors gracefully with proper error detection and response Implement comprehensive error handling for DPoP configuration and runtime issues: ```typescript -import { auth0 } from "@/lib/auth0"; import { DPoPError, DPoPErrorCode } from "@auth0/nextjs-auth0/server"; +import { auth0 } from "@/lib/auth0"; + try { const fetcher = await auth0.createFetcher(req, { baseUrl: "https://api.example.com", @@ -1300,13 +1310,13 @@ try { const response = await fetcher.fetchWithAuth("/protected-resource"); const data = await response.json(); - + return Response.json(data); } catch (error) { // Check for DPoP-specific errors first if (error instanceof DPoPError) { console.error(`DPoP Error [${error.code}]:`, error.message); - + // Handle specific DPoP error types switch (error.code) { case DPoPErrorCode.DPOP_KEY_EXPORT_FAILED: @@ -1328,7 +1338,7 @@ try { ); } } - + // Handle non-DPoP errors (network, API errors, etc.) return Response.json({ error: "Request failed" }, { status: 500 }); } @@ -1380,9 +1390,9 @@ Pass token options directly to individual requests: ```ts // Specify audience and scope per request const response = await fetcher.fetchWithAuth("/protected-resource", { - scope: "read:admin write:admin", // Request specific scopes - audience: "https://api.example.com", // Target specific API - refresh: true // Force token refresh if needed + scope: "read:admin write:admin", // Request specific scopes + audience: "https://api.example.com", // Target specific API + refresh: true // Force token refresh if needed }); ``` @@ -1392,12 +1402,13 @@ Enable DPoP selectively based on environment or security requirements: ```typescript // Dynamic DPoP configuration based on environment or route sensitivity -const shouldUseDPoP = process.env.NODE_ENV === "production" || - request.url.includes("/sensitive-api"); +const shouldUseDPoP = + process.env.NODE_ENV === "production" || + request.url.includes("/sensitive-api"); const fetcher = await auth0.createFetcher(req, { baseUrl: "https://api.example.com", - useDPoP: shouldUseDPoP // DPoP only for production or sensitive routes + useDPoP: shouldUseDPoP // DPoP only for production or sensitive routes }); ``` @@ -1412,21 +1423,20 @@ const fetcher = await auth0.createFetcher(req, { // Custom fetch implementation with logging and metrics fetch: async (request) => { console.log(`DPoP request to: ${request.url}`); - + const startTime = Date.now(); const response = await fetch(request); const duration = Date.now() - startTime; - + // Log response metrics console.log(`Response: ${response.status} (${duration}ms)`); - + // Could add custom headers, retry logic, etc. return response; } }); ``` - ### Token Audience Validation with Multiple APIs When using DPoP with **multiple audiences** in the same application (e.g., via MRRT policies), ensure each access token is sent **only** to its intended API. Sending a token to the wrong API will result in audience validation failures. @@ -1438,20 +1448,22 @@ When creating multiple fetcher instances for different APIs: ```javascript // Fetcher for API 1 const fetcher1 = createFetcher({ - url: 'https://api1.example.com', - accessTokenFactory: () => getAccessToken({ - audience: 'https://api1.example.com', - // ... - }) + url: "https://api1.example.com", + accessTokenFactory: () => + getAccessToken({ + audience: "https://api1.example.com" + // ... + }) }); -// Fetcher for API 2 +// Fetcher for API 2 const fetcher2 = createFetcher({ - url: 'https://api2.example.com', - accessTokenFactory: () => getAccessToken({ - audience: 'https://api2.example.com', - // ... - }) + url: "https://api2.example.com", + accessTokenFactory: () => + getAccessToken({ + audience: "https://api2.example.com" + // ... + }) }); ``` @@ -1464,21 +1476,25 @@ OAUTH_JWT_CLAIM_COMPARISON_FAILED: unexpected JWT "aud" (audience) claim value #### Mitigation Strategies **1. Scope fetcher instances appropriately** + - Create one fetcher per API/audience combination - Use clear, descriptive variable names that indicate which API each fetcher targets - Consider namespacing or module organization to prevent confusion **2. Configure MRRT policies correctly** + - Ensure your MRRT policies include all audiences your application needs to access - Set `skip_consent_for_verifiable_first_party_clients: true` on all APIs in MRRT policies - Only include **custom scopes** in MRRT policies (OIDC scopes like `openid`, `profile`, `offline_access` are automatically included) **3. Validate in development** + - Log the `aud` claim from decoded tokens during development to verify correct routing - Implement error handling that clearly identifies audience mismatches - Test each fetcher instance against its intended API endpoint before production deployment **4. API server validation** + - Ensure your API servers validate the `aud` claim matches their expected audience identifier - Use the same audience string in both Auth0 API configuration and server-side validation @@ -1486,11 +1502,11 @@ OAUTH_JWT_CLAIM_COMPARISON_FAILED: unexpected JWT "aud" (audience) claim value ```javascript // ✅ Correct: Each fetcher calls its own API -await fetcher1.fetchWithAuth('/users'); // Uses token with aud: "https://api1.example.com" -await fetcher2.fetchWithAuth('/orders'); // Uses token with aud: "https://api2.example.com" +await fetcher1.fetchWithAuth("/users"); // Uses token with aud: "https://api1.example.com" +await fetcher2.fetchWithAuth("/orders"); // Uses token with aud: "https://api2.example.com" // ❌ Incorrect: Wrong fetcher for the API -await fetcher1.fetchWithAuth('https://api2.example.com/orders'); // Will fail with aud mismatch +await fetcher1.fetchWithAuth("https://api2.example.com/orders"); // Will fail with aud mismatch ``` **Remember**: JWT audience validation is a critical security feature that prevents token misuse across different resource servers. These errors indicate your security controls are working correctly—the solution is to ensure proper token-to-API routing in your application code. @@ -1500,7 +1516,7 @@ await fetcher1.fetchWithAuth('https://api2.example.com/orders'); // Will fail wi Follow these guidelines for secure DPoP implementation: - **Key Management**: Use hardware security modules (HSMs) for key storage in production -- **Key Rotation**: Implement regular key rotation policies for long-lived applications +- **Key Rotation**: Implement regular key rotation policies for long-lived applications - **Monitoring**: Monitor DPoP error rates to detect potential attacks or configuration issues - **Clock Tolerance**: Keep clock tolerance as low as possible (≤ 30 seconds recommended) - **Environment Isolation**: Use unique key pairs per environment (dev, staging, production) @@ -1513,27 +1529,34 @@ Diagnose and resolve common DPoP configuration and runtime issues. #### Common Issues **DPoP keys not found:** + ``` WARNING: useDPoP is set to true but dpopKeyPair is not provided. ``` + **Solution**: Ensure `AUTH0_DPOP_PUBLIC_KEY` and `AUTH0_DPOP_PRIVATE_KEY` are set correctly in your environment, or provide the `dpopKeyPair` option directly in the Auth0Client constructor. **Key pair validation failed:** + ``` WARNING: Private and public keys do not form a valid key pair ``` + **Solution**: Verify that your keys are correctly paired, in PEM format, and use the P-256 elliptic curve. Regenerate keys if necessary using the SDK's `generateDpopKeyPair()` function. **Clock tolerance warnings:** + ``` WARNING: clockTolerance of 300s exceeds recommended maximum of 30s ``` + **Solution**: Synchronize server clocks using NTP instead of increasing tolerance. High tolerance values weaken DPoP security. **DPoP nonce errors:** If you see frequent nonce errors, check: + - **Server clock synchronization**: Ensure clocks are accurate and synced -- **Network stability**: Verify stable connection between client and authorization server +- **Network stability**: Verify stable connection between client and authorization server - **Rate limiting**: Check if authorization server is rate limiting requests #### Debug Logging @@ -1547,12 +1570,13 @@ const fetcher = await auth0.createFetcher(req, { useDPoP: true, fetch: async (request) => { // Log outgoing request details - console.log("DPoP Request Headers:", + console.log( + "DPoP Request Headers:", Object.fromEntries(request.headers.entries()) ); - + const response = await fetch(request); - + // Log response details, especially for failures if (!response.ok) { console.error("DPoP Request Failed:", { @@ -1561,7 +1585,7 @@ const fetcher = await auth0.createFetcher(req, { headers: Object.fromEntries(response.headers.entries()) }); } - + return response; } }); @@ -1611,7 +1635,8 @@ export const auth0 = new Auth0Client({ authorizationParameters: { audience: "urn:your-api-identifier", scope: { - [`https://${process.env.AUTH0_DOMAIN}/me/`]: "profile:read profile:write factors:manage" + [`https://${process.env.AUTH0_DOMAIN}/me/`]: + "profile:read profile:write factors:manage" } } }); @@ -1636,14 +1661,14 @@ export default function MyAccountProfile() { const response = await fetch("/me/v1/profile", { method: "GET", headers: { - "scope": "profile:read" + scope: "profile:read" } }); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const data = await response.json(); setProfile(data); } catch (error) { @@ -1659,15 +1684,15 @@ export default function MyAccountProfile() { method: "PATCH", headers: { "content-type": "application/json", - "scope": "profile:write" + scope: "profile:write" }, body: JSON.stringify(updates) }); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + return await response.json(); } catch (error) { console.error("Failed to update profile:", error); @@ -1680,9 +1705,7 @@ export default function MyAccountProfile() { - {profile && ( -
{JSON.stringify(profile, null, 2)}
- )} + {profile &&
{JSON.stringify(profile, null, 2)}
} ); } @@ -1695,6 +1718,7 @@ The `scope` header specifies the scope required for the request. The SDK uses th Format: `"scope": "scope1 scope2 scope3"` Common scopes for My Account API: + - `profile:read` - Read user profile information - `profile:write` - Update user profile information - `factors:read` - Read enrolled MFA factors @@ -1718,7 +1742,8 @@ export const auth0 = new Auth0Client({ authorizationParameters: { audience: "urn:your-api-identifier", scope: { - [`https://${process.env.AUTH0_DOMAIN}/my-org/`]: "org:read org:write members:read" + [`https://${process.env.AUTH0_DOMAIN}/my-org/`]: + "org:read org:write members:read" } } }); @@ -1731,7 +1756,7 @@ Make requests to the My Organization API through the `/my-org/*` path: ```tsx "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; export default function MyOrganization() { const [organizations, setOrganizations] = useState([]); @@ -1747,14 +1772,14 @@ export default function MyOrganization() { const response = await fetch("/my-org/organizations", { method: "GET", headers: { - "scope": "org:read" + scope: "org:read" } }); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const data = await response.json(); setOrganizations(data.organizations || []); } catch (error) { @@ -1770,15 +1795,15 @@ export default function MyOrganization() { method: "PATCH", headers: { "content-type": "application/json", - "scope": "org:write" + scope: "org:write" }, body: JSON.stringify(updates) }); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + return await response.json(); } catch (error) { console.error("Failed to update organization:", error); @@ -1802,6 +1827,7 @@ export default function MyOrganization() { ``` Common scopes for My Organization API: + - `org:read` - Read organization information - `org:write` - Update organization information - `members:read` - Read organization members @@ -1824,7 +1850,7 @@ const myAccountClient = new MyAccountClient({ ...init, headers: { ...init?.headers, - "scope": authParams?.scope?.join(" ") || "" + scope: authParams?.scope?.join(" ") || "" } }); } @@ -1832,6 +1858,7 @@ const myAccountClient = new MyAccountClient({ ``` This configuration: + - Sets `baseUrl` to `/me` to route requests through the proxy - Passes the required scope via the `scope` header - Ensures the SDK middleware handles authentication transparently @@ -1897,6 +1924,7 @@ export const auth0 = new Auth0Client({ ``` This will log: + - Request proxying flow - Token retrieval and refresh operations - DPoP proof generation @@ -2042,6 +2070,7 @@ export const auth0 = new Auth0Client({ Rolling sessions provide a seamless user experience by automatically extending session lifetime as users actively use your application. Here's how they work: **How rolling sessions work:** + - Each request to your application extends the session by the `inactivityDuration` - Sessions are only extended if used within the inactivity window - Once the `absoluteDuration` is reached, sessions expire regardless of activity @@ -2056,13 +2085,14 @@ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)] }; -// ❌ INCORRECT: Narrow matcher breaks rolling sessions +// ❌ INCORRECT: Narrow matcher breaks rolling sessions export const config = { matcher: ["/dashboard/:path*", "/profile/:path*"] }; ``` **Why broad middleware is necessary:** + - **Session extension**: Each page request extends the session lifetime - **Consistent auth state**: Ensures authentication status is up-to-date across all pages - **Security headers**: Applies no-cache headers to prevent caching of authenticated content @@ -2269,6 +2299,7 @@ The connect endpoint (`/auth/connect` or your custom path) accepts the following - `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. +- `scopes`: (optional) defines the permissions that the client requests from the Identity Provider.. Can be specified as multiple values (e.g., `?scopes=openid&scopes=profile&scopes=email`) or using bracket notation (e.g., `?scopes[]=openid&scopes[]=profile&scopes[]=email`). - Any additional parameters will be passed as the `authorizationParams` in the call to `/me/v1/connected-accounts/connect`. ### `onCallback` hook @@ -2304,8 +2335,8 @@ import { auth0 } from "@/lib/auth0"; export async function GET() { const res = await auth0.connectAccount({ connection: "my-connection", + scopes: ["openid", "profile", "offline_access", "read:something"], authorizationParams: { - scope: "openid profile offline_access read:something", prompt: "consent", audience: "https://myapi.com" }, diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 3386ceb3..ad5853f6 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -5977,9 +5977,15 @@ ca/T0LLtgmbMmxSv/MmzIg== state: expect.any(String), code_challenge: expect.any(String), code_challenge_method: "S256", + scopes: [ + "openid", + "profile", + "email", + "offline_access", + "read:messages" + ], authorization_params: expect.objectContaining({ - audience: "urn:some-audience", - scope: "openid profile email offline_access read:messages" + audience: "urn:some-audience" }) }) ); @@ -6017,10 +6023,11 @@ ca/T0LLtgmbMmxSv/MmzIg== 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" - ); + url.searchParams.append("scopes", "openid"); + url.searchParams.append("scopes", "profile"); + url.searchParams.append("scopes", "email"); + url.searchParams.append("scopes", "offline_access"); + url.searchParams.append("scopes", "read:messages"); const request = new NextRequest(url, { method: "GET", @@ -6117,9 +6124,15 @@ ca/T0LLtgmbMmxSv/MmzIg== state: expect.any(String), code_challenge: expect.any(String), code_challenge_method: "S256", + scopes: [ + "openid", + "profile", + "email", + "offline_access", + "read:messages" + ], authorization_params: expect.objectContaining({ - audience: "urn:some-audience", - scope: "openid profile email offline_access read:messages" + audience: "urn:some-audience" }) }) ); @@ -6157,10 +6170,11 @@ ca/T0LLtgmbMmxSv/MmzIg== 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" - ); + url.searchParams.append("scopes", "openid"); + url.searchParams.append("scopes", "profile"); + url.searchParams.append("scopes", "email"); + url.searchParams.append("scopes", "offline_access"); + url.searchParams.append("scopes", "read:messages"); const request = new NextRequest(url, { method: "GET", @@ -6254,10 +6268,11 @@ ca/T0LLtgmbMmxSv/MmzIg== 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" - ); + url.searchParams.append("scopes", "openid"); + url.searchParams.append("scopes", "profile"); + url.searchParams.append("scopes", "email"); + url.searchParams.append("scopes", "offline_access"); + url.searchParams.append("scopes", "read:messages"); authClient.handleConnectAccount = vi.fn(); expect(authClient.handleConnectAccount).not.toHaveBeenCalled(); @@ -6294,10 +6309,11 @@ ca/T0LLtgmbMmxSv/MmzIg== 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" - ); + url.searchParams.append("scopes", "openid"); + url.searchParams.append("scopes", "profile"); + url.searchParams.append("scopes", "email"); + url.searchParams.append("scopes", "offline_access"); + url.searchParams.append("scopes", "read:messages"); const request = new NextRequest(url, { method: "GET", @@ -6425,10 +6441,11 @@ ca/T0LLtgmbMmxSv/MmzIg== 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" - ); + url.searchParams.append("scopes", "openid"); + url.searchParams.append("scopes", "profile"); + url.searchParams.append("scopes", "email"); + url.searchParams.append("scopes", "offline_access"); + url.searchParams.append("scopes", "read:messages"); const request = new NextRequest(url, { method: "GET", @@ -6498,10 +6515,11 @@ ca/T0LLtgmbMmxSv/MmzIg== 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" - ); + url.searchParams.append("scopes", "openid"); + url.searchParams.append("scopes", "profile"); + url.searchParams.append("scopes", "email"); + url.searchParams.append("scopes", "offline_access"); + url.searchParams.append("scopes", "read:messages"); const request = new NextRequest(url, { method: "GET", diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 5996880b..fc96607c 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -1013,12 +1013,14 @@ 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 + // pass all query params except `connection`, `returnTo`, `scopes` as authorization params const connection = req.nextUrl.searchParams.get("connection"); const returnTo = req.nextUrl.searchParams.get("returnTo") ?? undefined; + const scopes = req.nextUrl.searchParams.getAll("scopes"); const authorizationParams = Object.fromEntries( [...req.nextUrl.searchParams.entries()].filter( - ([key]) => key !== "connection" && key !== "returnTo" + ([key]) => + key !== "connection" && key !== "returnTo" && key !== "scopes" ) ); @@ -1056,6 +1058,7 @@ export class AuthClient { await this.connectAccount({ tokenSet: tokenSet, connection, + scopes, authorizationParams, returnTo }); @@ -1819,6 +1822,7 @@ export class AuthClient { state, codeChallenge, codeChallengeMethod, + scopes: options.scopes, authorizationParams: options.authorizationParams }); @@ -1873,6 +1877,7 @@ export class AuthClient { state: options.state, code_challenge: options.codeChallenge, code_challenge_method: options.codeChallengeMethod, + scopes: options.scopes, authorization_params: options.authorizationParams }; diff --git a/src/types/connected-accounts.ts b/src/types/connected-accounts.ts index 657030b4..8eaae85b 100644 --- a/src/types/connected-accounts.ts +++ b/src/types/connected-accounts.ts @@ -10,6 +10,10 @@ export interface ConnectAccountOptions { * The name of the connection to link the account with (e.g., 'google-oauth2', 'facebook'). */ connection: string; + /** + * Array of scopes to request from the Identity Provider during the connect account flow. + */ + scopes?: string[]; /** * Authorization parameters to be passed to the authorization server. */ @@ -56,6 +60,10 @@ export interface ConnectAccountRequest { * The method used to derive the code challenge. Required when code_challenge is provided. */ codeChallengeMethod?: string; + /** + * Array of scopes to request during the connect account flow. + */ + scopes?: string[]; /** * Authorization parameters to be sent to the underlying Identity Provider (IdP) */ From 8792639ace1f64cda9e410e70691fbcdc6936834 Mon Sep 17 00:00:00 2001 From: guabu <135956181+guabu@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:59:51 +0100 Subject: [PATCH 2/2] chore: add callout to enable offline access. only send scopes if specifed at least 1. --- EXAMPLES.md | 6 ++ src/server/auth-client.test.ts | 106 +++++++++++++++++++++++++++++++++ src/server/auth-client.ts | 18 +++--- src/server/client.ts | 2 + 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 7b1f16f8..cdc65f63 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2302,6 +2302,9 @@ The connect endpoint (`/auth/connect` or your custom path) accepts the following - `scopes`: (optional) defines the permissions that the client requests from the Identity Provider.. Can be specified as multiple values (e.g., `?scopes=openid&scopes=profile&scopes=email`) or using bracket notation (e.g., `?scopes[]=openid&scopes[]=profile&scopes[]=email`). - Any additional parameters will be passed as the `authorizationParams` in the call to `/me/v1/connected-accounts/connect`. +> [!IMPORTANT] +> You must enable `Offline Access` from the Connection Permissions settings to be able to use the connection with Connected Accounts. + ### `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: @@ -2347,6 +2350,9 @@ export async function GET() { } ``` +> [!IMPORTANT] +> You must enable `Offline Access` from the Connection Permissions settings to be able to use the connection with Connected Accounts. + ## 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/server/auth-client.test.ts b/src/server/auth-client.test.ts index ad5853f6..3caa4b39 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -6549,6 +6549,112 @@ ca/T0LLtgmbMmxSv/MmzIg== const response = await authClient.handler(request); expect(response.status).toEqual(400); }); + + it("should only forward the scopes if at least one scope is requested", 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", + expires_in: 86400 // expires in 10 days + } as oauth.TokenEndpointResponse, + onConnectAccountRequest: async (req) => { + connectAccountRequestBody = await req.json(); + expect(connectAccountRequestBody.scopes).toBeUndefined(); + } + }), + + 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"); + + 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 + }) + ); + }); }); describe("getTokenSet", async () => { diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index fc96607c..a6276803 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -1054,14 +1054,18 @@ export class AuthClient { } const { tokenSet } = getTokenSetResponse; + const connectAccountParams: ConnectAccountOptions = { + connection, + authorizationParams, + returnTo + }; + + if (scopes.length > 0) { + connectAccountParams.scopes = scopes; + } + const [connectAccountError, connectAccountResponse] = - await this.connectAccount({ - tokenSet: tokenSet, - connection, - scopes, - authorizationParams, - returnTo - }); + await this.connectAccount({ tokenSet, ...connectAccountParams }); if (connectAccountError) { return new NextResponse(connectAccountError.message, { diff --git a/src/server/client.ts b/src/server/client.ts index 5424538f..d5dd2613 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1007,6 +1007,8 @@ export class Auth0Client { * 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. + * + * 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 session = await this.getSession();