@ultimat3/auth 29.4.0 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CLAUDE.md +46 -58
  2. package/README.md +162 -9
  3. package/package.json +20 -5
  4. package/src/adapter.ts +88 -2
  5. package/src/api-key-resolver.ts +3 -3
  6. package/src/auth.ts +29 -7
  7. package/src/authorization-server-clients.ts +169 -0
  8. package/src/authorization-server-config.ts +239 -0
  9. package/src/authorization-server-errors.ts +23 -0
  10. package/src/authorization-server-postgres-sql.ts +107 -0
  11. package/src/authorization-server-postgres.ts +222 -0
  12. package/src/authorization-server-register.ts +98 -0
  13. package/src/authorization-server-request.ts +126 -0
  14. package/src/authorization-server-routes.ts +181 -0
  15. package/src/authorization-server-schema.ts +82 -0
  16. package/src/authorization-server-store.ts +189 -0
  17. package/src/authorization-server-tokens.ts +118 -0
  18. package/src/authorization-server-types.ts +108 -0
  19. package/src/authorization-server-wire.ts +135 -0
  20. package/src/authorization-server.ts +178 -0
  21. package/src/base64url.ts +40 -0
  22. package/src/builtin-adapter-violation.ts +12 -9
  23. package/src/builtin-adapter.ts +31 -5
  24. package/src/cbor-encode.ts +52 -0
  25. package/src/cbor.ts +129 -0
  26. package/src/client-scope.ts +2 -5
  27. package/src/client.ts +16 -0
  28. package/src/cose-key.ts +129 -0
  29. package/src/errors.ts +51 -0
  30. package/src/grant-authorization-code.ts +113 -0
  31. package/src/grant-client-credentials.ts +55 -0
  32. package/src/grant-device.ts +188 -0
  33. package/src/index.ts +108 -3
  34. package/src/jwks.ts +81 -12
  35. package/src/jwt-bearer-resolver.ts +80 -0
  36. package/src/limiter-install.ts +31 -2
  37. package/src/memory-adapter.ts +18 -0
  38. package/src/mfa-challenge.ts +13 -7
  39. package/src/mfa-replay-postgres.ts +71 -0
  40. package/src/mfa.ts +24 -13
  41. package/src/oauth-bearer-resolver.ts +44 -0
  42. package/src/oauth-cookie.ts +2 -1
  43. package/src/oauth-discovery.ts +17 -6
  44. package/src/oauth-exchange.ts +23 -12
  45. package/src/oauth-profile.ts +20 -9
  46. package/src/oauth-route.ts +5 -5
  47. package/src/passkey-assertion.ts +60 -0
  48. package/src/passkey-challenge.ts +59 -0
  49. package/src/passkey-client.ts +156 -0
  50. package/src/passkey-config.ts +92 -0
  51. package/src/passkey-errors.ts +89 -0
  52. package/src/passkey-evidence.ts +74 -0
  53. package/src/passkey-login.ts +127 -0
  54. package/src/passkey-memory.ts +77 -0
  55. package/src/passkey-postgres.ts +155 -0
  56. package/src/passkey-step-up.ts +135 -0
  57. package/src/passkey-verify.ts +180 -0
  58. package/src/passkey-wire.ts +61 -0
  59. package/src/soft-authenticator.ts +208 -0
  60. package/src/subject-target.ts +73 -0
  61. package/src/tables.ts +46 -0
  62. package/src/token-endpoints.ts +61 -0
  63. package/src/tokens.ts +2 -25
  64. package/src/verification-purposes.ts +61 -0
  65. package/src/verify.ts +86 -12
  66. package/src/webauthn-data.ts +111 -0
  67. package/src/workload.ts +67 -15
package/src/tables.ts CHANGED
@@ -112,6 +112,46 @@ export const X_API_KEYS_TABLE = `create table if not exists x_api_keys (
112
112
  created_at timestamptz not null default now()
113
113
  )`;
114
114
 
