From 95bc2311ecf400391a500936dfaebf75f995fc68 Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Mon, 20 Jul 2026 23:38:08 +0200 Subject: [PATCH 1/5] feat(oauth2): add consent flow support to @ory/nextjs Add getConsentFlow, acceptConsentRequest and rejectConsentRequest for the app router, and getServerSideConsentFlow plus useSession for the pages router. The consent request is always fetched server side since reading it requires the Hydra admin API and the project API token. Accepting and rejecting validate that the session identity matches the consent request subject to prevent consent hijacking with a stolen consent_challenge. URL rewriting is consent-aware in rewriteUrls. --- .changeset/oauth2-consent-flow.md | 6 + .../nextjs/api-report/nextjs-client.api.json | 338 ++++++++++++++++++ packages/nextjs/api-report/nextjs.api.md | 33 ++ packages/nextjs/src/app/client.ts | 19 +- packages/nextjs/src/app/consent.test.ts | 208 +++++++++++ packages/nextjs/src/app/consent.ts | 204 +++++++++++ packages/nextjs/src/app/index.ts | 5 + packages/nextjs/src/pages/client.ts | 15 +- packages/nextjs/src/pages/consent.ts | 79 ++++ packages/nextjs/src/pages/index.ts | 2 + packages/nextjs/src/pages/session.ts | 59 +++ packages/nextjs/src/utils/rewrite.test.ts | 40 +++ packages/nextjs/src/utils/rewrite.ts | 80 +++-- 13 files changed, 1060 insertions(+), 28 deletions(-) create mode 100644 .changeset/oauth2-consent-flow.md create mode 100644 packages/nextjs/src/app/consent.test.ts create mode 100644 packages/nextjs/src/app/consent.ts create mode 100644 packages/nextjs/src/pages/consent.ts create mode 100644 packages/nextjs/src/pages/session.ts diff --git a/.changeset/oauth2-consent-flow.md b/.changeset/oauth2-consent-flow.md new file mode 100644 index 000000000..41a2888ee --- /dev/null +++ b/.changeset/oauth2-consent-flow.md @@ -0,0 +1,6 @@ +--- +"@ory/elements-react": minor +"@ory/nextjs": minor +--- + +Add OAuth2 consent flow support. `@ory/nextjs` gains `getConsentFlow`, `acceptConsentRequest` and `rejectConsentRequest` for the app router, plus `getConsentFlow` and `useSession` for the pages router, validating that the active session identity matches the consent request subject. `@ory/elements-react` renders the OAuth2 client name and logo on the consent screen. The Next.js examples demonstrate CSRF protection for the consent endpoint using `@csrf-armor/nextjs`. diff --git a/packages/nextjs/api-report/nextjs-client.api.json b/packages/nextjs/api-report/nextjs-client.api.json index 67471acf4..ba0ff6f70 100644 --- a/packages/nextjs/api-report/nextjs-client.api.json +++ b/packages/nextjs/api-report/nextjs-client.api.json @@ -381,6 +381,90 @@ "name": "", "preserveMemberOrder": false, "members": [ + { + "kind": "Function", + "canonicalReference": "@ory/nextjs!acceptConsentRequest:function(1)", + "docComment": "/**\n * Accept an OAuth2 consent request.\n *\n * This method should be called from an API route handler when the user accepts the consent. It validates that the provided session identity matches the consent request subject to prevent consent hijacking attacks.\n *\n * @param consentChallenge - The consent challenge from the form.\n *\n * @param options - Options for accepting the consent request.\n *\n * @returns The redirect URL to complete the OAuth2 flow.\n *\n * @example\n * ```tsx\n * // app/api/consent/route.ts\n * import { acceptConsentRequest, rejectConsentRequest, getServerSession } from \"@ory/nextjs/app\"\n * import { redirect } from \"next/navigation\"\n *\n * export async function POST(request: Request) {\n * const session = await getServerSession()\n * if (!session) {\n * return new Response(\"Unauthorized\", { status: 401 })\n * }\n *\n * const formData = await request.formData()\n * const action = formData.get(\"action\")\n * const consentChallenge = formData.get(\"consent_challenge\") as string\n * const grantScope = formData.getAll(\"grant_scope\") as string[]\n * const remember = formData.get(\"remember\") === \"true\"\n *\n * if (action === \"accept\") {\n * const redirectTo = await acceptConsentRequest(consentChallenge, {\n * grantScope,\n * remember,\n * identityId: session.identity?.id,\n * })\n * return redirect(redirectTo)\n * } else {\n * const redirectTo = await rejectConsentRequest(consentChallenge, {\n * identityId: session.identity?.id,\n * })\n * return redirect(redirectTo)\n * }\n * }\n * ```\n *\n * @throws\n *\n * Error if identityId doesn't match the consent request subject.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function acceptConsentRequest(consentChallenge: " + }, + { + "kind": "Content", + "text": "string" + }, + { + "kind": "Content", + "text": ", options: " + }, + { + "kind": "Content", + "text": "{\n grantScope: string[];\n remember?: boolean;\n rememberFor?: number;\n identityId?: string;\n session?: {\n accessToken?: " + }, + { + "kind": "Reference", + "text": "Record", + "canonicalReference": "!Record:type" + }, + { + "kind": "Content", + "text": ";\n idToken?: " + }, + { + "kind": "Reference", + "text": "Record", + "canonicalReference": "!Record:type" + }, + { + "kind": "Content", + "text": ";\n };\n}" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/app/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 9, + "endIndex": 11 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "consentChallenge", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + }, + { + "parameterName": "options", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 8 + }, + "isOptional": false + } + ], + "name": "acceptConsentRequest" + }, { "kind": "Function", "canonicalReference": "@ory/nextjs!createOryMiddleware:function(1)", @@ -454,6 +538,88 @@ ], "name": "createOryMiddleware" }, + { + "kind": "Function", + "canonicalReference": "@ory/nextjs!getConsentFlow:function(1)", + "docComment": "/**\n * Use this method in an app router page to fetch an OAuth2 consent request. This method works with server-side rendering.\n *\n * The consent flow is different from other Ory flows - it requires: 1. A consent_challenge query parameter (provided by Ory Hydra) 2. A valid user session (the user must be logged in) 3. A CSRF token for form protection 4. A form action URL where the consent form submits to\n *\n * @param params - The query parameters of the request.\n *\n * @returns The OAuth2 consent request or null if no consent_challenge is found.\n *\n * @example\n * ```tsx\n * import { Consent } from \"@ory/elements-react/theme\"\n * import { getConsentFlow, getServerSession, OryPageParams } from \"@ory/nextjs/app\"\n *\n * import config from \"@/ory.config\"\n *\n * export default async function ConsentPage(props: OryPageParams) {\n * const consentRequest = await getConsentFlow(props.searchParams)\n * const session = await getServerSession()\n *\n * if (!consentRequest || !session) {\n * return null\n * }\n *\n * return (\n * \n * )\n * }\n * ```\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function getConsentFlow(params: " + }, + { + "kind": "Reference", + "text": "QueryParams", + "canonicalReference": "@ory/nextjs!~QueryParams:type" + }, + { + "kind": "Content", + "text": " | " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "<" + }, + { + "kind": "Reference", + "text": "QueryParams", + "canonicalReference": "@ory/nextjs!~QueryParams:type" + }, + { + "kind": "Content", + "text": ">" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "<" + }, + { + "kind": "Reference", + "text": "OAuth2ConsentRequest", + "canonicalReference": "@ory/client-fetch!OAuth2ConsentRequest:interface" + }, + { + "kind": "Content", + "text": " | null>" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/app/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 8, + "endIndex": 12 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "params", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 7 + }, + "isOptional": false + } + ], + "name": "getConsentFlow" + }, { "kind": "Function", "canonicalReference": "@ory/nextjs!getError:function(1)", @@ -1110,6 +1276,66 @@ "parameters": [], "name": "getServerSession" }, + { + "kind": "Function", + "canonicalReference": "@ory/nextjs!getServerSideConsentFlow:function(1)", + "docComment": "/**\n * Use this method in `getServerSideProps` of a pages router page to fetch an OAuth2 consent request. Fetching the consent request requires the Ory project API key, so it must never run in the browser.\n *\n * The consent flow is different from other Ory flows - it requires: 1. A consent_challenge query parameter (provided by Ory Hydra) 2. A valid user session (the user must be logged in) 3. A CSRF token for form protection 4. A form action URL where the consent form submits to\n *\n * @param query - The parsed query of the incoming request.\n *\n * @returns The OAuth2 consent request or null if no consent_challenge is found.\n *\n * @example\n * ```tsx\n * import { Consent } from \"@ory/elements-react/theme\"\n * import { getServerSideConsentFlow, useSession } from \"@ory/nextjs/pages\"\n * import { OAuth2ConsentRequest } from \"@ory/client-fetch\"\n * import { GetServerSideProps } from \"next\"\n *\n * import config from \"@/ory.config\"\n *\n * export const getServerSideProps: GetServerSideProps = async (context) => {\n * const consentRequest = await getServerSideConsentFlow(context.query)\n * if (!consentRequest) {\n * return { notFound: true }\n * }\n * // Strip undefined values: Next.js requires JSON-serializable props.\n * return {\n * props: { consentRequest: JSON.parse(JSON.stringify(consentRequest)) },\n * }\n * }\n *\n * export default function ConsentPage({\n * consentRequest,\n * }: {\n * consentRequest: OAuth2ConsentRequest\n * }) {\n * const { session, loading } = useSession()\n *\n * if (loading || !session) {\n * return null\n * }\n *\n * return (\n * \n * )\n * }\n * ```\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function getServerSideConsentFlow(query: " + }, + { + "kind": "Reference", + "text": "ParsedUrlQuery", + "canonicalReference": "!\"\\\"querystring\\\"\".ParsedUrlQuery:interface" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "<" + }, + { + "kind": "Reference", + "text": "OAuth2ConsentRequest", + "canonicalReference": "@ory/client-fetch!OAuth2ConsentRequest:interface" + }, + { + "kind": "Content", + "text": " | null>" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/pages/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 7 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "query", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "name": "getServerSideConsentFlow" + }, { "kind": "Function", "canonicalReference": "@ory/nextjs!getSettingsFlow:function(1)", @@ -1400,6 +1626,72 @@ ], "extendsTokenRanges": [] }, + { + "kind": "Function", + "canonicalReference": "@ory/nextjs!rejectConsentRequest:function(1)", + "docComment": "/**\n * Reject an OAuth2 consent request.\n *\n * This method should be called from an API route handler when the user rejects the consent. It validates that the provided session identity matches the consent request subject to prevent consent hijacking attacks.\n *\n * @param consentChallenge - The consent challenge from the form.\n *\n * @param options - Options for rejecting the consent request.\n *\n * @returns The redirect URL to complete the OAuth2 flow.\n *\n * @throws\n *\n * Error if identityId doesn't match the consent request subject.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function rejectConsentRequest(consentChallenge: " + }, + { + "kind": "Content", + "text": "string" + }, + { + "kind": "Content", + "text": ", options?: " + }, + { + "kind": "Content", + "text": "{\n error?: string;\n errorDescription?: string;\n identityId?: string;\n}" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/app/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 7 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "consentChallenge", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + }, + { + "parameterName": "options", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": true + } + ], + "name": "rejectConsentRequest" + }, { "kind": "Function", "canonicalReference": "@ory/nextjs!useError:function(1)", @@ -1583,6 +1875,52 @@ "parameters": [], "name": "useRegistrationFlow" }, + { + "kind": "Function", + "canonicalReference": "@ory/nextjs!useSession:function(1)", + "docComment": "/**\n * A client-side hook to fetch the current user session.\n *\n * @returns The session object, loading state, and error if any.\n *\n * @example\n * ```tsx\n * import { useSession } from \"@ory/nextjs/pages\"\n *\n * export default function ProfilePage() {\n * const { session, loading, error } = useSession()\n *\n * if (loading) {\n * return
Loading...
\n * }\n *\n * if (error || !session) {\n * return
Not logged in
\n * }\n *\n * return
Hello {session.identity?.traits?.email}
\n * }\n * ```\n *\n * @group\n *\n * Hooks\n *\n * @public @function\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function useSession(): " + }, + { + "kind": "Content", + "text": "{\n session: " + }, + { + "kind": "Reference", + "text": "Session", + "canonicalReference": "@ory/client-fetch!Session:interface" + }, + { + "kind": "Content", + "text": " | null;\n loading: boolean;\n error: " + }, + { + "kind": "Reference", + "text": "Error", + "canonicalReference": "!Error:interface" + }, + { + "kind": "Content", + "text": " | null;\n}" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/pages/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 6 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [], + "name": "useSession" + }, { "kind": "Function", "canonicalReference": "@ory/nextjs!useSettingsFlow:function(1)", diff --git a/packages/nextjs/api-report/nextjs.api.md b/packages/nextjs/api-report/nextjs.api.md index 24c7140e6..86d6d131a 100644 --- a/packages/nextjs/api-report/nextjs.api.md +++ b/packages/nextjs/api-report/nextjs.api.md @@ -12,6 +12,7 @@ import { LoginFlow } from '@ory/client-fetch'; import { LogoutFlow } from '@ory/client-fetch'; import { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { OAuth2ConsentRequest } from '@ory/client-fetch'; import * as _ory_client_fetch from '@ory/client-fetch'; import { ParsedUrlQuery } from 'querystring'; import { RecoveryFlow } from '@ory/client-fetch'; @@ -20,11 +21,26 @@ import { Session } from '@ory/client-fetch'; import { SettingsFlow } from '@ory/client-fetch'; import { VerificationFlow } from '@ory/client-fetch'; +// @public +export function acceptConsentRequest(consentChallenge: string, options: { + grantScope: string[]; + remember?: boolean; + rememberFor?: number; + identityId?: string; + session?: { + accessToken?: Record; + idToken?: Record; + }; +}): Promise; + // @public export function createOryMiddleware(options: OryMiddlewareOptions): (r: NextRequest) => Promise>; // Warning: (ae-forgotten-export) The symbol "QueryParams" needs to be exported by the entry point api-extractor-type-index.d.ts // +// @public +export function getConsentFlow(params: QueryParams | Promise): Promise; + // @public export function getError(searchParams: QueryParams | Promise): Promise<{ error: string; @@ -65,6 +81,9 @@ export function getRegistrationFlow(config: { // @public export function getServerSession(): Promise; +// @public +export function getServerSideConsentFlow(query: ParsedUrlQuery): Promise; + // @public export function getSettingsFlow(config: { project: { @@ -94,6 +113,13 @@ export interface OryPageParams { }>; } +// @public +export function rejectConsentRequest(consentChallenge: string, options?: { + error?: string; + errorDescription?: string; + identityId?: string; +}): Promise; + // Warning: (ae-forgotten-export) The symbol "OryError" needs to be exported by the entry point api-extractor-type-index.d.ts // // @public @@ -111,6 +137,13 @@ export const useRecoveryFlow: () => void | _ory_client_fetch.RecoveryFlow | null // @public export const useRegistrationFlow: () => void | _ory_client_fetch.RegistrationFlow | null; +// @public +export function useSession(): { + session: Session | null; + loading: boolean; + error: Error | null; +}; + // @public export const useSettingsFlow: () => void | _ory_client_fetch.SettingsFlow | null; diff --git a/packages/nextjs/src/app/client.ts b/packages/nextjs/src/app/client.ts index 254086ef0..9a290a463 100644 --- a/packages/nextjs/src/app/client.ts +++ b/packages/nextjs/src/app/client.ts @@ -1,10 +1,14 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { Configuration, FrontendApi } from "@ory/client-fetch" +import { Configuration, FrontendApi, OAuth2Api } from "@ory/client-fetch" import { orySdkUrl } from "../utils/sdk" +function getProjectApiKey() { + return process.env["ORY_PROJECT_API_TOKEN"] ?? "" +} + export const serverSideFrontendClient = () => new FrontendApi( new Configuration({ @@ -14,3 +18,16 @@ export const serverSideFrontendClient = () => basePath: orySdkUrl(), }), ) + +export const serverSideOAuth2Client = () => { + const apiKey = getProjectApiKey() + return new OAuth2Api( + new Configuration({ + headers: { + Accept: "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + basePath: orySdkUrl(), + }), + ) +} diff --git a/packages/nextjs/src/app/consent.test.ts b/packages/nextjs/src/app/consent.test.ts new file mode 100644 index 000000000..dc9b735f2 --- /dev/null +++ b/packages/nextjs/src/app/consent.test.ts @@ -0,0 +1,208 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { OAuth2ConsentRequest } from "@ory/client-fetch" + +import { + getConsentFlow, + acceptConsentRequest, + rejectConsentRequest, +} from "./consent" +import { serverSideOAuth2Client } from "./client" + +jest.mock("./client", () => ({ + serverSideOAuth2Client: jest.fn(), +})) + +describe("getConsentFlow", () => { + const mockGetOAuth2ConsentRequest = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(serverSideOAuth2Client as jest.Mock).mockReturnValue({ + getOAuth2ConsentRequest: mockGetOAuth2ConsentRequest, + }) + }) + + it("should return null when consent_challenge is missing", async () => { + const result = await getConsentFlow({}) + expect(result).toBeNull() + expect(mockGetOAuth2ConsentRequest).not.toHaveBeenCalled() + }) + + it("should return null when consent_challenge is not a string", async () => { + const result = await getConsentFlow({ consent_challenge: 123 as unknown }) + expect(result).toBeNull() + expect(mockGetOAuth2ConsentRequest).not.toHaveBeenCalled() + }) + + it("should return null when consent_challenge is an array", async () => { + const result = await getConsentFlow({ consent_challenge: ["challenge1"] }) + expect(result).toBeNull() + expect(mockGetOAuth2ConsentRequest).not.toHaveBeenCalled() + }) + + it("should return consent request on valid challenge", async () => { + const mockConsentRequest: OAuth2ConsentRequest = { + challenge: "test-challenge", + requested_scope: ["openid", "profile"], + } + mockGetOAuth2ConsentRequest.mockResolvedValue(mockConsentRequest) + + const result = await getConsentFlow({ consent_challenge: "test-challenge" }) + + expect(result).toEqual(mockConsentRequest) + expect(mockGetOAuth2ConsentRequest).toHaveBeenCalledWith({ + consentChallenge: "test-challenge", + }) + }) + + it("should handle Promise params", async () => { + const mockConsentRequest: OAuth2ConsentRequest = { + challenge: "test-challenge", + } + mockGetOAuth2ConsentRequest.mockResolvedValue(mockConsentRequest) + + const result = await getConsentFlow( + Promise.resolve({ consent_challenge: "test-challenge" }), + ) + + expect(result).toEqual(mockConsentRequest) + }) + + it("should return null on API error (silent failure)", async () => { + mockGetOAuth2ConsentRequest.mockRejectedValue(new Error("API Error")) + + const result = await getConsentFlow({ consent_challenge: "test-challenge" }) + + expect(result).toBeNull() + }) +}) + +describe("acceptConsentRequest", () => { + const mockAcceptOAuth2ConsentRequest = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(serverSideOAuth2Client as jest.Mock).mockReturnValue({ + acceptOAuth2ConsentRequest: mockAcceptOAuth2ConsentRequest, + }) + }) + + it("should accept consent with required params", async () => { + mockAcceptOAuth2ConsentRequest.mockResolvedValue({ + redirect_to: "https://example.com/callback", + }) + + const result = await acceptConsentRequest("test-challenge", { + grantScope: ["openid", "profile"], + }) + + expect(result).toBe("https://example.com/callback") + expect(mockAcceptOAuth2ConsentRequest).toHaveBeenCalledWith({ + consentChallenge: "test-challenge", + acceptOAuth2ConsentRequest: { + grant_scope: ["openid", "profile"], + remember: undefined, + remember_for: undefined, + session: undefined, + }, + }) + }) + + it("should accept consent with remember option", async () => { + mockAcceptOAuth2ConsentRequest.mockResolvedValue({ + redirect_to: "https://example.com/callback", + }) + + await acceptConsentRequest("test-challenge", { + grantScope: ["openid"], + remember: true, + rememberFor: 3600, + }) + + expect(mockAcceptOAuth2ConsentRequest).toHaveBeenCalledWith({ + consentChallenge: "test-challenge", + acceptOAuth2ConsentRequest: { + grant_scope: ["openid"], + remember: true, + remember_for: 3600, + session: undefined, + }, + }) + }) + + it("should accept consent with session tokens", async () => { + mockAcceptOAuth2ConsentRequest.mockResolvedValue({ + redirect_to: "https://example.com/callback", + }) + + await acceptConsentRequest("test-challenge", { + grantScope: ["openid"], + session: { + accessToken: { custom_claim: "value" }, + idToken: { name: "Test User" }, + }, + }) + + expect(mockAcceptOAuth2ConsentRequest).toHaveBeenCalledWith({ + consentChallenge: "test-challenge", + acceptOAuth2ConsentRequest: { + grant_scope: ["openid"], + remember: undefined, + remember_for: undefined, + session: { + access_token: { custom_claim: "value" }, + id_token: { name: "Test User" }, + }, + }, + }) + }) +}) + +describe("rejectConsentRequest", () => { + const mockRejectOAuth2ConsentRequest = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(serverSideOAuth2Client as jest.Mock).mockReturnValue({ + rejectOAuth2ConsentRequest: mockRejectOAuth2ConsentRequest, + }) + }) + + it("should reject consent with default error", async () => { + mockRejectOAuth2ConsentRequest.mockResolvedValue({ + redirect_to: "https://example.com/error", + }) + + const result = await rejectConsentRequest("test-challenge") + + expect(result).toBe("https://example.com/error") + expect(mockRejectOAuth2ConsentRequest).toHaveBeenCalledWith({ + consentChallenge: "test-challenge", + rejectOAuth2Request: { + error: "access_denied", + error_description: "The resource owner denied the request", + }, + }) + }) + + it("should reject consent with custom error", async () => { + mockRejectOAuth2ConsentRequest.mockResolvedValue({ + redirect_to: "https://example.com/error", + }) + + await rejectConsentRequest("test-challenge", { + error: "invalid_scope", + errorDescription: "The requested scope is invalid", + }) + + expect(mockRejectOAuth2ConsentRequest).toHaveBeenCalledWith({ + consentChallenge: "test-challenge", + rejectOAuth2Request: { + error: "invalid_scope", + error_description: "The requested scope is invalid", + }, + }) + }) +}) diff --git a/packages/nextjs/src/app/consent.ts b/packages/nextjs/src/app/consent.ts new file mode 100644 index 000000000..9e08d40f1 --- /dev/null +++ b/packages/nextjs/src/app/consent.ts @@ -0,0 +1,204 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { OAuth2ConsentRequest } from "@ory/client-fetch" + +import { QueryParams } from "../types" +import { serverSideOAuth2Client } from "./client" + +/** + * Use this method in an app router page to fetch an OAuth2 consent request. + * This method works with server-side rendering. + * + * The consent flow is different from other Ory flows - it requires: + * 1. A consent_challenge query parameter (provided by Ory Hydra) + * 2. A valid user session (the user must be logged in) + * 3. A CSRF token for form protection + * 4. A form action URL where the consent form submits to + * + * @example + * ```tsx + * import { Consent } from "@ory/elements-react/theme" + * import { getConsentFlow, getServerSession, OryPageParams } from "@ory/nextjs/app" + * + * import config from "@/ory.config" + * + * export default async function ConsentPage(props: OryPageParams) { + * const consentRequest = await getConsentFlow(props.searchParams) + * const session = await getServerSession() + * + * if (!consentRequest || !session) { + * return null + * } + * + * return ( + * + * ) + * } + * ``` + * + * @param params - The query parameters of the request. + * @returns The OAuth2 consent request or null if no consent_challenge is found. + * @public + */ +export async function getConsentFlow( + params: QueryParams | Promise, +): Promise { + const resolvedParams = await params + const consentChallenge = resolvedParams["consent_challenge"] + + if (!consentChallenge || typeof consentChallenge !== "string") { + return null + } + + return serverSideOAuth2Client() + .getOAuth2ConsentRequest({ consentChallenge }) + .catch(() => null) +} + +/** + * Accept an OAuth2 consent request. + * + * This method should be called from an API route handler when the user accepts the consent. + * It validates that the provided session identity matches the consent request subject + * to prevent consent hijacking attacks. + * + * @example + * ```tsx + * // app/api/consent/route.ts + * import { acceptConsentRequest, rejectConsentRequest, getServerSession } from "@ory/nextjs/app" + * import { redirect } from "next/navigation" + * + * export async function POST(request: Request) { + * const session = await getServerSession() + * if (!session) { + * return new Response("Unauthorized", { status: 401 }) + * } + * + * const formData = await request.formData() + * const action = formData.get("action") + * const consentChallenge = formData.get("consent_challenge") as string + * const grantScope = formData.getAll("grant_scope") as string[] + * const remember = formData.get("remember") === "true" + * + * if (action === "accept") { + * const redirectTo = await acceptConsentRequest(consentChallenge, { + * grantScope, + * remember, + * identityId: session.identity?.id, + * }) + * return redirect(redirectTo) + * } else { + * const redirectTo = await rejectConsentRequest(consentChallenge, { + * identityId: session.identity?.id, + * }) + * return redirect(redirectTo) + * } + * } + * ``` + * + * @param consentChallenge - The consent challenge from the form. + * @param options - Options for accepting the consent request. + * @returns The redirect URL to complete the OAuth2 flow. + * @throws Error if identityId doesn't match the consent request subject. + * @public + */ +export async function acceptConsentRequest( + consentChallenge: string, + options: { + grantScope: string[] + remember?: boolean + rememberFor?: number + identityId?: string + session?: { + accessToken?: Record + idToken?: Record + } + }, +): Promise { + const oauth2Client = serverSideOAuth2Client() + + // Security: Verify session identity matches consent request subject + if (options.identityId) { + const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ + consentChallenge, + }) + + if (consentRequest.subject !== options.identityId) { + throw new Error( + "Forbidden: Session identity does not match consent request subject", + ) + } + } + + const response = await oauth2Client.acceptOAuth2ConsentRequest({ + consentChallenge, + acceptOAuth2ConsentRequest: { + grant_scope: options.grantScope, + remember: options.remember, + remember_for: options.rememberFor, + session: options.session + ? { + access_token: options.session.accessToken, + id_token: options.session.idToken, + } + : undefined, + }, + }) + + return response.redirect_to +} + +/** + * Reject an OAuth2 consent request. + * + * This method should be called from an API route handler when the user rejects the consent. + * It validates that the provided session identity matches the consent request subject + * to prevent consent hijacking attacks. + * + * @param consentChallenge - The consent challenge from the form. + * @param options - Options for rejecting the consent request. + * @returns The redirect URL to complete the OAuth2 flow. + * @throws Error if identityId doesn't match the consent request subject. + * @public + */ +export async function rejectConsentRequest( + consentChallenge: string, + options?: { + error?: string + errorDescription?: string + identityId?: string + }, +): Promise { + const oauth2Client = serverSideOAuth2Client() + + // Security: Verify session identity matches consent request subject + if (options?.identityId) { + const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ + consentChallenge, + }) + + if (consentRequest.subject !== options.identityId) { + throw new Error( + "Forbidden: Session identity does not match consent request subject", + ) + } + } + + const response = await oauth2Client.rejectOAuth2ConsentRequest({ + consentChallenge, + rejectOAuth2Request: { + error: options?.error ?? "access_denied", + error_description: + options?.errorDescription ?? "The resource owner denied the request", + }, + }) + + return response.redirect_to +} diff --git a/packages/nextjs/src/app/index.ts b/packages/nextjs/src/app/index.ts index 7e84dcc26..654730d16 100644 --- a/packages/nextjs/src/app/index.ts +++ b/packages/nextjs/src/app/index.ts @@ -11,5 +11,10 @@ export { getLogoutFlow } from "./logout" export { getServerSession } from "./session" export { getFlowFactory } from "./flow" export { getError } from "./error" +export { + getConsentFlow, + acceptConsentRequest, + rejectConsentRequest, +} from "./consent" export type { OryPageParams } from "./utils" diff --git a/packages/nextjs/src/pages/client.ts b/packages/nextjs/src/pages/client.ts index 9cc0359d9..d822c8c54 100644 --- a/packages/nextjs/src/pages/client.ts +++ b/packages/nextjs/src/pages/client.ts @@ -1,6 +1,6 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { Configuration, FrontendApi } from "@ory/client-fetch" +import { Configuration, FrontendApi, OAuth2Api } from "@ory/client-fetch" import { guessPotentiallyProxiedOrySdkUrl } from "../utils/sdk" @@ -16,3 +16,16 @@ export const clientSideFrontendClient = () => }), }), ) + +export const clientSideOAuth2Client = () => + new OAuth2Api( + new Configuration({ + headers: { + Accept: "application/json", + }, + credentials: "include", + basePath: guessPotentiallyProxiedOrySdkUrl({ + knownProxiedUrl: window.location.origin, + }), + }), + ) diff --git a/packages/nextjs/src/pages/consent.ts b/packages/nextjs/src/pages/consent.ts new file mode 100644 index 000000000..54e13660f --- /dev/null +++ b/packages/nextjs/src/pages/consent.ts @@ -0,0 +1,79 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { OAuth2ConsentRequest } from "@ory/client-fetch" +import { ParsedUrlQuery } from "querystring" + +import { serverSideOAuth2Client } from "../app/client" + +/** + * Use this method in `getServerSideProps` of a pages router page to fetch an + * OAuth2 consent request. Fetching the consent request requires the Ory + * project API key, so it must never run in the browser. + * + * The consent flow is different from other Ory flows - it requires: + * 1. A consent_challenge query parameter (provided by Ory Hydra) + * 2. A valid user session (the user must be logged in) + * 3. A CSRF token for form protection + * 4. A form action URL where the consent form submits to + * + * @example + * ```tsx + * import { Consent } from "@ory/elements-react/theme" + * import { getServerSideConsentFlow, useSession } from "@ory/nextjs/pages" + * import { OAuth2ConsentRequest } from "@ory/client-fetch" + * import { GetServerSideProps } from "next" + * + * import config from "@/ory.config" + * + * export const getServerSideProps: GetServerSideProps = async (context) => { + * const consentRequest = await getServerSideConsentFlow(context.query) + * if (!consentRequest) { + * return { notFound: true } + * } + * // Strip undefined values: Next.js requires JSON-serializable props. + * return { + * props: { consentRequest: JSON.parse(JSON.stringify(consentRequest)) }, + * } + * } + * + * export default function ConsentPage({ + * consentRequest, + * }: { + * consentRequest: OAuth2ConsentRequest + * }) { + * const { session, loading } = useSession() + * + * if (loading || !session) { + * return null + * } + * + * return ( + * + * ) + * } + * ``` + * + * @param query - The parsed query of the incoming request. + * @returns The OAuth2 consent request or null if no consent_challenge is found. + * @public + */ +export async function getServerSideConsentFlow( + query: ParsedUrlQuery, +): Promise { + const consentChallenge = query["consent_challenge"] + + if (!consentChallenge || typeof consentChallenge !== "string") { + return null + } + + return serverSideOAuth2Client() + .getOAuth2ConsentRequest({ consentChallenge }) + .catch(() => null) +} diff --git a/packages/nextjs/src/pages/index.ts b/packages/nextjs/src/pages/index.ts index 4ab67fd21..505b9f547 100644 --- a/packages/nextjs/src/pages/index.ts +++ b/packages/nextjs/src/pages/index.ts @@ -9,3 +9,5 @@ export { useLoginFlow } from "./login" export { useSettingsFlow } from "./settings" export { useLogoutFlow } from "./logout" export { useError } from "./error" +export { getServerSideConsentFlow } from "./consent" +export { useSession } from "./session" diff --git a/packages/nextjs/src/pages/session.ts b/packages/nextjs/src/pages/session.ts new file mode 100644 index 000000000..bf4f2672a --- /dev/null +++ b/packages/nextjs/src/pages/session.ts @@ -0,0 +1,59 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Session } from "@ory/client-fetch" +import { useEffect, useState } from "react" +import { clientSideFrontendClient } from "./client" + +/** + * A client-side hook to fetch the current user session. + * + * @example + * ```tsx + * import { useSession } from "@ory/nextjs/pages" + * + * export default function ProfilePage() { + * const { session, loading, error } = useSession() + * + * if (loading) { + * return
Loading...
+ * } + * + * if (error || !session) { + * return
Not logged in
+ * } + * + * return
Hello {session.identity?.traits?.email}
+ * } + * ``` + * + * @returns The session object, loading state, and error if any. + * @public + * @function + * @group Hooks + */ +export function useSession(): { + session: Session | null + loading: boolean + error: Error | null +} { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + clientSideFrontendClient() + .toSession() + .then((session) => { + setSession(session) + setLoading(false) + return + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err : new Error(String(err))) + setLoading(false) + }) + }, []) + + return { session, loading, error } +} diff --git a/packages/nextjs/src/utils/rewrite.test.ts b/packages/nextjs/src/utils/rewrite.test.ts index 557f20b02..91fa9abfb 100644 --- a/packages/nextjs/src/utils/rewrite.test.ts +++ b/packages/nextjs/src/utils/rewrite.test.ts @@ -43,6 +43,36 @@ describe("rewriteUrls", () => { const result = rewriteUrls(source, matchBaseUrl, selfUrl, config) expect(result).toBe("https://self.com/some/path") }) + + it("should NOT rewrite OAuth2 paths", () => { + const matchBaseUrl = "https://example.com" + const selfUrl = "https://self.com" + + const oauth2Paths = [ + "/oauth2/auth", + "/oauth2/token", + "/userinfo", + "/.well-known/openid-configuration", + "/.well-known/jwks.json", + ] + + for (const path of oauth2Paths) { + const source = `https://example.com${path}` + const result = rewriteUrls(source, matchBaseUrl, selfUrl, config) + expect(result).toBe(source) + } + }) + + it("should rewrite non-OAuth2 paths while preserving OAuth2 paths in same source", () => { + const source = + '{"login":"https://example.com/login","oauth":"https://example.com/oauth2/auth"}' + const matchBaseUrl = "https://example.com" + const selfUrl = "https://self.com" + const result = rewriteUrls(source, matchBaseUrl, selfUrl, config) + expect(result).toBe( + '{"login":"https://self.com/custom/login","oauth":"https://example.com/oauth2/auth"}', + ) + }) }) describe("rewriteJsonResponse", () => { @@ -106,4 +136,14 @@ describe("rewriteJsonResponse", () => { ], }) }) + + it("should handle null input gracefully", () => { + const result = rewriteJsonResponse(null as unknown as object) + expect(result).toBeNull() + }) + + it("should handle undefined input gracefully", () => { + const result = rewriteJsonResponse(undefined as unknown as object) + expect(result).toBeUndefined() + }) }) diff --git a/packages/nextjs/src/utils/rewrite.ts b/packages/nextjs/src/utils/rewrite.ts index 3d3e52b48..481b301b7 100644 --- a/packages/nextjs/src/utils/rewrite.ts +++ b/packages/nextjs/src/utils/rewrite.ts @@ -3,7 +3,6 @@ import { OryMiddlewareOptions } from "src/middleware/middleware" import { orySdkUrl } from "./sdk" -import { joinUrlPaths } from "./utils" export function rewriteUrls( source: string, @@ -11,36 +10,61 @@ export function rewriteUrls( selfUrl: string, config: OryMiddlewareOptions, ) { - for (const [_, [matchPath, replaceWith]] of [ - // TODO load these dynamically from the project config + // OAuth2 endpoints must stay on Ory's domain + const oauth2Paths = [ + "/oauth2/", + "/userinfo", + "/.well-known/openid-configuration", + "/.well-known/jwks.json", + ] + // UI path mappings from project config + // TODO: load these dynamically from the project config + const uiPathMappings: Record = { // Old AX routes - ["/ui/recovery", config.project?.recovery_ui_url], - ["/ui/registration", config.project?.registration_ui_url], - ["/ui/login", config.project?.login_ui_url], - ["/ui/verification", config.project?.verification_ui_url], - ["/ui/settings", config.project?.settings_ui_url], - ["/ui/welcome", config.project?.default_redirect_url], - + "/ui/recovery": config.project?.recovery_ui_url, + "/ui/registration": config.project?.registration_ui_url, + "/ui/login": config.project?.login_ui_url, + "/ui/verification": config.project?.verification_ui_url, + "/ui/settings": config.project?.settings_ui_url, + "/ui/welcome": config.project?.default_redirect_url, // New AX routes - ["/recovery", config.project?.recovery_ui_url], - ["/registration", config.project?.registration_ui_url], - ["/login", config.project?.login_ui_url], - ["/verification", config.project?.verification_ui_url], - ["/settings", config.project?.settings_ui_url], - ].entries()) { - const match = joinUrlPaths(matchBaseUrl, matchPath || "") - if (replaceWith && source.startsWith(match)) { - source = source.replaceAll( - match, - new URL(replaceWith, selfUrl).toString(), - ) - } + "/recovery": config.project?.recovery_ui_url, + "/registration": config.project?.registration_ui_url, + "/login": config.project?.login_ui_url, + "/verification": config.project?.verification_ui_url, + "/settings": config.project?.settings_ui_url, } - return source.replaceAll( - matchBaseUrl.replace(/\/$/, ""), - new URL(selfUrl).toString().replace(/\/$/, ""), + + const baseUrlNormalized = matchBaseUrl.replace(/\/$/, "") + const selfUrlNormalized = new URL(selfUrl).toString().replace(/\/$/, "") + + // Single-pass replacement for all Ory URLs + const regex = new RegExp( + escapeRegExp(baseUrlNormalized) + "(/[^\"'\\s]*)?", + "g", ) + + return source.replace(regex, (match: string, path: string | undefined) => { + // OAuth2 paths must stay on Ory's domain + if (path && oauth2Paths.some((p) => path.startsWith(p))) { + return match + } + + // Check for UI path overrides from config + for (const [uiPath, configUrl] of Object.entries(uiPathMappings)) { + if (path && configUrl && path.startsWith(uiPath)) { + return path.replace(uiPath, new URL(configUrl, selfUrl).toString()) + } + } + + // Default: rewrite to app's URL + return selfUrlNormalized + (path || "") + }) +} + +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } /** @@ -55,6 +79,10 @@ export function rewriteJsonResponse( obj: T, proxyUrl?: string, ): T { + // Handle null/undefined input to prevent runtime errors + if (!obj) { + return obj + } return Object.fromEntries( Object.entries(obj) .filter(([_, value]) => value !== undefined) From 819970ac64d1015096fd68fcf8336bd4a67e39f7 Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Mon, 20 Jul 2026 23:38:17 +0200 Subject: [PATCH 2/5] feat(oauth2): render OAuth2 client info on the consent screen The consent card lists the requested scopes as grant_scope checkboxes and shows the OAuth2 client name. The client logo override only applies to the consent screen: per review feedback, login and registration keep the application's own branding. --- .../src/components/card/card-consent.test.ts | 185 ++++++++++++++++++ .../src/components/card/card-consent.tsx | 42 +++- .../src/components/form/nodes/input.tsx | 10 +- .../src/components/form/nodes/node-button.tsx | 3 - .../src/theme/default/flows/consent.tsx | 8 +- .../src/theme/default/utils/oauth2-config.ts | 28 +++ .../src/theme/default/utils/oauth2.ts | 2 +- 7 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 packages/elements-react/src/components/card/card-consent.test.ts create mode 100644 packages/elements-react/src/theme/default/utils/oauth2-config.ts diff --git a/packages/elements-react/src/components/card/card-consent.test.ts b/packages/elements-react/src/components/card/card-consent.test.ts new file mode 100644 index 000000000..475aeee2b --- /dev/null +++ b/packages/elements-react/src/components/card/card-consent.test.ts @@ -0,0 +1,185 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { UiNode, UiNodeInputAttributesTypeEnum } from "@ory/client-fetch" + +import { getConsentNodeKey, isFooterNode } from "./card-consent" + +describe("getConsentNodeKey", () => { + it("should return name_value for input nodes with value", () => { + const node: UiNode = { + type: "input", + group: "oauth2_consent", + attributes: { + node_type: "input", + name: "grant_scope", + type: UiNodeInputAttributesTypeEnum.Checkbox, + value: "openid", + disabled: false, + }, + messages: [], + meta: {}, + } + + expect(getConsentNodeKey(node)).toBe("grant_scope_openid") + }) + + it("should return name for input nodes without value", () => { + const node: UiNode = { + type: "input", + group: "oauth2_consent", + attributes: { + node_type: "input", + name: "remember", + type: UiNodeInputAttributesTypeEnum.Checkbox, + disabled: false, + }, + messages: [], + meta: {}, + } + + expect(getConsentNodeKey(node)).toBe("remember") + }) + + it("should return name for input nodes with null value", () => { + const node: UiNode = { + type: "input", + group: "oauth2_consent", + attributes: { + node_type: "input", + name: "remember", + type: UiNodeInputAttributesTypeEnum.Checkbox, + value: null as unknown as string, + disabled: false, + }, + messages: [], + meta: {}, + } + + expect(getConsentNodeKey(node)).toBe("remember") + }) + + it("should handle numeric values", () => { + const node: UiNode = { + type: "input", + group: "oauth2_consent", + attributes: { + node_type: "input", + name: "grant_scope", + type: UiNodeInputAttributesTypeEnum.Checkbox, + value: 123 as unknown as string, + disabled: false, + }, + messages: [], + meta: {}, + } + + expect(getConsentNodeKey(node)).toBe("grant_scope_123") + }) + + it("should use getNodeId for non-input nodes", () => { + const node: UiNode = { + type: "text", + group: "oauth2_consent", + attributes: { + node_type: "text", + id: "text-node-1", + text: { id: 1, text: "Some text", type: "info" }, + }, + messages: [], + meta: {}, + } + + // getNodeId returns the id for text nodes + expect(getConsentNodeKey(node)).toBe("text-node-1") + }) +}) + +describe("isFooterNode", () => { + it("should return true for remember checkbox", () => { + const node: UiNode = { + type: "input", + group: "oauth2_consent", + attributes: { + node_type: "input", + name: "remember", + type: UiNodeInputAttributesTypeEnum.Checkbox, + disabled: false, + }, + messages: [], + meta: {}, + } + + expect(isFooterNode(node)).toBe(true) + }) + + it("should return true for submit buttons", () => { + const node: UiNode = { + type: "input", + group: "oauth2_consent", + attributes: { + node_type: "input", + name: "action", + type: UiNodeInputAttributesTypeEnum.Submit, + value: "accept", + disabled: false, + }, + messages: [], + meta: {}, + } + + expect(isFooterNode(node)).toBe(true) + }) + + it("should return false for grant_scope checkboxes", () => { + const node: UiNode = { + type: "input", + group: "oauth2_consent", + attributes: { + node_type: "input", + name: "grant_scope", + type: UiNodeInputAttributesTypeEnum.Checkbox, + value: "openid", + disabled: false, + }, + messages: [], + meta: {}, + } + + expect(isFooterNode(node)).toBe(false) + }) + + it("should return false for hidden inputs", () => { + const node: UiNode = { + type: "input", + group: "oauth2_consent", + attributes: { + node_type: "input", + name: "consent_challenge", + type: UiNodeInputAttributesTypeEnum.Hidden, + value: "challenge-123", + disabled: false, + }, + messages: [], + meta: {}, + } + + expect(isFooterNode(node)).toBe(false) + }) + + it("should return false for non-input nodes", () => { + const node: UiNode = { + type: "text", + group: "oauth2_consent", + attributes: { + node_type: "text", + id: "text-node-1", + text: { id: 1, text: "Some text", type: "info" }, + }, + messages: [], + meta: {}, + } + + expect(isFooterNode(node)).toBe(false) + }) +}) diff --git a/packages/elements-react/src/components/card/card-consent.tsx b/packages/elements-react/src/components/card/card-consent.tsx index 4ab748c13..a4e957b1d 100644 --- a/packages/elements-react/src/components/card/card-consent.tsx +++ b/packages/elements-react/src/components/card/card-consent.tsx @@ -8,7 +8,39 @@ import { OryCard } from "./card" import { OryCardContent } from "./content" import { OryCardFooter } from "./footer" import { OryCardHeader } from "./header" -import { getNodeId } from "../../util/sdk-helpers/ui" +import { getNodeId, isUiNodeInputAttributes } from "../../util/sdk-helpers/ui" +import { UiNode, UiNodeInputAttributesTypeEnum } from "@ory/client-fetch" + +/** + * Returns a unique key for a consent node. + * For input nodes, combines name with value for uniqueness. + * + * @internal Exported for testing + */ +export function getConsentNodeKey(node: UiNode): string { + if (isUiNodeInputAttributes(node.attributes)) { + const { name, value } = node.attributes + if (value !== undefined && value !== null) { + return `${name}_${String(value)}` + } + return name + } + return getNodeId(node) +} + +/** + * Checks if a node should be rendered in the footer instead of the main content. + * The Remember checkbox and submit buttons are rendered by ConsentCardFooter. + * + * @internal Exported for testing + */ +export function isFooterNode(node: UiNode): boolean { + if (!isUiNodeInputAttributes(node.attributes)) { + return false + } + const { name, type } = node.attributes + return name === "remember" || type === UiNodeInputAttributesTypeEnum.Submit +} /** * The `OryConsentCard` component renders a card for displaying the OAuth2 consent flow. @@ -26,9 +58,11 @@ export function OryConsentCard() { - {flow.flow.ui.nodes.map((node) => ( - - ))} + {flow.flow.ui.nodes + .filter((node) => !isFooterNode(node)) + .map((node) => ( + + ))} diff --git a/packages/elements-react/src/components/form/nodes/input.tsx b/packages/elements-react/src/components/form/nodes/input.tsx index f821edace..03d3d438d 100644 --- a/packages/elements-react/src/components/form/nodes/input.tsx +++ b/packages/elements-react/src/components/form/nodes/input.tsx @@ -77,14 +77,10 @@ export const NodeInput = ({ case UiNodeInputAttributesTypeEnum.Checkbox: if ( node.group === "oauth2_consent" && - node.attributes.node_type === "input" + node.attributes.node_type === "input" && + node.attributes.name === "grant_scope" ) { - switch (node.attributes.name) { - case "grant_scope": - return - default: - return null - } + return } return case UiNodeInputAttributesTypeEnum.Hidden: diff --git a/packages/elements-react/src/components/form/nodes/node-button.tsx b/packages/elements-react/src/components/form/nodes/node-button.tsx index e7a1c8e44..a0d47f09b 100644 --- a/packages/elements-react/src/components/form/nodes/node-button.tsx +++ b/packages/elements-react/src/components/form/nodes/node-button.tsx @@ -17,9 +17,6 @@ export function NodeButton({ node }: NodeButtonProps) { if (isResendNode || isScreenSelectionNode) { return null } - if (node.group === "oauth2_consent") { - return null - } const isSocial = (node.attributes.name === "provider" || node.attributes.name === "link") && diff --git a/packages/elements-react/src/theme/default/flows/consent.tsx b/packages/elements-react/src/theme/default/flows/consent.tsx index e08bb1fbd..8efee96f8 100644 --- a/packages/elements-react/src/theme/default/flows/consent.tsx +++ b/packages/elements-react/src/theme/default/flows/consent.tsx @@ -11,6 +11,7 @@ import { OrySuccessHandler, } from "@ory/elements-react" import { getOryComponents } from "../components" +import { getConfigWithOAuth2Logo } from "../utils/oauth2-config" import { translateConsentChallengeToUiNodes } from "../utils/oauth2" /** @@ -116,9 +117,14 @@ export function Consent({ session, ) + const configWithLogo = getConfigWithOAuth2Logo( + config, + consentChallenge.client?.logo_uri, + ) + return ( Date: Mon, 20 Jul 2026 23:38:17 +0200 Subject: [PATCH 3/5] feat(examples): add OAuth2 consent flow to Next.js examples Add consent pages and API routes to the app router, custom components, and pages router examples. CSRF protection uses the @csrf-armor/nextjs middleware plus an explicit double-submit comparison in the consent routes, since the library falls back to the csrf cookie when extracting the submitted token. Middleware path matching is aligned with the @ory/nextjs proxy match list, and the consent pages only render when the session identity matches the consent request subject. --- .gitignore | 2 +- .../nextjs-app-router-custom-components/.env | 3 + .../app/api/consent/route.ts | 140 +++++++++++++++++ .../app/auth/consent/consent-form.tsx | 37 +++++ .../app/auth/consent/page.tsx | 25 +++ .../components/consent-utils.ts | 27 ++++ .../custom-consent-scope-checkbox.tsx | 68 +++++++++ .../components/custom-footer.tsx | 37 ++++- .../components/index.tsx | 2 + .../middleware.ts | 48 +++++- .../package.json | 1 + examples/nextjs-app-router/.env | 3 + .../app/api/consent/route.ts | 138 +++++++++++++++++ .../app/auth/consent/consent-form.tsx | 38 +++++ .../app/auth/consent/page.tsx | 25 +++ examples/nextjs-app-router/middleware.ts | 48 +++++- examples/nextjs-app-router/package.json | 2 + examples/nextjs-pages-router/.env | 3 + examples/nextjs-pages-router/middleware.ts | 48 +++++- examples/nextjs-pages-router/package.json | 2 + .../nextjs-pages-router/pages/api/consent.ts | 144 ++++++++++++++++++ .../pages/auth/consent.tsx | 78 ++++++++++ pnpm-lock.yaml | 50 +++++- 23 files changed, 949 insertions(+), 20 deletions(-) create mode 100644 examples/nextjs-app-router-custom-components/app/api/consent/route.ts create mode 100644 examples/nextjs-app-router-custom-components/app/auth/consent/consent-form.tsx create mode 100644 examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx create mode 100644 examples/nextjs-app-router-custom-components/components/consent-utils.ts create mode 100644 examples/nextjs-app-router-custom-components/components/custom-consent-scope-checkbox.tsx create mode 100644 examples/nextjs-app-router/app/api/consent/route.ts create mode 100644 examples/nextjs-app-router/app/auth/consent/consent-form.tsx create mode 100644 examples/nextjs-app-router/app/auth/consent/page.tsx create mode 100644 examples/nextjs-pages-router/pages/api/consent.ts create mode 100644 examples/nextjs-pages-router/pages/auth/consent.tsx diff --git a/.gitignore b/.gitignore index d175c6a10..f4631cb20 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,4 @@ storybook-static /test-results/ .nx -coverage/ \ No newline at end of file +coverage/ diff --git a/examples/nextjs-app-router-custom-components/.env b/examples/nextjs-app-router-custom-components/.env index 6bdc430da..619d6c208 100644 --- a/examples/nextjs-app-router-custom-components/.env +++ b/examples/nextjs-app-router-custom-components/.env @@ -3,3 +3,6 @@ NEXT_PUBLIC_ORY_SDK_URL=https://nervous-lewin-4edwdlsbyw.projects.oryapis.com # Set the API Token for support of SAML and OIDC. ORY_PROJECT_API_TOKEN= + +# Secret key for CSRF token signing (replace with a secure random value in production) +CSRF_SECRET=change-me-to-a-32-char-secret!! diff --git a/examples/nextjs-app-router-custom-components/app/api/consent/route.ts b/examples/nextjs-app-router-custom-components/app/api/consent/route.ts new file mode 100644 index 000000000..b03b4a509 --- /dev/null +++ b/examples/nextjs-app-router-custom-components/app/api/consent/route.ts @@ -0,0 +1,140 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { + acceptConsentRequest, + getServerSession, + rejectConsentRequest, +} from "@ory/nextjs/app" +import { cookies } from "next/headers" +import { NextResponse } from "next/server" + +interface ConsentBody { + action?: string + consent_challenge?: string + grant_scope?: string | string[] + remember?: boolean | string + csrf_token?: string +} + +async function parseRequest(request: Request): Promise { + const contentType = request.headers.get("content-type") || "" + + if (contentType.includes("application/json")) { + return (await request.json()) as ConsentBody + } + + if ( + contentType.includes("application/x-www-form-urlencoded") || + contentType.includes("multipart/form-data") + ) { + const formData = await request.formData() + return { + action: formData.get("action") as string, + consent_challenge: formData.get("consent_challenge") as string, + grant_scope: formData.getAll("grant_scope") as string[], + remember: formData.get("remember") as string, + csrf_token: formData.get("csrf_token") as string, + } + } + + // Try JSON as fallback + try { + return (await request.json()) as ConsentBody + } catch { + return {} + } +} + +export async function POST(request: Request) { + // Security: Verify session exists before processing consent + const session = await getServerSession() + if (!session) { + console.error("Consent security: No session found") + return NextResponse.json( + { error: "unauthorized", error_description: "No session" }, + { status: 401 }, + ) + } + + const identityId = session.identity?.id + if (!identityId) { + console.error("Consent security: Session has no identity ID") + return NextResponse.json( + { error: "unauthorized", error_description: "Invalid session" }, + { status: 401 }, + ) + } + + const body = await parseRequest(request) + + // Defense in depth: the CSRF middleware validates the signed cookie pair, + // but the token extraction of @csrf-armor/nextjs falls back to the cookie + // itself, so the submitted field must be compared against it explicitly. + const cookieStore = await cookies() + const csrfCookie = cookieStore.get("csrf-token")?.value + if (!csrfCookie || body.csrf_token !== csrfCookie) { + return NextResponse.json( + { error: "invalid_request", error_description: "CSRF token mismatch" }, + { status: 403 }, + ) + } + + const action = body.action + const consentChallenge = body.consent_challenge + const grantScope = Array.isArray(body.grant_scope) + ? body.grant_scope + : body.grant_scope + ? [body.grant_scope] + : [] + const remember = body.remember === true || body.remember === "true" + + if (!consentChallenge) { + return NextResponse.json( + { + error: "invalid_request", + error_description: "Missing consent_challenge", + }, + { status: 400 }, + ) + } + + try { + let redirectTo: string + + if (action === "accept") { + redirectTo = await acceptConsentRequest(consentChallenge, { + grantScope, + remember, + identityId, + }) + } else { + redirectTo = await rejectConsentRequest(consentChallenge, { + identityId, + }) + } + + return NextResponse.json({ redirect_to: redirectTo }) + } catch (error) { + console.error("Consent error:", error) + + // Check for identity mismatch error + if ( + error instanceof Error && + error.message.includes("does not match consent request subject") + ) { + return NextResponse.json( + { + error: "forbidden", + error_description: "Session does not match consent request subject", + }, + { status: 403 }, + ) + } + + return NextResponse.json( + { error: "server_error", error_description: "Failed to process consent" }, + { status: 500 }, + ) + } +} diff --git a/examples/nextjs-app-router-custom-components/app/auth/consent/consent-form.tsx b/examples/nextjs-app-router-custom-components/app/auth/consent/consent-form.tsx new file mode 100644 index 000000000..ea7600678 --- /dev/null +++ b/examples/nextjs-app-router-custom-components/app/auth/consent/consent-form.tsx @@ -0,0 +1,37 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 +"use client" + +import { getCsrfToken } from "@csrf-armor/nextjs/client" +import { OAuth2ConsentRequest, Session } from "@ory/client-fetch" +import { Consent } from "@ory/elements-react/theme" +import { useEffect, useState } from "react" + +import { myCustomComponents } from "@/components" +import config from "@/ory.config" + +interface ConsentFormProps { + consentRequest: OAuth2ConsentRequest + session: Session +} + +export function ConsentForm({ consentRequest, session }: ConsentFormProps) { + // The csrf-token cookie is set by the CSRF middleware on the first response, + // so it is only readable client-side, after hydration. + const [csrfToken, setCsrfToken] = useState("") + + useEffect(() => { + setCsrfToken(getCsrfToken() ?? "") + }, []) + + return ( + + ) +} diff --git a/examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx b/examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx new file mode 100644 index 000000000..3108c48ef --- /dev/null +++ b/examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx @@ -0,0 +1,25 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { + getConsentFlow, + getServerSession, + OryPageParams, +} from "@ory/nextjs/app" + +import { ConsentForm } from "./consent-form" + +export default async function ConsentPage(props: OryPageParams) { + const consentRequest = await getConsentFlow(props.searchParams) + const session = await getServerSession() + + if (!consentRequest || !session) { + return null + } + + if (session.identity?.id !== consentRequest.subject) { + return

