Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/oauth2-consent-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ory/elements-react": minor
"@ory/nextjs": minor
---

Add OAuth2 consent flow support. `@ory/nextjs` gains `getConsentFlow`, `acceptConsentRequest` and `rejectConsentRequest` for the app router, plus `getConsentFlow` and `useSession` for the pages router, validating that the active session identity matches the consent request subject. `@ory/elements-react` renders the OAuth2 client name and logo on the consent screen. The Next.js examples demonstrate CSRF protection for the consent endpoint using `@csrf-armor/nextjs`.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ storybook-static

/test-results/
.nx
coverage/
coverage/
3 changes: 3 additions & 0 deletions examples/nextjs-app-router-custom-components/.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ NEXT_PUBLIC_ORY_SDK_URL=https://nervous-lewin-4edwdlsbyw.projects.oryapis.com

# Set the API Token for support of SAML and OIDC.
ORY_PROJECT_API_TOKEN=

# Secret key for CSRF token signing (replace with a secure random value in production)
CSRF_SECRET=change-me-to-a-32-char-secret!!
140 changes: 140 additions & 0 deletions examples/nextjs-app-router-custom-components/app/api/consent/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

import {
acceptConsentRequest,
getServerSession,
rejectConsentRequest,
} from "@ory/nextjs/app"
import { cookies } from "next/headers"
import { NextResponse } from "next/server"

interface ConsentBody {
action?: string
consent_challenge?: string
grant_scope?: string | string[]
remember?: boolean | string
csrf_token?: string
}

async function parseRequest(request: Request): Promise<ConsentBody> {
const contentType = request.headers.get("content-type") || ""

if (contentType.includes("application/json")) {
return (await request.json()) as ConsentBody
}

if (
contentType.includes("application/x-www-form-urlencoded") ||
contentType.includes("multipart/form-data")
) {
const formData = await request.formData()
return {
action: formData.get("action") as string,
consent_challenge: formData.get("consent_challenge") as string,
grant_scope: formData.getAll("grant_scope") as string[],
remember: formData.get("remember") as string,
csrf_token: formData.get("csrf_token") as string,
}
}

// Try JSON as fallback
try {
return (await request.json()) as ConsentBody
} catch {
return {}
}
}