115
+ // Passkeys (`passkey-*.ts`). Byte fields are base64url text; the challenge row holds the
116
+ // challenge's sha256, never the challenge; evidence carries its own key so it verifies alone.
117
+ export const X_PASSKEYS_TABLE = `create table if not exists x_passkeys (
118
+ id text primary key,
119
+ user_id uuid not null references x_users (id) on delete cascade,
120
+ public_key text not null,
121
+ algorithm integer not null,
122
+ sign_count bigint not null default 0,
123
+ transports text[] not null default '{}',
124
+ created_at timestamptz not null default now(),
125
+ last_used_at timestamptz
126
+ );
127
+ create index if not exists x_passkeys_user_id_idx on x_passkeys (user_id)`;
128
+
129
+ export const X_PASSKEY_CHALLENGES_TABLE = `create table if not exists x_passkey_challenges (
130
+ id text primary key,
131
+ ceremony text not null,
132
+ user_id uuid references x_users (id) on delete cascade,
133
+ digest text,
134
+ nonce text,
135
+ expires_at timestamptz not null,
136
+ created_at timestamptz not null default now()
137
+ );
138
+ create index if not exists x_passkey_challenges_expires_at_idx on x_passkey_challenges (expires_at)`;
139
+
140
+ export const X_PASSKEY_EVIDENCE_TABLE = `create table if not exists x_passkey_evidence (
141
+ id text primary key,
142
+ user_id uuid not null references x_users (id) on delete cascade,
143
+ credential_id text not null,
144
+ digest text not null,
145
+ nonce text not null,
146
+ authenticator_data text not null,
147
+ client_data_json text not null,
148
+ signature text not null,
149
+ public_key text not null,
150
+ algorithm integer not null,
151
+ created_at timestamptz not null default now()
152
+ );
153
+ create index if not exists x_passkey_evidence_digest_idx on x_passkey_evidence (digest)`;
154
+
115
155
  /** Ordered by foreign-key dependency — run them top to bottom. */
