@reflagged/shell 1.0.2 → 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.2",
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
  },
@@ -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