@reflagged/shell 1.0.0 → 1.0.3

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,10 +1,14 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
- "files": ["src", "tsconfig.json", "README.md"],
7
+ "files": [
8
+ "src",
9
+ "tsconfig.json",
10
+ "README.md"
11
+ ],
8
12
  "publishConfig": {
9
13
  "access": "public"
10
14
  },
@@ -14,6 +18,7 @@
14
18
  "./components/BrandSwitcher": "./src/components/BrandSwitcher.tsx",
15
19
  "./auth/oidc-config": "./src/lib/auth/oidc-config.ts",
16
20
  "./auth/oidc-cookie": "./src/lib/auth/oidc-cookie.ts",
21
+ "./auth/can-access": "./src/lib/auth/can-access.ts",
17
22
  "./auth/oidc-refresh": "./src/lib/auth/oidc-refresh.ts",
18
23
  "./auth/nextauth-strategy": "./src/lib/auth/nextauth-strategy.ts",
19
24
  "./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
 
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
 
3
+ import type { OidcSessionToken } from '../auth/oidc-config'
3
4
  import { OIDC_COOKIE, OIDC_SESSION_TTL, loadOidcEnv } from '../auth/oidc-config'
4
5
  import { signSessionCookie, verifySessionCookie } from '../auth/oidc-cookie'
5
6
  import { refreshAccessToken } from '../auth/oidc-refresh'
@@ -7,6 +8,41 @@ import { refreshAccessToken } from '../auth/oidc-refresh'
7
8
  export const runtime = 'nodejs'
8
9
  export const dynamic = 'force-dynamic'
9
10
 
11
+ // Renew the access token this many seconds before its declared expiry, so a
12
+ // token about to lapse mid-request is refreshed up front rather than after a
13
+ // guaranteed 401 round-trip.
14
+ const REFRESH_SKEW_SECONDS = 30
15
+
16
+ type Refreshed = NonNullable<Awaited<ReturnType<typeof refreshAccessToken>>>
17
+
18
+ /**
19
+ * Re-sign the session cookie after a token refresh, PRESERVING every
20
+ * membership/workspace claim from the existing session. Only the token fields
21
+ * change — dropping orgRole/accessibleServices/tenantSlug/... here would
22
+ * silently degrade per-service entitlement gating and the workspace header
23
+ * after each refresh.
24
+ */
25
+ function reSignSession(
26
+ authSecret: string,
27
+ session: OidcSessionToken,
28
+ refreshed: Refreshed,
29
+ ): Promise<string> {
30
+ return signSessionCookie(authSecret, {
31
+ sub: session.sub,
32
+ email: session.email,
33
+ name: session.name,
34
+ emailVerified: session.emailVerified,
35
+ tenantId: session.tenantId,
36
+ tenantSlug: session.tenantSlug,
37
+ orgSlug: session.orgSlug,
38
+ orgRole: session.orgRole,
39
+ accessibleServices: session.accessibleServices,
40
+ accessToken: refreshed.accessToken,
41
+ refreshToken: refreshed.refreshToken,
42
+ accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
43
+ })
44
+ }
45
+
10
46
  export async function GET(req: Request): Promise<NextResponse> {
11
47
  const env = loadOidcEnv()
12
48
  if (!env) return NextResponse.json({ error: 'oidc-not-configured' }, { status: 503 })
@@ -32,22 +68,38 @@ export async function GET(req: Request): Promise<NextResponse> {
32
68
  cache: 'no-store',
33
69
  })
34
70
 
35
- let upstream = await callUpstream(session.accessToken)
71
+ let accessToken: string = session.accessToken
36
72
  let refreshedCookie: string | null = null
37
73
 
74
+ // Proactive refresh: if the access token has expired (or is within the skew
75
+ // window), renew it BEFORE calling upstream. The access token lives ~1h and
76
+ // the refresh token ~14d, so the App-Switcher menu stays alive for the whole
77
+ // session without burning a 401 round-trip or forcing a manual re-login.
78
+ const nowSeconds = Math.floor(Date.now() / 1000)
79
+ if (
80
+ session.refreshToken &&
81
+ typeof session.accessTokenExpiresAt === 'number' &&
82
+ session.accessTokenExpiresAt - REFRESH_SKEW_SECONDS <= nowSeconds
83
+ ) {
84
+ const refreshed = await refreshAccessToken(env, session.refreshToken)
85
+ if (refreshed) {
86
+ refreshedCookie = await reSignSession(env.authSecret, session, refreshed)
87
+ session = { ...session, ...refreshed }
88
+ accessToken = refreshed.accessToken
89
+ }
90
+ }
91
+
92
+ let upstream = await callUpstream(accessToken)
93
+
94
+ // Reactive backstop: the token was rejected before its declared expiry
95
+ // (revoked, clock skew, rotated elsewhere) — try one refresh and retry.
38
96
  if (upstream.status === 401 && session.refreshToken) {
39
97
  const refreshed = await refreshAccessToken(env, session.refreshToken)
40
98
  if (refreshed) {
99
+ refreshedCookie = await reSignSession(env.authSecret, session, refreshed)
41
100
  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)
101
+ accessToken = refreshed.accessToken
102
+ upstream = await callUpstream(accessToken)
51
103
  }
52
104
  }
53
105
 
@@ -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
  }