@ultimat3/auth 29.3.2 → 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.
- package/CLAUDE.md +46 -58
- package/README.md +162 -9
- package/package.json +20 -5
- package/src/adapter.ts +88 -2
- package/src/api-key-resolver.ts +3 -3
- package/src/auth.ts +29 -7
- package/src/authorization-server-clients.ts +169 -0
- package/src/authorization-server-config.ts +239 -0
- package/src/authorization-server-errors.ts +23 -0
- package/src/authorization-server-postgres-sql.ts +107 -0
- package/src/authorization-server-postgres.ts +222 -0
- package/src/authorization-server-register.ts +98 -0
- package/src/authorization-server-request.ts +126 -0
- package/src/authorization-server-routes.ts +181 -0
- package/src/authorization-server-schema.ts +82 -0
- package/src/authorization-server-store.ts +189 -0
- package/src/authorization-server-tokens.ts +118 -0
- package/src/authorization-server-types.ts +108 -0
- package/src/authorization-server-wire.ts +135 -0
- package/src/authorization-server.ts +178 -0
- package/src/base64url.ts +40 -0
- package/src/builtin-adapter-violation.ts +12 -9
- package/src/builtin-adapter.ts +31 -5
- package/src/cbor-encode.ts +52 -0
- package/src/cbor.ts +129 -0
- package/src/client-scope.ts +2 -5
- package/src/client.ts +16 -0
- package/src/cose-key.ts +129 -0
- package/src/errors.ts +51 -0
- package/src/grant-authorization-code.ts +113 -0
- package/src/grant-client-credentials.ts +55 -0
- package/src/grant-device.ts +188 -0
- package/src/index.ts +108 -3
- package/src/jwks.ts +81 -12
- package/src/jwt-bearer-resolver.ts +80 -0
- package/src/limiter-install.ts +31 -2
- package/src/memory-adapter.ts +18 -0
- package/src/mfa-challenge.ts +13 -7
- package/src/mfa-replay-postgres.ts +71 -0
- package/src/mfa.ts +24 -13
- package/src/oauth-bearer-resolver.ts +44 -0
- package/src/oauth-cookie.ts +2 -1
- package/src/oauth-discovery.ts +17 -6
- package/src/oauth-exchange.ts +23 -12
- package/src/oauth-profile.ts +20 -9
- package/src/oauth-route.ts +5 -5
- package/src/passkey-assertion.ts +60 -0
- package/src/passkey-challenge.ts +59 -0
- package/src/passkey-client.ts +156 -0
- package/src/passkey-config.ts +92 -0
- package/src/passkey-errors.ts +89 -0
- package/src/passkey-evidence.ts +74 -0
- package/src/passkey-login.ts +127 -0
- package/src/passkey-memory.ts +77 -0
- package/src/passkey-postgres.ts +155 -0
- package/src/passkey-step-up.ts +135 -0
- package/src/passkey-verify.ts +180 -0
- package/src/passkey-wire.ts +61 -0
- package/src/soft-authenticator.ts +208 -0
- package/src/subject-target.ts +73 -0
- package/src/tables.ts +46 -0
- package/src/token-endpoints.ts +61 -0
- package/src/tokens.ts +2 -25
- package/src/verification-purposes.ts +61 -0
- package/src/verify.ts +86 -12
- package/src/webauthn-data.ts +111 -0
- package/src/workload.ts +67 -15
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Single responsibility: `passkeyStepUp(auth, { digest })` — a fresh, user-verified assertion
|
|
2
|
+
// bound to bytes the SERVER computed from the action's input, so the stored assertion proves a
|
|
3
|
+
// person approved exactly those bytes. The WebAuthn challenge is derived (nonce + digest), never
|
|
4
|
+
// supplied: a caller cannot choose what is signed, only present what the server asked for.
|
|
5
|
+
|
|
6
|
+
import { timingSafeEqual, uuidV7 } from '@ultimat3/core';
|
|
7
|
+
import type { PasskeyEvidence } from './adapter';
|
|
8
|
+
import type { Auth } from './auth';
|
|
9
|
+
import { base64Url } from './base64url';
|
|
10
|
+
import { acceptAssertion } from './passkey-assertion';
|
|
11
|
+
import { issuePasskeyChallenge, redeemPasskeyChallenge } from './passkey-challenge';
|
|
12
|
+
import { passkeyPolicyOf } from './passkey-config';
|
|
13
|
+
import {
|
|
14
|
+
MAX_STEP_UP_DIGEST_BYTES,
|
|
15
|
+
passkeyStepUpRequired,
|
|
16
|
+
refusePasskey,
|
|
17
|
+
stepUpDigestInvalid,
|
|
18
|
+
} from './passkey-errors';
|
|
19
|
+
import { stepUpChallenge } from './passkey-evidence';
|
|
20
|
+
import { readEnvelope } from './passkey-verify';
|
|
21
|
+
import type { PasskeyRequestOptions } from './passkey-wire';
|
|
22
|
+
import { assertFiniteAuthCount } from './policy-numbers';
|
|
23
|
+
import { randomBytes } from './tokens';
|
|
24
|
+
|
|
25
|
+
export interface PasskeyStepUpOptions<I> {
|
|
26
|
+
/**
|
|
27
|
+
* The bytes the person approves, computed on the server from what the action will act on —
|
|
28
|
+
* `({ input }) => sha256Hex(canonicalJson(input))`. Text is taken as UTF-8. 1 to 1024 bytes.
|
|
29
|
+
*/
|
|
30
|
+
readonly digest: (args: {
|
|
31
|
+
readonly input: I;
|
|
32
|
+
readonly userId: string;
|
|
33
|
+
}) => string | Uint8Array | Promise<string | Uint8Array>;
|
|
34
|
+
/** How long a proof may wait for its action. Defaults to `passkeys.challengeTtlMs`. */
|
|
35
|
+
readonly ttlMs?: number | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PasskeyStepUp<I> {
|
|
39
|
+
/** For the user about to act: hand the result to `getPasskey` in the browser. */
|
|
40
|
+
options(args: { readonly userId: string; readonly input: I }): Promise<PasskeyRequestOptions>;
|
|
41
|
+
/**
|
|
42
|
+
* Inside the sensitive action, before it acts: the proof for THIS input, or
|
|
43
|
+
* `X_PASSKEY_STEP_UP_REQUIRED`. The answer is stored and returned as evidence.
|
|
44
|
+
*/
|
|
45
|
+
verify(args: {
|
|
46
|
+
readonly userId: string;
|
|
47
|
+
readonly input: I;
|
|
48
|
+
readonly response: unknown;
|
|
49
|
+
}): Promise<PasskeyEvidence>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function passkeyStepUp<I>(auth: Auth, declared: PasskeyStepUpOptions<I>): PasskeyStepUp<I> {
|
|
53
|
+
const policy = passkeyPolicyOf(auth, 'passkeyStepUp');
|
|
54
|
+
const ttlMs = assertFiniteAuthCount(
|
|
55
|
+
'passkeyStepUp.ttlMs',
|
|
56
|
+
declared.ttlMs ?? policy.challengeTtlMs,
|
|
57
|
+
'a step-up proof would never expire',
|
|
58
|
+
1,
|
|
59
|
+
);
|
|
60
|
+
const digestOf = async (input: I, userId: string): Promise<Uint8Array> => {
|
|
61
|
+
const value = await declared.digest({ input, userId });
|
|
62
|
+
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
|
|
63
|
+
if (bytes.length === 0 || bytes.length > MAX_STEP_UP_DIGEST_BYTES) {
|
|
64
|
+
throw stepUpDigestInvalid(bytes.length);
|
|
65
|
+
}
|
|
66
|
+
return bytes;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
async options({ userId, input }) {
|
|
71
|
+
const digest = await digestOf(input, userId);
|
|
72
|
+
const passkeys = await auth.adapter.listPasskeys(userId);
|
|
73
|
+
if (passkeys.length === 0) throw passkeyStepUpRequired('no-passkey');
|
|
74
|
+
const nonce = randomBytes(32);
|
|
75
|
+
const { challenge } = await issuePasskeyChallenge(auth, 'step-up', {
|
|
76
|
+
userId,
|
|
77
|
+
digest: base64Url(digest),
|
|
78
|
+
nonce: base64Url(nonce),
|
|
79
|
+
challenge: await stepUpChallenge(nonce, digest, userId),
|
|
80
|
+
ttlMs,
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
challenge,
|
|
84
|
+
rpId: policy.rpId,
|
|
85
|
+
timeout: ttlMs,
|
|
86
|
+
userVerification: 'required',
|
|
87
|
+
allowCredentials: passkeys.map((row) => ({
|
|
88
|
+
type: 'public-key' as const,
|
|
89
|
+
id: row.id,
|
|
90
|
+
transports: row.transports,
|
|
91
|
+
})),
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async verify({ userId, input, response }) {
|
|
96
|
+
if (response === undefined || response === null) throw passkeyStepUpRequired('no-proof');
|
|
97
|
+
const envelope = readEnvelope(policy, response, 'webauthn.get');
|
|
98
|
+
if (!envelope.ok) throw refusePasskey('step-up', envelope.check);
|
|
99
|
+
const digest = base64Url(await digestOf(input, userId));
|
|
100
|
+
const issued = await redeemPasskeyChallenge(auth, 'step-up', envelope.value.challenge);
|
|
101
|
+
if (issued.userId !== userId) throw refusePasskey('step-up', 'user');
|
|
102
|
+
// The proof is real and fresh, and for other bytes: the action this call carries was not
|
|
103
|
+
// the one approved. A step-up for THIS input is still owed.
|
|
104
|
+
if (
|
|
105
|
+
issued.digest === null ||
|
|
106
|
+
!timingSafeEqual(issued.digest, digest) ||
|
|
107
|
+
issued.nonce === null
|
|
108
|
+
) {
|
|
109
|
+
throw passkeyStepUpRequired('other-digest');
|
|
110
|
+
}
|
|
111
|
+
const { credential, assertion } = await acceptAssertion(
|
|
112
|
+
auth,
|
|
113
|
+
policy,
|
|
114
|
+
'step-up',
|
|
115
|
+
envelope.value,
|
|
116
|
+
userId,
|
|
117
|
+
);
|
|
118
|
+
const evidence: PasskeyEvidence = {
|
|
119
|
+
id: uuidV7(auth.clock),
|
|
120
|
+
userId,
|
|
121
|
+
credentialId: credential.id,
|
|
122
|
+
digest,
|
|
123
|
+
nonce: issued.nonce,
|
|
124
|
+
authenticatorData: base64Url(assertion.authenticatorData),
|
|
125
|
+
clientDataJSON: base64Url(envelope.value.clientDataJSON),
|
|
126
|
+
signature: base64Url(assertion.signature),
|
|
127
|
+
publicKey: credential.publicKey,
|
|
128
|
+
algorithm: credential.algorithm,
|
|
129
|
+
createdAt: auth.clock.now(),
|
|
130
|
+
};
|
|
131
|
+
await auth.adapter.putPasskeyEvidence(evidence);
|
|
132
|
+
return evidence;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Single responsibility: the WebAuthn checks (L3 §7.1 registration, §7.2 assertion) over a
|
|
2
|
+
// response the browser forwarded — envelope, `clientDataJSON` type and origin, rp id hash, the UP
|
|
3
|
+
// and UV flags, the attested key, the signature. Pure: no store, no clock. Every refusal answers
|
|
4
|
+
// `null` plus the name of the check, which the caller logs; the challenge is the caller's to match.
|
|
5
|
+
|
|
6
|
+
import { base64Url, canonicalBase64UrlBytes } from './base64url';
|
|
7
|
+
import { importCoseKey, PASSKEY_ALGORITHMS, verifyCoseSignature } from './cose-key';
|
|
8
|
+
import { isRecord } from './json';
|
|
9
|
+
import type { PasskeyRelyingParty } from './passkey-config';
|
|
10
|
+
import {
|
|
11
|
+
type AuthenticatorData,
|
|
12
|
+
FLAG_AT,
|
|
13
|
+
FLAG_UP,
|
|
14
|
+
FLAG_UV,
|
|
15
|
+
parseAttestationObject,
|
|
16
|
+
parseAuthenticatorData,
|
|
17
|
+
parseClientData,
|
|
18
|
+
} from './webauthn-data';
|
|
19
|
+
|
|
20
|
+
/** Which check refused — a log field, never a `cause:`. */
|
|
21
|
+
export type PasskeyCheck =
|
|
22
|
+
| 'envelope'
|
|
23
|
+
| 'client-data'
|
|
24
|
+
| 'type'
|
|
25
|
+
| 'origin'
|
|
26
|
+
| 'attestation'
|
|
27
|
+
| 'rp-id'
|
|
28
|
+
| 'user-present'
|
|
29
|
+
| 'user-verified'
|
|
30
|
+
| 'credential-id'
|
|
31
|
+
| 'key'
|
|
32
|
+
| 'signature';
|
|
33
|
+
|
|
34
|
+
export type Checked<T> =
|
|
35
|
+
| { readonly ok: true; readonly value: T }
|
|
36
|
+
| { readonly ok: false; readonly check: PasskeyCheck };
|
|
37
|
+
|
|
38
|
+
const refuse = (check: PasskeyCheck): { readonly ok: false; readonly check: PasskeyCheck } => ({
|
|
39
|
+
ok: false,
|
|
40
|
+
check,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** The parts of a response every ceremony reads before it knows which challenge it answers. */
|
|
44
|
+
export interface CeremonyEnvelope {
|
|
45
|
+
readonly credentialId: string;
|
|
46
|
+
readonly challenge: string;
|
|
47
|
+
readonly clientDataJSON: Uint8Array;
|
|
48
|
+
readonly response: Readonly<Record<string, unknown>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sha256 = async (bytes: Uint8Array): Promise<Uint8Array> =>
|
|
52
|
+
new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)));
|
|
53
|
+
|
|
54
|
+
const equalBytes = (a: Uint8Array, b: Uint8Array): boolean =>
|
|
55
|
+
a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
56
|
+
|
|
57
|
+
export const rpIdHash = (rpId: string): Promise<Uint8Array> =>
|
|
58
|
+
sha256(new TextEncoder().encode(rpId));
|
|
59
|
+
|
|
60
|
+
/** `id === rawId`, both canonical base64url; `type` is `public-key`; the client data is ours. */
|
|
61
|
+
export function readEnvelope(
|
|
62
|
+
policy: PasskeyRelyingParty,
|
|
63
|
+
raw: unknown,
|
|
64
|
+
type: 'webauthn.create' | 'webauthn.get',
|
|
65
|
+
): Checked<CeremonyEnvelope> {
|
|
66
|
+
if (!isRecord(raw) || raw['type'] !== 'public-key' || !isRecord(raw['response'])) {
|
|
67
|
+
return refuse('envelope');
|
|
68
|
+
}
|
|
69
|
+
const id = raw['id'];
|
|
70
|
+
if (canonicalBase64UrlBytes(id) === null || raw['rawId'] !== id || typeof id !== 'string') {
|
|
71
|
+
return refuse('envelope');
|
|
72
|
+
}
|
|
73
|
+
const response = raw['response'];
|
|
74
|
+
const clientDataJSON = canonicalBase64UrlBytes(response['clientDataJSON']);
|
|
75
|
+
const clientData = clientDataJSON === null ? null : parseClientData(clientDataJSON);
|
|
76
|
+
if (clientDataJSON === null || clientData === null) return refuse('client-data');
|
|
77
|
+
if (clientData.type !== type) return refuse('type');
|
|
78
|
+
// An exact match against the declared list — never a prefix, never a host compare — and never
|
|
79
|
+
// from inside a cross-origin iframe, which the declaration did not name.
|
|
80
|
+
if (!policy.origins.includes(clientData.origin) || clientData.crossOrigin) {
|
|
81
|
+
return refuse('origin');
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
value: { credentialId: id, challenge: clientData.challenge, clientDataJSON, response },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Checked once per ceremony: our rp, a person present, and that person verified. */
|
|
90
|
+
async function checkFlags(
|
|
91
|
+
policy: PasskeyRelyingParty,
|
|
92
|
+
data: AuthenticatorData,
|
|
93
|
+
): Promise<PasskeyCheck | null> {
|
|
94
|
+
if (!equalBytes(data.rpIdHash, await rpIdHash(policy.rpId))) return 'rp-id';
|
|
95
|
+
if ((data.flags & FLAG_UP) === 0) return 'user-present';
|
|
96
|
+
if ((data.flags & FLAG_UV) === 0) return 'user-verified';
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface AttestedPasskey {
|
|
101
|
+
readonly credentialId: string;
|
|
102
|
+
readonly publicKey: string;
|
|
103
|
+
readonly algorithm: number;
|
|
104
|
+
readonly signCount: number;
|
|
105
|
+
readonly transports: readonly string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const TRANSPORT = /^[a-z-]{1,32}$/;
|
|
109
|
+
|
|
110
|
+
/** §7.1 steps 7-27 that need no store. The attestation STATEMENT is not verified: we asked for none. */
|
|
111
|
+
export async function verifyAttestation(
|
|
112
|
+
policy: PasskeyRelyingParty,
|
|
113
|
+
envelope: CeremonyEnvelope,
|
|
114
|
+
): Promise<Checked<AttestedPasskey>> {
|
|
115
|
+
const attestation = canonicalBase64UrlBytes(envelope.response['attestationObject']);
|
|
116
|
+
const parsed = attestation === null ? null : parseAttestationObject(attestation);
|
|
117
|
+
const data = parsed === null ? null : parseAuthenticatorData(parsed.authData);
|
|
118
|
+
if (data === null) return refuse('attestation');
|
|
119
|
+
const flags = await checkFlags(policy, data);
|
|
120
|
+
if (flags !== null) return refuse(flags);
|
|
121
|
+
if ((data.flags & FLAG_AT) === 0 || data.attested === null) return refuse('attestation');
|
|
122
|
+
if (base64Url(data.attested.credentialId) !== envelope.credentialId)
|
|
123
|
+
return refuse('credential-id');
|
|
124
|
+
const key = await importCoseKey(data.attested.publicKey);
|
|
125
|
+
if (key === null || !PASSKEY_ALGORITHMS.includes(key.algorithm)) return refuse('key');
|
|
126
|
+
const declared = envelope.response['transports'];
|
|
127
|
+
const transports = Array.isArray(declared)
|
|
128
|
+
? declared.filter((item): item is string => typeof item === 'string' && TRANSPORT.test(item))
|
|
129
|
+
: [];
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
value: {
|
|
133
|
+
credentialId: envelope.credentialId,
|
|
134
|
+
publicKey: base64Url(data.attested.publicKey),
|
|
135
|
+
algorithm: key.algorithm,
|
|
136
|
+
signCount: data.signCount,
|
|
137
|
+
transports: transports.slice(0, 8),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface VerifiedAssertion {
|
|
143
|
+
readonly signCount: number;
|
|
144
|
+
/** The `userHandle` the authenticator returned, decoded as text; `null` when it sent none. */
|
|
145
|
+
readonly userHandle: string | null;
|
|
146
|
+
readonly authenticatorData: Uint8Array;
|
|
147
|
+
readonly signature: Uint8Array;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** §7.2 steps 11-21 that need no store, against the key `publicKey` (stored COSE, base64url). */
|
|
151
|
+
export async function verifyAssertion(
|
|
152
|
+
policy: PasskeyRelyingParty,
|
|
153
|
+
envelope: CeremonyEnvelope,
|
|
154
|
+
publicKey: string,
|
|
155
|
+
): Promise<Checked<VerifiedAssertion>> {
|
|
156
|
+
const authenticatorData = canonicalBase64UrlBytes(envelope.response['authenticatorData']);
|
|
157
|
+
const signature = canonicalBase64UrlBytes(envelope.response['signature']);
|
|
158
|
+
const data = authenticatorData === null ? null : parseAuthenticatorData(authenticatorData);
|
|
159
|
+
if (authenticatorData === null || signature === null || data === null) return refuse('envelope');
|
|
160
|
+
const flags = await checkFlags(policy, data);
|
|
161
|
+
if (flags !== null) return refuse(flags);
|
|
162
|
+
const cose = canonicalBase64UrlBytes(publicKey);
|
|
163
|
+
const key = cose === null ? null : await importCoseKey(cose);
|
|
164
|
+
if (key === null) return refuse('key');
|
|
165
|
+
const signed = new Uint8Array(authenticatorData.length + 32);
|
|
166
|
+
signed.set(authenticatorData);
|
|
167
|
+
signed.set(await sha256(envelope.clientDataJSON), authenticatorData.length);
|
|
168
|
+
if (!(await verifyCoseSignature(key, signature, signed))) return refuse('signature');
|
|
169
|
+
const handle = envelope.response['userHandle'];
|
|
170
|
+
let userHandle: string | null = null;
|
|
171
|
+
if (handle !== undefined && handle !== null) {
|
|
172
|
+
const bytes = canonicalBase64UrlBytes(handle);
|
|
173
|
+
if (bytes === null) return refuse('envelope');
|
|
174
|
+
userHandle = new TextDecoder().decode(bytes);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
value: { signCount: data.signCount, userHandle, authenticatorData, signature },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Single responsibility: the JSON shapes a passkey ceremony crosses the wire in — the options the
|
|
2
|
+
// server hands the browser and the credential the browser hands back — as TYPES only, so the
|
|
3
|
+
// browser entry (`client.ts`) and the server share one declaration and the client pays no bytes.
|
|
4
|
+
// Every binary field is base64url; the names are WebAuthn L3's `*JSON` dictionaries (§5.1.8).
|
|
5
|
+
|
|
6
|
+
export type PasskeyCredentialDescriptor = {
|
|
7
|
+
readonly type: 'public-key';
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly transports: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** `PublicKeyCredentialCreationOptionsJSON`, as `passkeyLogin(auth).registerOptions` issues it. */
|
|
13
|
+
export type PasskeyCreationOptions = {
|
|
14
|
+
readonly challenge: string;
|
|
15
|
+
readonly rp: { readonly id: string; readonly name: string };
|
|
16
|
+
readonly user: { readonly id: string; readonly name: string; readonly displayName: string };
|
|
17
|
+
readonly pubKeyCredParams: readonly { readonly type: 'public-key'; readonly alg: number }[];
|
|
18
|
+
readonly timeout: number;
|
|
19
|
+
readonly attestation: 'none';
|
|
20
|
+
readonly authenticatorSelection: {
|
|
21
|
+
readonly residentKey: 'required';
|
|
22
|
+
readonly requireResidentKey: true;
|
|
23
|
+
readonly userVerification: 'required';
|
|
24
|
+
};
|
|
25
|
+
readonly excludeCredentials: readonly PasskeyCredentialDescriptor[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** `PublicKeyCredentialRequestOptionsJSON`, for sign-in and for step-up. */
|
|
29
|
+
export type PasskeyRequestOptions = {
|
|
30
|
+
readonly challenge: string;
|
|
31
|
+
readonly rpId: string;
|
|
32
|
+
readonly timeout: number;
|
|
33
|
+
readonly userVerification: 'required';
|
|
34
|
+
/** Empty for a usernameless sign-in; the user's own passkeys for a step-up. */
|
|
35
|
+
readonly allowCredentials: readonly PasskeyCredentialDescriptor[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** `RegistrationResponseJSON`: what `newPasskey(options)` answers and `register` reads. */
|
|
39
|
+
export type PasskeyRegistrationResponse = {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly rawId: string;
|
|
42
|
+
readonly type: 'public-key';
|
|
43
|
+
readonly response: {
|
|
44
|
+
readonly clientDataJSON: string;
|
|
45
|
+
readonly attestationObject: string;
|
|
46
|
+
readonly transports: readonly string[];
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** `AuthenticationResponseJSON`: what `getPasskey(options)` answers and `login`/`verify` read. */
|
|
51
|
+
export type PasskeyAssertionResponse = {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly rawId: string;
|
|
54
|
+
readonly type: 'public-key';
|
|
55
|
+
readonly response: {
|
|
56
|
+
readonly clientDataJSON: string;
|
|
57
|
+
readonly authenticatorData: string;
|
|
58
|
+
readonly signature: string;
|
|
59
|
+
readonly userHandle: string | null;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Single responsibility: a SOFTWARE authenticator on WebCrypto — it mints real ES256 / RS256
|
|
2
|
+
// credentials and signs real assertions, the bytes a platform authenticator sends, so a test runs
|
|
3
|
+
// a passkey ceremony end to end against the real verifier. Public as `@ultimat3/auth/soft-authenticator`, the one
|
|
4
|
+
// path an app's test and this package's own suites share.
|
|
5
|
+
|
|
6
|
+
import { base64Url, base64UrlBytes } from './base64url';
|
|
7
|
+
import type { CborValue } from './cbor';
|
|
8
|
+
import { cborEncode } from './cbor-encode';
|
|
9
|
+
import type {
|
|
10
|
+
PasskeyAssertionResponse,
|
|
11
|
+
PasskeyCreationOptions,
|
|
12
|
+
PasskeyRegistrationResponse,
|
|
13
|
+
PasskeyRequestOptions,
|
|
14
|
+
} from './passkey-wire';
|
|
15
|
+
import { FLAG_AT, FLAG_UP, FLAG_UV } from './webauthn-data';
|
|
16
|
+
|
|
17
|
+
const concat = (...parts: readonly Uint8Array[]): Uint8Array<ArrayBuffer> => {
|
|
18
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
19
|
+
let at = 0;
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
out.set(part, at);
|
|
22
|
+
at += part.length;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const sha256 = async (bytes: Uint8Array): Promise<Uint8Array> =>
|
|
28
|
+
new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)));
|
|
29
|
+
|
|
30
|
+
const u32 = (value: number): Uint8Array => {
|
|
31
|
+
const out = new Uint8Array(4);
|
|
32
|
+
new DataView(out.buffer).setUint32(0, value);
|
|
33
|
+
return out;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Raw `r || s` → strict DER, as an authenticator encodes an ES256 signature. */
|
|
37
|
+
export function ecdsaRawToDer(raw: Uint8Array): Uint8Array {
|
|
38
|
+
const integer = (bytes: Uint8Array): Uint8Array => {
|
|
39
|
+
let start = 0;
|
|
40
|
+
while (start < bytes.length - 1 && bytes[start] === 0) start += 1;
|
|
41
|
+
const trimmed = bytes.subarray(start);
|
|
42
|
+
const padded = (trimmed[0] ?? 0) & 0x80 ? concat(Uint8Array.of(0), trimmed) : trimmed;
|
|
43
|
+
return concat(Uint8Array.of(0x02, padded.length), padded);
|
|
44
|
+
};
|
|
45
|
+
const body = concat(integer(raw.subarray(0, 32)), integer(raw.subarray(32)));
|
|
46
|
+
return concat(Uint8Array.of(0x30, body.length), body);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One knob per check the verifier makes, so a test breaks exactly one: the origin or the
|
|
51
|
+
* `clientDataJSON` type a phishing page would send, another rp id, flags without UV, a counter that
|
|
52
|
+
* went backwards, another ceremony's challenge, a user handle the credential was not made for.
|
|
53
|
+
*/
|
|
54
|
+
export interface SoftCeremonyOverrides {
|
|
55
|
+
readonly origin?: string | undefined;
|
|
56
|
+
readonly type?: string | undefined;
|
|
57
|
+
readonly rpId?: string | undefined;
|
|
58
|
+
readonly flags?: number | undefined;
|
|
59
|
+
readonly signCount?: number | undefined;
|
|
60
|
+
readonly challenge?: string | undefined;
|
|
61
|
+
readonly userHandle?: string | null | undefined;
|
|
62
|
+
readonly crossOrigin?: boolean | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A passkey: `create` answers `newPasskey`'s response, `get` answers `getPasskey`'s. */
|
|
66
|
+
export interface SoftAuthenticator {
|
|
67
|
+
/** base64url, as the server stores it. */
|
|
68
|
+
readonly credentialId: string;
|
|
69
|
+
create(
|
|
70
|
+
options: PasskeyCreationOptions,
|
|
71
|
+
overrides?: SoftCeremonyOverrides,
|
|
72
|
+
): Promise<PasskeyRegistrationResponse>;
|
|
73
|
+
get(
|
|
74
|
+
options: PasskeyRequestOptions,
|
|
75
|
+
overrides?: SoftCeremonyOverrides,
|
|
76
|
+
): Promise<PasskeyAssertionResponse>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SoftAuthenticatorInput {
|
|
80
|
+
/** The page origin the browser would report: one of `passkeys.origins`, or the test fails. */
|
|
81
|
+
readonly origin: string;
|
|
82
|
+
/** `ES256` by default. */
|
|
83
|
+
readonly algorithm?: 'ES256' | 'RS256' | undefined;
|
|
84
|
+
/** Where the counter starts; `null` is an authenticator that keeps none (always 0). */
|
|
85
|
+
readonly counter?: number | null | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A fresh key pair and credential id: one passkey, on no device. */
|
|
89
|
+
export async function softAuthenticator(init: SoftAuthenticatorInput): Promise<SoftAuthenticator> {
|
|
90
|
+
const rsa = init.algorithm === 'RS256';
|
|
91
|
+
const pair = rsa
|
|
92
|
+
? await crypto.subtle.generateKey(
|
|
93
|
+
{
|
|
94
|
+
name: 'RSASSA-PKCS1-v1_5',
|
|
95
|
+
modulusLength: 2048,
|
|
96
|
+
publicExponent: Uint8Array.of(1, 0, 1),
|
|
97
|
+
hash: 'SHA-256',
|
|
98
|
+
},
|
|
99
|
+
true,
|
|
100
|
+
['sign', 'verify'],
|
|
101
|
+
)
|
|
102
|
+
: await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
|
103
|
+
'sign',
|
|
104
|
+
'verify',
|
|
105
|
+
]);
|
|
106
|
+
const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
|
107
|
+
const bytesOf = (field: string | undefined): Uint8Array =>
|
|
108
|
+
base64UrlBytes(field ?? '') ?? new Uint8Array();
|
|
109
|
+
const cose = rsa
|
|
110
|
+
? cborEncode(
|
|
111
|
+
new Map<number, CborValue>([
|
|
112
|
+
[1, 3],
|
|
113
|
+
[3, -257],
|
|
114
|
+
[-1, bytesOf(jwk.n)],
|
|
115
|
+
[-2, bytesOf(jwk.e)],
|
|
116
|
+
]),
|
|
117
|
+
)
|
|
118
|
+
: cborEncode(
|
|
119
|
+
new Map<number, CborValue>([
|
|
120
|
+
[1, 2],
|
|
121
|
+
[3, -7],
|
|
122
|
+
[-1, 1],
|
|
123
|
+
[-2, bytesOf(jwk.x)],
|
|
124
|
+
[-3, bytesOf(jwk.y)],
|
|
125
|
+
]),
|
|
126
|
+
);
|
|
127
|
+
const rawId = crypto.getRandomValues(new Uint8Array(16));
|
|
128
|
+
const credentialId = base64Url(rawId);
|
|
129
|
+
let counter = init.counter === undefined ? 0 : init.counter;
|
|
130
|
+
let userHandle: string | null = null;
|
|
131
|
+
|
|
132
|
+
const clientData = (type: string, challenge: string, o: SoftCeremonyOverrides): Uint8Array =>
|
|
133
|
+
new TextEncoder().encode(
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
type: o.type ?? type,
|
|
136
|
+
challenge: o.challenge ?? challenge,
|
|
137
|
+
origin: o.origin ?? init.origin,
|
|
138
|
+
crossOrigin: o.crossOrigin ?? false,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
const nextCount = (o: SoftCeremonyOverrides): number => {
|
|
142
|
+
if (o.signCount !== undefined) return o.signCount;
|
|
143
|
+
if (counter === null) return 0;
|
|
144
|
+
counter += 1;
|
|
145
|
+
return counter;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
credentialId,
|
|
150
|
+
async create(options, o = {}) {
|
|
151
|
+
userHandle = options.user.id;
|
|
152
|
+
const authData = concat(
|
|
153
|
+
await sha256(new TextEncoder().encode(o.rpId ?? options.rp.id)),
|
|
154
|
+
Uint8Array.of(o.flags ?? FLAG_UP | FLAG_UV | FLAG_AT),
|
|
155
|
+
u32(o.signCount ?? counter ?? 0),
|
|
156
|
+
new Uint8Array(16),
|
|
157
|
+
Uint8Array.of(rawId.length >> 8, rawId.length & 0xff),
|
|
158
|
+
rawId,
|
|
159
|
+
cose,
|
|
160
|
+
);
|
|
161
|
+
const attestationObject = cborEncode(
|
|
162
|
+
new Map<string, CborValue>([
|
|
163
|
+
['fmt', 'none'],
|
|
164
|
+
['attStmt', new Map()],
|
|
165
|
+
['authData', authData],
|
|
166
|
+
]),
|
|
167
|
+
);
|
|
168
|
+
return {
|
|
169
|
+
id: credentialId,
|
|
170
|
+
rawId: credentialId,
|
|
171
|
+
type: 'public-key',
|
|
172
|
+
response: {
|
|
173
|
+
clientDataJSON: base64Url(clientData('webauthn.create', options.challenge, o)),
|
|
174
|
+
attestationObject: base64Url(attestationObject),
|
|
175
|
+
transports: ['internal', 'hybrid'],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
async get(options, o = {}) {
|
|
180
|
+
const authData = concat(
|
|
181
|
+
await sha256(new TextEncoder().encode(o.rpId ?? options.rpId)),
|
|
182
|
+
Uint8Array.of(o.flags ?? FLAG_UP | FLAG_UV),
|
|
183
|
+
u32(nextCount(o)),
|
|
184
|
+
);
|
|
185
|
+
const data = clientData('webauthn.get', options.challenge, o);
|
|
186
|
+
const signed = concat(authData, await sha256(data));
|
|
187
|
+
const signature = rsa
|
|
188
|
+
? new Uint8Array(await crypto.subtle.sign('RSASSA-PKCS1-v1_5', pair.privateKey, signed))
|
|
189
|
+
: ecdsaRawToDer(
|
|
190
|
+
new Uint8Array(
|
|
191
|
+
await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, signed),
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
const handle = o.userHandle === undefined ? userHandle : o.userHandle;
|
|
195
|
+
return {
|
|
196
|
+
id: credentialId,
|
|
197
|
+
rawId: credentialId,
|
|
198
|
+
type: 'public-key',
|
|
199
|
+
response: {
|
|
200
|
+
clientDataJSON: base64Url(data),
|
|
201
|
+
authenticatorData: base64Url(authData),
|
|
202
|
+
signature: base64Url(signature),
|
|
203
|
+
userHandle: handle,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `authSubjectTarget()`: the identity tables this package owns, as a subject target a
|
|
2
|
+
// `defineSubject({ targets })` lists — structural, so `@ultimat3/jobs` imports nothing from here.
|
|
3
|
+
// Exports the `x_users` row (never a digest or the sealed TOTP seed); erases the user, which
|
|
4
|
+
// cascades their sessions and linked accounts, plus their API keys and pending verifications.
|
|
5
|
+
|
|
6
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** The subject as a target is handed it: the key asked for, and the subject's row read fresh. */
|
|
9
|
+
export interface AuthSubjectRef {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly row: Readonly<Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuthSubjectTargetOptions {
|
|
15
|
+
readonly executor: PgExecutor;
|
|
16
|
+
/**
|
|
17
|
+
* The `x_users.id` the subject is: `({ row }) => String(row.userId)` for a membership row that
|
|
18
|
+
* carries it. `undefined` is a subject with no identity here, and touches nothing.
|
|
19
|
+
*/
|
|
20
|
+
readonly user: (subject: AuthSubjectRef) => string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Cell = string | number | boolean | null;
|
|
24
|
+
|
|
25
|
+
export interface AuthSubjectTarget {
|
|
26
|
+
readonly name: 'x_users';
|
|
27
|
+
readonly columns: readonly string[];
|
|
28
|
+
exportPage(
|
|
29
|
+
subject: AuthSubjectRef,
|
|
30
|
+
cursor: string | null,
|
|
31
|
+
size: number,
|
|
32
|
+
): Promise<{ readonly rows: readonly Readonly<Record<string, Cell>>[]; readonly cursor: null }>;
|
|
33
|
+
erase(subject: AuthSubjectRef, size: number): Promise<number>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What leaves in an export. `password_hash`, `mfa_secret` and the recovery digests never do. */
|
|
37
|
+
export const AUTH_SUBJECT_COLUMNS = [
|
|
38
|
+
'id',
|
|
39
|
+
'email',
|
|
40
|
+
'email_verified_at',
|
|
41
|
+
'roles',
|
|
42
|
+
'created_at',
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
const SQL_USER = `select id::text as id, email, email_verified_at::text as email_verified_at,
|
|
46
|
+
array_to_string(roles, ',') as roles, created_at::text as created_at
|
|
47
|
+
from x_users where id = $1::uuid`;
|
|
48
|
+
const SQL_API_KEYS = 'delete from x_api_keys where user_id = $1::uuid';
|
|
49
|
+
const SQL_VERIFICATIONS = `delete from x_verifications
|
|
50
|
+
where identifier = (select email from x_users where id = $1::uuid)`;
|
|
51
|
+
// Sessions and accounts reference the user `on delete cascade` (`tables.ts`): one statement.
|
|
52
|
+
const SQL_USER_DELETE = 'delete from x_users where id = $1::uuid returning id';
|
|
53
|
+
|
|
54
|
+
export function authSubjectTarget(options: AuthSubjectTargetOptions): AuthSubjectTarget {
|
|
55
|
+
const { executor } = options;
|
|
56
|
+
return {
|
|
57
|
+
name: 'x_users',
|
|
58
|
+
columns: AUTH_SUBJECT_COLUMNS,
|
|
59
|
+
async exportPage(subject) {
|
|
60
|
+
const id = options.user(subject);
|
|
61
|
+
if (id === undefined) return { rows: [], cursor: null };
|
|
62
|
+
const rows = await executor.query<Record<string, Cell>>(SQL_USER, [id]);
|
|
63
|
+
return { rows, cursor: null };
|
|
64
|
+
},
|
|
65
|
+
async erase(subject) {
|
|
66
|
+
const id = options.user(subject);
|
|
67
|
+
if (id === undefined) return 0;
|
|
68
|
+
await executor.query(SQL_API_KEYS, [id]);
|
|
69
|
+
await executor.query(SQL_VERIFICATIONS, [id]);
|
|
70
|
+
return (await executor.query(SQL_USER_DELETE, [id])).length;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|