diff --git a/EXAMPLES.md b/EXAMPLES.md index 5855892b8..635abbeac 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -45,6 +45,15 @@ - [Handling `MfaRequiredError`](#handling-mfarequirederror) - [MFA Tenant Configuration](#mfa-tenant-configuration) - [Critical Warning](#critical-warning) +- [Reactive MFA Step-Up (Popup)](#reactive-mfa-step-up-popup) + - [Overview](#overview-1) + - [Basic Usage](#basic-usage) + - [Handling MfaRequiredError from Client Components](#handling-mfarequirederror-from-client-components) + - [Configuration Options](#configuration-options) + - [CSP Nonce Support](#csp-nonce-support) + - [Error Handling](#error-handling-2) + - [Security Considerations](#security-considerations-1) + - [Known Limitations](#known-limitations) - [Silent authentication](#silent-authentication) - [DPoP (Demonstrating Proof-of-Possession)](#dpop-demonstrating-proof-of-possession) - [What is DPoP?](#what-is-dpop) @@ -1342,8 +1351,9 @@ export async function GET() { ``` **Client Side:** -When the client receives the 403 with `mfa_required`, you should redirect the user to complete the step-up challenge. +When the client receives the 403 with `mfa_required`, you can either redirect the user to a dedicated MFA page or use the popup-based approach to complete MFA without a full-page redirect. +**Option 1: Full-page redirect** ```javascript const response = await fetch("/api/protected"); if (response.status === 403) { @@ -1356,6 +1366,10 @@ if (response.status === 403) { } ``` +**Option 2: Popup (no redirect)** + +Use `mfa.challengeWithPopup()` to complete MFA in a popup without leaving the current page. See [Reactive MFA Step-Up (Popup)](#reactive-mfa-step-up-popup) for full documentation. + ### MFA Tenant Configuration The SDK relies on background token refreshes to maintain user sessions. For these non-interactive requests to succeed, it is important to configure your MFA policies to allow `refresh_token` exchanges without immediate user challenge. @@ -3705,10 +3719,194 @@ The SDK provides typed error classes for all MFA operations: | `MfaTokenExpiredError` | `mfa_token_expired` | Token TTL exceeded | Context expired | | `MfaTokenInvalidError` | `mfa_token_invalid` | Token tampered or wrong secret | Decryption failed | -## Multiple Custom Domains (MCD) +## Reactive MFA Step-Up (Popup) ### Overview +The SDK supports **reactive MFA step-up** via a browser popup using Auth0 Universal Login. When an API call fails with `mfa_required`, the client-side `mfa.challengeWithPopup()` method opens a popup window where the user completes MFA through Auth0's Universal Login. After completion, the token is cached in the server-side session and returned directly to the caller — no full-page redirect required. + +This is useful for applications that need to protect specific actions (e.g., transferring funds, changing settings) with MFA without disrupting the user's current page state. + +**Flow summary:** +1. App calls an API that requires MFA → receives `MfaRequiredError` +2. App calls `mfa.challengeWithPopup({ audience })` → popup opens +3. User completes MFA in the popup via Auth0 Universal Login +4. Popup sends result back via `postMessage` → popup auto-closes +5. SDK retrieves the cached token from the server session +6. `challengeWithPopup()` resolves with the access token + +### Basic Usage + +```tsx +'use client'; + +import { mfa, getAccessToken } from '@auth0/nextjs-auth0/client'; +import { MfaRequiredError } from '@auth0/nextjs-auth0/errors'; +import { useState } from 'react'; + +export function ProtectedAction() { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + async function handleAction() { + try { + // 1. Try to get an access token for the protected API + const token = await getAccessToken({ + audience: 'https://api.example.com', + scope: 'read:sensitive' + }); + + // 2. Use the token to call your API + const res = await fetch('https://api.example.com/sensitive', { + headers: { Authorization: `Bearer ${token}` } + }); + setResult(await res.json()); + } catch (err) { + if (err instanceof MfaRequiredError) { + try { + // 3. MFA required — trigger popup step-up + const { token } = await mfa.challengeWithPopup({ + audience: 'https://api.example.com', + scope: 'read:sensitive' + }); + + // 4. Retry with the step-up token + const res = await fetch('https://api.example.com/sensitive', { + headers: { Authorization: `Bearer ${token}` } + }); + setResult(await res.json()); + } catch (popupErr) { + setError(popupErr.message); + } + } else { + setError(err.message); + } + } + } + + return ( +
+ + {error &&

{error}

} + {result &&
{JSON.stringify(result, null, 2)}
} +
+ ); +} +``` + +### Handling MfaRequiredError from Client Components + +The client-side `getAccessToken()` helper automatically detects 403 responses with `error: "mfa_required"` and throws `MfaRequiredError`. This allows you to use `instanceof` checks to trigger the popup flow: + +```tsx +import { getAccessToken } from '@auth0/nextjs-auth0/client'; +import { MfaRequiredError } from '@auth0/nextjs-auth0/errors'; + +try { + const token = await getAccessToken({ audience: 'https://api.example.com' }); +} catch (err) { + if (err instanceof MfaRequiredError) { + // Trigger popup MFA step-up + const { token } = await mfa.challengeWithPopup({ + audience: 'https://api.example.com' + }); + } +} +``` + +> [!NOTE] +> The `MfaRequiredError` detection works for both server-side and client-side `getAccessToken()` calls. On the client, it is reconstructed from the 403 JSON response returned by the `/auth/access-token` endpoint. + +### Configuration Options + +`challengeWithPopup()` accepts the following options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `audience` | `string` | *(required)* | Target API audience identifier | +| `scope` | `string` | `'openid profile email'` | Space-separated scopes for the token | +| `acr_values` | `string` | `'http://schemas.openid.net/pape/policies/2007/06/multi-factor'` | ACR values sent to Auth0 for step-up policy | +| `returnTo` | `string` | `'/'` | Return URL (used internally by the OAuth flow) | +| `timeout` | `number` | `60000` | Popup timeout in milliseconds | +| `popupWidth` | `number` | `400` | Popup window width in pixels | +| `popupHeight` | `number` | `600` | Popup window height in pixels | + +**Example with custom options:** + +```tsx +const { token } = await mfa.challengeWithPopup({ + audience: 'https://api.example.com', + scope: 'openid profile email transfer:funds', + timeout: 120000, // 2 minutes + popupWidth: 500, + popupHeight: 700 +}); +``` + +> [!NOTE] +> Popup timeout is configured per-call only. There is no server-side configuration option or environment variable for this — timeout is a client-side runtime concern. If you need a consistent default across your app, define an application-level constant and pass it to every call. + +### CSP Nonce Support + +If your application uses a strict Content Security Policy that blocks inline scripts, configure a CSP nonce on the server-side `Auth0Client`: + +```typescript +// lib/auth0.ts +import { Auth0Client } from '@auth0/nextjs-auth0/server'; + +export const auth0 = new Auth0Client({ + cspNonce: 'your-generated-nonce' +}); +``` + +The nonce is injected into the `' + }) + ).toThrow(/cspNonce must contain only base64 characters/); + }); + + it("should escape < in JSON to prevent script injection (XSS)", async () => { + const response = createAuthCompletePostMessageResponse({ + success: false, + error: { + code: "xss_test", + message: '' + } + }); + + const body = await response.text(); + // The JSON inside injection + expect(body).not.toContain("") + // produces the literal "" which the HTML parser interprets as + // closing the injection is impossible. Unlike escapeHtml() (which produces + // HTML entities like " that are NOT valid JS), \u003c is valid in both + // JS string literals and JSON. + const message = ( + options.success + ? JSON.stringify({ + type: "auth_complete", + success: true, + user: options.user + }) + : JSON.stringify({ + type: "auth_complete", + success: false, + error: options.error + }) + ).replace(/ + +Authentication Complete + +

${escapeHtml(statusText)}

+ +(function(){ + try { + if (window.opener) { + window.opener.postMessage(${message}, window.location.origin); + } + } catch(e) {} + setTimeout(function(){ window.close(); }, 100); +})(); + + +`; + + return new NextResponse(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store" + } + }); +} diff --git a/src/utils/popup-helpers.test.ts b/src/utils/popup-helpers.test.ts new file mode 100644 index 000000000..0d79eb3fc --- /dev/null +++ b/src/utils/popup-helpers.test.ts @@ -0,0 +1,301 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + PopupCancelledError, + PopupTimeoutError +} from "../errors/popup-errors.js"; +import { + AUTO_CLOSE_DELAY, + DEFAULT_POPUP_HEIGHT, + DEFAULT_POPUP_TIMEOUT, + DEFAULT_POPUP_WIDTH, + openCenteredPopup, + POLL_INTERVAL, + waitForPopupCompletion +} from "./popup-helpers.js"; + +describe("popup-helpers", () => { + describe("constants", () => { + it("should export correct default values", () => { + expect(DEFAULT_POPUP_WIDTH).toBe(400); + expect(DEFAULT_POPUP_HEIGHT).toBe(600); + expect(DEFAULT_POPUP_TIMEOUT).toBe(60000); + expect(AUTO_CLOSE_DELAY).toBe(2000); + expect(POLL_INTERVAL).toBe(500); + }); + }); + + describe("openCenteredPopup", () => { + let openSpy: any; + + beforeEach(() => { + openSpy = vi.spyOn(window, "open"); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("should call window.open with centered coordinates", () => { + // Mock window dimensions + Object.defineProperty(window, "screenX", { value: 100, writable: true }); + Object.defineProperty(window, "screenY", { value: 50, writable: true }); + Object.defineProperty(window, "outerWidth", { + value: 1200, + writable: true + }); + Object.defineProperty(window, "outerHeight", { + value: 800, + writable: true + }); + + const mockPopup = { closed: false } as Window; + openSpy.mockReturnValue(mockPopup); + + const result = openCenteredPopup("https://example.com", 400, 600); + + expect(result).toBe(mockPopup); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com", + "_blank", + expect.stringContaining("width=400") + ); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com", + "_blank", + expect.stringContaining("height=600") + ); + + // Check centered positioning: left = 100 + (1200 - 400) / 2 = 500 + // top = 50 + (800 - 600) / 2 = 150 + const features = openSpy.mock.calls[0][2] as string; + expect(features).toContain("left=500"); + expect(features).toContain("top=150"); + expect(features).toContain("scrollbars=yes"); + }); + + it("should return null when popup is blocked", () => { + openSpy.mockReturnValue(null); + + const result = openCenteredPopup("https://example.com", 400, 600); + + expect(result).toBeNull(); + }); + + it("should use custom dimensions", () => { + const mockPopup = { closed: false } as Window; + openSpy.mockReturnValue(mockPopup); + + openCenteredPopup("https://example.com", 800, 1000); + + const features = openSpy.mock.calls[0][2] as string; + expect(features).toContain("width=800"); + expect(features).toContain("height=1000"); + }); + }); + + describe("waitForPopupCompletion", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should resolve on valid auth_complete postMessage from same origin", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 60000); + + // Simulate postMessage from same origin + const messageEvent = new MessageEvent("message", { + data: { + type: "auth_complete", + success: true, + user: { sub: "auth0|123", email: "test@example.com" } + }, + origin: window.location.origin + }); + window.dispatchEvent(messageEvent); + + const result = await promise; + expect(result).toEqual({ + type: "auth_complete", + success: true, + user: { sub: "auth0|123", email: "test@example.com" } + }); + }); + + it("should resolve on error auth_complete postMessage", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 60000); + + const messageEvent = new MessageEvent("message", { + data: { + type: "auth_complete", + success: false, + error: { code: "access_denied", message: "User denied" } + }, + origin: window.location.origin + }); + window.dispatchEvent(messageEvent); + + const result = await promise; + expect(result).toEqual({ + type: "auth_complete", + success: false, + error: { code: "access_denied", message: "User denied" } + }); + }); + + it("should ignore messages from different origin", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 1000); + // Attach catch handler immediately to avoid unhandled rejection + const rejection = + expect(promise).rejects.toBeInstanceOf(PopupTimeoutError); + + // Simulate cross-origin message + const crossOriginEvent = new MessageEvent("message", { + data: { type: "auth_complete", success: true }, + origin: "https://evil.com" + }); + window.dispatchEvent(crossOriginEvent); + + // Should not resolve — advance timers to timeout + await vi.advanceTimersByTimeAsync(1000); + + await rejection; + }); + + it("should ignore messages without auth_complete type", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 1000); + const rejection = + expect(promise).rejects.toBeInstanceOf(PopupTimeoutError); + + // Dispatch unrelated message from same origin + const unrelatedEvent = new MessageEvent("message", { + data: { type: "other_event", payload: "test" }, + origin: window.location.origin + }); + window.dispatchEvent(unrelatedEvent); + + await vi.advanceTimersByTimeAsync(1000); + + await rejection; + }); + + it("should reject with PopupTimeoutError after timeout", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 5000); + const rejection = promise.catch((e) => e); + + await vi.advanceTimersByTimeAsync(5000); + + const error = await rejection; + expect(error).toBeInstanceOf(PopupTimeoutError); + expect(error.message).toBe("Popup did not complete within 5000ms"); + }); + + it("should reject with PopupCancelledError when popup is closed", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 60000); + const rejection = promise.catch((e) => e); + + // Simulate popup closing + Object.defineProperty(mockPopup, "closed", { value: true }); + + // Advance past poll interval + await vi.advanceTimersByTimeAsync(POLL_INTERVAL); + + const error = await rejection; + expect(error).toBeInstanceOf(PopupCancelledError); + expect(error.message).toBe("Popup was closed by user"); + }); + + it("should clean up event listener and timers on success", async () => { + const mockPopup = { closed: false } as Window; + const removeListenerSpy = vi.spyOn(window, "removeEventListener"); + + const promise = waitForPopupCompletion(mockPopup, 60000); + + const messageEvent = new MessageEvent("message", { + data: { type: "auth_complete", success: true }, + origin: window.location.origin + }); + window.dispatchEvent(messageEvent); + + await promise; + + expect(removeListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function) + ); + + removeListenerSpy.mockRestore(); + }); + + it("should clean up event listener and timers on timeout", async () => { + const mockPopup = { closed: false } as Window; + const removeListenerSpy = vi.spyOn(window, "removeEventListener"); + + const promise = waitForPopupCompletion(mockPopup, 1000); + const rejection = promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(1000); + await rejection; + + expect(removeListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function) + ); + + removeListenerSpy.mockRestore(); + }); + + it("should clean up event listener and timers on cancel", async () => { + const mockPopup = { closed: false } as Window; + const removeListenerSpy = vi.spyOn(window, "removeEventListener"); + + const promise = waitForPopupCompletion(mockPopup, 60000); + const rejection = promise.catch(() => {}); + + Object.defineProperty(mockPopup, "closed", { value: true }); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL); + await rejection; + + expect(removeListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function) + ); + + removeListenerSpy.mockRestore(); + }); + + it("should accept message with undefined user on success", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 60000); + + const messageEvent = new MessageEvent("message", { + data: { type: "auth_complete", success: true }, + origin: window.location.origin + }); + window.dispatchEvent(messageEvent); + + const result = await promise; + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/utils/popup-helpers.ts b/src/utils/popup-helpers.ts new file mode 100644 index 000000000..7eef226d9 --- /dev/null +++ b/src/utils/popup-helpers.ts @@ -0,0 +1,116 @@ +import { + PopupCancelledError, + PopupTimeoutError +} from "../errors/popup-errors.js"; +import { POLL_INTERVAL } from "./constants.js"; + +export { + AUTO_CLOSE_DELAY, + DEFAULT_POPUP_HEIGHT, + DEFAULT_POPUP_TIMEOUT, + DEFAULT_POPUP_WIDTH, + POLL_INTERVAL +} from "./constants.js"; + +/** + * postMessage payload sent from the popup callback page to the parent window. + * + * Uses a discriminated union on `success` for type-safe handling: + * - `success: true` — MFA completed, optional user metadata attached + * - `success: false` — error occurred, error code and message attached + * + * Security: Never contains raw access tokens. Only user metadata (`sub`, `email`) + * is sent via postMessage. Tokens remain server-side in the encrypted session. + */ +export type AuthCompleteMessage = + | { + type: "auth_complete"; + success: true; + /** User metadata from the authenticated session (sub and email only). */ + user?: { sub: string; email: string }; + } + | { + type: "auth_complete"; + success: false; + /** Error details from the callback (OAuth error code + description). */ + error: { code: string; message: string }; + }; + +/** + * Opens a centered popup window. + * @param url - URL to open in popup + * @param width - Popup width (pixels) + * @param height - Popup height (pixels) + * @returns Window reference or null if blocked + */ +export function openCenteredPopup( + url: string, + width: number, + height: number +): Window | null { + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2; + + return window.open( + url, + "_blank", + `width=${width},height=${height},left=${left},top=${top},scrollbars=yes` + ); +} + +/** + * Waits for popup to complete authentication via postMessage. + * + * Monitors three conditions concurrently: + * 1. **postMessage** with `type: 'auth_complete'` from same origin (resolves) + * 2. **popup.closed** polling every {@link POLL_INTERVAL}ms (rejects) + * 3. **Timeout** expiry (rejects) + * + * Only accepts messages where `event.origin === window.location.origin` + * (same-origin validation, Design Decision DD-1). + * + * @param popup - Popup window reference from `window.open()` + * @param timeout - Timeout in milliseconds before rejecting + * @returns Promise resolving to {@link AuthCompleteMessage} + * @throws {PopupTimeoutError} If timeout expires before completion + * @throws {PopupCancelledError} If user closes popup before completion + */ +export function waitForPopupCompletion( + popup: Window, + timeout: number +): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + reject( + new PopupTimeoutError(`Popup did not complete within ${timeout}ms`) + ); + }, timeout); + + const pollId = setInterval(() => { + if (popup.closed) { + cleanup(); + reject(new PopupCancelledError("Popup was closed by user")); + } + }, POLL_INTERVAL); + + function messageHandler(event: MessageEvent) { + if (event.origin !== window.location.origin) { + return; // Ignore cross-origin messages + } + + if (event.data?.type === "auth_complete") { + cleanup(); + resolve(event.data as AuthCompleteMessage); + } + } + + function cleanup() { + clearTimeout(timeoutId); + clearInterval(pollId); + window.removeEventListener("message", messageHandler); + } + + window.addEventListener("message", messageHandler); + }); +} diff --git a/src/utils/session-helpers.ts b/src/utils/session-helpers.ts new file mode 100644 index 000000000..1911736ea --- /dev/null +++ b/src/utils/session-helpers.ts @@ -0,0 +1,126 @@ +import * as oauth from "oauth4webapi"; + +import { TransactionState } from "../server/transaction-store.js"; +import type { AccessTokenSet, SessionData } from "../types/index.js"; + +/** + * Merge an access token from a popup MFA callback into an existing session. + * + * Adds a new `AccessTokenSet` entry to `session.accessTokens[]` for the + * popup's target audience. This preserves the user's existing MRRT tokens, + * refresh token, and primary `tokenSet` — a fresh session would destroy them. + * + * Also updates `refreshToken` and `idToken`/`user` claims if new ones were + * issued during the popup flow. + * + * **Why `requestedScope` uses `transactionState.scope`, not `oidcRes.scope`:** + * + * This is a deliberate design choice driven by MRRT scope accumulation. + * + * When the SDK requests a token for a specific audience, it sends the full + * merged scope string from the global config (e.g. "oauth openid profile + * email offline_access"). Auth0 returns only the scopes relevant to the + * target audience (e.g. "openid" for an API token) — the OIDC scopes like + * "profile" and "email" are filtered out because they don't apply to the + * API audience. This is expected Auth0 behavior, not a permission denial. + * + * The SDK's `findAccessTokenSet()` (default `matchMode: "requestedScope"`) + * checks whether the stored `requestedScope` is a superset of the lookup + * scope. The lookup scope is computed from the same global config via + * `getTokenSet() -> mergeScopes(getScopeForAudience(...))`. By storing + * `transactionState.scope` (which originates from the same global config), + * the cache key roundtrips exactly: the same scope string used to start + * the auth flow is the same one used to look it up later. + * + * If `oidcRes.scope` were stored as `requestedScope` instead, the cache + * lookup would break. `findAccessTokenSet` calls + * `compareScopes(stored.requestedScope, lookupScope)` — checking whether + * the stored value is a superset of the lookup value. With + * `requestedScope = "openid"` (narrow, from oidcRes) and a lookup scope + * of `"oauth openid profile email offline_access"` (wide, from global + * config), the superset check fails: "openid" does not contain "oauth", + * "profile", "email", or "offline_access". This cache miss triggers a + * refresh grant, which re-triggers MFA policy — producing an + * `mfa_required` error loop. + * + * Mutates `session` in-place. Caller is responsible for calling + * `finalizeSession()` afterward. + * + * @param session - Existing user session loaded from cookie store + * @param oidcRes - OAuth token response from the popup's code exchange + * @param transactionState - Transaction state containing audience and scope + * @param idTokenClaims - Validated ID token claims, if present in response + */ +export function mergePopupTokenIntoSession( + session: SessionData, + oidcRes: oauth.TokenEndpointResponse, + transactionState: TransactionState, + idTokenClaims?: oauth.IDToken +): void { + session.accessTokens = session.accessTokens || []; + + const newAccessTokenSet: AccessTokenSet = { + accessToken: oidcRes.access_token, + scope: oidcRes.scope, + requestedScope: transactionState.scope, + audience: transactionState.audience || "", + expiresAt: Math.floor(Date.now() / 1000) + Number(oidcRes.expires_in), + token_type: oidcRes.token_type + }; + + // Replace existing token for same audience, or append new one + const existingIdx = session.accessTokens.findIndex( + (t) => t.audience === transactionState.audience + ); + if (existingIdx >= 0) { + session.accessTokens[existingIdx] = newAccessTokenSet; + } else { + session.accessTokens.push(newAccessTokenSet); + } + + // Update refresh token if a new one was issued + if (oidcRes.refresh_token) { + session.tokenSet.refreshToken = oidcRes.refresh_token; + } + + // Update id token and user claims if new ones were issued + if (oidcRes.id_token) { + session.tokenSet.idToken = oidcRes.id_token; + if (idTokenClaims) { + session.user = { ...session.user, ...idTokenClaims }; + } + } +} + +/** + * Build a fresh SessionData from an OAuth token response and transaction state. + * Used by both the postMessage fallback (no existing session) and the standard + * redirect branch to avoid duplicating the same construction logic. + * + * @param idTokenClaims - Validated ID token claims (must be present) + * @param oidcRes - OAuth token endpoint response + * @param transactionState - Transaction state with audience/scope + * @returns A new SessionData object + */ +export function buildSessionFromCallback( + idTokenClaims: oauth.IDToken, + oidcRes: oauth.TokenEndpointResponse, + transactionState: TransactionState +): SessionData { + return { + user: idTokenClaims, + tokenSet: { + accessToken: oidcRes.access_token, + idToken: oidcRes.id_token, + scope: oidcRes.scope, + requestedScope: transactionState.scope, + audience: transactionState.audience, + refreshToken: oidcRes.refresh_token, + expiresAt: Math.floor(Date.now() / 1000) + Number(oidcRes.expires_in) + }, + internal: { + sid: idTokenClaims.sid as string, + createdAt: Math.floor(Date.now() / 1000) + } + }; +}