@reflagged/shell 1.0.2 → 1.1.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/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
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
  },
@@ -81,6 +81,7 @@ export async function GET(req: Request): Promise<NextResponse> {
81
81
  tenantSlug: asString(claims.tenant_slug),
82
82
  orgSlug: asString(claims.org_slug),
83
83
  orgRole: asString(claims.org_role),
84
+ platformRole: asString(claims.platform_role),
84
85
  accessibleServices,
85
86
  })
86
87
 
@@ -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
 
@@ -17,6 +17,16 @@ export type OidcSessionToken = {
17
17
  tenantSlug?: string | null
18
18
  orgSlug?: string | null
19
19
  orgRole?: string | null
20
+ /**
21
+ * The platform-wide role (`superadmin` · `reflagged_admin` · `tenant_admin`
22
+ * · `member`), beside `org_role` rather than behind it.
23
+ *
24
+ * `org_role` is emitted as `membership ?? platform`, which drops the second
25
+ * one: a `superadmin` carried as `member` in their own organisation looks
26
+ * like an ordinary member to a service. A service deriving rights from the
27
+ * token would lock them out of their own instance at the next sign-in.
28
+ */
29
+ platformRole?: string | null
20
30
  /** Product keys the user may access; ['*'] = all, [] = none. */
21
31
  accessibleServices?: string[] | null
22
32
  iat: number
@@ -22,6 +22,7 @@ export async function signSessionCookie(
22
22
  tenantSlug?: string | null
23
23
  orgSlug?: string | null
24
24
  orgRole?: string | null
25
+ platformRole?: string | null
25
26
  accessibleServices?: string[] | null
26
27
  },
27
28
  ): Promise<string> {
@@ -36,6 +37,7 @@ export async function signSessionCookie(
36
37
  tenantSlug: payload.tenantSlug ?? null,
37
38
  orgSlug: payload.orgSlug ?? null,
38
39
  orgRole: payload.orgRole ?? null,
40
+ platformRole: payload.platformRole ?? null,
39
41
  accessibleServices: payload.accessibleServices ?? null,
40
42
  })
41
43
  .setProtectedHeader({ alg: ALG })
@@ -65,6 +67,7 @@ export async function verifySessionCookie(
65
67
  tenantSlug: typeof payload.tenantSlug === 'string' ? payload.tenantSlug : null,
66
68
  orgSlug: typeof payload.orgSlug === 'string' ? payload.orgSlug : null,
67
69
  orgRole: typeof payload.orgRole === 'string' ? payload.orgRole : null,
70
+ platformRole: typeof payload.platformRole === 'string' ? payload.platformRole : null,
68
71
  accessibleServices: Array.isArray(payload.accessibleServices)
69
72
  ? (payload.accessibleServices as unknown[]).filter((s): s is string => typeof s === 'string')
70
73
  : null,