From d0f3a1b3098757ebb7c060d32e33534bb34ae49c Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Fri, 26 Dec 2025 19:37:48 +0100 Subject: [PATCH 1/4] feat(oauth2): add consent flow support with client info display - Add getConsentFlow, acceptConsentRequest, rejectConsentRequest in @ory/nextjs - Add consent page and API routes for app-router, pages-router, custom-components - Display OAuth2 client logo and subtitle on login/registration cards - Add ConsentFooter and custom scope checkbox for custom-components example - Export getConsentNodeKey, isFooterNode, isUiNodeInput, UiNodeInput utilities - Optimize rewriteUrls to single-pass replacement with OAuth2 path exclusion - Add null/undefined handling in rewriteJsonResponse - Add unit tests for consent utilities, card-consent functions, and rewrite --- .gitignore | 2 +- .../app/api/consent/route.ts | 81 +++++ .../app/auth/consent/page.tsx | 28 ++ .../components/consent-utils.ts | 27 ++ .../custom-consent-scope-checkbox.tsx | 68 ++++ .../components/custom-footer.tsx | 35 +- .../components/index.tsx | 2 + .../app/api/consent/route.ts | 81 +++++ .../app/auth/consent/page.tsx | 29 ++ .../nextjs-pages-router/pages/api/consent.ts | 84 +++++ .../pages/auth/consent.tsx | 32 ++ .../src/components/card/card-consent.test.ts | 189 +++++++++++ .../src/components/card/card-consent.tsx | 48 ++- .../src/components/form/nodes/input.tsx | 10 +- .../src/components/form/nodes/node-button.tsx | 3 - .../theme/default/components/card/header.tsx | 41 ++- .../default/utils/constructCardHeader.ts | 57 ++-- .../src/theme/default/utils/oauth2.ts | 2 +- .../nextjs/api-report/nextjs-client.api.json | 311 ++++++++++++++++++ packages/nextjs/api-report/nextjs.api.md | 31 ++ packages/nextjs/src/app/client.ts | 19 +- packages/nextjs/src/app/consent.test.ts | 208 ++++++++++++ packages/nextjs/src/app/consent.ts | 159 +++++++++ packages/nextjs/src/app/index.ts | 5 + packages/nextjs/src/pages/client.ts | 15 +- packages/nextjs/src/pages/consent.ts | 76 +++++ packages/nextjs/src/pages/index.ts | 2 + packages/nextjs/src/pages/session.ts | 58 ++++ packages/nextjs/src/utils/rewrite.test.ts | 40 +++ packages/nextjs/src/utils/rewrite.ts | 80 +++-- 30 files changed, 1756 insertions(+), 67 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/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/page.tsx create mode 100644 examples/nextjs-pages-router/pages/api/consent.ts create mode 100644 examples/nextjs-pages-router/pages/auth/consent.tsx create mode 100644 packages/elements-react/src/components/card/card-consent.test.ts 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/.gitignore b/.gitignore index dda6d7ace..95c6e2c64 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ storybook-static pnpm-lock.yaml /test-results/ .nx -coverage/ \ No newline at end of file +coverage/ 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..840086a4e --- /dev/null +++ b/examples/nextjs-app-router-custom-components/app/api/consent/route.ts @@ -0,0 +1,81 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { acceptConsentRequest, rejectConsentRequest } from "@ory/nextjs/app" +import { NextResponse } from "next/server" + +interface ConsentBody { + action?: string + consent_challenge?: string + grant_scope?: string | string[] + remember?: boolean | 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, + } + } + + // Try JSON as fallback + try { + return (await request.json()) as ConsentBody + } catch { + return {} + } +} + +export async function POST(request: Request) { + const body = await parseRequest(request) + + 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, + }) + } else { + redirectTo = await rejectConsentRequest(consentChallenge) + } + + return NextResponse.json({ redirect_to: redirectTo }) + } catch (error) { + console.error("Consent error:", error) + 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/page.tsx b/examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx new file mode 100644 index 000000000..0e4ae22bb --- /dev/null +++ b/examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx @@ -0,0 +1,28 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Consent } from "@ory/elements-react/theme" +import { getConsentFlow, getServerSession, OryPageParams } from "@ory/nextjs/app" + +import { myCustomComponents } from "@/components" +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 ( + + ) +} \ No newline at end of file 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..336f56b9a --- /dev/null +++ b/examples/nextjs-app-router-custom-components/components/consent-utils.ts @@ -0,0 +1,27 @@ +// Copyright © 2024 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 } +} \ No newline at end of file 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..a38eaca4f --- /dev/null +++ b/examples/nextjs-app-router-custom-components/components/custom-consent-scope-checkbox.tsx @@ -0,0 +1,68 @@ +// Copyright © 2024 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 ( + + ) +} \ No newline at end of file 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 132c09b39..cb990d390 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-footer.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-footer.tsx @@ -3,8 +3,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison -- eslint gets confused because of different versions of @ory/client-fetch */ 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() @@ -32,8 +33,38 @@ 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/app/api/consent/route.ts b/examples/nextjs-app-router/app/api/consent/route.ts new file mode 100644 index 000000000..395bd2217 --- /dev/null +++ b/examples/nextjs-app-router/app/api/consent/route.ts @@ -0,0 +1,81 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { acceptConsentRequest, rejectConsentRequest } from "@ory/nextjs/app" +import { NextResponse } from "next/server" + +interface ConsentBody { + action?: string + consent_challenge?: string + grant_scope?: string | string[] + remember?: boolean | 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, + } + } + + // Try JSON as fallback + try { + return (await request.json()) as ConsentBody + } catch { + return {} + } +} + +export async function POST(request: Request) { + const body = await parseRequest(request) + + 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, + }) + } else { + redirectTo = await rejectConsentRequest(consentChallenge) + } + + return NextResponse.json({ redirect_to: redirectTo }) + } catch (error) { + console.error("Consent error:", error) + return NextResponse.json( + { error: "server_error", error_description: "Failed to process consent" }, + { status: 500 }, + ) + } +} \ No newline at end of file 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..2e8391771 --- /dev/null +++ b/examples/nextjs-app-router/app/auth/consent/page.tsx @@ -0,0 +1,29 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +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 ( + + ) +} \ No newline at end of file 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..6e237218c --- /dev/null +++ b/examples/nextjs-pages-router/pages/api/consent.ts @@ -0,0 +1,84 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Configuration, OAuth2Api } from "@ory/client-fetch" +import type { NextApiRequest, NextApiResponse } from "next" + +function getOAuth2Client() { + 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") + } + + const apiKey = process.env.ORY_PROJECT_API_TOKEN ?? "" + + return new OAuth2Api( + new Configuration({ + basePath: baseUrl.replace(/\/$/, ""), + headers: { + Accept: "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + }), + ) +} + +interface ConsentRequestBody { + action?: string + consent_challenge?: string + grant_scope?: string | string[] + remember?: string | boolean +} + +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" }) + } + + const oauth2Client = getOAuth2Client() + + try { + 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..c093d9b34 --- /dev/null +++ b/examples/nextjs-pages-router/pages/auth/consent.tsx @@ -0,0 +1,32 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 +"use client" +import { Consent } from "@ory/elements-react/theme" +import "@ory/elements-react/theme/styles.css" +import { useConsentFlow, useSession } from "@ory/nextjs/pages" + +import config from "@/ory.config" + +export default function ConsentPage() { + const consentRequest = useConsentFlow() + const { session, loading } = useSession() + + if (!consentRequest || loading || !session) { + return null + } + + return ( +
+ +
+ ) +} \ No newline at end of file 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..58ae5d365 --- /dev/null +++ b/packages/elements-react/src/components/card/card-consent.test.ts @@ -0,0 +1,189 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { + UiNode, + UiNodeInputAttributesTypeEnum, + UiNodeTextAttributes, +} 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" }, + } as UiNodeTextAttributes, + 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" }, + } as UiNodeTextAttributes, + messages: [], + meta: {}, + } + + expect(isFooterNode(node)).toBe(false) + }) +}) \ No newline at end of file diff --git a/packages/elements-react/src/components/card/card-consent.tsx b/packages/elements-react/src/components/card/card-consent.tsx index baee931c2..922f82992 100644 --- a/packages/elements-react/src/components/card/card-consent.tsx +++ b/packages/elements-react/src/components/card/card-consent.tsx @@ -8,7 +8,45 @@ import { OryCard } from "./card" import { OryCardContent } from "./content" import { OryCardFooter } from "./footer" import { OryCardHeader } from "./header" -import { getNodeId } from "@ory/client-fetch" +import { + getNodeId, + UiNode, + isUiNodeInputAttributes, + 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 +64,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 5a27f6044..c7220ec92 100644 --- a/packages/elements-react/src/components/form/nodes/input.tsx +++ b/packages/elements-react/src/components/form/nodes/input.tsx @@ -74,14 +74,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/components/card/header.tsx b/packages/elements-react/src/theme/default/components/card/header.tsx index e523eedd0..c498fbde0 100644 --- a/packages/elements-react/src/theme/default/components/card/header.tsx +++ b/packages/elements-react/src/theme/default/components/card/header.tsx @@ -1,23 +1,54 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 +import { FlowType, LoginFlow, RegistrationFlow } from "@ory/client-fetch" import { messageTestId, useComponents, useOryFlow } from "@ory/elements-react" import { useCardHeaderText } from "../../utils/constructCardHeader" import { DefaultCurrentIdentifierButton } from "./current-identifier-button" +/** + * Extracts OAuth2 client info from login or registration flows. + */ +function getOAuth2ClientInfo(flow: unknown, flowType: FlowType) { + if (flowType === FlowType.Login || flowType === FlowType.Registration) { + const typedFlow = flow as LoginFlow | RegistrationFlow + const client = typedFlow.oauth2_login_request?.client + if (client) { + return { + clientName: client.client_name, + logoUri: client.logo_uri, + } + } + } + return null +} + function InnerCardHeader({ title, text, messageId, + oauth2Client, }: { title: string text?: string messageId?: string + oauth2Client?: { clientName?: string; logoUri?: string } | null }) { const { Card } = useComponents() return (
- + {oauth2Client?.logoUri ? ( +
+ {oauth2Client.clientName + +
+ ) : ( + + )}

{title} @@ -49,8 +80,14 @@ export function DefaultCardHeader() { context.flow.ui, context, ) + const oauth2Client = getOAuth2ClientInfo(context.flow, context.flowType) return ( - + ) } diff --git a/packages/elements-react/src/theme/default/utils/constructCardHeader.ts b/packages/elements-react/src/theme/default/utils/constructCardHeader.ts index ff34cf5cb..0847e78c7 100644 --- a/packages/elements-react/src/theme/default/utils/constructCardHeader.ts +++ b/packages/elements-react/src/theme/default/utils/constructCardHeader.ts @@ -32,11 +32,23 @@ export type CardHeaderTextOptions = flow: { refresh?: boolean requested_aal?: AuthenticatorAssuranceLevel + oauth2_login_request?: { + client?: { + client_name?: string + } + } } formState?: FormState } | { flowType: FlowType.Registration + flow?: { + oauth2_login_request?: { + client?: { + client_name?: string + } + } + } formState?: FormState } | { @@ -313,12 +325,15 @@ export function useCardHeaderText( }), } } + const clientName = opts.flow.oauth2_login_request?.client?.client_name return { - title: intl.formatMessage({ - id: "login.title", - }), - description: - parts.length > 0 + title: intl.formatMessage({ id: "login.title" }), + description: clientName + ? intl.formatMessage( + { id: "login.subtitle-oauth2" }, + { clientName }, + ) + : parts.length > 0 ? intl.formatMessage( { id: codeSent @@ -346,25 +361,29 @@ export function useCardHeaderText( codeMethodNode && opts.formState?.current === "method_active" && opts.formState?.method === "code" + const clientName = opts.flow?.oauth2_login_request?.client?.client_name return { - title: intl.formatMessage({ - id: "registration.title", - }), + title: intl.formatMessage({ id: "registration.title" }), description: codeSent ? intl.formatMessage({ id: "identities.messages.1040005" }) - : parts.length > 0 + : clientName ? intl.formatMessage( - { - id: "registration.subtitle", - }, - { - parts: joinWithCommaOr( - parts, - intl.formatMessage({ id: "misc.or" }), - ), - }, + { id: "registration.subtitle-oauth2" }, + { clientName }, ) - : "", + : parts.length > 0 + ? intl.formatMessage( + { + id: "registration.subtitle", + }, + { + parts: joinWithCommaOr( + parts, + intl.formatMessage({ id: "misc.or" }), + ), + }, + ) + : "", } } case FlowType.OAuth2Consent: diff --git a/packages/elements-react/src/theme/default/utils/oauth2.ts b/packages/elements-react/src/theme/default/utils/oauth2.ts index 4a1012ddd..a5e445762 100644 --- a/packages/elements-react/src/theme/default/utils/oauth2.ts +++ b/packages/elements-react/src/theme/default/utils/oauth2.ts @@ -114,7 +114,7 @@ function scopesToUiNodes(scopes: string[]): UiNode[] { }, attributes: { node_type: "input", - name: `grant_scope`, + name: "grant_scope", value: scope, type: "checkbox", disabled: false, diff --git a/packages/nextjs/api-report/nextjs-client.api.json b/packages/nextjs/api-report/nextjs-client.api.json index 842273afd..bb5574a10 100644 --- a/packages/nextjs/api-report/nextjs-client.api.json +++ b/packages/nextjs/api-report/nextjs-client.api.json @@ -361,6 +361,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.\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 } from \"@ory/nextjs/app\"\n * import { redirect } from \"next/navigation\"\n *\n * export async function POST(request: Request) {\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 * session: { ... }\n * })\n * return redirect(redirectTo)\n * } else {\n * const redirectTo = await rejectConsentRequest(consentChallenge)\n * return redirect(redirectTo)\n * }\n * }\n * ```\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 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)", @@ -434,6 +518,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!getFlowFactory:function(1)", @@ -1290,6 +1456,105 @@ ], "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.\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 * @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}" + }, + { + "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!useConsentFlow:function(1)", + "docComment": "/**\n * A client-side hook to fetch an OAuth2 consent request.\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 * @returns The OAuth2 consent request or null/undefined.\n *\n * @example\n * ```tsx\n * import { Consent } from \"@ory/elements-react/theme\"\n * import { useConsentFlow, useSession } from \"@ory/nextjs/pages\"\n *\n * import config from \"@/ory.config\"\n *\n * export default function ConsentPage() {\n * const consentRequest = useConsentFlow()\n * const { session, loading } = useSession()\n *\n * if (!consentRequest || loading || !session) {\n * return null\n * }\n *\n * return (\n * \n * )\n * }\n * ```\n *\n * @group\n *\n * Hooks\n *\n * @public @function\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function useConsentFlow(): " + }, + { + "kind": "Reference", + "text": "OAuth2ConsentRequest", + "canonicalReference": "@ory/client-fetch!OAuth2ConsentRequest:interface" + }, + { + "kind": "Content", + "text": " | null | undefined" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/pages/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 3 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [], + "name": "useConsentFlow" + }, { "kind": "Function", "canonicalReference": "@ory/nextjs!useLoginFlow:function(1)", @@ -1422,6 +1687,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 c7a545e69..bb25f8368 100644 --- a/packages/nextjs/api-report/nextjs.api.md +++ b/packages/nextjs/api-report/nextjs.api.md @@ -11,6 +11,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 { RecoveryFlow } from '@ory/client-fetch'; import { RegistrationFlow } from '@ory/client-fetch'; @@ -18,11 +19,25 @@ 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; + 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 getFlowFactory(params: QueryParams, fetchFlowRaw: () => Promise>, flowType: FlowType, baseUrl: string, route: string, options?: { disableRewrite?: boolean; @@ -86,6 +101,15 @@ export interface OryPageParams { }>; } +// @public +export function rejectConsentRequest(consentChallenge: string, options?: { + error?: string; + errorDescription?: string; +}): Promise; + +// @public +export function useConsentFlow(): OAuth2ConsentRequest | null | undefined; + // @public export const useLoginFlow: () => void | _ory_client_fetch.LoginFlow | null; @@ -98,6 +122,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..0b13f4a72 --- /dev/null +++ b/packages/nextjs/src/app/consent.test.ts @@ -0,0 +1,208 @@ +// Copyright © 2024 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..8dc00f2f9 --- /dev/null +++ b/packages/nextjs/src/app/consent.ts @@ -0,0 +1,159 @@ +// Copyright © 2024 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. + * + * @example + * ```tsx + * // app/api/consent/route.ts + * import { acceptConsentRequest, rejectConsentRequest } from "@ory/nextjs/app" + * import { redirect } from "next/navigation" + * + * export async function POST(request: Request) { + * 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, + * session: { ... } + * }) + * return redirect(redirectTo) + * } else { + * const redirectTo = await rejectConsentRequest(consentChallenge) + * 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. + * @public + */ +export async function acceptConsentRequest( + consentChallenge: string, + options: { + grantScope: string[] + remember?: boolean + rememberFor?: number + session?: { + accessToken?: Record + idToken?: Record + } + }, +): Promise { + const response = await serverSideOAuth2Client().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. + * + * @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. + * @public + */ +export async function rejectConsentRequest( + consentChallenge: string, + options?: { + error?: string + errorDescription?: string + }, +): Promise { + const response = await serverSideOAuth2Client().rejectOAuth2ConsentRequest({ + consentChallenge, + rejectOAuth2Request: { + error: options?.error ?? "access_denied", + error_description: + options?.errorDescription ?? "The resource owner denied the request", + }, + }) + + return response.redirect_to +} \ No newline at end of file diff --git a/packages/nextjs/src/app/index.ts b/packages/nextjs/src/app/index.ts index fab3ba44d..7b275465a 100644 --- a/packages/nextjs/src/app/index.ts +++ b/packages/nextjs/src/app/index.ts @@ -10,5 +10,10 @@ export { getSettingsFlow } from "./settings" export { getLogoutFlow } from "./logout" export { getServerSession } from "./session" export { getFlowFactory } from "./flow" +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..c207bb3c7 --- /dev/null +++ b/packages/nextjs/src/pages/consent.ts @@ -0,0 +1,76 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { OAuth2ConsentRequest } from "@ory/client-fetch" +import { useEffect, useState } from "react" +import { useRouter } from "next/router" +import { useSearchParams } from "next/navigation" +import { clientSideOAuth2Client } from "./client" + +/** + * A client-side hook to fetch an OAuth2 consent request. + * + * 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 { useConsentFlow, useSession } from "@ory/nextjs/pages" + * + * import config from "@/ory.config" + * + * export default function ConsentPage() { + * const consentRequest = useConsentFlow() + * const { session, loading } = useSession() + * + * if (!consentRequest || loading || !session) { + * return null + * } + * + * return ( + * + * ) + * } + * ``` + * + * @returns The OAuth2 consent request or null/undefined. + * @public + * @function + * @group Hooks + */ +export function useConsentFlow(): OAuth2ConsentRequest | null | undefined { + const [consentRequest, setConsentRequest] = useState() + const router = useRouter() + const searchParams = useSearchParams() + + useEffect(() => { + if (!router.isReady || consentRequest !== undefined) { + return + } + + const consentChallenge = searchParams.get("consent_challenge") + + if (!consentChallenge) { + return + } + + clientSideOAuth2Client() + .getOAuth2ConsentRequest({ consentChallenge }) + .then(setConsentRequest) + .catch(() => { + // Silent failure - no consent request available + }) + }, [searchParams, router, router.isReady, consentRequest]) + + return consentRequest +} \ No newline at end of file diff --git a/packages/nextjs/src/pages/index.ts b/packages/nextjs/src/pages/index.ts index a82a2ce59..f879a5fcc 100644 --- a/packages/nextjs/src/pages/index.ts +++ b/packages/nextjs/src/pages/index.ts @@ -8,3 +8,5 @@ export { useRecoveryFlow } from "./recovery" export { useLoginFlow } from "./login" export { useSettingsFlow } from "./settings" export { useLogoutFlow } from "./logout" +export { useConsentFlow } 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..df87e3caa --- /dev/null +++ b/packages/nextjs/src/pages/session.ts @@ -0,0 +1,58 @@ +// Copyright © 2024 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) + }) + .catch((err) => { + setError(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..038f3b260 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, path) => { + // 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 f5bd1944d863df6eeaddf13242825c02ce58917a Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Mon, 5 Jan 2026 18:56:44 +0100 Subject: [PATCH 2/4] feat(oauth2): display OAuth2 client logo on login, registration, and consent flows Add shared utility getConfigWithOAuth2Logo to override project logo with OAuth2 client logo when available. Apply to Login, Registration, and Consent flows to display the OAuth2 client's logo during OAuth2-initiated flows. --- .../theme/default/components/card/header.tsx | 41 +------------ .../src/theme/default/flows/consent.tsx | 8 ++- .../src/theme/default/flows/login.tsx | 8 ++- .../src/theme/default/flows/registration.tsx | 8 ++- .../default/utils/constructCardHeader.ts | 57 +++++++------------ .../src/theme/default/utils/oauth2-config.ts | 28 +++++++++ 6 files changed, 70 insertions(+), 80 deletions(-) create mode 100644 packages/elements-react/src/theme/default/utils/oauth2-config.ts diff --git a/packages/elements-react/src/theme/default/components/card/header.tsx b/packages/elements-react/src/theme/default/components/card/header.tsx index c498fbde0..e523eedd0 100644 --- a/packages/elements-react/src/theme/default/components/card/header.tsx +++ b/packages/elements-react/src/theme/default/components/card/header.tsx @@ -1,54 +1,23 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { FlowType, LoginFlow, RegistrationFlow } from "@ory/client-fetch" import { messageTestId, useComponents, useOryFlow } from "@ory/elements-react" import { useCardHeaderText } from "../../utils/constructCardHeader" import { DefaultCurrentIdentifierButton } from "./current-identifier-button" -/** - * Extracts OAuth2 client info from login or registration flows. - */ -function getOAuth2ClientInfo(flow: unknown, flowType: FlowType) { - if (flowType === FlowType.Login || flowType === FlowType.Registration) { - const typedFlow = flow as LoginFlow | RegistrationFlow - const client = typedFlow.oauth2_login_request?.client - if (client) { - return { - clientName: client.client_name, - logoUri: client.logo_uri, - } - } - } - return null -} - function InnerCardHeader({ title, text, messageId, - oauth2Client, }: { title: string text?: string messageId?: string - oauth2Client?: { clientName?: string; logoUri?: string } | null }) { const { Card } = useComponents() return (
- {oauth2Client?.logoUri ? ( -
- {oauth2Client.clientName - -
- ) : ( - - )} +

{title} @@ -80,14 +49,8 @@ export function DefaultCardHeader() { context.flow.ui, context, ) - const oauth2Client = getOAuth2ClientInfo(context.flow, context.flowType) return ( - + ) } diff --git a/packages/elements-react/src/theme/default/flows/consent.tsx b/packages/elements-react/src/theme/default/flows/consent.tsx index e77feb250..ba862d6a5 100644 --- a/packages/elements-react/src/theme/default/flows/consent.tsx +++ b/packages/elements-react/src/theme/default/flows/consent.tsx @@ -9,6 +9,7 @@ import { OryProvider, } from "@ory/elements-react" import { getOryComponents } from "../components" +import { getConfigWithOAuth2Logo } from "../utils/oauth2-config" import { translateConsentChallengeToUiNodes } from "../utils/oauth2" /** @@ -98,9 +99,14 @@ export function Consent({ session, ) + const configWithLogo = getConfigWithOAuth2Logo( + config, + consentChallenge.client?.logo_uri, + ) + return ( 0 + title: intl.formatMessage({ + id: "login.title", + }), + description: + parts.length > 0 ? intl.formatMessage( { id: codeSent @@ -361,29 +346,25 @@ export function useCardHeaderText( codeMethodNode && opts.formState?.current === "method_active" && opts.formState?.method === "code" - const clientName = opts.flow?.oauth2_login_request?.client?.client_name return { - title: intl.formatMessage({ id: "registration.title" }), + title: intl.formatMessage({ + id: "registration.title", + }), description: codeSent ? intl.formatMessage({ id: "identities.messages.1040005" }) - : clientName + : parts.length > 0 ? intl.formatMessage( - { id: "registration.subtitle-oauth2" }, - { clientName }, + { + id: "registration.subtitle", + }, + { + parts: joinWithCommaOr( + parts, + intl.formatMessage({ id: "misc.or" }), + ), + }, ) - : parts.length > 0 - ? intl.formatMessage( - { - id: "registration.subtitle", - }, - { - parts: joinWithCommaOr( - parts, - intl.formatMessage({ id: "misc.or" }), - ), - }, - ) - : "", + : "", } } case FlowType.OAuth2Consent: diff --git a/packages/elements-react/src/theme/default/utils/oauth2-config.ts b/packages/elements-react/src/theme/default/utils/oauth2-config.ts new file mode 100644 index 000000000..61abc0787 --- /dev/null +++ b/packages/elements-react/src/theme/default/utils/oauth2-config.ts @@ -0,0 +1,28 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { OryClientConfiguration } from "@ory/elements-react" + +/** + * Returns a config with the logo overridden by the OAuth2 client logo if available. + * + * @param config - The original Ory client configuration + * @param logoUrl - Optional OAuth2 client logo URL to override the default + * @returns The config with logo_light_url overridden if logoUrl is provided + */ +export function getConfigWithOAuth2Logo( + config: OryClientConfiguration, + logoUrl: string | undefined, +): OryClientConfiguration { + if (!logoUrl) { + return config + } + + return { + ...config, + project: { + ...config.project, + logo_light_url: logoUrl, + }, + } +} \ No newline at end of file From a7d773e55ed8835a1e527ba9cd33d2c9b99563a0 Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Wed, 7 Jan 2026 14:27:36 +0100 Subject: [PATCH 3/4] chore: run format --- .../app/api/consent/route.ts | 5 ++++- .../app/auth/consent/page.tsx | 8 ++++++-- .../components/consent-utils.ts | 2 +- .../components/custom-consent-scope-checkbox.tsx | 2 +- .../components/custom-footer.tsx | 6 ++++-- examples/nextjs-app-router/app/api/consent/route.ts | 7 +++++-- examples/nextjs-app-router/app/auth/consent/page.tsx | 8 ++++++-- examples/nextjs-pages-router/pages/auth/consent.tsx | 2 +- .../src/components/card/card-consent.test.ts | 2 +- .../elements-react/src/components/card/card-consent.tsx | 4 +--- .../src/theme/default/utils/oauth2-config.ts | 2 +- packages/nextjs/src/app/consent.ts | 2 +- packages/nextjs/src/pages/consent.ts | 2 +- 13 files changed, 33 insertions(+), 19 deletions(-) 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 index 840086a4e..1b4420515 100644 --- a/examples/nextjs-app-router-custom-components/app/api/consent/route.ts +++ b/examples/nextjs-app-router-custom-components/app/api/consent/route.ts @@ -53,7 +53,10 @@ export async function POST(request: Request) { if (!consentChallenge) { return NextResponse.json( - { error: "invalid_request", error_description: "Missing consent_challenge" }, + { + error: "invalid_request", + error_description: "Missing consent_challenge", + }, { status: 400 }, ) } 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 index 0e4ae22bb..a06773a74 100644 --- a/examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx +++ b/examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { Consent } from "@ory/elements-react/theme" -import { getConsentFlow, getServerSession, OryPageParams } from "@ory/nextjs/app" +import { + getConsentFlow, + getServerSession, + OryPageParams, +} from "@ory/nextjs/app" import { myCustomComponents } from "@/components" import config from "@/ory.config" @@ -25,4 +29,4 @@ export default async function ConsentPage(props: OryPageParams) { components={myCustomComponents} /> ) -} \ No newline at end of file +} diff --git a/examples/nextjs-app-router-custom-components/components/consent-utils.ts b/examples/nextjs-app-router-custom-components/components/consent-utils.ts index 336f56b9a..b2a24c285 100644 --- a/examples/nextjs-app-router-custom-components/components/consent-utils.ts +++ b/examples/nextjs-app-router-custom-components/components/consent-utils.ts @@ -24,4 +24,4 @@ export function findConsentNodes(nodes: UiNode[]) { } return { rememberNode, submitNodes } -} \ No newline at end of file +} 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 index a38eaca4f..c8ec0a742 100644 --- 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 @@ -65,4 +65,4 @@ export function MyCustomConsentScopeCheckbox({

) -} \ No newline at end of file +} 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 cb990d390..413c65ffa 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-footer.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-footer.tsx @@ -41,7 +41,8 @@ export function MyCustomFooter() { function ConsentFooter({ flow }: { flow: ConsentFlow }) { const { rememberNode, submitNodes } = findConsentNodes(flow.ui.nodes) - const clientName = flow.consent_request.client?.client_name ?? "this application" + const clientName = + flow.consent_request.client?.client_name ?? "this application" return (
@@ -50,7 +51,8 @@ function ConsentFooter({ flow }: { flow: ConsentFlow }) { Make sure you trust {clientName}

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

diff --git a/examples/nextjs-app-router/app/api/consent/route.ts b/examples/nextjs-app-router/app/api/consent/route.ts index 395bd2217..1b4420515 100644 --- a/examples/nextjs-app-router/app/api/consent/route.ts +++ b/examples/nextjs-app-router/app/api/consent/route.ts @@ -53,7 +53,10 @@ export async function POST(request: Request) { if (!consentChallenge) { return NextResponse.json( - { error: "invalid_request", error_description: "Missing consent_challenge" }, + { + error: "invalid_request", + error_description: "Missing consent_challenge", + }, { status: 400 }, ) } @@ -78,4 +81,4 @@ export async function POST(request: Request) { { status: 500 }, ) } -} \ No newline at end of file +} diff --git a/examples/nextjs-app-router/app/auth/consent/page.tsx b/examples/nextjs-app-router/app/auth/consent/page.tsx index 2e8391771..c86ad9a3e 100644 --- a/examples/nextjs-app-router/app/auth/consent/page.tsx +++ b/examples/nextjs-app-router/app/auth/consent/page.tsx @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { Consent } from "@ory/elements-react/theme" -import { getConsentFlow, getServerSession, OryPageParams } from "@ory/nextjs/app" +import { + getConsentFlow, + getServerSession, + OryPageParams, +} from "@ory/nextjs/app" import config from "@/ory.config" @@ -26,4 +30,4 @@ export default async function ConsentPage(props: OryPageParams) { }} /> ) -} \ No newline at end of file +} diff --git a/examples/nextjs-pages-router/pages/auth/consent.tsx b/examples/nextjs-pages-router/pages/auth/consent.tsx index c093d9b34..288a08476 100644 --- a/examples/nextjs-pages-router/pages/auth/consent.tsx +++ b/examples/nextjs-pages-router/pages/auth/consent.tsx @@ -29,4 +29,4 @@ export default function ConsentPage() { /> ) -} \ No newline at end of file +} diff --git a/packages/elements-react/src/components/card/card-consent.test.ts b/packages/elements-react/src/components/card/card-consent.test.ts index 58ae5d365..739c353db 100644 --- a/packages/elements-react/src/components/card/card-consent.test.ts +++ b/packages/elements-react/src/components/card/card-consent.test.ts @@ -186,4 +186,4 @@ describe("isFooterNode", () => { expect(isFooterNode(node)).toBe(false) }) -}) \ No newline at end of file +}) diff --git a/packages/elements-react/src/components/card/card-consent.tsx b/packages/elements-react/src/components/card/card-consent.tsx index 922f82992..4da68ef9a 100644 --- a/packages/elements-react/src/components/card/card-consent.tsx +++ b/packages/elements-react/src/components/card/card-consent.tsx @@ -43,9 +43,7 @@ export function isFooterNode(node: UiNode): boolean { return false } const { name, type } = node.attributes - return ( - name === "remember" || type === UiNodeInputAttributesTypeEnum.Submit - ) + return name === "remember" || type === UiNodeInputAttributesTypeEnum.Submit } /** diff --git a/packages/elements-react/src/theme/default/utils/oauth2-config.ts b/packages/elements-react/src/theme/default/utils/oauth2-config.ts index 61abc0787..afcbd48ac 100644 --- a/packages/elements-react/src/theme/default/utils/oauth2-config.ts +++ b/packages/elements-react/src/theme/default/utils/oauth2-config.ts @@ -25,4 +25,4 @@ export function getConfigWithOAuth2Logo( logo_light_url: logoUrl, }, } -} \ No newline at end of file +} diff --git a/packages/nextjs/src/app/consent.ts b/packages/nextjs/src/app/consent.ts index 8dc00f2f9..2035b774d 100644 --- a/packages/nextjs/src/app/consent.ts +++ b/packages/nextjs/src/app/consent.ts @@ -156,4 +156,4 @@ export async function rejectConsentRequest( }) return response.redirect_to -} \ No newline at end of file +} diff --git a/packages/nextjs/src/pages/consent.ts b/packages/nextjs/src/pages/consent.ts index c207bb3c7..29941f60b 100644 --- a/packages/nextjs/src/pages/consent.ts +++ b/packages/nextjs/src/pages/consent.ts @@ -73,4 +73,4 @@ export function useConsentFlow(): OAuth2ConsentRequest | null | undefined { }, [searchParams, router, router.isReady, consentRequest]) return consentRequest -} \ No newline at end of file +} From 73020fc4ec1b79ac5bedfd040cef03d6558a11bd Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Tue, 3 Feb 2026 10:11:37 +0100 Subject: [PATCH 4/4] fix(oauth2): validate session identity matches consent request subject Add security validation to prevent consent hijacking attacks where an attacker could use a stolen consent_challenge to grant or reject consent on behalf of a different user. Changes: - Pages Router: verify session cookie and compare identity with subject - App Router: add identityId parameter to accept/reject functions - Return 401 for missing session, 403 for identity mismatch --- .../app/api/consent/route.ts | 45 ++++++++++++++- .../nextjs-pages-router/pages/api/consent.ts | 57 ++++++++++++++++++- .../nextjs/api-report/nextjs-client.api.json | 8 +-- packages/nextjs/api-report/nextjs.api.md | 2 + packages/nextjs/src/app/consent.ts | 55 ++++++++++++++++-- 5 files changed, 153 insertions(+), 14 deletions(-) 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 index 1b4420515..623be01fb 100644 --- a/examples/nextjs-app-router-custom-components/app/api/consent/route.ts +++ b/examples/nextjs-app-router-custom-components/app/api/consent/route.ts @@ -1,7 +1,11 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { acceptConsentRequest, rejectConsentRequest } from "@ory/nextjs/app" +import { + acceptConsentRequest, + getServerSession, + rejectConsentRequest, +} from "@ory/nextjs/app" import { NextResponse } from "next/server" interface ConsentBody { @@ -40,6 +44,25 @@ async function parseRequest(request: Request): Promise { } 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) const action = body.action @@ -68,14 +91,32 @@ export async function POST(request: Request) { redirectTo = await acceptConsentRequest(consentChallenge, { grantScope, remember, + identityId, }) } else { - redirectTo = await rejectConsentRequest(consentChallenge) + 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-pages-router/pages/api/consent.ts b/examples/nextjs-pages-router/pages/api/consent.ts index 6e237218c..3131ab9b1 100644 --- a/examples/nextjs-pages-router/pages/api/consent.ts +++ b/examples/nextjs-pages-router/pages/api/consent.ts @@ -1,20 +1,23 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { Configuration, OAuth2Api } from "@ory/client-fetch" +import { Configuration, FrontendApi, OAuth2Api } from "@ory/client-fetch" import type { NextApiRequest, NextApiResponse } from "next" -function getOAuth2Client() { +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: baseUrl.replace(/\/$/, ""), + basePath: getBaseUrl(), headers: { Accept: "application/json", ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), @@ -23,6 +26,17 @@ function getOAuth2Client() { ) } +function getFrontendClient() { + return new FrontendApi( + new Configuration({ + basePath: getBaseUrl(), + headers: { + Accept: "application/json", + }, + }), + ) +} + interface ConsentRequestBody { action?: string consent_challenge?: string @@ -46,8 +60,45 @@ export default async function handler( } 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") { diff --git a/packages/nextjs/api-report/nextjs-client.api.json b/packages/nextjs/api-report/nextjs-client.api.json index bb5574a10..7fc019553 100644 --- a/packages/nextjs/api-report/nextjs-client.api.json +++ b/packages/nextjs/api-report/nextjs-client.api.json @@ -364,7 +364,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.\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 } from \"@ory/nextjs/app\"\n * import { redirect } from \"next/navigation\"\n *\n * export async function POST(request: Request) {\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 * session: { ... }\n * })\n * return redirect(redirectTo)\n * } else {\n * const redirectTo = await rejectConsentRequest(consentChallenge)\n * return redirect(redirectTo)\n * }\n * }\n * ```\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) {\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", @@ -380,7 +380,7 @@ }, { "kind": "Content", - "text": "{\n grantScope: string[];\n remember?: boolean;\n rememberFor?: number;\n session?: {\n accessToken?: " + "text": "{\n grantScope: string[];\n remember?: boolean;\n rememberFor?: number;\n identityId?: string;\n session?: {\n accessToken?: " }, { "kind": "Reference", @@ -1459,7 +1459,7 @@ { "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.\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 * @public\n */\n", + "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", @@ -1475,7 +1475,7 @@ }, { "kind": "Content", - "text": "{\n error?: string;\n errorDescription?: string;\n}" + "text": "{\n error?: string;\n errorDescription?: string;\n identityId?: string;\n}" }, { "kind": "Content", diff --git a/packages/nextjs/api-report/nextjs.api.md b/packages/nextjs/api-report/nextjs.api.md index bb25f8368..7e05bc005 100644 --- a/packages/nextjs/api-report/nextjs.api.md +++ b/packages/nextjs/api-report/nextjs.api.md @@ -24,6 +24,7 @@ export function acceptConsentRequest(consentChallenge: string, options: { grantScope: string[]; remember?: boolean; rememberFor?: number; + identityId?: string; session?: { accessToken?: Record; idToken?: Record; @@ -105,6 +106,7 @@ export interface OryPageParams { export function rejectConsentRequest(consentChallenge: string, options?: { error?: string; errorDescription?: string; + identityId?: string; }): Promise; // @public diff --git a/packages/nextjs/src/app/consent.ts b/packages/nextjs/src/app/consent.ts index 2035b774d..bfa048d33 100644 --- a/packages/nextjs/src/app/consent.ts +++ b/packages/nextjs/src/app/consent.ts @@ -66,14 +66,21 @@ export async function getConsentFlow( * 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 } from "@ory/nextjs/app" + * 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 @@ -84,11 +91,13 @@ export async function getConsentFlow( * const redirectTo = await acceptConsentRequest(consentChallenge, { * grantScope, * remember, - * session: { ... } + * identityId: session.identity?.id, * }) * return redirect(redirectTo) * } else { - * const redirectTo = await rejectConsentRequest(consentChallenge) + * const redirectTo = await rejectConsentRequest(consentChallenge, { + * identityId: session.identity?.id, + * }) * return redirect(redirectTo) * } * } @@ -97,6 +106,7 @@ export async function getConsentFlow( * @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( @@ -105,13 +115,29 @@ export async function acceptConsentRequest( grantScope: string[] remember?: boolean rememberFor?: number + identityId?: string session?: { accessToken?: Record idToken?: Record } }, ): Promise { - const response = await serverSideOAuth2Client().acceptOAuth2ConsentRequest({ + 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, @@ -133,10 +159,13 @@ export async function acceptConsentRequest( * 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( @@ -144,9 +173,25 @@ export async function rejectConsentRequest( options?: { error?: string errorDescription?: string + identityId?: string }, ): Promise { - const response = await serverSideOAuth2Client().rejectOAuth2ConsentRequest({ + 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",