@wtfalch/auth 0.29.0 → 0.31.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/dist/names.d.ts CHANGED
@@ -39,9 +39,11 @@ export interface NameRules {
39
39
  }
40
40
  export declare const DEFAULT_RULES: NameRules;
41
41
  /**
42
- * Lowercase letters with the digits read as letters and nothing else, runs
43
- * collapsed. Every reading, since `1` may be an `i` or an `l`; the first is
44
- * the one a list entry is stored under.
42
+ * NFKC-normalised and confusables-resolved -- so a look-alike from another
43
+ * script (Cyrillic `а` in `аdmin`) reads as the Latin letter it imitates
44
+ * instead of being dropped -- then lowercase letters with the digits read as
45
+ * letters and nothing else, runs collapsed. Every reading, since `1` may be
46
+ * an `i` or an `l`; the first is the one a list entry is stored under.
45
47
  */
46
48
  export declare function flattenings(name: string): string[];
47
49
  /** Every entry checkName holds back: the system names, the people's and the reserved-usernames list, once each. */
package/dist/names.js CHANGED
@@ -82,14 +82,22 @@ const LETTERS = {
82
82
  '8': ['b'],
83
83
  '9': ['g'],
84
84
  };
85
+ /** Confusables resolved per character, outside any matcher, so a plain string can be normalised. */
86
+ const confusablesTransform = resolveConfusablesTransformer().transform;
85
87
  /**
86
- * Lowercase letters with the digits read as letters and nothing else, runs
87
- * collapsed. Every reading, since `1` may be an `i` or an `l`; the first is
88
- * the one a list entry is stored under.
88
+ * NFKC-normalised and confusables-resolved -- so a look-alike from another
89
+ * script (Cyrillic `а` in `аdmin`) reads as the Latin letter it imitates
90
+ * instead of being dropped -- then lowercase letters with the digits read as
91
+ * letters and nothing else, runs collapsed. Every reading, since `1` may be
92
+ * an `i` or an `l`; the first is the one a list entry is stored under.
89
93
  */
