@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.
@@ -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.