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/eslint.config.mjs b/eslint.config.mjs index fdceac9b0..c9753da32 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -182,8 +182,8 @@ const config = tseslint.config([ ], }, ], - } - } + }, + }, ]) export default config 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..1b4420515 --- /dev/null +++ b/examples/nextjs-app-router-custom-components/app/api/consent/route.ts @@ -0,0 +1,84 @@ +// 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..a06773a74 --- /dev/null +++ b/examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx @@ -0,0 +1,32 @@ +// 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 ( + + ) +} 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..b2a24c285 --- /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 } +} diff --git a/examples/nextjs-app-router-custom-components/components/custom-button.tsx b/examples/nextjs-app-router-custom-components/components/custom-button.tsx index d95721ab7..10a24584c 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-button.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-button.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + "use client" import { getNodeLabel } from "@ory/client-fetch" import { OryNodeButtonProps } from "@ory/elements-react" diff --git a/examples/nextjs-app-router-custom-components/components/custom-checkbox.tsx b/examples/nextjs-app-router-custom-components/components/custom-checkbox.tsx index 7dde683d9..42b04bd96 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-checkbox.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-checkbox.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { getNodeLabel } from "@ory/client-fetch" import { OryNodeCheckboxProps } from "@ory/elements-react" 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..c8ec0a742 --- /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 ( + + ) +} 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 b077f4178..413c65ffa 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-footer.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-footer.tsx @@ -1,7 +1,11 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + /* 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() @@ -29,8 +33,40 @@ export function MyCustomFooter() { case FlowType.Verification: return null case FlowType.OAuth2Consent: - return null + return default: return null } } + +function ConsentFooter({ flow }: { flow: ConsentFlow }) { + const { rememberNode, submitNodes } = findConsentNodes(flow.ui.nodes) + const clientName = + flow.consent_request.client?.client_name ?? "this application" + + return ( +
+
+

+ Make sure you trust {clientName} +

+

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

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

+ Authorizing will redirect to {clientName} +