export async function POST(request: Request) {
// Security: Verify session exists before processing consent
const session = await getServerSession()
if (!session) {
console.error("Consent security: No session found")
return NextResponse.json(
{ error: "unauthorized", error_description: "No session" },
{ status: 401 },
)
}

const identityId = session.identity?.id
if (!identityId) {
console.error("Consent security: Session has no identity ID")
return NextResponse.json(
{ error: "unauthorized", error_description: "Invalid session" },
{ status: 401 },
)
}

const body = await parseRequest(request)

// Defense in depth: the CSRF middleware validates the signed cookie pair,
// but the token extraction of @csrf-armor/nextjs falls back to the cookie
// itself, so the submitted field must be compared against it explicitly.
const cookieStore = await cookies()
const csrfCookie = cookieStore.get("csrf-token")?.value
if (!csrfCookie || body.csrf_token !== csrfCookie) {
return NextResponse.json(
{ error: "invalid_request", error_description: "CSRF token mismatch" },
{ status: 403 },
)
}

const action = body.action
const consentChallenge = body.consent_challenge
const grantScope = Array.isArray(body.grant_scope)
? body.grant_scope
: body.grant_scope
? [body.grant_scope]
: []
const remember = body.remember === true || body.remember === "true"

if (!consentChallenge) {
return NextResponse.json(
{
error: "invalid_request",
error_description: "Missing consent_challenge",
},
{ status: 400 },
)
}

try {
let redirectTo: string

if (action === "accept") {
redirectTo = await acceptConsentRequest(consentChallenge, {
grantScope,
remember,
identityId,
})
} else {
redirectTo = await rejectConsentRequest(consentChallenge, {
identityId,
})
}

return NextResponse.json({ redirect_to: redirectTo })
} catch (error) {
console.error("Consent error:", error)

// Check for identity mismatch error
if (
error instanceof Error &&
error.message.includes("does not match consent request subject")
) {
return NextResponse.json(
{
error: "forbidden",
error_description: "Session does not match consent request subject",
},
{ status: 403 },
)
}

return NextResponse.json(
{ error: "server_error", error_description: "Failed to process consent" },
{ status: 500 },
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
"use client"

import { getCsrfToken } from "@csrf-armor/nextjs/client"
import { OAuth2ConsentRequest, Session } from "@ory/client-fetch"
import { Consent } from "@ory/elements-react/theme"
import { useEffect, useState } from "react"

import { myCustomComponents } from "@/components"
import config from "@/ory.config"

interface ConsentFormProps {
consentRequest: OAuth2ConsentRequest
session: Session
}

export function ConsentForm({ consentRequest, session }: ConsentFormProps) {
// The csrf-token cookie is set by the CSRF middleware on the first response,
// so it is only readable client-side, after hydration.
const [csrfToken, setCsrfToken] = useState("")

useEffect(() => {
setCsrfToken(getCsrfToken() ?? "")
}, [])

return (
<Consent
consentChallenge={consentRequest}
session={session}
config={config}
csrfToken={csrfToken}
formActionUrl="/api/consent"
components={myCustomComponents}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

import {
getConsentFlow,
getServerSession,
OryPageParams,
} from "@ory/nextjs/app"

import { ConsentForm } from "./consent-form"

export default async function ConsentPage(props: OryPageParams) {
const consentRequest = await getConsentFlow(props.searchParams)
const session = await getServerSession()

if (!consentRequest || !session) {
return null
}

if (session.identity?.id !== consentRequest.subject) {
return <p>This consent request was issued for a different account.</p>
}

return <ConsentForm consentRequest={consentRequest} session={session} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

import { UiNode, UiNodeInputAttributesTypeEnum } from "@ory/client-fetch"
import { isUiNodeInput, UiNodeInput } from "@ory/elements-react"

/**
* Finds consent-specific nodes from the UI nodes list.
*/
export function findConsentNodes(nodes: UiNode[]) {
let rememberNode: UiNodeInput | undefined
const submitNodes: UiNodeInput[] = []

for (const node of nodes) {
if (!isUiNodeInput(node)) {
continue
}

if (node.attributes.name === "remember") {
rememberNode = node
} else if (node.attributes.type === UiNodeInputAttributesTypeEnum.Submit) {
submitNodes.push(node)
}
}

return { rememberNode, submitNodes }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

import { OryNodeConsentScopeCheckboxProps } from "@ory/elements-react"

const scopeLabels: Record<string, { title: string; description: string }> = {
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 (
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<div className="flex-1">
<p className="font-medium text-gray-900">{label.title}</p>
{label.description && (
<p className="text-sm text-gray-500">{label.description}</p>
)}
</div>
<div className="ml-4">
<button
type="button"
role="switch"
aria-checked={inputProps.checked}
onClick={() => onCheckedChange(!inputProps.checked)}
disabled={inputProps.disabled}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
${inputProps.checked ? "bg-blue-600" : "bg-gray-300"}
${inputProps.disabled ? "opacity-50 cursor-not-allowed" : ""}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${inputProps.checked ? "translate-x-6" : "translate-x-1"}
`}
/>
</button>
</div>
</label>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// SPDX-License-Identifier: Apache-2.0

import { FlowType } from "@ory/client-fetch"
import { useOryFlow } from "@ory/elements-react"
import { ConsentFlow, Node, useOryFlow } from "@ory/elements-react"
import Link from "next/link"
import { findConsentNodes } from "./consent-utils"

export function MyCustomFooter() {
const flow = useOryFlow()
Expand Down Expand Up @@ -31,8 +32,40 @@ export function MyCustomFooter() {
case FlowType.Verification:
return null
case FlowType.OAuth2Consent:
return null
return <ConsentFooter flow={flow.flow} />
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 (
<div className="flex flex-col gap-4">
<div>
<p className="font-medium text-gray-700">
Make sure you trust {clientName}
</p>
<p className="text-sm text-gray-500">
You may be sharing sensitive information with this site or
application.
</p>
</div>

{rememberNode && <Node.Checkbox node={rememberNode} />}

<div className="grid grid-cols-2 gap-2">
{submitNodes.map((node) => (
<Node.Button key={String(node.attributes.value)} node={node} />
))}
</div>

<p className="text-xs text-gray-400">
Authorizing will redirect to {clientName}
</p>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -20,6 +21,7 @@ export const myCustomComponents: OryFlowComponentOverrides = {
Input: MyCustomInput,
CodeInput: MyCustomPinCodeInput,
Checkbox: MyCustomCheckbox,
ConsentScopeCheckbox: MyCustomConsentScopeCheckbox,
Image: MyCustomImage,
Label: MyCustomLabel,
},
Expand Down
Loading
Loading