Skip to content

Commit 73020fc

Browse files
committed
fix(oauth2): validate session identity matches consent request subject
Add security validation to prevent consent hijacking attacks where an attacker could use a stolen consent_challenge to grant or reject consent on behalf of a different user. Changes: - Pages Router: verify session cookie and compare identity with subject - App Router: add identityId parameter to accept/reject functions - Return 401 for missing session, 403 for identity mismatch
1 parent a7d773e commit 73020fc

File tree

5 files changed

+153
-14
lines changed

5 files changed

+153
-14
lines changed

examples/nextjs-app-router-custom-components/app/api/consent/route.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
// Copyright © 2024 Ory Corp
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { acceptConsentRequest, rejectConsentRequest } from "@ory/nextjs/app"
4+
import {
5+
acceptConsentRequest,
6+
getServerSession,
7+
rejectConsentRequest,
8+
} from "@ory/nextjs/app"
59
import { NextResponse } from "next/server"
610

711
interface ConsentBody {
@@ -40,6 +44,25 @@ async function parseRequest(request: Request): Promise<ConsentBody> {
4044
}
4145

4246
export async function POST(request: Request) {
47+
// Security: Verify session exists before processing consent
48+
const session = await getServerSession()
49+
if (!session) {
50+
console.error("Consent security: No session found")
51+
return NextResponse.json(
52+
{ error: "unauthorized", error_description: "No session" },
53+
{ status: 401 },
54+
)
55+
}
56+
57+
const identityId = session.identity?.id
58+
if (!identityId) {
59+
console.error("Consent security: Session has no identity ID")
60+
return NextResponse.json(
61+
{ error: "unauthorized", error_description: "Invalid session" },
62+
{ status: 401 },
63+
)
64+
}
65+
4366
const body = await parseRequest(request)
4467

4568
const action = body.action
@@ -68,14 +91,32 @@ export async function POST(request: Request) {
6891
redirectTo = await acceptConsentRequest(consentChallenge, {
6992
grantScope,
7093
remember,
94+
identityId,
7195
})
7296
} else {
73-
redirectTo = await rejectConsentRequest(consentChallenge)
97+
redirectTo = await rejectConsentRequest(consentChallenge, {
98+
identityId,
99+
})
74100
}
75101

76102
return NextResponse.json({ redirect_to: redirectTo })
77103
} catch (error) {
78104
console.error("Consent error:", error)
105+
106+
// Check for identity mismatch error
107+
if (
108+
error instanceof Error &&
109+
error.message.includes("does not match consent request subject")
110+
) {
111+
return NextResponse.json(
112+
{
113+
error: "forbidden",
114+
error_description: "Session does not match consent request subject",
115+
},
116+
{ status: 403 },
117+
)
118+
}
119+
79120
return NextResponse.json(
80121
{ error: "server_error", error_description: "Failed to process consent" },
81122
{ status: 500 },

examples/nextjs-pages-router/pages/api/consent.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
// Copyright © 2024 Ory Corp
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { Configuration, OAuth2Api } from "@ory/client-fetch"
4+
import { Configuration, FrontendApi, OAuth2Api } from "@ory/client-fetch"
55
import type { NextApiRequest, NextApiResponse } from "next"
66

7-
function getOAuth2Client() {
7+
function getBaseUrl(): string {
88
const baseUrl = process.env.NEXT_PUBLIC_ORY_SDK_URL || process.env.ORY_SDK_URL
99
if (!baseUrl) {
1010
throw new Error("ORY_SDK_URL is not set")
1111
}
12+
return baseUrl.replace(/\/$/, "")
13+
}
1214

15+
function getOAuth2Client() {
1316
const apiKey = process.env.ORY_PROJECT_API_TOKEN ?? ""
1417

1518
return new OAuth2Api(
1619
new Configuration({
17-
basePath: baseUrl.replace(/\/$/, ""),
20+
basePath: getBaseUrl(),
1821
headers: {
1922
Accept: "application/json",
2023
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
@@ -23,6 +26,17 @@ function getOAuth2Client() {
2326
)
2427
}
2528

29+
function getFrontendClient() {
30+
return new FrontendApi(
31+
new Configuration({
32+
basePath: getBaseUrl(),
33+
headers: {
34+
Accept: "application/json",
35+
},
36+
}),
37+
)
38+
}
39+
2640
interface ConsentRequestBody {
2741
action?: string
2842
consent_challenge?: string
@@ -46,8 +60,45 @@ export default async function handler(
4660
}
4761

4862
const oauth2Client = getOAuth2Client()
63+
const frontendClient = getFrontendClient()
4964

5065
try {
66+
// Security: Fetch the consent request to get the expected subject
67+
const consentRequest = await oauth2Client.getOAuth2ConsentRequest({
68+
consentChallenge: consent_challenge,
69+
})
70+
71+
// Security: Verify the current session matches the consent challenge subject
72+
// This prevents an attacker from using a stolen consent_challenge
73+
// to grant consent on behalf of a different user
74+
const cookie = req.headers.cookie
75+
if (!cookie) {
76+
console.error("Consent security: No session cookie provided")
77+
return res.status(401).json({ error: "Unauthorized: No session" })
78+
}
79+
80+
let session
81+
try {
82+
session = await frontendClient.toSession({ cookie })
83+
} catch {
84+
console.error("Consent security: Invalid or expired session")
85+
return res.status(401).json({ error: "Unauthorized: Invalid session" })
86+
}
87+
88+
// Compare the session identity with the consent request subject
89+
const sessionIdentityId = session.identity?.id
90+
const consentSubject = consentRequest.subject
91+
92+
if (!sessionIdentityId || sessionIdentityId !== consentSubject) {
93+
console.error(
94+
"Consent security: Session identity mismatch. " +
95+
`Session: ${sessionIdentityId}, Consent subject: ${consentSubject}`,
96+
)
97+
return res.status(403).json({
98+
error: "Forbidden: Session does not match consent request subject",
99+
})
100+
}
101+
51102
let redirectTo: string
52103

53104
if (action === "accept") {

packages/nextjs/api-report/nextjs-client.api.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@
364364
{
365365
"kind": "Function",
366366
"canonicalReference": "@ory/nextjs!acceptConsentRequest:function(1)",
367-
"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",
367+
"docComment": "/**\n * Accept an OAuth2 consent request.\n *\n * This method should be called from an API route handler when the user accepts the consent. It validates that the provided session identity matches the consent request subject to prevent consent hijacking attacks.\n *\n * @param consentChallenge - The consent challenge from the form.\n *\n * @param options - Options for accepting the consent request.\n *\n * @returns The redirect URL to complete the OAuth2 flow.\n *\n * @example\n * ```tsx\n * // app/api/consent/route.ts\n * import { acceptConsentRequest, rejectConsentRequest, getServerSession } from \"@ory/nextjs/app\"\n * import { redirect } from \"next/navigation\"\n *\n * export async function POST(request: Request) {\n * const session = await getServerSession()\n * if (!session) {\n * return new Response(\"Unauthorized\", { status: 401 })\n * }\n *\n * const formData = await request.formData()\n * const action = formData.get(\"action\")\n * const consentChallenge = formData.get(\"consent_challenge\") as string\n * const grantScope = formData.getAll(\"grant_scope\") as string[]\n * const remember = formData.get(\"remember\") === \"true\"\n *\n * if (action === \"accept\") {\n * const redirectTo = await acceptConsentRequest(consentChallenge, {\n * grantScope,\n * remember,\n * identityId: session.identity?.id,\n * })\n * return redirect(redirectTo)\n * } else {\n * const redirectTo = await rejectConsentRequest(consentChallenge, {\n * identityId: session.identity?.id,\n * })\n * return redirect(redirectTo)\n * }\n * }\n * ```\n *\n * @throws\n *\n * Error if identityId doesn't match the consent request subject.\n *\n * @public\n */\n",
368368
"excerptTokens": [
369369
{
370370
"kind": "Content",
@@ -380,7 +380,7 @@
380380
},
381381
{
382382
"kind": "Content",
383-
"text": "{\n grantScope: string[];\n remember?: boolean;\n rememberFor?: number;\n session?: {\n accessToken?: "
383+
"text": "{\n grantScope: string[];\n remember?: boolean;\n rememberFor?: number;\n identityId?: string;\n session?: {\n accessToken?: "
384384
},
385385
{
386386
"kind": "Reference",
@@ -1459,7 +1459,7 @@
14591459
{
14601460
"kind": "Function",
14611461
"canonicalReference": "@ory/nextjs!rejectConsentRequest:function(1)",
1462-
"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",
1462+
"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",
14631463
"excerptTokens": [
14641464
{
14651465
"kind": "Content",
@@ -1475,7 +1475,7 @@
14751475
},
14761476
{
14771477
"kind": "Content",
1478-
"text": "{\n error?: string;\n errorDescription?: string;\n}"
1478+
"text": "{\n error?: string;\n errorDescription?: string;\n identityId?: string;\n}"
14791479
},
14801480
{
14811481
"kind": "Content",

packages/nextjs/api-report/nextjs.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function acceptConsentRequest(consentChallenge: string, options: {
2424
grantScope: string[];
2525
remember?: boolean;
2626
rememberFor?: number;
27+
identityId?: string;
2728
session?: {
2829
accessToken?: Record<string, unknown>;
2930
idToken?: Record<string, unknown>;
@@ -105,6 +106,7 @@ export interface OryPageParams {
105106
export function rejectConsentRequest(consentChallenge: string, options?: {
106107
error?: string;
107108
errorDescription?: string;
109+
identityId?: string;
108110
}): Promise<string>;
109111

110112
// @public

packages/nextjs/src/app/consent.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,21 @@ export async function getConsentFlow(
6666
* Accept an OAuth2 consent request.
6767
*
6868
* This method should be called from an API route handler when the user accepts the consent.
69+
* It validates that the provided session identity matches the consent request subject
70+
* to prevent consent hijacking attacks.
6971
*
7072
* @example
7173
* ```tsx
7274
* // app/api/consent/route.ts
73-
* import { acceptConsentRequest, rejectConsentRequest } from "@ory/nextjs/app"
75+
* import { acceptConsentRequest, rejectConsentRequest, getServerSession } from "@ory/nextjs/app"
7476
* import { redirect } from "next/navigation"
7577
*
7678
* export async function POST(request: Request) {
79+
* const session = await getServerSession()
80+
* if (!session) {
81+
* return new Response("Unauthorized", { status: 401 })
82+
* }
83+
*
7784
* const formData = await request.formData()
7885
* const action = formData.get("action")
7986
* const consentChallenge = formData.get("consent_challenge") as string
@@ -84,11 +91,13 @@ export async function getConsentFlow(
8491
* const redirectTo = await acceptConsentRequest(consentChallenge, {
8592
* grantScope,
8693
* remember,
87-
* session: { ... }
94+
* identityId: session.identity?.id,
8895
* })
8996
* return redirect(redirectTo)
9097
* } else {
91-
* const redirectTo = await rejectConsentRequest(consentChallenge)
98+
* const redirectTo = await rejectConsentRequest(consentChallenge, {
99+
* identityId: session.identity?.id,
100+
* })
92101
* return redirect(redirectTo)
93102
* }
94103
* }
@@ -97,6 +106,7 @@ export async function getConsentFlow(
97106
* @param consentChallenge - The consent challenge from the form.
98107
* @param options - Options for accepting the consent request.
99108
* @returns The redirect URL to complete the OAuth2 flow.
109+
* @throws Error if identityId doesn't match the consent request subject.
100110
* @public
101111
*/
102112
export async function acceptConsentRequest(
@@ -105,13 +115,29 @@ export async function acceptConsentRequest(
105115
grantScope: string[]
106116
remember?: boolean
107117
rememberFor?: number
118+
identityId?: string
108119
session?: {
109120
accessToken?: Record<string, unknown>
110121
idToken?: Record<string, unknown>
111122
}
112123
},
113124
): Promise<string> {
114-
const response = await serverSideOAuth2Client().acceptOAuth2ConsentRequest({
125+
const oauth2Client = serverSideOAuth2Client()
126+
127+
// Security: Verify session identity matches consent request subject
128+
if (options.identityId) {
129+
const consentRequest = await oauth2Client.getOAuth2ConsentRequest({
130+
consentChallenge,
131+
})
132+
133+
if (consentRequest.subject !== options.identityId) {
134+
throw new Error(
135+
"Forbidden: Session identity does not match consent request subject",
136+
)
137+
}
138+
}
139+
140+
const response = await oauth2Client.acceptOAuth2ConsentRequest({
115141
consentChallenge,
116142
acceptOAuth2ConsentRequest: {
117143
grant_scope: options.grantScope,
@@ -133,20 +159,39 @@ export async function acceptConsentRequest(
133159
* Reject an OAuth2 consent request.
134160
*
135161
* This method should be called from an API route handler when the user rejects the consent.
162+
* It validates that the provided session identity matches the consent request subject
163+
* to prevent consent hijacking attacks.
136164
*
137165
* @param consentChallenge - The consent challenge from the form.
138166
* @param options - Options for rejecting the consent request.
139167
* @returns The redirect URL to complete the OAuth2 flow.
168+
* @throws Error if identityId doesn't match the consent request subject.
140169
* @public
141170
*/
142171
export async function rejectConsentRequest(
143172
consentChallenge: string,
144173
options?: {
145174
error?: string
146175
errorDescription?: string
176+
identityId?: string
147177
},
148178
): Promise<string> {
149-
const response = await serverSideOAuth2Client().rejectOAuth2ConsentRequest({
179+
const oauth2Client = serverSideOAuth2Client()
180+
181+
// Security: Verify session identity matches consent request subject
182+
if (options?.identityId) {
183+
const consentRequest = await oauth2Client.getOAuth2ConsentRequest({
184+
consentChallenge,
185+
})
186+
187+
if (consentRequest.subject !== options.identityId) {
188+
throw new Error(
189+
"Forbidden: Session identity does not match consent request subject",
190+
)
191+
}
192+
}
193+
194+
const response = await oauth2Client.rejectOAuth2ConsentRequest({
150195
consentChallenge,
151196
rejectOAuth2Request: {
152197
error: options?.error ?? "access_denied",

0 commit comments

Comments
 (0)