@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
|
|
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": [
|
|
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
|
|
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
|
|
|
@@ -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,
|