@reflagged/shell 1.0.0

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/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @reflagged/shell
2
+
3
+ Shared app shell for Reflagged services. Provides:
4
+
5
+ - OIDC authentication (signin, callback, signout)
6
+ - SSO session management (JWT cookie + Payload AuthStrategy)
7
+ - `/api/shell-info` proxy for platform service catalog
8
+ - `BrandSwitcher` component (app switcher dropdown)
9
+ - Admin route SSO enforcement middleware
10
+
11
+ Published as a **public npm package of raw TS/TSX source**. Consumers transpile
12
+ it in-app via Next's `transpilePackages` — there is no build/bundle step.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pnpm add @reflagged/shell
18
+ ```
19
+
20
+ In each consuming Next.js app, add the package to `transpilePackages`
21
+ (**required** — the package ships `.ts/.tsx` source, incl. a `'use client'`
22
+ component, that Next must compile):
23
+
24
+ ```ts
25
+ // next.config.ts
26
+ const nextConfig = {
27
+ transpilePackages: ['@reflagged/shell'],
28
+ }
29
+ ```
30
+
31
+ Do **not** add it to `serverExternalPackages` — it must be transpiled.
32
+
33
+ Configure via env vars (`NEXT_PUBLIC_*` are inlined at **build** time):
34
+
35
+ | Variable | Purpose |
36
+ |----------|---------|
37
+ | `NEXT_PUBLIC_RFLGD_APP_KEY` | App key matching the platform catalog `service` value (highlights the current app as "aktuell") |
38
+ | `NEXT_PUBLIC_RFLGD_APP_LABEL` | Display name in BrandSwitcher |
39
+ | `RFLGD_DEFAULT_USER_ROLE` | Role for auto-created users (default: `user`) |
40
+
41
+ ## Entry points
42
+
43
+ ```ts
44
+ import { BrandSwitcher } from '@reflagged/shell/components/BrandSwitcher'
45
+ import { loadAppConfig } from '@reflagged/shell/config'
46
+ import { OIDC_COOKIE, loadOidcEnv } from '@reflagged/shell/auth/oidc-config'
47
+ import { verifySessionCookie } from '@reflagged/shell/auth/oidc-cookie'
48
+ import { refreshAccessToken } from '@reflagged/shell/auth/oidc-refresh'
49
+ import { nextauthStrategy } from '@reflagged/shell/auth/nextauth-strategy'
50
+ ```
51
+
52
+ ### Per-app route files + middleware
53
+
54
+ Each app keeps thin wrappers that re-export the handlers:
55
+
56
+ ```ts
57
+ // src/app/api/oidc/signin/route.ts
58
+ export { GET, dynamic, runtime } from '@reflagged/shell/api/oidc-signin'
59
+
60
+ // src/middleware.ts
61
+ export { default, config } from '@reflagged/shell/middleware'
62
+ ```
63
+
64
+ Other API re-exports: `@reflagged/shell/api/oidc-callback`,
65
+ `@reflagged/shell/api/oidc-signout`, `@reflagged/shell/api/shell-info`.
66
+
67
+ ## Local development (live-edit against a consumer)
68
+
69
+ A published package is frozen in `node_modules`. To iterate on the shell and a
70
+ consuming app at the same time, add a **local, uncommitted** pnpm override in
71
+ the consumer (all Reflagged repos sit side-by-side under `~/Development/`):
72
+
73
+ ```jsonc
74
+ // <app>/package.json — DEV ONLY, do not commit
75
+ "pnpm": {
76
+ "overrides": {
77
+ "@reflagged/shell": "link:../rflgd-shell"
78
+ }
79
+ }
80
+ ```
81
+
82
+ Then `pnpm install`. Edits in `rflgd-shell/src` are picked up live (still
83
+ transpiled via `transpilePackages`). **Remove the override before committing** —
84
+ `link:../rflgd-shell` does not exist in the Coolify build context and would
85
+ break the build.
86
+
87
+ ## Releasing
88
+
89
+ Raw source, no build. To publish a new version:
90
+
91
+ 1. Bump `version` in `package.json`.
92
+ 2. Commit, then `git tag vX.Y.Z && git push --tags`.
93
+ 3. The `.github/workflows/publish.yml` workflow typechecks, verifies the tag
94
+ matches `version`, and runs `npm publish --access public` (needs the
95
+ `NPM_TOKEN` repo secret).
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@reflagged/shell",
3
+ "version": "1.0.0",
4
+ "description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": ["src", "tsconfig.json", "README.md"],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ "./middleware": "./src/middleware.ts",
13
+ "./config": "./src/config.ts",
14
+ "./components/BrandSwitcher": "./src/components/BrandSwitcher.tsx",
15
+ "./auth/oidc-config": "./src/lib/auth/oidc-config.ts",
16
+ "./auth/oidc-cookie": "./src/lib/auth/oidc-cookie.ts",
17
+ "./auth/oidc-refresh": "./src/lib/auth/oidc-refresh.ts",
18
+ "./auth/nextauth-strategy": "./src/lib/auth/nextauth-strategy.ts",
19
+ "./api/oidc-signin": "./src/lib/api/oidc-signin.ts",
20
+ "./api/oidc-callback": "./src/lib/api/oidc-callback.ts",
21
+ "./api/oidc-signout": "./src/lib/api/oidc-signout.ts",
22
+ "./api/shell-info": "./src/lib/api/shell-info.ts",
23
+ "./package.json": "./package.json"
24
+ },
25
+ "scripts": {
26
+ "typecheck": "tsc --noEmit"
27
+ },
28
+ "dependencies": {
29
+ "jose": "^5.10.0",
30
+ "oauth4webapi": "^3.6.0"
31
+ },
32
+ "peerDependencies": {
33
+ "next": "^15.4.0",
34
+ "payload": "^3.79.0",
35
+ "react": "^19.2.0",
36
+ "react-dom": "^19.2.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^19.2.0",
40
+ "@types/react-dom": "^19.2.0",
41
+ "next": "^15.4.0",
42
+ "payload": "^3.79.0",
43
+ "react": "^19.2.0",
44
+ "react-dom": "^19.2.0",
45
+ "typescript": "^5.7.0"
46
+ }
47
+ }
@@ -0,0 +1,455 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { loadAppConfig } from '../config'
6
+
7
+ type Booking = {
8
+ id: string
9
+ service: string
10
+ label: string
11
+ status: string
12
+ url: string | null
13
+ iconUrl: string | null
14
+ }
15
+
16
+ type Org = {
17
+ id: string
18
+ slug: string
19
+ name: string
20
+ logoUrl: string | null
21
+ }
22
+
23
+ type ShellInfo = {
24
+ user: { email: string; name?: string | null }
25
+ org: Org | null
26
+ orgs?: Org[]
27
+ bookings: Booking[]
28
+ catalogUrl: string
29
+ baseUrl: string
30
+ }
31
+
32
+ const StackIcon = ({ size = 22 }: { size?: number }) => (
33
+ <svg
34
+ width={size}
35
+ height={(size * 30) / 32}
36
+ viewBox="0 0 32 30"
37
+ fill="none"
38
+ aria-hidden
39
+ style={{ flexShrink: 0 }}
40
+ >
41
+ <path d="M32,0 H23 A3,3 0 0,0 20,3 A3,3 0 0,0 23,6 H32 Z" fill="#B09A6A" />
42
+ <path d="M32,11 H12 A2,2 0 0,0 10,13 A2,2 0 0,0 12,15 H32 Z" fill="#B09A6A" opacity="0.45" />
43
+ <path
44
+ d="M32,23 H1.5 A1.5,1.5 0 0,0 0,24.5 A1.5,1.5 0 0,0 1.5,26 H32 Z"
45
+ fill="#B09A6A"
46
+ opacity="0.25"
47
+ />
48
+ </svg>
49
+ )
50
+
51
+ export function BrandSwitcher() {
52
+ const config = loadAppConfig()
53
+ const [shell, setShell] = useState<ShellInfo | null>(null)
54
+ const [error, setError] = useState(false)
55
+ const [open, setOpen] = useState(false)
56
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
57
+ const [mounted, setMounted] = useState(false)
58
+ const triggerRef = useRef<HTMLDivElement>(null)
59
+
60
+ useEffect(() => {
61
+ setMounted(true)
62
+ }, [])
63
+
64
+ useEffect(() => {
65
+ let cancel = false
66
+ fetch('/api/shell-info', { credentials: 'include' })
67
+ .then((r) => {
68
+ if (r.status === 401 || r.status === 403) {
69
+ setError(true)
70
+ return null
71
+ }
72
+ return r.ok ? r.json() : null
73
+ })
74
+ .then((d) => {
75
+ if (!cancel && d) setShell(d)
76
+ })
77
+ .catch(() => setError(true))
78
+ return () => {
79
+ cancel = true
80
+ }
81
+ }, [])
82
+
83
+ const handleToggle = () => {
84
+ if (!open && triggerRef.current) {
85
+ const r = triggerRef.current.getBoundingClientRect()
86
+ setPos({ top: r.bottom + 4, left: r.left })
87
+ }
88
+ setOpen((v) => !v)
89
+ }
90
+
91
+ // Keep position in sync on resize/scroll while open
92
+ useEffect(() => {
93
+ if (!open || !triggerRef.current) return
94
+ const update = () => {
95
+ const r = triggerRef.current?.getBoundingClientRect()
96
+ if (r) setPos({ top: r.bottom + 4, left: r.left })
97
+ }
98
+ window.addEventListener('resize', update)
99
+ window.addEventListener('scroll', update, true)
100
+ return () => {
101
+ window.removeEventListener('resize', update)
102
+ window.removeEventListener('scroll', update, true)
103
+ }
104
+ }, [open])
105
+
106
+ useEffect(() => {
107
+ if (!open) return
108
+ const onClick = (e: MouseEvent) => {
109
+ if ((e.target as HTMLElement)?.closest('[data-brand-switcher]')) return
110
+ setOpen(false)
111
+ }
112
+ document.addEventListener('mousedown', onClick)
113
+ return () => document.removeEventListener('mousedown', onClick)
114
+ }, [open])
115
+
116
+ const ready = (shell?.bookings ?? []).filter((b) => b.status === 'ready' && b.url)
117
+ const orgName = shell?.org?.name ?? null
118
+ const baseUrl = shell?.baseUrl ?? 'https://app.rfl.gd'
119
+ const orgs = shell?.orgs ?? (shell?.org ? [shell.org] : [])
120
+ const showWorkspaceSection = orgs.length > 1
121
+
122
+ const trigger = (
123
+ <div ref={triggerRef}>
124
+ <button
125
+ type="button"
126
+ onClick={handleToggle}
127
+ aria-haspopup="menu"
128
+ aria-expanded={open}
129
+ aria-label="Reflagged Apps wechseln"
130
+ style={{
131
+ display: 'flex',
132
+ alignItems: 'center',
133
+ gap: 8,
134
+ cursor: 'pointer',
135
+ background: 'transparent',
136
+ border: 'none',
137
+ padding: '4px 8px',
138
+ borderRadius: 4,
139
+ fontFamily: 'inherit',
140
+ color: 'inherit',
141
+ }}
142
+ >
143
+ <StackIcon size={22} />
144
+ <div style={{ flex: 1, minWidth: 0, lineHeight: 1.15 }}>
145
+ <div
146
+ style={{
147
+ fontFamily: "'Red Hat Display', system-ui, sans-serif",
148
+ fontWeight: 700,
149
+ fontSize: 17,
150
+ letterSpacing: '-0.01em',
151
+ whiteSpace: 'nowrap',
152
+ overflow: 'hidden',
153
+ textOverflow: 'ellipsis',
154
+ color: '#2C1E14',
155
+ }}
156
+ >
157
+ {config.appLabel}
158
+ </div>
159
+ <div
160
+ style={{
161
+ fontFamily: "'JetBrains Mono', monospace",
162
+ fontSize: 10.5,
163
+ letterSpacing: '0.1em',
164
+ color: '#7A6A58',
165
+ marginTop: 2,
166
+ whiteSpace: 'nowrap',
167
+ overflow: 'hidden',
168
+ textOverflow: 'ellipsis',
169
+ }}
170
+ >
171
+ {orgName ? `${orgName} · rfl.gd` : 'rfl.gd'}
172
+ </div>
173
+ </div>
174
+ {orgName ? (
175
+ <span
176
+ style={{
177
+ fontSize: 10,
178
+ textTransform: 'uppercase',
179
+ letterSpacing: '0.06em',
180
+ padding: '2px 8px',
181
+ borderRadius: 99,
182
+ border: '1px solid rgba(44,30,20,0.1)',
183
+ background: 'rgba(44,30,20,0.03)',
184
+ color: '#7A6A58',
185
+ whiteSpace: 'nowrap',
186
+ }}
187
+ >
188
+ {orgName}
189
+ </span>
190
+ ) : null}
191
+ <span aria-hidden style={{ color: '#7A6A58', fontSize: 10, marginLeft: 4 }}>
192
+ {open ? '▴' : '▾'}
193
+ </span>
194
+ </button>
195
+ </div>
196
+ )
197
+
198
+ const sectionLabelStyle: React.CSSProperties = {
199
+ padding: '10px 12px 6px',
200
+ fontFamily: "'JetBrains Mono', monospace",
201
+ fontSize: 10,
202
+ color: '#7A6A58',
203
+ textTransform: 'uppercase',
204
+ letterSpacing: '0.12em',
205
+ }
206
+
207
+ const popover = open ? (
208
+ <div
209
+ role="menu"
210
+ data-brand-switcher
211
+ style={{
212
+ position: pos ? 'fixed' : 'absolute',
213
+ left: pos ? pos.left : 0,
214
+ top: pos ? pos.top : '100%',
215
+ zIndex: 2147483647,
216
+ marginTop: pos ? 0 : 8,
217
+ width: 280,
218
+ borderRadius: 14,
219
+ border: '1px solid rgba(176,154,106,0.16)',
220
+ background: '#FFFFFF',
221
+ padding: 8,
222
+ boxShadow: '0 1px 0 rgba(176,154,106,0.08), 0 12px 40px rgba(0,0,0,0.15)',
223
+ }}
224
+ >
225
+ {showWorkspaceSection ? (
226
+ <>
227
+ <div style={sectionLabelStyle}>Workspace</div>
228
+ {orgs.map((o) => {
229
+ const isCurrent = o.id === shell?.org?.id
230
+ return (
231
+ <div
232
+ key={o.id}
233
+ style={{
234
+ display: 'flex',
235
+ alignItems: 'center',
236
+ gap: 12,
237
+ padding: '8px 12px',
238
+ borderRadius: 8,
239
+ background: isCurrent ? 'rgba(176,154,106,0.15)' : 'transparent',
240
+ }}
241
+ >
242
+ <div
243
+ style={{
244
+ width: 32,
245
+ height: 32,
246
+ borderRadius: 8,
247
+ background: 'rgba(176,154,106,0.15)',
248
+ color: '#B09A6A',
249
+ display: 'flex',
250
+ alignItems: 'center',
251
+ justifyContent: 'center',
252
+ fontFamily: "'Red Hat Display', system-ui, sans-serif",
253
+ fontWeight: 600,
254
+ }}
255
+ >
256
+ {o.name.slice(0, 1).toUpperCase()}
257
+ </div>
258
+ <div style={{ flex: 1, minWidth: 0 }}>
259
+ <div style={{ fontWeight: 500, fontSize: 13.5, color: '#2C1E14' }}>{o.name}</div>
260
+ <div style={{ fontSize: 11.5, color: '#7A6A58', marginTop: 2 }}>
261
+ {isCurrent
262
+ ? `${ready.length} Service${ready.length === 1 ? '' : 's'} aktiv`
263
+ : o.slug}
264
+ </div>
265
+ </div>
266
+ {isCurrent ? (
267
+ <span
268
+ style={{
269
+ fontFamily: "'JetBrains Mono', monospace",
270
+ fontSize: 9.5,
271
+ color: '#B09A6A',
272
+ padding: '2px 6px',
273
+ background: 'rgba(176,154,106,0.15)',
274
+ borderRadius: 4,
275
+ textTransform: 'uppercase',
276
+ letterSpacing: '0.1em',
277
+ }}
278
+ >
279
+ aktuell
280
+ </span>
281
+ ) : null}
282
+ </div>
283
+ )
284
+ })}
285
+ <hr
286
+ style={{
287
+ border: 'none',
288
+ borderTop: '1px solid rgba(176,154,106,0.12)',
289
+ margin: '6px 0',
290
+ }}
291
+ />
292
+ </>
293
+ ) : null}
294
+
295
+ <div style={sectionLabelStyle}>Apps</div>
296
+ {error && !shell ? (
297
+ <a
298
+ href="/api/oidc/signin"
299
+ style={{ display: 'block', padding: '8px 12px 12px', fontSize: 13, color: '#B09A6A', textDecoration: 'none' }}
300
+ >
301
+ Anmelden, um Services zu sehen →
302
+ </a>
303
+ ) : (
304
+ <>
305
+ {/* Base app is always a first-class entry — that's where users land
306
+ to book new services. */}
307
+ <a
308
+ href={baseUrl}
309
+ role="menuitem"
310
+ style={{
311
+ display: 'flex',
312
+ alignItems: 'center',
313
+ gap: 12,
314
+ padding: '10px 12px',
315
+ borderRadius: 8,
316
+ textDecoration: 'none',
317
+ color: '#2C1E14',
318
+ }}
319
+ >
320
+ <div
321
+ style={{
322
+ width: 32,
323
+ height: 32,
324
+ borderRadius: 8,
325
+ background: 'rgba(176,154,106,0.1)',
326
+ display: 'flex',
327
+ alignItems: 'center',
328
+ justifyContent: 'center',
329
+ }}
330
+ >
331
+ <StackIcon size={18} />
332
+ </div>
333
+ <div style={{ flex: 1, minWidth: 0 }}>
334
+ <div style={{ fontWeight: 500, fontSize: 13.5 }}>Reflagged Base</div>
335
+ <div style={{ fontSize: 11.5, color: '#7A6A58', marginTop: 2 }}>
336
+ Katalog & Settings
337
+ </div>
338
+ </div>
339
+ <span
340
+ style={{
341
+ fontFamily: "'JetBrains Mono', monospace",
342
+ fontSize: 9.5,
343
+ color: '#7A6A58',
344
+ padding: '2px 6px',
345
+ background: 'rgba(176,154,106,0.08)',
346
+ borderRadius: 4,
347
+ textTransform: 'uppercase',
348
+ letterSpacing: '0.1em',
349
+ }}
350
+ >
351
+ Hub
352
+ </span>
353
+ </a>
354
+
355
+ {ready.length === 0 ? (
356
+ <div style={{ padding: '8px 12px 12px', fontSize: 13, color: '#7A6A58' }}>
357
+ Noch keine aktiven Services.
358
+ </div>
359
+ ) : (
360
+ ready.map((b) => {
361
+ const active = b.service === config.appKey
362
+ return (
363
+ <a
364
+ key={b.id}
365
+ href={b.url ?? '#'}
366
+ role="menuitem"
367
+ style={{
368
+ display: 'flex',
369
+ alignItems: 'center',
370
+ gap: 12,
371
+ padding: '10px 12px',
372
+ borderRadius: 8,
373
+ textDecoration: 'none',
374
+ color: '#2C1E14',
375
+ background: active ? 'rgba(176,154,106,0.15)' : 'transparent',
376
+ }}
377
+ >
378
+ <div
379
+ style={{
380
+ width: 32,
381
+ height: 32,
382
+ borderRadius: 8,
383
+ background: active ? 'rgba(176,154,106,0.25)' : 'rgba(176,138,122,0.25)',
384
+ color: active ? '#B09A6A' : '#B08A7A',
385
+ display: 'flex',
386
+ alignItems: 'center',
387
+ justifyContent: 'center',
388
+ fontFamily: "'Red Hat Display', system-ui, sans-serif",
389
+ fontWeight: 600,
390
+ }}
391
+ >
392
+ {b.label.slice(0, 1).toUpperCase()}
393
+ </div>
394
+ <div style={{ flex: 1, minWidth: 0 }}>
395
+ <div style={{ fontWeight: 500, fontSize: 13.5 }}>{b.label}</div>
396
+ <div style={{ fontSize: 11.5, color: '#7A6A58', marginTop: 2 }}>
397
+ {active ? 'aktuell geöffnet' : 'bereit'}
398
+ </div>
399
+ </div>
400
+ {active ? (
401
+ <span
402
+ style={{
403
+ fontFamily: "'JetBrains Mono', monospace",
404
+ fontSize: 9.5,
405
+ color: '#B09A6A',
406
+ padding: '2px 6px',
407
+ background: 'rgba(176,154,106,0.15)',
408
+ borderRadius: 4,
409
+ textTransform: 'uppercase',
410
+ letterSpacing: '0.1em',
411
+ }}
412
+ >
413
+ aktuell
414
+ </span>
415
+ ) : (
416
+ <span
417
+ style={{
418
+ fontFamily: "'JetBrains Mono', monospace",
419
+ fontSize: 9.5,
420
+ color: '#3F8F5C',
421
+ padding: '2px 6px',
422
+ background: 'rgba(63,143,92,0.12)',
423
+ borderRadius: 4,
424
+ textTransform: 'uppercase',
425
+ letterSpacing: '0.1em',
426
+ }}
427
+ >
428
+ bereit
429
+ </span>
430
+ )}
431
+ </a>
432
+ )
433
+ })
434
+ )}
435
+ </>
436
+ )}
437
+ </div>
438
+ ) : null
439
+
440
+ if (mounted && open && pos) {
441
+ return (
442
+ <div data-brand-switcher style={{ position: 'relative' }}>
443
+ {trigger}
444
+ {createPortal(popover, document.body)}
445
+ </div>
446
+ )
447
+ }
448
+
449
+ return (
450
+ <div data-brand-switcher style={{ position: 'relative' }}>
451
+ {trigger}
452
+ {popover}
453
+ </div>
454
+ )
455
+ }
package/src/config.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type AppConfig = {
2
+ appKey: string
3
+ appLabel: string
4
+ defaultRole: string
5
+ }
6
+
7
+ export function loadAppConfig(): AppConfig {
8
+ return {
9
+ appKey: process.env.NEXT_PUBLIC_RFLGD_APP_KEY ?? 'unknown',
10
+ appLabel: process.env.NEXT_PUBLIC_RFLGD_APP_LABEL ?? 'Reflagged App',
11
+ defaultRole: process.env.RFLGD_DEFAULT_USER_ROLE ?? 'user',
12
+ }
13
+ }
@@ -0,0 +1,78 @@
1
+ import { NextResponse } from 'next/server'
2
+ import * as oauth from 'oauth4webapi'
3
+
4
+ import { OIDC_COOKIE, OIDC_SESSION_TTL, OIDC_STATE_COOKIE, loadOidcEnv } from '../auth/oidc-config'
5
+ import { signSessionCookie } from '../auth/oidc-cookie'
6
+
7
+ export const dynamic = 'force-dynamic'
8
+ export const runtime = 'nodejs'
9
+
10
+ export async function GET(req: Request): Promise<NextResponse> {
11
+ const env = loadOidcEnv()
12
+ if (!env) return NextResponse.json({ error: 'oidc-not-configured' }, { status: 500 })
13
+
14
+ const url = new URL(req.url)
15
+ const stateCookie = req.headers
16
+ .get('cookie')
17
+ ?.split(';')
18
+ .map((c) => c.trim())
19
+ .find((c) => c.startsWith(`${OIDC_STATE_COOKIE}=`))
20
+ if (!stateCookie) return NextResponse.json({ error: 'missing-state' }, { status: 400 })
21
+
22
+ let pkce: { codeVerifier: string; state: string; nonce: string; callback: string }
23
+ try {
24
+ pkce = JSON.parse(decodeURIComponent(stateCookie.split('=')[1] ?? ''))
25
+ } catch {
26
+ return NextResponse.json({ error: 'corrupt-state' }, { status: 400 })
27
+ }
28
+
29
+ const issuerUrl = new URL(env.issuer)
30
+ const as = await oauth.discoveryRequest(issuerUrl, { algorithm: 'oidc' }).then((r) =>
31
+ oauth.processDiscoveryResponse(issuerUrl, r),
32
+ )
33
+ const client: oauth.Client = { client_id: env.clientId }
34
+ const clientAuth = oauth.ClientSecretBasic(env.clientSecret)
35
+
36
+ const params = oauth.validateAuthResponse(as, client, url, pkce.state)
37
+
38
+ const tokenResponse = await oauth.authorizationCodeGrantRequest(
39
+ as, client, clientAuth, params, env.redirectUri, pkce.codeVerifier,
40
+ )
41
+ const result = await oauth.processAuthorizationCodeResponse(as, client, tokenResponse, {
42
+ expectedNonce: pkce.nonce,
43
+ requireIdToken: true,
44
+ })
45
+
46
+ const claims = oauth.getValidatedIdTokenClaims(result)
47
+ if (!claims) return NextResponse.json({ error: 'no-id-token' }, { status: 400 })
48
+ const email = typeof claims.email === 'string' ? claims.email : null
49
+ if (!email) return NextResponse.json({ error: 'no-email-claim' }, { status: 400 })
50
+
51
+ const accessToken = typeof result.access_token === 'string' ? result.access_token : null
52
+ const refreshToken = typeof result.refresh_token === 'string' ? result.refresh_token : null
53
+ const accessTokenExpiresAt =
54
+ typeof result.expires_in === 'number'
55
+ ? Math.floor(Date.now() / 1000) + result.expires_in
56
+ : null
57
+
58
+ const session = await signSessionCookie(env.authSecret, {
59
+ sub: String(claims.sub),
60
+ email,
61
+ name: typeof claims.name === 'string' ? claims.name : null,
62
+ accessToken,
63
+ refreshToken,
64
+ accessTokenExpiresAt,
65
+ })
66
+
67
+ const origin = env.baseUrl || url.origin
68
+ const res = NextResponse.redirect(new URL(pkce.callback || '/admin', origin))
69
+ res.cookies.set(OIDC_COOKIE, session, {
70
+ httpOnly: true,
71
+ secure: req.url.startsWith('https'),
72
+ sameSite: 'lax',
73
+ path: '/',
74
+ maxAge: OIDC_SESSION_TTL,
75
+ })
76
+ res.cookies.delete(OIDC_STATE_COOKIE)
77
+ return res
78
+ }
@@ -0,0 +1,51 @@
1
+ import { NextResponse } from 'next/server'
2
+ import * as oauth from 'oauth4webapi'
3
+
4
+ import { OIDC_STATE_COOKIE, loadOidcEnv } from '../auth/oidc-config'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+ export const runtime = 'nodejs'
8
+
9
+ export async function GET(req: Request): Promise<NextResponse> {
10
+ const env = loadOidcEnv()
11
+ if (!env) {
12
+ return NextResponse.json({ error: 'oidc-not-configured' }, { status: 500 })
13
+ }
14
+
15
+ const url = new URL(req.url)
16
+ const callback = url.searchParams.get('callbackUrl') ?? '/admin'
17
+
18
+ const issuerUrl = new URL(env.issuer)
19
+ const as = await oauth.discoveryRequest(issuerUrl, { algorithm: 'oidc' }).then((r) =>
20
+ oauth.processDiscoveryResponse(issuerUrl, r),
21
+ )
22
+
23
+ const codeVerifier = oauth.generateRandomCodeVerifier()
24
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier)
25
+ const state = oauth.generateRandomState()
26
+ const nonce = oauth.generateRandomNonce()
27
+
28
+ const authEndpoint = as.authorization_endpoint
29
+ if (!authEndpoint) {
30
+ return NextResponse.json({ error: 'no-authorization-endpoint' }, { status: 500 })
31
+ }
32
+ const authUrl = new URL(authEndpoint)
33
+ authUrl.searchParams.set('client_id', env.clientId)
34
+ authUrl.searchParams.set('redirect_uri', env.redirectUri)
35
+ authUrl.searchParams.set('response_type', 'code')
36
+ authUrl.searchParams.set('scope', 'openid profile email offline_access')
37
+ authUrl.searchParams.set('state', state)
38
+ authUrl.searchParams.set('nonce', nonce)
39
+ authUrl.searchParams.set('code_challenge', codeChallenge)
40
+ authUrl.searchParams.set('code_challenge_method', 'S256')
41
+
42
+ const res = NextResponse.redirect(authUrl)
43
+ res.cookies.set(OIDC_STATE_COOKIE, JSON.stringify({ codeVerifier, state, nonce, callback }), {
44
+ httpOnly: true,
45
+ secure: req.url.startsWith('https'),
46
+ sameSite: 'lax',
47
+ path: '/',
48
+ maxAge: 600,
49
+ })
50
+ return res
51
+ }
@@ -0,0 +1,30 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { OIDC_COOKIE, loadOidcEnv } from '../auth/oidc-config'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+ export const runtime = 'nodejs'
6
+
7
+ const SIGNED_OUT_PATH = '/admin/login?signed-out=1'
8
+
9
+ export async function GET(req: Request): Promise<NextResponse> {
10
+ const env = loadOidcEnv()
11
+ const fallbackOrigin = new URL(req.url).origin
12
+ const origin = (env?.baseUrl ?? fallbackOrigin).replace(/\/$/, '')
13
+ const localLanding = `${origin}${SIGNED_OUT_PATH}`
14
+
15
+ if (!env) {
16
+ const res = NextResponse.redirect(localLanding)
17
+ res.cookies.delete(OIDC_COOKIE)
18
+ return res
19
+ }
20
+
21
+ const baseUrl = env.issuer.replace(/\/oidc\/?$/, '')
22
+ const target = new URL(`${baseUrl}/api/sso/signout`)
23
+ target.searchParams.set('post_logout_redirect_uri', localLanding)
24
+
25
+ const res = NextResponse.redirect(target)
26
+ res.cookies.delete(OIDC_COOKIE)
27
+ return res
28
+ }
29
+
30
+ export const POST = GET
@@ -0,0 +1,72 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ import { OIDC_COOKIE, OIDC_SESSION_TTL, loadOidcEnv } from '../auth/oidc-config'
4
+ import { signSessionCookie, verifySessionCookie } from '../auth/oidc-cookie'
5
+ import { refreshAccessToken } from '../auth/oidc-refresh'
6
+
7
+ export const runtime = 'nodejs'
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ export async function GET(req: Request): Promise<NextResponse> {
11
+ const env = loadOidcEnv()
12
+ if (!env) return NextResponse.json({ error: 'oidc-not-configured' }, { status: 503 })
13
+
14
+ const cookieHeader = req.headers.get('cookie') ?? ''
15
+ const segment = cookieHeader
16
+ .split(';')
17
+ .map((c) => c.trim())
18
+ .find((c) => c.startsWith(`${OIDC_COOKIE}=`))
19
+ if (!segment) return NextResponse.json({ error: 'no-session' }, { status: 401 })
20
+
21
+ const token = segment.slice(`${OIDC_COOKIE}=`.length)
22
+ let session = await verifySessionCookie(env.authSecret, token)
23
+ if (!session) return NextResponse.json({ error: 'invalid-session' }, { status: 401 })
24
+ if (!session.accessToken) {
25
+ return NextResponse.json({ error: 'no-access-token' }, { status: 401 })
26
+ }
27
+
28
+ const baseUrl = env.issuer.replace(/\/oidc\/?$/, '')
29
+ const callUpstream = (accessToken: string) =>
30
+ fetch(`${baseUrl}/api/shell/me`, {
31
+ headers: { Authorization: `Bearer ${accessToken}` },
32
+ cache: 'no-store',
33
+ })
34
+
35
+ let upstream = await callUpstream(session.accessToken)
36
+ let refreshedCookie: string | null = null
37
+
38
+ if (upstream.status === 401 && session.refreshToken) {
39
+ const refreshed = await refreshAccessToken(env, session.refreshToken)
40
+ if (refreshed) {
41
+ session = { ...session, ...refreshed }
42
+ refreshedCookie = await signSessionCookie(env.authSecret, {
43
+ sub: session.sub,
44
+ email: session.email,
45
+ name: session.name,
46
+ accessToken: refreshed.accessToken,
47
+ refreshToken: refreshed.refreshToken,
48
+ accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
49
+ })
50
+ upstream = await callUpstream(refreshed.accessToken)
51
+ }
52
+ }
53
+
54
+ if (!upstream.ok) {
55
+ return NextResponse.json(
56
+ { error: 'upstream-failed', status: upstream.status },
57
+ { status: upstream.status },
58
+ )
59
+ }
60
+
61
+ const res = NextResponse.json(await upstream.json())
62
+ if (refreshedCookie) {
63
+ res.cookies.set(OIDC_COOKIE, refreshedCookie, {
64
+ httpOnly: true,
65
+ secure: req.url.startsWith('https'),
66
+ sameSite: 'lax',
67
+ path: '/',
68
+ maxAge: OIDC_SESSION_TTL,
69
+ })
70
+ }
71
+ return res
72
+ }
@@ -0,0 +1,50 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import type { AuthStrategy } from 'payload'
3
+
4
+ import { loadAppConfig } from '../../config'
5
+ import { OIDC_COOKIE } from './oidc-config'
6
+ import { verifySessionCookie } from './oidc-cookie'
7
+
8
+ export const nextauthStrategy: AuthStrategy = {
9
+ name: 'rflgd-oidc',
10
+ authenticate: async ({ payload, headers }) => {
11
+ const secret = process.env.AUTH_SECRET
12
+ if (!secret) return { user: null }
13
+
14
+ const cookieHeader = headers.get('cookie') ?? ''
15
+ const cookieStart = `${OIDC_COOKIE}=`
16
+ const segment = cookieHeader
17
+ .split(';')
18
+ .map((c) => c.trim())
19
+ .find((c) => c.startsWith(cookieStart))
20
+ if (!segment) return { user: null }
21
+
22
+ const token = segment.slice(cookieStart.length)
23
+ const session = await verifySessionCookie(secret, token)
24
+ if (!session?.email) return { user: null }
25
+
26
+ const existing = await payload.find({
27
+ collection: 'users',
28
+ where: { email: { equals: session.email } },
29
+ limit: 1,
30
+ overrideAccess: true,
31
+ })
32
+
33
+ let user = existing.docs[0]
34
+ if (!user) {
35
+ const config = loadAppConfig()
36
+ const isFirst = (await payload.count({ collection: 'users', overrideAccess: true })).totalDocs === 0
37
+ user = await payload.create({
38
+ collection: 'users',
39
+ data: {
40
+ email: session.email,
41
+ password: randomBytes(32).toString('hex'),
42
+ role: isFirst ? 'admin' : config.defaultRole,
43
+ },
44
+ overrideAccess: true,
45
+ })
46
+ }
47
+
48
+ return { user: { ...user, collection: 'users' } }
49
+ },
50
+ }
@@ -0,0 +1,40 @@
1
+ export const OIDC_COOKIE = 'rflgd-session'
2
+ export const OIDC_STATE_COOKIE = 'rflgd-oidc-state'
3
+ export const OIDC_SESSION_TTL = 60 * 60 * 24 * 30
4
+
5
+ export type OidcSessionToken = {
6
+ sub: string
7
+ email: string
8
+ name?: string | null
9
+ accessToken?: string | null
10
+ refreshToken?: string | null
11
+ accessTokenExpiresAt?: number | null
12
+ iat: number
13
+ exp: number
14
+ }
15
+
16
+ export type OidcEnv = {
17
+ issuer: string
18
+ clientId: string
19
+ clientSecret: string
20
+ redirectUri: string
21
+ authSecret: string
22
+ baseUrl: string
23
+ }
24
+
25
+ export function loadOidcEnv(): OidcEnv | null {
26
+ const issuer = process.env.OIDC_ISSUER
27
+ const clientId = process.env.OIDC_CLIENT_ID
28
+ const clientSecret = process.env.OIDC_CLIENT_SECRET
29
+ const authSecret = process.env.AUTH_SECRET
30
+ const base = process.env.NEXT_PUBLIC_SERVER_URL
31
+ if (!issuer || !clientId || !clientSecret || !authSecret || !base) return null
32
+ return {
33
+ issuer,
34
+ clientId,
35
+ clientSecret,
36
+ redirectUri: `${base.replace(/\/$/, '')}/api/oidc/callback`,
37
+ authSecret,
38
+ baseUrl: base.replace(/\/$/, ''),
39
+ }
40
+ }
@@ -0,0 +1,57 @@
1
+ import { SignJWT, jwtVerify } from 'jose'
2
+ import type { OidcSessionToken } from './oidc-config'
3
+ import { OIDC_SESSION_TTL } from './oidc-config'
4
+
5
+ const ALG = 'HS256'
6
+
7
+ function secretKey(authSecret: string): Uint8Array {
8
+ return new TextEncoder().encode(authSecret)
9
+ }
10
+
11
+ export async function signSessionCookie(
12
+ authSecret: string,
13
+ payload: {
14
+ sub: string
15
+ email: string
16
+ name?: string | null
17
+ accessToken?: string | null
18
+ refreshToken?: string | null
19
+ accessTokenExpiresAt?: number | null
20
+ },
21
+ ): Promise<string> {
22
+ return new SignJWT({
23
+ email: payload.email,
24
+ name: payload.name ?? null,
25
+ accessToken: payload.accessToken ?? null,
26
+ refreshToken: payload.refreshToken ?? null,
27
+ accessTokenExpiresAt: payload.accessTokenExpiresAt ?? null,
28
+ })
29
+ .setProtectedHeader({ alg: ALG })
30
+ .setIssuedAt()
31
+ .setSubject(payload.sub)
32
+ .setExpirationTime(`${OIDC_SESSION_TTL}s`)
33
+ .sign(secretKey(authSecret))
34
+ }
35
+
36
+ export async function verifySessionCookie(
37
+ authSecret: string,
38
+ token: string,
39
+ ): Promise<OidcSessionToken | null> {
40
+ try {
41
+ const { payload } = await jwtVerify(token, secretKey(authSecret), { algorithms: [ALG] })
42
+ if (typeof payload.sub !== 'string' || typeof payload.email !== 'string') return null
43
+ return {
44
+ sub: payload.sub,
45
+ email: payload.email,
46
+ name: typeof payload.name === 'string' ? payload.name : null,
47
+ accessToken: typeof payload.accessToken === 'string' ? payload.accessToken : null,
48
+ refreshToken: typeof payload.refreshToken === 'string' ? payload.refreshToken : null,
49
+ accessTokenExpiresAt:
50
+ typeof payload.accessTokenExpiresAt === 'number' ? payload.accessTokenExpiresAt : null,
51
+ iat: payload.iat ?? 0,
52
+ exp: payload.exp ?? 0,
53
+ }
54
+ } catch {
55
+ return null
56
+ }
57
+ }
@@ -0,0 +1,34 @@
1
+ import * as oauth from 'oauth4webapi'
2
+ import type { OidcEnv } from './oidc-config'
3
+
4
+ export async function refreshAccessToken(
5
+ env: OidcEnv,
6
+ refreshToken: string,
7
+ ): Promise<{ accessToken: string; refreshToken: string | null; accessTokenExpiresAt: number } | null> {
8
+ const issuerUrl = new URL(env.issuer)
9
+ let as: oauth.AuthorizationServer
10
+ try {
11
+ as = await oauth
12
+ .discoveryRequest(issuerUrl, { algorithm: 'oidc' })
13
+ .then((r) => oauth.processDiscoveryResponse(issuerUrl, r))
14
+ } catch {
15
+ return null
16
+ }
17
+ const client: oauth.Client = { client_id: env.clientId }
18
+ const clientAuth = oauth.ClientSecretBasic(env.clientSecret)
19
+ try {
20
+ const res = await oauth.refreshTokenGrantRequest(as, client, clientAuth, refreshToken)
21
+ const body = await oauth.processRefreshTokenResponse(as, client, res)
22
+ if (typeof body.access_token !== 'string') return null
23
+ return {
24
+ accessToken: body.access_token,
25
+ refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : refreshToken,
26
+ accessTokenExpiresAt:
27
+ typeof body.expires_in === 'number'
28
+ ? Math.floor(Date.now() / 1000) + body.expires_in
29
+ : Math.floor(Date.now() / 1000) + 3600,
30
+ }
31
+ } catch {
32
+ return null
33
+ }
34
+ }
@@ -0,0 +1,34 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+
3
+ const OIDC_COOKIE = 'rflgd-session'
4
+
5
+ export default function middleware(req: NextRequest): NextResponse | undefined {
6
+ const { pathname, search, origin } = req.nextUrl
7
+
8
+ if (pathname.startsWith('/admin/api')) return
9
+
10
+ const wantsAdmin = pathname === '/admin' || pathname.startsWith('/admin/')
11
+ if (!wantsAdmin) return
12
+
13
+ if (pathname.startsWith('/admin/logout')) {
14
+ return NextResponse.redirect(new URL('/api/oidc/signout', origin))
15
+ }
16
+
17
+ if (pathname.startsWith('/admin/login')) {
18
+ const target = '/admin'
19
+ const signinUrl = new URL('/api/oidc/signin', origin)
20
+ signinUrl.searchParams.set('callbackUrl', target)
21
+ return NextResponse.redirect(signinUrl)
22
+ }
23
+
24
+ if (req.cookies.get(OIDC_COOKIE)) return
25
+
26
+ const target = pathname + search
27
+ const signinUrl = new URL('/api/oidc/signin', origin)
28
+ signinUrl.searchParams.set('callbackUrl', target)
29
+ return NextResponse.redirect(signinUrl)
30
+ }
31
+
32
+ export const config = {
33
+ matcher: ['/admin/:path*'],
34
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "noEmit": true
14
+ },
15
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
16
+ "exclude": ["node_modules"]
17
+ }