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..623be01fb --- /dev/null +++ b/examples/nextjs-app-router-custom-components/app/api/consent/route.ts @@ -0,0 +1,125 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { + acceptConsentRequest, + getServerSession, + 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) { + // 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 + const consentChallenge = body.consent_challenge + const grantScope = Array.isArray(body.grant_scope) + ? body.grant_scope + : body.grant_scope + ? [body.grant_scope] + : [] + const remember = body.remember === true || body.remember === "true" + + if (!consentChallenge) { + return NextResponse.json( + { + error: "invalid_request", + error_description: "Missing consent_challenge", + }, + { status: 400 }, + ) + } + + try { + let redirectTo: string + + if (action === "accept") { + redirectTo = await acceptConsentRequest(consentChallenge, { + grantScope, + remember, + identityId, + }) + } else { + redirectTo = await rejectConsentRequest(consentChallenge, { + identityId, + }) + } + + return NextResponse.json({ redirect_to: redirectTo }) + } catch (error) { + console.error("Consent error:", error) + + // Check for identity mismatch error + if ( + error instanceof Error && + error.message.includes("does not match consent request subject") + ) { + return NextResponse.json( + { + error: "forbidden", + error_description: "Session does not match consent request subject", + }, + { status: 403 }, + ) + } + + return NextResponse.json( + { error: "server_error", error_description: "Failed to process consent" }, + { status: 500 }, + ) + } +} diff --git a/examples/nextjs-app-router-custom-components/app/auth/consent/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-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 132c09b39..413c65ffa 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,40 @@ export function MyCustomFooter() { case FlowType.Verification: return null case FlowType.OAuth2Consent: - return null + return default: return null } } + +function ConsentFooter({ flow }: { flow: ConsentFlow }) { + const { rememberNode, submitNodes } = findConsentNodes(flow.ui.nodes) + const clientName = + flow.consent_request.client?.client_name ?? "this application" + + return ( +
+
+

+ Make sure you trust {clientName} +

+

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

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

+ Authorizing will redirect to {clientName} +

+
+ ) +} diff --git a/examples/nextjs-app-router-custom-components/components/index.tsx b/examples/nextjs-app-router-custom-components/components/index.tsx index 2ee409d2b..46a52ac86 100644 --- a/examples/nextjs-app-router-custom-components/components/index.tsx +++ b/examples/nextjs-app-router-custom-components/components/index.tsx @@ -9,6 +9,7 @@ import { MyCustomSsoButton } from "./custom-social" import { MyCustomInput } from "./custom-input" import { MyCustomPinCodeInput } from "./custom-pin-code" import { MyCustomCheckbox } from "./custom-checkbox" +import { MyCustomConsentScopeCheckbox } from "./custom-consent-scope-checkbox" import { MyCustomImage } from "./custom-image" import { MyCustomLabel } from "./custom-label" import { MyCustomFooter } from "./custom-footer" @@ -20,6 +21,7 @@ export const myCustomComponents: OryFlowComponentOverrides = { Input: MyCustomInput, CodeInput: MyCustomPinCodeInput, Checkbox: MyCustomCheckbox, + ConsentScopeCheckbox: MyCustomConsentScopeCheckbox, Image: MyCustomImage, Label: MyCustomLabel, }, diff --git a/examples/nextjs-app-router/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..3131ab9b1 --- /dev/null +++ b/examples/nextjs-pages-router/pages/api/consent.ts @@ -0,0 +1,135 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Configuration, FrontendApi, OAuth2Api } from "@ory/client-fetch" +import type { NextApiRequest, NextApiResponse } from "next" + +function getBaseUrl(): string { + const baseUrl = process.env.NEXT_PUBLIC_ORY_SDK_URL || process.env.ORY_SDK_URL + if (!baseUrl) { + throw new Error("ORY_SDK_URL is not set") + } + return baseUrl.replace(/\/$/, "") +} + +function getOAuth2Client() { + const apiKey = process.env.ORY_PROJECT_API_TOKEN ?? "" + + return new OAuth2Api( + new Configuration({ + basePath: getBaseUrl(), + headers: { + Accept: "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + }), + ) +} + +function getFrontendClient() { + return new FrontendApi( + new Configuration({ + basePath: getBaseUrl(), + headers: { + Accept: "application/json", + }, + }), + ) +} + +interface ConsentRequestBody { + action?: string + consent_challenge?: string + grant_scope?: string | string[] + remember?: string | boolean +} + +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() + const frontendClient = getFrontendClient() + + try { + // Security: Fetch the consent request to get the expected subject + const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ + consentChallenge: consent_challenge, + }) + + // Security: Verify the current session matches the consent challenge subject + // This prevents an attacker from using a stolen consent_challenge + // to grant consent on behalf of a different user + const cookie = req.headers.cookie + if (!cookie) { + console.error("Consent security: No session cookie provided") + return res.status(401).json({ error: "Unauthorized: No session" }) + } + + let session + try { + session = await frontendClient.toSession({ cookie }) + } catch { + console.error("Consent security: Invalid or expired session") + return res.status(401).json({ error: "Unauthorized: Invalid session" }) + } + + // Compare the session identity with the consent request subject + const sessionIdentityId = session.identity?.id + const consentSubject = consentRequest.subject + + if (!sessionIdentityId || sessionIdentityId !== consentSubject) { + console.error( + "Consent security: Session identity mismatch. " + + `Session: ${sessionIdentityId}, Consent subject: ${consentSubject}`, + ) + return res.status(403).json({ + error: "Forbidden: Session does not match consent request subject", + }) + } + + let redirectTo: string + + if (action === "accept") { + const scopes: string[] = Array.isArray(grant_scope) + ? grant_scope + : grant_scope + ? [grant_scope] + : [] + + const response = await oauth2Client.acceptOAuth2ConsentRequest({ + consentChallenge: consent_challenge, + acceptOAuth2ConsentRequest: { + grant_scope: scopes, + remember: remember === "true" || remember === true, + }, + }) + redirectTo = response.redirect_to + } else { + const response = await oauth2Client.rejectOAuth2ConsentRequest({ + consentChallenge: consent_challenge, + rejectOAuth2Request: { + error: "access_denied", + error_description: "The resource owner denied the request", + }, + }) + redirectTo = response.redirect_to + } + + return res.status(200).json({ redirect_to: redirectTo }) + } catch (error) { + console.error("Consent error:", error) + return res.status(500).json({ error: "Failed to process consent" }) + } +} diff --git a/examples/nextjs-pages-router/pages/auth/consent.tsx b/examples/nextjs-pages-router/pages/auth/consent.tsx new file mode 100644 index 000000000..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 e7a1c8e44..a0d47f09b 100644 --- a/packages/elements-react/src/components/form/nodes/node-button.tsx +++ b/packages/elements-react/src/components/form/nodes/node-button.tsx @@ -17,9 +17,6 @@ export function NodeButton({ node }: NodeButtonProps) { if (isResendNode || isScreenSelectionNode) { return null } - if (node.group === "oauth2_consent") { - return null - } const isSocial = (node.attributes.name === "provider" || node.attributes.name === "link") && diff --git a/packages/elements-react/src/theme/default/flows/consent.tsx b/packages/elements-react/src/theme/default/flows/consent.tsx index 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 ( ;\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. It validates that the provided session identity matches the consent request subject to prevent consent hijacking attacks.\n *\n * @param consentChallenge - The consent challenge from the form.\n *\n * @param options - Options for rejecting the consent request.\n *\n * @returns The redirect URL to complete the OAuth2 flow.\n *\n * @throws\n *\n * Error if identityId doesn't match the consent request subject.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function rejectConsentRequest(consentChallenge: " + }, + { + "kind": "Content", + "text": "string" + }, + { + "kind": "Content", + "text": ", options?: " + }, + { + "kind": "Content", + "text": "{\n error?: string;\n errorDescription?: string;\n identityId?: string;\n}" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/app/index.d.ts", + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 7 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "consentChallenge", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + }, + { + "parameterName": "options", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": true + } + ], + "name": "rejectConsentRequest" + }, + { + "kind": "Function", + "canonicalReference": "@ory/nextjs!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..7e05bc005 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,26 @@ import { Session } from '@ory/client-fetch'; import { SettingsFlow } from '@ory/client-fetch'; import { VerificationFlow } from '@ory/client-fetch'; +// @public +export function acceptConsentRequest(consentChallenge: string, options: { + grantScope: string[]; + remember?: boolean; + rememberFor?: number; + identityId?: string; + session?: { + accessToken?: Record; + idToken?: Record; + }; +}): Promise; + // @public export function createOryMiddleware(options: OryMiddlewareOptions): (r: NextRequest) => Promise>; // Warning: (ae-forgotten-export) The symbol "QueryParams" needs to be exported by the entry point api-extractor-type-index.d.ts // +// @public +export function getConsentFlow(params: QueryParams | Promise): Promise; + // @public export function getFlowFactory(params: QueryParams, fetchFlowRaw: () => Promise>, flowType: FlowType, baseUrl: string, route: string, options?: { disableRewrite?: boolean; @@ -86,6 +102,16 @@ export interface OryPageParams { }>; } +// @public +export function rejectConsentRequest(consentChallenge: string, options?: { + error?: string; + errorDescription?: string; + identityId?: string; +}): Promise; + +// @public +export function useConsentFlow(): OAuth2ConsentRequest | null | undefined; + // @public export const useLoginFlow: () => void | _ory_client_fetch.LoginFlow | null; @@ -98,6 +124,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..bfa048d33 --- /dev/null +++ b/packages/nextjs/src/app/consent.ts @@ -0,0 +1,204 @@ +// 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. + * It validates that the provided session identity matches the consent request subject + * to prevent consent hijacking attacks. + * + * @example + * ```tsx + * // app/api/consent/route.ts + * import { acceptConsentRequest, rejectConsentRequest, getServerSession } from "@ory/nextjs/app" + * import { redirect } from "next/navigation" + * + * export async function POST(request: Request) { + * const session = await getServerSession() + * if (!session) { + * return new Response("Unauthorized", { status: 401 }) + * } + * + * const formData = await request.formData() + * const action = formData.get("action") + * const consentChallenge = formData.get("consent_challenge") as string + * const grantScope = formData.getAll("grant_scope") as string[] + * const remember = formData.get("remember") === "true" + * + * if (action === "accept") { + * const redirectTo = await acceptConsentRequest(consentChallenge, { + * grantScope, + * remember, + * identityId: session.identity?.id, + * }) + * return redirect(redirectTo) + * } else { + * const redirectTo = await rejectConsentRequest(consentChallenge, { + * identityId: session.identity?.id, + * }) + * return redirect(redirectTo) + * } + * } + * ``` + * + * @param consentChallenge - The consent challenge from the form. + * @param options - Options for accepting the consent request. + * @returns The redirect URL to complete the OAuth2 flow. + * @throws Error if identityId doesn't match the consent request subject. + * @public + */ +export async function acceptConsentRequest( + consentChallenge: string, + options: { + grantScope: string[] + remember?: boolean + rememberFor?: number + identityId?: string + session?: { + accessToken?: Record + idToken?: Record + } + }, +): Promise { + const oauth2Client = serverSideOAuth2Client() + + // Security: Verify session identity matches consent request subject + if (options.identityId) { + const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ + consentChallenge, + }) + + if (consentRequest.subject !== options.identityId) { + throw new Error( + "Forbidden: Session identity does not match consent request subject", + ) + } + } + + const response = await oauth2Client.acceptOAuth2ConsentRequest({ + consentChallenge, + acceptOAuth2ConsentRequest: { + grant_scope: options.grantScope, + remember: options.remember, + remember_for: options.rememberFor, + session: options.session + ? { + access_token: options.session.accessToken, + id_token: options.session.idToken, + } + : undefined, + }, + }) + + return response.redirect_to +} + +/** + * Reject an OAuth2 consent request. + * + * This method should be called from an API route handler when the user rejects the consent. + * It validates that the provided session identity matches the consent request subject + * to prevent consent hijacking attacks. + * + * @param consentChallenge - The consent challenge from the form. + * @param options - Options for rejecting the consent request. + * @returns The redirect URL to complete the OAuth2 flow. + * @throws Error if identityId doesn't match the consent request subject. + * @public + */ +export async function rejectConsentRequest( + consentChallenge: string, + options?: { + error?: string + errorDescription?: string + identityId?: string + }, +): Promise { + const oauth2Client = serverSideOAuth2Client() + + // Security: Verify session identity matches consent request subject + if (options?.identityId) { + const consentRequest = await oauth2Client.getOAuth2ConsentRequest({ + consentChallenge, + }) + + if (consentRequest.subject !== options.identityId) { + throw new Error( + "Forbidden: Session identity does not match consent request subject", + ) + } + } + + const response = await oauth2Client.rejectOAuth2ConsentRequest({ + consentChallenge, + rejectOAuth2Request: { + error: options?.error ?? "access_denied", + error_description: + options?.errorDescription ?? "The resource owner denied the request", + }, + }) + + return response.redirect_to +} diff --git a/packages/nextjs/src/app/index.ts b/packages/nextjs/src/app/index.ts index 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)