@nexartis/sentinel-sdk 1.6.0 → 1.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.
package/README.md CHANGED
@@ -13,6 +13,7 @@ Sentinel is a passwordless authentication service: users sign in with a one-time
13
13
  ## Table of contents
14
14
 
15
15
  - [What you get](#what-you-get)
16
+ - [Security: signature verification](#security-signature-verification)
16
17
  - [Install](#install)
17
18
  - [Configure](#configure)
18
19
  - [Quickstart](#quickstart)
@@ -37,6 +38,40 @@ Sentinel is a passwordless authentication service: users sign in with a one-time
37
38
 
38
39
  ---
39
40
 
41
+ ## Security: signature verification
42
+
43
+ Since **1.8.0**, the SvelteKit handle verifies every access token's **Ed25519
44
+ (EdDSA) signature** against the auth server's published JWKS before trusting
45
+ the payload. A forged or tampered cookie is treated as unauthenticated — it
46
+ can no longer inject arbitrary `locals.user.roles` (e.g. `admin`) into
47
+ downstream authorization gates.
48
+
49
+ - JWKS is fetched once and cached per isolate (default TTL **10 minutes**;
50
+ override with `jwksCacheTtlMs`). An unknown `kid` triggers one throttled
51
+ refresh to pick up rotated keys.
52
+ - Only `alg: EdDSA` is accepted. `HS256`, `none`, and any other algorithm are
53
+ rejected outright.
54
+ - Verification **never throws**. If the JWKS document is unreachable the
55
+ request fails closed to unauthenticated (not a 500).
56
+
57
+ The JWKS URL defaults to `<apiUrl origin>/.well-known/jwks.json`; override it
58
+ with `jwksUrl` or the `SENTINEL_JWKS_URL` environment variable.
59
+
60
+ > **Backend requirement:** the Sentinel service must expose Ed25519 keys and
61
+ > sign tokens with EdDSA. Legacy `HS256` tokens are rejected — complete the
62
+ > backend Ed25519 migration before upgrading to this SDK version.
63
+
64
+ ### Development-only opt-out
65
+
66
+ Setting `SENTINEL_SKIP_JWKS_VERIFY=1` (or `skipJwksVerify: true`) restores the
67
+ pre-1.8.0 behaviour of decoding the payload without verifying the signature.
68
+ This is an escape hatch for local development against a backend that has not
69
+ yet been migrated. It logs a loud warning per isolate and **must never be
70
+ enabled in production** — it reopens the exact unverified-payload hole this
71
+ release closes.
72
+
73
+ ---
74
+
40
75
  ## Install
41
76
 
42
77
  ```bash
@@ -64,7 +99,7 @@ The SDK does not bake in any backend URL. Every request is directed at the URL *
64
99
  | `apiUrl` | Base URL of the Sentinel auth backend (e.g. `https://auth.example.com`). |
65
100
  | `baseUrl` | Public base URL of your own app (e.g. `https://app.example.com`) — used when constructing magic-link callback URLs. |
66
101
 
67
- Expose them via SvelteKit environment (`$env/static/private` for `apiUrl`, `$env/static/public` for the browser-visible base URL) and pass them into `createSentinelHandle` / `createAuthHandlers`.
102
+ Expose them via SvelteKit environment (`$env/static/private` for `apiUrl`, `$env/static/public` for the browser-visible base URL) and pass them into `createSentinelHandle` as top-level `apiUrl` / `baseUrl` options. `createAuthHandlers` reads `SENTINEL_API_URL` plus `VITE_BASE_URL` or `PUBLIC_BASE_URL` from `event.platform.env` (Workers) and falls back to the same process env names on Node / `adapter-node`.
68
103
 
69
104
  ```bash
70
105
  # .env
@@ -85,7 +120,8 @@ import { SENTINEL_API_URL } from '$env/static/private';
85
120
  import { PUBLIC_BASE_URL } from '$env/static/public';
86
121
 
87
122
  export const handle = createSentinelHandle({
88
- config: { apiUrl: SENTINEL_API_URL, baseUrl: PUBLIC_BASE_URL },
123
+ apiUrl: SENTINEL_API_URL,
124
+ baseUrl: PUBLIC_BASE_URL,
89
125
  protectedRoutes: ['/dashboard', '/admin'],
90
126
  loginRedirect: '/auth'
91
127
  });
@@ -121,15 +157,15 @@ export const { GET, POST } = createAuthHandlers({
121
157
  ```ts
122
158
  // src/routes/auth/magic/+page.server.ts
123
159
  import { handleMagicLink } from '@nexartis/sentinel-sdk/sveltekit/pages';
124
- export const load = handleMagicLink();
160
+ export const load = handleMagicLink;
125
161
 
126
162
  // src/routes/auth/verify/+page.server.ts
127
163
  import { handleVerifyEmail } from '@nexartis/sentinel-sdk/sveltekit/pages';
128
- export const load = handleVerifyEmail();
164
+ export const load = handleVerifyEmail;
129
165
 
130
166
  // src/routes/auth/verified/+page.server.ts
131
167
  import { handleVerified } from '@nexartis/sentinel-sdk/sveltekit/pages';
132
- export const load = handleVerified();
168
+ export const load = handleVerified;
133
169
  ```
134
170
 
135
171
  ### 4. Call your own APIs with automatic refresh
@@ -171,7 +207,7 @@ const client = new SentinelClient({
171
207
 
172
208
  const decoded = decodeTokenLocally(accessToken);
173
209
  if (isTokenExpired(accessToken)) {
174
- // ... refresh via client.refresh(refreshToken)
210
+ const refreshed = await client.refreshToken(refreshToken);
175
211
  }
176
212
  ```
177
213
 
@@ -9,10 +9,14 @@ export declare class SentinelClient {
9
9
  private request;
10
10
  /** Exponential backoff: 1s, 2s, 4s, ... */
11
11
  private backoff;
12
- startLogin(email: string): Promise<StartLoginResponse>;
12
+ startLogin(email: string, options?: {
13
+ redirectContext?: string;
14
+ }): Promise<StartLoginResponse>;
13
15
  pollStatus(transactionId: string): Promise<PollResponse>;
14
16
  claimTokens(transactionId: string, clientToken: string): Promise<ClaimResponse>;
15
- register(email: string): Promise<RegisterResponse>;
17
+ register(email: string, options?: {
18
+ redirectContext?: string;
19
+ }): Promise<RegisterResponse>;
16
20
  verifyEmail(token: string): Promise<VerifyResponse>;
17
21
  completePasswordless(magicToken: string): Promise<CompletePasswordlessResponse>;
18
22
  refreshToken(refreshToken: string): Promise<RefreshResponse>;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/core/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACX,cAAc,EACd,kBAAkB,EAClB,YAAY,EACZ,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,cAAc,EACd,4BAA4B,EAC5B,MAAM,YAAY,CAAC;AAOpB,qBAAa,cAAc;IAC1B,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,GAAG,CAAiB;gBAEhB,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,OAAO,UAAU,CAAC,KAAa;YAQ9D,OAAO;IA0DrB,2CAA2C;IAC3C,OAAO,CAAC,OAAO;IAKT,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAUtD,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAOxD,WAAW,CAAC,aAAa,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAO/E,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAUlD,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAOnD,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,4BAA4B,CAAC;IAO/E,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAO5D,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAS7D,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;CAQvE"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/core/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACX,cAAc,EACd,kBAAkB,EAClB,YAAY,EACZ,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,cAAc,EACd,4BAA4B,EAC5B,MAAM,YAAY,CAAC;AAOpB,qBAAa,cAAc;IAC1B,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,GAAG,CAAiB;gBAEhB,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,OAAO,UAAU,CAAC,KAAa;YAQ9D,OAAO;IA0DrB,2CAA2C;IAC3C,OAAO,CAAC,OAAO;IAKT,UAAU,CACf,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,GACpC,OAAO,CAAC,kBAAkB,CAAC;IAcxB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAOxD,WAAW,CAAC,aAAa,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAO/E,QAAQ,CACb,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,GACpC,OAAO,CAAC,gBAAgB,CAAC;IActB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAOnD,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,4BAA4B,CAAC;IAO/E,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAO5D,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAS7D,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;CAQvE"}
@@ -69,13 +69,17 @@ export class SentinelClient {
69
69
  const delay = Math.min(1000 * Math.pow(2, attempt), 8000);
70
70
  return new Promise(resolve => setTimeout(resolve, delay));
71
71
  }
72
- async startLogin(email) {
72
+ async startLogin(email, options) {
73
+ const body = {
74
+ email,
75
+ email_endpoint: this.config.baseUrl
76
+ };
77
+ if (options?.redirectContext !== undefined) {
78
+ body.redirect_context = options.redirectContext;
79
+ }
73
80
  return this.request('/auth/login-passwordless', {
74
81
  method: 'POST',
75
- body: JSON.stringify({
76
- email,
77
- email_endpoint: this.config.baseUrl
78
- })
82
+ body: JSON.stringify(body)
79
83
  });
80
84
  }
81
85
  async pollStatus(transactionId) {
@@ -90,13 +94,17 @@ export class SentinelClient {
90
94
  body: JSON.stringify({ transactionId, clientToken })
91
95
  });
92
96
  }
93
- async register(email) {
97
+ async register(email, options) {
98
+ const body = {
99
+ email,
100
+ email_endpoint: this.config.baseUrl
101
+ };
102
+ if (options?.redirectContext !== undefined) {
103
+ body.redirect_context = options.redirectContext;
104
+ }
94
105
  return this.request('/auth/register', {
95
106
  method: 'POST',
96
- body: JSON.stringify({
97
- email,
98
- email_endpoint: this.config.baseUrl
99
- })
107
+ body: JSON.stringify(body)
100
108
  });
101
109
  }
102
110
  async verifyEmail(token) {
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/core/client.ts"],"names":[],"mappings":"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","sourcesContent":["// src/core/client.ts\nimport type {\n\tSentinelConfig,\n\tStartLoginResponse,\n\tPollResponse,\n\tClaimResponse,\n\tRefreshResponse,\n\tValidateResponse,\n\tCurrentUserResponse,\n\tRegisterResponse,\n\tVerifyResponse,\n\tCompletePasswordlessResponse\n} from './types.js';\nimport { SentinelTimeoutError, isRetryableError, isRetryableStatus } from './errors.js';\nimport { createLogger, type SentinelLogger } from './logger.js';\n\nconst DEFAULT_TIMEOUT_MS = 8_000;\nconst DEFAULT_MAX_RETRIES = 1;\n\nexport class SentinelClient {\n\tprivate config: SentinelConfig;\n\tprivate fetch: typeof globalThis.fetch;\n\tprivate timeoutMs: number;\n\tprivate maxRetries: number;\n\tprivate log: SentinelLogger;\n\n\tconstructor(config: SentinelConfig, fetchFn: typeof globalThis.fetch = fetch) {\n\t\tthis.config = config;\n\t\tthis.fetch = fetchFn;\n\t\tthis.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\t\tthis.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;\n\t\tthis.log = config.logger ?? createLogger('sentinel-client');\n\t}\n\n\tprivate async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n\t\tconst url = `${this.config.apiUrl}${endpoint}`;\n\t\tconst headers = {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t...options.headers\n\t\t};\n\n\t\tlet lastError: unknown;\n\n\t\tfor (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n\t\t\ttry {\n\t\t\t\t// Apply per-request timeout via AbortController\n\t\t\t\tconst controller = new AbortController();\n\t\t\t\tconst timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);\n\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await this.fetch(url, {\n\t\t\t\t\t\t...options,\n\t\t\t\t\t\theaders,\n\t\t\t\t\t\tsignal: controller.signal\n\t\t\t\t\t});\n\n\t\t\t\t\tclearTimeout(timeoutId);\n\n\t\t\t\t\t// If the response status is retryable and we have retries left, retry\n\t\t\t\t\tif (isRetryableStatus(response.status) && attempt < this.maxRetries) {\n\t\t\t\t\t\tlastError = new Error(`HTTP ${response.status} from ${url}`);\n\t\t\t\t\t\tawait this.backoff(attempt);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\n\t\t\t\t\treturn await response.json();\n\t\t\t\t} catch (fetchError) {\n\t\t\t\t\tclearTimeout(timeoutId);\n\n\t\t\t\t\t// Convert AbortError to SentinelTimeoutError\n\t\t\t\t\tif (fetchError instanceof DOMException && fetchError.name === 'AbortError') {\n\t\t\t\t\t\tthrow new SentinelTimeoutError(url, this.timeoutMs);\n\t\t\t\t\t}\n\t\t\t\t\tthrow fetchError;\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlastError = error;\n\n\t\t\t\t// Only retry retryable errors\n\t\t\t\tif (attempt < this.maxRetries && isRetryableError(error)) {\n\t\t\t\t\tawait this.backoff(attempt);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t}\n\n\t\t// Should not reach here, but just in case\n\t\tthrow lastError;\n\t}\n\n\t/** Exponential backoff: 1s, 2s, 4s, ... */\n\tprivate backoff(attempt: number): Promise<void> {\n\t\tconst delay = Math.min(1000 * Math.pow(2, attempt), 8000);\n\t\treturn new Promise(resolve => setTimeout(resolve, delay));\n\t}\n\n\tasync startLogin(email: string): Promise<StartLoginResponse> {\n\t\treturn this.request<StartLoginResponse>('/auth/login-passwordless', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({\n\t\t\t\temail,\n\t\t\t\temail_endpoint: this.config.baseUrl\n\t\t\t})\n\t\t});\n\t}\n\n\tasync pollStatus(transactionId: string): Promise<PollResponse> {\n\t\treturn this.request<PollResponse>('/auth/poll', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ transactionId })\n\t\t});\n\t}\n\n\tasync claimTokens(transactionId: string, clientToken: string): Promise<ClaimResponse> {\n\t\treturn this.request<ClaimResponse>('/auth/claim', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ transactionId, clientToken })\n\t\t});\n\t}\n\n\tasync register(email: string): Promise<RegisterResponse> {\n\t\treturn this.request<RegisterResponse>('/auth/register', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({\n\t\t\t\temail,\n\t\t\t\temail_endpoint: this.config.baseUrl\n\t\t\t})\n\t\t});\n\t}\n\n\tasync verifyEmail(token: string): Promise<VerifyResponse> {\n\t\treturn this.request<VerifyResponse>('/auth/verify', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ verification_token: token })\n\t\t});\n\t}\n\n\tasync completePasswordless(magicToken: string): Promise<CompletePasswordlessResponse> {\n\t\treturn this.request<CompletePasswordlessResponse>('/auth/complete-passwordless', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ magic_token: magicToken })\n\t\t});\n\t}\n\n\tasync refreshToken(refreshToken: string): Promise<RefreshResponse> {\n\t\treturn this.request<RefreshResponse>('/auth/refresh', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ refresh_token: refreshToken })\n\t\t});\n\t}\n\n\tasync validateToken(accessToken: string): Promise<ValidateResponse> {\n\t\treturn this.request<ValidateResponse>('/auth/validate', {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {\n\t\t\t\t'Authorization': `Bearer ${accessToken}`\n\t\t\t}\n\t\t});\n\t}\n\n\tasync getCurrentUser(accessToken: string): Promise<CurrentUserResponse> {\n\t\treturn this.request<CurrentUserResponse>('/users/me', {\n\t\t\tmethod: 'GET',\n\t\t\theaders: {\n\t\t\t\t'Authorization': `Bearer ${accessToken}`\n\t\t\t}\n\t\t});\n\t}\n}\n\n"]}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/core/client.ts"],"names":[],"mappings":"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","sourcesContent":["// src/core/client.ts\nimport type {\n\tSentinelConfig,\n\tStartLoginResponse,\n\tPollResponse,\n\tClaimResponse,\n\tRefreshResponse,\n\tValidateResponse,\n\tCurrentUserResponse,\n\tRegisterResponse,\n\tVerifyResponse,\n\tCompletePasswordlessResponse\n} from './types.js';\nimport { SentinelTimeoutError, isRetryableError, isRetryableStatus } from './errors.js';\nimport { createLogger, type SentinelLogger } from './logger.js';\n\nconst DEFAULT_TIMEOUT_MS = 8_000;\nconst DEFAULT_MAX_RETRIES = 1;\n\nexport class SentinelClient {\n\tprivate config: SentinelConfig;\n\tprivate fetch: typeof globalThis.fetch;\n\tprivate timeoutMs: number;\n\tprivate maxRetries: number;\n\tprivate log: SentinelLogger;\n\n\tconstructor(config: SentinelConfig, fetchFn: typeof globalThis.fetch = fetch) {\n\t\tthis.config = config;\n\t\tthis.fetch = fetchFn;\n\t\tthis.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\t\tthis.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;\n\t\tthis.log = config.logger ?? createLogger('sentinel-client');\n\t}\n\n\tprivate async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n\t\tconst url = `${this.config.apiUrl}${endpoint}`;\n\t\tconst headers = {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t...options.headers\n\t\t};\n\n\t\tlet lastError: unknown;\n\n\t\tfor (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n\t\t\ttry {\n\t\t\t\t// Apply per-request timeout via AbortController\n\t\t\t\tconst controller = new AbortController();\n\t\t\t\tconst timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);\n\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await this.fetch(url, {\n\t\t\t\t\t\t...options,\n\t\t\t\t\t\theaders,\n\t\t\t\t\t\tsignal: controller.signal\n\t\t\t\t\t});\n\n\t\t\t\t\tclearTimeout(timeoutId);\n\n\t\t\t\t\t// If the response status is retryable and we have retries left, retry\n\t\t\t\t\tif (isRetryableStatus(response.status) && attempt < this.maxRetries) {\n\t\t\t\t\t\tlastError = new Error(`HTTP ${response.status} from ${url}`);\n\t\t\t\t\t\tawait this.backoff(attempt);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\n\t\t\t\t\treturn await response.json();\n\t\t\t\t} catch (fetchError) {\n\t\t\t\t\tclearTimeout(timeoutId);\n\n\t\t\t\t\t// Convert AbortError to SentinelTimeoutError\n\t\t\t\t\tif (fetchError instanceof DOMException && fetchError.name === 'AbortError') {\n\t\t\t\t\t\tthrow new SentinelTimeoutError(url, this.timeoutMs);\n\t\t\t\t\t}\n\t\t\t\t\tthrow fetchError;\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlastError = error;\n\n\t\t\t\t// Only retry retryable errors\n\t\t\t\tif (attempt < this.maxRetries && isRetryableError(error)) {\n\t\t\t\t\tawait this.backoff(attempt);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t}\n\n\t\t// Should not reach here, but just in case\n\t\tthrow lastError;\n\t}\n\n\t/** Exponential backoff: 1s, 2s, 4s, ... */\n\tprivate backoff(attempt: number): Promise<void> {\n\t\tconst delay = Math.min(1000 * Math.pow(2, attempt), 8000);\n\t\treturn new Promise(resolve => setTimeout(resolve, delay));\n\t}\n\n\tasync startLogin(\n\t\temail: string,\n\t\toptions?: { redirectContext?: string }\n\t): Promise<StartLoginResponse> {\n\t\tconst body: Record<string, unknown> = {\n\t\t\temail,\n\t\t\temail_endpoint: this.config.baseUrl\n\t\t};\n\t\tif (options?.redirectContext !== undefined) {\n\t\t\tbody.redirect_context = options.redirectContext;\n\t\t}\n\t\treturn this.request<StartLoginResponse>('/auth/login-passwordless', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify(body)\n\t\t});\n\t}\n\n\tasync pollStatus(transactionId: string): Promise<PollResponse> {\n\t\treturn this.request<PollResponse>('/auth/poll', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ transactionId })\n\t\t});\n\t}\n\n\tasync claimTokens(transactionId: string, clientToken: string): Promise<ClaimResponse> {\n\t\treturn this.request<ClaimResponse>('/auth/claim', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ transactionId, clientToken })\n\t\t});\n\t}\n\n\tasync register(\n\t\temail: string,\n\t\toptions?: { redirectContext?: string }\n\t): Promise<RegisterResponse> {\n\t\tconst body: Record<string, unknown> = {\n\t\t\temail,\n\t\t\temail_endpoint: this.config.baseUrl\n\t\t};\n\t\tif (options?.redirectContext !== undefined) {\n\t\t\tbody.redirect_context = options.redirectContext;\n\t\t}\n\t\treturn this.request<RegisterResponse>('/auth/register', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify(body)\n\t\t});\n\t}\n\n\tasync verifyEmail(token: string): Promise<VerifyResponse> {\n\t\treturn this.request<VerifyResponse>('/auth/verify', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ verification_token: token })\n\t\t});\n\t}\n\n\tasync completePasswordless(magicToken: string): Promise<CompletePasswordlessResponse> {\n\t\treturn this.request<CompletePasswordlessResponse>('/auth/complete-passwordless', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ magic_token: magicToken })\n\t\t});\n\t}\n\n\tasync refreshToken(refreshToken: string): Promise<RefreshResponse> {\n\t\treturn this.request<RefreshResponse>('/auth/refresh', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: JSON.stringify({ refresh_token: refreshToken })\n\t\t});\n\t}\n\n\tasync validateToken(accessToken: string): Promise<ValidateResponse> {\n\t\treturn this.request<ValidateResponse>('/auth/validate', {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {\n\t\t\t\t'Authorization': `Bearer ${accessToken}`\n\t\t\t}\n\t\t});\n\t}\n\n\tasync getCurrentUser(accessToken: string): Promise<CurrentUserResponse> {\n\t\treturn this.request<CurrentUserResponse>('/users/me', {\n\t\t\tmethod: 'GET',\n\t\t\theaders: {\n\t\t\t\t'Authorization': `Bearer ${accessToken}`\n\t\t\t}\n\t\t});\n\t}\n}\n\n"]}
@@ -2,6 +2,8 @@ export { SentinelClient } from './client.js';
2
2
  export { SentinelError, SentinelTimeoutError, ErrorCode, SentinelCode, isRetryableError, isRetryableStatus } from './errors.js';
3
3
  export type { SentinelCodeValue } from './errors.js';
4
4
  export { validateToken, isTokenExpired, decodeTokenLocally } from './auth-utils.js';
5
+ export { createJwksVerifier, __resetJwksCacheForTests } from './jwks.js';
6
+ export type { JwksVerifier, JwksVerifierOptions, TokenVerifyOutcome } from './jwks.js';
5
7
  export { createLogger, noopLogger } from './logger.js';
6
8
  export type { SentinelLogger, LogLevel, LogEntry } from './logger.js';
7
9
  export type { SentinelConfig, SentinelResponse, SentinelResponseBase, StartLoginResponse, PollResponse, ClaimResponse, RefreshResponse, ValidateResponse, RegisterResponse, VerifyResponse, CompletePasswordlessResponse, DecodedToken, UserLocals, SentinelHandleOptions, AuthHandlerOptions, BeforeLoginContext, BeforeLoginReject, AfterLoginContext, AfterClaimContext, ErrorContext, ResolveRedirectContext, LogoutRedirectContext } from './types.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,SAAS,EAAE,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChI,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACtE,YAAY,EACX,cAAc,EACd,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,YAAY,EACZ,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,cAAc,EACd,4BAA4B,EAC5B,YAAY,EACZ,UAAU,EACV,qBAAqB,EACrB,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,EACZ,sBAAsB,EACtB,qBAAqB,EACrB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,SAAS,EAAE,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChI,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACpF,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAC;AACzE,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AACvF,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACtE,YAAY,EACX,cAAc,EACd,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,YAAY,EACZ,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,cAAc,EACd,4BAA4B,EAC5B,YAAY,EACZ,UAAU,EACV,qBAAqB,EACrB,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,EACZ,sBAAsB,EACtB,qBAAqB,EACrB,MAAM,YAAY,CAAC"}
@@ -2,5 +2,6 @@
2
2
  export { SentinelClient } from './client.js';
3
3
  export { SentinelError, SentinelTimeoutError, ErrorCode, SentinelCode, isRetryableError, isRetryableStatus } from './errors.js';
4
4
  export { validateToken, isTokenExpired, decodeTokenLocally } from './auth-utils.js';
5
+ export { createJwksVerifier, __resetJwksCacheForTests } from './jwks.js';
5
6
  export { createLogger, noopLogger } from './logger.js';
6
7
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,oBAAoB;AACpB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,SAAS,EAAE,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEhI,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC","sourcesContent":["// src/core/index.ts\nexport { SentinelClient } from './client.js';\nexport { SentinelError, SentinelTimeoutError, ErrorCode, SentinelCode, isRetryableError, isRetryableStatus } from './errors.js';\nexport type { SentinelCodeValue } from './errors.js';\nexport { validateToken, isTokenExpired, decodeTokenLocally } from './auth-utils.js';\nexport { createLogger, noopLogger } from './logger.js';\nexport type { SentinelLogger, LogLevel, LogEntry } from './logger.js';\nexport type {\n\tSentinelConfig,\n\tSentinelResponse,\n\tSentinelResponseBase,\n\tStartLoginResponse,\n\tPollResponse,\n\tClaimResponse,\n\tRefreshResponse,\n\tValidateResponse,\n\tRegisterResponse,\n\tVerifyResponse,\n\tCompletePasswordlessResponse,\n\tDecodedToken,\n\tUserLocals,\n\tSentinelHandleOptions,\n\tAuthHandlerOptions,\n\tBeforeLoginContext,\n\tBeforeLoginReject,\n\tAfterLoginContext,\n\tAfterClaimContext,\n\tErrorContext,\n\tResolveRedirectContext,\n\tLogoutRedirectContext\n} from './types.js';\n\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,oBAAoB;AACpB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,SAAS,EAAE,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEhI,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACpF,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAC;AAEzE,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC","sourcesContent":["// src/core/index.ts\nexport { SentinelClient } from './client.js';\nexport { SentinelError, SentinelTimeoutError, ErrorCode, SentinelCode, isRetryableError, isRetryableStatus } from './errors.js';\nexport type { SentinelCodeValue } from './errors.js';\nexport { validateToken, isTokenExpired, decodeTokenLocally } from './auth-utils.js';\nexport { createJwksVerifier, __resetJwksCacheForTests } from './jwks.js';\nexport type { JwksVerifier, JwksVerifierOptions, TokenVerifyOutcome } from './jwks.js';\nexport { createLogger, noopLogger } from './logger.js';\nexport type { SentinelLogger, LogLevel, LogEntry } from './logger.js';\nexport type {\n\tSentinelConfig,\n\tSentinelResponse,\n\tSentinelResponseBase,\n\tStartLoginResponse,\n\tPollResponse,\n\tClaimResponse,\n\tRefreshResponse,\n\tValidateResponse,\n\tRegisterResponse,\n\tVerifyResponse,\n\tCompletePasswordlessResponse,\n\tDecodedToken,\n\tUserLocals,\n\tSentinelHandleOptions,\n\tAuthHandlerOptions,\n\tBeforeLoginContext,\n\tBeforeLoginReject,\n\tAfterLoginContext,\n\tAfterClaimContext,\n\tErrorContext,\n\tResolveRedirectContext,\n\tLogoutRedirectContext\n} from './types.js';\n\n"]}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * JWKS-based access-token signature verification (Ed25519 / EdDSA).
3
+ *
4
+ * Security model: the Sentinel backend signs access tokens with EdDSA and
5
+ * publishes the public key at `/.well-known/jwks.json` (RFC 7517). This
6
+ * module fetches and caches that key set, then verifies each access token's
7
+ * signature with WebCrypto BEFORE the payload is trusted. Tokens that fail
8
+ * verification are treated as unauthenticated — verification never throws.
9
+ *
10
+ * Cache behaviour:
11
+ * - JWKS documents are cached per isolate, keyed by URL, with a TTL
12
+ * (default 10 minutes).
13
+ * - A token whose `kid` is not in the cache triggers ONE forced refresh
14
+ * (key rotation support), throttled to at most once per
15
+ * `refreshMinIntervalMs` (default 10 seconds) so unknown-kid floods cannot
16
+ * be used to hammer the auth server.
17
+ * - JWKS fetch failure classifies as `unavailable` (treat as
18
+ * unauthenticated); it never surfaces as a 500.
19
+ *
20
+ * Only OKP/Ed25519 keys with `alg: EdDSA` semantics are accepted. Tokens
21
+ * with any other algorithm (including legacy HS256) are rejected as
22
+ * `invalid` — a symmetric HS256 secret can never be verified from a public
23
+ * JWKS, and accepting it here would reintroduce the unverified-payload hole.
24
+ */
25
+ import type { DecodedToken } from './types.js';
26
+ import type { SentinelLogger } from './logger.js';
27
+ export interface JwksVerifierOptions {
28
+ /** Full URL of the JWKS document, e.g. https://auth.example.com/.well-known/jwks.json */
29
+ jwksUrl: string;
30
+ /** Fetch implementation. Defaults to globalThis.fetch (pass event.fetch in SvelteKit handles). */
31
+ fetchFn?: typeof globalThis.fetch;
32
+ /** Cache TTL in milliseconds. Default: 600_000 (10 minutes). */
33
+ cacheTtlMs?: number;
34
+ /** Minimum interval between forced refreshes for unknown kids, in ms. Default: 10_000. */
35
+ refreshMinIntervalMs?: number;
36
+ /** Timeout for each JWKS fetch, in ms. Default: 5_000. */
37
+ timeoutMs?: number;
38
+ /** Optional structured logger. */
39
+ logger?: SentinelLogger;
40
+ }
41
+ export type TokenVerifyOutcome =
42
+ /** Signature valid, payload structurally sound and not expired. */
43
+ {
44
+ status: 'valid';
45
+ payload: DecodedToken;
46
+ }
47
+ /**
48
+ * Token rejected: bad signature, malformed token, unsupported alg,
49
+ * unknown kid (after refresh), expired, or structurally invalid payload.
50
+ * Treat the request as unauthenticated.
51
+ */
52
+ | {
53
+ status: 'invalid';
54
+ reason: string;
55
+ }
56
+ /**
57
+ * Verification could not run because the JWKS document is unreachable or
58
+ * unusable. Treat the request as unauthenticated (fail closed) — never a 500.
59
+ */
60
+ | {
61
+ status: 'unavailable';
62
+ reason: string;
63
+ };
64
+ export interface JwksVerifier {
65
+ verify(token: string): Promise<TokenVerifyOutcome>;
66
+ /** Test-only: drop cached JWKS state for this verifier's URL. */
67
+ _resetCacheForTests(): void;
68
+ }
69
+ /** Test-only: drop all cached JWKS state. */
70
+ export declare function __resetJwksCacheForTests(): void;
71
+ /**
72
+ * Create a JWKS verifier bound to one JWKS URL. The underlying key cache is
73
+ * shared per isolate across verifiers with the same URL.
74
+ */
75
+ export declare function createJwksVerifier(userOptions: JwksVerifierOptions): JwksVerifier;
76
+ //# sourceMappingURL=jwks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwks.d.ts","sourceRoot":"","sources":["../../src/core/jwks.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAGlD,MAAM,WAAW,mBAAmB;IACnC,yFAAyF;IACzF,OAAO,EAAE,MAAM,CAAC;IAChB,kGAAkG;IAClG,OAAO,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAClC,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0FAA0F;IAC1F,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kCAAkC;IAClC,MAAM,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,MAAM,MAAM,kBAAkB;AAC7B,mEAAmE;AACjE;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,YAAY,CAAA;CAAE;AAC5C;;;;GAIG;GACD;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AACvC;;;GAGG;GACD;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7C,MAAM,WAAW,YAAY;IAC5B,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACnD,iEAAiE;IACjE,mBAAmB,IAAI,IAAI,CAAC;CAC5B;AA2BD,6CAA6C;AAC7C,wBAAgB,wBAAwB,IAAI,IAAI,CAI/C;AAoJD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,mBAAmB,GAAG,YAAY,CAgFjF"}
@@ -0,0 +1,231 @@
1
+ // src/core/jwks.ts
2
+ import { createLogger } from './logger.js';
3
+ // Per-isolate cache shared across verifiers with the same JWKS URL.
4
+ const jwksCache = new Map();
5
+ // Per-URL in-flight fetch dedup (avoids thundering herd on cold cache).
6
+ const inflightFetches = new Map();
7
+ // Per-URL throttle markers for forced refreshes on unknown kid.
8
+ const lastForcedRefreshAt = new Map();
9
+ /** Test-only: drop all cached JWKS state. */
10
+ export function __resetJwksCacheForTests() {
11
+ jwksCache.clear();
12
+ inflightFetches.clear();
13
+ lastForcedRefreshAt.clear();
14
+ }
15
+ function base64UrlDecodeToBytes(input) {
16
+ let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
17
+ const pad = base64.length % 4;
18
+ if (pad)
19
+ base64 += '='.repeat(4 - pad);
20
+ const binary = atob(base64);
21
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
22
+ for (let i = 0; i < binary.length; i += 1) {
23
+ bytes[i] = binary.charCodeAt(i);
24
+ }
25
+ return bytes;
26
+ }
27
+ function base64UrlDecodeToString(input) {
28
+ // Decode via Uint8Array + TextDecoder for proper UTF-8 handling.
29
+ return new TextDecoder('utf-8').decode(base64UrlDecodeToBytes(input));
30
+ }
31
+ async function importPublicEd25519Key(jwk) {
32
+ if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string' || jwk.x.length === 0) {
33
+ return null;
34
+ }
35
+ try {
36
+ return await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, { name: 'Ed25519' }, false, ['verify']);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ async function fetchJwksDocument(jwksUrl, fetchFn, timeoutMs, log) {
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
45
+ try {
46
+ const response = await fetchFn(jwksUrl, {
47
+ signal: controller.signal,
48
+ headers: { accept: 'application/json' }
49
+ });
50
+ if (!response.ok) {
51
+ log.warn('JWKS fetch returned non-OK status', { jwksUrl, status: response.status });
52
+ return null;
53
+ }
54
+ const document = (await response.json());
55
+ if (!Array.isArray(document.keys)) {
56
+ log.warn('JWKS document has no keys array', { jwksUrl });
57
+ return null;
58
+ }
59
+ const keys = new Map();
60
+ for (const rawKey of document.keys) {
61
+ if (typeof rawKey?.kid !== 'string' || rawKey.kid.length === 0)
62
+ continue;
63
+ const imported = await importPublicEd25519Key(rawKey);
64
+ if (imported)
65
+ keys.set(rawKey.kid, imported);
66
+ }
67
+ if (keys.size === 0) {
68
+ log.warn('JWKS document contained no usable Ed25519 keys', { jwksUrl });
69
+ return null;
70
+ }
71
+ return { keys, fetchedAt: Date.now() };
72
+ }
73
+ catch (error) {
74
+ log.warn('JWKS fetch failed', { jwksUrl, error: String(error) });
75
+ return null;
76
+ }
77
+ finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }
81
+ async function getJwksEntry(options, force, log) {
82
+ const { jwksUrl, cacheTtlMs, refreshMinIntervalMs, timeoutMs } = options;
83
+ const fetchFn = options.fetchFn ?? globalThis.fetch;
84
+ const now = Date.now();
85
+ const cached = jwksCache.get(jwksUrl);
86
+ if (!force && cached && now - cached.fetchedAt < cacheTtlMs) {
87
+ return cached;
88
+ }
89
+ if (force) {
90
+ const lastForced = lastForcedRefreshAt.get(jwksUrl) ?? 0;
91
+ if (now - lastForced < refreshMinIntervalMs) {
92
+ // Throttled: report whatever we have (possibly nothing).
93
+ return cached ?? null;
94
+ }
95
+ lastForcedRefreshAt.set(jwksUrl, now);
96
+ }
97
+ const inflight = inflightFetches.get(jwksUrl);
98
+ if (inflight)
99
+ return inflight;
100
+ const pending = fetchJwksDocument(jwksUrl, fetchFn, timeoutMs, log)
101
+ .then((entry) => {
102
+ if (entry)
103
+ jwksCache.set(jwksUrl, entry);
104
+ return entry;
105
+ })
106
+ .finally(() => {
107
+ inflightFetches.delete(jwksUrl);
108
+ });
109
+ inflightFetches.set(jwksUrl, pending);
110
+ return pending;
111
+ }
112
+ function parseTokenHeader(token) {
113
+ try {
114
+ const parts = token.split('.');
115
+ if (parts.length !== 3)
116
+ return null;
117
+ const header = JSON.parse(base64UrlDecodeToString(parts[0]));
118
+ return typeof header === 'object' && header !== null ? header : null;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ function parseAndValidatePayload(payloadPart) {
125
+ try {
126
+ const payload = JSON.parse(base64UrlDecodeToString(payloadPart));
127
+ if (typeof payload !== 'object' || payload === null)
128
+ return null;
129
+ if (typeof payload.sub !== 'string' || payload.sub.length === 0)
130
+ return null;
131
+ if (typeof payload.email !== 'string' || payload.email.length === 0)
132
+ return null;
133
+ if (typeof payload.exp !== 'number' || isNaN(payload.exp))
134
+ return null;
135
+ if (typeof payload.iat !== 'number' || isNaN(payload.iat))
136
+ return null;
137
+ return {
138
+ sub: payload.sub,
139
+ email: payload.email,
140
+ roles: Array.isArray(payload.roles)
141
+ ? payload.roles.filter((r) => typeof r === 'string')
142
+ : [],
143
+ exp: payload.exp,
144
+ iat: payload.iat
145
+ };
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ /**
152
+ * Create a JWKS verifier bound to one JWKS URL. The underlying key cache is
153
+ * shared per isolate across verifiers with the same URL.
154
+ */
155
+ export function createJwksVerifier(userOptions) {
156
+ const options = {
157
+ jwksUrl: userOptions.jwksUrl,
158
+ fetchFn: userOptions.fetchFn,
159
+ cacheTtlMs: userOptions.cacheTtlMs ?? 600_000,
160
+ refreshMinIntervalMs: userOptions.refreshMinIntervalMs ?? 10_000,
161
+ timeoutMs: userOptions.timeoutMs ?? 5_000,
162
+ logger: userOptions.logger
163
+ };
164
+ const log = options.logger ?? createLogger('sentinel-jwks');
165
+ async function resolveKey(kid) {
166
+ let entry = await getJwksEntry(options, false, log);
167
+ if (entry?.keys.has(kid))
168
+ return { key: entry.keys.get(kid), entry };
169
+ // Unknown kid (or no cache): one throttled forced refresh for key rotation.
170
+ entry = await getJwksEntry(options, true, log);
171
+ if (entry?.keys.has(kid))
172
+ return { key: entry.keys.get(kid), entry };
173
+ return { entry };
174
+ }
175
+ return {
176
+ async verify(token) {
177
+ try {
178
+ const parts = token.split('.');
179
+ if (parts.length !== 3) {
180
+ return { status: 'invalid', reason: 'malformed token structure' };
181
+ }
182
+ const [headerPart, payloadPart, signaturePart] = parts;
183
+ const header = parseTokenHeader(token);
184
+ if (!header) {
185
+ return { status: 'invalid', reason: 'unparseable token header' };
186
+ }
187
+ if (header.alg !== 'EdDSA') {
188
+ return {
189
+ status: 'invalid',
190
+ reason: `unsupported token alg "${String(header.alg)}" — only EdDSA tokens can be JWKS-verified (backend must be migrated from HS256)`
191
+ };
192
+ }
193
+ if (typeof header.kid !== 'string' || header.kid.length === 0) {
194
+ return { status: 'invalid', reason: 'token header missing kid' };
195
+ }
196
+ const { key, entry } = await resolveKey(header.kid);
197
+ if (!entry) {
198
+ return { status: 'unavailable', reason: 'JWKS document unreachable' };
199
+ }
200
+ if (!key) {
201
+ return { status: 'invalid', reason: `unknown kid "${header.kid}" (after JWKS refresh)` };
202
+ }
203
+ const signature = base64UrlDecodeToBytes(signaturePart);
204
+ const data = new TextEncoder().encode(`${headerPart}.${payloadPart}`);
205
+ const signatureValid = await crypto.subtle.verify('Ed25519', key, signature, data);
206
+ if (!signatureValid) {
207
+ return { status: 'invalid', reason: 'signature verification failed' };
208
+ }
209
+ const payload = parseAndValidatePayload(payloadPart);
210
+ if (!payload) {
211
+ return { status: 'invalid', reason: 'payload missing required claims' };
212
+ }
213
+ if (Date.now() >= payload.exp * 1000) {
214
+ return { status: 'invalid', reason: 'token expired' };
215
+ }
216
+ return { status: 'valid', payload };
217
+ }
218
+ catch (error) {
219
+ // Never throw out of verification — classify and fail closed.
220
+ log.error('Unexpected JWKS verification error', { error: String(error) });
221
+ return { status: 'invalid', reason: 'verification error' };
222
+ }
223
+ },
224
+ _resetCacheForTests() {
225
+ jwksCache.delete(options.jwksUrl);
226
+ lastForcedRefreshAt.delete(options.jwksUrl);
227
+ inflightFetches.delete(options.jwksUrl);
228
+ }
229
+ };
230
+ }
231
+ //# sourceMappingURL=jwks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwks.js","sourceRoot":"","sources":["../../src/core/jwks.ts"],"names":[],"mappings":"AAAA,mBAAmB;AA6BnB,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAwD3C,oEAAoE;AACpE,MAAM,SAAS,GAAG,IAAI,GAAG,EAA0B,CAAC;AACpD,wEAAwE;AACxE,MAAM,eAAe,GAAG,IAAI,GAAG,EAA0C,CAAC;AAC1E,gEAAgE;AAChE,MAAM,mBAAmB,GAAG,IAAI,GAAG,EAAkB,CAAC;AAEtD,6CAA6C;AAC7C,MAAM,UAAU,wBAAwB;IACvC,SAAS,CAAC,KAAK,EAAE,CAAC;IAClB,eAAe,CAAC,KAAK,EAAE,CAAC;IACxB,mBAAmB,CAAC,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,SAAS,sBAAsB,CAAC,KAAa;IAC5C,IAAI,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACzD,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;IAC9B,IAAI,GAAG;QAAE,MAAM,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC;IAC5B,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,IAAI,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IAC7D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3C,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IACjC,CAAC;IACD,OAAO,KAAK,CAAC;AACd,CAAC;AAED,SAAS,uBAAuB,CAAC,KAAa;IAC7C,iEAAiE;IACjE,OAAO,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,sBAAsB,CAAC,KAAK,CAAC,CAAC,CAAC;AACvE,CAAC;AAED,KAAK,UAAU,sBAAsB,CAAC,GAAiB;IACtD,IAAI,GAAG,CAAC,GAAG,KAAK,KAAK,IAAI,GAAG,CAAC,GAAG,KAAK,SAAS,IAAI,OAAO,GAAG,CAAC,CAAC,KAAK,QAAQ,IAAI,GAAG,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACnG,OAAO,IAAI,CAAC;IACb,CAAC;IACD,IAAI,CAAC;QACJ,OAAO,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CACnC,KAAK,EACL,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,EACxC,EAAE,IAAI,EAAE,SAAS,EAAE,EACnB,KAAK,EACL,CAAC,QAAQ,CAAC,CACV,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED,KAAK,UAAU,iBAAiB,CAC/B,OAAe,EACf,OAAgC,EAChC,SAAiB,EACjB,GAAmB;IAEnB,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,SAAS,CAAC,CAAC;IAC9D,IAAI,CAAC;QACJ,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE;YACvC,MAAM,EAAE,UAAU,CAAC,MAAM;YACzB,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE;SACvC,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YAClB,GAAG,CAAC,IAAI,CAAC,mCAAmC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;YACpF,OAAO,IAAI,CAAC;QACb,CAAC;QACD,MAAM,QAAQ,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAsB,CAAC;QAC9D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YACnC,GAAG,CAAC,IAAI,CAAC,iCAAiC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;YACzD,OAAO,IAAI,CAAC;QACb,CAAC;QACD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAqB,CAAC;QAC1C,KAAK,MAAM,MAAM,IAAI,QAAQ,CAAC,IAAsB,EAAE,CAAC;YACtD,IAAI,OAAO,MAAM,EAAE,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,GAAG,CAAC,MAAM,KAAK,CAAC;gBAAE,SAAS;YACzE,MAAM,QAAQ,GAAG,MAAM,sBAAsB,CAAC,MAAM,CAAC,CAAC;YACtD,IAAI,QAAQ;gBAAE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;QAC9C,CAAC;QACD,IAAI,IAAI,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;YACrB,GAAG,CAAC,IAAI,CAAC,gDAAgD,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;YACxE,OAAO,IAAI,CAAC;QACb,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;IACxC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,GAAG,CAAC,IAAI,CAAC,mBAAmB,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACjE,OAAO,IAAI,CAAC;IACb,CAAC;YAAS,CAAC;QACV,YAAY,CAAC,KAAK,CAAC,CAAC;IACrB,CAAC;AACF,CAAC;AAED,KAAK,UAAU,YAAY,CAC1B,OAAmI,EACnI,KAAc,EACd,GAAmB;IAEnB,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,oBAAoB,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IACzE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,UAAU,CAAC,KAAK,CAAC;IACpD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAEvB,MAAM,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACtC,IAAI,CAAC,KAAK,IAAI,MAAM,IAAI,GAAG,GAAG,MAAM,CAAC,SAAS,GAAG,UAAU,EAAE,CAAC;QAC7D,OAAO,MAAM,CAAC;IACf,CAAC;IAED,IAAI,KAAK,EAAE,CAAC;QACX,MAAM,UAAU,GAAG,mBAAmB,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACzD,IAAI,GAAG,GAAG,UAAU,GAAG,oBAAoB,EAAE,CAAC;YAC7C,yDAAyD;YACzD,OAAO,MAAM,IAAI,IAAI,CAAC;QACvB,CAAC;QACD,mBAAmB,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAE9B,MAAM,OAAO,GAAG,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,CAAC;SACjE,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE;QACf,IAAI,KAAK;YAAE,SAAS,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACzC,OAAO,KAAK,CAAC;IACd,CAAC,CAAC;SACD,OAAO,CAAC,GAAG,EAAE;QACb,eAAe,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACjC,CAAC,CAAC,CAAC;IACJ,eAAe,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACtC,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAa;IACtC,IAAI,CAAC;QACJ,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACpC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7D,OAAO,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,CAAC,CAAC,CAAE,MAA2C,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5G,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED,SAAS,uBAAuB,CAAC,WAAmB;IACnD,IAAI,CAAC;QACJ,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,uBAAuB,CAAC,WAAW,CAAC,CAAC,CAAC;QACjE,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QACjE,IAAI,OAAO,OAAO,CAAC,GAAG,KAAK,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAC7E,IAAI,OAAO,OAAO,CAAC,KAAK,KAAK,QAAQ,IAAI,OAAO,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACjF,IAAI,OAAO,OAAO,CAAC,GAAG,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC;QACvE,IAAI,OAAO,OAAO,CAAC,GAAG,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC;QACvE,OAAO;YACN,GAAG,EAAE,OAAO,CAAC,GAAG;YAChB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC;gBAClC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAU,EAAe,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC;gBAC1E,CAAC,CAAC,EAAE;YACL,GAAG,EAAE,OAAO,CAAC,GAAG;YAChB,GAAG,EAAE,OAAO,CAAC,GAAG;SAChB,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,WAAgC;IAClE,MAAM,OAAO,GAAG;QACf,OAAO,EAAE,WAAW,CAAC,OAAO;QAC5B,OAAO,EAAE,WAAW,CAAC,OAAO;QAC5B,UAAU,EAAE,WAAW,CAAC,UAAU,IAAI,OAAO;QAC7C,oBAAoB,EAAE,WAAW,CAAC,oBAAoB,IAAI,MAAM;QAChE,SAAS,EAAE,WAAW,CAAC,SAAS,IAAI,KAAK;QACzC,MAAM,EAAE,WAAW,CAAC,MAAM;KAC1B,CAAC;IACF,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,IAAI,YAAY,CAAC,eAAe,CAAC,CAAC;IAE5D,KAAK,UAAU,UAAU,CAAC,GAAW;QACpC,IAAI,KAAK,GAAG,MAAM,YAAY,CAAC,OAAO,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;QACpD,IAAI,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,OAAO,EAAE,GAAG,EAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC;QAErE,4EAA4E;QAC5E,KAAK,GAAG,MAAM,YAAY,CAAC,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC;QAC/C,IAAI,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,OAAO,EAAE,GAAG,EAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC;QACrE,OAAO,EAAE,KAAK,EAAE,CAAC;IAClB,CAAC;IAED,OAAO;QACN,KAAK,CAAC,MAAM,CAAC,KAAa;YACzB,IAAI,CAAC;gBACJ,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;gBAC/B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBACxB,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,2BAA2B,EAAE,CAAC;gBACnE,CAAC;gBACD,MAAM,CAAC,UAAU,EAAE,WAAW,EAAE,aAAa,CAAC,GAAG,KAAK,CAAC;gBAEvD,MAAM,MAAM,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;gBACvC,IAAI,CAAC,MAAM,EAAE,CAAC;oBACb,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,0BAA0B,EAAE,CAAC;gBAClE,CAAC;gBACD,IAAI,MAAM,CAAC,GAAG,KAAK,OAAO,EAAE,CAAC;oBAC5B,OAAO;wBACN,MAAM,EAAE,SAAS;wBACjB,MAAM,EAAE,0BAA0B,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,kFAAkF;qBACtI,CAAC;gBACH,CAAC;gBACD,IAAI,OAAO,MAAM,CAAC,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBAC/D,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,0BAA0B,EAAE,CAAC;gBAClE,CAAC;gBAED,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;gBACpD,IAAI,CAAC,KAAK,EAAE,CAAC;oBACZ,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,2BAA2B,EAAE,CAAC;gBACvE,CAAC;gBACD,IAAI,CAAC,GAAG,EAAE,CAAC;oBACV,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,gBAAgB,MAAM,CAAC,GAAG,wBAAwB,EAAE,CAAC;gBAC1F,CAAC;gBAED,MAAM,SAAS,GAAG,sBAAsB,CAAC,aAAa,CAAC,CAAC;gBACxD,MAAM,IAAI,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,GAAG,UAAU,IAAI,WAAW,EAAE,CAAC,CAAC;gBACtE,MAAM,cAAc,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,GAAG,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC;gBACnF,IAAI,CAAC,cAAc,EAAE,CAAC;oBACrB,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,+BAA+B,EAAE,CAAC;gBACvE,CAAC;gBAED,MAAM,OAAO,GAAG,uBAAuB,CAAC,WAAW,CAAC,CAAC;gBACrD,IAAI,CAAC,OAAO,EAAE,CAAC;oBACd,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,iCAAiC,EAAE,CAAC;gBACzE,CAAC;gBACD,IAAI,IAAI,CAAC,GAAG,EAAE,IAAI,OAAO,CAAC,GAAG,GAAG,IAAI,EAAE,CAAC;oBACtC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;gBACvD,CAAC;gBACD,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;YACrC,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBAChB,8DAA8D;gBAC9D,GAAG,CAAC,KAAK,CAAC,oCAAoC,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;gBAC1E,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,oBAAoB,EAAE,CAAC;YAC5D,CAAC;QACF,CAAC;QAED,mBAAmB;YAClB,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YAClC,mBAAmB,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YAC5C,eAAe,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QACzC,CAAC;KACD,CAAC;AACH,CAAC","sourcesContent":["// src/core/jwks.ts\n\n/**\n * JWKS-based access-token signature verification (Ed25519 / EdDSA).\n *\n * Security model: the Sentinel backend signs access tokens with EdDSA and\n * publishes the public key at `/.well-known/jwks.json` (RFC 7517). This\n * module fetches and caches that key set, then verifies each access token's\n * signature with WebCrypto BEFORE the payload is trusted. Tokens that fail\n * verification are treated as unauthenticated — verification never throws.\n *\n * Cache behaviour:\n * - JWKS documents are cached per isolate, keyed by URL, with a TTL\n * (default 10 minutes).\n * - A token whose `kid` is not in the cache triggers ONE forced refresh\n * (key rotation support), throttled to at most once per\n * `refreshMinIntervalMs` (default 10 seconds) so unknown-kid floods cannot\n * be used to hammer the auth server.\n * - JWKS fetch failure classifies as `unavailable` (treat as\n * unauthenticated); it never surfaces as a 500.\n *\n * Only OKP/Ed25519 keys with `alg: EdDSA` semantics are accepted. Tokens\n * with any other algorithm (including legacy HS256) are rejected as\n * `invalid` — a symmetric HS256 secret can never be verified from a public\n * JWKS, and accepting it here would reintroduce the unverified-payload hole.\n */\n\nimport type { DecodedToken } from './types.js';\nimport type { SentinelLogger } from './logger.js';\nimport { createLogger } from './logger.js';\n\nexport interface JwksVerifierOptions {\n\t/** Full URL of the JWKS document, e.g. https://auth.example.com/.well-known/jwks.json */\n\tjwksUrl: string;\n\t/** Fetch implementation. Defaults to globalThis.fetch (pass event.fetch in SvelteKit handles). */\n\tfetchFn?: typeof globalThis.fetch;\n\t/** Cache TTL in milliseconds. Default: 600_000 (10 minutes). */\n\tcacheTtlMs?: number;\n\t/** Minimum interval between forced refreshes for unknown kids, in ms. Default: 10_000. */\n\trefreshMinIntervalMs?: number;\n\t/** Timeout for each JWKS fetch, in ms. Default: 5_000. */\n\ttimeoutMs?: number;\n\t/** Optional structured logger. */\n\tlogger?: SentinelLogger;\n}\n\nexport type TokenVerifyOutcome =\n\t/** Signature valid, payload structurally sound and not expired. */\n\t| { status: 'valid'; payload: DecodedToken }\n\t/**\n\t * Token rejected: bad signature, malformed token, unsupported alg,\n\t * unknown kid (after refresh), expired, or structurally invalid payload.\n\t * Treat the request as unauthenticated.\n\t */\n\t| { status: 'invalid'; reason: string }\n\t/**\n\t * Verification could not run because the JWKS document is unreachable or\n\t * unusable. Treat the request as unauthenticated (fail closed) — never a 500.\n\t */\n\t| { status: 'unavailable'; reason: string };\n\nexport interface JwksVerifier {\n\tverify(token: string): Promise<TokenVerifyOutcome>;\n\t/** Test-only: drop cached JWKS state for this verifier's URL. */\n\t_resetCacheForTests(): void;\n}\n\ninterface JwksCacheEntry {\n\tkeys: Map<string, CryptoKey>;\n\tfetchedAt: number;\n}\n\ninterface JwksKeyShape {\n\tkty?: unknown;\n\tcrv?: unknown;\n\tx?: unknown;\n\tkid?: unknown;\n\talg?: unknown;\n\tuse?: unknown;\n}\n\ninterface JwksDocumentShape {\n\tkeys?: unknown;\n}\n\n// Per-isolate cache shared across verifiers with the same JWKS URL.\nconst jwksCache = new Map<string, JwksCacheEntry>();\n// Per-URL in-flight fetch dedup (avoids thundering herd on cold cache).\nconst inflightFetches = new Map<string, Promise<JwksCacheEntry | null>>();\n// Per-URL throttle markers for forced refreshes on unknown kid.\nconst lastForcedRefreshAt = new Map<string, number>();\n\n/** Test-only: drop all cached JWKS state. */\nexport function __resetJwksCacheForTests(): void {\n\tjwksCache.clear();\n\tinflightFetches.clear();\n\tlastForcedRefreshAt.clear();\n}\n\nfunction base64UrlDecodeToBytes(input: string): Uint8Array<ArrayBuffer> {\n\tlet base64 = input.replace(/-/g, '+').replace(/_/g, '/');\n\tconst pad = base64.length % 4;\n\tif (pad) base64 += '='.repeat(4 - pad);\n\tconst binary = atob(base64);\n\tconst bytes = new Uint8Array(new ArrayBuffer(binary.length));\n\tfor (let i = 0; i < binary.length; i += 1) {\n\t\tbytes[i] = binary.charCodeAt(i);\n\t}\n\treturn bytes;\n}\n\nfunction base64UrlDecodeToString(input: string): string {\n\t// Decode via Uint8Array + TextDecoder for proper UTF-8 handling.\n\treturn new TextDecoder('utf-8').decode(base64UrlDecodeToBytes(input));\n}\n\nasync function importPublicEd25519Key(jwk: JwksKeyShape): Promise<CryptoKey | null> {\n\tif (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string' || jwk.x.length === 0) {\n\t\treturn null;\n\t}\n\ttry {\n\t\treturn await crypto.subtle.importKey(\n\t\t\t'jwk',\n\t\t\t{ kty: 'OKP', crv: 'Ed25519', x: jwk.x },\n\t\t\t{ name: 'Ed25519' },\n\t\t\tfalse,\n\t\t\t['verify']\n\t\t);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nasync function fetchJwksDocument(\n\tjwksUrl: string,\n\tfetchFn: typeof globalThis.fetch,\n\ttimeoutMs: number,\n\tlog: SentinelLogger\n): Promise<JwksCacheEntry | null> {\n\tconst controller = new AbortController();\n\tconst timer = setTimeout(() => controller.abort(), timeoutMs);\n\ttry {\n\t\tconst response = await fetchFn(jwksUrl, {\n\t\t\tsignal: controller.signal,\n\t\t\theaders: { accept: 'application/json' }\n\t\t});\n\t\tif (!response.ok) {\n\t\t\tlog.warn('JWKS fetch returned non-OK status', { jwksUrl, status: response.status });\n\t\t\treturn null;\n\t\t}\n\t\tconst document = (await response.json()) as JwksDocumentShape;\n\t\tif (!Array.isArray(document.keys)) {\n\t\t\tlog.warn('JWKS document has no keys array', { jwksUrl });\n\t\t\treturn null;\n\t\t}\n\t\tconst keys = new Map<string, CryptoKey>();\n\t\tfor (const rawKey of document.keys as JwksKeyShape[]) {\n\t\t\tif (typeof rawKey?.kid !== 'string' || rawKey.kid.length === 0) continue;\n\t\t\tconst imported = await importPublicEd25519Key(rawKey);\n\t\t\tif (imported) keys.set(rawKey.kid, imported);\n\t\t}\n\t\tif (keys.size === 0) {\n\t\t\tlog.warn('JWKS document contained no usable Ed25519 keys', { jwksUrl });\n\t\t\treturn null;\n\t\t}\n\t\treturn { keys, fetchedAt: Date.now() };\n\t} catch (error) {\n\t\tlog.warn('JWKS fetch failed', { jwksUrl, error: String(error) });\n\t\treturn null;\n\t} finally {\n\t\tclearTimeout(timer);\n\t}\n}\n\nasync function getJwksEntry(\n\toptions: Required<Pick<JwksVerifierOptions, 'jwksUrl' | 'cacheTtlMs' | 'refreshMinIntervalMs' | 'timeoutMs'>> & JwksVerifierOptions,\n\tforce: boolean,\n\tlog: SentinelLogger\n): Promise<JwksCacheEntry | null> {\n\tconst { jwksUrl, cacheTtlMs, refreshMinIntervalMs, timeoutMs } = options;\n\tconst fetchFn = options.fetchFn ?? globalThis.fetch;\n\tconst now = Date.now();\n\n\tconst cached = jwksCache.get(jwksUrl);\n\tif (!force && cached && now - cached.fetchedAt < cacheTtlMs) {\n\t\treturn cached;\n\t}\n\n\tif (force) {\n\t\tconst lastForced = lastForcedRefreshAt.get(jwksUrl) ?? 0;\n\t\tif (now - lastForced < refreshMinIntervalMs) {\n\t\t\t// Throttled: report whatever we have (possibly nothing).\n\t\t\treturn cached ?? null;\n\t\t}\n\t\tlastForcedRefreshAt.set(jwksUrl, now);\n\t}\n\n\tconst inflight = inflightFetches.get(jwksUrl);\n\tif (inflight) return inflight;\n\n\tconst pending = fetchJwksDocument(jwksUrl, fetchFn, timeoutMs, log)\n\t\t.then((entry) => {\n\t\t\tif (entry) jwksCache.set(jwksUrl, entry);\n\t\t\treturn entry;\n\t\t})\n\t\t.finally(() => {\n\t\t\tinflightFetches.delete(jwksUrl);\n\t\t});\n\tinflightFetches.set(jwksUrl, pending);\n\treturn pending;\n}\n\nfunction parseTokenHeader(token: string): { alg?: unknown; kid?: unknown } | null {\n\ttry {\n\t\tconst parts = token.split('.');\n\t\tif (parts.length !== 3) return null;\n\t\tconst header = JSON.parse(base64UrlDecodeToString(parts[0]));\n\t\treturn typeof header === 'object' && header !== null ? (header as { alg?: unknown; kid?: unknown }) : null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction parseAndValidatePayload(payloadPart: string): DecodedToken | null {\n\ttry {\n\t\tconst payload = JSON.parse(base64UrlDecodeToString(payloadPart));\n\t\tif (typeof payload !== 'object' || payload === null) return null;\n\t\tif (typeof payload.sub !== 'string' || payload.sub.length === 0) return null;\n\t\tif (typeof payload.email !== 'string' || payload.email.length === 0) return null;\n\t\tif (typeof payload.exp !== 'number' || isNaN(payload.exp)) return null;\n\t\tif (typeof payload.iat !== 'number' || isNaN(payload.iat)) return null;\n\t\treturn {\n\t\t\tsub: payload.sub,\n\t\t\temail: payload.email,\n\t\t\troles: Array.isArray(payload.roles)\n\t\t\t\t? payload.roles.filter((r: unknown): r is string => typeof r === 'string')\n\t\t\t\t: [],\n\t\t\texp: payload.exp,\n\t\t\tiat: payload.iat\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Create a JWKS verifier bound to one JWKS URL. The underlying key cache is\n * shared per isolate across verifiers with the same URL.\n */\nexport function createJwksVerifier(userOptions: JwksVerifierOptions): JwksVerifier {\n\tconst options = {\n\t\tjwksUrl: userOptions.jwksUrl,\n\t\tfetchFn: userOptions.fetchFn,\n\t\tcacheTtlMs: userOptions.cacheTtlMs ?? 600_000,\n\t\trefreshMinIntervalMs: userOptions.refreshMinIntervalMs ?? 10_000,\n\t\ttimeoutMs: userOptions.timeoutMs ?? 5_000,\n\t\tlogger: userOptions.logger\n\t};\n\tconst log = options.logger ?? createLogger('sentinel-jwks');\n\n\tasync function resolveKey(kid: string): Promise<{ key?: CryptoKey; entry: JwksCacheEntry | null }> {\n\t\tlet entry = await getJwksEntry(options, false, log);\n\t\tif (entry?.keys.has(kid)) return { key: entry.keys.get(kid), entry };\n\n\t\t// Unknown kid (or no cache): one throttled forced refresh for key rotation.\n\t\tentry = await getJwksEntry(options, true, log);\n\t\tif (entry?.keys.has(kid)) return { key: entry.keys.get(kid), entry };\n\t\treturn { entry };\n\t}\n\n\treturn {\n\t\tasync verify(token: string): Promise<TokenVerifyOutcome> {\n\t\t\ttry {\n\t\t\t\tconst parts = token.split('.');\n\t\t\t\tif (parts.length !== 3) {\n\t\t\t\t\treturn { status: 'invalid', reason: 'malformed token structure' };\n\t\t\t\t}\n\t\t\t\tconst [headerPart, payloadPart, signaturePart] = parts;\n\n\t\t\t\tconst header = parseTokenHeader(token);\n\t\t\t\tif (!header) {\n\t\t\t\t\treturn { status: 'invalid', reason: 'unparseable token header' };\n\t\t\t\t}\n\t\t\t\tif (header.alg !== 'EdDSA') {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tstatus: 'invalid',\n\t\t\t\t\t\treason: `unsupported token alg \"${String(header.alg)}\" — only EdDSA tokens can be JWKS-verified (backend must be migrated from HS256)`\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tif (typeof header.kid !== 'string' || header.kid.length === 0) {\n\t\t\t\t\treturn { status: 'invalid', reason: 'token header missing kid' };\n\t\t\t\t}\n\n\t\t\t\tconst { key, entry } = await resolveKey(header.kid);\n\t\t\t\tif (!entry) {\n\t\t\t\t\treturn { status: 'unavailable', reason: 'JWKS document unreachable' };\n\t\t\t\t}\n\t\t\t\tif (!key) {\n\t\t\t\t\treturn { status: 'invalid', reason: `unknown kid \"${header.kid}\" (after JWKS refresh)` };\n\t\t\t\t}\n\n\t\t\t\tconst signature = base64UrlDecodeToBytes(signaturePart);\n\t\t\t\tconst data = new TextEncoder().encode(`${headerPart}.${payloadPart}`);\n\t\t\t\tconst signatureValid = await crypto.subtle.verify('Ed25519', key, signature, data);\n\t\t\t\tif (!signatureValid) {\n\t\t\t\t\treturn { status: 'invalid', reason: 'signature verification failed' };\n\t\t\t\t}\n\n\t\t\t\tconst payload = parseAndValidatePayload(payloadPart);\n\t\t\t\tif (!payload) {\n\t\t\t\t\treturn { status: 'invalid', reason: 'payload missing required claims' };\n\t\t\t\t}\n\t\t\t\tif (Date.now() >= payload.exp * 1000) {\n\t\t\t\t\treturn { status: 'invalid', reason: 'token expired' };\n\t\t\t\t}\n\t\t\t\treturn { status: 'valid', payload };\n\t\t\t} catch (error) {\n\t\t\t\t// Never throw out of verification — classify and fail closed.\n\t\t\t\tlog.error('Unexpected JWKS verification error', { error: String(error) });\n\t\t\t\treturn { status: 'invalid', reason: 'verification error' };\n\t\t\t}\n\t\t},\n\n\t\t_resetCacheForTests(): void {\n\t\t\tjwksCache.delete(options.jwksUrl);\n\t\t\tlastForcedRefreshAt.delete(options.jwksUrl);\n\t\t\tinflightFetches.delete(options.jwksUrl);\n\t\t}\n\t};\n}\n"]}