This consent request was issued for a different account.

+ } + + return +} diff --git a/examples/nextjs-app-router-custom-components/components/consent-utils.ts b/examples/nextjs-app-router-custom-components/components/consent-utils.ts new file mode 100644 index 000000000..2ca5c26f8 --- /dev/null +++ b/examples/nextjs-app-router-custom-components/components/consent-utils.ts @@ -0,0 +1,27 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { UiNode, UiNodeInputAttributesTypeEnum } from "@ory/client-fetch" +import { isUiNodeInput, UiNodeInput } from "@ory/elements-react" + +/** + * Finds consent-specific nodes from the UI nodes list. + */ +export function findConsentNodes(nodes: UiNode[]) { + let rememberNode: UiNodeInput | undefined + const submitNodes: UiNodeInput[] = [] + + for (const node of nodes) { + if (!isUiNodeInput(node)) { + continue + } + + if (node.attributes.name === "remember") { + rememberNode = node + } else if (node.attributes.type === UiNodeInputAttributesTypeEnum.Submit) { + submitNodes.push(node) + } + } + + return { rememberNode, submitNodes } +} diff --git a/examples/nextjs-app-router-custom-components/components/custom-consent-scope-checkbox.tsx b/examples/nextjs-app-router-custom-components/components/custom-consent-scope-checkbox.tsx new file mode 100644 index 000000000..c4ccecdab --- /dev/null +++ b/examples/nextjs-app-router-custom-components/components/custom-consent-scope-checkbox.tsx @@ -0,0 +1,68 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { OryNodeConsentScopeCheckboxProps } from "@ory/elements-react" + +const scopeLabels: Record = { + openid: { + title: "Identity", + description: "Allows the application to verify your identity.", + }, + offline_access: { + title: "Offline Access", + description: "Allows the application to keep you signed in.", + }, + profile: { + title: "Profile Information", + description: "Allows access to your basic profile details.", + }, + email: { + title: "Email Address", + description: "Retrieve your email address and its verification status.", + }, + phone: { + title: "Phone Number", + description: "Retrieve your phone number.", + }, +} + +export function MyCustomConsentScopeCheckbox({ + attributes, + onCheckedChange, + inputProps, +}: OryNodeConsentScopeCheckboxProps) { + const scope = attributes.value as string + const label = scopeLabels[scope] ?? { title: scope, description: "" } + + return ( + + ) +} diff --git a/examples/nextjs-app-router-custom-components/components/custom-footer.tsx b/examples/nextjs-app-router-custom-components/components/custom-footer.tsx index f6908af14..6734fa73c 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-footer.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-footer.tsx @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { FlowType } from "@ory/client-fetch" -import { useOryFlow } from "@ory/elements-react" +import { ConsentFlow, Node, useOryFlow } from "@ory/elements-react" import Link from "next/link" +import { findConsentNodes } from "./consent-utils" export function MyCustomFooter() { const flow = useOryFlow() @@ -31,8 +32,40 @@ export function MyCustomFooter() { case FlowType.Verification: return null case FlowType.OAuth2Consent: - return null + return default: return null } } + +function ConsentFooter({ flow }: { flow: ConsentFlow }) { + const { rememberNode, submitNodes } = findConsentNodes(flow.ui.nodes) + const clientName = + flow.consent_request.client?.client_name ?? "this application" + + return ( +
+
+

+ Make sure you trust {clientName} +

+

+ You may be sharing sensitive information with this site or + application. +

+
+ + {rememberNode && } + +
+ {submitNodes.map((node) => ( + + ))} +
+ +

+ Authorizing will redirect to {clientName} +

+
+ ) +} diff --git a/examples/nextjs-app-router-custom-components/components/index.tsx b/examples/nextjs-app-router-custom-components/components/index.tsx index 2ee409d2b..46a52ac86 100644 --- a/examples/nextjs-app-router-custom-components/components/index.tsx +++ b/examples/nextjs-app-router-custom-components/components/index.tsx @@ -9,6 +9,7 @@ import { MyCustomSsoButton } from "./custom-social" import { MyCustomInput } from "./custom-input" import { MyCustomPinCodeInput } from "./custom-pin-code" import { MyCustomCheckbox } from "./custom-checkbox" +import { MyCustomConsentScopeCheckbox } from "./custom-consent-scope-checkbox" import { MyCustomImage } from "./custom-image" import { MyCustomLabel } from "./custom-label" import { MyCustomFooter } from "./custom-footer" @@ -20,6 +21,7 @@ export const myCustomComponents: OryFlowComponentOverrides = { Input: MyCustomInput, CodeInput: MyCustomPinCodeInput, Checkbox: MyCustomCheckbox, + ConsentScopeCheckbox: MyCustomConsentScopeCheckbox, Image: MyCustomImage, Label: MyCustomLabel, }, diff --git a/examples/nextjs-app-router-custom-components/middleware.ts b/examples/nextjs-app-router-custom-components/middleware.ts index 5d7131950..2df7dca5d 100644 --- a/examples/nextjs-app-router-custom-components/middleware.ts +++ b/examples/nextjs-app-router-custom-components/middleware.ts @@ -1,11 +1,53 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 +import { createCsrfMiddleware } from "@csrf-armor/nextjs" import { createOryMiddleware } from "@ory/nextjs/middleware" +import { NextRequest, NextResponse } from "next/server" + import oryConfig from "@/ory.config" -// This function can be marked `async` if using `await` inside -export const middleware = createOryMiddleware(oryConfig) +const oryMiddleware = createOryMiddleware(oryConfig) + +// Paths handled by the Ory proxy middleware; keep in sync with the +// proxyRequest match list in @ory/nextjs. +const oryPathPrefixes = [ + "/self-service", + "/sessions/whoami", + "/ui", + "/.well-known/ory", + "/.ory", +] + +const csrfProtect = createCsrfMiddleware({ + strategy: "signed-double-submit", + secret: process.env.CSRF_SECRET, + cookie: { + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: false, + }, + token: { fieldName: "csrf_token" }, + excludePaths: oryPathPrefixes, +}) + +export async function middleware(request: NextRequest) { + const isOryPath = oryPathPrefixes.some((prefix) => + request.nextUrl.pathname.startsWith(prefix), + ) + if (isOryPath) { + return oryMiddleware(request) + } + + const response = NextResponse.next() + const result = await csrfProtect(request, response) + if (!result.success) { + return NextResponse.json( + { error: "CSRF validation failed" }, + { status: 403 }, + ) + } + return response +} -// See "Matching Paths" below to learn more export const config = {} diff --git a/examples/nextjs-app-router-custom-components/package.json b/examples/nextjs-app-router-custom-components/package.json index ebd8c0585..61b91fef9 100644 --- a/examples/nextjs-app-router-custom-components/package.json +++ b/examples/nextjs-app-router-custom-components/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@csrf-armor/nextjs": "^1.4.1", "@ory/client-fetch": "1.22.37", "@ory/elements-react": "workspace:*", "@ory/nextjs": "workspace:*", diff --git a/examples/nextjs-app-router/.env b/examples/nextjs-app-router/.env index 6bdc430da..619d6c208 100644 --- a/examples/nextjs-app-router/.env +++ b/examples/nextjs-app-router/.env @@ -3,3 +3,6 @@ NEXT_PUBLIC_ORY_SDK_URL=https://nervous-lewin-4edwdlsbyw.projects.oryapis.com # Set the API Token for support of SAML and OIDC. ORY_PROJECT_API_TOKEN= + +# Secret key for CSRF token signing (replace with a secure random value in production) +CSRF_SECRET=change-me-to-a-32-char-secret!! diff --git a/examples/nextjs-app-router/app/api/consent/route.ts b/examples/nextjs-app-router/app/api/consent/route.ts new file mode 100644 index 000000000..d3d1e3454 --- /dev/null +++ b/examples/nextjs-app-router/app/api/consent/route.ts @@ -0,0 +1,138 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { + acceptConsentRequest, + getServerSession, + rejectConsentRequest, +} from "@ory/nextjs/app" +import { cookies } from "next/headers" +import { NextResponse } from "next/server" + +interface ConsentBody { + action?: string + consent_challenge?: string + grant_scope?: string | string[] + remember?: boolean | string + csrf_token?: string +} + +async function parseRequest(request: Request): Promise { + const contentType = request.headers.get("content-type") || "" + + if (contentType.includes("application/json")) { + return (await request.json()) as ConsentBody + } + + if ( + contentType.includes("application/x-www-form-urlencoded") || + contentType.includes("multipart/form-data") + ) { + const formData = await request.formData() + return { + action: formData.get("action") as string, + consent_challenge: formData.get("consent_challenge") as string, + grant_scope: formData.getAll("grant_scope") as string[], + remember: formData.get("remember") as string, + csrf_token: formData.get("csrf_token") as string, + } + } + + // Try JSON as fallback + try { + return (await request.json()) as ConsentBody + } catch { + return {} + } +} + +export async function POST(request: Request) { + const session = await getServerSession() + if (!session) { + console.error("Consent security: No session found") + return NextResponse.json( + { error: "unauthorized", error_description: "No session" }, + { status: 401 }, + ) + } + + const identityId = session.identity?.id + if (!identityId) { + console.error("Consent security: Session has no identity ID") + return NextResponse.json( + { error: "unauthorized", error_description: "Invalid session" }, + { status: 401 }, + ) + } + + const body = await parseRequest(request) + + // Defense in depth: the CSRF middleware validates the signed cookie pair, + // but the token extraction of @csrf-armor/nextjs falls back to the cookie + // itself, so the submitted field must be compared against it explicitly. + const cookieStore = await cookies() + const csrfCookie = cookieStore.get("csrf-token")?.value + if (!csrfCookie || body.csrf_token !== csrfCookie) { + return NextResponse.json( + { error: "invalid_request", error_description: "CSRF token mismatch" }, + { status: 403 }, + ) + } + + const action = body.action + const consentChallenge = body.consent_challenge + const grantScope = Array.isArray(body.grant_scope) + ? body.grant_scope + : body.grant_scope + ? [body.grant_scope] + : [] + const remember = body.remember === true || body.remember === "true" + + if (!consentChallenge) { + return NextResponse.json( + { + error: "invalid_request", + error_description: "Missing consent_challenge", + }, + { status: 400 }, + ) + } + + try { + let redirectTo: string + + if (action === "accept") { + redirectTo = await acceptConsentRequest(consentChallenge, { + grantScope, + remember, + identityId, + }) + } else { + redirectTo = await rejectConsentRequest(consentChallenge, { + identityId, + }) + } + + return NextResponse.json({ redirect_to: redirectTo }) + } catch (error) { + console.error("Consent error:", error) + + if ( + error instanceof Error && + error.message.includes("does not match consent request subject") + ) { + return NextResponse.json( + { + error: "forbidden", + error_description: "Session does not match consent request subject", + }, + { status: 403 }, + ) + } + + return NextResponse.json( + { error: "server_error", error_description: "Failed to process consent" }, + { status: 500 }, + ) + } +} diff --git a/examples/nextjs-app-router/app/auth/consent/consent-form.tsx b/examples/nextjs-app-router/app/auth/consent/consent-form.tsx new file mode 100644 index 000000000..716bf4bdc --- /dev/null +++ b/examples/nextjs-app-router/app/auth/consent/consent-form.tsx @@ -0,0 +1,38 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 +"use client" + +import { getCsrfToken } from "@csrf-armor/nextjs/client" +import { OAuth2ConsentRequest, Session } from "@ory/client-fetch" +import { Consent } from "@ory/elements-react/theme" +import { useEffect, useState } from "react" + +import config from "@/ory.config" + +interface ConsentFormProps { + consentRequest: OAuth2ConsentRequest + session: Session +} + +export function ConsentForm({ consentRequest, session }: ConsentFormProps) { + // The csrf-token cookie is set by the CSRF middleware on the first response, + // so it is only readable client-side, after hydration. + const [csrfToken, setCsrfToken] = useState("") + + useEffect(() => { + setCsrfToken(getCsrfToken() ?? "") + }, []) + + return ( + + ) +} diff --git a/examples/nextjs-app-router/app/auth/consent/page.tsx b/examples/nextjs-app-router/app/auth/consent/page.tsx new file mode 100644 index 000000000..3108c48ef --- /dev/null +++ b/examples/nextjs-app-router/app/auth/consent/page.tsx @@ -0,0 +1,25 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { + getConsentFlow, + getServerSession, + OryPageParams, +} from "@ory/nextjs/app" + +import { ConsentForm } from "./consent-form" + +export default async function ConsentPage(props: OryPageParams) { + const consentRequest = await getConsentFlow(props.searchParams) + const session = await getServerSession() + + if (!consentRequest || !session) { + return null + } + + if (session.identity?.id !== consentRequest.subject) { + return

