@c1m-public/platform-auth 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ /** Scope a permission entry applies to. Mirrors c1m-server authz.Scope*. */
2
+ export type AccessScopeType = "platform" | "saas_app" | "workspace";
3
+ export interface AccessEntry {
4
+ /** Permission key, e.g. "platform.users.manage_access". */
5
+ key: string;
6
+ scope_type: AccessScopeType;
7
+ /** "" for platform scope; saas_app slug or "*" for saas_app scope; workspace id for workspace scope. */
8
+ scope_id: string;
9
+ }
10
+ /**
11
+ * The resolved authorization snapshot for one subject, as returned by
12
+ * `GET /api/v1/platform/access`. This is the *output* of the backend resolver
13
+ * (base-role grant ∪ scoped grants), never the grant rows themselves.
14
+ */
15
+ export interface AccessSnapshot {
16
+ subject_id: string;
17
+ /** Monotonic; bumps on any grant/role change. Use for optimistic concurrency and cache keys. */
18
+ access_version: number;
19
+ is_owner: boolean;
20
+ active: boolean;
21
+ /** Platform operator base role (derived; display only — never authorize on it). */
22
+ platform_role: string;
23
+ permissions: AccessEntry[];
24
+ /** Effective workspace-staff permission keys per workspace id. */
25
+ workspace_permissions: Record<string, string[]>;
26
+ /** Workspace-staff role bundle per workspace id (display only). */
27
+ workspace_roles: Record<string, string>;
28
+ }
29
+ export declare function emptySnapshot(subjectId?: string): AccessSnapshot;
30
+ /** Runtime validator for the wire payload. Throws AccessSnapshotError("invalid_snapshot"). */
31
+ export declare function parseAccessSnapshot(input: unknown): AccessSnapshot;
@@ -0,0 +1,80 @@
1
+ import { AccessSnapshotError } from "./errors.js";
2
+ const SCOPE_TYPES = new Set(["platform", "saas_app", "workspace"]);
3
+ export function emptySnapshot(subjectId = "") {
4
+ return {
5
+ subject_id: subjectId,
6
+ access_version: 0,
7
+ is_owner: false,
8
+ active: false,
9
+ platform_role: "",
10
+ permissions: [],
11
+ workspace_permissions: {},
12
+ workspace_roles: {},
13
+ };
14
+ }
15
+ function isRecord(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ function fail(field, why) {
19
+ throw new AccessSnapshotError("invalid_snapshot", `access snapshot: ${field} ${why}`);
20
+ }
21
+ /** Runtime validator for the wire payload. Throws AccessSnapshotError("invalid_snapshot"). */
22
+ export function parseAccessSnapshot(input) {
23
+ if (!isRecord(input))
24
+ fail("payload", "is not an object");
25
+ const { subject_id, access_version, is_owner, active, platform_role, permissions, workspace_permissions, workspace_roles } = input;
26
+ if (typeof subject_id !== "string" || subject_id === "")
27
+ fail("subject_id", "must be a non-empty string");
28
+ if (typeof access_version !== "number" || !Number.isInteger(access_version) || access_version < 0)
29
+ fail("access_version", "must be a non-negative integer");
30
+ if (typeof is_owner !== "boolean")
31
+ fail("is_owner", "must be a boolean");
32
+ if (typeof active !== "boolean")
33
+ fail("active", "must be a boolean");
34
+ if (platform_role !== undefined && platform_role !== null && typeof platform_role !== "string")
35
+ fail("platform_role", "must be a string");
36
+ if (!Array.isArray(permissions))
37
+ fail("permissions", "must be an array");
38
+ const entries = permissions.map((raw, i) => {
39
+ if (!isRecord(raw))
40
+ fail(`permissions[${i}]`, "is not an object");
41
+ const { key, scope_type, scope_id } = raw;
42
+ if (typeof key !== "string" || key === "")
43
+ fail(`permissions[${i}].key`, "must be a non-empty string");
44
+ if (typeof scope_type !== "string" || !SCOPE_TYPES.has(scope_type))
45
+ fail(`permissions[${i}].scope_type`, "must be platform|saas_app|workspace");
46
+ if (scope_id !== undefined && scope_id !== null && typeof scope_id !== "string")
47
+ fail(`permissions[${i}].scope_id`, "must be a string");
48
+ return { key, scope_type: scope_type, scope_id: typeof scope_id === "string" ? scope_id : "" };
49
+ });
50
+ const wsPerms = {};
51
+ if (workspace_permissions !== undefined && workspace_permissions !== null) {
52
+ if (!isRecord(workspace_permissions))
53
+ fail("workspace_permissions", "must be an object");
54
+ for (const [ws, keys] of Object.entries(workspace_permissions)) {
55
+ if (!Array.isArray(keys) || keys.some((k) => typeof k !== "string"))
56
+ fail(`workspace_permissions[${ws}]`, "must be a string array");
57
+ wsPerms[ws] = [...keys];
58
+ }
59
+ }
60
+ const wsRoles = {};
61
+ if (workspace_roles !== undefined && workspace_roles !== null) {
62
+ if (!isRecord(workspace_roles))
63
+ fail("workspace_roles", "must be an object");
64
+ for (const [ws, role] of Object.entries(workspace_roles)) {
65
+ if (typeof role !== "string")
66
+ fail(`workspace_roles[${ws}]`, "must be a string");
67
+ wsRoles[ws] = role;
68
+ }
69
+ }
70
+ return {
71
+ subject_id,
72
+ access_version,
73
+ is_owner,
74
+ active,
75
+ platform_role: typeof platform_role === "string" ? platform_role : "",
76
+ permissions: entries,
77
+ workspace_permissions: wsPerms,
78
+ workspace_roles: wsRoles,
79
+ };
80
+ }
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";
@@ -1,8 +1,6 @@
1
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.
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): {
@@ -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;
@@ -1,9 +1,5 @@
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
- */
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
  }
