@reflagged/shell 1.0.0 → 1.0.2
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/package.json +2 -1
- package/src/components/BrandSwitcher.tsx +32 -1
- package/src/lib/api/oidc-callback.ts +22 -2
- package/src/lib/api/oidc-signin.ts +3 -3
- package/src/lib/api/oidc-signout.ts +22 -7
- package/src/lib/auth/can-access.ts +30 -0
- package/src/lib/auth/oidc-config.ts +49 -0
- package/src/lib/auth/oidc-cookie.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reflagged/shell",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"./components/BrandSwitcher": "./src/components/BrandSwitcher.tsx",
|
|
15
15
|
"./auth/oidc-config": "./src/lib/auth/oidc-config.ts",
|
|
16
16
|
"./auth/oidc-cookie": "./src/lib/auth/oidc-cookie.ts",
|
|
17
|
+
"./auth/can-access": "./src/lib/auth/can-access.ts",
|
|
17
18
|
"./auth/oidc-refresh": "./src/lib/auth/oidc-refresh.ts",
|
|
18
19
|
"./auth/nextauth-strategy": "./src/lib/auth/nextauth-strategy.ts",
|
|
19
20
|
"./api/oidc-signin": "./src/lib/api/oidc-signin.ts",
|
|
@@ -295,7 +295,9 @@ export function BrandSwitcher() {
|
|
|
295
295
|
<div style={sectionLabelStyle}>Apps</div>
|
|
296
296
|
{error && !shell ? (
|
|
297
297
|
<a
|
|
298
|
-
href=
|
|
298
|
+
href={`/api/oidc/signin?callbackUrl=${encodeURIComponent(
|
|
299
|
+
typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/',
|
|
300
|
+
)}`}
|
|
299
301
|
style={{ display: 'block', padding: '8px 12px 12px', fontSize: 13, color: '#B09A6A', textDecoration: 'none' }}
|
|
300
302
|
>
|
|
301
303
|
Anmelden, um Services zu sehen →
|
|
@@ -432,6 +434,35 @@ export function BrandSwitcher() {
|
|
|
432
434
|
)
|
|
433
435
|
})
|
|
434
436
|
)}
|
|
437
|
+
|
|
438
|
+
{/* Single-logout — clears this service's session AND the platform
|
|
439
|
+
SSO session (via <provider>/api/sso/signout). Present for every
|
|
440
|
+
consumer so logout is always reachable from the app-switcher. */}
|
|
441
|
+
<hr
|
|
442
|
+
style={{
|
|
443
|
+
border: 'none',
|
|
444
|
+
borderTop: '1px solid rgba(176,154,106,0.12)',
|
|
445
|
+
margin: '6px 0',
|
|
446
|
+
}}
|
|
447
|
+
/>
|
|
448
|
+
<a
|
|
449
|
+
href="/api/oidc/signout"
|
|
450
|
+
role="menuitem"
|
|
451
|
+
data-testid="brand-switcher-logout"
|
|
452
|
+
style={{
|
|
453
|
+
display: 'flex',
|
|
454
|
+
alignItems: 'center',
|
|
455
|
+
gap: 12,
|
|
456
|
+
padding: '10px 12px',
|
|
457
|
+
borderRadius: 8,
|
|
458
|
+
textDecoration: 'none',
|
|
459
|
+
color: '#B0413A',
|
|
460
|
+
fontWeight: 500,
|
|
461
|
+
fontSize: 13.5,
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
Abmelden
|
|
465
|
+
</a>
|
|
435
466
|
</>
|
|
436
467
|
)}
|
|
437
468
|
</div>
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import * as oauth from 'oauth4webapi'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
OIDC_COOKIE,
|
|
6
|
+
OIDC_SESSION_TTL,
|
|
7
|
+
OIDC_STATE_COOKIE,
|
|
8
|
+
loadOidcEnv,
|
|
9
|
+
secureCookies,
|
|
10
|
+
} from '../auth/oidc-config'
|
|
5
11
|
import { signSessionCookie } from '../auth/oidc-cookie'
|
|
6
12
|
|
|
7
13
|
export const dynamic = 'force-dynamic'
|
|
@@ -55,6 +61,14 @@ export async function GET(req: Request): Promise<NextResponse> {
|
|
|
55
61
|
? Math.floor(Date.now() / 1000) + result.expires_in
|
|
56
62
|
: null
|
|
57
63
|
|
|
64
|
+
// Membership/workspace claims (present when the rflgd:memberships scope was
|
|
65
|
+
// requested). Persisted into the session JWT so consumers can gate features
|
|
66
|
+
// via accessibleServices without a /api/shell/me roundtrip.
|
|
67
|
+
const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null)
|
|
68
|
+
const accessibleServices = Array.isArray(claims.accessible_services)
|
|
69
|
+
? (claims.accessible_services as unknown[]).filter((s): s is string => typeof s === 'string')
|
|
70
|
+
: null
|
|
71
|
+
|
|
58
72
|
const session = await signSessionCookie(env.authSecret, {
|
|
59
73
|
sub: String(claims.sub),
|
|
60
74
|
email,
|
|
@@ -62,13 +76,19 @@ export async function GET(req: Request): Promise<NextResponse> {
|
|
|
62
76
|
accessToken,
|
|
63
77
|
refreshToken,
|
|
64
78
|
accessTokenExpiresAt,
|
|
79
|
+
emailVerified: typeof claims.email_verified === 'boolean' ? claims.email_verified : null,
|
|
80
|
+
tenantId: asString(claims.tenant_id),
|
|
81
|
+
tenantSlug: asString(claims.tenant_slug),
|
|
82
|
+
orgSlug: asString(claims.org_slug),
|
|
83
|
+
orgRole: asString(claims.org_role),
|
|
84
|
+
accessibleServices,
|
|
65
85
|
})
|
|
66
86
|
|
|
67
87
|
const origin = env.baseUrl || url.origin
|
|
68
88
|
const res = NextResponse.redirect(new URL(pkce.callback || '/admin', origin))
|
|
69
89
|
res.cookies.set(OIDC_COOKIE, session, {
|
|
70
90
|
httpOnly: true,
|
|
71
|
-
secure:
|
|
91
|
+
secure: secureCookies(),
|
|
72
92
|
sameSite: 'lax',
|
|
73
93
|
path: '/',
|
|
74
94
|
maxAge: OIDC_SESSION_TTL,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import * as oauth from 'oauth4webapi'
|
|
3
3
|
|
|
4
|
-
import { OIDC_STATE_COOKIE, loadOidcEnv } from '../auth/oidc-config'
|
|
4
|
+
import { OIDC_STATE_COOKIE, loadOidcEnv, oidcScopes, secureCookies } from '../auth/oidc-config'
|
|
5
5
|
|
|
6
6
|
export const dynamic = 'force-dynamic'
|
|
7
7
|
export const runtime = 'nodejs'
|
|
@@ -33,7 +33,7 @@ export async function GET(req: Request): Promise<NextResponse> {
|
|
|
33
33
|
authUrl.searchParams.set('client_id', env.clientId)
|
|
34
34
|
authUrl.searchParams.set('redirect_uri', env.redirectUri)
|
|
35
35
|
authUrl.searchParams.set('response_type', 'code')
|
|
36
|
-
authUrl.searchParams.set('scope',
|
|
36
|
+
authUrl.searchParams.set('scope', oidcScopes())
|
|
37
37
|
authUrl.searchParams.set('state', state)
|
|
38
38
|
authUrl.searchParams.set('nonce', nonce)
|
|
39
39
|
authUrl.searchParams.set('code_challenge', codeChallenge)
|
|
@@ -42,7 +42,7 @@ export async function GET(req: Request): Promise<NextResponse> {
|
|
|
42
42
|
const res = NextResponse.redirect(authUrl)
|
|
43
43
|
res.cookies.set(OIDC_STATE_COOKIE, JSON.stringify({ codeVerifier, state, nonce, callback }), {
|
|
44
44
|
httpOnly: true,
|
|
45
|
-
secure:
|
|
45
|
+
secure: secureCookies(),
|
|
46
46
|
sameSite: 'lax',
|
|
47
47
|
path: '/',
|
|
48
48
|
maxAge: 600,
|
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
OIDC_COOKIE,
|
|
4
|
+
loadOidcEnv,
|
|
5
|
+
providerOrigin,
|
|
6
|
+
secureCookies,
|
|
7
|
+
signedOutPath,
|
|
8
|
+
} from '../auth/oidc-config'
|
|
3
9
|
|
|
4
10
|
export const dynamic = 'force-dynamic'
|
|
5
11
|
export const runtime = 'nodejs'
|
|
6
12
|
|
|
7
|
-
|
|
13
|
+
function clearSession(res: NextResponse): void {
|
|
14
|
+
// Explicit attributes so the delete actually matches the cookie set at login.
|
|
15
|
+
res.cookies.set(OIDC_COOKIE, '', {
|
|
16
|
+
httpOnly: true,
|
|
17
|
+
secure: secureCookies(),
|
|
18
|
+
sameSite: 'lax',
|
|
19
|
+
path: '/',
|
|
20
|
+
maxAge: 0,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
8
23
|
|
|
9
24
|
export async function GET(req: Request): Promise<NextResponse> {
|
|
10
25
|
const env = loadOidcEnv()
|
|
11
26
|
const fallbackOrigin = new URL(req.url).origin
|
|
12
27
|
const origin = (env?.baseUrl ?? fallbackOrigin).replace(/\/$/, '')
|
|
13
|
-
const localLanding = `${origin}${
|
|
28
|
+
const localLanding = `${origin}${signedOutPath()}`
|
|
14
29
|
|
|
15
30
|
if (!env) {
|
|
16
31
|
const res = NextResponse.redirect(localLanding)
|
|
17
|
-
res
|
|
32
|
+
clearSession(res)
|
|
18
33
|
return res
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
|
|
22
|
-
const target = new URL(`${
|
|
36
|
+
// Provider single-logout endpoint lives at the issuer origin.
|
|
37
|
+
const target = new URL(`${providerOrigin(env)}/api/sso/signout`)
|
|
23
38
|
target.searchParams.set('post_logout_redirect_uri', localLanding)
|
|
24
39
|
|
|
25
40
|
const res = NextResponse.redirect(target)
|
|
26
|
-
res
|
|
41
|
+
clearSession(res)
|
|
27
42
|
return res
|
|
28
43
|
}
|
|
29
44
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { OidcSessionToken } from './oidc-config'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Feature-gating helper for the `accessible_services` claim.
|
|
5
|
+
*
|
|
6
|
+
* Semantics (from the provider contract):
|
|
7
|
+
* - `['*']` → the user may access ALL services (platform/org admins).
|
|
8
|
+
* - `[]` → the user may access NO services.
|
|
9
|
+
* - `[...]` → the user may access exactly the listed product keys.
|
|
10
|
+
* - `null`/absent → claim was not requested (no `rflgd:memberships` scope)
|
|
11
|
+
* OR a session minted before this field existed. We return
|
|
12
|
+
* `false` (fail-closed) so callers must opt into gating
|
|
13
|
+
* only once the claim is reliably present.
|
|
14
|
+
*/
|
|
15
|
+
export function hasService(
|
|
16
|
+
accessibleServices: string[] | null | undefined,
|
|
17
|
+
serviceKey: string,
|
|
18
|
+
): boolean {
|
|
19
|
+
if (!Array.isArray(accessibleServices)) return false
|
|
20
|
+
if (accessibleServices.includes('*')) return true
|
|
21
|
+
return accessibleServices.includes(serviceKey)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Convenience overload taking the whole session token. */
|
|
25
|
+
export function sessionHasService(
|
|
26
|
+
session: Pick<OidcSessionToken, 'accessibleServices'> | null | undefined,
|
|
27
|
+
serviceKey: string,
|
|
28
|
+
): boolean {
|
|
29
|
+
return hasService(session?.accessibleServices, serviceKey)
|
|
30
|
+
}
|
|
@@ -9,10 +9,59 @@ export type OidcSessionToken = {
|
|
|
9
9
|
accessToken?: string | null
|
|
10
10
|
refreshToken?: string | null
|
|
11
11
|
accessTokenExpiresAt?: number | null
|
|
12
|
+
// Membership/workspace claims (released by the provider when the
|
|
13
|
+
// `rflgd:memberships` scope is requested). All optional for backward
|
|
14
|
+
// compatibility with sessions minted before this was added.
|
|
15
|
+
emailVerified?: boolean | null
|
|
16
|
+
tenantId?: string | null
|
|
17
|
+
tenantSlug?: string | null
|
|
18
|
+
orgSlug?: string | null
|
|
19
|
+
orgRole?: string | null
|
|
20
|
+
/** Product keys the user may access; ['*'] = all, [] = none. */
|
|
21
|
+
accessibleServices?: string[] | null
|
|
12
22
|
iat: number
|
|
13
23
|
exp: number
|
|
14
24
|
}
|
|
15
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Scopes requested at authorize time. Defaults include `rflgd:memberships`
|
|
28
|
+
* so the id_token carries org_slug/org_role/accessible_services. Override via
|
|
29
|
+
* the OIDC_SCOPES env var (space-separated) for clients that need less.
|
|
30
|
+
*/
|
|
31
|
+
export function oidcScopes(): string {
|
|
32
|
+
return (
|
|
33
|
+
process.env.OIDC_SCOPES?.trim() ||
|
|
34
|
+
'openid profile email offline_access rflgd:memberships'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Local landing after single-logout. Defaults to `/login?signed-out=1`
|
|
40
|
+
* (every consumer has a /login). Override per app via RFLGD_SIGNED_OUT_PATH
|
|
41
|
+
* (e.g. `/?signed-out=1`). Previously hardcoded to `/admin/login`, which
|
|
42
|
+
* 404'd in consumers without an /admin route.
|
|
43
|
+
*/
|
|
44
|
+
export function signedOutPath(): string {
|
|
45
|
+
return process.env.RFLGD_SIGNED_OUT_PATH?.trim() || '/login?signed-out=1'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Origin of the provider (where /api/sso/signout lives). Robustly derived
|
|
50
|
+
* from the issuer URL rather than string-stripping a trailing `/oidc`.
|
|
51
|
+
*/
|
|
52
|
+
export function providerOrigin(env: OidcEnv): string {
|
|
53
|
+
try {
|
|
54
|
+
return new URL(env.issuer).origin
|
|
55
|
+
} catch {
|
|
56
|
+
return env.issuer.replace(/\/oidc\/?$/, '')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True in production — used for the Secure cookie flag (don't infer from req.url behind a TLS-terminating proxy). */
|
|
61
|
+
export function secureCookies(): boolean {
|
|
62
|
+
return process.env.NODE_ENV === 'production'
|
|
63
|
+
}
|
|
64
|
+
|
|
16
65
|
export type OidcEnv = {
|
|
17
66
|
issuer: string
|
|
18
67
|
clientId: string
|
|
@@ -17,6 +17,12 @@ export async function signSessionCookie(
|
|
|
17
17
|
accessToken?: string | null
|
|
18
18
|
refreshToken?: string | null
|
|
19
19
|
accessTokenExpiresAt?: number | null
|
|
20
|
+
emailVerified?: boolean | null
|
|
21
|
+
tenantId?: string | null
|
|
22
|
+
tenantSlug?: string | null
|
|
23
|
+
orgSlug?: string | null
|
|
24
|
+
orgRole?: string | null
|
|
25
|
+
accessibleServices?: string[] | null
|
|
20
26
|
},
|
|
21
27
|
): Promise<string> {
|
|
22
28
|
return new SignJWT({
|
|
@@ -25,6 +31,12 @@ export async function signSessionCookie(
|
|
|
25
31
|
accessToken: payload.accessToken ?? null,
|
|
26
32
|
refreshToken: payload.refreshToken ?? null,
|
|
27
33
|
accessTokenExpiresAt: payload.accessTokenExpiresAt ?? null,
|
|
34
|
+
emailVerified: payload.emailVerified ?? null,
|
|
35
|
+
tenantId: payload.tenantId ?? null,
|
|
36
|
+
tenantSlug: payload.tenantSlug ?? null,
|
|
37
|
+
orgSlug: payload.orgSlug ?? null,
|
|
38
|
+
orgRole: payload.orgRole ?? null,
|
|
39
|
+
accessibleServices: payload.accessibleServices ?? null,
|
|
28
40
|
})
|
|
29
41
|
.setProtectedHeader({ alg: ALG })
|
|
30
42
|
.setIssuedAt()
|
|
@@ -48,6 +60,14 @@ export async function verifySessionCookie(
|
|
|
48
60
|
refreshToken: typeof payload.refreshToken === 'string' ? payload.refreshToken : null,
|
|
49
61
|
accessTokenExpiresAt:
|
|
50
62
|
typeof payload.accessTokenExpiresAt === 'number' ? payload.accessTokenExpiresAt : null,
|
|
63
|
+
emailVerified: typeof payload.emailVerified === 'boolean' ? payload.emailVerified : null,
|
|
64
|
+
tenantId: typeof payload.tenantId === 'string' ? payload.tenantId : null,
|
|
65
|
+
tenantSlug: typeof payload.tenantSlug === 'string' ? payload.tenantSlug : null,
|
|
66
|
+
orgSlug: typeof payload.orgSlug === 'string' ? payload.orgSlug : null,
|
|
67
|
+
orgRole: typeof payload.orgRole === 'string' ? payload.orgRole : null,
|
|
68
|
+
accessibleServices: Array.isArray(payload.accessibleServices)
|
|
69
|
+
? (payload.accessibleServices as unknown[]).filter((s): s is string => typeof s === 'string')
|
|
70
|
+
: null,
|
|
51
71
|
iat: payload.iat ?? 0,
|
|
52
72
|
exp: payload.exp ?? 0,
|
|
53
73
|
}
|