@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 +6 -2
- package/src/lib/api/shell-info.ts +62 -10
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reflagged/shell",
|
|
3
|
-
"version": "1.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": [
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|