@edcalderon/auth 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +4 -11
- package/docs/authentik-integration-guide.md +286 -0
- package/docs/cig-reference-map.md +118 -0
- package/docs/nextjs-examples.md +784 -0
- package/docs/provisioning-model.md +416 -0
- package/docs/upgrade-migration.md +256 -0
- package/package.json +1 -1
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
# Next.js Reference Examples
|
|
2
|
+
|
|
3
|
+
> **Package:** `@edcalderon/auth/authentik` (v1.4.0+)
|
|
4
|
+
> **Framework:** Next.js 14/15 App Router
|
|
5
|
+
|
|
6
|
+
This document provides complete, copy-paste-ready Next.js examples for every common Authentik integration pattern. Each example builds on the previous one.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
1. [Shared Configuration](#shared-configuration)
|
|
13
|
+
2. [Example 1: Same-Origin Login + Callback](#example-1-same-origin-login--callback)
|
|
14
|
+
3. [Example 2: Cross-Origin Login via Relay](#example-2-cross-origin-login-via-relay)
|
|
15
|
+
4. [Example 3: Supabase Provisioning Adapter](#example-3-supabase-provisioning-adapter)
|
|
16
|
+
5. [Example 4: Full Integrated Flow (Authentik + Supabase + Sync)](#example-4-full-integrated-flow-authentik--supabase--sync)
|
|
17
|
+
6. [Example 5: Logout with Authentik Invalidation](#example-5-logout-with-authentik-invalidation)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Shared Configuration
|
|
22
|
+
|
|
23
|
+
All examples use this shared configuration module:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// lib/authentik-config.ts
|
|
27
|
+
import {
|
|
28
|
+
discoverEndpoints,
|
|
29
|
+
validateFullConfig,
|
|
30
|
+
type AuthentikEndpoints,
|
|
31
|
+
} from "@edcalderon/auth/authentik";
|
|
32
|
+
|
|
33
|
+
// Cache endpoints at module level (resolved once at startup)
|
|
34
|
+
let _endpoints: AuthentikEndpoints | null = null;
|
|
35
|
+
|
|
36
|
+
export async function getEndpoints(): Promise<AuthentikEndpoints> {
|
|
37
|
+
if (!_endpoints) {
|
|
38
|
+
_endpoints = await discoverEndpoints(process.env.AUTHENTIK_ISSUER!);
|
|
39
|
+
}
|
|
40
|
+
return _endpoints;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getAuthentikConfig() {
|
|
44
|
+
return {
|
|
45
|
+
issuer: process.env.AUTHENTIK_ISSUER!,
|
|
46
|
+
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
|
47
|
+
redirectUri: process.env.AUTHENTIK_REDIRECT_URI!,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Run at app startup (e.g. in instrumentation.ts)
|
|
52
|
+
export async function validateConfig() {
|
|
53
|
+
const endpoints = await getEndpoints();
|
|
54
|
+
const result = validateFullConfig(
|
|
55
|
+
{
|
|
56
|
+
...getAuthentikConfig(),
|
|
57
|
+
tokenEndpoint: endpoints.token,
|
|
58
|
+
userinfoEndpoint: endpoints.userinfo,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
supabaseUrl: process.env.SUPABASE_URL,
|
|
62
|
+
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (!result.valid) {
|
|
67
|
+
const errors = result.checks.filter((c) => !c.passed);
|
|
68
|
+
console.error("[authentik] Config validation failed:", errors);
|
|
69
|
+
throw new Error("Authentik configuration is invalid");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Required `.env.local`:**
|
|
75
|
+
|
|
76
|
+
```env
|
|
77
|
+
AUTHENTIK_ISSUER=https://auth.example.com/application/o/my-app/
|
|
78
|
+
AUTHENTIK_CLIENT_ID=your-client-id
|
|
79
|
+
AUTHENTIK_REDIRECT_URI=https://app.example.com/auth/callback
|
|
80
|
+
|
|
81
|
+
# For Supabase provisioning (Examples 3-5)
|
|
82
|
+
SUPABASE_URL=https://xxx.supabase.co
|
|
83
|
+
SUPABASE_SERVICE_ROLE_KEY=eyJ...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Example 1: Same-Origin Login + Callback
|
|
89
|
+
|
|
90
|
+
The simplest pattern: login page and callback handler on the same Next.js app.
|
|
91
|
+
|
|
92
|
+
### Login Page
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
// app/login/page.tsx
|
|
96
|
+
"use client";
|
|
97
|
+
|
|
98
|
+
import { useState } from "react";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generate PKCE parameters client-side, store in sessionStorage,
|
|
102
|
+
* and redirect to Authentik.
|
|
103
|
+
*/
|
|
104
|
+
async function startLogin(provider: string) {
|
|
105
|
+
// Generate PKCE
|
|
106
|
+
const codeVerifier = crypto.randomUUID() + crypto.randomUUID();
|
|
107
|
+
const encoder = new TextEncoder();
|
|
108
|
+
const data = encoder.encode(codeVerifier);
|
|
109
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
110
|
+
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
111
|
+
.replace(/\+/g, "-")
|
|
112
|
+
.replace(/\//g, "_")
|
|
113
|
+
.replace(/=+$/, "");
|
|
114
|
+
const state = crypto.randomUUID();
|
|
115
|
+
|
|
116
|
+
// Store in sessionStorage for the callback
|
|
117
|
+
sessionStorage.setItem("authentik_relay:verifier", codeVerifier);
|
|
118
|
+
sessionStorage.setItem("authentik_relay:state", state);
|
|
119
|
+
sessionStorage.setItem("authentik_relay:provider", provider);
|
|
120
|
+
|
|
121
|
+
// Build authorize URL
|
|
122
|
+
const params = new URLSearchParams({
|
|
123
|
+
response_type: "code",
|
|
124
|
+
client_id: process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID!,
|
|
125
|
+
redirect_uri: process.env.NEXT_PUBLIC_AUTHENTIK_REDIRECT_URI!,
|
|
126
|
+
scope: "openid profile email",
|
|
127
|
+
state,
|
|
128
|
+
code_challenge: codeChallenge,
|
|
129
|
+
code_challenge_method: "S256",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Redirect to Authentik social login source
|
|
133
|
+
const issuerOrigin = new URL(process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER!).origin;
|
|
134
|
+
const authorizeUrl = `${issuerOrigin}/application/o/authorize/?${params}`;
|
|
135
|
+
const loginUrl = `${issuerOrigin}/source/oauth/login/${provider}/?next=${encodeURIComponent(authorizeUrl)}`;
|
|
136
|
+
|
|
137
|
+
window.location.href = loginUrl;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default function LoginPage() {
|
|
141
|
+
const [loading, setLoading] = useState(false);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div>
|
|
145
|
+
<h1>Sign In</h1>
|
|
146
|
+
<button
|
|
147
|
+
disabled={loading}
|
|
148
|
+
onClick={() => { setLoading(true); startLogin("google"); }}
|
|
149
|
+
>
|
|
150
|
+
Sign in with Google
|
|
151
|
+
</button>
|
|
152
|
+
<button
|
|
153
|
+
disabled={loading}
|
|
154
|
+
onClick={() => { setLoading(true); startLogin("github"); }}
|
|
155
|
+
>
|
|
156
|
+
Sign in with GitHub
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Callback Page
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
// app/auth/callback/page.tsx
|
|
167
|
+
"use client";
|
|
168
|
+
|
|
169
|
+
import { useEffect, useState } from "react";
|
|
170
|
+
import { useSearchParams, useRouter } from "next/navigation";
|
|
171
|
+
import { readRelayStorage, clearRelayStorage } from "@edcalderon/auth/authentik";
|
|
172
|
+
|
|
173
|
+
export default function CallbackPage() {
|
|
174
|
+
const searchParams = useSearchParams();
|
|
175
|
+
const router = useRouter();
|
|
176
|
+
const [error, setError] = useState<string | null>(null);
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
async function handleCallback() {
|
|
180
|
+
const code = searchParams.get("code");
|
|
181
|
+
const state = searchParams.get("state");
|
|
182
|
+
|
|
183
|
+
if (!code || !state) {
|
|
184
|
+
setError("Missing code or state in callback URL");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Read PKCE params from sessionStorage
|
|
189
|
+
const relay = readRelayStorage(sessionStorage);
|
|
190
|
+
if (!relay) {
|
|
191
|
+
setError("Missing relay data in sessionStorage — did the login flow complete?");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Validate state
|
|
196
|
+
if (state !== relay.state) {
|
|
197
|
+
setError("State mismatch — possible CSRF attack");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Exchange code for tokens via server-side API route
|
|
202
|
+
const response = await fetch("/api/auth/callback", {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
code,
|
|
207
|
+
codeVerifier: relay.codeVerifier,
|
|
208
|
+
state,
|
|
209
|
+
provider: relay.provider,
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const result = await response.json();
|
|
214
|
+
|
|
215
|
+
// Clean up relay storage
|
|
216
|
+
clearRelayStorage(sessionStorage);
|
|
217
|
+
|
|
218
|
+
if (!result.success) {
|
|
219
|
+
setError(result.error || "Authentication failed");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Redirect to the app
|
|
224
|
+
router.push(relay.next || "/dashboard");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
handleCallback();
|
|
228
|
+
}, [searchParams, router]);
|
|
229
|
+
|
|
230
|
+
if (error) {
|
|
231
|
+
return <div><h1>Authentication Error</h1><p>{error}</p></div>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return <div><p>Completing sign-in…</p></div>;
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Server-Side API Route
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// app/api/auth/callback/route.ts
|
|
242
|
+
import { NextResponse } from "next/server";
|
|
243
|
+
import { processCallback } from "@edcalderon/auth/authentik";
|
|
244
|
+
import { getAuthentikConfig, getEndpoints } from "@/lib/authentik-config";
|
|
245
|
+
|
|
246
|
+
export async function POST(request: Request) {
|
|
247
|
+
const { code, codeVerifier, state, provider } = await request.json();
|
|
248
|
+
|
|
249
|
+
const endpoints = await getEndpoints();
|
|
250
|
+
const config = getAuthentikConfig();
|
|
251
|
+
|
|
252
|
+
const result = await processCallback({
|
|
253
|
+
config: {
|
|
254
|
+
...config,
|
|
255
|
+
tokenEndpoint: endpoints.token,
|
|
256
|
+
userinfoEndpoint: endpoints.userinfo,
|
|
257
|
+
},
|
|
258
|
+
code,
|
|
259
|
+
codeVerifier,
|
|
260
|
+
state,
|
|
261
|
+
expectedState: state, // In same-origin, client validates state
|
|
262
|
+
provider,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (!result.success) {
|
|
266
|
+
return NextResponse.json(
|
|
267
|
+
{ success: false, error: result.error, errorCode: result.errorCode },
|
|
268
|
+
{ status: 400 },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Set session cookie, store tokens, etc.
|
|
273
|
+
// ... your session management logic here ...
|
|
274
|
+
|
|
275
|
+
return NextResponse.json({ success: true });
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Example 2: Cross-Origin Login via Relay
|
|
282
|
+
|
|
283
|
+
When your landing page (login) and dashboard (callback) are on different origins — e.g. `https://landing.example.com` and `https://app.example.com`.
|
|
284
|
+
|
|
285
|
+
### Login Page (Landing Origin)
|
|
286
|
+
|
|
287
|
+
```tsx
|
|
288
|
+
// On landing.example.com
|
|
289
|
+
// app/login/page.tsx
|
|
290
|
+
"use client";
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Generate PKCE and redirect to the relay on the dashboard origin.
|
|
294
|
+
* The relay stores PKCE params in the dashboard's sessionStorage.
|
|
295
|
+
*/
|
|
296
|
+
async function startCrossOriginLogin(provider: string) {
|
|
297
|
+
const codeVerifier = crypto.randomUUID() + crypto.randomUUID();
|
|
298
|
+
const encoder = new TextEncoder();
|
|
299
|
+
const data = encoder.encode(codeVerifier);
|
|
300
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
301
|
+
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
302
|
+
.replace(/\+/g, "-")
|
|
303
|
+
.replace(/\//g, "_")
|
|
304
|
+
.replace(/=+$/, "");
|
|
305
|
+
const state = crypto.randomUUID();
|
|
306
|
+
|
|
307
|
+
// Redirect to the relay route on the dashboard origin
|
|
308
|
+
const relayUrl = new URL("https://app.example.com/auth/relay");
|
|
309
|
+
relayUrl.searchParams.set("provider", provider);
|
|
310
|
+
relayUrl.searchParams.set("code_verifier", codeVerifier);
|
|
311
|
+
relayUrl.searchParams.set("code_challenge", codeChallenge);
|
|
312
|
+
relayUrl.searchParams.set("state", state);
|
|
313
|
+
relayUrl.searchParams.set("next", "/dashboard");
|
|
314
|
+
|
|
315
|
+
window.location.href = relayUrl.toString();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export default function LoginPage() {
|
|
319
|
+
return (
|
|
320
|
+
<div>
|
|
321
|
+
<h1>Welcome</h1>
|
|
322
|
+
<button onClick={() => startCrossOriginLogin("google")}>
|
|
323
|
+
Sign in with Google
|
|
324
|
+
</button>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Relay Route (Dashboard Origin)
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// On app.example.com
|
|
334
|
+
// app/auth/relay/route.ts
|
|
335
|
+
import { parseRelayParams, createRelayPageHtml } from "@edcalderon/auth/authentik";
|
|
336
|
+
import { getAuthentikConfig, getEndpoints } from "@/lib/authentik-config";
|
|
337
|
+
|
|
338
|
+
export async function GET(request: Request) {
|
|
339
|
+
const url = new URL(request.url);
|
|
340
|
+
const params = parseRelayParams(url.searchParams);
|
|
341
|
+
|
|
342
|
+
if (!params) {
|
|
343
|
+
return new Response("Missing required relay parameters", { status: 400 });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const config = getAuthentikConfig();
|
|
347
|
+
const endpoints = await getEndpoints();
|
|
348
|
+
|
|
349
|
+
const { html } = createRelayPageHtml(
|
|
350
|
+
{
|
|
351
|
+
issuer: config.issuer,
|
|
352
|
+
clientId: config.clientId,
|
|
353
|
+
redirectUri: config.redirectUri,
|
|
354
|
+
authorizePath: endpoints.authorization,
|
|
355
|
+
// No providerFlowSlugs → uses direct social login by default
|
|
356
|
+
},
|
|
357
|
+
params,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
return new Response(html, {
|
|
361
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Callback Page (Dashboard Origin)
|
|
367
|
+
|
|
368
|
+
The callback page is identical to [Example 1's callback page](#callback-page) — it reads PKCE params from `sessionStorage` (which was populated by the relay page) and exchanges the code for tokens.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Example 3: Supabase Provisioning Adapter
|
|
373
|
+
|
|
374
|
+
Adding user provisioning so that every authenticated user gets a `public.users` record and a shadow `auth.users` record.
|
|
375
|
+
|
|
376
|
+
### Server-Side Adapter Setup
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
// lib/provisioning.ts
|
|
380
|
+
import { createClient } from "@supabase/supabase-js";
|
|
381
|
+
import { createSupabaseSyncAdapter } from "@edcalderon/auth/authentik";
|
|
382
|
+
|
|
383
|
+
// Create a Supabase client with service_role key (server-side only)
|
|
384
|
+
const supabaseAdmin = createClient(
|
|
385
|
+
process.env.SUPABASE_URL!,
|
|
386
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
export const provisioningAdapter = createSupabaseSyncAdapter(supabaseAdmin, {
|
|
390
|
+
supabaseUrl: process.env.SUPABASE_URL!,
|
|
391
|
+
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
392
|
+
createShadowAuthUser: true,
|
|
393
|
+
rollbackOnFailure: true,
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### API Route with Provisioning
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
// app/api/auth/callback/route.ts
|
|
401
|
+
import { NextResponse } from "next/server";
|
|
402
|
+
import { processCallback } from "@edcalderon/auth/authentik";
|
|
403
|
+
import { getAuthentikConfig, getEndpoints } from "@/lib/authentik-config";
|
|
404
|
+
import { provisioningAdapter } from "@/lib/provisioning";
|
|
405
|
+
|
|
406
|
+
export async function POST(request: Request) {
|
|
407
|
+
const { code, codeVerifier, state, provider } = await request.json();
|
|
408
|
+
|
|
409
|
+
const endpoints = await getEndpoints();
|
|
410
|
+
const config = getAuthentikConfig();
|
|
411
|
+
|
|
412
|
+
const result = await processCallback({
|
|
413
|
+
config: {
|
|
414
|
+
...config,
|
|
415
|
+
tokenEndpoint: endpoints.token,
|
|
416
|
+
userinfoEndpoint: endpoints.userinfo,
|
|
417
|
+
},
|
|
418
|
+
code,
|
|
419
|
+
codeVerifier,
|
|
420
|
+
state,
|
|
421
|
+
expectedState: state,
|
|
422
|
+
provider,
|
|
423
|
+
// The provisioning adapter blocks until sync completes
|
|
424
|
+
provisioningAdapter,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (!result.success) {
|
|
428
|
+
return NextResponse.json(
|
|
429
|
+
{ success: false, error: result.error, errorCode: result.errorCode },
|
|
430
|
+
{ status: 400 },
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// At this point, the user is guaranteed to exist in:
|
|
435
|
+
// - public.users (with their OIDC identity)
|
|
436
|
+
// - auth.users (shadow record, if enabled)
|
|
437
|
+
const { authUserId, authUserCreated } = result.provisioningResult || {};
|
|
438
|
+
|
|
439
|
+
return NextResponse.json({
|
|
440
|
+
success: true,
|
|
441
|
+
authUserId,
|
|
442
|
+
isNewUser: authUserCreated,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## Example 4: Full Integrated Flow (Authentik + Supabase + Sync)
|
|
450
|
+
|
|
451
|
+
A complete example combining cross-origin relay, Supabase provisioning, and mandatory sync before redirect.
|
|
452
|
+
|
|
453
|
+
### File Structure
|
|
454
|
+
|
|
455
|
+
```
|
|
456
|
+
app/
|
|
457
|
+
├── auth/
|
|
458
|
+
│ ├── relay/route.ts # Cross-origin relay handler
|
|
459
|
+
│ ├── callback/page.tsx # Client callback page
|
|
460
|
+
│ └── error/page.tsx # Error display page
|
|
461
|
+
├── api/auth/
|
|
462
|
+
│ ├── callback/route.ts # Server-side token exchange + provisioning
|
|
463
|
+
│ └── validate/route.ts # Health check endpoint
|
|
464
|
+
├── dashboard/page.tsx # Protected page
|
|
465
|
+
└── layout.tsx
|
|
466
|
+
lib/
|
|
467
|
+
├── authentik-config.ts # Shared config (from above)
|
|
468
|
+
└── provisioning.ts # Supabase adapter (from above)
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Relay Route
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
// app/auth/relay/route.ts
|
|
475
|
+
import { parseRelayParams, createRelayPageHtml } from "@edcalderon/auth/authentik";
|
|
476
|
+
import { getAuthentikConfig, getEndpoints } from "@/lib/authentik-config";
|
|
477
|
+
|
|
478
|
+
export async function GET(request: Request) {
|
|
479
|
+
const url = new URL(request.url);
|
|
480
|
+
const params = parseRelayParams(url.searchParams);
|
|
481
|
+
|
|
482
|
+
if (!params) {
|
|
483
|
+
return new Response("Missing required relay parameters", { status: 400 });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const config = getAuthentikConfig();
|
|
487
|
+
const endpoints = await getEndpoints();
|
|
488
|
+
|
|
489
|
+
const { html } = createRelayPageHtml(
|
|
490
|
+
{
|
|
491
|
+
issuer: config.issuer,
|
|
492
|
+
clientId: config.clientId,
|
|
493
|
+
redirectUri: config.redirectUri,
|
|
494
|
+
authorizePath: endpoints.authorization,
|
|
495
|
+
},
|
|
496
|
+
params,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return new Response(html, {
|
|
500
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Server-Side Callback with Mandatory Sync
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
// app/api/auth/callback/route.ts
|
|
509
|
+
import { NextResponse } from "next/server";
|
|
510
|
+
import {
|
|
511
|
+
processCallback,
|
|
512
|
+
resolveSafeRedirect,
|
|
513
|
+
} from "@edcalderon/auth/authentik";
|
|
514
|
+
import { getAuthentikConfig, getEndpoints } from "@/lib/authentik-config";
|
|
515
|
+
import { provisioningAdapter } from "@/lib/provisioning";
|
|
516
|
+
|
|
517
|
+
export async function POST(request: Request) {
|
|
518
|
+
const body = await request.json();
|
|
519
|
+
const { code, codeVerifier, state, provider, next } = body;
|
|
520
|
+
|
|
521
|
+
const endpoints = await getEndpoints();
|
|
522
|
+
const config = getAuthentikConfig();
|
|
523
|
+
|
|
524
|
+
// Process callback with mandatory provisioning
|
|
525
|
+
const result = await processCallback({
|
|
526
|
+
config: {
|
|
527
|
+
...config,
|
|
528
|
+
tokenEndpoint: endpoints.token,
|
|
529
|
+
userinfoEndpoint: endpoints.userinfo,
|
|
530
|
+
},
|
|
531
|
+
code,
|
|
532
|
+
codeVerifier,
|
|
533
|
+
state,
|
|
534
|
+
expectedState: state,
|
|
535
|
+
provider,
|
|
536
|
+
provisioningAdapter, // Sync is mandatory — blocks until complete
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (!result.success) {
|
|
540
|
+
return NextResponse.json(
|
|
541
|
+
{
|
|
542
|
+
success: false,
|
|
543
|
+
error: result.error,
|
|
544
|
+
errorCode: result.errorCode,
|
|
545
|
+
},
|
|
546
|
+
{ status: 400 },
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Resolve safe redirect (prevents open redirect attacks)
|
|
551
|
+
const redirectTo = resolveSafeRedirect(next, {
|
|
552
|
+
allowedOrigins: [
|
|
553
|
+
process.env.NEXT_PUBLIC_APP_URL!,
|
|
554
|
+
],
|
|
555
|
+
fallbackUrl: "/dashboard",
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
return NextResponse.json({
|
|
559
|
+
success: true,
|
|
560
|
+
redirectTo,
|
|
561
|
+
claims: result.callbackResult?.claims,
|
|
562
|
+
isNewUser: result.provisioningResult?.authUserCreated,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Client Callback Page
|
|
568
|
+
|
|
569
|
+
```tsx
|
|
570
|
+
// app/auth/callback/page.tsx
|
|
571
|
+
"use client";
|
|
572
|
+
|
|
573
|
+
import { useEffect, useState } from "react";
|
|
574
|
+
import { useSearchParams, useRouter } from "next/navigation";
|
|
575
|
+
import { readRelayStorage, clearRelayStorage } from "@edcalderon/auth/authentik";
|
|
576
|
+
|
|
577
|
+
export default function CallbackPage() {
|
|
578
|
+
const searchParams = useSearchParams();
|
|
579
|
+
const router = useRouter();
|
|
580
|
+
const [status, setStatus] = useState("Completing sign-in…");
|
|
581
|
+
const [error, setError] = useState<string | null>(null);
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
async function handleCallback() {
|
|
585
|
+
const code = searchParams.get("code");
|
|
586
|
+
const state = searchParams.get("state");
|
|
587
|
+
|
|
588
|
+
if (!code || !state) {
|
|
589
|
+
setError("Missing authorization code or state");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const relay = readRelayStorage(sessionStorage);
|
|
594
|
+
if (!relay) {
|
|
595
|
+
setError("Session expired — please sign in again");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (state !== relay.state) {
|
|
600
|
+
setError("Invalid session state — please sign in again");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
setStatus("Syncing your account…");
|
|
605
|
+
|
|
606
|
+
// Exchange code and run provisioning on the server
|
|
607
|
+
const response = await fetch("/api/auth/callback", {
|
|
608
|
+
method: "POST",
|
|
609
|
+
headers: { "Content-Type": "application/json" },
|
|
610
|
+
body: JSON.stringify({
|
|
611
|
+
code,
|
|
612
|
+
codeVerifier: relay.codeVerifier,
|
|
613
|
+
state,
|
|
614
|
+
provider: relay.provider,
|
|
615
|
+
next: relay.next,
|
|
616
|
+
}),
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const result = await response.json();
|
|
620
|
+
clearRelayStorage(sessionStorage);
|
|
621
|
+
|
|
622
|
+
if (!result.success) {
|
|
623
|
+
setError(result.error || "Authentication failed");
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
setStatus(result.isNewUser ? "Account created! Redirecting…" : "Redirecting…");
|
|
628
|
+
router.push(result.redirectTo || "/dashboard");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
handleCallback();
|
|
632
|
+
}, [searchParams, router]);
|
|
633
|
+
|
|
634
|
+
if (error) {
|
|
635
|
+
return (
|
|
636
|
+
<div>
|
|
637
|
+
<h1>Sign-In Error</h1>
|
|
638
|
+
<p>{error}</p>
|
|
639
|
+
<a href="/login">Try again</a>
|
|
640
|
+
</div>
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return <div><p>{status}</p></div>;
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### Config Validation Health Check
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
// app/api/auth/validate/route.ts
|
|
652
|
+
import { NextResponse } from "next/server";
|
|
653
|
+
import { validateFullConfig } from "@edcalderon/auth/authentik";
|
|
654
|
+
import { getAuthentikConfig, getEndpoints } from "@/lib/authentik-config";
|
|
655
|
+
|
|
656
|
+
export async function GET() {
|
|
657
|
+
const endpoints = await getEndpoints();
|
|
658
|
+
const config = getAuthentikConfig();
|
|
659
|
+
|
|
660
|
+
const result = validateFullConfig(
|
|
661
|
+
{
|
|
662
|
+
...config,
|
|
663
|
+
tokenEndpoint: endpoints.token,
|
|
664
|
+
userinfoEndpoint: endpoints.userinfo,
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
supabaseUrl: process.env.SUPABASE_URL,
|
|
668
|
+
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
669
|
+
},
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
return NextResponse.json({
|
|
673
|
+
valid: result.valid,
|
|
674
|
+
checks: result.checks.map((c) => ({
|
|
675
|
+
name: c.name,
|
|
676
|
+
passed: c.passed,
|
|
677
|
+
message: c.passed ? "OK" : c.message,
|
|
678
|
+
})),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## Example 5: Logout with Authentik Invalidation
|
|
686
|
+
|
|
687
|
+
Complete logout flow including token revocation and Authentik session clearing.
|
|
688
|
+
|
|
689
|
+
### Logout API Route
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
// app/api/auth/logout/route.ts
|
|
693
|
+
import { NextResponse } from "next/server";
|
|
694
|
+
import { orchestrateLogout } from "@edcalderon/auth/authentik";
|
|
695
|
+
import { getAuthentikConfig, getEndpoints } from "@/lib/authentik-config";
|
|
696
|
+
|
|
697
|
+
export async function POST(request: Request) {
|
|
698
|
+
const { accessToken, idToken } = await request.json();
|
|
699
|
+
|
|
700
|
+
const config = getAuthentikConfig();
|
|
701
|
+
const endpoints = await getEndpoints();
|
|
702
|
+
|
|
703
|
+
const result = await orchestrateLogout(
|
|
704
|
+
{
|
|
705
|
+
issuer: config.issuer,
|
|
706
|
+
postLogoutRedirectUri: process.env.NEXT_PUBLIC_POST_LOGOUT_URI || "/",
|
|
707
|
+
endSessionEndpoint: endpoints.endSession!,
|
|
708
|
+
revocationEndpoint: endpoints.revocation,
|
|
709
|
+
clientId: config.clientId,
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
accessToken,
|
|
713
|
+
idToken,
|
|
714
|
+
},
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
return NextResponse.json({
|
|
718
|
+
endSessionUrl: result.endSessionUrl,
|
|
719
|
+
tokenRevoked: result.tokenRevoked,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
### Logout Button Component
|
|
725
|
+
|
|
726
|
+
```tsx
|
|
727
|
+
// components/LogoutButton.tsx
|
|
728
|
+
"use client";
|
|
729
|
+
|
|
730
|
+
import { useState } from "react";
|
|
731
|
+
|
|
732
|
+
interface Props {
|
|
733
|
+
accessToken?: string;
|
|
734
|
+
idToken?: string;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export function LogoutButton({ accessToken, idToken }: Props) {
|
|
738
|
+
const [loading, setLoading] = useState(false);
|
|
739
|
+
|
|
740
|
+
async function handleLogout() {
|
|
741
|
+
setLoading(true);
|
|
742
|
+
|
|
743
|
+
try {
|
|
744
|
+
// 1. Clear app-local state BEFORE calling the logout API
|
|
745
|
+
localStorage.removeItem("session");
|
|
746
|
+
sessionStorage.clear();
|
|
747
|
+
|
|
748
|
+
// 2. Call the server-side logout orchestrator
|
|
749
|
+
const response = await fetch("/api/auth/logout", {
|
|
750
|
+
method: "POST",
|
|
751
|
+
headers: { "Content-Type": "application/json" },
|
|
752
|
+
body: JSON.stringify({ accessToken, idToken }),
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const { endSessionUrl } = await response.json();
|
|
756
|
+
|
|
757
|
+
// 3. Navigate to Authentik to clear the SSO session
|
|
758
|
+
// The invalidation flow will redirect back to postLogoutRedirectUri
|
|
759
|
+
window.location.href = endSessionUrl;
|
|
760
|
+
} catch {
|
|
761
|
+
// Fallback: redirect to home even if logout API fails
|
|
762
|
+
window.location.href = "/";
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<button onClick={handleLogout} disabled={loading}>
|
|
768
|
+
{loading ? "Signing out…" : "Sign Out"}
|
|
769
|
+
</button>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
### Authentik Invalidation Flow Requirements
|
|
775
|
+
|
|
776
|
+
For the logout flow to work correctly, your Authentik Provider must have an **invalidation flow** configured:
|
|
777
|
+
|
|
778
|
+
1. **Flow designation:** Invalidation
|
|
779
|
+
2. **Stage 1:** User Logout — clears the Authentik browser session
|
|
780
|
+
3. **Stage 2:** Redirect — sends the browser to your `postLogoutRedirectUri`
|
|
781
|
+
|
|
782
|
+
Without this flow, the `endSessionUrl` will fail to clear the Authentik session, leaving the user authenticated on the Authentik side. See the [Authentik Integration Guide](./authentik-integration-guide.md#3-invalidation--logout-flow) for setup details.
|
|
783
|
+
|
|
784
|
+
The `postLogoutRedirectUri` must be registered in your Authentik Provider's redirect URI configuration.
|