@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.
@@ -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/middleware. Concurrent
3
- * requests carrying the same refresh token share one backend call for a short
4
- * window, so a burst of parallel page/API requests after expiry cannot rotate
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
- /** Backend origin (no /api/v1). */
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>;
@@ -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 ?? DEFAULT_TIMEOUT_MS;
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 apiUrl = typeof config.apiUrl === "function" ? config.apiUrl() : config.apiUrl;
15
- const response = await fetch(`${apiUrl}${config.refreshPath}`, {
16
- method: "POST",
17
- headers: { "Content-Type": "application/json", "X-Client-Type": config.clientType },
18
- body: JSON.stringify({ refresh_token: refreshToken }),
19
- cache: "no-store",
20
- signal: controller.signal,
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
- const actorId = decoded.actor_id ||
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.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "description": "Framework-neutral session helpers for C1M platform surfaces: token payload reads, forwarded client headers, safe return paths, device identity cookies, and the first-party session handoff (target callback + source start) built on Web Request/Response",
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
+ }