This consent request was issued for a different account.

+ } + + return +} diff --git a/examples/nextjs-app-router/middleware.ts b/examples/nextjs-app-router/middleware.ts index 5d7131950..2df7dca5d 100644 --- a/examples/nextjs-app-router/middleware.ts +++ b/examples/nextjs-app-router/middleware.ts @@ -1,11 +1,53 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 +import { createCsrfMiddleware } from "@csrf-armor/nextjs" import { createOryMiddleware } from "@ory/nextjs/middleware" +import { NextRequest, NextResponse } from "next/server" + import oryConfig from "@/ory.config" -// This function can be marked `async` if using `await` inside -export const middleware = createOryMiddleware(oryConfig) +const oryMiddleware = createOryMiddleware(oryConfig) + +// Paths handled by the Ory proxy middleware; keep in sync with the +// proxyRequest match list in @ory/nextjs. +const oryPathPrefixes = [ + "/self-service", + "/sessions/whoami", + "/ui", + "/.well-known/ory", + "/.ory", +] + +const csrfProtect = createCsrfMiddleware({ + strategy: "signed-double-submit", + secret: process.env.CSRF_SECRET, + cookie: { + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: false, + }, + token: { fieldName: "csrf_token" }, + excludePaths: oryPathPrefixes, +}) + +export async function middleware(request: NextRequest) { + const isOryPath = oryPathPrefixes.some((prefix) => + request.nextUrl.pathname.startsWith(prefix), + ) + if (isOryPath) { + return oryMiddleware(request) + } + + const response = NextResponse.next() + const result = await csrfProtect(request, response) + if (!result.success) { + return NextResponse.json( + { error: "CSRF validation failed" }, + { status: 403 }, + ) + } + return response +} -// See "Matching Paths" below to learn more export const config = {} diff --git a/examples/nextjs-app-router/package.json b/examples/nextjs-app-router/package.json index 04e9fd5e9..97f574574 100644 --- a/examples/nextjs-app-router/package.json +++ b/examples/nextjs-app-router/package.json @@ -9,6 +9,8 @@ "lint": "next lint" }, "dependencies": { + "@csrf-armor/nextjs": "^1.4.1", + "@ory/client-fetch": "1.22.37", "@ory/elements-react": "workspace:*", "@ory/nextjs": "workspace:*", "next": "15.5.18", diff --git a/examples/nextjs-pages-router/.env b/examples/nextjs-pages-router/.env index 6bdc430da..619d6c208 100644 --- a/examples/nextjs-pages-router/.env +++ b/examples/nextjs-pages-router/.env @@ -3,3 +3,6 @@ NEXT_PUBLIC_ORY_SDK_URL=https://nervous-lewin-4edwdlsbyw.projects.oryapis.com # Set the API Token for support of SAML and OIDC. ORY_PROJECT_API_TOKEN= + +# Secret key for CSRF token signing (replace with a secure random value in production) +CSRF_SECRET=change-me-to-a-32-char-secret!! diff --git a/examples/nextjs-pages-router/middleware.ts b/examples/nextjs-pages-router/middleware.ts index f38bfa5ea..864d304b4 100644 --- a/examples/nextjs-pages-router/middleware.ts +++ b/examples/nextjs-pages-router/middleware.ts @@ -1,11 +1,53 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 +import { createCsrfMiddleware } from "@csrf-armor/nextjs" import { createOryMiddleware } from "@ory/nextjs/middleware" +import { NextRequest, NextResponse } from "next/server" + import oryConfig from "./ory.config" -// This function can be marked `async` if using `await` inside -export const middleware = createOryMiddleware(oryConfig) +const oryMiddleware = createOryMiddleware(oryConfig) + +// Paths handled by the Ory proxy middleware; keep in sync with the +// proxyRequest match list in @ory/nextjs. +const oryPathPrefixes = [ + "/self-service", + "/sessions/whoami", + "/ui", + "/.well-known/ory", + "/.ory", +] + +const csrfProtect = createCsrfMiddleware({ + strategy: "signed-double-submit", + secret: process.env.CSRF_SECRET, + cookie: { + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: false, + }, + token: { fieldName: "csrf_token" }, + excludePaths: oryPathPrefixes, +}) + +export async function middleware(request: NextRequest) { + const isOryPath = oryPathPrefixes.some((prefix) => + request.nextUrl.pathname.startsWith(prefix), + ) + if (isOryPath) { + return oryMiddleware(request) + } + + const response = NextResponse.next() + const result = await csrfProtect(request, response) + if (!result.success) { + return NextResponse.json( + { error: "CSRF validation failed" }, + { status: 403 }, + ) + } + return response +} -// See "Matching Paths" below to learn more export const config = {} diff --git a/examples/nextjs-pages-router/package.json b/examples/nextjs-pages-router/package.json index a875b2961..51b17d6b5 100644 --- a/examples/nextjs-pages-router/package.json +++ b/examples/nextjs-pages-router/package.json @@ -8,6 +8,8 @@ "start": "next start" }, "dependencies": { + "@csrf-armor/nextjs": "^1.4.1", + "@ory/client-fetch": "1.22.37", "@ory/elements-react": "workspace:*", "@ory/nextjs": "workspace:*", "next": "15.5.18", diff --git a/examples/nextjs-pages-router/pages/api/consent.ts b/examples/nextjs-pages-router/pages/api/consent.ts new file mode 100644 index 000000000..218eac1c4 --- /dev/null +++ b/examples/nextjs-pages-router/pages/api/consent.ts @@ -0,0 +1,144 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Configuration, FrontendApi, OAuth2Api } from "@ory/client-fetch" +import type { NextApiRequest, NextApiResponse } from "next" + +function getBaseUrl(): string { + const baseUrl = process.env.NEXT_PUBLIC_ORY_SDK_URL || process.env.ORY_SDK_URL + if (!baseUrl) { + throw new Error("ORY_SDK_URL is not set") + } + return baseUrl.replace(/\/$/, "") +} + +function getOAuth2Client() { + const apiKey = process.env.ORY_PROJECT_API_TOKEN ?? "" + + return new OAuth2Api( + new Configuration({ + basePath: getBaseUrl(), + headers: { + Accept: "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + }), + ) +} + +function getFrontendClient() { + return new FrontendApi( + new Configuration({ + basePath: getBaseUrl(), + headers: { + Accept: "application/json", + }, + }), + ) +} + +interface ConsentRequestBody { + action?: string + consent_challenge?: string + grant_scope?: string | string[] + remember?: string | boolean + csrf_token?: string +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }) + } + + const body = req.body as ConsentRequestBody + const { action, consent_challenge, grant_scope, remember } = body + + if (!consent_challenge) { + return res.status(400).json({ error: "Missing consent_challenge" }) + } + + // Defense in depth: the CSRF middleware validates the signed cookie pair, + // but the token extraction of @csrf-armor/nextjs falls back to the cookie + // itself, so the submitted field must be compared against it explicitly. + const csrfCookie = req.cookies["csrf-token"] + if (!csrfCookie || body.csrf_token !== csrfCookie) { + return res.status(403).json({ error: "CSRF token mismatch" }) + } + + const oauth2Client = getOAuth2Client() + const frontendClient = getFrontendClient() + + try { + // Security: Fetch the consent request to get the expected subject + const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ + consentChallenge: consent_challenge, + }) + + // Security: Verify the current session matches the consent challenge subject + // This prevents an attacker from using a stolen consent_challenge + // to grant consent on behalf of a different user + const cookie = req.headers.cookie + if (!cookie) { + console.error("Consent security: No session cookie provided") + return res.status(401).json({ error: "Unauthorized: No session" }) + } + + let session + try { + session = await frontendClient.toSession({ cookie }) + } catch { + console.error("Consent security: Invalid or expired session") + return res.status(401).json({ error: "Unauthorized: Invalid session" }) + } + + // Compare the session identity with the consent request subject + const sessionIdentityId = session.identity?.id + const consentSubject = consentRequest.subject + + if (!sessionIdentityId || sessionIdentityId !== consentSubject) { + console.error( + "Consent security: Session identity mismatch. " + + `Session: ${sessionIdentityId}, Consent subject: ${consentSubject}`, + ) + return res.status(403).json({ + error: "Forbidden: Session does not match consent request subject", + }) + } + + let redirectTo: string + + if (action === "accept") { + const scopes: string[] = Array.isArray(grant_scope) + ? grant_scope + : grant_scope + ? [grant_scope] + : [] + + const response = await oauth2Client.acceptOAuth2ConsentRequest({ + consentChallenge: consent_challenge, + acceptOAuth2ConsentRequest: { + grant_scope: scopes, + remember: remember === "true" || remember === true, + }, + }) + redirectTo = response.redirect_to + } else { + const response = await oauth2Client.rejectOAuth2ConsentRequest({ + consentChallenge: consent_challenge, + rejectOAuth2Request: { + error: "access_denied", + error_description: "The resource owner denied the request", + }, + }) + redirectTo = response.redirect_to + } + + return res.status(200).json({ redirect_to: redirectTo }) + } catch (error) { + console.error("Consent error:", error) + return res.status(500).json({ error: "Failed to process consent" }) + } +} diff --git a/examples/nextjs-pages-router/pages/auth/consent.tsx b/examples/nextjs-pages-router/pages/auth/consent.tsx new file mode 100644 index 000000000..d7eab9c9e --- /dev/null +++ b/examples/nextjs-pages-router/pages/auth/consent.tsx @@ -0,0 +1,78 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { OAuth2ConsentRequest } from "@ory/client-fetch" +import { Consent } from "@ory/elements-react/theme" +import "@ory/elements-react/theme/styles.css" +import { getServerSideConsentFlow, useSession } from "@ory/nextjs/pages" +import { GetServerSideProps } from "next" + +import config from "@/ory.config" + +interface ConsentPageProps { + consentRequest: OAuth2ConsentRequest +} + +// The @csrf-armor/nextjs/client barrel pulls in next/navigation, which breaks +// the pages router build, so the cookie is read with a local helper instead. +function getCsrfTokenFromCookie(): string { + if (typeof document === "undefined") { + return "" + } + const cookie = document.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith("csrf-token=")) + return cookie ? decodeURIComponent(cookie.slice("csrf-token=".length)) : "" +} + +export const getServerSideProps: GetServerSideProps = async ( + context, +) => { + const consentRequest = await getServerSideConsentFlow(context.query) + + if (!consentRequest) { + return { notFound: true } + } + + // Strip undefined values: Next.js requires JSON-serializable props. + return { + props: { + consentRequest: JSON.parse( + JSON.stringify(consentRequest), + ) as OAuth2ConsentRequest, + }, + } +} + +export default function ConsentPage({ consentRequest }: ConsentPageProps) { + const { session, loading } = useSession() + const csrfToken = getCsrfTokenFromCookie() + + if (loading || !session) { + return null + } + + if (session.identity?.id !== consentRequest.subject) { + return ( +
+

