@c1m-public/platform-auth 0.2.0 → 0.3.1
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/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 +0 -5
- package/dist/handoff-errors.js +1 -5
- package/dist/handoff.d.ts +5 -31
- package/dist/handoff.js +7 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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 +3 -13
- package/dist/session-refresh.js +0 -2
- package/dist/token.d.ts +1 -5
- package/dist/token.js +4 -7
- package/package.json +10 -28
package/dist/cookies.d.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal, dependency-free cookie helpers over the Web `Request` / `Response`
|
|
3
|
-
* types so route handlers stay framework-neutral (Next.js route handlers
|
|
4
|
-
* accept and return exactly these).
|
|
5
|
-
*/
|
|
6
1
|
export interface CookieOptions {
|
|
7
2
|
httpOnly?: boolean;
|
|
8
3
|
secure?: boolean;
|
package/dist/cookies.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Minimal, dependency-free cookie helpers over the Web `Request` / `Response`
|
|
3
|
-
* types so route handlers stay framework-neutral (Next.js route handlers
|
|
4
|
-
* accept and return exactly these).
|
|
5
|
-
*/
|
|
1
|
+
// Dependency-free cookie helpers over Web Request/Response (what Next route handlers use).
|
|
6
2
|
export function readCookie(request, name) {
|
|
7
3
|
const header = request.headers.get("cookie");
|
|
8
4
|
if (!header)
|
|
@@ -30,7 +26,6 @@ export function serializeCookie(name, value, options = {}) {
|
|
|
30
26
|
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
|
|
31
27
|
if (options.httpOnly ?? true)
|
|
32
28
|
parts.push("HttpOnly");
|
|
33
|
-
// `__Host-` cookies are only valid with Secure and no Domain.
|
|
34
29
|
if (options.secure ?? name.startsWith("__Host-"))
|
|
35
30
|
parts.push("Secure");
|
|
36
31
|
const sameSite = options.sameSite ?? "lax";
|
package/dist/device-cookie.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* without it, so every sign-in and handoff exchange must send it. It carries
|
|
5
|
-
* no authority: it is informational device recognition only.
|
|
2
|
+
* Per-browser identity sent as `X-Client-Device-ID`; c1m-server refuses to
|
|
3
|
+
* create a session without it. Informational only, no authority.
|
|
6
4
|
*/
|
|
7
5
|
export interface DeviceIdentity {
|
|
8
6
|
value: string;
|
|
@@ -10,9 +8,7 @@ export interface DeviceIdentity {
|
|
|
10
8
|
}
|
|
11
9
|
export interface DeviceIdentityConfig {
|
|
12
10
|
cookieName: string;
|
|
13
|
-
/** Defaults to true; set false only for plain-http local development. */
|
|
14
11
|
secure?: boolean;
|
|
15
|
-
/** Defaults to one year. */
|
|
16
12
|
maxAgeSeconds?: number;
|
|
17
13
|
}
|
|
18
14
|
export declare function createDeviceIdentity(config: DeviceIdentityConfig): {
|
package/dist/forwarding.d.ts
CHANGED
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Copies the browser's client identity (IP, geo, user agent, host, proto) from
|
|
3
|
-
* an incoming BFF request onto an outgoing backend request. c1m-server reads
|
|
4
|
-
* these `X-Client-*` / `X-Forwarded-*` headers for session device records,
|
|
5
|
-
* rate limits, and audit — never for authentication.
|
|
6
|
-
*/
|
|
7
1
|
export declare function applyForwardedClientHeaders(incoming: Headers, headers?: Headers): Headers;
|
|
8
|
-
/** Builds outgoing headers for a backend call made on behalf of `request`. */
|
|
9
2
|
export declare function forwardedRequestHeaders(request: Request, init?: HeadersInit): Headers;
|
package/dist/forwarding.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* an incoming BFF request onto an outgoing backend request. c1m-server reads
|
|
4
|
-
* these `X-Client-*` / `X-Forwarded-*` headers for session device records,
|
|
5
|
-
* rate limits, and audit — never for authentication.
|
|
6
|
-
*/
|
|
1
|
+
// Copies client identity (IP, geo, UA, host, proto) from an incoming BFF request onto a backend
|
|
2
|
+
// request as X-Client-* / X-Forwarded-*. Used for device records, rate limits, audit — never auth.
|
|
7
3
|
export function applyForwardedClientHeaders(incoming, headers = new Headers()) {
|
|
8
4
|
const forwardedFor = incoming.get("x-forwarded-for");
|
|
9
5
|
const realIp = incoming.get("x-real-ip") ?? forwardedFor?.split(",")[0]?.trim();
|
|
@@ -31,7 +27,6 @@ export function applyForwardedClientHeaders(incoming, headers = new Headers()) {
|
|
|
31
27
|
headers.set("X-Client-User-Agent", userAgent);
|
|
32
28
|
return headers;
|
|
33
29
|
}
|
|
34
|
-
/** Builds outgoing headers for a backend call made on behalf of `request`. */
|
|
35
30
|
export function forwardedRequestHeaders(request, init) {
|
|
36
31
|
return applyForwardedClientHeaders(request.headers, new Headers(init));
|
|
37
32
|
}
|
package/dist/handoff-errors.d.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The one error vocabulary for the first-party session handoff. Backend codes
|
|
3
|
-
* (c1m-server `mapHandoffError`) and BFF-side codes share this map so every
|
|
4
|
-
* surface renders the same specific message instead of a generic banner.
|
|
5
|
-
*/
|
|
6
1
|
export declare const HANDOFF_ERROR_MESSAGES: Record<string, string>;
|
|
7
2
|
export declare const HANDOFF_ERROR_FALLBACK = "Sign-in failed \u2014 please try again.";
|
|
8
3
|
export declare function handoffErrorMessage(code: string | null | undefined): string;
|
package/dist/handoff-errors.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* The one error vocabulary for the first-party session handoff. Backend codes
|
|
3
|
-
* (c1m-server `mapHandoffError`) and BFF-side codes share this map so every
|
|
4
|
-
* surface renders the same specific message instead of a generic banner.
|
|
5
|
-
*/
|
|
1
|
+
// One error vocabulary for the handoff: backend codes (c1m-server mapHandoffError) + BFF codes.
|
|
6
2
|
export const HANDOFF_ERROR_MESSAGES = {
|
|
7
3
|
// backend — handoff broker
|
|
8
4
|
handoff_unknown_app: "That application is not registered for sign-in handoff.",
|
package/dist/handoff.d.ts
CHANGED
|
@@ -1,41 +1,24 @@
|
|
|
1
1
|
import type { DeviceIdentityStore } from "./device-cookie.js";
|
|
2
2
|
import { type AuthResponse } from "./token.js";
|
|
3
|
-
/** Path a source exposes to start a handoff to `app` (relative to the source origin). */
|
|
4
3
|
export declare function sourceStartPath(app: string): string;
|
|
5
|
-
/** Backend routes for a registered application key. */
|
|
6
4
|
export declare function brokerPaths(app: string): {
|
|
7
5
|
readonly start: `/api/v1/platform/auth/handoffs/${string}/start`;
|
|
8
6
|
readonly exchange: `/api/v1/platform/auth/handoffs/${string}/exchange`;
|
|
9
7
|
};
|
|
10
8
|
type Resolvable = string | (() => string);
|
|
11
9
|
export interface HandoffTargetConfig {
|
|
12
|
-
/** Registered application key (`docs`). */
|
|
13
10
|
app: string;
|
|
14
|
-
/** Backend origin, e.g. https://api.example.com (no /api/v1). */
|
|
15
11
|
apiUrl: Resolvable;
|
|
16
|
-
/** Audience the exchange must mint; the callback refuses anything else. */
|
|
17
12
|
audience: string;
|
|
18
|
-
/** X-Client-Type sent to the backend. */
|
|
19
13
|
clientType: string;
|
|
20
|
-
/** Source key → source origin. The `?source=` query on start selects one. */
|
|
21
14
|
sources: Record<string, Resolvable>;
|
|
22
|
-
/** Source used when `?source=` is absent. */
|
|
23
15
|
defaultSource: string;
|
|
24
|
-
/** Prefix for the temporary handoff cookies (`__Host-<prefix>_handoff_*`). */
|
|
25
16
|
cookiePrefix: string;
|
|
26
|
-
/** Target's login page (receives `?error=` on failure). */
|
|
27
17
|
loginPath: string;
|
|
28
|
-
/** Where to land when no `from` was supplied. */
|
|
29
18
|
defaultReturnPath: string;
|
|
30
|
-
/** Device identity cookie store (X-Client-Device-ID). */
|
|
31
19
|
device: DeviceIdentityStore;
|
|
32
|
-
/**
|
|
33
|
-
* Establish the target session on the outgoing redirect. Return the response
|
|
34
|
-
* (with cookies applied) — or return nothing if cookies were written through
|
|
35
|
-
* a framework store such as `next/headers`.
|
|
36
|
-
*/
|
|
20
|
+
/** Write session cookies onto `response` (or return void if written via a framework store). */
|
|
37
21
|
establishSession: (session: AuthResponse, response: Response, request: Request) => Promise<Response | void> | Response | void;
|
|
38
|
-
/** Set false only for plain-http local development (drops the `__Host-` prefix). */
|
|
39
22
|
secureCookies?: boolean;
|
|
40
23
|
fetchTimeoutMs?: number;
|
|
41
24
|
}
|
|
@@ -45,37 +28,28 @@ export declare function createHandoffTarget(config: HandoffTargetConfig): {
|
|
|
45
28
|
readonly verifier: `${string}verifier`;
|
|
46
29
|
readonly from: `${string}from`;
|
|
47
30
|
};
|
|
48
|
-
/** GET `?source
|
|
31
|
+
/** GET `?source=&from=` */
|
|
49
32
|
start(request: Request): Promise<Response>;
|
|
50
|
-
/** GET `?code=&state=`
|
|
33
|
+
/** GET `?code=&state=` */
|
|
51
34
|
callback(request: Request): Promise<Response>;
|
|
52
35
|
};
|
|
53
36
|
export type HandoffTarget = ReturnType<typeof createHandoffTarget>;
|
|
54
37
|
export interface HandoffSourceTarget {
|
|
55
|
-
/** Exact target origin; must equal the origin of the backend's redirect_url. */
|
|
56
38
|
origin: Resolvable;
|
|
57
|
-
/** Registered callback path on the target. */
|
|
58
39
|
callbackPath?: string;
|
|
59
|
-
/** Target login page for error redirects. */
|
|
60
40
|
loginPath?: string;
|
|
61
41
|
}
|
|
62
42
|
export interface HandoffSourceConfig {
|
|
63
43
|
apiUrl: Resolvable;
|
|
64
44
|
clientType: string;
|
|
65
|
-
/** Registered application key → target. */
|
|
66
45
|
targets: Record<string, HandoffSourceTarget>;
|
|
67
|
-
/**
|
|
68
|
-
* The source's current access token, or null when signed out. Implementations
|
|
69
|
-
* should refresh an expiring token before returning it — the backend needs a
|
|
70
|
-
* live bearer to mint the code.
|
|
71
|
-
*/
|
|
46
|
+
/** Live bearer or null when signed out; refresh an expiring token before returning it. */
|
|
72
47
|
getAccessToken: (request: Request) => Promise<string | null> | string | null;
|
|
73
|
-
/** Source login page; a signed-out start bounces here with `?from=` set to resume. */
|
|
74
48
|
loginPath: string;
|
|
75
49
|
fetchTimeoutMs?: number;
|
|
76
50
|
}
|
|
77
51
|
export declare function createHandoffSource(config: HandoffSourceConfig): {
|
|
78
|
-
/** GET `/api/auth/handoffs/{app}/start?code_challenge=&state
|
|
52
|
+
/** GET `/api/auth/handoffs/{app}/start?code_challenge=&state=` */
|
|
79
53
|
start(request: Request, app: string): Promise<Response>;
|
|
80
54
|
};
|
|
81
55
|
export type HandoffSource = ReturnType<typeof createHandoffSource>;
|
package/dist/handoff.js
CHANGED
|
@@ -3,31 +3,17 @@ import { forwardedRequestHeaders } from "./forwarding.js";
|
|
|
3
3
|
import { safeInternalPath } from "./return-path.js";
|
|
4
4
|
import { hasTokenAudience, parseAuthResponse } from "./token.js";
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* host-local cookies, redirects to the
|
|
11
|
-
* source's start route with the S256
|
|
12
|
-
* challenge.
|
|
13
|
-
* source.start (source origin) requires the source session, asks the
|
|
14
|
-
* backend for a one-time code, redirects to
|
|
15
|
-
* the target's registered callback.
|
|
16
|
-
* target.callback (target origin) verifies state, exchanges code+verifier,
|
|
17
|
-
* establishes the target session in its own
|
|
18
|
-
* cookies, redirects to the return path.
|
|
19
|
-
*
|
|
20
|
-
* Tokens never appear in URLs; every failure is a redirect to a login page
|
|
21
|
-
* with a code from HANDOFF_ERROR_MESSAGES.
|
|
6
|
+
* Browser halves of c1m-server's `/api/v1/platform/auth/handoffs/{app}` broker.
|
|
7
|
+
* target.start → source.start → target.callback. PKCE S256 + state, host-local
|
|
8
|
+
* cookies, no tokens in URLs; failures redirect to a login page with a code
|
|
9
|
+
* from HANDOFF_ERROR_MESSAGES.
|
|
22
10
|
*/
|
|
23
11
|
const HANDOFF_COOKIE_TTL_SECONDS = 120;
|
|
24
12
|
const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
25
13
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
26
|
-
/** Path a source exposes to start a handoff to `app` (relative to the source origin). */
|
|
27
14
|
export function sourceStartPath(app) {
|
|
28
15
|
return `/api/auth/handoffs/${encodeURIComponent(app)}/start`;
|
|
29
16
|
}
|
|
30
|
-
/** Backend routes for a registered application key. */
|
|
31
17
|
export function brokerPaths(app) {
|
|
32
18
|
const key = encodeURIComponent(app);
|
|
33
19
|
return {
|
|
@@ -94,7 +80,7 @@ export function createHandoffTarget(config) {
|
|
|
94
80
|
const fail = (origin, code) => clearHandoffCookies(loginErrorRedirect(origin, config.loginPath, code));
|
|
95
81
|
return {
|
|
96
82
|
cookies,
|
|
97
|
-
/** GET `?source
|
|
83
|
+
/** GET `?source=&from=` */
|
|
98
84
|
async start(request) {
|
|
99
85
|
const url = new URL(request.url);
|
|
100
86
|
const sourceKey = url.searchParams.get("source")?.trim() || config.defaultSource;
|
|
@@ -117,7 +103,7 @@ export function createHandoffTarget(config) {
|
|
|
117
103
|
setCookie(response, cookies.from, from, cookieOptions);
|
|
118
104
|
return response;
|
|
119
105
|
},
|
|
120
|
-
/** GET `?code=&state=`
|
|
106
|
+
/** GET `?code=&state=` */
|
|
121
107
|
async callback(request) {
|
|
122
108
|
const url = new URL(request.url);
|
|
123
109
|
const code = url.searchParams.get("code") ?? "";
|
|
@@ -160,7 +146,7 @@ const DEFAULT_TARGET_LOGIN = "/login";
|
|
|
160
146
|
export function createHandoffSource(config) {
|
|
161
147
|
const timeout = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
162
148
|
return {
|
|
163
|
-
/** GET `/api/auth/handoffs/{app}/start?code_challenge=&state
|
|
149
|
+
/** GET `/api/auth/handoffs/{app}/start?code_challenge=&state=` */
|
|
164
150
|
async start(request, app) {
|
|
165
151
|
const target = config.targets[app];
|
|
166
152
|
if (!target)
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/next.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Server-only entry
|
|
2
|
-
// entry is framework-neutral (Web Request/Response); this one only adds the
|
|
3
|
-
// bundler guard so session helpers cannot leak into client bundles.
|
|
1
|
+
// Server-only entry: root API plus the bundler guard against client bundles.
|
|
4
2
|
import "server-only";
|
|
5
3
|
export * from "./index.js";
|
package/dist/return-path.d.ts
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Strict same-origin return path
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* A value is accepted only when it:
|
|
7
|
-
* 1. is non-empty after trimming
|
|
8
|
-
* 2. starts with `/`
|
|
9
|
-
* 3. does NOT start with `//` (protocol-relative bypass) or contain `://`
|
|
10
|
-
* 4. contains no encoded path separators or control characters
|
|
11
|
-
* 5. round-trips to the exact same string (catches `\`, hostname components,
|
|
12
|
-
* and other parser-normalised trickery)
|
|
13
|
-
*
|
|
14
|
-
* The accepted value is returned verbatim — query and hash are preserved.
|
|
2
|
+
* Strict same-origin return path: must start with `/`, not `//` or contain `://`,
|
|
3
|
+
* no encoded slashes or control chars, and round-trip URL parsing unchanged.
|
|
4
|
+
* Returns the value verbatim (query/hash kept) or null.
|
|
15
5
|
*/
|
|
16
6
|
export declare function safeInternalPath(raw: string | null | undefined): string | null;
|
package/dist/return-path.js
CHANGED
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Strict same-origin return path
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* A value is accepted only when it:
|
|
7
|
-
* 1. is non-empty after trimming
|
|
8
|
-
* 2. starts with `/`
|
|
9
|
-
* 3. does NOT start with `//` (protocol-relative bypass) or contain `://`
|
|
10
|
-
* 4. contains no encoded path separators or control characters
|
|
11
|
-
* 5. round-trips to the exact same string (catches `\`, hostname components,
|
|
12
|
-
* and other parser-normalised trickery)
|
|
13
|
-
*
|
|
14
|
-
* The accepted value is returned verbatim — query and hash are preserved.
|
|
2
|
+
* Strict same-origin return path: must start with `/`, not `//` or contain `://`,
|
|
3
|
+
* no encoded slashes or control chars, and round-trip URL parsing unchanged.
|
|
4
|
+
* Returns the value verbatim (query/hash kept) or null.
|
|
15
5
|
*/
|
|
16
6
|
export function safeInternalPath(raw) {
|
|
17
7
|
if (raw == null)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RefreshedSession, SessionRefresher } from "./session-refresh.js";
|
|
2
|
+
export declare function hasSessionShape(token: string | undefined | null): boolean;
|
|
3
|
+
export type SessionGateDecision = {
|
|
4
|
+
kind: "pass";
|
|
5
|
+
refreshed?: RefreshedSession;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "redirect_home";
|
|
8
|
+
refreshed?: RefreshedSession;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "anonymous_public";
|
|
11
|
+
} | {
|
|
12
|
+
kind: "unauthorized_api";
|
|
13
|
+
} | {
|
|
14
|
+
kind: "redirect_login";
|
|
15
|
+
};
|
|
16
|
+
export interface SessionGateInput {
|
|
17
|
+
accessToken?: string | null;
|
|
18
|
+
refreshToken?: string | null;
|
|
19
|
+
audience: string;
|
|
20
|
+
refresher: Pick<SessionRefresher, "refresh">;
|
|
21
|
+
isPublic: boolean;
|
|
22
|
+
isLoginPath: boolean;
|
|
23
|
+
isApiPath: boolean;
|
|
24
|
+
expiringSoonBufferSeconds?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function evaluateSessionGate(input: SessionGateInput): Promise<SessionGateDecision>;
|
|
@@ -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,23 @@
|
|
|
1
1
|
/**
|
|
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.
|
|
2
|
+
* Coalesced refresh-token exchange for a BFF proxy: same refresh token within
|
|
3
|
+
* a short window shares one backend call. Per-process best effort; backend
|
|
4
|
+
* reuse detection is the correctness guarantee.
|
|
7
5
|
*/
|
|
8
6
|
export interface RefreshedSession {
|
|
9
7
|
accessToken: string;
|
|
10
8
|
refreshToken?: string;
|
|
11
9
|
}
|
|
12
10
|
export interface SessionRefresherConfig {
|
|
13
|
-
/** Backend origin (no /api/v1). */
|
|
14
11
|
apiUrl: string | (() => string);
|
|
15
|
-
/** Refresh endpoint path, e.g. /api/v1/platform/auth/refresh. */
|
|
16
12
|
refreshPath: string;
|
|
17
|
-
/** X-Client-Type sent to the backend. */
|
|
18
13
|
clientType: string;
|
|
19
|
-
/** Audience the refreshed access token must carry; anything else is rejected. */
|
|
20
14
|
audience: string;
|
|
21
|
-
/** How long a completed refresh keeps answering for the same token. Default 5 s. */
|
|
22
15
|
singleflightTtlMs?: number;
|
|
23
|
-
/** Bound on remembered in-flight/recent refreshes. Default 1000. */
|
|
24
16
|
maxSingleflights?: number;
|
|
25
17
|
fetchTimeoutMs?: number;
|
|
26
18
|
}
|
|
27
19
|
export declare function createSessionRefresher(config: SessionRefresherConfig): {
|
|
28
|
-
/** Exchange a refresh token; identical concurrent calls share one backend request. */
|
|
29
20
|
refresh(refreshToken: string): Promise<RefreshedSession | null>;
|
|
30
|
-
/** Test/inspection hook: number of remembered refreshes. */
|
|
31
21
|
size(): number;
|
|
32
22
|
};
|
|
33
23
|
export type SessionRefresher = ReturnType<typeof createSessionRefresher>;
|
package/dist/session-refresh.js
CHANGED
|
@@ -46,7 +46,6 @@ export function createSessionRefresher(config) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
return {
|
|
49
|
-
/** Exchange a refresh token; identical concurrent calls share one backend request. */
|
|
50
49
|
refresh(refreshToken) {
|
|
51
50
|
const now = Date.now();
|
|
52
51
|
prune(now);
|
|
@@ -61,7 +60,6 @@ export function createSessionRefresher(config) {
|
|
|
61
60
|
inflight.set(refreshToken, { expiresAt: now + ttl, promise });
|
|
62
61
|
return promise;
|
|
63
62
|
},
|
|
64
|
-
/** Test/inspection hook: number of remembered refreshes. */
|
|
65
63
|
size() {
|
|
66
64
|
return inflight.size;
|
|
67
65
|
},
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c1m-public/platform-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
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",
|
|
@@ -12,32 +12,12 @@
|
|
|
12
12
|
"main": "./dist/index.js",
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
14
|
"exports": {
|
|
15
|
-
".": {
|
|
16
|
-
|
|
17
|
-
"default": "./dist/index.js"
|
|
18
|
-
},
|
|
19
|
-
"./next": {
|
|
20
|
-
"types": "./dist/next.d.ts",
|
|
21
|
-
"default": "./dist/next.js"
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
"files": [
|
|
25
|
-
"dist",
|
|
26
|
-
"README.md"
|
|
27
|
-
],
|
|
28
|
-
"engines": {
|
|
29
|
-
"node": ">=20"
|
|
30
|
-
},
|
|
31
|
-
"publishConfig": {
|
|
32
|
-
"access": "public"
|
|
33
|
-
},
|
|
34
|
-
"dependencies": {
|
|
35
|
-
"server-only": "0.0.1"
|
|
36
|
-
},
|
|
37
|
-
"devDependencies": {
|
|
38
|
-
"typescript": "5.9.3",
|
|
39
|
-
"vitest": "^4.1.4"
|
|
15
|
+
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
16
|
+
"./next": { "types": "./dist/next.d.ts", "default": "./dist/next.js" }
|
|
40
17
|
},
|
|
18
|
+
"files": ["dist", "README.md"],
|
|
19
|
+
"engines": { "node": ">=20" },
|
|
20
|
+
"publishConfig": { "access": "public" },
|
|
41
21
|
"scripts": {
|
|
42
22
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
43
23
|
"build": "pnpm clean && tsc -p tsconfig.build.json",
|
|
@@ -45,5 +25,7 @@
|
|
|
45
25
|
"test": "vitest",
|
|
46
26
|
"test:run": "vitest run",
|
|
47
27
|
"type-check": "tsc -p tsconfig.json --noEmit"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
28
|
+
},
|
|
29
|
+
"dependencies": { "server-only": "0.0.1" },
|
|
30
|
+
"devDependencies": { "typescript": "5.9.3", "vitest": "^4.1.4" }
|
|
31
|
+
}
|