Skip to content

Commit c01d31c

Browse files
committed
feat(oauth2): integrate @csrf-armor/nextjs for consent flow CSRF protection
Adds signed-double-submit CSRF protection to all three Next.js example apps using @csrf-armor/nextjs. The middleware composes with the existing Ory middleware, skipping CSRF validation for Ory-proxied paths. Also adds session identity validation to the nextjs-app-router consent API route to match the pattern already used in the other two examples.
1 parent 5640874 commit c01d31c

14 files changed

Lines changed: 233 additions & 16 deletions

File tree

examples/nextjs-app-router-custom-components/.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ NEXT_PUBLIC_ORY_SDK_URL=https://nervous-lewin-4edwdlsbyw.projects.oryapis.com
33

44
# Set the API Token for support of SAML and OIDC.
55
ORY_PROJECT_API_TOKEN=
6+
7+
# Secret key for CSRF token signing (replace with a secure random value in production)
8+
CSRF_SECRET=change-me-to-a-32-char-secret!!

examples/nextjs-app-router-custom-components/app/auth/consent/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getServerSession,
88
OryPageParams,
99
} from "@ory/nextjs/app"
10+
import { cookies } from "next/headers"
1011

1112
import { myCustomComponents } from "@/components"
1213
import config from "@/ory.config"
@@ -19,12 +20,15 @@ export default async function ConsentPage(props: OryPageParams) {
1920
return null
2021
}
2122

