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/.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..f683a200d 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 result.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..f683a200d 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 result.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..dee8c290b 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 result.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/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 ( ;\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": false + } + ], + "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..57833db7e 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..a59b0b2f7 --- /dev/null +++ b/packages/nextjs/src/app/consent.test.ts @@ -0,0 +1,252 @@ +// 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() + const mockGetOAuth2ConsentRequest = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(serverSideOAuth2Client as jest.Mock).mockReturnValue({ + acceptOAuth2ConsentRequest: mockAcceptOAuth2ConsentRequest, + getOAuth2ConsentRequest: mockGetOAuth2ConsentRequest, + }) + mockGetOAuth2ConsentRequest.mockResolvedValue({ + challenge: "test-challenge", + subject: "identity-1", + }) + }) + + 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"], + identityId: "identity-1", + }) + + 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, + identityId: "identity-1", + }) + + 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"], + identityId: "identity-1", + 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" }, + }, + }, + }) + }) + + 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", + }) + }) + + it("should reject consent with default error", async () => { + mockRejectOAuth2ConsentRequest.mockResolvedValue({ + redirect_to: "https://example.com/error", + }) + + const result = await rejectConsentRequest("test-challenge", { + identityId: "identity-1", + }) + + 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", + identityId: "identity-1", + }) + + expect(mockRejectOAuth2ConsentRequest).toHaveBeenCalledWith({ + consentChallenge: "test-challenge", + rejectOAuth2Request: { + error: "invalid_scope", + error_description: "The requested scope is invalid", + }, + }) + }) + + 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 new file mode 100644 index 000000000..e760b3a85 --- /dev/null +++ b/packages/nextjs/src/app/consent.ts @@ -0,0 +1,200 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +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. + * + * 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?.identity) { + * 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 + await assertConsentSubject(oauth2Client, consentChallenge, options.identityId) + + 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 + await assertConsentSubject(oauth2Client, consentChallenge, options.identityId) + + 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..0d10dcf7e 100644 --- a/packages/nextjs/src/utils/rewrite.test.ts +++ b/packages/nextjs/src/utils/rewrite.test.ts @@ -43,6 +43,52 @@ describe("rewriteUrls", () => { const result = rewriteUrls(source, matchBaseUrl, selfUrl, config) 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" + + 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 +152,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..0f82902a8 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,75 @@ 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. The lookahead enforces a domain + // boundary so longer hostnames sharing the prefix are left untouched. + const regex = new RegExp( + 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) => pathStartsWithSegment(path, p))) { + return match + } + + // Check for UI path overrides from config + for (const [uiPath, configUrl] of Object.entries(uiPathMappings)) { + if (path && configUrl && pathStartsWithSegment(path, uiPath)) { + return path.replace(uiPath, new URL(configUrl, selfUrl).toString()) + } + } + + // Default: rewrite to app's URL + return selfUrlNormalized + (path || "") + }) +} + +// 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, "\\$&") } /** @@ -55,6 +93,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) 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