@c1m-public/platform-auth 0.2.0 → 0.4.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/README.md +99 -22
- package/dist/access/errors.d.ts +13 -0
- package/dist/access/errors.js +19 -0
- package/dist/access/fetch.d.ts +23 -0
- package/dist/access/fetch.js +43 -0
- package/dist/access/generated/keys.d.ts +14 -0
- package/dist/access/generated/keys.js +58 -0
- package/dist/access/index.d.ts +5 -0
- package/dist/access/index.js +5 -0
- package/dist/access/next.d.ts +51 -0
- package/dist/access/next.js +86 -0
- package/dist/access/predicates.d.ts +36 -0
- package/dist/access/predicates.js +76 -0
- package/dist/access/react.d.ts +28 -0
- package/dist/access/react.js +40 -0
- package/dist/access/snapshot.d.ts +31 -0
- package/dist/access/snapshot.js +80 -0
- package/dist/cookies.d.ts +0 -5
- package/dist/cookies.js +1 -6
- package/dist/device-cookie.d.ts +2 -6
- package/dist/forwarding.d.ts +0 -7
- package/dist/forwarding.js +2 -7
- package/dist/handoff-errors.d.ts +9 -5
- package/dist/handoff-errors.js +33 -5
- package/dist/handoff.d.ts +6 -33
- package/dist/handoff.js +14 -39
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +27 -0
- package/dist/next.js +1 -3
- package/dist/return-path.d.ts +3 -13
- package/dist/return-path.js +3 -13
- package/dist/session-gate.d.ts +26 -0
- package/dist/session-gate.js +33 -0
- package/dist/session-refresh.d.ts +5 -14
- package/dist/session-refresh.js +9 -16
- package/dist/token.d.ts +1 -5
- package/dist/token.js +4 -7
- package/package.json +39 -10
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { decodeTokenPayload, hasTokenAudience, isTokenExpired, isTokenExpiringSoon } from "./token.js";
|
|
2
|
+
// Pure decision for a BFF proxy/middleware: given the request's session cookies and path
|
|
3
|
+
// class, decide pass / bounce / clear. The proxy owns response construction.
|
|
4
|
+
export function hasSessionShape(token) {
|
|
5
|
+
if (!token)
|
|
6
|
+
return false;
|
|
7
|
+
const payload = decodeTokenPayload(token);
|
|
8
|
+
return Boolean(payload?.user_id || payload?.actor_id || payload?.customer_id || payload?.sub);
|
|
9
|
+
}
|
|
10
|
+
export async function evaluateSessionGate(input) {
|
|
11
|
+
const { accessToken, refreshToken, audience, refresher, isPublic, isLoginPath, isApiPath } = input;
|
|
12
|
+
const live = !!accessToken && hasSessionShape(accessToken) && hasTokenAudience(accessToken, audience) && !isTokenExpired(accessToken);
|
|
13
|
+
if (live) {
|
|
14
|
+
if (isPublic && isLoginPath)
|
|
15
|
+
return { kind: "redirect_home" };
|
|
16
|
+
if (refreshToken && isTokenExpiringSoon(accessToken, input.expiringSoonBufferSeconds)) {
|
|
17
|
+
const refreshed = await refresher.refresh(refreshToken);
|
|
18
|
+
if (refreshed)
|
|
19
|
+
return { kind: "pass", refreshed };
|
|
20
|
+
}
|
|
21
|
+
return { kind: "pass" };
|
|
22
|
+
}
|
|
23
|
+
if (refreshToken) {
|
|
24
|
+
const refreshed = await refresher.refresh(refreshToken);
|
|
25
|
+
if (refreshed)
|
|
26
|
+
return isPublic ? { kind: "redirect_home", refreshed } : { kind: "pass", refreshed };
|
|
27
|
+
}
|
|
28
|
+
if (isPublic)
|
|
29
|
+
return { kind: "anonymous_public" };
|
|
30
|
+
if (isApiPath)
|
|
31
|
+
return { kind: "unauthorized_api" };
|
|
32
|
+
return { kind: "redirect_login" };
|
|
33
|
+
}
|
|
@@ -1,33 +1,24 @@
|
|
|
1
|
+
import { type Resolvable } from "./internal.js";
|
|
1
2
|
/**
|
|
2
|
-
* Coalesced refresh-token exchange for a BFF proxy
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* the family more than once. Best-effort inside a single process only — the
|
|
6
|
-
* backend's reuse detection remains the correctness guarantee.
|
|
3
|
+
* Coalesced refresh-token exchange for a BFF proxy: same refresh token within
|
|
4
|
+
* a short window shares one backend call. Per-process best effort; backend
|
|
5
|
+
* reuse detection is the correctness guarantee.
|
|
7
6
|
*/
|
|
8
7
|
export interface RefreshedSession {
|
|
9
8
|
accessToken: string;
|
|
10
9
|
refreshToken?: string;
|
|
11
10
|
}
|
|
12
11
|
export interface SessionRefresherConfig {
|
|
13
|
-
|
|
14
|
-
apiUrl: string | (() => string);
|
|
15
|
-
/** Refresh endpoint path, e.g. /api/v1/platform/auth/refresh. */
|
|
12
|
+
apiUrl: Resolvable;
|
|
16
13
|
refreshPath: string;
|
|
17
|
-
/** X-Client-Type sent to the backend. */
|
|
18
14
|
clientType: string;
|
|
19
|
-
/** Audience the refreshed access token must carry; anything else is rejected. */
|
|
20
15
|
audience: string;
|
|
21
|
-
/** How long a completed refresh keeps answering for the same token. Default 5 s. */
|
|
22
16
|
singleflightTtlMs?: number;
|
|
23
|
-
/** Bound on remembered in-flight/recent refreshes. Default 1000. */
|
|
24
17
|
maxSingleflights?: number;
|
|
25
18
|
fetchTimeoutMs?: number;
|
|
26
19
|
}
|
|
27
20
|
export declare function createSessionRefresher(config: SessionRefresherConfig): {
|
|
28
|
-
/** Exchange a refresh token; identical concurrent calls share one backend request. */
|
|
29
21
|
refresh(refreshToken: string): Promise<RefreshedSession | null>;
|
|
30
|
-
/** Test/inspection hook: number of remembered refreshes. */
|
|
31
22
|
size(): number;
|
|
32
23
|
};
|
|
33
24
|
export type SessionRefresher = ReturnType<typeof createSessionRefresher>;
|
package/dist/session-refresh.js
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
|
+
import { DEFAULT_FETCH_TIMEOUT_MS, resolveValue, timedFetch } from "./internal.js";
|
|
1
2
|
import { hasTokenAudience, parseAuthResponse } from "./token.js";
|
|
2
3
|
const DEFAULT_TTL_MS = 5000;
|
|
3
4
|
const DEFAULT_MAX = 1000;
|
|
4
|
-
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
5
5
|
export function createSessionRefresher(config) {
|
|
6
6
|
const ttl = config.singleflightTtlMs ?? DEFAULT_TTL_MS;
|
|
7
7
|
const max = config.maxSingleflights ?? DEFAULT_MAX;
|
|
8
|
-
const timeoutMs = config.fetchTimeoutMs ??
|
|
8
|
+
const timeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
9
9
|
const inflight = new Map();
|
|
10
10
|
async function perform(refreshToken) {
|
|
11
|
-
const controller = new AbortController();
|
|
12
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
13
11
|
try {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
const response = await timedFetch(`${resolveValue(config.apiUrl)}${config.refreshPath}`, {
|
|
13
|
+
init: {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: { "Content-Type": "application/json", "X-Client-Type": config.clientType },
|
|
16
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
17
|
+
},
|
|
18
|
+
timeoutMs,
|
|
21
19
|
});
|
|
22
20
|
if (!response.ok)
|
|
23
21
|
return null;
|
|
@@ -29,9 +27,6 @@ export function createSessionRefresher(config) {
|
|
|
29
27
|
catch {
|
|
30
28
|
return null;
|
|
31
29
|
}
|
|
32
|
-
finally {
|
|
33
|
-
clearTimeout(timer);
|
|
34
|
-
}
|
|
35
30
|
}
|
|
36
31
|
function prune(now) {
|
|
37
32
|
for (const [token, entry] of inflight) {
|
|
@@ -46,7 +41,6 @@ export function createSessionRefresher(config) {
|
|
|
46
41
|
}
|
|
47
42
|
}
|
|
48
43
|
return {
|
|
49
|
-
/** Exchange a refresh token; identical concurrent calls share one backend request. */
|
|
50
44
|
refresh(refreshToken) {
|
|
51
45
|
const now = Date.now();
|
|
52
46
|
prune(now);
|
|
@@ -61,7 +55,6 @@ export function createSessionRefresher(config) {
|
|
|
61
55
|
inflight.set(refreshToken, { expiresAt: now + ttl, promise });
|
|
62
56
|
return promise;
|
|
63
57
|
},
|
|
64
|
-
/** Test/inspection hook: number of remembered refreshes. */
|
|
65
58
|
size() {
|
|
66
59
|
return inflight.size;
|
|
67
60
|
},
|
package/dist/token.d.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Read-only access-token payload helpers. Decoding is not verification: these
|
|
3
|
-
* helpers exist so a BFF can route on audience/expiry before calling the
|
|
4
|
-
* backend, which remains the security boundary.
|
|
5
|
-
*/
|
|
6
1
|
export interface SessionUser {
|
|
7
2
|
id: string;
|
|
8
3
|
email: string;
|
|
@@ -25,6 +20,7 @@ export type TokenPayload = Record<string, unknown> & {
|
|
|
25
20
|
exp?: number;
|
|
26
21
|
iat?: number;
|
|
27
22
|
actor_id?: string;
|
|
23
|
+
effective_user_id?: string;
|
|
28
24
|
customer_id?: string;
|
|
29
25
|
user_id?: string;
|
|
30
26
|
aud?: string | string[];
|
package/dist/token.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Read-only access-token payload helpers. Decoding is not verification: these
|
|
3
|
-
* helpers exist so a BFF can route on audience/expiry before calling the
|
|
4
|
-
* backend, which remains the security boundary.
|
|
5
|
-
*/
|
|
1
|
+
// Access-token payload reads for BFF routing. Decoding is not verification; the backend is the boundary.
|
|
6
2
|
function readStringClaim(value, fallback = "") {
|
|
7
3
|
return typeof value === "string" ? value : fallback;
|
|
8
4
|
}
|
|
@@ -13,7 +9,6 @@ export function decodeBase64Url(input) {
|
|
|
13
9
|
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
14
10
|
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
|
|
15
11
|
try {
|
|
16
|
-
// Node >= 20 and every browser expose atob globally.
|
|
17
12
|
const binary = globalThis.atob(padded);
|
|
18
13
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
19
14
|
return new TextDecoder().decode(bytes);
|
|
@@ -69,7 +64,9 @@ export function decodeDefaultSession(token) {
|
|
|
69
64
|
const decoded = decodeTokenPayload(token);
|
|
70
65
|
if (!decoded)
|
|
71
66
|
return null;
|
|
72
|
-
|
|
67
|
+
// effective_user_id wins: a support-session token re-identifies as the impersonated user.
|
|
68
|
+
const actorId = decoded.effective_user_id ||
|
|
69
|
+
decoded.actor_id ||
|
|
73
70
|
decoded.customer_id ||
|
|
74
71
|
decoded.user_id ||
|
|
75
72
|
(typeof decoded.sub === "string" ? decoded.sub : undefined);
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c1m-public/platform-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "Server-side session and authorization helpers for C1M platform surfaces: token/session reads, the first-party handoff (PKCE target + source), proxy session gate and coalesced refresh, plus the resolved access snapshot, typed permission keys, predicates, a Next loader and React bindings under ./access",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/cloud1mediaLtd/mml-js.git",
|
|
@@ -19,6 +19,18 @@
|
|
|
19
19
|
"./next": {
|
|
20
20
|
"types": "./dist/next.d.ts",
|
|
21
21
|
"default": "./dist/next.js"
|
|
22
|
+
},
|
|
23
|
+
"./access": {
|
|
24
|
+
"types": "./dist/access/index.d.ts",
|
|
25
|
+
"default": "./dist/access/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./access/next": {
|
|
28
|
+
"types": "./dist/access/next.d.ts",
|
|
29
|
+
"default": "./dist/access/next.js"
|
|
30
|
+
},
|
|
31
|
+
"./access/react": {
|
|
32
|
+
"types": "./dist/access/react.d.ts",
|
|
33
|
+
"default": "./dist/access/react.js"
|
|
22
34
|
}
|
|
23
35
|
},
|
|
24
36
|
"files": [
|
|
@@ -31,13 +43,6 @@
|
|
|
31
43
|
"publishConfig": {
|
|
32
44
|
"access": "public"
|
|
33
45
|
},
|
|
34
|
-
"dependencies": {
|
|
35
|
-
"server-only": "0.0.1"
|
|
36
|
-
},
|
|
37
|
-
"devDependencies": {
|
|
38
|
-
"typescript": "5.9.3",
|
|
39
|
-
"vitest": "^4.1.4"
|
|
40
|
-
},
|
|
41
46
|
"scripts": {
|
|
42
47
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
43
48
|
"build": "pnpm clean && tsc -p tsconfig.build.json",
|
|
@@ -45,5 +50,29 @@
|
|
|
45
50
|
"test": "vitest",
|
|
46
51
|
"test:run": "vitest run",
|
|
47
52
|
"type-check": "tsc -p tsconfig.json --noEmit"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"server-only": "0.0.1"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/react": "^19",
|
|
59
|
+
"@types/react-dom": "^19",
|
|
60
|
+
"next": "16.3.1",
|
|
61
|
+
"react": "19.2.4",
|
|
62
|
+
"react-dom": "19.2.4",
|
|
63
|
+
"typescript": "5.9.3",
|
|
64
|
+
"vitest": "^4.1.4"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"next": ">=15",
|
|
68
|
+
"react": ">=18"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"next": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"react": {
|
|
75
|
+
"optional": true
|
|
76
|
+
}
|
|
48
77
|
}
|
|
49
|
-
}
|
|
78
|
+
}
|