@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 +95 -0
- package/package.json +47 -0
- package/src/components/BrandSwitcher.tsx +455 -0
- package/src/config.ts +13 -0
- package/src/lib/api/oidc-callback.ts +78 -0
- package/src/lib/api/oidc-signin.ts +51 -0
- package/src/lib/api/oidc-signout.ts +30 -0
- package/src/lib/api/shell-info.ts +72 -0
- package/src/lib/auth/nextauth-strategy.ts +50 -0
- package/src/lib/auth/oidc-config.ts +40 -0
- package/src/lib/auth/oidc-cookie.ts +57 -0
- package/src/lib/auth/oidc-refresh.ts +34 -0
- package/src/middleware.ts +34 -0
- package/tsconfig.json +17 -0
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
|
+
}
|