116
156
  export const AUTH_TABLES: readonly string[] = Object.freeze([
117
157
  X_USERS_TABLE,
@@ -120,6 +160,9 @@ export const AUTH_TABLES: readonly string[] = Object.freeze([
120
160
  X_ACCOUNTS_TABLE,
121
161
  X_VERIFICATIONS_TABLE,
122
162
  X_API_KEYS_TABLE,
163
+ X_PASSKEYS_TABLE,
164
+ X_PASSKEY_CHALLENGES_TABLE,
165
+ X_PASSKEY_EVIDENCE_TABLE,
123
166
  ]);
124
167
 
125
168
  export const AUTH_TABLE_NAMES: readonly string[] = Object.freeze([
@@ -128,4 +171,7 @@ export const AUTH_TABLE_NAMES: readonly string[] = Object.freeze([
128
171
  'x_accounts',
129
172
  'x_verifications',
130
173
  'x_api_keys',
174
+ 'x_passkeys',
175
+ 'x_passkey_challenges',
176
+ 'x_passkey_evidence',
131
177
  ]);
@@ -0,0 +1,61 @@
1
+ // Single responsibility: RFC 7009 revocation and RFC 7662 introspection. Revocation answers 200
2
+ // whatever the token was (§2.2: an unknown token is not an error, or the endpoint is an oracle)
3
+ // and ends the whole grant at once. Introspection is a resource server's question, so it needs a
4
+ // confidential client and answers `{ active: false }`, nothing else, for a token it does not own.
5
+
6
+ import type { ResolvedAuthServerConfig } from './authorization-server-config';
7
+ import { refused, resolveAccess, type TokenOutcome } from './authorization-server-tokens';
8
+ import type { AuthServerClient } from './authorization-server-types';
9
+ import { sha256Hex } from './tokens';
10
+
11
+ const INACTIVE: TokenOutcome = { ok: true, body: { active: false } };
12
+
13
+ export async function revokeToken(
14
+ config: ResolvedAuthServerConfig,
15
+ form: ReadonlyMap<string, string>,
16
+ client: AuthServerClient,
17
+ ): Promise<TokenOutcome> {
18
+ const token = form.get('token');
19
+ if (token === undefined) return refused('invalid_request', 'token is required');
20
+ const record = await config.store.getToken(sha256Hex(token));
21
+ const grant = record === null ? null : await config.store.getGrant(record.grantId);
22
+ // Only the client the token was issued to may revoke it (§2.1); anyone else learns nothing.
23
+ if (grant !== null && grant.clientId === client.clientId) {
24
+ await config.store.revokeGrant(grant.id, config.clock.now());
25
+ config.emit({ kind: 'revoked', clientId: client.clientId });
26
+ }
27
+ return { ok: true, body: {} };
28
+ }
29
+
30
+ export async function introspectToken(
31
+ config: ResolvedAuthServerConfig,
32
+ form: ReadonlyMap<string, string>,
33
+ client: AuthServerClient,
34
+ ): Promise<TokenOutcome> {
35
+ if (client.secretHash === null) {
36
+ return refused('invalid_client', 'introspection needs a confidential client', 401);
37
+ }
38
+ const token = form.get('token');
39
+ if (token === undefined) return refused('invalid_request', 'token is required');
40
+ const live = await resolveAccess(config, token);
41
+ if (live === null) return INACTIVE;
42
+ const { grant } = live;
43
+ const owned =
44
+ grant.clientId === client.clientId ||
45
+ (grant.resource !== null && client.resources.includes(grant.resource));
46
+ if (!owned) return INACTIVE;
47
+ return {
48
+ ok: true,
49
+ body: {
50
+ active: true,
51
+ scope: grant.scopes.join(' '),
52
+ client_id: grant.clientId,
53
+ sub: grant.subject,
54
+ token_type: 'Bearer',
55
+ iss: config.issuer,
56
+ iat: Math.floor(live.token.createdAt.getTime() / 1000),
57
+ exp: Math.floor(live.token.expiresAt.getTime() / 1000),
58
+ ...(grant.resource === null ? {} : { aud: grant.resource }),
59
+ },
60
+ };
61
+ }
package/src/tokens.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  // same one — imported from core by every caller, never re-exported here (`X_HELPER_COPY`).
6
6
 
7
7
  import { finiteCount, timingSafeEqual } from '@ultimat3/core';
8
+ import { base64Url } from './base64url';
8
9
 
9
- const BASE64URL_UNSAFE = /[+/=]/g;
10
- const BASE64URL_REPLACEMENTS: Readonly<Record<string, string>> = { '+': '-', '/': '_', '=': '' };
10
+ export { base64Url, base64UrlBytes } from './base64url';
11
11
 
12
12
  export function randomBytes(length: number): Uint8Array {
13
13
  const bytes = new Uint8Array(length);
@@ -15,12 +15,6 @@ export function randomBytes(length: number): Uint8Array {
15
15
  return bytes;
16
16
  }
17
17
 
18
- export function base64Url(bytes: Uint8Array): string {
19
- let binary = '';
20
- for (const byte of bytes) binary += String.fromCharCode(byte);
21
- return btoa(binary).replace(BASE64URL_UNSAFE, (char) => BASE64URL_REPLACEMENTS[char] ?? '');
22
- }
23
-
24
18
  /**
25
19
  * 32 bytes -> 43 base64url chars. Opaque by construction: it encodes nothing about the user.
26
20
  *
@@ -39,23 +33,6 @@ export function randomToken(byteLength = 32): string {
39
33
  return base64Url(randomBytes(finiteCount('randomToken', 'byteLength', byteLength, 1)));
40
34
  }
41
35
 
42
- /**
43
- * The inverse of `base64Url`, for the segments of a JWT. Answers `null` rather than throwing
44
- * because every caller is reading an attacker-supplied string — a `URIError` or an `InvalidCharacterError`
45
- * escaping from here would turn a coded refusal into a 500, the same reason `readCookie` never throws.
46
- */
47
- export function base64UrlBytes(segment: string): Uint8Array<ArrayBuffer> | null {
48
- const padded = segment
49
- .replaceAll('-', '+')
50
- .replaceAll('_', '/')
51
- .padEnd(Math.ceil(segment.length / 4) * 4, '=');
52
- try {
53
- return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
54
- } catch {
55
- return null;
56
- }
57
- }
58
-
59
36
  export function sha256Hex(value: string): string {
60
37
  return new Bun.CryptoHasher('sha256').update(value).digest('hex');
61
38
  }
@@ -0,0 +1,61 @@
1
+ // Single responsibility: the one registry of verification purposes — a name, the mail template it
2
+ // sends, how long its token lives and how its identifier is keyed. Open, not an enum: an invite or
3
+ // an account deletion is the same single-use mailed credential as a reset, so it is declared here
4
+ // once (`defineVerificationPurpose`, `verify.ts`) instead of hand-rolling hashing and compare again.
5
+
6
+ import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
7
+ import { AuthError } from './errors';
8
+
9
+ export interface VerificationPurposeDef {
10
+ readonly name: string;
11
+ /** A catalog key, not copy: the body lives in the app's i18n catalog. */
12
+ readonly template: string;
13
+ readonly ttlMs: number;
14
+ /** How the identifier becomes the store key and the mail address. */
15
+ readonly identifier: (raw: string) => string;
16
+ }
17
+
18
+ // A Map, never a record keyed by an attacker-reachable string: `consumeVerification` looks a
19
+ // purpose up by whatever name its caller passed, and `__proto__` on an object is not "undeclared".
20
+ const purposes = new Map<string, VerificationPurposeDef>();
21
+
22
+ const purposeInvalid = (cause: string, fix: string): AuthError =>
23
+ new AuthError({ code: 'X_VERIFICATION_PURPOSE_INVALID', cause, fix });
24
+
25
+ /** At boot. A second declaration under one name is refused: two templates for one link. */
26
+ export function registerVerificationPurpose(def: VerificationPurposeDef): void {
27
+ if (def.name.trim() === '' || def.name !== def.name.trim()) {
28
+ throw purposeInvalid(
29
+ `defineVerificationPurpose() was handed the name ${renderCauseValue(def.name)}, which is empty or padded`,
30
+ "defineVerificationPurpose({ name: 'member-invite', template, ttlMs }) # a non-empty, unpadded name",
31
+ );
32
+ }
33
+ if (def.template.trim() === '') {
34
+ throw purposeInvalid(
35
+ `defineVerificationPurpose({ name: '${def.name}' }) has no template, so its token could not be mailed`,
36
+ `defineVerificationPurpose({ name: '${def.name}', template: 'auth.${def.name}', ttlMs }) # and add the key to your i18n catalog`,
37
+ );
38
+ }
39
+ if (purposes.has(def.name)) {
40
+ throw purposeInvalid(
41
+ `the verification purpose '${def.name}' is declared twice, so which template and lifetime it has depends on import order`,
42
+ `defineVerificationPurpose({ name: '<another-name>', template, ttlMs }) # rename one of the two '${def.name}' declarations, or import the first`,
43
+ );
44
+ }
45
+ purposes.set(def.name, def);
46
+ }
47
+
48
+ /** The declared purpose, or the 400 an undeclared one is. Never a default. */
49
+ export function verificationPurpose(name: string): VerificationPurposeDef {
50
+ const def = purposes.get(name);
51
+ if (def !== undefined) return def;
52
+ throw new AuthError({
53
+ code: 'X_VERIFICATION_PURPOSE_UNKNOWN',
54
+ // The name may be a request field: rendered as a literal, so it cannot forge a log line.
55
+ cause: `no verification purpose named ${renderCauseValue(name)} is declared`,
56
+ fix: `defineVerificationPurpose({ name: ${renderFixLiteral(name, "'<purpose>'")}, template: 'auth.<purpose>', ttlMs }) # once, at boot`,
57
+ });
58
+ }
59
+
60
+ /** Every declared name, for a test or `x doctor` to list. */
61
+ export const verificationPurposeNames = (): readonly string[] => [...purposes.keys()];
package/src/verify.ts CHANGED
@@ -1,6 +1,6 @@
1
- // Single responsibility: email verification and password reset — the two flows where a link in
2
- // an inbox is a credential. Tokens are single-use, expiring, stored hashed and compared in
3
- // constant time. Mail leaves through an injected structural `MailSender` port: `@ultimat3/mail` is
1
+ // Single responsibility: every flow where a link in an inbox is a credential — email verification
2
+ // and password reset built in, any other through `defineVerificationPurpose`, one code path.
3
+ // Tokens are single-use, expiring, stored hashed and compared in constant time. Mail leaves through an injected structural `MailSender` port: `@ultimat3/mail` is
4
4
  // tier 4 and auth is tier 2, so the app wires mail's `send` in and auth imports nothing upward.
5
5
 
6
6
  import type { Clock } from '@ultimat3/core';
@@ -10,6 +10,7 @@ import { normaliseEmail } from './email';
10
10
  import { AuthError } from './errors';
11
11
  import { assertFiniteAuthCount } from './policy-numbers';
12
12
  import { randomToken, sha256Hex } from './tokens';
13
+ import { registerVerificationPurpose, verificationPurpose } from './verification-purposes';
13
14
 
14
15
  /**
15
16
  * `locale` is required, not optional: an inferred locale is how a user gets a security email
@@ -24,18 +25,25 @@ export interface MailSender {
24
25
  ): Promise<void>;
25
26
  }
26
27
 
28
+ /** The two purposes this package declares itself. Any other is `defineVerificationPurpose`'s. */
27
29
  export const VERIFICATION_PURPOSES = ['email-verify', 'password-reset'] as const;
28
30
 
29
- export type VerificationPurpose = (typeof VERIFICATION_PURPOSES)[number];
31
+ /**
32
+ * Open since 30.0: any declared name. A `switch` over it needs a default arm. Undeclared at the
33
+ * call is `X_VERIFICATION_PURPOSE_UNKNOWN`, never a silent default template.
34
+ */
35
+ export type VerificationPurpose = string;
36
+
37
+ type BuiltinPurpose = (typeof VERIFICATION_PURPOSES)[number];
30
38
 
31
39
  /** Catalog keys, not copy. The template body lives in the app's i18n catalog. */
32
- export const VERIFICATION_TEMPLATES: Readonly<Record<VerificationPurpose, string>> = {
40
+ export const VERIFICATION_TEMPLATES: Readonly<Record<BuiltinPurpose, string>> = {
33
41
  'email-verify': 'auth.email-verify',
34
42
  'password-reset': 'auth.password-reset',
35
43
  };
36
44
 
37
45
  /** Short by default: a reset link is a password, and a password that lives a day is a liability. */
38
- export const DEFAULT_VERIFICATION_TTL_MS: Readonly<Record<VerificationPurpose, number>> = {
46
+ export const DEFAULT_VERIFICATION_TTL_MS: Readonly<Record<BuiltinPurpose, number>> = {
39
47
  'email-verify': 24 * 60 * 60 * 1000,
40
48
  'password-reset': 60 * 60 * 1000,
41
49
  };
@@ -67,6 +75,7 @@ export async function issueVerification(
67
75
  runtime: VerificationRuntime,
68
76
  input: IssueVerificationInput,
69
77
  ): Promise<IssuedVerification> {
78
+ const purpose = verificationPurpose(input.purpose);
70
79
  const now = runtime.clock.now();
71
80
  const token = randomToken(32);
72
81
  // Screened HERE, above the write, not beside the `Date` it feeds. Unscreened this threw a bare
@@ -77,7 +86,7 @@ export async function issueVerification(
77
86
  // had replaced whatever live token that address held on its way to failing.
78
87
  const ttl = assertFiniteAuthCount(
79
88
  'verification.ttlMs',
80
- input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[input.purpose],
89
+ input.ttlMs ?? purpose.ttlMs,
81
90
  'the stored expiry is an Invalid Date, `now >= NaN` is false, and the mailed link is a password that never expires',
82
91
  1,
83
92
  );
@@ -88,10 +97,10 @@ export async function issueVerification(
88
97
  // that was per SPELLING, so N live reset tokens for one address could be held at once by varying
89
98
  // the case, and each one of them is a password. It is also the address the mail is sent to, so
90
99
  // the link goes to the inbox the account is keyed by rather than to whatever a form posted.
91
- const identifier = normaliseEmail(input.identifier);
100
+ const identifier = purpose.identifier(input.identifier);
92
101
  await runtime.store.putVerification({
93
102
  id: randomToken(12),
94
- purpose: input.purpose,
103
+ purpose: purpose.name,
95
104
  identifier,
96
105
  tokenHash: sha256Hex(token),
97
106
  expiresAt,
@@ -99,7 +108,7 @@ export async function issueVerification(
99
108
  createdAt: now,
100
109
  });
101
110
  await runtime.mail.send(
102
- VERIFICATION_TEMPLATES[input.purpose],
111
+ purpose.template,
103
112
  identifier,
104
113
  {
105
114
  ...(input.data ?? {}),
@@ -139,12 +148,13 @@ export async function consumeVerification(
139
148
  runtime: VerificationRuntime,
140
149
  input: ConsumeVerificationInput,
141
150
  ): Promise<AuthVerification> {
151
+ const purpose = verificationPurpose(input.purpose);
142
152
  const tokenHash = sha256Hex(input.token);
143
153
  // Normalised on the way OUT as well as in, or a link issued for `ada@x.test` is unredeemable by
144
154
  // a form that posted `Ada@X.test` — one door, one key.
145
155
  const record = await runtime.store.takeVerification(
146
- input.purpose,
147
- normaliseEmail(input.identifier),
156
+ purpose.name,
157
+ purpose.identifier(input.identifier),
148
158
  tokenHash,
149
159
  );
150
160
  if (record === null) throw verificationInvalid(input.purpose);
@@ -159,3 +169,67 @@ export async function consumeVerification(
159
169
  }
160
170
  return record;
161
171
  }
172
+
173
+ export interface DefineVerificationPurposeInput {
174
+ /** The store key's first half and the name a consume must repeat. Unique, process-wide. */
175
+ readonly name: string;
176
+ /** The i18n catalog key of the mail: `'auth.member-invite'`. */
177
+ readonly template: string;
178
+ /** Required: how long a mailed credential lives is the app's call, stated once. */
179
+ readonly ttlMs: number;
180
+ /** The identifier's one normalisation. Defaults to `normaliseEmail`, the address door's. */
181
+ readonly identifier?: ((raw: string) => string) | undefined;
182
+ }
183
+
184
+ export interface VerificationPurposeHandle {
185
+ readonly name: string;
186
+ issue(
187
+ runtime: VerificationRuntime,
188
+ input: Omit<IssueVerificationInput, 'purpose'>,
189
+ ): Promise<IssuedVerification>;
190
+ consume(
191
+ runtime: VerificationRuntime,
192
+ input: Omit<ConsumeVerificationInput, 'purpose'>,
193
+ ): Promise<AuthVerification>;
194
+ }
195
+
196
+ /**
197
+ * A single-use mailed credential under its own name — an invite, an email change, an account
198
+ * deletion — on the one code path the two built-ins run: hashed at rest, consumed on a hash
199
+ * match in one atomic step, compared in constant time. Call it at module scope, once.
200
+ */
201
+ export function defineVerificationPurpose(
202
+ input: DefineVerificationPurposeInput,
203
+ ): VerificationPurposeHandle {
204
+ const ttlMs = assertFiniteAuthCount(
205
+ `verification.${input.name}.ttlMs`,
206
+ input.ttlMs,
207
+ 'the stored expiry is an Invalid Date and the mailed link is a credential that never expires',
208
+ 1,
209
+ );
210
+ registerVerificationPurpose({
211
+ name: input.name,
212
+ template: input.template,
213
+ ttlMs,
214
+ identifier: input.identifier ?? normaliseEmail,
215
+ });
216
+ const name = input.name;
217
+ return {
218
+ name,
219
+ issue: (runtime, rest) => issueVerification(runtime, { ...rest, purpose: name }),
220
+ consume: (runtime, rest) => consumeVerification(runtime, { ...rest, purpose: name }),
221
+ };
222
+ }
223
+
224
+ /** The two built-ins, as instances: one code path for every purpose. */
225
+ export const emailVerification = defineVerificationPurpose({
226
+ name: 'email-verify',
227
+ template: VERIFICATION_TEMPLATES['email-verify'],
228
+ ttlMs: DEFAULT_VERIFICATION_TTL_MS['email-verify'],
229
+ });
230
+
231
+ export const passwordReset = defineVerificationPurpose({
232
+ name: 'password-reset',
233
+ template: VERIFICATION_TEMPLATES['password-reset'],
234
+ ttlMs: DEFAULT_VERIFICATION_TTL_MS['password-reset'],
235
+ });
@@ -0,0 +1,111 @@
1
+ // Single responsibility: the two structures every WebAuthn ceremony signs over, read from the
2
+ // bytes the browser forwarded — `authenticatorData` (W3C WebAuthn L3 §6.1) and `clientDataJSON`
3
+ // (§5.8.1). Parsing only: what each field must equal is `passkey-verify.ts`'s to decide.
4
+
5
+ import { canonicalBase64UrlBytes } from './base64url';
6
+ import { type CborValue, decodeCborItem } from './cbor';
7
+ import { isRecord } from './json';
8
+
9
+ /** User Present, User Verified, Attested credential data, Extension data (§6.1 flags). */
10
+ export const FLAG_UP = 0x01;
11
+ export const FLAG_UV = 0x04;
12
+ export const FLAG_AT = 0x40;
13
+ export const FLAG_ED = 0x80;
14
+
15
+ /** Longer than this is not a credential id an authenticator mints (§5.8.3, 1023 bytes). */
16
+ const MAX_CREDENTIAL_ID_BYTES = 1023;
17
+
18
+ export interface AttestedCredential {
19
+ readonly aaguid: Uint8Array;
20
+ readonly credentialId: Uint8Array;
21
+ /** The COSE_Key exactly as the authenticator encoded it — what is stored and re-imported. */
22
+ readonly publicKey: Uint8Array;
23
+ }
24
+
25
+ export interface AuthenticatorData {
26
+ readonly rpIdHash: Uint8Array;
27
+ readonly flags: number;
28
+ readonly signCount: number;
29
+ readonly attested: AttestedCredential | null;
30
+ }
31
+
32
+ /** `null` for a truncated buffer, trailing bytes, or an attested key that is not one CBOR item. */
33
+ export function parseAuthenticatorData(bytes: Uint8Array): AuthenticatorData | null {
34
+ if (bytes.length < 37) return null;
35
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
36
+ const flags = bytes[32] ?? 0;
37
+ const signCount = view.getUint32(33);
38
+ let at = 37;
39
+ let attested: AttestedCredential | null = null;
40
+ if ((flags & FLAG_AT) !== 0) {
41
+ if (bytes.length < at + 18) return null;
42
+ const aaguid = bytes.slice(at, at + 16);
43
+ const idLength = view.getUint16(at + 16);
44
+ at += 18;
45
+ if (idLength === 0 || idLength > MAX_CREDENTIAL_ID_BYTES || at + idLength > bytes.length) {
46
+ return null;
47
+ }
48
+ const credentialId = bytes.slice(at, at + idLength);
49
+ at += idLength;
50
+ const key = decodeCborItem(bytes, at);
51
+ if (key === null || !(key.value instanceof Map)) return null;
52
+ attested = { aaguid, credentialId, publicKey: bytes.slice(at, key.end) };
53
+ at = key.end;
54
+ }
55
+ if ((flags & FLAG_ED) !== 0) {
56
+ const extensions = decodeCborItem(bytes, at);
57
+ if (extensions === null || !(extensions.value instanceof Map)) return null;
58
+ at = extensions.end;
59
+ }
60
+ if (at !== bytes.length) return null;
61
+ return { rpIdHash: bytes.slice(0, 32), flags, signCount, attested };
62
+ }
63
+
64
+ export interface ClientData {
65
+ readonly type: string;
66
+ /** The challenge as the browser echoed it: base64url TEXT, compared as text. */
67
+ readonly challenge: string;
68
+ readonly origin: string;
69
+ readonly crossOrigin: boolean;
70
+ }
71
+
72
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
73
+
74
+ /** `null` unless the bytes are UTF-8 JSON carrying the four fields, each of its type. */
75
+ export function parseClientData(bytes: Uint8Array): ClientData | null {
76
+ let parsed: unknown;
77
+ try {
78
+ parsed = JSON.parse(utf8.decode(bytes));
79
+ } catch {
80
+ return null;
81
+ }
82
+ if (!isRecord(parsed)) return null;
83
+ const { type, challenge, origin, crossOrigin } = parsed;
84
+ if (typeof type !== 'string' || typeof origin !== 'string') return null;
85
+ if (canonicalBase64UrlBytes(challenge) === null || typeof challenge !== 'string') return null;
86
+ if (crossOrigin !== undefined && typeof crossOrigin !== 'boolean') return null;
87
+ return { type, challenge, origin, crossOrigin: crossOrigin === true };
88
+ }
89
+
90
+ export interface AttestationObject {
91
+ readonly fmt: string;
92
+ readonly authData: Uint8Array;
93
+ }
94
+
95
+ /**
96
+ * The attestation object's `fmt` and `authData`. `attStmt` is read for shape and NOT verified:
97
+ * registration asks for `attestation: 'none'`, so no statement is trusted for anything — the
98
+ * credential's key is bound by the signed challenge of every later assertion, not by its vendor.
99
+ */
100
+ export function parseAttestationObject(bytes: Uint8Array): AttestationObject | null {
101
+ const decoded = decodeCborItem(bytes);
102
+ if (decoded === null || decoded.end !== bytes.length || !(decoded.value instanceof Map)) {
103
+ return null;
104
+ }
105
+ const map: ReadonlyMap<number | string, CborValue> = decoded.value;
106
+ const fmt = map.get('fmt');
107
+ const authData = map.get('authData');
108
+ if (typeof fmt !== 'string' || !(authData instanceof Uint8Array)) return null;
109
+ if (!(map.get('attStmt') instanceof Map)) return null;
110
+ return { fmt, authData };
111
+ }
package/src/workload.ts CHANGED
@@ -35,8 +35,28 @@ export interface WorkloadToken {
35
35
  readonly claims: WorkloadClaims;
36
36
  /** Feed this to `actorFromService()` — the same funnel every other credential goes through. */
37
37
  readonly identity: ServiceIdentity;
38
+ /**
39
+ * The whole decoded payload, read only after the signature, issuer, audience and time bounds
40
+ * held: an issuer's own claim (`tid`, `groups`, `azp`) is the app's to judge, and re-decoding the
41
+ * token to reach it is a second parser beside this one.
42
+ */
43
+ readonly raw: Readonly<Record<string, unknown>>;
38
44
  }
39
45
 
46
+ /** Which check refused a token — a name for a log line, never a claim's value. */
47
+ export type WorkloadCheck =
48
+ | 'signature'
49
+ | 'payload'
50
+ | 'subject'
51
+ | 'expiry'
52
+ | 'issuer'
53
+ | 'audience'
54
+ | 'not-before';
55
+
56
+ export type WorkloadTokenCheck =
57
+ | { readonly ok: true; readonly token: WorkloadToken }
58
+ | { readonly ok: false; readonly check: WorkloadCheck; readonly reason: string };
59
+
40
60
  export interface VerifyWorkloadTokenInput {
41
61
  readonly token: string;
42
62
  /** Every issuer this caller may claim. A list, never a wildcard: an unpinned `iss` is no check. */
@@ -77,35 +97,63 @@ const readAudience = (value: unknown): readonly string[] => {
77
97
  * wrote it, and every check below would otherwise pass for a token the caller minted themselves.
78
98
  */
79
99
  export async function verifyWorkloadToken(input: VerifyWorkloadTokenInput): Promise<WorkloadToken> {
100
+ const checked = await checkWorkloadToken(input);
101
+ if (!checked.ok) throw refused(checked.reason);
102
+ return checked.token;
103
+ }
104
+
105
+ const checkRefusal = (check: WorkloadCheck, reason: string): WorkloadTokenCheck => ({
106
+ ok: false,
107
+ check,
108
+ reason,
109
+ });
110
+
111
+ /**
112
+ * `verifyWorkloadToken`'s checks as a verdict rather than a throw, naming the check that refused —
113
+ * what `jwtBearerResolver` logs. A key source that throws still throws: an unreadable key set is
114
+ * not a verdict on the token, and the caller decides what it is.
115
+ */
116
+ export async function checkWorkloadToken(
117
+ input: VerifyWorkloadTokenInput,
118
+ ): Promise<WorkloadTokenCheck> {
80
119
  if (!(await verifyJwtSignature(input.token, input.keys))) {
81
- throw refused('the signature does not verify against the published key set');
120
+ return checkRefusal('signature', 'the signature does not verify against the published key set');
82
121
  }
83
122
 
84
123
  const payloadSegment = input.token.split('.')[1];
85
124
  const parsed = payloadSegment === undefined ? null : decodeJwtSegment(payloadSegment);
86
125
  if (parsed === null) {
87
- throw refused('the payload is not base64url-encoded JSON describing an object');
126
+ return checkRefusal(
127
+ 'payload',
128
+ 'the payload is not base64url-encoded JSON describing an object',
129
+ );
88
130
  }
89
131
 
90
132
  const iss = parsed['iss'];
91
133
  const sub = parsed['sub'];
92
134
  const exp = parsed['exp'];
93
135
  if (typeof iss !== 'string' || typeof sub !== 'string' || sub === '') {
94
- throw refused('the payload carries no iss or sub');
136
+ return checkRefusal('subject', 'the payload carries no iss or sub');
137
+ }
138
+ if (typeof exp !== 'number') return checkRefusal('expiry', 'the payload carries no numeric exp');
139
+ if (!input.issuers.includes(iss)) {
140
+ return checkRefusal('issuer', 'the issuer is not one this service accepts');
95
141
  }
96
- if (typeof exp !== 'number') throw refused('the payload carries no numeric exp');
97
- if (!input.issuers.includes(iss)) throw refused('the issuer is not one this service accepts');
98
142
 
99
143
  const aud = readAudience(parsed['aud']);
100
- if (!aud.includes(input.audience)) throw refused('the token is addressed to another audience');
144
+ if (!aud.includes(input.audience)) {
145
+ return checkRefusal('audience', 'the token is addressed to another audience');
146
+ }
101
147
 
102
148
  const nowMs = input.clock.now().getTime();
103
149
  // The same skew the id token path allows, from the same declaration: two servers rarely agree
104
150
  // on the second, and a second number here would drift from that one.
105
- if (exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= nowMs) throw refused('the token is already expired');
151
+ if (exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= nowMs) {
152
+ return checkRefusal('expiry', 'the token is already expired');
153
+ }
106
154
  const nbf = parsed['nbf'];
107
155
  if (typeof nbf === 'number' && nbf * 1000 - ID_TOKEN_CLOCK_SKEW_MS > nowMs) {
108
- throw refused('the token is not valid yet');
156
+ return checkRefusal('not-before', 'the token is not valid yet');
109
157
  }
110
158
  const iat = parsed['iat'];
111
159
 
@@ -119,13 +167,17 @@ export async function verifyWorkloadToken(input: VerifyWorkloadTokenInput): Prom
119
167
  ...(typeof iat === 'number' ? { iat } : {}),
120
168
  };
121
169
  return {
122
- claims,
123
- identity: {
124
- // The SPIFFE ID, the service account's `system:serviceaccount:ns:name`, or the IMDS
125
- // principal — whichever the issuer put there. Never rewritten, so a trace names the caller.
126
- id: claims.sub,
127
- scopes: claims.scopes,
128
- ...(input.orgId === undefined ? {} : { orgId: input.orgId }),
170
+ ok: true,
171
+ token: {
172
+ claims,
173
+ identity: {
174
+ // The SPIFFE ID, the service account's `system:serviceaccount:ns:name`, or the IMDS
175
+ // principal — whichever the issuer put there. Never rewritten, so a trace names the caller.
176
+ id: claims.sub,
177
+ scopes: claims.scopes,
178
+ ...(input.orgId === undefined ? {} : { orgId: input.orgId }),
179
+ },
180
+ raw: parsed,
129
181
  },
130
182
  };
131
183
  }