23+
const cookieStore = await cookies()
24+
const csrfToken = cookieStore.get("csrf-token")?.value ?? ""
25+
2226
return (
2327
<Consent
2428
consentChallenge={consentRequest}
2529
session={session}
2630
config={config}
27-
csrfToken=""
31+
csrfToken={csrfToken}
2832
formActionUrl="/api/consent"
2933
components={myCustomComponents}
3034
/>
Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,52 @@
11
// Copyright © 2024 Ory Corp
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { createCsrfMiddleware } from "@csrf-armor/nextjs"
45
import { createOryMiddleware } from "@ory/nextjs/middleware"
6+
import { NextRequest, NextResponse } from "next/server"
7+
58
import oryConfig from "@/ory.config"
69

7-
// This function can be marked `async` if using `await` inside
8-
export const middleware = createOryMiddleware(oryConfig)
10+
const oryMiddleware = createOryMiddleware(oryConfig)
11+
12+
const csrfProtect = createCsrfMiddleware({
13+
strategy: "signed-double-submit",
14+
secret: process.env.CSRF_SECRET,
15+
cookie: {
16+
secure: process.env.NODE_ENV === "production",
17+
sameSite: "lax",
18+
httpOnly: false,
19+
},
20+
token: { fieldName: "csrf_token" },
21+
excludePaths: ["/self-service", "/sessions", "/.well-known", "/.ory"],
22+
})
23+
24+
const oryPathPrefixes = [
25+
"/self-service/",
26+
"/sessions/",
27+
"/.well-known/",
28+
"/.ory/",
29+
]
30+
31+
export async function middleware(request: NextRequest) {
32+
const oryResponse = await oryMiddleware(request)
33+
34+
const isOryPath = oryPathPrefixes.some((prefix) =>
35+
request.nextUrl.pathname.startsWith(prefix),
36+
)
37+
if (isOryPath) {
38+
return oryResponse
39+
}
40+
41+
const response = NextResponse.next()
42+
const result = await csrfProtect(request, response)
43+
if (!result.success) {
44+
return NextResponse.json(
45+
{ error: "CSRF validation failed" },
46+
{ status: 403 },
47+
)
48+
}
49+
return response
50+
}
951

10-
// See "Matching Paths" below to learn more
1152
export const config = {}

examples/nextjs-app-router-custom-components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12+
"@csrf-armor/nextjs": "^1.4.1",
1213
"@ory/client-fetch": "1.22.22",
1314
"@ory/elements-react": "*",
1415
"@ory/nextjs": "*",

examples/nextjs-app-router/.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ NEXT_PUBLIC_ORY_SDK_URL=https://nervous-lewin-4edwdlsbyw.projects.oryapis.com
33

44
# Set the API Token for support of SAML and OIDC.
55
ORY_PROJECT_API_TOKEN=
6+
7+
# Secret key for CSRF token signing (replace with a secure random value in production)
8+
CSRF_SECRET=change-me-to-a-32-char-secret!!

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

Lines changed: 41 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,24 @@ async function parseRequest(request: Request): Promise<ConsentBody> {
4044
}
4145

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

4567
const action = body.action
@@ -68,14 +90,31 @@ export async function POST(request: Request) {
6890
redirectTo = await acceptConsentRequest(consentChallenge, {
6991
grantScope,
7092
remember,
93+
identityId,
7194
})
7295
} else {
73-
redirectTo = await rejectConsentRequest(consentChallenge)
96+
redirectTo = await rejectConsentRequest(consentChallenge, {
97+
identityId,
98+
})
7499
}
75100

76101
return NextResponse.json({ redirect_to: redirectTo })
77102
} catch (error) {
78103
console.error("Consent error:", error)
104+
105+
if (
106+
error instanceof Error &&
107+
error.message.includes("does not match consent request subject")
108+
) {
109+
return NextResponse.json(
110+
{
111+
error: "forbidden",
112+
error_description: "Session does not match consent request subject",
113+
},
114+
{ status: 403 },
115+
)
116+
}
117+
79118
return NextResponse.json(
80119
{ error: "server_error", error_description: "Failed to process consent" },
81120
{ status: 500 },

examples/nextjs-app-router/app/auth/consent/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getServerSession,
88
OryPageParams,
99
} from "@ory/nextjs/app"
10+
import { cookies } from "next/headers"
1011

1112
import config from "@/ory.config"
1213

@@ -18,12 +19,15 @@ export default async function ConsentPage(props: OryPageParams) {
1819
return null
1920
}
2021

22+
const cookieStore = await cookies()
23+
const csrfToken = cookieStore.get("csrf-token")?.value ?? ""
24+
2125
return (
2226
<Consent
2327
consentChallenge={consentRequest}
2428
session={session}
2529
config={config}
26-
csrfToken=""
30+
csrfToken={csrfToken}
2731
formActionUrl="/api/consent"
2832
components={{
2933
Card: {},
Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,52 @@
11
// Copyright © 2024 Ory Corp
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { createCsrfMiddleware } from "@csrf-armor/nextjs"
45
import { createOryMiddleware } from "@ory/nextjs/middleware"
6+
import { NextRequest, NextResponse } from "next/server"
7+
58
import oryConfig from "@/ory.config"
69

7-
// This function can be marked `async` if using `await` inside
8-
export const middleware = createOryMiddleware(oryConfig)
10+
const oryMiddleware = createOryMiddleware(oryConfig)
11+
12+
const csrfProtect = createCsrfMiddleware({
13+
strategy: "signed-double-submit",
14+
secret: process.env.CSRF_SECRET,
15+
cookie: {
16+
secure: process.env.NODE_ENV === "production",
17+
sameSite: "lax",
18+
httpOnly: false,
19+
},
20+
token: { fieldName: "csrf_token" },
21+
excludePaths: ["/self-service", "/sessions", "/.well-known", "/.ory"],
22+
})
23+
24+
const oryPathPrefixes = [
25+
"/self-service/",
26+
"/sessions/",
27+
"/.well-known/",
28+
"/.ory/",
29+
]
30+
31+
export async function middleware(request: NextRequest) {
32+
const oryResponse = await oryMiddleware(request)
33+
34+
const isOryPath = oryPathPrefixes.some((prefix) =>
35+
request.nextUrl.pathname.startsWith(prefix),
36+
)
37+
if (isOryPath) {
38+
return oryResponse
39+
}
40+
41+
const response = NextResponse.next()
42+
const result = await csrfProtect(request, response)
43+
if (!result.success) {
44+
return NextResponse.json(
45+
{ error: "CSRF validation failed" },
46+
{ status: 403 },
47+
)
48+
}
49+
return response
50+
}
951

10-
// See "Matching Paths" below to learn more
1152
export const config = {}

examples/nextjs-app-router/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12-
"@ory/nextjs": "*",
12+
"@csrf-armor/nextjs": "^1.4.1",
1313
"@ory/elements-react": "*",
14+
"@ory/nextjs": "*",
1415
"next": "15.5.9",
1516
"react": "^18",
1617
"react-dom": "^18"

examples/nextjs-pages-router/.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ NEXT_PUBLIC_ORY_SDK_URL=https://nervous-lewin-4edwdlsbyw.projects.oryapis.com
33

44
# Set the API Token for support of SAML and OIDC.
55
ORY_PROJECT_API_TOKEN=
6+
7+
# Secret key for CSRF token signing (replace with a secure random value in production)
8+
CSRF_SECRET=change-me-to-a-32-char-secret!!

0 commit comments

Comments
 (0)