+
+ ) +} diff --git a/examples/nextjs-app-router-custom-components/components/custom-image.tsx b/examples/nextjs-app-router-custom-components/components/custom-image.tsx index 5dc29e916..1c13c7cff 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-image.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-image.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { OryNodeImageProps } from "@ory/elements-react" export function MyCustomImage({ node }: OryNodeImageProps) { diff --git a/examples/nextjs-app-router-custom-components/components/custom-input.tsx b/examples/nextjs-app-router-custom-components/components/custom-input.tsx index 852adc19d..971f2c45c 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-input.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-input.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { OryNodeInputProps } from "@ory/elements-react" export function MyCustomInput({ inputProps }: OryNodeInputProps) { diff --git a/examples/nextjs-app-router-custom-components/components/custom-label.tsx b/examples/nextjs-app-router-custom-components/components/custom-label.tsx index 935d3028c..5c26d6778 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-label.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-label.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { OryNodeLabelProps } from "@ory/elements-react" export function MyCustomLabel({ diff --git a/examples/nextjs-app-router-custom-components/components/custom-pin-code.tsx b/examples/nextjs-app-router-custom-components/components/custom-pin-code.tsx index a6544743e..8e738f5b5 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-pin-code.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-pin-code.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { OryNodeInputProps } from "@ory/elements-react" export function MyCustomPinCodeInput({ inputProps }: OryNodeInputProps) { diff --git a/examples/nextjs-app-router-custom-components/components/custom-social.tsx b/examples/nextjs-app-router-custom-components/components/custom-social.tsx index 6b6d83f15..052bbd1fb 100644 --- a/examples/nextjs-app-router-custom-components/components/custom-social.tsx +++ b/examples/nextjs-app-router-custom-components/components/custom-social.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { OryNodeSsoButtonProps } from "@ory/elements-react" import { IconBrandGoogle, IconTopologyRing } from "@tabler/icons-react" diff --git a/examples/nextjs-app-router-custom-components/components/index.tsx b/examples/nextjs-app-router-custom-components/components/index.tsx index f3f182e89..46a52ac86 100644 --- a/examples/nextjs-app-router-custom-components/components/index.tsx +++ b/examples/nextjs-app-router-custom-components/components/index.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + "use client" import { OryFlowComponentOverrides } from "@ory/elements-react" import { MyCustomButton } from "./custom-button" @@ -6,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" @@ -17,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..1b4420515 --- /dev/null +++ b/examples/nextjs-app-router/app/api/consent/route.ts @@ -0,0 +1,84 @@ +// 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/app/auth/consent/page.tsx b/examples/nextjs-app-router/app/auth/consent/page.tsx new file mode 100644 index 000000000..c86ad9a3e --- /dev/null +++ b/examples/nextjs-app-router/app/auth/consent/page.tsx @@ -0,0 +1,33 @@ +// 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 ( + + ) +} 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..288a08476 --- /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 ( +
+ +
+ ) +} 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..739c353db --- /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) + }) +}) diff --git a/packages/elements-react/src/components/card/card-consent.tsx b/packages/elements-react/src/components/card/card-consent.tsx index baee931c2..4da68ef9a 100644 --- a/packages/elements-react/src/components/card/card-consent.tsx +++ b/packages/elements-react/src/components/card/card-consent.tsx @@ -8,7 +8,43 @@ 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 +62,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 692f304b4..a0d47f09b 100644 --- a/packages/elements-react/src/components/form/nodes/node-button.tsx +++ b/packages/elements-react/src/components/form/nodes/node-button.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { UiNodeGroupEnum } from "@ory/client-fetch" import { UiNodeInput } from "../../../util/utilFixSDKTypesHelper" import { NodeRenderer } from "./renderer" @@ -14,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/components/form/nodes/renderer/button-renderer.tsx b/packages/elements-react/src/components/form/nodes/renderer/button-renderer.tsx index 215dbafb6..289e3abac 100644 --- a/packages/elements-react/src/components/form/nodes/renderer/button-renderer.tsx +++ b/packages/elements-react/src/components/form/nodes/renderer/button-renderer.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { useCallback, useEffect } from "react" import { useFormContext } from "react-hook-form" import { useDebounceValue } from "usehooks-ts" diff --git a/packages/elements-react/src/components/form/nodes/renderer/consent-checkbox-renderer.tsx b/packages/elements-react/src/components/form/nodes/renderer/consent-checkbox-renderer.tsx index b3e716055..25ab0dd8a 100644 --- a/packages/elements-react/src/components/form/nodes/renderer/consent-checkbox-renderer.tsx +++ b/packages/elements-react/src/components/form/nodes/renderer/consent-checkbox-renderer.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { useMemo } from "react" import { useFormContext } from "react-hook-form" import { useComponents } from "../../../../context" diff --git a/packages/elements-react/src/components/form/nodes/renderer/hidden-input-renderer.tsx b/packages/elements-react/src/components/form/nodes/renderer/hidden-input-renderer.tsx index ca96a1604..b007cd427 100644 --- a/packages/elements-react/src/components/form/nodes/renderer/hidden-input-renderer.tsx +++ b/packages/elements-react/src/components/form/nodes/renderer/hidden-input-renderer.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { useComponents } from "../../../../context" import { UiNodeInput } from "../../../../util/utilFixSDKTypesHelper" import { useInputProps } from "../hooks/useInputProps" diff --git a/packages/elements-react/src/components/form/nodes/renderer/image-renderer.tsx b/packages/elements-react/src/components/form/nodes/renderer/image-renderer.tsx index ce82d71f3..62539c373 100644 --- a/packages/elements-react/src/components/form/nodes/renderer/image-renderer.tsx +++ b/packages/elements-react/src/components/form/nodes/renderer/image-renderer.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { useComponents } from "../../../../context" import { UiNodeImage } from "../../../../util/utilFixSDKTypesHelper" diff --git a/packages/elements-react/src/components/form/nodes/renderer/index.ts b/packages/elements-react/src/components/form/nodes/renderer/index.ts index 0f52c733f..40198f356 100644 --- a/packages/elements-react/src/components/form/nodes/renderer/index.ts +++ b/packages/elements-react/src/components/form/nodes/renderer/index.ts @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { ButtonRenderer } from "./button-renderer" import { CheckboxRenderer } from "./checkbox-renderer" import { ConsentCheckboxRenderer } from "./consent-checkbox-renderer" diff --git a/packages/elements-react/src/components/form/nodes/renderer/input-renderer.tsx b/packages/elements-react/src/components/form/nodes/renderer/input-renderer.tsx index 75c19e608..b5829662c 100644 --- a/packages/elements-react/src/components/form/nodes/renderer/input-renderer.tsx +++ b/packages/elements-react/src/components/form/nodes/renderer/input-renderer.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { getNodeLabel } from "@ory/client-fetch" import { useComponents } from "../../../../context" import { UiNodeInput } from "../../../../util/utilFixSDKTypesHelper" diff --git a/packages/elements-react/src/components/form/nodes/renderer/sso-button-renderer.tsx b/packages/elements-react/src/components/form/nodes/renderer/sso-button-renderer.tsx index e660dc963..2a1f714d1 100644 --- a/packages/elements-react/src/components/form/nodes/renderer/sso-button-renderer.tsx +++ b/packages/elements-react/src/components/form/nodes/renderer/sso-button-renderer.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { useCallback, useEffect } from "react" import { useFormContext } from "react-hook-form" import { useDebounceValue } from "usehooks-ts" diff --git a/packages/elements-react/src/components/form/nodes/renderer/text-renderer.tsx b/packages/elements-react/src/components/form/nodes/renderer/text-renderer.tsx index 03cfce462..64044bcb0 100644 --- a/packages/elements-react/src/components/form/nodes/renderer/text-renderer.tsx +++ b/packages/elements-react/src/components/form/nodes/renderer/text-renderer.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { useComponents } from "../../../../context" import { UiNodeText } from "../../../../util/utilFixSDKTypesHelper" diff --git a/packages/elements-react/src/components/form/useResendCode.ts b/packages/elements-react/src/components/form/useResendCode.ts index ebc16b53c..0f675b7a7 100644 --- a/packages/elements-react/src/components/form/useResendCode.ts +++ b/packages/elements-react/src/components/form/useResendCode.ts @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { UiNode } from "@ory/client-fetch" import { useOryFlow } from "../../context" import { useOryFormSubmit } from "./useOryFormSubmit" diff --git a/packages/elements-react/src/locales/README.md b/packages/elements-react/src/locales/README.md index dfae8b16c..34f9628de 100644 --- a/packages/elements-react/src/locales/README.md +++ b/packages/elements-react/src/locales/README.md @@ -1,12 +1,16 @@ # Translation System Documentation -This directory contains the internationalization (i18n) locale files and a translation management script that uses Ollama to automatically translate content. +This directory contains the internationalization (i18n) locale files and a +translation management script that uses Ollama to automatically translate +content. ## Overview The translation system works by: + 1. Using `en.json` as the source of truth for all translations -2. Automatically detecting differences between `en.json` and other language files +2. Automatically detecting differences between `en.json` and other language + files 3. Using Ollama with multilingual models to translate missing or new content 4. Maintaining consistency across all language files @@ -36,7 +40,8 @@ locales/ ## Using the Translation Script -The script is located at `script/translate.js` and provides several modes of operation: +The script is located at `script/translate.js` and provides several modes of +operation: ### View Usage Information @@ -47,7 +52,8 @@ node translate.js ### Update Existing Translations -When you add new keys to `en.json` or modify existing values, you can update all language files: +When you add new keys to `en.json` or modify existing values, you can update all +language files: ```bash # Update ALL existing language files with missing keys @@ -59,6 +65,7 @@ node translate.js --update es # Updates Spanish only ``` The script will: + - Compare each language file with `en.json` - Identify missing keys - Translate only the missing keys @@ -76,6 +83,7 @@ node translate.js --new nl # Creates Dutch (nl.json) ``` The script will: + - Translate the entire `en.json` file to the target language - Create a new `[language_code].json` file - Preserve all placeholders and HTML tags @@ -90,6 +98,7 @@ node translate.js --all ``` This will: + 1. Update all existing language files with any missing keys 2. Prompt you to add new languages (optional) @@ -98,6 +107,7 @@ This will: ### Example 1: Adding New Features 1. Add new keys to `en.json`: + ```json { "existing.key": "Existing text", @@ -107,6 +117,7 @@ This will: ``` 2. Update all translations: + ```bash cd script node translate.js --update @@ -116,6 +127,7 @@ node translate.js --update 1. Check available language codes in the script (ISO 639-1 codes) 2. Create the new language file: + ```bash cd script node translate.js --new it # Italian @@ -124,6 +136,7 @@ node translate.js --new it # Italian ### Example 3: Fixing a Single Language If one language file is out of sync: + ```bash cd script node translate.js --update de # Update German only @@ -134,12 +147,14 @@ node translate.js --update de # Update German only ### Translation Quality - The script uses AI translation which provides good baseline translations -- For production applications, consider having native speakers review translations +- For production applications, consider having native speakers review + translations - Complex technical terms or brand-specific language may need manual adjustment ### Placeholders and HTML The script automatically preserves: + - Placeholders: `{provider}`, `{contactSupportEmail}`, `{clientName}`, etc. - Date formats: `{used_at, date, long}` - HTML tags: ``, ``, ``, etc. @@ -160,22 +175,27 @@ The script automatically preserves: ## Troubleshooting ### "Model not found" Error + ```bash ollama pull qwen3:32b ``` ### "Connection refused" Error + ```bash ollama serve # Start Ollama service ``` ### Empty or Invalid Translations + - Try running the command again (script has 3 retry attempts) - Check if Ollama has enough memory allocated - Consider using a larger model ### Partial Translations + The script will: + - Warn about missing keys - Add English fallbacks for any missing translations - Log which keys used fallbacks @@ -202,7 +222,8 @@ See the full list in `script/translate.js` in the `LANGUAGE_NAMES` object. ## Best Practices 1. **Always test after translation** - Verify UI layout with longer translations -2. **Review critical content** - Legal, medical, or financial content needs human review +2. **Review critical content** - Legal, medical, or financial content needs + human review 3. **Keep `en.json` organized** - Group related keys together 4. **Use descriptive keys** - Makes translations more accurate 5. **Document context** - Add comments in code where keys are used @@ -221,7 +242,9 @@ The update script will skip keys that are already translated. ## Using Claude Code for Translations -As an alternative to the Ollama script, you can use Claude Code directly for translations. This provides more control and allows for interactive refinement of translations. +As an alternative to the Ollama script, you can use Claude Code directly for +translations. This provides more control and allows for interactive refinement +of translations. ### Prompt Examples @@ -301,11 +324,16 @@ Create a British English variant (en-GB.json) from en.json, adjusting spellings ### Tips for Using Claude Code -1. **Be specific about preservation rules** - Always mention preserving placeholders and HTML tags -2. **Specify the target language clearly** - Use both language name and code (e.g., "French (fr)") -3. **Provide context when needed** - Mention if it's for a specific industry or user base -4. **Request validation** - Ask Claude to verify all keys are present after translation -5. **Iterative refinement** - You can ask Claude to adjust specific translations after initial generation +1. **Be specific about preservation rules** - Always mention preserving + placeholders and HTML tags +2. **Specify the target language clearly** - Use both language name and code + (e.g., "French (fr)") +3. **Provide context when needed** - Mention if it's for a specific industry or + user base +4. **Request validation** - Ask Claude to verify all keys are present after + translation +5. **Iterative refinement** - You can ask Claude to adjust specific translations + after initial generation ### Advantages of Using Claude Code @@ -313,26 +341,35 @@ Create a British English variant (en-GB.json) from en.json, adjusting spellings - **Context awareness** - Provide specific context about your application - **Selective updates** - Translate only specific keys or sections - **Quality review** - Ask for native speaker-level refinements -- **Custom requirements** - Handle special cases like regional variants or industry terminology +- **Custom requirements** - Handle special cases like regional variants or + industry terminology - **Immediate availability** - No need to set up Ollama or download models ### Example Workflow with Claude Code -1. **Initial setup**: "Show me what language files exist in the locales directory" -2. **Analysis**: "Compare en.json with all other language files and show which ones need updates" +1. **Initial setup**: "Show me what language files exist in the locales + directory" +2. **Analysis**: "Compare en.json with all other language files and show which + ones need updates" 3. **Translation**: "Update fr.json and de.json with the missing keys you found" -4. **Validation**: "Verify that all language files now have the same keys as en.json" -5. **Refinement**: "The French translation for 'dashboard.analytics.retention' seems too literal, can you make it more natural?" +4. **Validation**: "Verify that all language files now have the same keys as + en.json" +5. **Refinement**: "The French translation for 'dashboard.analytics.retention' + seems too literal, can you make it more natural?" -This approach gives you full control over the translation process while leveraging Claude's language capabilities. +This approach gives you full control over the translation process while +leveraging Claude's language capabilities. ## Using Claude.ai or ChatGPT for Translation Review -You can also use web-based AI assistants like Claude.ai or ChatGPT to translate or review your locale files by attaching the JSON files directly. This is useful for quality assurance and getting detailed translation reports. +You can also use web-based AI assistants like Claude.ai or ChatGPT to translate +or review your locale files by attaching the JSON files directly. This is useful +for quality assurance and getting detailed translation reports. ### How to Use -1. **Attach the files**: Upload `en.json` and the target language file(s) to the chat +1. **Attach the files**: Upload `en.json` and the target language file(s) to the + chat 2. **Use one of the prompts below**: Copy and paste the appropriate prompt 3. **Review the output**: The AI will provide a structured report for evaluation @@ -452,7 +489,7 @@ Please identify missing keys and provide translations for them: 1. **Find Missing Keys**: Compare the files and list all keys present in en.json but missing from [language].json 2. **Provide Translations**: For each missing key, provide the translation in this format: - + ```json { "missing.key.1": "Translated text here", @@ -512,15 +549,19 @@ Please focus on actionable insights that help prioritize translation updates. ### Tips for Best Results -1. **Attach files in order**: Always attach en.json first, then target language files +1. **Attach files in order**: Always attach en.json first, then target language + files 2. **Specify the language**: Always mention the full language name and code -3. **Be clear about the context**: Mention if it's for a specific type of application -4. **Request specific formats**: Ask for JSON output when you need ready-to-use translations +3. **Be clear about the context**: Mention if it's for a specific type of + application +4. **Request specific formats**: Ask for JSON output when you need ready-to-use + translations 5. **Ask for examples**: Request specific examples of issues found ### Evaluating the AI's Response A good translation review should: + - ✅ Identify all missing or extra keys - ✅ Catch placeholder and HTML tag issues - ✅ Point out unnatural or awkward translations @@ -531,9 +572,10 @@ A good translation review should: ### When to Use This Method This approach is ideal for: + - **Quality assurance**: Before deploying new translations - **Vendor validation**: Checking translations from external translators - **Periodic reviews**: Regular quality checks of existing translations - **Quick assessments**: Getting a fast overview of translation status - **Comparative analysis**: Reviewing multiple languages at once -- **Documentation**: Creating translation quality reports for stakeholders \ No newline at end of file +- **Documentation**: Creating translation quality reports for stakeholders 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 ( { ).sort() // Check if placeholders match - if (JSON.stringify(enPlaceholders) !== JSON.stringify(translatedPlaceholders)) { + if ( + JSON.stringify(enPlaceholders) !== + JSON.stringify(translatedPlaceholders) + ) { issues.push( `Key "${key}": expected placeholders ${JSON.stringify(enPlaceholders)}, got ${JSON.stringify(translatedPlaceholders)}`, ) @@ -150,7 +153,9 @@ describe("Translations", () => { // Check if the translation is exactly the same as English (potential fallback) if (enValue === translatedValue) { - issues.push(`Key "${key}": translation matches English text "${enValue}"`) + issues.push( + `Key "${key}": translation matches English text "${enValue}"`, + ) } } diff --git a/packages/elements-react/src/util/removeFalsyValues.spec.ts b/packages/elements-react/src/util/removeFalsyValues.spec.ts index d3145b29a..070d05e8c 100644 --- a/packages/elements-react/src/util/removeFalsyValues.spec.ts +++ b/packages/elements-react/src/util/removeFalsyValues.spec.ts @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { removeEmptyStrings } from "./removeFalsyValues" describe("removeFalsyValues", () => { diff --git a/packages/elements-react/src/util/removeFalsyValues.ts b/packages/elements-react/src/util/removeFalsyValues.ts index 6d8b7d34a..ae5abe21b 100644 --- a/packages/elements-react/src/util/removeFalsyValues.ts +++ b/packages/elements-react/src/util/removeFalsyValues.ts @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + type AnyObject = Record /** diff --git a/packages/elements-react/src/util/utilFixSDKTypesHelper.ts b/packages/elements-react/src/util/utilFixSDKTypesHelper.ts index a28ef53a6..af23d697b 100644 --- a/packages/elements-react/src/util/utilFixSDKTypesHelper.ts +++ b/packages/elements-react/src/util/utilFixSDKTypesHelper.ts @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import { UiNode, UiNodeAnchorAttributes, diff --git a/packages/elements-react/stories/customized/components/card-header.tsx b/packages/elements-react/stories/customized/components/card-header.tsx index ec5b34bd4..c1563dc3c 100644 --- a/packages/elements-react/stories/customized/components/card-header.tsx +++ b/packages/elements-react/stories/customized/components/card-header.tsx @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + /* eslint-disable better-tailwindcss/no-unregistered-classes */ export function MyCustomCardHeader() { diff --git a/packages/elements-react/stories/customized/input.stories.ts b/packages/elements-react/stories/customized/input.stories.ts index f33cbc12b..3b8bfd09b 100644 --- a/packages/elements-react/stories/customized/input.stories.ts +++ b/packages/elements-react/stories/customized/input.stories.ts @@ -1,3 +1,6 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + import type { Meta, StoryObj } from "@storybook/react" import { Verification } from "../../src/theme/default" import { config } from "../utils" diff --git a/packages/elements-react/tailwind/generated/variables.css b/packages/elements-react/tailwind/generated/variables.css index c3ebe8f4e..45d7315b7 100644 --- a/packages/elements-react/tailwind/generated/variables.css +++ b/packages/elements-react/tailwind/generated/variables.css @@ -1,3 +1,6 @@ +/* Copyright © 2026 Ory Corp */ +/* SPDX-License-Identifier: Apache-2.0 */ + @theme { --ui-100: #f1f5f9; --ui-200: #e2e8f0; 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..2035b774d --- /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 +} 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..29941f60b --- /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 +} 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)