@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
@@ -0,0 +1,92 @@
1
+ // Single responsibility: `defineAuth({ passkeys })` — the relying party every ceremony verifies
2
+ // against, refused where it is written. An rp id no origin sits under, or an origin that is not
3
+ // one, would otherwise surface as every passkey in production failing `X_PASSKEY_INVALID`.
4
+
5
+ import type { Auth } from './auth';
6
+ import { passkeyConfigInvalid, passkeysUndeclared } from './passkey-errors';
7
+ import { assertFiniteAuthCount } from './policy-numbers';
8
+
9
+ /** Five minutes: long enough for a person to find their phone, short enough to be single-use. */
10
+ export const DEFAULT_PASSKEY_CHALLENGE_TTL_MS = 5 * 60_000;
11
+
12
+ export interface PasskeyPolicyInput {
13
+ /** The bare host the credentials are scoped to — `example.com`, never a URL. */
14
+ readonly rpId: string;
15
+ /** Every exact origin a ceremony may arrive from. Each must be on `rpId` or a subdomain of it. */
16
+ readonly origins: readonly string[];
17
+ /** Shown by the authenticator at registration. Defaults to `rpId`. */
18
+ readonly rpName?: string | undefined;
19
+ /** How long issued options stay redeemable. Defaults to `DEFAULT_PASSKEY_CHALLENGE_TTL_MS`. */
20
+ readonly challengeTtlMs?: number | undefined;
21
+ }
22
+
23
+ /** The resolved block, read by `passkeyLogin`, `passkeyStepUp` and `verifyPasskeyEvidence`. */
24
+ export interface PasskeyPolicy {
25
+ readonly rpId: string;
26
+ readonly origins: readonly string[];
27
+ readonly rpName: string;
28
+ readonly challengeTtlMs: number;
29
+ }
30
+
31
+ /** What a signature is checked against — all a verifier in another process needs. */
32
+ export type PasskeyRelyingParty = Pick<PasskeyPolicy, 'rpId' | 'origins'>;
33
+
34
+ /** A DNS name: labels of letters, digits and inner hyphens. An IP address is not an rp id (§5.1.4). */
35
+ const HOST =
36
+ /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
37
+ const IPV4 = /^\d+(?:\.\d+){3}$/;
38
+
39
+ export function resolvePasskeyPolicy(input: PasskeyPolicyInput): PasskeyPolicy {
40
+ const declared: { readonly rpId?: unknown; readonly origins?: unknown } = input;
41
+ const { rpId, origins } = declared;
42
+ if (typeof rpId !== 'string' || !HOST.test(rpId) || IPV4.test(rpId)) {
43
+ throw passkeyConfigInvalid('rpId', rpId, 'it is not a bare lowercase host name');
44
+ }
45
+ if (!Array.isArray(origins) || origins.length === 0) {
46
+ throw passkeyConfigInvalid('origins', origins, 'it must list at least one origin');
47
+ }
48
+ const checked = origins.map((origin: unknown) => checkOrigin(rpId, origin));
49
+ const ttl = assertFiniteAuthCount(
50
+ 'passkeys.challengeTtlMs',
51
+ input.challengeTtlMs ?? DEFAULT_PASSKEY_CHALLENGE_TTL_MS,
52
+ 'an issued passkey challenge would never expire',
53
+ 1,
54
+ );
55
+ return Object.freeze({
56
+ rpId,
57
+ origins: Object.freeze(checked),
58
+ rpName: input.rpName ?? rpId,
59
+ challengeTtlMs: ttl,
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Exactly an origin (`URL.origin` round-trips it), on the rp id or under it, and https — except
65
+ * `http://localhost`, the one insecure origin browsers run WebAuthn on.
66
+ */
67
+ function checkOrigin(rpId: string, origin: unknown): string {
68
+ let url: URL | null = null;
69
+ try {
70
+ url = typeof origin === 'string' ? new URL(origin) : null;
71
+ } catch {
72
+ url = null;
73
+ }
74
+ if (url === null || url.origin !== origin) {
75
+ throw passkeyConfigInvalid('origins', origin, 'it is not an origin (scheme://host[:port])');
76
+ }
77
+ const secure =
78
+ url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost');
79
+ if (!secure) {
80
+ throw passkeyConfigInvalid('origins', origin, 'only https, or http://localhost, runs WebAuthn');
81
+ }
82
+ if (url.hostname !== rpId && !url.hostname.endsWith(`.${rpId}`)) {
83
+ throw passkeyConfigInvalid('origins', origin, `its host is not ${rpId} or a subdomain of it`);
84
+ }
85
+ return origin;
86
+ }
87
+
88
+ /** The resolved block of an `Auth`, or the refusal naming where to declare it. */
89
+ export function passkeyPolicyOf(auth: Auth, factory: string): PasskeyPolicy {
90
+ if (auth.passkeys === undefined) throw passkeysUndeclared(factory);
91
+ return auth.passkeys;
92
+ }
@@ -0,0 +1,89 @@
1
+ // Single responsibility: the passkey refusals. Three codes, one each for the three things a caller
2
+ // does differently — retry the ceremony (`X_PASSKEY_INVALID`), fetch fresh options
3
+ // (`X_PASSKEY_CHALLENGE_EXPIRED`), or go and get a proof at all (`X_PASSKEY_STEP_UP_REQUIRED`).
4
+ // WHICH check failed is never in `cause:` — it is a log line's, the way `loginFailed()` is.
5
+
6
+ import { logger, renderCauseValue, renderFixLiteral } from '@ultimat3/core';
7
+ import { AuthError } from './errors';
8
+
9
+ /** Forged, tampered, wrong origin or rp id, no user verification, a counter that went back. */
10
+ export const passkeyInvalid = (ceremony: 'register' | 'login' | 'step-up'): AuthError =>
11
+ new AuthError({
12
+ code: 'X_PASSKEY_INVALID',
13
+ cause: `the passkey ${ceremony} response did not pass verification`,
14
+ fix:
15
+ ceremony === 'register'
16
+ ? 'x errors explain X_PASSKEY_INVALID --json, then run registration again: newPasskey(await passkeys.registerOptions({ userId }))'
17
+ : 'x errors explain X_PASSKEY_INVALID --json, then sign in again: getPasskey(await passkeys.loginOptions())',
18
+ callerFix: 'start the passkey ceremony again from fresh options',
19
+ meta: { ceremony },
20
+ });
21
+
22
+ /** The refusal, with the check that failed in a log line — the only place it is ever named. */
23
+ export const refusePasskey = (
24
+ ceremony: 'register' | 'login' | 'step-up',
25
+ check: string,
26
+ ): AuthError => {
27
+ logger.info('auth.passkey.refused', { ceremony, check });
28
+ return passkeyInvalid(ceremony);
29
+ };
30
+
31
+ /** Unknown, already consumed, or past its TTL — replay and slowness answer the same. */
32
+ export const passkeyChallengeExpired = (ceremony: 'register' | 'login' | 'step-up'): AuthError =>
33
+ new AuthError({
34
+ code: 'X_PASSKEY_CHALLENGE_EXPIRED',
35
+ cause: `the passkey ${ceremony} challenge is unknown, already used or past its ttl`,
36
+ fix: 'x errors explain X_PASSKEY_CHALLENGE_EXPIRED --json, then fetch fresh options and run the ceremony once — every challenge is single-use',
37
+ callerFix: 'fetch fresh options and run the passkey ceremony once',
38
+ meta: { ceremony },
39
+ });
40
+
41
+ export type StepUpMissing = 'no-proof' | 'other-digest' | 'no-passkey';
42
+
43
+ const STEP_UP_CAUSES: ReadonlyMap<StepUpMissing, string> = new Map([
44
+ ['no-proof', 'the call carried no passkey assertion'],
45
+ ['other-digest', 'the passkey assertion approved other bytes than the ones this call acts on'],
46
+ ['no-passkey', 'this user has registered no passkey, so no step-up proof can be made'],
47
+ ]);
48
+
49
+ export const passkeyStepUpRequired = (missing: StepUpMissing): AuthError =>
50
+ new AuthError({
51
+ code: 'X_PASSKEY_STEP_UP_REQUIRED',
52
+ cause: STEP_UP_CAUSES.get(missing) ?? 'no fresh passkey proof',
53
+ fix:
54
+ missing === 'no-passkey'
55
+ ? 'x errors explain X_PASSKEY_STEP_UP_REQUIRED --json, then register one: newPasskey(await passkeys.registerOptions({ userId }))'
56
+ : 'x errors explain X_PASSKEY_STEP_UP_REQUIRED --json, then call stepUp.options({ userId, input }), run getPasskey(options) in the browser and resend the call with its response',
57
+ callerFix: 'approve this action with your passkey, then send it again',
58
+ meta: { missing },
59
+ });
60
+
61
+ /** `defineAuth({ passkeys })` with a value the ceremonies cannot run on. */
62
+ export const passkeyConfigInvalid = (field: string, value: unknown, reason: string): AuthError =>
63
+ new AuthError({
64
+ code: 'X_CONFIG_INVALID',
65
+ cause: `defineAuth({ passkeys: { ${field}: ${renderCauseValue(value)} } }) is refused: ${reason}`,
66
+ fix: `defineAuth({ adapter, passkeys: { rpId: 'example.com', origins: ['https://example.com'] } }) — the rp id is the bare host every origin is on, never ${renderFixLiteral(value, 'that value')}`,
67
+ meta: { option: `passkeys.${field}` },
68
+ });
69
+
70
+ /** `passkeyLogin(auth)` / `passkeyStepUp(auth)` on an `Auth` that declared no `passkeys`. */
71
+ export const passkeysUndeclared = (factory: string): AuthError =>
72
+ new AuthError({
73
+ code: 'X_CONFIG_INVALID',
74
+ cause: `${factory}(auth) was called on an Auth with no passkeys block, so there is no rp id or origin to verify against`,
75
+ fix: "defineAuth({ adapter, passkeys: { rpId: 'example.com', origins: ['https://example.com'] } }) // in the module that builds this Auth",
76
+ meta: { factory },
77
+ });
78
+
79
+ /** A step-up `digest` callback that answered nothing, or a whole payload rather than a digest. */
80
+ export const stepUpDigestInvalid = (length: number): AuthError =>
81
+ new AuthError({
82
+ code: 'X_CONFIG_INVALID',
83
+ cause: `passkeyStepUp's digest callback answered ${length} bytes; a digest is 1 to ${MAX_STEP_UP_DIGEST_BYTES}`,
84
+ fix: 'passkeyStepUp(auth, { digest: ({ input }) => sha256Hex(JSON.stringify(input)) }) // hash what the person approves; never return the payload itself',
85
+ meta: { length },
86
+ });
87
+
88
+ /** A SHA-512 in hex is 128 bytes; this leaves room for a versioned, prefixed one. */
89
+ export const MAX_STEP_UP_DIGEST_BYTES = 1024;
@@ -0,0 +1,74 @@
1
+ // Single responsibility: what a step-up proof IS, checkable by anyone holding the row — the
2
+ // challenge derivation (`stepUpChallenge`) and `verifyPasskeyEvidence`, which re-runs every
3
+ // signature check offline: no adapter, no clock, no session. An auditor's process, a week later,
4
+ // needs the evidence row and the relying party, nothing else.
5
+
6
+ import { timingSafeEqual } from '@ultimat3/core';
7
+ import type { PasskeyEvidence } from './adapter';
8
+ import { base64Url, canonicalBase64UrlBytes } from './base64url';
9
+ import type { PasskeyRelyingParty } from './passkey-config';
10
+ import { readEnvelope, verifyAssertion } from './passkey-verify';
11
+
12
+ /** Versioned domain tag: a step-up challenge can never collide with a sign-in one's random bytes. */
13
+ const STEP_UP_TAG = new TextEncoder().encode('ultimate.passkey.step-up.v1\u0000');
14
+
15
+ /**
16
+ * `SHA-256(tag || nonce || u16(len digest) || digest || userId)`. The nonce is a fixed 32 bytes
17
+ * and the digest is length-prefixed, so no two inputs share an encoding. The nonce makes two
18
+ * approvals of one digest two signatures; the user id makes the row's `userId` part of what was
19
+ * signed, so re-attributing an approval to someone else breaks it.
20
+ */
21
+ export async function stepUpChallenge(
22
+ nonce: Uint8Array,
23
+ digest: Uint8Array,
24
+ userId: string,
25
+ ): Promise<Uint8Array> {
26
+ const user = new TextEncoder().encode(userId);
27
+ const input = new Uint8Array(STEP_UP_TAG.length + nonce.length + 2 + digest.length + user.length);
28
+ let at = 0;
29
+ for (const part of [
30
+ STEP_UP_TAG,
31
+ nonce,
32
+ Uint8Array.of(digest.length >> 8, digest.length & 0xff),
33
+ digest,
34
+ user,
35
+ ]) {
36
+ input.set(part, at);
37
+ at += part.length;
38
+ }
39
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', input));
40
+ }
41
+
42
+ /**
43
+ * `true` only when the stored assertion is a user-verified signature, by the stored key, over a
44
+ * challenge derived from the stored nonce, digest and user, made for this relying party. An edit
45
+ * to any of those, to the key or to the signed bytes is `false`. What it cannot prove alone is
46
+ * that the key is the user's: that is `x_passkeys`' row, and the reason the key is stored here.
47
+ */
48
+ export async function verifyPasskeyEvidence(
49
+ evidence: PasskeyEvidence,
50
+ relyingParty: PasskeyRelyingParty,
51
+ ): Promise<boolean> {
52
+ const nonce = canonicalBase64UrlBytes(evidence.nonce);
53
+ const digest = canonicalBase64UrlBytes(evidence.digest);
54
+ if (nonce === null || nonce.length !== 32 || digest === null || digest.length === 0) return false;
55
+ const envelope = readEnvelope(
56
+ relyingParty,
57
+ {
58
+ id: evidence.credentialId,
59
+ rawId: evidence.credentialId,
60
+ type: 'public-key',
61
+ response: {
62
+ clientDataJSON: evidence.clientDataJSON,
63
+ authenticatorData: evidence.authenticatorData,
64
+ signature: evidence.signature,
65
+ },
66
+ },
67
+ 'webauthn.get',
68
+ );
69
+ if (!envelope.ok) return false;
70
+ const expected = base64Url(await stepUpChallenge(nonce, digest, evidence.userId));
71
+ if (!timingSafeEqual(envelope.value.challenge, expected)) return false;
72
+ const verified = await verifyAssertion(relyingParty, envelope.value, evidence.publicKey);
73
+ return verified.ok;
74
+ }
@@ -0,0 +1,127 @@
1
+ // Single responsibility: `passkeyLogin(auth)` — the two WebAuthn ceremonies a person signs in
2
+ // with: registering a passkey onto an account they already hold, and a usernameless sign-in that
3
+ // ends in the same session `login()` mints. Plain functions over `Auth`; the app wraps each in its
4
+ // own action (tier 2 cannot construct one), exactly as it wraps `login` and `completeMfa`.
5
+
6
+ import type { PasskeyCredential } from './adapter';
7
+ import type { Auth, LoginResult } from './auth';
8
+ import { base64Url } from './base64url';
9
+ import { PASSKEY_ALGORITHMS } from './cose-key';
10
+ import { acceptAssertion } from './passkey-assertion';
11
+ import { issuePasskeyChallenge, redeemPasskeyChallenge } from './passkey-challenge';
12
+ import { passkeyPolicyOf } from './passkey-config';
13
+ import { refusePasskey } from './passkey-errors';
14
+ import { readEnvelope, verifyAttestation } from './passkey-verify';
15
+ import type { PasskeyCreationOptions, PasskeyRequestOptions } from './passkey-wire';
16
+ import { resolveActor } from './policy-bridge';
17
+ import { createSession, sessionCookie } from './session';
18
+
19
+ export interface PasskeyLogin {
20
+ /** For a signed-in user adding a passkey: hand the result to `newPasskey` in the browser. */
21
+ registerOptions(input: { readonly userId: string }): Promise<PasskeyCreationOptions>;
22
+ /** Verifies `newPasskey`'s answer and stores the credential under `userId`. */
23
+ register(input: {
24
+ readonly userId: string;
25
+ readonly response: unknown;
26
+ }): Promise<PasskeyCredential>;
27
+ /** Usernameless: no account is named, so nothing here enumerates one. */
28
+ loginOptions(): Promise<PasskeyRequestOptions>;
29
+ /** Verifies `getPasskey`'s answer and mints a session — `login()`'s result shape. */
30
+ login(input: {
31
+ readonly response: unknown;
32
+ readonly ip?: string | null | undefined;
33
+ readonly userAgent?: string | null | undefined;
34
+ }): Promise<LoginResult>;
35
+ }
36
+
37
+ export function passkeyLogin(auth: Auth): PasskeyLogin {
38
+ const policy = passkeyPolicyOf(auth, 'passkeyLogin');
39
+ return {
40
+ async registerOptions({ userId }) {
41
+ const user = await auth.adapter.findUserById(userId);
42
+ if (user === null || user.disabledAt !== null) throw refusePasskey('register', 'user');
43
+ const { challenge } = await issuePasskeyChallenge(auth, 'register', {
44
+ userId,
45
+ ttlMs: policy.challengeTtlMs,
46
+ });
47
+ const existing = await auth.adapter.listPasskeys(userId);
48
+ return {
49
+ challenge,
50
+ rp: { id: policy.rpId, name: policy.rpName },
51
+ // The user handle is the auth user's id — opaque, never the address (§14.6.1).
52
+ user: {
53
+ id: base64Url(new TextEncoder().encode(userId)),
54
+ name: user.email,
55
+ displayName: user.email,
56
+ },
57
+ pubKeyCredParams: PASSKEY_ALGORITHMS.map((alg) => ({ type: 'public-key' as const, alg })),
58
+ timeout: policy.challengeTtlMs,
59
+ attestation: 'none',
60
+ authenticatorSelection: {
61
+ residentKey: 'required',
62
+ requireResidentKey: true,
63
+ userVerification: 'required',
64
+ },
65
+ excludeCredentials: existing.map((row) => ({
66
+ type: 'public-key' as const,
67
+ id: row.id,
68
+ transports: row.transports,
69
+ })),
70
+ };
71
+ },
72
+
73
+ async register({ userId, response }) {
74
+ const envelope = readEnvelope(policy, response, 'webauthn.create');
75
+ if (!envelope.ok) throw refusePasskey('register', envelope.check);
76
+ const issued = await redeemPasskeyChallenge(auth, 'register', envelope.value.challenge);
77
+ // A challenge issued to one account never registers a key onto another.
78
+ if (issued.userId !== userId) throw refusePasskey('register', 'user');
79
+ const attested = await verifyAttestation(policy, envelope.value);
80
+ if (!attested.ok) throw refusePasskey('register', attested.check);
81
+ return await auth.adapter.putPasskey({
82
+ ...attested.value,
83
+ id: attested.value.credentialId,
84
+ userId,
85
+ createdAt: auth.clock.now(),
86
+ lastUsedAt: null,
87
+ });
88
+ },
89
+
90
+ async loginOptions() {
91
+ const { challenge } = await issuePasskeyChallenge(auth, 'login', {
92
+ userId: null,
93
+ ttlMs: policy.challengeTtlMs,
94
+ });
95
+ return {
96
+ challenge,
97
+ rpId: policy.rpId,
98
+ timeout: policy.challengeTtlMs,
99
+ userVerification: 'required',
100
+ allowCredentials: [],
101
+ };
102
+ },
103
+
104
+ async login({ response, ip, userAgent }) {
105
+ const envelope = readEnvelope(policy, response, 'webauthn.get');
106
+ if (!envelope.ok) throw refusePasskey('login', envelope.check);
107
+ await redeemPasskeyChallenge(auth, 'login', envelope.value.challenge);
108
+ const { credential } = await acceptAssertion(auth, policy, 'login', envelope.value, null);
109
+ const user = await auth.adapter.findUserById(credential.userId);
110
+ if (user === null || user.disabledAt !== null) throw refusePasskey('login', 'user');
111
+ // A user-verified passkey is two factors in one gesture (possession + the device's PIN or
112
+ // biometric), so the session is MFA-satisfied even where a TOTP secret is enrolled.
113
+ const issued = await createSession(auth.sessions, {
114
+ userId: user.id,
115
+ ip: ip ?? null,
116
+ userAgent,
117
+ mfaSatisfied: true,
118
+ });
119
+ return {
120
+ actor: resolveActor({ kind: 'user', user, session: issued.session }),
121
+ session: issued.session,
122
+ token: issued.token,
123
+ cookie: sessionCookie(issued.token, auth.sessions.policy),
124
+ };
125
+ },
126
+ };
127
+ }
@@ -0,0 +1,77 @@
1
+ // Single responsibility: `MemoryAuthAdapter`'s `PasskeyStore` members — the three `x_passkey*`
2
+ // tables as maps, with the same constraints Postgres enforces (a credential id is unique across
3
+ // every user; a take deletes) so a flow that passes here passes there. `passkey-store.test.ts`.
4
+
5
+ import { timingSafeEqual } from '@ultimat3/core';
6
+ import type {
7
+ PasskeyChallengeRecord,
8
+ PasskeyCredential,
9
+ PasskeyEvidence,
10
+ PasskeyStore,
11
+ } from './adapter';
12
+ import { authUniqueViolation } from './errors';
13
+
14
+ const byCreated = <T extends { readonly createdAt: Date; readonly id: string }>(a: T, b: T) =>
15
+ a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
16
+
17
+ /** The members over three maps, as closures — `MemoryAuthAdapter` binds each one as a field. */
18
+ export function memoryPasskeyStore(): PasskeyStore {
19
+ const passkeys = new Map<string, PasskeyCredential>();
20
+ const challenges = new Map<string, PasskeyChallengeRecord>();
21
+ const evidence = new Map<string, PasskeyEvidence>();
22
+ return {
23
+ async putPasskey(credential) {
24
+ if (passkeys.has(credential.id)) throw authUniqueViolation('putPasskey', 'x_passkeys', 'id');
25
+ const stored = { ...credential, transports: [...credential.transports] };
26
+ passkeys.set(credential.id, stored);
27
+ return stored;
28
+ },
29
+ async findPasskey(id) {
30
+ return passkeys.get(id) ?? null;
31
+ },
32
+ async listPasskeys(userId) {
33
+ return [...passkeys.values()].filter((row) => row.userId === userId).sort(byCreated);
34
+ },
35
+ async advancePasskeyCounter(id, expected, next, at) {
36
+ const row = passkeys.get(id);
37
+ if (row === undefined || row.signCount !== expected) return false;
38
+ passkeys.set(id, { ...row, signCount: next, lastUsedAt: at });
39
+ return true;
40
+ },
41
+ async deletePasskey(userId, id) {
42
+ if (passkeys.get(id)?.userId !== userId) return false;
43
+ return passkeys.delete(id);
44
+ },
45
+ async putPasskeyChallenge(record) {
46
+ // Swept from the oldest put, stopping at the first live one: insertion order is issue order,
47
+ // so a put costs the expired rows it removes plus one. A full scan per put made a flood of
48
+ // unauthenticated `loginOptions` within one TTL quadratic. A live row never goes early; an
49
+ // expired one behind a longer-lived row waits for it, which bounds memory by the longest TTL.
50
+ const nowMs = record.createdAt.getTime();
51
+ for (const [id, row] of challenges) {
52
+ if (row.expiresAt.getTime() >= nowMs) break;
53
+ challenges.delete(id);
54
+ }
55
+ // Deleted first, so a re-put of one id moves to the end with its new expiry.
56
+ challenges.delete(record.id);
57
+ challenges.set(record.id, record);
58
+ },
59
+ async takePasskeyChallenge(id, ceremony) {
60
+ const row = challenges.get(id);
61
+ if (row === undefined || row.ceremony !== ceremony) return null;
62
+ challenges.delete(id);
63
+ return row;
64
+ },
65
+ async putPasskeyEvidence(record) {
66
+ if (evidence.has(record.id)) {
67
+ throw authUniqueViolation('putPasskeyEvidence', 'x_passkey_evidence', 'id');
68
+ }
69
+ evidence.set(record.id, record);
70
+ },
71
+ async listPasskeyEvidence(digest) {
72
+ return [...evidence.values()]
73
+ .filter((row) => timingSafeEqual(row.digest, digest))
74
+ .sort(byCreated);
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,155 @@
1
+ // Single responsibility: `PostgresAuthAdapter`'s `PasskeyStore` members, as SQL over the three
2
+ // `x_passkey*` tables (`tables.ts`). Split out of `builtin-adapter.ts`, which delegates one line per
3
+ // member; the readers are this file's because a passkey row is the only one with a bigint counter.
4
+
5
+ import { type DbClient, sql } from '@ultimat3/db';
6
+ import type {
7
+ PasskeyCeremony,
8
+ PasskeyChallengeRecord,
9
+ PasskeyCredential,
10
+ PasskeyEvidence,
11
+ PasskeyStore,
12
+ } from './adapter';
13
+ import { usersUniqueViolation } from './builtin-adapter-violation';
14
+ import { authWriteFailed } from './errors';
15
+
16
+ type Row = Readonly<Record<string, unknown>>;
17
+
18
+ const str = (row: Row, key: string): string => {
19
+ const value = row[key];
20
+ return typeof value === 'string' ? value : '';
21
+ };
22
+ const strOrNull = (row: Row, key: string): string | null => {
23
+ const value = row[key];
24
+ return typeof value === 'string' ? value : null;
25
+ };
26
+ /** `bigint` arrives as a string from some drivers and a number from others; NaN is never a count. */
27
+ const int = (row: Row, key: string): number => {
28
+ const value = Number(row[key]);
29
+ return Number.isSafeInteger(value) ? value : 0;
30
+ };
31
+ const when = (row: Row, key: string): Date | null => {
32
+ const value = row[key];
33
+ if (value instanceof Date) return value;
34
+ if (typeof value !== 'string' && typeof value !== 'number') return null;
35
+ const parsed = new Date(value);
36
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
37
+ };
38
+ const strings = (row: Row, key: string): readonly string[] => {
39
+ const value = row[key];
40
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
41
+ };
42
+
43
+ const toCredential = (row: Row): PasskeyCredential => ({
44
+ id: str(row, 'id'),
45
+ userId: str(row, 'user_id'),
46
+ publicKey: str(row, 'public_key'),
47
+ algorithm: int(row, 'algorithm'),
48
+ signCount: int(row, 'sign_count'),
49
+ transports: strings(row, 'transports'),
50
+ createdAt: when(row, 'created_at') ?? new Date(0),
51
+ lastUsedAt: when(row, 'last_used_at'),
52
+ });
53
+
54
+ const toChallenge = (row: Row, ceremony: PasskeyCeremony): PasskeyChallengeRecord => ({
55
+ id: str(row, 'id'),
56
+ // The DELETE matched on it, so the row's ceremony IS the one asked for.
57
+ ceremony,
58
+ userId: strOrNull(row, 'user_id'),
59
+ digest: strOrNull(row, 'digest'),
60
+ nonce: strOrNull(row, 'nonce'),
61
+ // An unreadable instant is an expired one: fail closed.
62
+ expiresAt: when(row, 'expires_at') ?? new Date(0),
63
+ createdAt: when(row, 'created_at') ?? new Date(0),
64
+ });
65
+
66
+ const toEvidence = (row: Row): PasskeyEvidence => ({
67
+ id: str(row, 'id'),
68
+ userId: str(row, 'user_id'),
69
+ credentialId: str(row, 'credential_id'),
70
+ digest: str(row, 'digest'),
71
+ nonce: str(row, 'nonce'),
72
+ authenticatorData: str(row, 'authenticator_data'),
73
+ clientDataJSON: str(row, 'client_data_json'),
74
+ signature: str(row, 'signature'),
75
+ publicKey: str(row, 'public_key'),
76
+ algorithm: int(row, 'algorithm'),
77
+ createdAt: when(row, 'created_at') ?? new Date(0),
78
+ });
79
+
80
+ /** The members, over a client resolved per call — the adapter's ambient `db()` rule. */
81
+ export function postgresPasskeyStore(client: () => DbClient): PasskeyStore {
82
+ return {
83
+ async putPasskey(credential) {
84
+ let row: Row | null;
85
+ try {
86
+ row = await client().one<Row>(sql`
87
+ insert into x_passkeys (id, user_id, public_key, algorithm, sign_count, transports,
88
+ created_at)
89
+ values (${credential.id}, ${credential.userId}, ${credential.publicKey},
90
+ ${credential.algorithm}, ${credential.signCount}, ${[...credential.transports]},
91
+ ${credential.createdAt})
92
+ returning *`);
93
+ } catch (error) {
94
+ throw usersUniqueViolation('putPasskey', error) ?? error;
95
+ }
96
+ if (row === null) throw authWriteFailed('putPasskey', 'x_passkeys');
97
+ return toCredential(row);
98
+ },
99
+ async findPasskey(id) {
100
+ const row = await client().one<Row>(sql`select * from x_passkeys where id = ${id}`);
101
+ return row === null ? null : toCredential(row);
102
+ },
103
+ async listPasskeys(userId) {
104
+ const rows = await client().query<Row>(sql`
105
+ select * from x_passkeys where user_id = ${userId} order by created_at asc, id collate "C" asc`);
106
+ return rows.map(toCredential);
107
+ },
108
+ async advancePasskeyCounter(id, expected, next, at) {
109
+ const row = await client().one<Row>(sql`
110
+ update x_passkeys set sign_count = ${next}, last_used_at = ${at}
111
+ where id = ${id} and sign_count = ${expected}
112
+ returning id`);
113
+ return row !== null;
114
+ },
115
+ async deletePasskey(userId, id) {
116
+ const removed = await client().execute(
117
+ sql`delete from x_passkeys where id = ${id} and user_id = ${userId}`,
118
+ );
119
+ return removed > 0;
120
+ },
121
+ async putPasskeyChallenge(record) {
122
+ await client().execute(
123
+ sql`delete from x_passkey_challenges where expires_at < ${record.createdAt}`,
124
+ );
125
+ await client().execute(sql`
126
+ insert into x_passkey_challenges (id, ceremony, user_id, digest, nonce, expires_at,
127
+ created_at)
128
+ values (${record.id}, ${record.ceremony}, ${record.userId}, ${record.digest},
129
+ ${record.nonce}, ${record.expiresAt}, ${record.createdAt})`);
130
+ },
131
+ async takePasskeyChallenge(id, ceremony) {
132
+ // DELETE … RETURNING is the take: the row lock makes the second of two racing redemptions
133
+ // find nothing, and a redeemed challenge leaves nothing behind to sweep.
134
+ const row = await client().one<Row>(sql`
135
+ delete from x_passkey_challenges where id = ${id} and ceremony = ${ceremony}
136
+ returning *`);
137
+ return row === null ? null : toChallenge(row, ceremony);
138
+ },
139
+ async putPasskeyEvidence(evidence) {
140
+ await client().execute(sql`
141
+ insert into x_passkey_evidence (id, user_id, credential_id, digest, nonce,
142
+ authenticator_data, client_data_json, signature,
143
+ public_key, algorithm, created_at)
144
+ values (${evidence.id}, ${evidence.userId}, ${evidence.credentialId}, ${evidence.digest},
145
+ ${evidence.nonce}, ${evidence.authenticatorData}, ${evidence.clientDataJSON},
146
+ ${evidence.signature}, ${evidence.publicKey}, ${evidence.algorithm},
147
+ ${evidence.createdAt})`);
148
+ },
149
+ async listPasskeyEvidence(digest) {
150
+ const rows = await client().query<Row>(sql`
151
+ select * from x_passkey_evidence where digest = ${digest} order by created_at asc, id collate "C" asc`);
152
+ return rows.map(toEvidence);
153
+ },
154
+ };
155
+ }