@relyt/auth 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Relyt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,218 @@
1
- # Temporary Holding Version
1
+ # @relyt/auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Token tooling for Relyt managed auth:
4
+
5
+ - server-side JWT verification: `createVerifier` / `verifyToken` (EdDSA only, keys from the auth host's remote JWKS);
6
+ - a token cache for browsers and Node: `createAuthClient`;
7
+ - a Next.js middleware entry: `@relyt/auth/next` (`createAuthMiddleware`, `getClaims`, `setTokenCookie`, `clearTokenCookie`);
8
+ - cross-site social sign-in helpers: `startSocialSignIn` / `completeSocialSignIn` (browser) and `createSocialStart` / `exchangeHandoffCode` (server-side proxy).
9
+
10
+ The package uses Web APIs only (`fetch`, WebCrypto, `URL`, …), so the main entry runs in browsers, Node and the Next.js edge runtime. Its only runtime dependency is [`jose`](https://github.com/panva/jose).
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install @relyt/auth
16
+ # or
17
+ pnpm add @relyt/auth
18
+ ```
19
+
20
+ Requirements:
21
+
22
+ - Node.js 20 or later (the test suite runs on Node 22 and 24; CI also builds and smoke-tests the package on Node 20);
23
+ - ESM only (`"type": "module"`); TypeScript types are included;
24
+ - `next` is an optional peer dependency, needed only for `@relyt/auth/next`. Supported: **`^14.2.35` or `>=15.5.28 <16`**. The test suite runs on Next 15.5.28.
25
+
26
+ ## Verifying tokens on the server
27
+
28
+ ```ts
29
+ import { createVerifier, verifyToken, RelytAuthError } from "@relyt/auth";
30
+
31
+ const verify = createVerifier({ issuer: "https://<authHost>" });
32
+
33
+ try {
34
+ const claims = await verify(token); // RelytClaims: sub, iss, aud, iat, exp, role, app_role, cv, o?, act?
35
+ } catch (e) {
36
+ if (e instanceof RelytAuthError && e.code === "jwks_unavailable") {
37
+ // keys could not be fetched: answer 503, not 401
38
+ }
39
+ // invalid_token / invalid_claims ⇒ 401
40
+ }
41
+
42
+ // One-off form: options are resolved on every call; the remote key set is still shared per cache key.
43
+ const claims = await verifyToken(token, { issuer: "https://<authHost>" });
44
+ ```
45
+
46
+ What is fixed and what is configurable:
47
+
48
+ - `algorithms` is pinned to `["EdDSA"]` and cannot be changed. `alg: none`, HMAC and other algorithms are rejected, and `jku` / `x5u` / embedded `jwk` headers are never followed.
49
+ - `issuer` is the expected value of **both** `iss` and `aud`. It must be exactly an origin (`https://host[:port]`, no trailing slash, no path, nothing URL normalization would rewrite) because the token's `iss` is compared to it verbatim. Plain `http` is accepted only for `localhost`, `127.0.0.1` and `[::1]`.
50
+ - `jwksUrl` defaults to `${issuer}/.well-known/jwks.json`; its origin follows the same rule as `issuer`.
51
+ - `clockToleranceS` defaults to 30 and must be ≤ 60 (after a key rotation, the auth host keeps the retired key in its JWKS for only 60 s beyond the token lifetime).
52
+ - `maxTokenAgeS` defaults to 900. On top of jose's `maxTokenAge` (which bounds the age of `iat`), the SDK also rejects tokens whose issued lifetime `exp − iat` is not in `(0, maxTokenAgeS]`.
53
+ - `cooldownMs` (default 30 000) and `timeoutMs` (default 5 000) control the remote JWKS fetch. The key set is cached per process (keyed by JWKS URL, cooldown and timeout, LRU of 64 entries) and kept for up to 5 minutes; an unknown `kid` triggers an earlier refetch, at most once per cooldown.
54
+ - `onEvent` receives `verify_failed` events (code and issuer only, never the token).
55
+ - Required claims: `exp`, `iat`, `sub`. The payload shape is checked as well (`role` must be `"authenticated"`, `cv` an integer, `o` / `act` well-formed when present); unknown extra claims are preserved.
56
+
57
+ ## Client token cache: `createAuthClient`
58
+
59
+ ```ts
60
+ import { createAuthClient } from "@relyt/auth";
61
+
62
+ // Cookie mode (same-site app): the browser sends the auth host's session cookie.
63
+ const client = createAuthClient({ authUrl: "https://<authHost>" });
64
+
65
+ // Bearer mode (cross-site app or Node): you store the session token and provide it.
66
+ let sessionToken: string | null = null;
67
+ const bearerClient = createAuthClient({
68
+ authUrl: "https://<authHost>",
69
+ sessionToken: () => sessionToken,
70
+ onSessionToken: (t) => (sessionToken = t),
71
+ });
72
+
73
+ const jwt = await client.getToken(); // cached; refreshed `refreshAheadS` (default 120 s) before expiry
74
+ await fetch("/api/todos", { headers: { authorization: `Bearer ${jwt}` } });
75
+
76
+ client.peekToken(); // cached token if still valid locally, else null (never triggers a request)
77
+ await client.getSession(); // session object, or null when signed out; may prime the token cache
78
+ client.clear(); // sign-out: drops the cache and cancels in-flight refreshes and retry waits
79
+ ```
80
+
81
+ - **The client only carries tokens; it never verifies them.** Trust decisions happen only on the server (`verifyToken` / `getClaims`).
82
+ - Concurrent `getToken()` calls share one request. A result that arrives after `clear()` is never written back to the cache.
83
+ - Retries: only a `503` with a readable `Retry-After` header is retried, at most 3 times, waiting `Retry-After` clamped to [0, 10] s (2 s when the value is invalid). Everything else fails immediately.
84
+ - **Worst-case latency of `getToken()` is 90 s**: at most 4 requests of `timeoutMs` (default 15 s) each, plus 3 backoff waits of at most 10 s (4 × 15 + 3 × 10).
85
+ - `refreshAheadS` must be within [10, 600]; `timeoutMs` must be an integer within [1 000, 60 000].
86
+
87
+ ## Next.js: `@relyt/auth/next`
88
+
89
+ ```ts
90
+ // middleware.ts
91
+ import { createAuthMiddleware } from "@relyt/auth/next";
92
+ export const middleware = createAuthMiddleware({
93
+ issuer: process.env.RELYT_AUTH_ISSUER!, // "https://<authHost>", from configuration only
94
+ signInUrl: "/sign-in",
95
+ publicPaths: ["/sign-in", "/api/public", /^\/_next\//],
96
+ });
97
+ export const config = { matcher: ["/((?!_next/static|favicon.ico).*)"] };
98
+
99
+ // app/api/x/route.ts
100
+ import { getClaims, RelytAuthUnavailableError } from "@relyt/auth/next";
101
+ export async function GET(req: Request) {
102
+ try {
103
+ const c = await getClaims(req, { issuer: process.env.RELYT_AUTH_ISSUER! });
104
+ if (!c) return new Response(null, { status: 401 });
105
+ return Response.json({ sub: c.sub });
106
+ } catch (e) {
107
+ if (e instanceof RelytAuthUnavailableError) return new Response(null, { status: 503, headers: { "retry-after": "2" } });
108
+ throw e;
109
+ }
110
+ }
111
+ ```
112
+
113
+ - **Where the token comes from**: first `Authorization: Bearer`, then the cookie `__Host-relyt-at` (change it with `cookieName`; the middleware, `getClaims` and `setTokenCookie` share the same default). A cookie name that occurs more than once is treated as no token. **A present `Authorization` header is authoritative**: if its value is not a valid `Bearer <token>` (e.g. `Basic …`, or empty), the request has no token and there is no fallback to the cookie. With `cookieOnApi: false`, API paths accept bearer tokens only; the route-side counterpart is `getClaims(req, o, { cookie: false })` (default `true`), which reads `Authorization` only.
114
+ - **Failure behaviour**: API paths (`apiPrefix`, default `/api/`; a prefix ending in `/` also covers the bare path `/api`; or any request whose `Accept` contains `application/json`) get 401 `{"error":"unauthorized"}`, with `WWW-Authenticate: Bearer` when there is no token and `Bearer error="invalid_token"` when the token is invalid. Pages get 307 for GET/HEAD and 303 for other methods, redirecting to `signInUrl?next=<relative path>` (if `next`, once decoded, contains a control character / DEL, starts with `//` or `/\`, contains `\`, or resolves cross-origin, it becomes `/`). When the JWKS is unavailable the answer is 503 `{"error":"auth_unavailable"}` with `Retry-After: 2`.
115
+ - **Claims are never forwarded**: the middleware strips every incoming `x-relyt-*` header and never writes claim headers. Route handlers must call `getClaims` themselves to re-verify (so a route missed by the matcher, or a bypassed middleware, never makes a forged header effective). By default `getClaims` throws `RelytAuthUnavailableError` when the JWKS is unavailable; pass `{ onUnavailable: "null" }` to get `null` instead.
116
+ - **The issuer comes from configuration, not from the request**: `issuer` is taken from the options only, never from `Host` / `X-Forwarded-Host`, and the token's `iss` / `aud` are compared to it verbatim. Only a relative `signInUrl` is resolved against the request URL (affecting just the redirect target's origin). **Use an absolute `signInUrl` in production**: a relative one is resolved against the request's `Host`, so a proxy that passes through a forged `Host` changes the redirect target. 401 / 307 / 303 / 503 responses all carry `Cache-Control: no-store`.
117
+ - **Only the server writes the cookie**: `setTokenCookie(res, token)` writes `HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=min(900, exp − now)` (an already expired token clears the cookie instead). Sign-out must call `clearTokenCookie(res)` on the server (same attributes plus `Max-Age=0`); browser JS cannot clear an HttpOnly cookie. `setTokenCookie` throws `RelytAuthError("config")` if the serialized cookie exceeds 4000 bytes (browsers silently drop oversized cookies).
118
+ - **The `__Host-` prefix requires `Secure`**: Safari refuses `__Host-` / `Secure` cookies on `http://localhost`, so develop locally over https, or use Chrome / Firefox.
119
+ - **CSRF**: cookie-authenticated APIs have a CSRF surface (`SameSite=Lax` blocks cross-site POSTs, but not same-site subdomains or side-effecting GETs). **Use bearer tokens for APIs and the cookie only for page navigation**; set `cookieOnApi: false` where needed.
120
+ - **Tokens without `kid`**: while the JWKS holds two keys (rotation overlap), a token without a `kid` header cannot select a key and is always rejected (401 / `null`).
121
+ - **Known limitation**: on a cold auth host (one that has not yet loaded the tenant's configuration), a busy / cold-read failure makes the CORS preflight of a non-simple cross-origin request itself return 503. The browser only sees a `TypeError` and cannot read `Retry-After`. By design the client does not retry network errors and fails immediately; in cookie mode the public 503 of the auth routes is likewise unreadable. Non-browser callers are not affected.
122
+
123
+ ## Cross-site social sign-in: `startSocialSignIn` / `completeSocialSignIn`
124
+
125
+ When the app and the auth host are on different sites, social sign-in uses "auth host start endpoint + single-use handoff code + PKCE exchange" and does not rely on cross-site cookies.
126
+
127
+ ```ts
128
+ import { createAuthClient, startSocialSignIn, completeSocialSignIn } from "@relyt/auth";
129
+
130
+ // Sign-in button: generates a PKCE verifier, stores it in sessionStorage, navigates the top-level window to the auth host
131
+ await startSocialSignIn({ authUrl, provider: "google", callbackURL: "https://app.example.com/auth/callback" });
132
+
133
+ // Callback page https://app.example.com/auth/callback
134
+ let sessionToken: string | null = null;
135
+ const client = createAuthClient({ authUrl, sessionToken: () => sessionToken, onSessionToken: (t) => (sessionToken = t) });
136
+ try {
137
+ await completeSocialSignIn({ authUrl, client, onSessionToken: (t) => (sessionToken = t) });
138
+ } catch (e) {
139
+ // RelytAuthError: oauth_error (e.oauthError) / no_pending_sign_in / missing_code /
140
+ // invalid_handoff_code / invalid_origin / rate_limited (e.retryAfterS) / unavailable / network / bad_response
141
+ }
142
+ ```
143
+
144
+ - `responseMode` defaults to `"fragment"` (the code arrives as `#relyt_code=` and never reaches server logs or the Referer); with `"query"` it arrives as `?relyt_code=`.
145
+ - The very first thing `completeSocialSignIn` does is remove `relyt_code` / `error` from the address bar with `history.replaceState`. The verifier is taken out once and deleted. If this tab has no pending sign-in record, it **never exchanges** the code (defending against planted codes) and throws `no_pending_sign_in`.
146
+ - The exchange is not retried: a code is valid for 60 s, single use, and invalidated on submission. After a failure, let the user click sign-in again.
147
+ - `completeSocialSignIn` is re-entrant: calling it again while one call is in progress (React StrictMode's double effect) returns the same promise, so the code is exchanged once and `onSessionToken` is called once. The pending record is bound to `authUrl` and valid for 10 minutes; a mismatched or expired record is handled as `no_pending_sign_in`.
148
+ - `e.retryAfterS` on `rate_limited` uses the same parser as `createAuthClient`: clamped to ≤ 10 s, 2 when the value is invalid, `undefined` when there is no `Retry-After` header.
149
+ - Server-side proxy (Node / Next route handler, query mode): start with the pure builder `createSocialStart` (touches neither sessionStorage nor location, works in browsers and Node) and exchange with `exchangeHandoffCode({ authUrl, code, codeVerifier })`, which returns `{ sessionToken }` and sends no Origin. Your server keeps the verifier and binds it to the initiating user's own session:
150
+
151
+ ```ts
152
+ // GET /login/google (server)
153
+ const { url, codeVerifier } = await createSocialStart({ authUrl, provider: "google", callbackURL: "https://app.example.com/auth/callback", responseMode: "query" });
154
+ session.relytVerifier = codeVerifier; // store in your own server-side session (bound to an HttpOnly cookie); never log it
155
+ return Response.redirect(url, 302);
156
+
157
+ // GET /auth/callback?relyt_code=… (server)
158
+ const v = session.relytVerifier; delete session.relytVerifier; // single use
159
+ const { sessionToken } = await exchangeHandoffCode({ authUrl, code, codeVerifier: v });
160
+ ```
161
+ The auth host rate-limits failed exchanges per source IP (10 per 60 s). In proxy mode all users share your server's egress IP, so one user's failures eat into everyone's budget.
162
+
163
+ ### Callback page rules
164
+
165
+ These are a security boundary: PKCE cannot stop a flow that the attacker started themselves; the defence is the integrity of the callback page.
166
+
167
+ 1. Call `completeSocialSignIn` **before loading any third-party script** (analytics, ads, chat widgets and the like all go after it).
168
+ 2. The callback page **must not do an HTTP redirect** before the code has been read, and must not contain an open redirect (a fragment is carried along or lost on redirect).
169
+ 3. Register the **exact** callback URLs in the console (`callbackURL` / `errorCallbackURL` / `newUserCallbackURL` are matched verbatim, no wildcards), and add the app's **exact origin** (e.g. `https://app.example.com`) to `trustedOrigins`, without wildcards. If the origin is not registered, the CORS preflight of the exchange request fails in the browser and the SDK can only report `network` (it cannot read the 403, so it will not be `invalid_origin`); when troubleshooting, check `trustedOrigins` first. `invalid_origin` only appears for non-browser callers that send their own `Origin` header.
170
+ 4. In query mode the code appears in the access logs of the server hosting the callback page; use it only when the exchange must be proxied by your server. Add `<meta name="referrer" content="no-referrer">` to the callback page so the code is not sent in the Referer to other resources on the page.
171
+ 5. **Every page that can receive the redirect back** (`callbackURL`, `newUserCallbackURL`, `errorCallbackURL`) must call `completeSocialSignIn`; otherwise the code or the error stays in the address bar and the pending record is never consumed.
172
+ 6. The page that starts the sign-in and the callback page must have **exactly the same origin** (`www.example.com` and `example.com` differ): the verifier lives in sessionStorage, which is isolated per origin, so after an origin change it cannot be read and the result is `no_pending_sign_in`.
173
+
174
+ ## Errors
175
+
176
+ Every failure is a `RelytAuthError` with a stable `code`, a `retryable` flag and a fixed English `message`. Messages never include tokens, response bodies or URL query strings.
177
+
178
+ | `code` | Meaning | `retryable` by default | Extra fields |
179
+ |---|---|---|---|
180
+ | `unauthenticated` | not signed in (401 / 403 from the auth host, or the session-token provider failed) | no | `status` |
181
+ | `unavailable` | auth service unavailable (503) | yes (no when `Retry-After` is missing or after a failed exchange) | `status` |
182
+ | `network` | network error or timeout | yes (no for the social exchange) | |
183
+ | `bad_response` | unexpected response from the auth host | no | `status` |
184
+ | `invalid_token` | signature, algorithm, issuer, audience or time check failed | no | |
185
+ | `invalid_claims` | payload shape is invalid | no | |
186
+ | `jwks_unavailable` | signing keys could not be fetched | yes | |
187
+ | `config` | invalid option | no | `option` (option name only, never the value) |
188
+ | `invalid_handoff_code` | invalid or expired sign-in code (400) | no | `status` |
189
+ | `invalid_origin` | origin not trusted by the auth host (403, non-browser callers only) | no | `status` |
190
+ | `rate_limited` | too many requests (429) | no | `status`, `retryAfterS` |
191
+ | `no_pending_sign_in` | no pending sign-in in this browser session | no | |
192
+ | `missing_code` | sign-in code missing from the callback URL | no | |
193
+ | `oauth_error` | the social provider reported an error | no | `oauthError` (filtered to `^[a-z0-9_]{1,64}$`, otherwise `"unknown"`) |
194
+
195
+ `@relyt/auth/next` additionally exports `RelytAuthUnavailableError` (a `RelytAuthError` with code `jwks_unavailable`), thrown by `getClaims` so route handlers can answer 503.
196
+
197
+ ## Security notes
198
+
199
+ - Tokens, session tokens, handoff codes, PKCE verifiers and cookie values never appear in errors, events or logs. The SDK itself never calls `console`.
200
+ - The issuer always comes from configuration; it is never derived from the token or the request.
201
+ - Verification fails closed: any error the SDK does not recognise becomes `invalid_token`.
202
+ - Report security issues privately through [GitHub security advisories](https://github.com/relytcloud/relyt-auth-js/security/advisories/new) rather than public issues.
203
+
204
+ ## Development
205
+
206
+ This repository uses **pnpm** (version pinned in `packageManager`; enable it with `corepack enable`).
207
+
208
+ ```sh
209
+ pnpm install
210
+ pnpm run typecheck
211
+ pnpm test # builds dist/ first, then runs vitest (Node 22 or 24)
212
+ pnpm run build
213
+ npm pack --dry-run # inspect the published file list
214
+ ```
215
+
216
+ ## License
217
+
218
+ [MIT](./LICENSE)
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Token payload type. Mirrors the claim set issued by the Relyt auth service; if the service adds or renames a
3
+ * claim, update `CLAIM_KEYS` and `checkClaimsShape` in this file together.
4
+ */
5
+ export interface RelytClaims {
6
+ sub: string;
7
+ iss: string;
8
+ aud: string | string[];
9
+ iat: number;
10
+ exp: number;
11
+ nbf?: number;
12
+ role: "authenticated";
13
+ app_role: string | null;
14
+ cv: number;
15
+ o?: {
16
+ id: string;
17
+ slug: string;
18
+ role: string;
19
+ perms: string[] | null;
20
+ };
21
+ act?: {
22
+ sub: string;
23
+ };
24
+ /** Forward compatible: unknown extra claims are preserved as-is. */
25
+ [extra: string]: unknown;
26
+ }
27
+ export declare const CLAIM_KEYS: readonly ["role", "app_role", "o", "cv", "act"];
28
+ export declare const REGISTERED_CLAIM_KEYS: readonly ["sub", "iss", "aud", "exp", "iat"];
29
+ /** Payload shape check only. Time and issuer checks are done by jose plus the post-checks in verify.ts. */
30
+ export declare function checkClaimsShape(p: Record<string, unknown>): boolean;
package/dist/claims.js ADDED
@@ -0,0 +1,27 @@
1
+ export const CLAIM_KEYS = ["role", "app_role", "o", "cv", "act"];
2
+ export const REGISTERED_CLAIM_KEYS = ["sub", "iss", "aud", "exp", "iat"];
3
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
+ const isStr = (v) => typeof v === "string";
5
+ /** Payload shape check only. Time and issuer checks are done by jose plus the post-checks in verify.ts. */
6
+ export function checkClaimsShape(p) {
7
+ if (!isStr(p.sub))
8
+ return false;
9
+ if (p.role !== "authenticated")
10
+ return false;
11
+ if (!(p.app_role === null || isStr(p.app_role)))
12
+ return false;
13
+ if (!Number.isInteger(p.cv))
14
+ return false;
15
+ if (p.o !== undefined) {
16
+ const o = p.o;
17
+ if (!isObj(o) || !isStr(o.id) || !isStr(o.slug) || !isStr(o.role))
18
+ return false;
19
+ if (!(o.perms === null || (Array.isArray(o.perms) && o.perms.every(isStr))))
20
+ return false;
21
+ }
22
+ if (p.act !== undefined) {
23
+ if (!isObj(p.act) || !isStr(p.act.sub))
24
+ return false;
25
+ }
26
+ return true;
27
+ }
@@ -0,0 +1,24 @@
1
+ import { type AuthEvent } from "./errors.js";
2
+ export interface AuthClientOptions {
3
+ /** `https://<authHost>`: no trailing slash, no path (same rule as the issuer). */
4
+ authUrl: string;
5
+ /** Set ⇒ bearer mode (`Authorization: Bearer`, `credentials: "omit"`); unset ⇒ cookie mode. */
6
+ sessionToken?: () => string | null | Promise<string | null>;
7
+ /** Called when the response carries a `set-auth-token` header. */
8
+ onSessionToken?: (t: string) => void;
9
+ /** Refresh lead time in seconds; default 120, must be within [10, 600]. */
10
+ refreshAheadS?: number;
11
+ fetch?: typeof fetch;
12
+ /** Per-request timeout in integer milliseconds; default 15_000, must be within [1_000, 60_000]. A timeout counts as `network` and is not retried. */
13
+ timeoutMs?: number;
14
+ onEvent?: (e: AuthEvent) => void;
15
+ }
16
+ export interface AuthClient {
17
+ getToken(): Promise<string>;
18
+ peekToken(): string | null;
19
+ getSession(): Promise<unknown | null>;
20
+ clear(): void;
21
+ }
22
+ /** Retry-After → milliseconds: integer seconds or IMF-fixdate (relative to the local clock), clamped to [0, 10] s; invalid ⇒ 2 s. */
23
+ export declare function retryAfterMs(raw: string): number;
24
+ export declare function createAuthClient(o: AuthClientOptions): AuthClient;
package/dist/client.js ADDED
@@ -0,0 +1,243 @@
1
+ // Client-side token cache. Uses Web APIs only (browser / Node / edge runtime).
2
+ // The client only carries tokens, it never trusts them: no signature check here; verification happens server-side in verifyToken.
3
+ import { TOKEN_TTL_S } from "./constants.js";
4
+ import { RelytAuthError } from "./errors.js";
5
+ import { normalizeIssuer } from "./issuer.js";
6
+ import { decodePayloadUnverified } from "./jwt-payload.js";
7
+ import { num } from "./options.js";
8
+ /** Retry only on "503 with a readable Retry-After", at most 3 retries (4 requests in total). */
9
+ const MAX_RETRIES = 3;
10
+ const RA_MAX_S = 10;
11
+ const RA_INVALID_S = 2;
12
+ const IMF_FIXDATE = /^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
13
+ /** Sentinel used when racing the session-token provider against the timeout (only ever resolves, never rejects, so no unhandled rejection). */
14
+ const ABORTED = Symbol("aborted");
15
+ /** Provider threw / produced an invalid header value: no `cause`, so the original TypeError message cannot leak the session token. */
16
+ const unauth = () => new RelytAuthError("unauthenticated", { retryable: false });
17
+ /** Retry-After → milliseconds: integer seconds or IMF-fixdate (relative to the local clock), clamped to [0, 10] s; invalid ⇒ 2 s. */
18
+ export function retryAfterMs(raw) {
19
+ const s = raw.trim();
20
+ let sec;
21
+ if (/^\d+$/.test(s))
22
+ sec = Number(s);
23
+ else if (IMF_FIXDATE.test(s) && !Number.isNaN(Date.parse(s)))
24
+ sec = (Date.parse(s) - Date.now()) / 1000;
25
+ else
26
+ sec = RA_INVALID_S;
27
+ return Math.round(Math.min(RA_MAX_S, Math.max(0, sec)) * 1000);
28
+ }
29
+ /** Returns `exp − iat` in seconds; undecodable or outside (0, TOKEN_TTL_S] ⇒ null (reported as bad_response, never cached). */
30
+ function lifetimeS(token) {
31
+ if (typeof token !== "string")
32
+ return null;
33
+ const payload = decodePayloadUnverified(token);
34
+ if (payload === null)
35
+ return null;
36
+ const { iat, exp } = payload;
37
+ if (typeof iat !== "number" || typeof exp !== "number")
38
+ return null;
39
+ const d = exp - iat;
40
+ return Number.isFinite(d) && d > 0 && d <= TOKEN_TTL_S ? d : null;
41
+ }
42
+ export function createAuthClient(o) {
43
+ if (typeof o !== "object" || o === null)
44
+ throw new RelytAuthError("config", { option: "authUrl" });
45
+ let base;
46
+ try {
47
+ base = normalizeIssuer(o.authUrl);
48
+ }
49
+ catch {
50
+ throw new RelytAuthError("config", { option: "authUrl" });
51
+ }
52
+ const refreshAheadMs = num(o.refreshAheadS, 120, "refreshAheadS", (n) => n >= 10 && n <= 600) * 1000;
53
+ const timeoutMs = num(o.timeoutMs, 15_000, "timeoutMs", (n) => Number.isInteger(n) && n >= 1_000 && n <= 60_000);
54
+ const doFetch = o.fetch ?? ((input, init) => globalThis.fetch(input, init));
55
+ let cache = null;
56
+ let inflight = null;
57
+ let gen = 0;
58
+ /** One per generation: aborted by clear() to cancel pending backoff waits. */
59
+ let genAc = new AbortController();
60
+ const emit = (e) => {
61
+ try {
62
+ o.onEvent?.(e);
63
+ }
64
+ catch {
65
+ /* a throwing callback must not affect token retrieval */
66
+ }
67
+ };
68
+ /** Generation-guarded write: results of a generation that predates clear() are never stored. */
69
+ const store = (g, token, d, receivedAt) => {
70
+ if (g === gen)
71
+ cache = { token, expiresAtLocal: receivedAt + d * 1000 };
72
+ };
73
+ /**
74
+ * Sends one request and reads the whole body. The timeout uses AbortController + setTimeout, equivalent to
75
+ * `AbortSignal.timeout(timeoutMs)` but clearable on completion; any rejection (including timeout / TypeError) ⇒ network, no retry.
76
+ */
77
+ async function request(path) {
78
+ // Start the timer first: the time spent in sessionToken() counts towards timeoutMs.
79
+ const ac = new AbortController();
80
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
81
+ try {
82
+ const headers = new Headers();
83
+ let credentials = "include";
84
+ if (o.sessionToken) {
85
+ credentials = "omit";
86
+ const provider = o.sessionToken;
87
+ const aborted = new Promise((r) => ac.signal.addEventListener("abort", () => r(ABORTED), { once: true }));
88
+ let st;
89
+ try {
90
+ st = await Promise.race([Promise.resolve().then(() => provider()), aborted]);
91
+ }
92
+ catch {
93
+ throw unauth();
94
+ }
95
+ if (st === ABORTED)
96
+ throw new RelytAuthError("network");
97
+ if (typeof st === "string" && st !== "") {
98
+ try {
99
+ headers.set("authorization", `Bearer ${st}`);
100
+ }
101
+ catch {
102
+ throw unauth();
103
+ }
104
+ }
105
+ }
106
+ try {
107
+ const res = await doFetch(`${base}${path}`, { method: "GET", headers, credentials, signal: ac.signal });
108
+ const receivedAt = Date.now();
109
+ const body = await res.text();
110
+ return { res, body, receivedAt };
111
+ }
112
+ catch {
113
+ throw new RelytAuthError("network");
114
+ }
115
+ }
116
+ finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
120
+ /** Backoff wait; an abort triggered by clear() clears the timer and rejects with unauthenticated. */
121
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
122
+ if (signal.aborted)
123
+ return reject(unauth());
124
+ const onAbort = () => {
125
+ clearTimeout(t);
126
+ reject(unauth());
127
+ };
128
+ const t = setTimeout(() => {
129
+ signal.removeEventListener("abort", onAbort);
130
+ resolve();
131
+ }, ms);
132
+ signal.addEventListener("abort", onAbort, { once: true });
133
+ });
134
+ async function refresh(g) {
135
+ const signal = genAc.signal;
136
+ for (let attempt = 0;; attempt++) {
137
+ if (g !== gen)
138
+ throw new RelytAuthError("unauthenticated", { retryable: false });
139
+ const { res, body, receivedAt } = await request("/api/auth/token");
140
+ if (res.status === 200) {
141
+ let token;
142
+ try {
143
+ token = JSON.parse(body)?.token;
144
+ }
145
+ catch {
146
+ token = undefined;
147
+ }
148
+ const d = lifetimeS(token);
149
+ if (d === null) {
150
+ emit({ type: "token_refresh", code: "bad_response", status: 200, attempt });
151
+ throw new RelytAuthError("bad_response", { status: 200 });
152
+ }
153
+ store(g, token, d, receivedAt);
154
+ emit({ type: "token_refresh", status: 200, attempt });
155
+ return token;
156
+ }
157
+ if (res.status === 401 || res.status === 403) {
158
+ if (g === gen)
159
+ cache = null;
160
+ emit({ type: "token_refresh", code: "unauthenticated", status: res.status, attempt });
161
+ throw new RelytAuthError("unauthenticated", { status: res.status });
162
+ }
163
+ if (res.status === 503) {
164
+ // Decided by the response header only, never by the body code. If CORS hides the header, get() is null ⇒ no retry.
165
+ const ra = res.headers.get("retry-after");
166
+ if (ra !== null && attempt < MAX_RETRIES) {
167
+ if (g !== gen)
168
+ throw unauth();
169
+ emit({ type: "retry", status: 503, attempt: attempt + 1 });
170
+ await sleep(retryAfterMs(ra), signal);
171
+ continue;
172
+ }
173
+ emit({ type: "token_refresh", code: "unavailable", status: 503, attempt });
174
+ throw new RelytAuthError("unavailable", { status: 503, retryable: ra !== null });
175
+ }
176
+ emit({ type: "token_refresh", code: "bad_response", status: res.status, attempt });
177
+ throw new RelytAuthError("bad_response", { status: res.status, retryable: false });
178
+ }
179
+ }
180
+ return {
181
+ getToken() {
182
+ if (cache && cache.expiresAtLocal - Date.now() > refreshAheadMs)
183
+ return Promise.resolve(cache.token);
184
+ if (inflight)
185
+ return inflight;
186
+ const p = refresh(gen).finally(() => {
187
+ if (inflight === p)
188
+ inflight = null;
189
+ });
190
+ inflight = p;
191
+ return p;
192
+ },
193
+ peekToken() {
194
+ return cache && cache.expiresAtLocal > Date.now() ? cache.token : null;
195
+ },
196
+ async getSession() {
197
+ const g = gen;
198
+ const { res, body, receivedAt } = await request("/api/auth/get-session");
199
+ if (res.status === 401 || res.status === 403) {
200
+ if (g === gen)
201
+ cache = null;
202
+ return null;
203
+ }
204
+ if (res.status === 503)
205
+ throw new RelytAuthError("unavailable", { status: 503, retryable: res.headers.get("retry-after") !== null });
206
+ if (res.status !== 200)
207
+ throw new RelytAuthError("bad_response", { status: res.status, retryable: false });
208
+ let data;
209
+ try {
210
+ data = JSON.parse(body);
211
+ }
212
+ catch {
213
+ throw new RelytAuthError("bad_response", { status: 200 });
214
+ }
215
+ const st = res.headers.get("set-auth-token");
216
+ if (g === gen && st !== null && st !== "" && o.onSessionToken) {
217
+ try {
218
+ o.onSessionToken(st);
219
+ }
220
+ catch {
221
+ /* a throwing application callback must not affect the result */
222
+ }
223
+ }
224
+ if (data === null) {
225
+ if (g === gen)
226
+ cache = null;
227
+ return null;
228
+ }
229
+ const jwt = res.headers.get("set-auth-jwt");
230
+ const d = lifetimeS(jwt);
231
+ if (jwt !== null && d !== null)
232
+ store(g, jwt, d, receivedAt);
233
+ return data;
234
+ },
235
+ clear() {
236
+ cache = null;
237
+ inflight = null;
238
+ gen += 1;
239
+ genAc.abort();
240
+ genAc = new AbortController();
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,4 @@
1
+ /** Upper bound of a token's issued lifetime in seconds: default `maxTokenAgeS` for verification and the client's `exp − iat` upper bound. */
2
+ export declare const TOKEN_TTL_S = 900;
3
+ /** Upper bound of the clock tolerance in seconds: how long the auth service keeps a retired key in its JWKS after rotation, beyond the token TTL. */
4
+ export declare const MAX_CLOCK_TOLERANCE_S = 60;
@@ -0,0 +1,5 @@
1
+ // Duration constants that must match the Relyt auth service (copied; see test/package.test.ts).
2
+ /** Upper bound of a token's issued lifetime in seconds: default `maxTokenAgeS` for verification and the client's `exp − iat` upper bound. */
3
+ export const TOKEN_TTL_S = 900;
4
+ /** Upper bound of the clock tolerance in seconds: how long the auth service keeps a retired key in its JWKS after rotation, beyond the token TTL. */
5
+ export const MAX_CLOCK_TOLERANCE_S = 60;
@@ -0,0 +1,29 @@
1
+ /** Unified SDK error. `message` is a fixed English phrase per code and never includes tokens, response bodies or URL query strings. */
2
+ export type RelytAuthErrorCode = "unauthenticated" | "unavailable" | "network" | "bad_response" | "invalid_token" | "invalid_claims" | "jwks_unavailable" | "config" | "invalid_handoff_code" | "invalid_origin" | "rate_limited" | "no_pending_sign_in" | "missing_code" | "oauth_error";
3
+ export declare class RelytAuthError extends Error {
4
+ readonly code: RelytAuthErrorCode;
5
+ readonly retryable: boolean;
6
+ readonly status?: number;
7
+ /** `config` only: name of the offending option (option names are not secret). */
8
+ readonly option?: string;
9
+ /** `rate_limited` only: integer seconds from the `retry-after` response header (when present and valid). */
10
+ readonly retryAfterS?: number;
11
+ /** `oauth_error` only: the `error` value from the callback URL, filtered by `^[a-z0-9_]{1,64}$`, otherwise `"unknown"`. */
12
+ readonly oauthError?: string;
13
+ constructor(code: RelytAuthErrorCode, o?: {
14
+ retryable?: boolean;
15
+ status?: number;
16
+ option?: string;
17
+ retryAfterS?: number;
18
+ oauthError?: string;
19
+ });
20
+ }
21
+ /** Event payload: codes and counters only, never a token. */
22
+ export type AuthEvent = {
23
+ type: "token_refresh" | "retry" | "verify_failed" | "jwks_fetch";
24
+ code?: string;
25
+ status?: number;
26
+ attempt?: number;
27
+ kid?: string;
28
+ issuer?: string;
29
+ };