This consent request was issued for a different account.

+
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16c2518ee..4b2eaa9b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,6 +233,12 @@ importers: examples/nextjs-app-router: dependencies: + '@csrf-armor/nextjs': + specifier: ^1.4.1 + version: 1.4.4(next@15.5.18(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@ory/client-fetch': + specifier: 1.22.37 + version: 1.22.37 '@ory/elements-react': specifier: workspace:* version: link:../../packages/elements-react @@ -273,6 +279,9 @@ importers: examples/nextjs-app-router-custom-components: dependencies: + '@csrf-armor/nextjs': + specifier: ^1.4.1 + version: 1.4.4(next@15.5.18(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@ory/client-fetch': specifier: 1.22.37 version: 1.22.37 @@ -365,6 +374,12 @@ importers: examples/nextjs-pages-router: dependencies: + '@csrf-armor/nextjs': + specifier: ^1.4.1 + version: 1.4.4(next@15.5.18(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@ory/client-fetch': + specifier: 1.22.37 + version: 1.22.37 '@ory/elements-react': specifier: workspace:* version: link:../../packages/elements-react @@ -1229,6 +1244,17 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csrf-armor/core@1.2.3': + resolution: {integrity: sha512-a2U4MgNsgwFOdHJkGDtaie1IFfE2Y6E2XJbV9feQ0X9baHcCASxIq+z90PZDQKdcPvPAZekl9jbyeAV/XS3tLA==} + engines: {node: '>=18.0.0'} + + '@csrf-armor/nextjs@1.4.4': + resolution: {integrity: sha512-ySJOeoMvlN7wcPDWio/F5I2CLSgsq3va7AnvXHL6xZt1ZHDB9dZfbJ99593qJGM1L/N+S2OWkv9yxGp0GdwuPw==} + engines: {node: '>=18.0.0'} + peerDependencies: + next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: 19.2.7 + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -10422,6 +10448,14 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csrf-armor/core@1.2.3': {} + + '@csrf-armor/nextjs@1.4.4(next@15.5.18(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@csrf-armor/core': 1.2.3 + next: 15.5.18(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -15837,7 +15871,7 @@ snapshots: eslint: 9.27.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.27.0(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.27.0(jiti@2.7.0)) eslint-plugin-react-hooks: 5.2.0(eslint@9.27.0(jiti@2.7.0)) @@ -15867,7 +15901,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -15940,7 +15974,7 @@ snapshots: - ts-jest - typescript - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15949,9 +15983,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.27.0(jiti@1.21.7) + eslint: 9.27.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@1.21.7)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -15969,7 +16003,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15978,9 +16012,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.27.0(jiti@2.7.0) + eslint: 9.27.0(jiti@1.21.7) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)))(eslint@9.27.0(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@1.21.7)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 From 138c47d2405dc009a84776e42b75b270fa0046ba Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Tue, 21 Jul 2026 00:18:41 +0200 Subject: [PATCH 4/5] fix(oauth2): enforce consent subject validation and URL boundaries Make identityId mandatory in acceptConsentRequest and rejectConsentRequest so the subject check cannot be skipped by omission, via a shared assertConsentSubject helper. Harden rewriteUrls against prefix collisions: a lookahead enforces a domain boundary so hostnames merely sharing the base URL prefix are left untouched, and UI path overrides only match whole path segments so "/login" no longer captures "/login-methods". --- .../nextjs/api-report/nextjs-client.api.json | 10 ++-- packages/nextjs/api-report/nextjs.api.md | 6 +- packages/nextjs/src/app/consent.test.ts | 46 ++++++++++++++- packages/nextjs/src/app/consent.ts | 58 +++++++++---------- packages/nextjs/src/utils/rewrite.test.ts | 16 +++++ packages/nextjs/src/utils/rewrite.ts | 22 +++++-- 6 files changed, 114 insertions(+), 44 deletions(-) diff --git a/packages/nextjs/api-report/nextjs-client.api.json b/packages/nextjs/api-report/nextjs-client.api.json index ba0ff6f70..dce97035b 100644 --- a/packages/nextjs/api-report/nextjs-client.api.json +++ b/packages/nextjs/api-report/nextjs-client.api.json @@ -384,7 +384,7 @@ { "kind": "Function", "canonicalReference": "@ory/nextjs!acceptConsentRequest:function(1)", - "docComment": "/**\n * Accept an OAuth2 consent request.\n *\n * This method should be called from an API route handler when the user accepts the consent. It validates that the provided session identity matches the consent request subject to prevent consent hijacking attacks.\n *\n * @param consentChallenge - The consent challenge from the form.\n *\n * @param options - Options for accepting the consent request.\n *\n * @returns The redirect URL to complete the OAuth2 flow.\n *\n * @example\n * ```tsx\n * // app/api/consent/route.ts\n * import { acceptConsentRequest, rejectConsentRequest, getServerSession } from \"@ory/nextjs/app\"\n * import { redirect } from \"next/navigation\"\n *\n * export async function POST(request: Request) {\n * const session = await getServerSession()\n * if (!session) {\n * return new Response(\"Unauthorized\", { status: 401 })\n * }\n *\n * const formData = await request.formData()\n * const action = formData.get(\"action\")\n * const consentChallenge = formData.get(\"consent_challenge\") as string\n * const grantScope = formData.getAll(\"grant_scope\") as string[]\n * const remember = formData.get(\"remember\") === \"true\"\n *\n * if (action === \"accept\") {\n * const redirectTo = await acceptConsentRequest(consentChallenge, {\n * grantScope,\n * remember,\n * identityId: session.identity?.id,\n * })\n * return redirect(redirectTo)\n * } else {\n * const redirectTo = await rejectConsentRequest(consentChallenge, {\n * identityId: session.identity?.id,\n * })\n * return redirect(redirectTo)\n * }\n * }\n * ```\n *\n * @throws\n *\n * Error if identityId doesn't match the consent request subject.\n *\n * @public\n */\n", + "docComment": "/**\n * Accept an OAuth2 consent request.\n *\n * This method should be called from an API route handler when the user accepts the consent. It validates that the provided session identity matches the consent request subject to prevent consent hijacking attacks.\n *\n * @param consentChallenge - The consent challenge from the form.\n *\n * @param options - Options for accepting the consent request.\n *\n * @returns The redirect URL to complete the OAuth2 flow.\n *\n * @example\n * ```tsx\n * // app/api/consent/route.ts\n * import { acceptConsentRequest, rejectConsentRequest, getServerSession } from \"@ory/nextjs/app\"\n * import { redirect } from \"next/navigation\"\n *\n * export async function POST(request: Request) {\n * const session = await getServerSession()\n * if (!session?.identity) {\n * return new Response(\"Unauthorized\", { status: 401 })\n * }\n *\n * const formData = await request.formData()\n * const action = formData.get(\"action\")\n * const consentChallenge = formData.get(\"consent_challenge\") as string\n * const grantScope = formData.getAll(\"grant_scope\") as string[]\n * const remember = formData.get(\"remember\") === \"true\"\n *\n * if (action === \"accept\") {\n * const redirectTo = await acceptConsentRequest(consentChallenge, {\n * grantScope,\n * remember,\n * identityId: session.identity.id,\n * })\n * return redirect(redirectTo)\n * } else {\n * const redirectTo = await rejectConsentRequest(consentChallenge, {\n * identityId: session.identity.id,\n * })\n * return redirect(redirectTo)\n * }\n * }\n * ```\n *\n * @throws\n *\n * Error if identityId doesn't match the consent request subject.\n *\n * @public\n */\n", "excerptTokens": [ { "kind": "Content", @@ -400,7 +400,7 @@ }, { "kind": "Content", - "text": "{\n grantScope: string[];\n remember?: boolean;\n rememberFor?: number;\n identityId?: string;\n session?: {\n accessToken?: " + "text": "{\n grantScope: string[];\n remember?: boolean;\n rememberFor?: number;\n identityId: string;\n session?: {\n accessToken?: " }, { "kind": "Reference", @@ -1641,11 +1641,11 @@ }, { "kind": "Content", - "text": ", options?: " + "text": ", options: " }, { "kind": "Content", - "text": "{\n error?: string;\n errorDescription?: string;\n identityId?: string;\n}" + "text": "{\n error?: string;\n errorDescription?: string;\n identityId: string;\n}" }, { "kind": "Content", @@ -1687,7 +1687,7 @@ "startIndex": 3, "endIndex": 4 }, - "isOptional": true + "isOptional": false } ], "name": "rejectConsentRequest" diff --git a/packages/nextjs/api-report/nextjs.api.md b/packages/nextjs/api-report/nextjs.api.md index 86d6d131a..57833db7e 100644 --- a/packages/nextjs/api-report/nextjs.api.md +++ b/packages/nextjs/api-report/nextjs.api.md @@ -26,7 +26,7 @@ export function acceptConsentRequest(consentChallenge: string, options: { grantScope: string[]; remember?: boolean; rememberFor?: number; - identityId?: string; + identityId: string; session?: { accessToken?: Record; idToken?: Record; @@ -114,10 +114,10 @@ export interface OryPageParams { } // @public -export function rejectConsentRequest(consentChallenge: string, options?: { +export function rejectConsentRequest(consentChallenge: string, options: { error?: string; errorDescription?: string; - identityId?: string; + identityId: string; }): Promise; // Warning: (ae-forgotten-export) The symbol "OryError" needs to be exported by the entry point api-extractor-type-index.d.ts diff --git a/packages/nextjs/src/app/consent.test.ts b/packages/nextjs/src/app/consent.test.ts index dc9b735f2..a59b0b2f7 100644 --- a/packages/nextjs/src/app/consent.test.ts +++ b/packages/nextjs/src/app/consent.test.ts @@ -81,11 +81,17 @@ describe("getConsentFlow", () => { describe("acceptConsentRequest", () => { const mockAcceptOAuth2ConsentRequest = jest.fn() + const mockGetOAuth2ConsentRequest = jest.fn() beforeEach(() => { jest.clearAllMocks() ;(serverSideOAuth2Client as jest.Mock).mockReturnValue({ acceptOAuth2ConsentRequest: mockAcceptOAuth2ConsentRequest, + getOAuth2ConsentRequest: mockGetOAuth2ConsentRequest, + }) + mockGetOAuth2ConsentRequest.mockResolvedValue({ + challenge: "test-challenge", + subject: "identity-1", }) }) @@ -96,6 +102,7 @@ describe("acceptConsentRequest", () => { const result = await acceptConsentRequest("test-challenge", { grantScope: ["openid", "profile"], + identityId: "identity-1", }) expect(result).toBe("https://example.com/callback") @@ -119,6 +126,7 @@ describe("acceptConsentRequest", () => { grantScope: ["openid"], remember: true, rememberFor: 3600, + identityId: "identity-1", }) expect(mockAcceptOAuth2ConsentRequest).toHaveBeenCalledWith({ @@ -139,6 +147,7 @@ describe("acceptConsentRequest", () => { await acceptConsentRequest("test-challenge", { grantScope: ["openid"], + identityId: "identity-1", session: { accessToken: { custom_claim: "value" }, idToken: { name: "Test User" }, @@ -158,15 +167,38 @@ describe("acceptConsentRequest", () => { }, }) }) + + it("should throw when identity does not match consent request subject", async () => { + mockGetOAuth2ConsentRequest.mockResolvedValue({ + challenge: "test-challenge", + subject: "identity-1", + }) + + await expect( + acceptConsentRequest("test-challenge", { + grantScope: ["openid"], + identityId: "identity-2", + }), + ).rejects.toThrow( + "Forbidden: Session identity does not match consent request subject", + ) + expect(mockAcceptOAuth2ConsentRequest).not.toHaveBeenCalled() + }) }) describe("rejectConsentRequest", () => { const mockRejectOAuth2ConsentRequest = jest.fn() + const mockGetOAuth2ConsentRequest = jest.fn() beforeEach(() => { jest.clearAllMocks() ;(serverSideOAuth2Client as jest.Mock).mockReturnValue({ rejectOAuth2ConsentRequest: mockRejectOAuth2ConsentRequest, + getOAuth2ConsentRequest: mockGetOAuth2ConsentRequest, + }) + mockGetOAuth2ConsentRequest.mockResolvedValue({ + challenge: "test-challenge", + subject: "identity-1", }) }) @@ -175,7 +207,9 @@ describe("rejectConsentRequest", () => { redirect_to: "https://example.com/error", }) - const result = await rejectConsentRequest("test-challenge") + const result = await rejectConsentRequest("test-challenge", { + identityId: "identity-1", + }) expect(result).toBe("https://example.com/error") expect(mockRejectOAuth2ConsentRequest).toHaveBeenCalledWith({ @@ -195,6 +229,7 @@ describe("rejectConsentRequest", () => { await rejectConsentRequest("test-challenge", { error: "invalid_scope", errorDescription: "The requested scope is invalid", + identityId: "identity-1", }) expect(mockRejectOAuth2ConsentRequest).toHaveBeenCalledWith({ @@ -205,4 +240,13 @@ describe("rejectConsentRequest", () => { }, }) }) + + it("should throw when identity does not match consent request subject", async () => { + await expect( + rejectConsentRequest("test-challenge", { identityId: "identity-2" }), + ).rejects.toThrow( + "Forbidden: Session identity does not match consent request subject", + ) + expect(mockRejectOAuth2ConsentRequest).not.toHaveBeenCalled() + }) }) diff --git a/packages/nextjs/src/app/consent.ts b/packages/nextjs/src/app/consent.ts index 9e08d40f1..e760b3a85 100644 --- a/packages/nextjs/src/app/consent.ts +++ b/packages/nextjs/src/app/consent.ts @@ -1,11 +1,27 @@ // Copyright © 2026 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { OAuth2ConsentRequest } from "@ory/client-fetch" +import { OAuth2Api, OAuth2ConsentRequest } from "@ory/client-fetch" import { QueryParams } from "../types" import { serverSideOAuth2Client } from "./client" +async function assertConsentSubject( + oauth2Client: OAuth2Api, + consentChallenge: string, + identityId: string, +): Promise { + const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ + consentChallenge, + }) + + if (consentRequest.subject !== identityId) { + throw new Error( + "Forbidden: Session identity does not match consent request subject", + ) + } +} + /** * Use this method in an app router page to fetch an OAuth2 consent request. * This method works with server-side rendering. @@ -77,7 +93,7 @@ export async function getConsentFlow( * * export async function POST(request: Request) { * const session = await getServerSession() - * if (!session) { + * if (!session?.identity) { * return new Response("Unauthorized", { status: 401 }) * } * @@ -91,12 +107,12 @@ export async function getConsentFlow( * const redirectTo = await acceptConsentRequest(consentChallenge, { * grantScope, * remember, - * identityId: session.identity?.id, + * identityId: session.identity.id, * }) * return redirect(redirectTo) * } else { * const redirectTo = await rejectConsentRequest(consentChallenge, { - * identityId: session.identity?.id, + * identityId: session.identity.id, * }) * return redirect(redirectTo) * } @@ -115,7 +131,7 @@ export async function acceptConsentRequest( grantScope: string[] remember?: boolean rememberFor?: number - identityId?: string + identityId: string session?: { accessToken?: Record idToken?: Record @@ -125,17 +141,7 @@ export async function acceptConsentRequest( const oauth2Client = serverSideOAuth2Client() // Security: Verify session identity matches consent request subject - if (options.identityId) { - const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ - consentChallenge, - }) - - if (consentRequest.subject !== options.identityId) { - throw new Error( - "Forbidden: Session identity does not match consent request subject", - ) - } - } + await assertConsentSubject(oauth2Client, consentChallenge, options.identityId) const response = await oauth2Client.acceptOAuth2ConsentRequest({ consentChallenge, @@ -170,33 +176,23 @@ export async function acceptConsentRequest( */ export async function rejectConsentRequest( consentChallenge: string, - options?: { + options: { error?: string errorDescription?: string - identityId?: string + identityId: string }, ): Promise { const oauth2Client = serverSideOAuth2Client() // Security: Verify session identity matches consent request subject - if (options?.identityId) { - const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ - consentChallenge, - }) - - if (consentRequest.subject !== options.identityId) { - throw new Error( - "Forbidden: Session identity does not match consent request subject", - ) - } - } + await assertConsentSubject(oauth2Client, consentChallenge, options.identityId) const response = await oauth2Client.rejectOAuth2ConsentRequest({ consentChallenge, rejectOAuth2Request: { - error: options?.error ?? "access_denied", + error: options.error ?? "access_denied", error_description: - options?.errorDescription ?? "The resource owner denied the request", + options.errorDescription ?? "The resource owner denied the request", }, }) diff --git a/packages/nextjs/src/utils/rewrite.test.ts b/packages/nextjs/src/utils/rewrite.test.ts index 91fa9abfb..0d10dcf7e 100644 --- a/packages/nextjs/src/utils/rewrite.test.ts +++ b/packages/nextjs/src/utils/rewrite.test.ts @@ -44,6 +44,22 @@ describe("rewriteUrls", () => { expect(result).toBe("https://self.com/some/path") }) + it("should not rewrite hostnames that only share the base URL prefix", () => { + const source = "https://example.com.evil.com/some/path" + const matchBaseUrl = "https://example.com" + const selfUrl = "https://self.com" + const result = rewriteUrls(source, matchBaseUrl, selfUrl, config) + expect(result).toBe("https://example.com.evil.com/some/path") + }) + + it("should not apply UI overrides to paths sharing a prefix segment", () => { + const source = "https://example.com/login-methods" + const matchBaseUrl = "https://example.com" + const selfUrl = "https://self.com" + const result = rewriteUrls(source, matchBaseUrl, selfUrl, config) + expect(result).toBe("https://self.com/login-methods") + }) + it("should NOT rewrite OAuth2 paths", () => { const matchBaseUrl = "https://example.com" const selfUrl = "https://self.com" diff --git a/packages/nextjs/src/utils/rewrite.ts b/packages/nextjs/src/utils/rewrite.ts index 481b301b7..0f82902a8 100644 --- a/packages/nextjs/src/utils/rewrite.ts +++ b/packages/nextjs/src/utils/rewrite.ts @@ -39,21 +39,22 @@ export function rewriteUrls( const baseUrlNormalized = matchBaseUrl.replace(/\/$/, "") const selfUrlNormalized = new URL(selfUrl).toString().replace(/\/$/, "") - // Single-pass replacement for all Ory URLs + // Single-pass replacement for all Ory URLs. The lookahead enforces a domain + // boundary so longer hostnames sharing the prefix are left untouched. const regex = new RegExp( - escapeRegExp(baseUrlNormalized) + "(/[^\"'\\s]*)?", + escapeRegExp(baseUrlNormalized) + "(?=[/?#\\s\"']|$)(/[^\"'\\s]*)?", "g", ) return source.replace(regex, (match: string, path: string | undefined) => { // OAuth2 paths must stay on Ory's domain - if (path && oauth2Paths.some((p) => path.startsWith(p))) { + if (path && oauth2Paths.some((p) => pathStartsWithSegment(path, p))) { return match } // Check for UI path overrides from config for (const [uiPath, configUrl] of Object.entries(uiPathMappings)) { - if (path && configUrl && path.startsWith(uiPath)) { + if (path && configUrl && pathStartsWithSegment(path, uiPath)) { return path.replace(uiPath, new URL(configUrl, selfUrl).toString()) } } @@ -63,6 +64,19 @@ export function rewriteUrls( }) } +// Prefix match on whole path segments only, so "/login" does not match +// "/login-methods". +function pathStartsWithSegment(path: string, prefix: string) { + if (!path.startsWith(prefix)) { + return false + } + if (prefix.endsWith("/")) { + return true + } + const nextChar = path[prefix.length] + return !nextChar || nextChar === "/" || nextChar === "?" || nextChar === "#" +} + function escapeRegExp(string: string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } From 9fb70dbece57d006bcf0aed98af4d29f80ba7524 Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Tue, 21 Jul 2026 00:18:41 +0200 Subject: [PATCH 5/5] fix(examples): return the csrf-armor processed middleware response Return result.response from csrfProtect instead of the pre-protection response. The current adapter mutates the passed response in place, but the processed response is the documented contract and stays correct if that implementation detail changes. --- examples/nextjs-app-router-custom-components/middleware.ts | 2 +- examples/nextjs-app-router/middleware.ts | 2 +- examples/nextjs-pages-router/middleware.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/nextjs-app-router-custom-components/middleware.ts b/examples/nextjs-app-router-custom-components/middleware.ts index 2df7dca5d..f683a200d 100644 --- a/examples/nextjs-app-router-custom-components/middleware.ts +++ b/examples/nextjs-app-router-custom-components/middleware.ts @@ -47,7 +47,7 @@ export async function middleware(request: NextRequest) { { status: 403 }, ) } - return response + return result.response } export const config = {} diff --git a/examples/nextjs-app-router/middleware.ts b/examples/nextjs-app-router/middleware.ts index 2df7dca5d..f683a200d 100644 --- a/examples/nextjs-app-router/middleware.ts +++ b/examples/nextjs-app-router/middleware.ts @@ -47,7 +47,7 @@ export async function middleware(request: NextRequest) { { status: 403 }, ) } - return response + return result.response } export const config = {} diff --git a/examples/nextjs-pages-router/middleware.ts b/examples/nextjs-pages-router/middleware.ts index 864d304b4..dee8c290b 100644 --- a/examples/nextjs-pages-router/middleware.ts +++ b/examples/nextjs-pages-router/middleware.ts @@ -47,7 +47,7 @@ export async function middleware(request: NextRequest) { { status: 403 }, ) } - return response + return result.response } export const config = {}