@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.0.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="/api/oidc/signin"
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 { OIDC_COOKIE, OIDC_SESSION_TTL, OIDC_STATE_COOKIE, loadOidcEnv } from '../auth/oidc-config'
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: req.url.startsWith('https'),
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', 'openid profile email offline_access')
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: req.url.startsWith('https'),
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 { OIDC_COOKIE, loadOidcEnv } from '../auth/oidc-config'
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
- const SIGNED_OUT_PATH = '/admin/login?signed-out=1'
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}${SIGNED_OUT_PATH}`
28
+ const localLanding = `${origin}${signedOutPath()}`
14
29
 
15
30
  if (!env) {
16
31
  const res = NextResponse.redirect(localLanding)
17
- res.cookies.delete(OIDC_COOKIE)
32
+ clearSession(res)
18
33
  return res
19
34
  }
20
35
 
21
- const baseUrl = env.issuer.replace(/\/oidc\/?$/, '')
22
- const target = new URL(`${baseUrl}/api/sso/signout`)
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.cookies.delete(OIDC_COOKIE)
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
  }