@c1m-public/platform-auth 0.6.0 → 0.8.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.
Files changed (47) hide show
  1. package/README.md +27 -0
  2. package/dist/access/index.d.ts +148 -5
  3. package/dist/access/index.js +2 -5
  4. package/dist/access/next.d.ts +11 -10
  5. package/dist/access/next.js +70 -81
  6. package/dist/access/react.d.ts +59 -12
  7. package/dist/access/react.js +84 -37
  8. package/dist/chunk-FBC2NUH7.js +83 -0
  9. package/dist/chunk-OPF4UZLE.js +261 -0
  10. package/dist/chunk-P4LKOHR4.js +47 -0
  11. package/dist/chunk-QV7HSZLE.js +392 -0
  12. package/dist/index.d.ts +224 -9
  13. package/dist/index.js +3 -9
  14. package/dist/internal-D6RafCm2.d.ts +6 -0
  15. package/dist/next.d.ts +171 -2
  16. package/dist/next.js +307 -3
  17. package/package.json +6 -4
  18. package/dist/access/errors.d.ts +0 -13
  19. package/dist/access/errors.js +0 -19
  20. package/dist/access/fetch.d.ts +0 -23
  21. package/dist/access/fetch.js +0 -43
  22. package/dist/access/generated/keys.d.ts +0 -20
  23. package/dist/access/generated/keys.js +0 -87
  24. package/dist/access/predicates.d.ts +0 -48
  25. package/dist/access/predicates.js +0 -95
  26. package/dist/access/snapshot.d.ts +0 -39
  27. package/dist/access/snapshot.js +0 -92
  28. package/dist/cookies.d.ts +0 -11
  29. package/dist/cookies.js +0 -42
  30. package/dist/device-cookie.d.ts +0 -18
  31. package/dist/device-cookie.js +0 -22
  32. package/dist/forwarding.d.ts +0 -2
  33. package/dist/forwarding.js +0 -32
  34. package/dist/handoff-errors.d.ts +0 -13
  35. package/dist/handoff-errors.js +0 -65
  36. package/dist/handoff.d.ts +0 -55
  37. package/dist/handoff.js +0 -195
  38. package/dist/internal.d.ts +0 -16
  39. package/dist/internal.js +0 -27
  40. package/dist/return-path.d.ts +0 -6
  41. package/dist/return-path.js +0 -33
  42. package/dist/session-gate.d.ts +0 -26
  43. package/dist/session-gate.js +0 -33
  44. package/dist/session-refresh.d.ts +0 -24
  45. package/dist/session-refresh.js +0 -62
  46. package/dist/token.d.ts +0 -51
  47. package/dist/token.js +0 -104