90
94
  export function flattenings(name) {
95
+ const resolved = Array.from(name.normalize('NFKC'), (char) => {
96
+ const codePoint = char.codePointAt(0);
97
+ return String.fromCodePoint(confusablesTransform(codePoint) ?? codePoint);
98
+ }).join('');
91
99
  let forms = [''];
92
- for (const char of name.toLowerCase()) {
100
+ for (const char of resolved.toLowerCase()) {
93
101
  const readings = /[a-z]/.test(char) ? [char] : (LETTERS[char] ?? []);
94
102
  if (readings.length === 0)
95
103
  continue;
@@ -147,7 +155,9 @@ export function checkName(name, rules = DEFAULT_RULES) {
147
155
  return { ok: false, reason: 'disallowed' };
148
156
  }
149
157
  // Last, so that a number that means something (`14.88` reads as `lab`) is refused for what it is.
150
- if (flattenings(typed).some((form) => reserved.has(form))) {
158
+ // Digits on the ends are also read as a counter, so `admin1` is `admin`.
159
+ const forms = [typed, typed.replace(/^\d+|\d+$/g, '')].flatMap(flattenings);
160
+ if (forms.some((form) => reserved.has(form))) {
151
161
  return { ok: false, reason: 'reserved' };
152
162
  }
153
163
  return { ok: true };
@@ -1,8 +1,7 @@
1
1
  import { createRemoteJWKSet, jwtVerify } from 'jose';
2
2
  import { resolveNamespaceContext } from './namespaces.js';
3
3
  import { safeLoginHint, safeNextPath } from './redirect.js';
4
- import { base64url } from './util.js';
5
- const OWNER = 'urn:zitadel:iam:user:resourceowner:id';
4
+ import { ORG_CLAIM, base64url } from './util.js';
6
5
  const ttl = 10 * 60_000;
7
6
  const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
8
7
  function read(storage, key) {
@@ -173,7 +172,7 @@ export function createNamespaceBrowserAuth(options, selection) {
173
172
  clockTolerance: 60,
174
173
  });
175
174
  if (payload.nonce !== tx.nonce ||
176
- payload[OWNER] !== binding.organizationId ||
175
+ payload[ORG_CLAIM] !== binding.organizationId ||
177
176
  (payload.azp !== undefined && payload.azp !== binding.clientId) ||
178
177
  (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== binding.clientId))
179
178
  return reject();
@@ -201,6 +200,8 @@ export function createNamespaceBrowserAuth(options, selection) {
201
200
  return {
202
201
  accountChanged,
203
202
  currentSession,
203
+ // This path keeps no ID token, so there is no hint to build a sign-out URL from.
204
+ endSessionUrl: () => null,
204
205
  silentSignInAvailable,
205
206
  completeSignIn,
206
207
  async refresh() {
@@ -3,7 +3,7 @@ import * as client from 'openid-client';
3
3
  import { BrokerError } from './broker.js';
4
4
  import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
5
5
  import { AuthError, isOAuthRefusal, rejected } from './oidc.js';
6
- import { expiryOf, issuedAtOf } from './util.js';
6
+ import { expiryOf, issuedAtOf, recheckSeconds } from './util.js';
7
7
  /** How long a binding status answer is trusted. The propagation bound for a suspension. */
8
8
  export const STATUS_FRESH_MS = 60_000;
9
9
  /** How long the last good answer carries on while the service cannot be asked. */
@@ -435,7 +435,7 @@ export function namespaceSessions(deps) {
435
435
  const exp = expiryOf(loaded.tokens.idt);
436
436
  const iat = issuedAtOf(loaded.tokens.idt);
437
437
  const valid = exp !== null && exp > now() - 60;
438
- const stale = iat !== null && now() - iat >= options().freshness;
438
+ const stale = iat !== null && now() - iat >= recheckSeconds(options().freshness);
439
439
  const due = force || exp === null || exp - options().refreshWindow <= now() || stale;
440
440
  if (due && loaded.tokens.rt) {
441
441
  loaded = await refresh(loaded, valid);
package/dist/next.js CHANGED
@@ -99,7 +99,7 @@ export function nextAuth(options) {
99
99
  * fresh id, which fails the same way — two requests to the issuer per lap,
100
100
  * no error page, nothing in a log, forever. That is not hypothetical: a
101
101
  * client id missing from the broker's registry produced exactly this on
102
- * manage.wtfalch.dev, and the visible symptom was Cloudflare rate-limiting
102
+ * boule.companyincluded.com, and the visible symptom was Cloudflare rate-limiting
103
103
  * the operator, twenty minutes before anybody could see a 403 underneath
104
104
  * it. The refusal was diagnosable from the first request and the loop hid
105
105
  * it behind thousands.
package/dist/oidc.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as jose from 'jose';
2
2
  import type { ResolvedOptions } from './config.js';
3
- export declare const ORG_CLAIM = "urn:zitadel:iam:user:resourceowner:id";
3
+ import { ORG_CLAIM } from './util.js';
4
+ export { ORG_CLAIM };
4
5
  /**
5
6
  * How long a rotated refresh token still answers with the tokens it rotated
6
7
  * to. A request that started just before another one finished the same
@@ -32,6 +33,19 @@ export interface Tokens {
32
33
  /** Epoch ms `accessToken` expires at. Present exactly when `accessToken` is and the issuer sent `expires_in`. */
33
34
  accessTokenExpiresAt?: number;
34
35
  }
36
+ /**
37
+ * The oldest a logout token may be, by its `iat`, before it is refused (the
38
+ * 60 s clock tolerance comes on top). There is no `jti` store, so this and
39
+ * the required `exp` are all that stop a captured token being replayed.
40
+ */
41
+ export declare const LOGOUT_TOKEN_MAX_AGE_SECONDS = 120;
42
+ /** The `events` claim key a back-channel logout token carries (OpenID Connect Back-Channel Logout 1.0 §2.4). */
43
+ export declare const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
44
+ /** What a verified logout token named: the issuer's user id, its own session id, or (rarely) both. */
45
+ export interface LogoutTokenClaims {
46
+ sub?: string;
47
+ sid?: string;
48
+ }
35
49
  export declare class Oidc {
36
50
  private readonly options;
37
51
  private configuration;
@@ -62,6 +76,15 @@ export declare class Oidc {
62
76
  endSessionUrl(idToken: string, postLogoutRedirectUri: string): Promise<URL | null>;
63
77
  private tokensFrom;
64
78
  verify(idToken: string): Promise<jose.JWTPayload>;
79
+ /**
80
+ * A back-channel logout token (OpenID Connect Back-Channel Logout 1.0
81
+ * §2.4-2.5): signed by this issuer for this client, carrying the
82
+ * back-channel-logout event and neither a `nonce` (the spec forbids one,
83
+ * since this token never rode a browser redirect) nor an empty `sub`/`sid`
84
+ * pair. Throws the same rejected-token errors `verify` does; the caller
85
+ * decides what that means for its own HTTP response.
86
+ */
87
+ verifyLogoutToken(logoutToken: string): Promise<LogoutTokenClaims>;
65
88
  private assertIdentityClaims;
66
89
  private assertOrganization;
67
90
  }
package/dist/oidc.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as jose from 'jose';
2
2
  import * as client from 'openid-client';
3
- export const ORG_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
3
+ import { ORG_CLAIM } from './util.js';
4
+ export { ORG_CLAIM };
4
5
  /**
5
6
  * How long a rotated refresh token still answers with the tokens it rotated
6
7
  * to. A request that started just before another one finished the same
@@ -39,6 +40,14 @@ export function scopeFor(organizationId, extraScopes) {
39
40
  }
40
41
  return scopes.join(' ');
41
42
  }
43
+ /**
44
+ * The oldest a logout token may be, by its `iat`, before it is refused (the
45
+ * 60 s clock tolerance comes on top). There is no `jti` store, so this and
46
+ * the required `exp` are all that stop a captured token being replayed.
47
+ */
48
+ export const LOGOUT_TOKEN_MAX_AGE_SECONDS = 120;
49
+ /** The `events` claim key a back-channel logout token carries (OpenID Connect Back-Channel Logout 1.0 §2.4). */
50
+ export const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
42
51
  export class Oidc {
43
52
  options;
44
53
  configuration = null;
@@ -181,6 +190,53 @@ export class Oidc {
181
190
  this.assertOrganization(payload);
182
191
  return payload;
183
192
  }
193
+ /**
194
+ * A back-channel logout token (OpenID Connect Back-Channel Logout 1.0
195
+ * §2.4-2.5): signed by this issuer for this client, carrying the
196
+ * back-channel-logout event and neither a `nonce` (the spec forbids one,
197
+ * since this token never rode a browser redirect) nor an empty `sub`/`sid`
198
+ * pair. Throws the same rejected-token errors `verify` does; the caller
199
+ * decides what that means for its own HTTP response.
200
+ */
201
+ async verifyLogoutToken(logoutToken) {
202
+ let payload;
203
+ try {
204
+ ({ payload } = await jose.jwtVerify(logoutToken, await this.keys(), {
205
+ issuer: this.options.issuer,
206
+ audience: this.options.clientId,
207
+ algorithms: ['RS256'],
208
+ clockTolerance: 60,
209
+ maxTokenAge: LOGOUT_TOKEN_MAX_AGE_SECONDS,
210
+ requiredClaims: ['iat', 'exp', 'events'],
211
+ }));
212
+ }
213
+ catch (error) {
214
+ // A kid this issuer's published keys do not contain refuses the logout
215
+ // token. It is deliberately not part of `rejected()`: for a session's
216
+ // id token the same error can be a just-rotated key inside the JWKS
217
+ // cooldown, which must not sign the person out.
218
+ if (error instanceof jose.errors.JWKSNoMatchingKey) {
219
+ throw new AuthError('exchange', 'the logout token is signed with an unknown key');
220
+ }
221
+ throw error;
222
+ }
223
+ if (payload.nonce !== undefined) {
224
+ throw new AuthError('exchange', 'a logout token must not carry a nonce');
225
+ }
226
+ const events = payload.events;
227
+ if (typeof events !== 'object' ||
228
+ events === null ||
229
+ Array.isArray(events) ||
230
+ !Object.hasOwn(events, BACKCHANNEL_LOGOUT_EVENT)) {
231
+ throw new AuthError('exchange', 'the logout token carries no back-channel-logout event');
232
+ }
233
+ const sub = typeof payload.sub === 'string' && payload.sub ? payload.sub : undefined;
234
+ const sid = typeof payload.sid === 'string' && payload.sid ? payload.sid : undefined;
235
+ if (!sub && !sid) {
236
+ throw new AuthError('exchange', 'the logout token names neither a subject nor a session');
237
+ }
238
+ return { sub, sid };
239
+ }
184
240
  // Both grant responses and stored/device tokens become a User. Keep their
185
241
  // identity and time shapes consistent, including optional freshness claims.
186
242
  assertIdentityClaims(claims) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The onboarding hold: what the broker hands an app's onboarding page during an
3
+ * MCP sign-in (ADR 0002), and what the app checks before it trusts the page's
4
+ * caller. Only WebCrypto, so this entry runs on workerd and in Node alike.
5
+ *
6
+ * `HOLD = base64url(JSON {h, s, o, e}) . base64url(HMAC-SHA256(appKey, firstPart))`
7
+ * where `h` is the hold id, `s` the person's ZITADEL user id, `o` their
8
+ * organisation id and `e` the expiry in epoch seconds. The key is the app's
9
+ * broker key (`AUTH_APP_KEY`), which only the broker and that app hold. A hold
10
+ * is a bearer proof that the broker just signed this person in; it carries no
11
+ * token and it is single use at the broker.
12
+ */
13
+ export interface OnboardingHold {
14
+ holdId: string;
15
+ sub: string;
16
+ organizationId: string;
17
+ }
18
+ /** Signs a hold for `appKey`. `expiresAt` is epoch seconds. */
19
+ export declare function signOnboardingHold(input: OnboardingHold & {
20
+ expiresAt: number;
21
+ }, appKey: string): Promise<string>;
22
+ /**
23
+ * The hold's contents when it is well formed, signed with `appKey` and not
24
+ * expired; otherwise null, with no hint which check failed. `now` is unix
25
+ * seconds, the unit of the hold's `e` (a test seam). The signature is compared by `crypto.subtle.verify`,
26
+ * which is constant time.
27
+ */
28
+ export declare function verifyOnboardingHold(hold: string, appKey: string, now?: number): Promise<OnboardingHold | null>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The onboarding hold: what the broker hands an app's onboarding page during an
3
+ * MCP sign-in (ADR 0002), and what the app checks before it trusts the page's
4
+ * caller. Only WebCrypto, so this entry runs on workerd and in Node alike.
5
+ *
6
+ * `HOLD = base64url(JSON {h, s, o, e}) . base64url(HMAC-SHA256(appKey, firstPart))`
7
+ * where `h` is the hold id, `s` the person's ZITADEL user id, `o` their
8
+ * organisation id and `e` the expiry in epoch seconds. The key is the app's
9
+ * broker key (`AUTH_APP_KEY`), which only the broker and that app hold. A hold
10
+ * is a bearer proof that the broker just signed this person in; it carries no
11
+ * token and it is single use at the broker.
12
+ */
13
+ const encoder = new TextEncoder();
14
+ const HOLD_RE = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/;
15
+ const MAX_HOLD_LENGTH = 2048;
16
+ const toBase64Url = (bytes) => {
17
+ let binary = '';
18
+ for (const byte of bytes)
19
+ binary += String.fromCharCode(byte);
20
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
21
+ };
22
+ const fromBase64Url = (value) => {
23
+ try {
24
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/');
25
+ const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4));
26
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ };
32
+ const hmacKey = (appKey, usage) => crypto.subtle.importKey('raw', encoder.encode(appKey), { name: 'HMAC', hash: 'SHA-256' }, false, [
33
+ usage,
34
+ ]);
35
+ /** Signs a hold for `appKey`. `expiresAt` is epoch seconds. */
36
+ export async function signOnboardingHold(input, appKey) {
37
+ const payload = toBase64Url(encoder.encode(JSON.stringify({
38
+ h: input.holdId,
39
+ s: input.sub,
40
+ o: input.organizationId,
41
+ e: input.expiresAt,
42
+ })));
43
+ const mac = await crypto.subtle.sign('HMAC', await hmacKey(appKey, 'sign'), encoder.encode(payload));
44
+ return `${payload}.${toBase64Url(new Uint8Array(mac))}`;
45
+ }
46
+ /**
47
+ * The hold's contents when it is well formed, signed with `appKey` and not
48
+ * expired; otherwise null, with no hint which check failed. `now` is unix
49
+ * seconds, the unit of the hold's `e` (a test seam). The signature is compared by `crypto.subtle.verify`,
50
+ * which is constant time.
51
+ */
52
+ export async function verifyOnboardingHold(hold, appKey, now = Math.floor(Date.now() / 1000)) {
53
+ if (typeof hold !== 'string' || hold.length > MAX_HOLD_LENGTH || !appKey)
54
+ return null;
55
+ const match = HOLD_RE.exec(hold);
56
+ if (!match)
57
+ return null;
58
+ const [, payload, signature] = match;
59
+ const mac = fromBase64Url(signature);
60
+ if (!mac)
61
+ return null;
62
+ let valid;
63
+ try {
64
+ valid = await crypto.subtle.verify('HMAC', await hmacKey(appKey, 'verify'), mac, encoder.encode(payload));
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ if (!valid)
70
+ return null;
71
+ const bytes = fromBase64Url(payload);
72
+ if (!bytes)
73
+ return null;
74
+ let claims;
75
+ try {
76
+ claims = JSON.parse(new TextDecoder().decode(bytes));
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (claims === null || typeof claims !== 'object')
82
+ return null;
83
+ const { h, s, o, e } = claims;
84
+ if (typeof h !== 'string' || !h || typeof s !== 'string' || !s)
85
+ return null;
86
+ if (typeof o !== 'string' || !o || typeof e !== 'number' || !Number.isFinite(e))
87
+ return null;
88
+ if (e <= now)
89
+ return null;
90
+ return { holdId: h, sub: s, organizationId: o };
91
+ }
@@ -26,21 +26,55 @@ import { type JWTPayload } from 'jose';
26
26
  *
27
27
  * A network failure or a non-2xx from either the discovery document or the
28
28
  * introspection endpoint fails closed: `verify` returns `null`, exactly
29
- * like a bad token, never treated as good. There is no second outcome for
30
- * "could not check" versus "checked and refused" -- a caller has only one
31
- * response to either, refuse.
29
+ * like a bad token, never treated as good. `verify` has no second outcome
30
+ * for "could not check" versus "checked and refused" -- a caller that needs
31
+ * one calls `verifyOutcome` instead. That method also introspects a JWT
32
+ * (live revocation only shows up there), so it needs the introspection
33
+ * credentials for every token, and tells `invalid`, `expired`, `revoked`
34
+ * and `unavailable` apart. A positive introspection answer is cached for at
35
+ * most 60 seconds; a refusal or an `unavailable` never is.
32
36
  */
37
+ export interface PersonIdentity {
38
+ sub: string;
39
+ /** The ZITADEL organisation that issued the token. Absent when the claim
40
+ * is absent, empty or not a string: a malformed claim is treated as
41
+ * missing and the token still verifies. */
42
+ organizationId?: string;
43
+ }
44
+ /** Why `verifyOutcome` refused a token. `unavailable` means the issuer could
45
+ * not be asked (network error, non-2xx, unreadable answer): retry, and never
46
+ * remember it as a refusal. The others are verdicts: `invalid` (bad
47
+ * signature, issuer, audience, shape or claims; also no introspection
48
+ * credentials), `expired`, and `revoked` (the issuer reports the token
49
+ * inactive). */
50
+ export type PersonTokenRefusal = 'invalid' | 'expired' | 'revoked' | 'unavailable';
51
+ export type PersonTokenOutcome = {
52
+ ok: true;
53
+ identity: PersonIdentity;
54
+ } | {
55
+ ok: false;
56
+ reason: PersonTokenRefusal;
57
+ };
33
58
  export interface PersonTokenVerifier {
34
- /** The token's subject (`sub`), or null for anything that does not stand
35
- * up: bad signature, wrong issuer/audience, expired, inactive, or
36
- * unreachable. Refusals never say which. */
37
- verify(accessToken: string): Promise<{
38
- sub: string;
39
- } | null>;
59
+ /** The token's subject (`sub`) and organisation, or null for anything that
60
+ * does not stand up: bad signature, wrong issuer/audience, expired,
61
+ * inactive, or unreachable. Refusals never say which.
62
+ *
63
+ * `organizationId` is read from the verified JWT claims or the
64
+ * introspection response, same claim either way. Present only when the
65
+ * claim is a non-empty string (every token minted through this SDK's own
66
+ * `scopeFor` carries it); never guessed or defaulted. Neither path checks
67
+ * it against anything fixed: see the module doc above. */
68
+ verify(accessToken: string): Promise<PersonIdentity | null>;
69
+ /** Like `verify`, but says why a token was refused, and also introspects a
70
+ * JWT after its signature checks pass, so a revoked JWT is refused as
71
+ * `revoked` before its `exp`. Needs `introspectionClientId`/`Secret`:
72
+ * without them every token is `invalid`. */
73
+ verifyOutcome(accessToken: string): Promise<PersonTokenOutcome>;
40
74
  }
41
75
  export interface PersonTokenConfig {
42
76
  /** The issuer every token must have been minted by, e.g.
43
- * `https://auth.wtfalch.dev`. */
77
+ * `https://auth.companyincluded.com`. */
44
78
  issuer: string;
45
79
  /** The ZITADEL project id a person's token must carry in `aud` --
46
80
  * requested by whatever app mints the token (an `extraScopes` entry of
@@ -1,5 +1,5 @@
1
1
  import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
2
- import { timeoutSignal } from './util.js';
2
+ import { ORG_CLAIM, timeoutSignal } from './util.js';
3
3
  /** A JWT is three base64url segments; a ZITADEL opaque token is not shaped
4
4
  * like that at all, so this is enough to route between `jwtVerify` and
5
5
  * introspection without ever trying to parse either kind as the other. */
@@ -8,6 +8,8 @@ const JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
8
8
  * checks again -- bounded well under the token's own `exp` so a revoked
9
9
  * token can't outlive a ZITADEL-side revocation for long. */
10
10
  const INTROSPECTION_CACHE_TTL_MS = 60_000;
11
+ /** How long the issuer's discovery document, and the key set built from it, are reused before they are fetched again, so a moved `jwks_uri` or `introspection_endpoint` is picked up without a restart. */
12
+ const DISCOVERY_TTL_MS = 10 * 60_000;
11
13
  /** An optional endpoint from the discovery document, required to be on the
12
14
  * issuer's own origin -- a compromised or misconfigured discovery
13
15
  * response must never point signature checking, or a token's liveness,
@@ -77,24 +79,50 @@ class PositiveIntrospectionCache {
77
79
  this.entries.set(token, { payload, expiresAt });
78
80
  }
79
81
  }
82
+ /** A key-set request that did not complete (network failure, abort). Tagged so it is told apart from every other error a verification can raise. */
83
+ class KeyFetchError extends Error {
84
+ constructor() {
85
+ super('person-token: the key set could not be fetched');
86
+ this.name = 'KeyFetchError';
87
+ }
88
+ }
80
89
  /** @internal */
81
90
  export function createPersonTokenCore(config) {
82
91
  const fetchImpl = config.fetch ?? fetch;
83
92
  let discovery = null;
93
+ let discoveredAt = 0;
84
94
  let jwks = null;
85
95
  const introspectionCache = new PositiveIntrospectionCache();
86
96
  const document = () => {
87
- discovery ??= discoverDocument(config.issuer, fetchImpl).catch((error) => {
97
+ if (discovery && Date.now() - discoveredAt >= DISCOVERY_TTL_MS) {
88
98
  discovery = null;
89
- throw error;
90
- });
99
+ jwks = null;
100
+ }
101
+ if (!discovery) {
102
+ discoveredAt = Date.now();
103
+ const fetched = discoverDocument(config.issuer, fetchImpl).catch((error) => {
104
+ if (discovery === fetched) {
105
+ discovery = null;
106
+ jwks = null;
107
+ }
108
+ throw error;
109
+ });
110
+ discovery = fetched;
111
+ }
91
112
  return discovery;
92
113
  };
93
114
  const keys = () => {
94
115
  jwks ??= document()
95
116
  .then((doc) => createRemoteJWKSet(new URL(doc.jwksUri), {
96
117
  cooldownDuration: config.jwksCooldownMs ?? 5_000,
97
- [customFetch]: (url, init) => fetchImpl(url, init),
118
+ [customFetch]: async (url, init) => {
119
+ try {
120
+ return await fetchImpl(url, init);
121
+ }
122
+ catch {
123
+ throw new KeyFetchError();
124
+ }
125
+ },
98
126
  }))
99
127
  .catch((error) => {
100
128
  jwks = null;
@@ -125,8 +153,13 @@ export function createPersonTokenCore(config) {
125
153
  const code = error?.code;
126
154
  if (code === 'ERR_JWT_EXPIRED')
127
155
  return { ok: false, reason: 'expired' };
128
- // A key fetch that failed or timed out is the issuer being down, not the token being bad.
129
- if (code === 'ERR_JWKS_TIMEOUT' || typeof code !== 'string') {
156
+ // A key fetch that failed, timed out or got a non-200/unreadable answer
157
+ // is the issuer being down, not the token being bad. jose reports the
158
+ // last two as a bare JOSEError; anything else without a code is a defect
159
+ // in the token or the call, so it is a refusal.
160
+ if (error instanceof KeyFetchError ||
161
+ code === 'ERR_JWKS_TIMEOUT' ||
162
+ code === 'ERR_JOSE_GENERIC') {
130
163
  return { ok: false, reason: 'unavailable' };
131
164
  }
132
165
  return { ok: false, reason: 'invalid' };
@@ -137,30 +170,30 @@ export function createPersonTokenCore(config) {
137
170
  return outcome.ok ? outcome.payload : null;
138
171
  }
139
172
  /**
140
- * RFC 7662 token introspection for an opaque access token: POSTs
141
- * `token=<accessToken>` to the issuer's `introspection_endpoint`,
142
- * authenticating as this resource server with `client_secret_basic`.
143
- * Fails closed on anything that keeps this from reaching a real
144
- * `active`/`exp`/`aud` verdict: no credentials configured, no endpoint
145
- * published, a network failure, a non-2xx response, or an unparseable
146
- * body -- every one of those returns `null`, refused, never accepted.
173
+ * RFC 7662 token introspection: POSTs `token=<accessToken>` to the
174
+ * issuer's `introspection_endpoint`, authenticating as this resource
175
+ * server with `client_secret_basic`. Fails closed on anything that keeps
176
+ * this from reaching a real `active`/`exp`/`aud`/`iss` verdict. A network
177
+ * failure, a non-2xx or an unreadable body is `unavailable`; the issuer's
178
+ * own answer is `revoked` (inactive), `expired` or `invalid`.
147
179
  */
148
- async function introspect(accessToken) {
149
- if (!config.introspectionClientId || !config.introspectionClientSecret)
150
- return null;
180
+ async function introspectOutcome(accessToken) {
181
+ if (!config.introspectionClientId || !config.introspectionClientSecret) {
182
+ return { ok: false, reason: 'invalid' };
183
+ }
151
184
  const now = Date.now();
152
185
  const cached = introspectionCache.get(accessToken, now);
153
186
  if (cached)
154
- return cached;
187
+ return { ok: true, payload: cached };
155
188
  let endpoint;
156
189
  try {
157
190
  ({ introspectionEndpoint: endpoint } = await document());
158
191
  }
159
192
  catch {
160
- return null;
193
+ return { ok: false, reason: 'unavailable' };
161
194
  }
162
195
  if (!endpoint)
163
- return null;
196
+ return { ok: false, reason: 'invalid' };
164
197
  const credentials = Buffer.from(`${config.introspectionClientId}:${config.introspectionClientSecret}`).toString('base64');
165
198
  let response;
166
199
  try {
@@ -175,30 +208,48 @@ export function createPersonTokenCore(config) {
175
208
  });
176
209
  }
177
210
  catch {
178
- return null;
211
+ return { ok: false, reason: 'unavailable' };
179
212
  }
180
213
  if (!response.ok)
181
- return null;
214
+ return { ok: false, reason: 'unavailable' };
182
215
  let body;
183
216
  try {
184
- body = (await response.json());
217
+ const parsed = await response.json();
218
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
219
+ return { ok: false, reason: 'unavailable' };
220
+ }
221
+ body = parsed;
185
222
  }
186
223
  catch {
187
- return null;
224
+ return { ok: false, reason: 'unavailable' };
188
225
  }
189
226
  if (body.active !== true)
190
- return null;
191
- if (typeof body.exp !== 'number' || body.exp <= now / 1000)
192
- return null;
227
+ return { ok: false, reason: 'revoked' };
228
+ if (typeof body.exp !== 'number')
229
+ return { ok: false, reason: 'invalid' };
230
+ if (body.exp <= now / 1000)
231
+ return { ok: false, reason: 'expired' };
193
232
  const aud = body.aud;
194
233
  const audiences = Array.isArray(aud) ? aud : aud === undefined ? [] : [aud];
195
234
  if (!audiences.includes(config.audience))
196
- return null;
197
- if (typeof body.iss === 'string' && body.iss !== config.issuer)
198
- return null;
235
+ return { ok: false, reason: 'invalid' };
236
+ if (body.iss !== config.issuer)
237
+ return { ok: false, reason: 'invalid' };
199
238
  const payload = body;
200
239
  introspectionCache.set(accessToken, payload, Math.min(body.exp * 1000, now + INTROSPECTION_CACHE_TTL_MS));
201
- return payload;
240
+ return { ok: true, payload };
241
+ }
242
+ async function introspect(accessToken) {
243
+ const outcome = await introspectOutcome(accessToken);
244
+ return outcome.ok ? outcome.payload : null;
245
+ }
246
+ function identityOf(payload) {
247
+ if (typeof payload.sub !== 'string' || !payload.sub)
248
+ return null;
249
+ const organizationId = payload[ORG_CLAIM];
250
+ return typeof organizationId === 'string' && organizationId
251
+ ? { sub: payload.sub, organizationId }
252
+ : { sub: payload.sub };
202
253
  }
203
254
  return {
204
255
  async verifyJwtClaims(accessToken) {
@@ -213,13 +264,34 @@ export function createPersonTokenCore(config) {
213
264
  const payload = JWT_SHAPE.test(accessToken)
214
265
  ? await verifyJwt(accessToken)
215
266
  : await introspect(accessToken);
216
- if (!payload || typeof payload.sub !== 'string' || !payload.sub)
217
- return null;
218
- return { sub: payload.sub };
267
+ return payload ? identityOf(payload) : null;
268
+ },
269
+ async verifyOutcome(accessToken) {
270
+ let payload;
271
+ if (JWT_SHAPE.test(accessToken)) {
272
+ const jwt = await verifyJwtOutcome(accessToken);
273
+ if (!jwt.ok)
274
+ return jwt;
275
+ const live = await introspectOutcome(accessToken);
276
+ if (!live.ok)
277
+ return live;
278
+ if (typeof live.payload.sub === 'string' && live.payload.sub !== jwt.payload.sub) {
279
+ return { ok: false, reason: 'invalid' };
280
+ }
281
+ payload = jwt.payload;
282
+ }
283
+ else {
284
+ const live = await introspectOutcome(accessToken);
285
+ if (!live.ok)
286
+ return live;
287
+ payload = live.payload;
288
+ }
289
+ const identity = identityOf(payload);
290
+ return identity ? { ok: true, identity } : { ok: false, reason: 'invalid' };
219
291
  },
220
292
  };
221
293
  }
222
294
  export function createPersonTokenVerifier(config) {
223
- const { verify } = createPersonTokenCore(config);
224
- return { verify };
295
+ const { verify, verifyOutcome } = createPersonTokenCore(config);
296
+ return { verify, verifyOutcome };
225
297
  }