@c1m-public/platform-auth 0.2.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 +28 -0
- package/dist/cookies.d.ts +16 -0
- package/dist/cookies.js +47 -0
- package/dist/device-cookie.d.ts +22 -0
- package/dist/device-cookie.js +22 -0
- package/dist/forwarding.d.ts +9 -0
- package/dist/forwarding.js +37 -0
- package/dist/handoff-errors.d.ts +9 -0
- package/dist/handoff-errors.js +37 -0
- package/dist/handoff.d.ts +82 -0
- package/dist/handoff.js +220 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/next.d.ts +2 -0
- package/dist/next.js +5 -0
- package/dist/return-path.d.ts +16 -0
- package/dist/return-path.js +43 -0
- package/dist/session-refresh.d.ts +33 -0
- package/dist/session-refresh.js +69 -0
- package/dist/token.d.ts +55 -0
- package/dist/token.js +107 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @c1m-public/platform-auth
|
|
2
|
+
|
|
3
|
+
Framework-neutral session helpers for C1M platform surfaces (Platform Admin
|
|
4
|
+
Center, Knowledge Library, SaaSDash BFFs):
|
|
5
|
+
|
|
6
|
+
- `token` — decode access-token payloads (audience, expiry, default session).
|
|
7
|
+
Decoding is routing, not verification; the backend stays the boundary.
|
|
8
|
+
- `safeInternalPath` — strict same-origin return-path sanitiser.
|
|
9
|
+
- `forwardedRequestHeaders` — copy the browser's client identity onto a
|
|
10
|
+
backend call (`X-Client-*` / `X-Forwarded-*`).
|
|
11
|
+
- `createDeviceIdentity` — the `X-Client-Device-ID` cookie the backend needs to
|
|
12
|
+
create a session.
|
|
13
|
+
- `createHandoffTarget` / `createHandoffSource` — the browser halves of the
|
|
14
|
+
first-party session handoff (`/api/v1/platform/auth/handoffs/{app}`):
|
|
15
|
+
PKCE S256 + state, one-time code, host-local cookies, no tokens in URLs.
|
|
16
|
+
- `HANDOFF_ERROR_MESSAGES` — the one error vocabulary for handoff failures.
|
|
17
|
+
|
|
18
|
+
Import from `@c1m-public/platform-auth/next` inside Next.js server code.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// target: app/api/auth/platform-sso/[action]/route.ts
|
|
22
|
+
const target = createHandoffTarget({ app: "docs", apiUrl: API_URL, audience: "platform_docs", ... });
|
|
23
|
+
export const GET = (req: Request, ctx) => ctx.params.action === "callback" ? target.callback(req) : target.start(req);
|
|
24
|
+
|
|
25
|
+
// source: app/api/auth/handoffs/[app]/start/route.ts
|
|
26
|
+
const source = createHandoffSource({ apiUrl: API_URL, clientType: "nextjs-platform-admin", targets: { docs: { origin: DOCS_URL } }, getAccessToken, loginPath: "/login" });
|
|
27
|
+
export const GET = (req: Request, ctx) => source.start(req, ctx.params.app);
|
|
28
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
export interface CookieOptions {
|
|
7
|
+
httpOnly?: boolean;
|
|
8
|
+
secure?: boolean;
|
|
9
|
+
sameSite?: "lax" | "strict" | "none";
|
|
10
|
+
path?: string;
|
|
11
|
+
maxAge?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function readCookie(request: Request, name: string): string | null;
|
|
14
|
+
export declare function serializeCookie(name: string, value: string, options?: CookieOptions): string;
|
|
15
|
+
export declare function setCookie(response: Response, name: string, value: string, options?: CookieOptions): Response;
|
|
16
|
+
export declare function deleteCookie(response: Response, name: string, options?: Pick<CookieOptions, "path" | "secure">): Response;
|
package/dist/cookies.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
export function readCookie(request, name) {
|
|
7
|
+
const header = request.headers.get("cookie");
|
|
8
|
+
if (!header)
|
|
9
|
+
return null;
|
|
10
|
+
for (const part of header.split(";")) {
|
|
11
|
+
const separator = part.indexOf("=");
|
|
12
|
+
if (separator < 0)
|
|
13
|
+
continue;
|
|
14
|
+
if (part.slice(0, separator).trim() !== name)
|
|
15
|
+
continue;
|
|
16
|
+
const raw = part.slice(separator + 1).trim();
|
|
17
|
+
try {
|
|
18
|
+
return decodeURIComponent(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export function serializeCookie(name, value, options = {}) {
|
|
27
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
28
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
29
|
+
if (options.maxAge !== undefined)
|
|
30
|
+
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
|
|
31
|
+
if (options.httpOnly ?? true)
|
|
32
|
+
parts.push("HttpOnly");
|
|
33
|
+
// `__Host-` cookies are only valid with Secure and no Domain.
|
|
34
|
+
if (options.secure ?? name.startsWith("__Host-"))
|
|
35
|
+
parts.push("Secure");
|
|
36
|
+
const sameSite = options.sameSite ?? "lax";
|
|
37
|
+
parts.push(`SameSite=${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`);
|
|
38
|
+
return parts.join("; ");
|
|
39
|
+
}
|
|
40
|
+
export function setCookie(response, name, value, options) {
|
|
41
|
+
response.headers.append("Set-Cookie", serializeCookie(name, value, options));
|
|
42
|
+
return response;
|
|
43
|
+
}
|
|
44
|
+
export function deleteCookie(response, name, options) {
|
|
45
|
+
response.headers.append("Set-Cookie", serializeCookie(name, "", { ...options, maxAge: 0 }));
|
|
46
|
+
return response;
|
|
47
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stable per-browser identity that c1m-server hashes into the session's
|
|
3
|
+
* device record (`X-Client-Device-ID`). Session creation refuses requests
|
|
4
|
+
* without it, so every sign-in and handoff exchange must send it. It carries
|
|
5
|
+
* no authority: it is informational device recognition only.
|
|
6
|
+
*/
|
|
7
|
+
export interface DeviceIdentity {
|
|
8
|
+
value: string;
|
|
9
|
+
created: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface DeviceIdentityConfig {
|
|
12
|
+
cookieName: string;
|
|
13
|
+
/** Defaults to true; set false only for plain-http local development. */
|
|
14
|
+
secure?: boolean;
|
|
15
|
+
/** Defaults to one year. */
|
|
16
|
+
maxAgeSeconds?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function createDeviceIdentity(config: DeviceIdentityConfig): {
|
|
19
|
+
read(request: Request): DeviceIdentity;
|
|
20
|
+
persist(response: Response, identity: DeviceIdentity): Response;
|
|
21
|
+
};
|
|
22
|
+
export type DeviceIdentityStore = ReturnType<typeof createDeviceIdentity>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readCookie, setCookie } from "./cookies.js";
|
|
2
|
+
const DEFAULT_DEVICE_MAX_AGE = 365 * 24 * 60 * 60;
|
|
3
|
+
export function createDeviceIdentity(config) {
|
|
4
|
+
const maxAge = config.maxAgeSeconds ?? DEFAULT_DEVICE_MAX_AGE;
|
|
5
|
+
return {
|
|
6
|
+
read(request) {
|
|
7
|
+
const existing = readCookie(request, config.cookieName)?.trim();
|
|
8
|
+
return existing ? { value: existing, created: false } : { value: crypto.randomUUID(), created: true };
|
|
9
|
+
},
|
|
10
|
+
persist(response, identity) {
|
|
11
|
+
if (!identity.created)
|
|
12
|
+
return response;
|
|
13
|
+
return setCookie(response, config.cookieName, identity.value, {
|
|
14
|
+
httpOnly: true,
|
|
15
|
+
secure: config.secure ?? true,
|
|
16
|
+
sameSite: "lax",
|
|
17
|
+
path: "/",
|
|
18
|
+
maxAge,
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
export declare function applyForwardedClientHeaders(incoming: Headers, headers?: Headers): Headers;
|
|
8
|
+
/** Builds outgoing headers for a backend call made on behalf of `request`. */
|
|
9
|
+
export declare function forwardedRequestHeaders(request: Request, init?: HeadersInit): Headers;
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
export function applyForwardedClientHeaders(incoming, headers = new Headers()) {
|
|
8
|
+
const forwardedFor = incoming.get("x-forwarded-for");
|
|
9
|
+
const realIp = incoming.get("x-real-ip") ?? forwardedFor?.split(",")[0]?.trim();
|
|
10
|
+
if (realIp && !headers.has("X-Client-IP"))
|
|
11
|
+
headers.set("X-Client-IP", realIp);
|
|
12
|
+
if (realIp && !headers.has("X-Real-IP"))
|
|
13
|
+
headers.set("X-Real-IP", realIp);
|
|
14
|
+
if (forwardedFor && !headers.has("X-Forwarded-For"))
|
|
15
|
+
headers.set("X-Forwarded-For", forwardedFor);
|
|
16
|
+
const country = incoming.get("cf-ipcountry") ??
|
|
17
|
+
incoming.get("x-vercel-ip-country") ??
|
|
18
|
+
incoming.get("x-geo-country") ??
|
|
19
|
+
incoming.get("x-country-code");
|
|
20
|
+
if (country && country !== "XX" && !headers.has("X-Geo-Country")) {
|
|
21
|
+
headers.set("X-Geo-Country", country);
|
|
22
|
+
}
|
|
23
|
+
const host = incoming.get("host");
|
|
24
|
+
if (host && !headers.has("X-Forwarded-Host"))
|
|
25
|
+
headers.set("X-Forwarded-Host", host);
|
|
26
|
+
const proto = incoming.get("x-forwarded-proto");
|
|
27
|
+
if (proto && !headers.has("X-Forwarded-Proto"))
|
|
28
|
+
headers.set("X-Forwarded-Proto", proto);
|
|
29
|
+
const userAgent = incoming.get("user-agent");
|
|
30
|
+
if (userAgent && !headers.has("X-Client-User-Agent"))
|
|
31
|
+
headers.set("X-Client-User-Agent", userAgent);
|
|
32
|
+
return headers;
|
|
33
|
+
}
|
|
34
|
+
/** Builds outgoing headers for a backend call made on behalf of `request`. */
|
|
35
|
+
export function forwardedRequestHeaders(request, init) {
|
|
36
|
+
return applyForwardedClientHeaders(request.headers, new Headers(init));
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
export declare const HANDOFF_ERROR_MESSAGES: Record<string, string>;
|
|
7
|
+
export declare const HANDOFF_ERROR_FALLBACK = "Sign-in failed \u2014 please try again.";
|
|
8
|
+
export declare function handoffErrorMessage(code: string | null | undefined): string;
|
|
9
|
+
export declare function isHandoffErrorCode(code: string | null | undefined): boolean;
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
export const HANDOFF_ERROR_MESSAGES = {
|
|
7
|
+
// backend — handoff broker
|
|
8
|
+
handoff_unknown_app: "That application is not registered for sign-in handoff.",
|
|
9
|
+
handoff_not_configured: "Sign-in handoff is not configured for this application yet.",
|
|
10
|
+
handoff_source_not_allowed: "Your current session cannot open that application. Sign in there directly.",
|
|
11
|
+
handoff_invalid: "That sign-in handoff expired or was already used. Start again from where you came from.",
|
|
12
|
+
handoff_device_required: "The handoff could not identify this browser. Please try again.",
|
|
13
|
+
handoff_request_invalid: "The sign-in handoff request was malformed. Please try again.",
|
|
14
|
+
handoff_failed: "Sign-in handoff failed. Sign in with an email code instead.",
|
|
15
|
+
// backend — target admission (same codes the target's own login returns)
|
|
16
|
+
knowledge_library_access_required: "Your account has not been granted Knowledge Library access. Ask a platform admin to enable it.",
|
|
17
|
+
admin_center_access_required: "Your account has not been granted Admin Center access.",
|
|
18
|
+
platform_admin_surface_required: "Platform operator accounts sign in through the Platform Admin Center.",
|
|
19
|
+
account_disabled: "This account is disabled.",
|
|
20
|
+
// BFF — target callback
|
|
21
|
+
handoff_state_invalid: "Sign-in attempt expired — please try again.",
|
|
22
|
+
handoff_exchange_failed: "Sign-in could not be completed. Please try again.",
|
|
23
|
+
// BFF — source start
|
|
24
|
+
handoff_start_failed: "Could not start the sign-in handoff. Please try again.",
|
|
25
|
+
handoff_redirect_invalid: "The sign-in handoff returned an unexpected destination and was stopped.",
|
|
26
|
+
// BFF — surface bounces
|
|
27
|
+
admin_surface: "Platform operators sign in here; SaaSDash sent you to the Admin Center.",
|
|
28
|
+
};
|
|
29
|
+
export const HANDOFF_ERROR_FALLBACK = "Sign-in failed — please try again.";
|
|
30
|
+
export function handoffErrorMessage(code) {
|
|
31
|
+
if (!code)
|
|
32
|
+
return HANDOFF_ERROR_FALLBACK;
|
|
33
|
+
return HANDOFF_ERROR_MESSAGES[code] ?? HANDOFF_ERROR_FALLBACK;
|
|
34
|
+
}
|
|
35
|
+
export function isHandoffErrorCode(code) {
|
|
36
|
+
return !!code && Object.prototype.hasOwnProperty.call(HANDOFF_ERROR_MESSAGES, code);
|
|
37
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { DeviceIdentityStore } from "./device-cookie.js";
|
|
2
|
+
import { type AuthResponse } from "./token.js";
|
|
3
|
+
/** Path a source exposes to start a handoff to `app` (relative to the source origin). */
|
|
4
|
+
export declare function sourceStartPath(app: string): string;
|
|
5
|
+
/** Backend routes for a registered application key. */
|
|
6
|
+
export declare function brokerPaths(app: string): {
|
|
7
|
+
readonly start: `/api/v1/platform/auth/handoffs/${string}/start`;
|
|
8
|
+
readonly exchange: `/api/v1/platform/auth/handoffs/${string}/exchange`;
|
|
9
|
+
};
|
|
10
|
+
type Resolvable = string | (() => string);
|
|
11
|
+
export interface HandoffTargetConfig {
|
|
12
|
+
/** Registered application key (`docs`). */
|
|
13
|
+
app: string;
|
|
14
|
+
/** Backend origin, e.g. https://api.example.com (no /api/v1). */
|
|
15
|
+
apiUrl: Resolvable;
|
|
16
|
+
/** Audience the exchange must mint; the callback refuses anything else. */
|
|
17
|
+
audience: string;
|
|
18
|
+
/** X-Client-Type sent to the backend. */
|
|
19
|
+
clientType: string;
|
|
20
|
+
/** Source key → source origin. The `?source=` query on start selects one. */
|
|
21
|
+
sources: Record<string, Resolvable>;
|
|
22
|
+
/** Source used when `?source=` is absent. */
|
|
23
|
+
defaultSource: string;
|
|
24
|
+
/** Prefix for the temporary handoff cookies (`__Host-<prefix>_handoff_*`). */
|
|
25
|
+
cookiePrefix: string;
|
|
26
|
+
/** Target's login page (receives `?error=` on failure). */
|
|
27
|
+
loginPath: string;
|
|
28
|
+
/** Where to land when no `from` was supplied. */
|
|
29
|
+
defaultReturnPath: string;
|
|
30
|
+
/** Device identity cookie store (X-Client-Device-ID). */
|
|
31
|
+
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
|
+
*/
|
|
37
|
+
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
|
+
secureCookies?: boolean;
|
|
40
|
+
fetchTimeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function createHandoffTarget(config: HandoffTargetConfig): {
|
|
43
|
+
cookies: {
|
|
44
|
+
readonly state: `${string}state`;
|
|
45
|
+
readonly verifier: `${string}verifier`;
|
|
46
|
+
readonly from: `${string}from`;
|
|
47
|
+
};
|
|
48
|
+
/** GET `?source=<key>&from=<path>` — begin a handoff by bouncing to the source. */
|
|
49
|
+
start(request: Request): Promise<Response>;
|
|
50
|
+
/** GET `?code=&state=` — exchange the one-time code and establish the session. */
|
|
51
|
+
callback(request: Request): Promise<Response>;
|
|
52
|
+
};
|
|
53
|
+
export type HandoffTarget = ReturnType<typeof createHandoffTarget>;
|
|
54
|
+
export interface HandoffSourceTarget {
|
|
55
|
+
/** Exact target origin; must equal the origin of the backend's redirect_url. */
|
|
56
|
+
origin: Resolvable;
|
|
57
|
+
/** Registered callback path on the target. */
|
|
58
|
+
callbackPath?: string;
|
|
59
|
+
/** Target login page for error redirects. */
|
|
60
|
+
loginPath?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface HandoffSourceConfig {
|
|
63
|
+
apiUrl: Resolvable;
|
|
64
|
+
clientType: string;
|
|
65
|
+
/** Registered application key → target. */
|
|
66
|
+
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
|
+
*/
|
|
72
|
+
getAccessToken: (request: Request) => Promise<string | null> | string | null;
|
|
73
|
+
/** Source login page; a signed-out start bounces here with `?from=` set to resume. */
|
|
74
|
+
loginPath: string;
|
|
75
|
+
fetchTimeoutMs?: number;
|
|
76
|
+
}
|
|
77
|
+
export declare function createHandoffSource(config: HandoffSourceConfig): {
|
|
78
|
+
/** GET `/api/auth/handoffs/{app}/start?code_challenge=&state=`. */
|
|
79
|
+
start(request: Request, app: string): Promise<Response>;
|
|
80
|
+
};
|
|
81
|
+
export type HandoffSource = ReturnType<typeof createHandoffSource>;
|
|
82
|
+
export {};
|
package/dist/handoff.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { deleteCookie, readCookie, setCookie } from "./cookies.js";
|
|
2
|
+
import { forwardedRequestHeaders } from "./forwarding.js";
|
|
3
|
+
import { safeInternalPath } from "./return-path.js";
|
|
4
|
+
import { hasTokenAudience, parseAuthResponse } from "./token.js";
|
|
5
|
+
/**
|
|
6
|
+
* First-party session handoff — the browser-side halves of c1m-server's
|
|
7
|
+
* `/api/v1/platform/auth/handoffs/{app}/start|exchange` broker.
|
|
8
|
+
*
|
|
9
|
+
* target.start (target origin) mints PKCE verifier + state into
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
const HANDOFF_COOKIE_TTL_SECONDS = 120;
|
|
24
|
+
const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
26
|
+
/** Path a source exposes to start a handoff to `app` (relative to the source origin). */
|
|
27
|
+
export function sourceStartPath(app) {
|
|
28
|
+
return `/api/auth/handoffs/${encodeURIComponent(app)}/start`;
|
|
29
|
+
}
|
|
30
|
+
/** Backend routes for a registered application key. */
|
|
31
|
+
export function brokerPaths(app) {
|
|
32
|
+
const key = encodeURIComponent(app);
|
|
33
|
+
return {
|
|
34
|
+
start: `/api/v1/platform/auth/handoffs/${key}/start`,
|
|
35
|
+
exchange: `/api/v1/platform/auth/handoffs/${key}/exchange`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const resolve = (value) => (typeof value === "function" ? value() : value);
|
|
39
|
+
function originOf(value) {
|
|
40
|
+
try {
|
|
41
|
+
return new URL(value).origin;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function base64Url(bytes) {
|
|
48
|
+
let binary = "";
|
|
49
|
+
for (const byte of bytes)
|
|
50
|
+
binary += String.fromCharCode(byte);
|
|
51
|
+
return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
52
|
+
}
|
|
53
|
+
function randomToken(bytes) {
|
|
54
|
+
const raw = new Uint8Array(bytes);
|
|
55
|
+
crypto.getRandomValues(raw);
|
|
56
|
+
return base64Url(raw);
|
|
57
|
+
}
|
|
58
|
+
async function s256(verifier) {
|
|
59
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
60
|
+
return base64Url(new Uint8Array(digest));
|
|
61
|
+
}
|
|
62
|
+
function validState(state) {
|
|
63
|
+
return state.length >= 16 && state.length <= 256 && !/[\r\n]/.test(state);
|
|
64
|
+
}
|
|
65
|
+
function redirect(url, status = 302) {
|
|
66
|
+
return new Response(null, { status, headers: { Location: url.toString(), "Cache-Control": "no-store" } });
|
|
67
|
+
}
|
|
68
|
+
function loginErrorRedirect(origin, loginPath, code) {
|
|
69
|
+
const target = new URL(loginPath, origin);
|
|
70
|
+
target.searchParams.set("error", code);
|
|
71
|
+
return redirect(target);
|
|
72
|
+
}
|
|
73
|
+
async function fetchWithTimeout(input, init, timeoutMs) {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
76
|
+
try {
|
|
77
|
+
return await fetch(input, { ...init, signal: controller.signal, cache: "no-store" });
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function createHandoffTarget(config) {
|
|
84
|
+
const secure = config.secureCookies ?? true;
|
|
85
|
+
const prefix = `${secure ? "__Host-" : ""}${config.cookiePrefix}_handoff_`;
|
|
86
|
+
const cookies = { state: `${prefix}state`, verifier: `${prefix}verifier`, from: `${prefix}from` };
|
|
87
|
+
const cookieOptions = { httpOnly: true, secure, sameSite: "lax", path: "/", maxAge: HANDOFF_COOKIE_TTL_SECONDS };
|
|
88
|
+
const timeout = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
89
|
+
const clearHandoffCookies = (response) => {
|
|
90
|
+
for (const name of Object.values(cookies))
|
|
91
|
+
deleteCookie(response, name, { path: "/", secure });
|
|
92
|
+
return response;
|
|
93
|
+
};
|
|
94
|
+
const fail = (origin, code) => clearHandoffCookies(loginErrorRedirect(origin, config.loginPath, code));
|
|
95
|
+
return {
|
|
96
|
+
cookies,
|
|
97
|
+
/** GET `?source=<key>&from=<path>` — begin a handoff by bouncing to the source. */
|
|
98
|
+
async start(request) {
|
|
99
|
+
const url = new URL(request.url);
|
|
100
|
+
const sourceKey = url.searchParams.get("source")?.trim() || config.defaultSource;
|
|
101
|
+
const source = config.sources[sourceKey];
|
|
102
|
+
if (!source)
|
|
103
|
+
return fail(url.origin, "handoff_request_invalid");
|
|
104
|
+
const sourceOrigin = originOf(resolve(source));
|
|
105
|
+
if (!sourceOrigin)
|
|
106
|
+
return fail(url.origin, "handoff_not_configured");
|
|
107
|
+
const verifier = randomToken(48);
|
|
108
|
+
const state = randomToken(32);
|
|
109
|
+
const start = new URL(sourceStartPath(config.app), sourceOrigin);
|
|
110
|
+
start.searchParams.set("code_challenge", await s256(verifier));
|
|
111
|
+
start.searchParams.set("state", state);
|
|
112
|
+
const response = redirect(start);
|
|
113
|
+
setCookie(response, cookies.verifier, verifier, cookieOptions);
|
|
114
|
+
setCookie(response, cookies.state, state, cookieOptions);
|
|
115
|
+
const from = safeInternalPath(url.searchParams.get("from"));
|
|
116
|
+
if (from)
|
|
117
|
+
setCookie(response, cookies.from, from, cookieOptions);
|
|
118
|
+
return response;
|
|
119
|
+
},
|
|
120
|
+
/** GET `?code=&state=` — exchange the one-time code and establish the session. */
|
|
121
|
+
async callback(request) {
|
|
122
|
+
const url = new URL(request.url);
|
|
123
|
+
const code = url.searchParams.get("code") ?? "";
|
|
124
|
+
const state = url.searchParams.get("state") ?? "";
|
|
125
|
+
const expectedState = readCookie(request, cookies.state) ?? "";
|
|
126
|
+
const verifier = readCookie(request, cookies.verifier) ?? "";
|
|
127
|
+
if (!code || !state || !expectedState || state !== expectedState || !verifier) {
|
|
128
|
+
return fail(url.origin, "handoff_state_invalid");
|
|
129
|
+
}
|
|
130
|
+
const device = config.device.read(request);
|
|
131
|
+
const respond = (response) => config.device.persist(response, device);
|
|
132
|
+
try {
|
|
133
|
+
const headers = forwardedRequestHeaders(request, {
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"X-Client-Type": config.clientType,
|
|
136
|
+
"X-Client-Device-ID": device.value,
|
|
137
|
+
});
|
|
138
|
+
const response = await fetchWithTimeout(`${resolve(config.apiUrl)}${brokerPaths(config.app).exchange}`, { method: "POST", headers, body: JSON.stringify({ code, code_verifier: verifier }) }, timeout);
|
|
139
|
+
const payload = await response.json().catch(() => ({}));
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const backendCode = payload?.code;
|
|
142
|
+
return respond(fail(url.origin, typeof backendCode === "string" ? backendCode : "handoff_exchange_failed"));
|
|
143
|
+
}
|
|
144
|
+
const session = parseAuthResponse(payload);
|
|
145
|
+
if (!session || !hasTokenAudience(session.access_token, config.audience)) {
|
|
146
|
+
return respond(fail(url.origin, "handoff_exchange_failed"));
|
|
147
|
+
}
|
|
148
|
+
const from = safeInternalPath(readCookie(request, cookies.from)) ?? config.defaultReturnPath;
|
|
149
|
+
const established = (await config.establishSession(session, redirect(new URL(from, url.origin)), request)) ?? redirect(new URL(from, url.origin));
|
|
150
|
+
return respond(clearHandoffCookies(established));
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return respond(fail(url.origin, "handoff_exchange_failed"));
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const DEFAULT_TARGET_CALLBACK = "/api/auth/platform-sso/callback";
|
|
159
|
+
const DEFAULT_TARGET_LOGIN = "/login";
|
|
160
|
+
export function createHandoffSource(config) {
|
|
161
|
+
const timeout = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
162
|
+
return {
|
|
163
|
+
/** GET `/api/auth/handoffs/{app}/start?code_challenge=&state=`. */
|
|
164
|
+
async start(request, app) {
|
|
165
|
+
const target = config.targets[app];
|
|
166
|
+
if (!target)
|
|
167
|
+
return new Response("unknown handoff application", { status: 404, headers: { "Cache-Control": "no-store" } });
|
|
168
|
+
const targetOrigin = originOf(resolve(target.origin));
|
|
169
|
+
const targetLogin = target.loginPath ?? DEFAULT_TARGET_LOGIN;
|
|
170
|
+
if (!targetOrigin)
|
|
171
|
+
return new Response("handoff target not configured", { status: 503, headers: { "Cache-Control": "no-store" } });
|
|
172
|
+
const targetError = (code) => loginErrorRedirect(targetOrigin, targetLogin, code);
|
|
173
|
+
const url = new URL(request.url);
|
|
174
|
+
const challenge = url.searchParams.get("code_challenge")?.trim() ?? "";
|
|
175
|
+
const state = url.searchParams.get("state")?.trim() ?? "";
|
|
176
|
+
if (!CHALLENGE_PATTERN.test(challenge) || !validState(state))
|
|
177
|
+
return targetError("handoff_request_invalid");
|
|
178
|
+
const sourceLogin = () => {
|
|
179
|
+
const login = new URL(config.loginPath, url.origin);
|
|
180
|
+
login.searchParams.set("from", `${url.pathname}${url.search}`);
|
|
181
|
+
return redirect(login);
|
|
182
|
+
};
|
|
183
|
+
const token = await config.getAccessToken(request);
|
|
184
|
+
if (!token)
|
|
185
|
+
return sourceLogin();
|
|
186
|
+
try {
|
|
187
|
+
const headers = forwardedRequestHeaders(request, {
|
|
188
|
+
"Content-Type": "application/json",
|
|
189
|
+
"X-Client-Type": config.clientType,
|
|
190
|
+
Authorization: `Bearer ${token}`,
|
|
191
|
+
});
|
|
192
|
+
const response = await fetchWithTimeout(`${resolve(config.apiUrl)}${brokerPaths(app).start}`, { method: "POST", headers, body: JSON.stringify({ code_challenge: challenge, state }) }, timeout);
|
|
193
|
+
if (response.status === 401)
|
|
194
|
+
return sourceLogin();
|
|
195
|
+
const payload = await response.json().catch(() => ({}));
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
const backendCode = payload?.code;
|
|
198
|
+
return targetError(typeof backendCode === "string" ? backendCode : "handoff_start_failed");
|
|
199
|
+
}
|
|
200
|
+
const redirectUrl = payload?.redirect_url;
|
|
201
|
+
if (typeof redirectUrl !== "string")
|
|
202
|
+
return targetError("handoff_start_failed");
|
|
203
|
+
let destination;
|
|
204
|
+
try {
|
|
205
|
+
destination = new URL(redirectUrl);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return targetError("handoff_redirect_invalid");
|
|
209
|
+
}
|
|
210
|
+
if (destination.origin !== targetOrigin || destination.pathname !== (target.callbackPath ?? DEFAULT_TARGET_CALLBACK)) {
|
|
211
|
+
return targetError("handoff_redirect_invalid");
|
|
212
|
+
}
|
|
213
|
+
return redirect(destination);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return targetError("handoff_start_failed");
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./token.js";
|
|
2
|
+
export * from "./return-path.js";
|
|
3
|
+
export * from "./forwarding.js";
|
|
4
|
+
export * from "./cookies.js";
|
|
5
|
+
export * from "./device-cookie.js";
|
|
6
|
+
export * from "./handoff-errors.js";
|
|
7
|
+
export * from "./handoff.js";
|
|
8
|
+
export * from "./session-refresh.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./token.js";
|
|
2
|
+
export * from "./return-path.js";
|
|
3
|
+
export * from "./forwarding.js";
|
|
4
|
+
export * from "./cookies.js";
|
|
5
|
+
export * from "./device-cookie.js";
|
|
6
|
+
export * from "./handoff-errors.js";
|
|
7
|
+
export * from "./handoff.js";
|
|
8
|
+
export * from "./session-refresh.js";
|
package/dist/next.d.ts
ADDED
package/dist/next.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Server-only entry for Next.js route handlers and server components. The root
|
|
2
|
+
// entry is framework-neutral (Web Request/Response); this one only adds the
|
|
3
|
+
// bundler guard so session helpers cannot leak into client bundles.
|
|
4
|
+
import "server-only";
|
|
5
|
+
export * from "./index.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict same-origin return path sanitiser (the hardened core shared by every
|
|
3
|
+
* MML surface). Accepts only absolute paths that survive a round-trip through
|
|
4
|
+
* URL parsing against a known-safe origin; returns `null` on any rejection.
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export declare function safeInternalPath(raw: string | null | undefined): string | null;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict same-origin return path sanitiser (the hardened core shared by every
|
|
3
|
+
* MML surface). Accepts only absolute paths that survive a round-trip through
|
|
4
|
+
* URL parsing against a known-safe origin; returns `null` on any rejection.
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export function safeInternalPath(raw) {
|
|
17
|
+
if (raw == null)
|
|
18
|
+
return null;
|
|
19
|
+
const candidate = raw.trim();
|
|
20
|
+
if (candidate.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
if (!candidate.startsWith("/"))
|
|
23
|
+
return null;
|
|
24
|
+
if (candidate.startsWith("//") || candidate.includes("://"))
|
|
25
|
+
return null;
|
|
26
|
+
if (/%(?:2f|5c)/i.test(candidate))
|
|
27
|
+
return null;
|
|
28
|
+
// eslint-disable-next-line no-control-regex
|
|
29
|
+
if (/[\x00-\x1f\x7f]/.test(candidate))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(candidate, "https://internal.invalid");
|
|
33
|
+
if (parsed.origin !== "https://internal.invalid")
|
|
34
|
+
return null;
|
|
35
|
+
const roundtrip = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
36
|
+
if (roundtrip !== candidate)
|
|
37
|
+
return null;
|
|
38
|
+
return candidate;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
export interface RefreshedSession {
|
|
9
|
+
accessToken: string;
|
|
10
|
+
refreshToken?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface SessionRefresherConfig {
|
|
13
|
+
/** Backend origin (no /api/v1). */
|
|
14
|
+
apiUrl: string | (() => string);
|
|
15
|
+
/** Refresh endpoint path, e.g. /api/v1/platform/auth/refresh. */
|
|
16
|
+
refreshPath: string;
|
|
17
|
+
/** X-Client-Type sent to the backend. */
|
|
18
|
+
clientType: string;
|
|
19
|
+
/** Audience the refreshed access token must carry; anything else is rejected. */
|
|
20
|
+
audience: string;
|
|
21
|
+
/** How long a completed refresh keeps answering for the same token. Default 5 s. */
|
|
22
|
+
singleflightTtlMs?: number;
|
|
23
|
+
/** Bound on remembered in-flight/recent refreshes. Default 1000. */
|
|
24
|
+
maxSingleflights?: number;
|
|
25
|
+
fetchTimeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function createSessionRefresher(config: SessionRefresherConfig): {
|
|
28
|
+
/** Exchange a refresh token; identical concurrent calls share one backend request. */
|
|
29
|
+
refresh(refreshToken: string): Promise<RefreshedSession | null>;
|
|
30
|
+
/** Test/inspection hook: number of remembered refreshes. */
|
|
31
|
+
size(): number;
|
|
32
|
+
};
|
|
33
|
+
export type SessionRefresher = ReturnType<typeof createSessionRefresher>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { hasTokenAudience, parseAuthResponse } from "./token.js";
|
|
2
|
+
const DEFAULT_TTL_MS = 5000;
|
|
3
|
+
const DEFAULT_MAX = 1000;
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
5
|
+
export function createSessionRefresher(config) {
|
|
6
|
+
const ttl = config.singleflightTtlMs ?? DEFAULT_TTL_MS;
|
|
7
|
+
const max = config.maxSingleflights ?? DEFAULT_MAX;
|
|
8
|
+
const timeoutMs = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
9
|
+
const inflight = new Map();
|
|
10
|
+
async function perform(refreshToken) {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
13
|
+
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,
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok)
|
|
23
|
+
return null;
|
|
24
|
+
const data = parseAuthResponse(await response.json());
|
|
25
|
+
return data && hasTokenAudience(data.access_token, config.audience)
|
|
26
|
+
? { accessToken: data.access_token, refreshToken: data.refresh_token }
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function prune(now) {
|
|
37
|
+
for (const [token, entry] of inflight) {
|
|
38
|
+
if (entry.expiresAt <= now)
|
|
39
|
+
inflight.delete(token);
|
|
40
|
+
}
|
|
41
|
+
while (inflight.size >= max) {
|
|
42
|
+
const oldest = inflight.keys().next().value;
|
|
43
|
+
if (typeof oldest !== "string")
|
|
44
|
+
break;
|
|
45
|
+
inflight.delete(oldest);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
/** Exchange a refresh token; identical concurrent calls share one backend request. */
|
|
50
|
+
refresh(refreshToken) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
prune(now);
|
|
53
|
+
const existing = inflight.get(refreshToken);
|
|
54
|
+
if (existing && existing.expiresAt > now)
|
|
55
|
+
return existing.promise;
|
|
56
|
+
const promise = perform(refreshToken).then((result) => {
|
|
57
|
+
if (!result)
|
|
58
|
+
inflight.delete(refreshToken);
|
|
59
|
+
return result;
|
|
60
|
+
});
|
|
61
|
+
inflight.set(refreshToken, { expiresAt: now + ttl, promise });
|
|
62
|
+
return promise;
|
|
63
|
+
},
|
|
64
|
+
/** Test/inspection hook: number of remembered refreshes. */
|
|
65
|
+
size() {
|
|
66
|
+
return inflight.size;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
export interface SessionUser {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
name: string;
|
|
10
|
+
role: string;
|
|
11
|
+
is_active: boolean;
|
|
12
|
+
email_verified: boolean;
|
|
13
|
+
created_at: string;
|
|
14
|
+
last_login_at?: string;
|
|
15
|
+
has_passkey?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface SessionData {
|
|
18
|
+
user: SessionUser;
|
|
19
|
+
token: string;
|
|
20
|
+
exp: number;
|
|
21
|
+
iat: number;
|
|
22
|
+
}
|
|
23
|
+
export type TokenPayload = Record<string, unknown> & {
|
|
24
|
+
sub?: string;
|
|
25
|
+
exp?: number;
|
|
26
|
+
iat?: number;
|
|
27
|
+
actor_id?: string;
|
|
28
|
+
customer_id?: string;
|
|
29
|
+
user_id?: string;
|
|
30
|
+
aud?: string | string[];
|
|
31
|
+
email?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
role?: string;
|
|
34
|
+
is_active?: boolean;
|
|
35
|
+
email_verified?: boolean;
|
|
36
|
+
created_at?: string;
|
|
37
|
+
has_passkeys?: boolean;
|
|
38
|
+
};
|
|
39
|
+
export declare function decodeBase64Url(input: string): string | null;
|
|
40
|
+
export declare function decodeTokenPayload(token: string): TokenPayload | null;
|
|
41
|
+
export declare function hasTokenAudience(token: string, requiredAudience: string): boolean;
|
|
42
|
+
/** Cookie max-age clamped to the token's remaining lifetime. */
|
|
43
|
+
export declare function tokenMaxAgeSeconds(token: string, configuredMaxAge: number): number;
|
|
44
|
+
export declare function isTokenExpired(token: string): boolean;
|
|
45
|
+
export declare function isTokenExpiringSoon(token: string, bufferSeconds?: number): boolean;
|
|
46
|
+
export declare function decodeDefaultSession(token: string): SessionData | null;
|
|
47
|
+
/** Backend auth response shape shared by verify-code, OIDC complete, and handoff exchange. */
|
|
48
|
+
export interface AuthResponse {
|
|
49
|
+
access_token: string;
|
|
50
|
+
refresh_token: string;
|
|
51
|
+
token_type: string;
|
|
52
|
+
expires_at: string;
|
|
53
|
+
user: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
export declare function parseAuthResponse(value: unknown): AuthResponse | null;
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
function readStringClaim(value, fallback = "") {
|
|
7
|
+
return typeof value === "string" ? value : fallback;
|
|
8
|
+
}
|
|
9
|
+
function readBooleanClaim(value, fallback) {
|
|
10
|
+
return typeof value === "boolean" ? value : fallback;
|
|
11
|
+
}
|
|
12
|
+
export function decodeBase64Url(input) {
|
|
13
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
14
|
+
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
|
|
15
|
+
try {
|
|
16
|
+
// Node >= 20 and every browser expose atob globally.
|
|
17
|
+
const binary = globalThis.atob(padded);
|
|
18
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
19
|
+
return new TextDecoder().decode(bytes);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function decodeTokenPayload(token) {
|
|
26
|
+
const payloadSegment = token.split(".")[1];
|
|
27
|
+
if (!payloadSegment)
|
|
28
|
+
return null;
|
|
29
|
+
const decodedJson = decodeBase64Url(payloadSegment);
|
|
30
|
+
if (!decodedJson)
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(decodedJson);
|
|
34
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function hasTokenAudience(token, requiredAudience) {
|
|
41
|
+
const audience = decodeTokenPayload(token)?.aud;
|
|
42
|
+
return typeof audience === "string"
|
|
43
|
+
? audience === requiredAudience
|
|
44
|
+
: Array.isArray(audience) && audience.includes(requiredAudience);
|
|
45
|
+
}
|
|
46
|
+
function nowInSeconds() {
|
|
47
|
+
return Math.floor(Date.now() / 1000);
|
|
48
|
+
}
|
|
49
|
+
/** Cookie max-age clamped to the token's remaining lifetime. */
|
|
50
|
+
export function tokenMaxAgeSeconds(token, configuredMaxAge) {
|
|
51
|
+
const decoded = decodeTokenPayload(token);
|
|
52
|
+
if (!decoded?.exp)
|
|
53
|
+
return configuredMaxAge;
|
|
54
|
+
return Math.max(0, Math.min(configuredMaxAge, decoded.exp - nowInSeconds()));
|
|
55
|
+
}
|
|
56
|
+
export function isTokenExpired(token) {
|
|
57
|
+
const decoded = decodeTokenPayload(token);
|
|
58
|
+
if (!decoded?.exp)
|
|
59
|
+
return true;
|
|
60
|
+
return decoded.exp <= nowInSeconds();
|
|
61
|
+
}
|
|
62
|
+
export function isTokenExpiringSoon(token, bufferSeconds = 120) {
|
|
63
|
+
const decoded = decodeTokenPayload(token);
|
|
64
|
+
if (!decoded?.exp)
|
|
65
|
+
return true;
|
|
66
|
+
return decoded.exp < nowInSeconds() + bufferSeconds;
|
|
67
|
+
}
|
|
68
|
+
export function decodeDefaultSession(token) {
|
|
69
|
+
const decoded = decodeTokenPayload(token);
|
|
70
|
+
if (!decoded)
|
|
71
|
+
return null;
|
|
72
|
+
const actorId = decoded.actor_id ||
|
|
73
|
+
decoded.customer_id ||
|
|
74
|
+
decoded.user_id ||
|
|
75
|
+
(typeof decoded.sub === "string" ? decoded.sub : undefined);
|
|
76
|
+
if (!actorId)
|
|
77
|
+
return null;
|
|
78
|
+
return {
|
|
79
|
+
user: {
|
|
80
|
+
id: String(actorId),
|
|
81
|
+
email: readStringClaim(decoded.email),
|
|
82
|
+
name: readStringClaim(decoded.name),
|
|
83
|
+
role: readStringClaim(decoded.role, "user"),
|
|
84
|
+
is_active: readBooleanClaim(decoded.is_active, true),
|
|
85
|
+
email_verified: readBooleanClaim(decoded.email_verified, false),
|
|
86
|
+
created_at: readStringClaim(decoded.created_at),
|
|
87
|
+
has_passkey: readBooleanClaim(decoded.has_passkeys, false),
|
|
88
|
+
},
|
|
89
|
+
token,
|
|
90
|
+
exp: decoded.exp || 0,
|
|
91
|
+
iat: decoded.iat || 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function parseAuthResponse(value) {
|
|
95
|
+
if (!value || typeof value !== "object")
|
|
96
|
+
return null;
|
|
97
|
+
const candidate = value;
|
|
98
|
+
if (typeof candidate.access_token !== "string" ||
|
|
99
|
+
typeof candidate.refresh_token !== "string" ||
|
|
100
|
+
typeof candidate.token_type !== "string" ||
|
|
101
|
+
typeof candidate.expires_at !== "string" ||
|
|
102
|
+
!candidate.user ||
|
|
103
|
+
typeof candidate.user !== "object") {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return candidate;
|
|
107
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@c1m-public/platform-auth",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
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",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/cloud1mediaLtd/mml-js.git",
|
|
10
|
+
"directory": "packages/platform-auth"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
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"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
43
|
+
"build": "pnpm clean && tsc -p tsconfig.build.json",
|
|
44
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
45
|
+
"test": "vitest",
|
|
46
|
+
"test:run": "vitest run",
|
|
47
|
+
"type-check": "tsc -p tsconfig.json --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|