package/dist/handoff.js DELETED
@@ -1,195 +0,0 @@
1
- import { deleteCookie, readCookie, setCookie } from "./cookies.js";
2
- import { forwardedRequestHeaders } from "./forwarding.js";
3
- import { safeInternalPath } from "./return-path.js";
4
- import { DEFAULT_FETCH_TIMEOUT_MS, resolveValue, timedFetch } from "./internal.js";
5
- import { hasTokenAudience, parseAuthResponse } from "./token.js";
6
- /**
7
- * Browser halves of c1m-server's `/api/v1/platform/auth/handoffs/{app}` broker.
8
- * target.start → source.start → target.callback. PKCE S256 + state, host-local
9
- * cookies, no tokens in URLs; failures redirect to a login page with a code
10
- * from HANDOFF_ERROR_MESSAGES.
11
- */
12
- const HANDOFF_COOKIE_TTL_SECONDS = 120;
13
- const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
14
- export function sourceStartPath(app) {
15
- return `/api/auth/handoffs/${encodeURIComponent(app)}/start`;
16
- }
17
- export function brokerPaths(app) {
18
- const key = encodeURIComponent(app);
19
- return {
20
- start: `/api/v1/platform/auth/handoffs/${key}/start`,
21
- exchange: `/api/v1/platform/auth/handoffs/${key}/exchange`,
22
- };
23
- }
24
- function originOf(value) {
25
- try {
26
- return new URL(value).origin;
27
- }
28
- catch {
29
- return null;
30
- }
31
- }
32
- function base64Url(bytes) {
33
- let binary = "";
34
- for (const byte of bytes)
35
- binary += String.fromCharCode(byte);
36
- return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
37
- }
38
- function randomToken(bytes) {
39
- const raw = new Uint8Array(bytes);
40
- crypto.getRandomValues(raw);
41
- return base64Url(raw);
42
- }
43
- async function s256(verifier) {
44
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
45
- return base64Url(new Uint8Array(digest));
46
- }
47
- function validState(state) {
48
- return state.length >= 16 && state.length <= 256 && !/[\r\n]/.test(state);
49
- }
50
- function redirect(url, status = 302) {
51
- return new Response(null, { status, headers: { Location: url.toString(), "Cache-Control": "no-store" } });
52
- }
53
- function loginErrorRedirect(origin, loginPath, code) {
54
- const target = new URL(loginPath, origin);
55
- target.searchParams.set("error", code);
56
- return redirect(target);
57
- }
58
- export function createHandoffTarget(config) {
59
- const secure = config.secureCookies ?? true;
60
- const prefix = `${secure ? "__Host-" : ""}${config.cookiePrefix}_handoff_`;
61
- const cookies = { state: `${prefix}state`, verifier: `${prefix}verifier`, from: `${prefix}from` };
62
- const cookieOptions = { httpOnly: true, secure, sameSite: "lax", path: "/", maxAge: HANDOFF_COOKIE_TTL_SECONDS };
63
- const timeout = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
64
- const clearHandoffCookies = (response) => {
65
- for (const name of Object.values(cookies))
66
- deleteCookie(response, name, { path: "/", secure });
67
- return response;
68
- };
69
- const fail = (origin, code) => clearHandoffCookies(loginErrorRedirect(origin, config.loginPath, code));
70
- return {
71
- cookies,
72
- /** GET `?source=&from=` */
73
- async start(request) {
74
- const url = new URL(request.url);
75
- const sourceKey = url.searchParams.get("source")?.trim() || config.defaultSource;
76
- const source = config.sources[sourceKey];
77
- if (!source)
78
- return fail(url.origin, "handoff_request_invalid");
79
- const sourceOrigin = originOf(resolveValue(source));
80
- if (!sourceOrigin)
81
- return fail(url.origin, "handoff_not_configured");
82
- const verifier = randomToken(48);
83
- const state = randomToken(32);
84
- const start = new URL(sourceStartPath(config.app), sourceOrigin);
85
- start.searchParams.set("code_challenge", await s256(verifier));
86
- start.searchParams.set("state", state);
87
- const response = redirect(start);
88
- setCookie(response, cookies.verifier, verifier, cookieOptions);
89
- setCookie(response, cookies.state, state, cookieOptions);
90
- const from = safeInternalPath(url.searchParams.get("from"));
91
- if (from)
92
- setCookie(response, cookies.from, from, cookieOptions);
93
- return response;
94
- },
95
- /** GET `?code=&state=` */
96
- async callback(request) {
97
- const url = new URL(request.url);
98
- const code = url.searchParams.get("code") ?? "";
99
- const state = url.searchParams.get("state") ?? "";
100
- const expectedState = readCookie(request, cookies.state) ?? "";
101
- const verifier = readCookie(request, cookies.verifier) ?? "";
102
- if (!code || !state || !expectedState || state !== expectedState || !verifier) {
103
- return fail(url.origin, "handoff_state_invalid");
104
- }
105
- const device = config.device.read(request);
106
- const respond = (response) => config.device.persist(response, device);
107
- try {
108
- const headers = forwardedRequestHeaders(request, {
109
- "Content-Type": "application/json",
110
- "X-Client-Type": config.clientType,
111
- "X-Client-Device-ID": device.value,
112
- });
113
- const response = await timedFetch(`${resolveValue(config.apiUrl)}${brokerPaths(config.app).exchange}`, { init: { method: "POST", headers, body: JSON.stringify({ code, code_verifier: verifier }) }, timeoutMs: timeout });
114
- const payload = await response.json().catch(() => ({}));
115
- if (!response.ok) {
116
- const backendCode = payload?.code;
117
- return respond(fail(url.origin, typeof backendCode === "string" ? backendCode : "handoff_exchange_failed"));
118
- }
119
- const session = parseAuthResponse(payload);
120
- if (!session || !hasTokenAudience(session.access_token, config.audience)) {
121
- return respond(fail(url.origin, "handoff_exchange_failed"));
122
- }
123
- const from = safeInternalPath(readCookie(request, cookies.from)) ?? config.defaultReturnPath;
124
- const established = (await config.establishSession(session, redirect(new URL(from, url.origin)), request)) ?? redirect(new URL(from, url.origin));
125
- return respond(clearHandoffCookies(established));
126
- }
127
- catch {
128
- return respond(fail(url.origin, "handoff_exchange_failed"));
129
- }
130
- },
131
- };
132
- }
133
- const DEFAULT_TARGET_CALLBACK = "/api/auth/platform-sso/callback";
134
- const DEFAULT_TARGET_LOGIN = "/login";
135
- export function createHandoffSource(config) {
136
- const timeout = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
137
- return {
138
- /** GET `/api/auth/handoffs/{app}/start?code_challenge=&state=` */
139
- async start(request, app) {
140
- const target = config.targets[app];
141
- if (!target)
142
- return new Response("unknown handoff application", { status: 404, headers: { "Cache-Control": "no-store" } });
143
- const targetOrigin = originOf(resolveValue(target.origin));
144
- const targetLogin = target.loginPath ?? DEFAULT_TARGET_LOGIN;
145
- if (!targetOrigin)
146
- return new Response("handoff target not configured", { status: 503, headers: { "Cache-Control": "no-store" } });
147
- const targetError = (code) => loginErrorRedirect(targetOrigin, targetLogin, code);
148
- const url = new URL(request.url);
149
- const challenge = url.searchParams.get("code_challenge")?.trim() ?? "";
150
- const state = url.searchParams.get("state")?.trim() ?? "";
151
- if (!CHALLENGE_PATTERN.test(challenge) || !validState(state))
152
- return targetError("handoff_request_invalid");
153
- const sourceLogin = () => {
154
- const login = new URL(config.loginPath, url.origin);
155
- login.searchParams.set("from", `${url.pathname}${url.search}`);
156
- return redirect(login);
157
- };
158
- const token = await config.getAccessToken(request);
159
- if (!token)
160
- return sourceLogin();
161
- try {
162
- const headers = forwardedRequestHeaders(request, {
163
- "Content-Type": "application/json",
164
- "X-Client-Type": config.clientType,
165
- Authorization: `Bearer ${token}`,
166
- });
167
- const response = await timedFetch(`${resolveValue(config.apiUrl)}${brokerPaths(app).start}`, { init: { method: "POST", headers, body: JSON.stringify({ code_challenge: challenge, state }) }, timeoutMs: timeout });
168
- if (response.status === 401)
169
- return sourceLogin();
170
- const payload = await response.json().catch(() => ({}));
171
- if (!response.ok) {
172
- const backendCode = payload?.code;
173
- return targetError(typeof backendCode === "string" ? backendCode : "handoff_start_failed");
174
- }
175
- const redirectUrl = payload?.redirect_url;
176
- if (typeof redirectUrl !== "string")
177
- return targetError("handoff_start_failed");
178
- let destination;
179
- try {
180
- destination = new URL(redirectUrl);
181
- }
182
- catch {
183
- return targetError("handoff_redirect_invalid");
184
- }
185
- if (destination.origin !== targetOrigin || destination.pathname !== (target.callbackPath ?? DEFAULT_TARGET_CALLBACK)) {
186
- return targetError("handoff_redirect_invalid");
187
- }
188
- return redirect(destination);
189
- }
190
- catch {
191
- return targetError("handoff_start_failed");
192
- }
193
- },
194
- };
195
- }
@@ -1,16 +0,0 @@
1
- /** A value that may be fixed or resolved per call (env read at request time). */
2
- export type Resolvable<T = string> = T | (() => T);
3
- export declare function resolveValue<T>(value: Resolvable<T>): T;
4
- export declare const DEFAULT_FETCH_TIMEOUT_MS = 10000;
5
- export interface TimedFetchOptions {
6
- init?: RequestInit;
7
- /** Omit or pass 0 for no timeout. */
8
- timeoutMs?: number;
9
- fetchImpl?: typeof fetch;
10
- }
11
- /**
12
- * `fetch` with an abort timeout and `cache: "no-store"`. Rejects with whatever
13
- * fetch rejects with (an abort surfaces as an AbortError); callers map that to
14
- * their own error vocabulary.
15
- */
16
- export declare function timedFetch(url: string, options?: TimedFetchOptions): Promise<Response>;
package/dist/internal.js DELETED
@@ -1,27 +0,0 @@
1
- // Shared internals. Not part of the public surface — exported only so the
2
- // session and access halves of this package stop keeping private copies.
3
- export function resolveValue(value) {
4
- return typeof value === "function" ? value() : value;
5
- }
6
- export const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
7
- /**
8
- * `fetch` with an abort timeout and `cache: "no-store"`. Rejects with whatever
9
- * fetch rejects with (an abort surfaces as an AbortError); callers map that to
10
- * their own error vocabulary.
11
- */
12
- export async function timedFetch(url, options = {}) {
13
- const doFetch = options.fetchImpl ?? fetch;
14
- const controller = options.timeoutMs ? new AbortController() : undefined;
15
- const timer = controller ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
16
- try {
17
- return await doFetch(url, {
18
- ...options.init,
19
- cache: "no-store",
20
- ...(controller ? { signal: controller.signal } : {}),
21
- });
22
- }
23
- finally {
24
- if (timer)
25
- clearTimeout(timer);
26
- }
27
- }
@@ -1,6 +0,0 @@
1
- /**
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.
5
- */
6
- export declare function safeInternalPath(raw: string | null | undefined): string | null;
@@ -1,33 +0,0 @@
1
- /**
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.
5
- */
6
- export function safeInternalPath(raw) {
7
- if (raw == null)
8
- return null;
9
- const candidate = raw.trim();
10
- if (candidate.length === 0)
11
- return null;
12
- if (!candidate.startsWith("/"))
13
- return null;
14
- if (candidate.startsWith("//") || candidate.includes("://"))
15
- return null;
16
- if (/%(?:2f|5c)/i.test(candidate))
17
- return null;
18
- // eslint-disable-next-line no-control-regex
19
- if (/[\x00-\x1f\x7f]/.test(candidate))
20
- return null;
21
- try {
22
- const parsed = new URL(candidate, "https://internal.invalid");
23
- if (parsed.origin !== "https://internal.invalid")
24
- return null;
25
- const roundtrip = `${parsed.pathname}${parsed.search}${parsed.hash}`;
26
- if (roundtrip !== candidate)
27
- return null;
28
- return candidate;
29
- }
30
- catch {
31
- return null;
32
- }
33
- }
@@ -1,26 +0,0 @@
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>;
@@ -1,33 +0,0 @@
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,24 +0,0 @@
1
- import { type Resolvable } from "./internal.js";
2
- /**
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.
6
- */
7
- export interface RefreshedSession {
8
- accessToken: string;
9
- refreshToken?: string;
10
- }
11
- export interface SessionRefresherConfig {
12
- apiUrl: Resolvable;
13
- refreshPath: string;
14
- clientType: string;
15
- audience: string;
16
- singleflightTtlMs?: number;
17
- maxSingleflights?: number;
18
- fetchTimeoutMs?: number;
19
- }
20
- export declare function createSessionRefresher(config: SessionRefresherConfig): {
21
- refresh(refreshToken: string): Promise<RefreshedSession | null>;
22
- size(): number;
23
- };
24
- export type SessionRefresher = ReturnType<typeof createSessionRefresher>;
@@ -1,62 +0,0 @@
1
- import { DEFAULT_FETCH_TIMEOUT_MS, resolveValue, timedFetch } from "./internal.js";
2
- import { hasTokenAudience, parseAuthResponse } from "./token.js";
3
- const DEFAULT_TTL_MS = 5000;
4
- const DEFAULT_MAX = 1000;
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_FETCH_TIMEOUT_MS;
9
- const inflight = new Map();
10
- async function perform(refreshToken) {
11
- try {
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,
19
- });
20
- if (!response.ok)
21
- return null;
22
- const data = parseAuthResponse(await response.json());
23
- return data && hasTokenAudience(data.access_token, config.audience)
24
- ? { accessToken: data.access_token, refreshToken: data.refresh_token }
25
- : null;
26
- }
27
- catch {
28
- return null;
29
- }
30
- }
31
- function prune(now) {
32
- for (const [token, entry] of inflight) {
33
- if (entry.expiresAt <= now)
34
- inflight.delete(token);
35
- }
36
- while (inflight.size >= max) {
37
- const oldest = inflight.keys().next().value;
38
- if (typeof oldest !== "string")
39
- break;
40
- inflight.delete(oldest);
41
- }
42
- }
43
- return {
44
- refresh(refreshToken) {
45
- const now = Date.now();
46
- prune(now);
47
- const existing = inflight.get(refreshToken);
48
- if (existing && existing.expiresAt > now)
49
- return existing.promise;
50
- const promise = perform(refreshToken).then((result) => {
51
- if (!result)
52
- inflight.delete(refreshToken);
53
- return result;
54
- });
55
- inflight.set(refreshToken, { expiresAt: now + ttl, promise });
56
- return promise;
57
- },
58
- size() {
59
- return inflight.size;
60
- },
61
- };
62
- }
package/dist/token.d.ts DELETED
@@ -1,51 +0,0 @@
1
- export interface SessionUser {
2
- id: string;
3
- email: string;
4
- name: string;
5
- role: string;
6
- is_active: boolean;
7
- email_verified: boolean;
8
- created_at: string;
9
- last_login_at?: string;
10
- has_passkey?: boolean;
11
- }
12
- export interface SessionData {
13
- user: SessionUser;
14
- token: string;
15
- exp: number;
16
- iat: number;
17
- }
18
- export type TokenPayload = Record<string, unknown> & {
19
- sub?: string;
20
- exp?: number;
21
- iat?: number;
22
- actor_id?: string;
23
- effective_user_id?: string;
24
- customer_id?: string;
25
- user_id?: string;
26
- aud?: string | string[];
27
- email?: string;
28
- name?: string;
29
- role?: string;
30
- is_active?: boolean;
31
- email_verified?: boolean;
32
- created_at?: string;
33
- has_passkeys?: boolean;
34
- };
35
- export declare function decodeBase64Url(input: string): string | null;
36
- export declare function decodeTokenPayload(token: string): TokenPayload | null;
37
- export declare function hasTokenAudience(token: string, requiredAudience: string): boolean;
38
- /** Cookie max-age clamped to the token's remaining lifetime. */
39
- export declare function tokenMaxAgeSeconds(token: string, configuredMaxAge: number): number;
40
- export declare function isTokenExpired(token: string): boolean;
41
- export declare function isTokenExpiringSoon(token: string, bufferSeconds?: number): boolean;
42
- export declare function decodeDefaultSession(token: string): SessionData | null;
43
- /** Backend auth response shape shared by verify-code, OIDC complete, and handoff exchange. */
44
- export interface AuthResponse {
45
- access_token: string;
46
- refresh_token: string;
47
- token_type: string;
48
- expires_at: string;
49
- user: Record<string, unknown>;
50
- }
51
- export declare function parseAuthResponse(value: unknown): AuthResponse | null;
package/dist/token.js DELETED
@@ -1,104 +0,0 @@
1
- // Access-token payload reads for BFF routing. Decoding is not verification; the backend is the boundary.
2
- function readStringClaim(value, fallback = "") {
3
- return typeof value === "string" ? value : fallback;
4
- }
5
- function readBooleanClaim(value, fallback) {
6
- return typeof value === "boolean" ? value : fallback;
7
- }
8
- export function decodeBase64Url(input) {
9
- const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
10
- const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
11
- try {
12
- const binary = globalThis.atob(padded);
13
- const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
14
- return new TextDecoder().decode(bytes);
15
- }
16
- catch {
17
- return null;
18
- }
19
- }
20
- export function decodeTokenPayload(token) {
21
- const payloadSegment = token.split(".")[1];
22
- if (!payloadSegment)
23
- return null;
24
- const decodedJson = decodeBase64Url(payloadSegment);
25
- if (!decodedJson)
26
- return null;
27
- try {
28
- const parsed = JSON.parse(decodedJson);
29
- return parsed && typeof parsed === "object" ? parsed : null;
30
- }
31
- catch {
32
- return null;
33
- }
34
- }
35
- export function hasTokenAudience(token, requiredAudience) {
36
- const audience = decodeTokenPayload(token)?.aud;
37
- return typeof audience === "string"
38
- ? audience === requiredAudience
39
- : Array.isArray(audience) && audience.includes(requiredAudience);
40
- }
41
- function nowInSeconds() {
42
- return Math.floor(Date.now() / 1000);
43
- }
44
- /** Cookie max-age clamped to the token's remaining lifetime. */
45
- export function tokenMaxAgeSeconds(token, configuredMaxAge) {
46
- const decoded = decodeTokenPayload(token);
47
- if (!decoded?.exp)
48
- return configuredMaxAge;
49
- return Math.max(0, Math.min(configuredMaxAge, decoded.exp - nowInSeconds()));
50
- }
51
- export function isTokenExpired(token) {
52
- const decoded = decodeTokenPayload(token);
53
- if (!decoded?.exp)
54
- return true;
55
- return decoded.exp <= nowInSeconds();
56
- }
57
- export function isTokenExpiringSoon(token, bufferSeconds = 120) {
58
- const decoded = decodeTokenPayload(token);
59
- if (!decoded?.exp)
60
- return true;
61
- return decoded.exp < nowInSeconds() + bufferSeconds;
62
- }
63
- export function decodeDefaultSession(token) {
64
- const decoded = decodeTokenPayload(token);
65
- if (!decoded)
66
- return null;
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 ||
70
- decoded.customer_id ||
71
- decoded.user_id ||
72
- (typeof decoded.sub === "string" ? decoded.sub : undefined);
73
- if (!actorId)
74
- return null;
75
- return {
76
- user: {
77
- id: String(actorId),
78
- email: readStringClaim(decoded.email),
79
- name: readStringClaim(decoded.name),
80
- role: readStringClaim(decoded.role, "user"),
81
- is_active: readBooleanClaim(decoded.is_active, true),
82
- email_verified: readBooleanClaim(decoded.email_verified, false),
83
- created_at: readStringClaim(decoded.created_at),
84
- has_passkey: readBooleanClaim(decoded.has_passkeys, false),
85
- },
86
- token,
87
- exp: decoded.exp || 0,
88
- iat: decoded.iat || 0,
89
- };
90
- }
91
- export function parseAuthResponse(value) {
92
- if (!value || typeof value !== "object")
93
- return null;
94
- const candidate = value;
95
- if (typeof candidate.access_token !== "string" ||
96
- typeof candidate.refresh_token !== "string" ||
97
- typeof candidate.token_type !== "string" ||
98
- typeof candidate.expires_at !== "string" ||
99
- !candidate.user ||
100
- typeof candidate.user !== "object") {
101
- return null;
102
- }
103
- return candidate;
104
- }