@@ -1,9 +1,13 @@
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.";
3
+ export declare const SSO_ERROR_MESSAGES: Record<string, string>;
4
+ /** Every sign-in failure code this package knows: handoff, admission, and SSO. */
5
+ export declare const AUTH_ERROR_MESSAGES: Record<string, string>;
8
6
  export declare function handoffErrorMessage(code: string | null | undefined): string;
9
7
  export declare function isHandoffErrorCode(code: string | null | undefined): boolean;
8
+ /**
9
+ * Resolve any sign-in failure code to copy. `overrides` wins, so a surface can
10
+ * reword a shared code or add its own without forking the whole map.
11
+ */
12
+ export declare function authErrorMessage(code: string | null | undefined, overrides?: Record<string, string>): string;
13
+ export declare function isAuthErrorCode(code: string | null | undefined, overrides?: Record<string, string>): boolean;
@@ -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.",
@@ -27,6 +23,23 @@ export const HANDOFF_ERROR_MESSAGES = {
27
23
  admin_surface: "Platform operators sign in here; SaaSDash sent you to the Admin Center.",
28
24
  };
29
25
  export const HANDOFF_ERROR_FALLBACK = "Sign-in failed — please try again.";
26
+ // External sign-in (OIDC/social) codes every platform surface renders identically.
27
+ // Surface-specific copy stays in the app and is passed as `overrides`.
28
+ export const SSO_ERROR_MESSAGES = {
29
+ sso_cancelled: "Sign-in was cancelled.",
30
+ sso_denied: "Sign-in was refused by the provider.",
31
+ sso_state_invalid: "Sign-in attempt expired — please try again.",
32
+ sso_start_failed: "Could not start sign-in with that provider. Please try again.",
33
+ sso_complete_failed: "Sign-in could not be completed. Please try again.",
34
+ sso_unknown_operator: "No operator account matches that identity. Ask an admin for an invite.",
35
+ sso_provider_not_found: "That sign-in provider is not available.",
36
+ sso_not_configured: "That sign-in provider is not fully configured.",
37
+ };
38
+ /** Every sign-in failure code this package knows: handoff, admission, and SSO. */
39
+ export const AUTH_ERROR_MESSAGES = {
40
+ ...HANDOFF_ERROR_MESSAGES,
41
+ ...SSO_ERROR_MESSAGES,
42
+ };
30
43
  export function handoffErrorMessage(code) {
31
44
  if (!code)
32
45
  return HANDOFF_ERROR_FALLBACK;
@@ -35,3 +48,18 @@ export function handoffErrorMessage(code) {
35
48
  export function isHandoffErrorCode(code) {
36
49
  return !!code && Object.prototype.hasOwnProperty.call(HANDOFF_ERROR_MESSAGES, code);
37
50
  }
51
+ /**
52
+ * Resolve any sign-in failure code to copy. `overrides` wins, so a surface can
53
+ * reword a shared code or add its own without forking the whole map.
54
+ */
55
+ export function authErrorMessage(code, overrides) {
56
+ if (!code)
57
+ return HANDOFF_ERROR_FALLBACK;
58
+ return overrides?.[code] ?? AUTH_ERROR_MESSAGES[code] ?? HANDOFF_ERROR_FALLBACK;
59
+ }
60
+ export function isAuthErrorCode(code, overrides) {
61
+ if (!code)
62
+ return false;
63
+ return (Object.prototype.hasOwnProperty.call(AUTH_ERROR_MESSAGES, code) ||
64
+ (!!overrides && Object.prototype.hasOwnProperty.call(overrides, code)));
65
+ }
package/dist/handoff.d.ts CHANGED
@@ -1,41 +1,24 @@
1
1
  import type { DeviceIdentityStore } from "./device-cookie.js";
2
+ import { type Resolvable } from "./internal.js";
2
3
  import { type AuthResponse } from "./token.js";
3
- /** Path a source exposes to start a handoff to `app` (relative to the source origin). */
4
4
  export declare function sourceStartPath(app: string): string;
5
- /** Backend routes for a registered application key. */
6
5
  export declare function brokerPaths(app: string): {
7
6
  readonly start: `/api/v1/platform/auth/handoffs/${string}/start`;
8
7
  readonly exchange: `/api/v1/platform/auth/handoffs/${string}/exchange`;
9
8
  };
10
- 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,38 +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=<key>&from=<path>` — begin a handoff by bouncing to the source. */
31
+ /** GET `?source=&from=` */
49
32
  start(request: Request): Promise<Response>;
50
- /** GET `?code=&state=` — exchange the one-time code and establish the session. */
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>;
82
- export {};
package/dist/handoff.js CHANGED
@@ -1,33 +1,19 @@
1
1
  import { deleteCookie, readCookie, setCookie } from "./cookies.js";
2
2
  import { forwardedRequestHeaders } from "./forwarding.js";
3
3
  import { safeInternalPath } from "./return-path.js";
4
+ import { DEFAULT_FETCH_TIMEOUT_MS, resolveValue, timedFetch } from "./internal.js";
4
5
  import { hasTokenAudience, parseAuthResponse } from "./token.js";
5
6
  /**
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.
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.
22
11
  */
23
12
  const HANDOFF_COOKIE_TTL_SECONDS = 120;
24
13
  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
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 {
@@ -35,7 +21,6 @@ export function brokerPaths(app) {
35
21
  exchange: `/api/v1/platform/auth/handoffs/${key}/exchange`,
36
22
  };
37
23
  }
38
- const resolve = (value) => (typeof value === "function" ? value() : value);
39
24
  function originOf(value) {
40
25
  try {
41
26
  return new URL(value).origin;
@@ -70,22 +55,12 @@ function loginErrorRedirect(origin, loginPath, code) {
70
55
  target.searchParams.set("error", code);
71
56
  return redirect(target);
72
57
  }
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
58
  export function createHandoffTarget(config) {
84
59
  const secure = config.secureCookies ?? true;
85
60
  const prefix = `${secure ? "__Host-" : ""}${config.cookiePrefix}_handoff_`;
86
61
  const cookies = { state: `${prefix}state`, verifier: `${prefix}verifier`, from: `${prefix}from` };
87
62
  const cookieOptions = { httpOnly: true, secure, sameSite: "lax", path: "/", maxAge: HANDOFF_COOKIE_TTL_SECONDS };
88
- const timeout = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
63
+ const timeout = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
89
64
  const clearHandoffCookies = (response) => {
90
65
  for (const name of Object.values(cookies))
91
66
  deleteCookie(response, name, { path: "/", secure });
@@ -94,14 +69,14 @@ export function createHandoffTarget(config) {
94
69
  const fail = (origin, code) => clearHandoffCookies(loginErrorRedirect(origin, config.loginPath, code));
95
70
  return {
96
71
  cookies,
97
- /** GET `?source=<key>&from=<path>` — begin a handoff by bouncing to the source. */
72
+ /** GET `?source=&from=` */
98
73
  async start(request) {
99
74
  const url = new URL(request.url);
100
75
  const sourceKey = url.searchParams.get("source")?.trim() || config.defaultSource;
101
76
  const source = config.sources[sourceKey];
102
77
  if (!source)
103
78
  return fail(url.origin, "handoff_request_invalid");
104
- const sourceOrigin = originOf(resolve(source));
79
+ const sourceOrigin = originOf(resolveValue(source));
105
80
  if (!sourceOrigin)
106
81
  return fail(url.origin, "handoff_not_configured");
107
82
  const verifier = randomToken(48);
@@ -117,7 +92,7 @@ export function createHandoffTarget(config) {
117
92
  setCookie(response, cookies.from, from, cookieOptions);
118
93
  return response;
119
94
  },
120
- /** GET `?code=&state=` — exchange the one-time code and establish the session. */
95
+ /** GET `?code=&state=` */
121
96
  async callback(request) {
122
97
  const url = new URL(request.url);
123
98
  const code = url.searchParams.get("code") ?? "";
@@ -135,7 +110,7 @@ export function createHandoffTarget(config) {
135
110
  "X-Client-Type": config.clientType,
136
111
  "X-Client-Device-ID": device.value,
137
112
  });
138
- const response = await fetchWithTimeout(`${resolve(config.apiUrl)}${brokerPaths(config.app).exchange}`, { method: "POST", headers, body: JSON.stringify({ code, code_verifier: verifier }) }, timeout);
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 });
139
114
  const payload = await response.json().catch(() => ({}));
140
115
  if (!response.ok) {
141
116
  const backendCode = payload?.code;
@@ -158,14 +133,14 @@ export function createHandoffTarget(config) {
158
133
  const DEFAULT_TARGET_CALLBACK = "/api/auth/platform-sso/callback";
159
134
  const DEFAULT_TARGET_LOGIN = "/login";
160
135
  export function createHandoffSource(config) {
161
- const timeout = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
136
+ const timeout = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
162
137
  return {
163
- /** GET `/api/auth/handoffs/{app}/start?code_challenge=&state=`. */
138
+ /** GET `/api/auth/handoffs/{app}/start?code_challenge=&state=` */
164
139
  async start(request, app) {
165
140
  const target = config.targets[app];
166
141
  if (!target)
167
142
  return new Response("unknown handoff application", { status: 404, headers: { "Cache-Control": "no-store" } });
168
- const targetOrigin = originOf(resolve(target.origin));
143
+ const targetOrigin = originOf(resolveValue(target.origin));
169
144
  const targetLogin = target.loginPath ?? DEFAULT_TARGET_LOGIN;
170
145
  if (!targetOrigin)
171
146
  return new Response("handoff target not configured", { status: 503, headers: { "Cache-Control": "no-store" } });
@@ -189,7 +164,7 @@ export function createHandoffSource(config) {
189
164
  "X-Client-Type": config.clientType,
190
165
  Authorization: `Bearer ${token}`,
191
166
  });
192
- const response = await fetchWithTimeout(`${resolve(config.apiUrl)}${brokerPaths(app).start}`, { method: "POST", headers, body: JSON.stringify({ code_challenge: challenge, state }) }, timeout);
167
+ const response = await timedFetch(`${resolveValue(config.apiUrl)}${brokerPaths(app).start}`, { init: { method: "POST", headers, body: JSON.stringify({ code_challenge: challenge, state }) }, timeoutMs: timeout });
193
168
  if (response.status === 401)
194
169
  return sourceLogin();
195
170
  const payload = await response.json().catch(() => ({}));
package/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export * from "./device-cookie.js";
6
6
  export * from "./handoff-errors.js";
7
7
  export * from "./handoff.js";
8
8
  export * from "./session-refresh.js";
9
+ export * from "./session-gate.js";
package/dist/index.js CHANGED
@@ -6,3 +6,4 @@ export * from "./device-cookie.js";
6
6
  export * from "./handoff-errors.js";
7
7
  export * from "./handoff.js";
8
8
  export * from "./session-refresh.js";
9
+ export * from "./session-gate.js";
@@ -0,0 +1,16 @@
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>;
@@ -0,0 +1,27 @@
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
+ }
package/dist/next.js CHANGED
@@ -1,5 +1,3 @@
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.
1
+ // Server-only entry: root API plus the bundler guard against client bundles.
4
2
  import "server-only";
5
3
  export * from "./index.js";
@@ -1,16 +1,6 @@
1
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.
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;
@@ -1,17 +1,7 @@
1
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.
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>;