@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.
- 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
package/src/mfa.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// and single-use, so a database dump is not a permanent MFA bypass; consuming one is the
|
|
5
5
|
// adapter's (`consumeRecoveryCode`), because single-use has to be one atomic step.
|
|
6
6
|
|
|
7
|
-
import { finiteCount, timingSafeEqual } from '@ultimat3/core';
|
|
7
|
+
import { finiteCount, hmac, timingSafeEqual } from '@ultimat3/core';
|
|
8
8
|
import type { Auth } from './auth';
|
|
9
9
|
import { mfaSecretInvalid } from './errors';
|
|
10
10
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
@@ -135,9 +135,7 @@ export function totpCode(secret: string, step: number, digits: number = TOTP_DIG
|
|
|
135
135
|
// code every other unreadable secret answers too. There is no code an unreadable secret is
|
|
136
136
|
// entitled to, so this returns none.
|
|
137
137
|
if (key.length === 0) throw mfaSecretInvalid('totpCode');
|
|
138
|
-
const mac =
|
|
139
|
-
new Bun.CryptoHasher('sha1', key).update(counterBytes(step)).digest(),
|
|
140
|
-
);
|
|
138
|
+
const mac = hmac('sha1', key, counterBytes(step));
|
|
141
139
|
const offset = (mac[mac.length - 1] ?? 0) & 0x0f;
|
|
142
140
|
const binary =
|
|
143
141
|
(((mac[offset] ?? 0) & 0x7f) << 24) |
|
|
@@ -149,7 +147,7 @@ export function totpCode(secret: string, step: number, digits: number = TOTP_DIG
|
|
|
149
147
|
|
|
150
148
|
export interface TotpVerification {
|
|
151
149
|
readonly ok: boolean;
|
|
152
|
-
/** The step the code belonged to —
|
|
150
|
+
/** The step the code belonged to — what `TotpReplayGuard.claim()` is asked to spend. */
|
|
153
151
|
readonly step: number | null;
|
|
154
152
|
}
|
|
155
153
|
|
|
@@ -191,14 +189,23 @@ export function verifyTotp(input: VerifyTotpInput): TotpVerification {
|
|
|
191
189
|
return { ok: false, step: null };
|
|
192
190
|
}
|
|
193
191
|
|
|
192
|
+
/**
|
|
193
|
+
* One atomic question: spend this step. A check and a write are two round trips across a network,
|
|
194
|
+
* and two replicas can both pass the check between them, so the guard has no separate read.
|
|
195
|
+
*/
|
|
194
196
|
export interface TotpReplayGuard {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
+
/**
|
|
198
|
+
* `true` the first time a subject spends a step, `false` for a replay. `at` is the caller's clock
|
|
199
|
+
* reading. A store that cannot answer REJECTS: a down database is not a wrong code.
|
|
200
|
+
*/
|
|
201
|
+
claim(subject: string, step: number, at: Date): Promise<boolean>;
|
|
197
202
|
}
|
|
198
203
|
|
|
199
|
-
/** What `memoryTotpReplayGuard` returns: the interface, plus the
|
|
204
|
+
/** What `memoryTotpReplayGuard` returns: the interface, plus the table it keeps, observable. */
|
|
200
205
|
export interface MemoryTotpReplayGuard extends TotpReplayGuard {
|
|
201
206
|
readonly size: number;
|
|
207
|
+
/** For a test reading the eviction order. Nothing on the sign-in path asks this: `claim` does. */
|
|
208
|
+
isUsed(subject: string, step: number): boolean;
|
|
202
209
|
}
|
|
203
210
|
|
|
204
211
|
/**
|
|
@@ -233,9 +240,9 @@ const newestStep = (steps: ReadonlySet<number>): number => {
|
|
|
233
240
|
};
|
|
234
241
|
|
|
235
242
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* drift window are dropped — nothing outside it can be replayed anyway.
|
|
243
|
+
* One process' table: the default when no host installed a guard, so a code is single-use per
|
|
244
|
+
* replica. A framework boot installs `postgresTotpReplayGuard` instead (`configureTotpReplay`).
|
|
245
|
+
* Steps older than the drift window are dropped — nothing outside it can be replayed anyway.
|
|
239
246
|
*
|
|
240
247
|
* Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
|
|
241
248
|
* and never revisited a subject who stopped signing in, so the table carried one permanent entry
|
|
@@ -270,7 +277,7 @@ export function memoryTotpReplayGuard(
|
|
|
270
277
|
if (steps.size === 0) used.delete(subject);
|
|
271
278
|
}
|
|
272
279
|
if (used.size <= cap) return;
|
|
273
|
-
// Map iteration is insertion order and `
|
|
280
|
+
// Map iteration is insertion order and `claim` re-files the subject it touches, so this
|
|
274
281
|
// sort — stable by specification — breaks a tie on the newest step by least recently seen.
|
|
275
282
|
// Both keys point the same way: the subject who just proved a code is the last one out.
|
|
276
283
|
const furthest = [...used.entries()].sort((a, b) => newestStep(a[1]) - newestStep(b[1]));
|
|
@@ -285,7 +292,10 @@ export function memoryTotpReplayGuard(
|
|
|
285
292
|
return used.size;
|
|
286
293
|
},
|
|
287
294
|
isUsed: (subject, step) => used.get(subject)?.has(step) === true,
|
|
288
|
-
|
|
295
|
+
// `async` with no `await` in the body: it runs to its `return` in one turn, so two concurrent
|
|
296
|
+
// completions carrying one code cannot both find the step unspent.
|
|
297
|
+
claim: async (subject, step, at) => {
|
|
298
|
+
if (used.get(subject)?.has(step) === true) return false;
|
|
289
299
|
const now = totpStep(at);
|
|
290
300
|
const floor = now - drift;
|
|
291
301
|
const steps = used.get(subject) ?? new Set<number>();
|
|
@@ -296,6 +306,7 @@ export function memoryTotpReplayGuard(
|
|
|
296
306
|
used.delete(subject);
|
|
297
307
|
used.set(subject, steps);
|
|
298
308
|
if (used.size > cap || now - lastSweepStep >= SWEEP_EVERY_STEPS) sweep(now, floor);
|
|
309
|
+
return true;
|
|
299
310
|
},
|
|
300
311
|
};
|
|
301
312
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Single responsibility: an authorization server's access token → who is calling, in the shape a
|
|
2
|
+
// bearer mount and an MCP endpoint both take as `resolveToken`. The `Actor` is built PER CALL by
|
|
3
|
+
// the app's `actor`, never stored with the grant: a member removed from the org loses access on
|
|
4
|
+
// the next request with no sweep. `resource`, when given, refuses a token bound to anything else.
|
|
5
|
+
|
|
6
|
+
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import type { ResolvedGrant } from './authorization-server-types';
|
|
8
|
+
|
|
9
|
+
/** Structurally `@ultimat3/http`'s `BearerCaller` and `@ultimat3/mcp`'s `ResolvedToken`. */
|
|
10
|
+
export interface OAuthBearerCaller {
|
|
11
|
+
readonly actor: Actor;
|
|
12
|
+
/** What the person (or machine) granted — the mount's scope map is cut by these. */
|
|
13
|
+
readonly scopes: ReadonlySet<string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OAuthBearerResolverOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Required: what a subject is NOW. `null` refuses the token — a removed member, a disabled
|
|
19
|
+
* account, a client the app no longer trusts. `grant.subject` is the client id itself for a
|
|
20
|
+
* `client_credentials` token.
|
|
21
|
+
*/
|
|
22
|
+
readonly actor: (grant: ResolvedGrant) => Promise<Actor | null> | Actor | null;
|
|
23
|
+
/** RFC 8707: accept only tokens bound to this resource. Unbound tokens are refused too. */
|
|
24
|
+
readonly resource?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What it needs of `authorizationServer()` — its `resolve`, and nothing else. */
|
|
28
|
+
export interface OAuthTokenSource {
|
|
29
|
+
resolve(token: string): Promise<ResolvedGrant | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function oauthBearerResolver(
|
|
33
|
+
server: OAuthTokenSource,
|
|
34
|
+
options: OAuthBearerResolverOptions,
|
|
35
|
+
): (token: string) => Promise<OAuthBearerCaller | null> {
|
|
36
|
+
return async (token) => {
|
|
37
|
+
const grant = await server.resolve(token);
|
|
38
|
+
if (grant === null) return null;
|
|
39
|
+
if (options.resource !== undefined && grant.resource !== options.resource) return null;
|
|
40
|
+
const actor = await options.actor(grant);
|
|
41
|
+
if (actor === null) return null;
|
|
42
|
+
return { actor, scopes: grant.scopes };
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/oauth-cookie.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Clock } from '@ultimat3/core';
|
|
9
9
|
import {
|
|
10
10
|
EnvMissingError,
|
|
11
|
+
hmac,
|
|
11
12
|
readCookie,
|
|
12
13
|
serializeSetCookie,
|
|
13
14
|
systemClock,
|
|
@@ -227,7 +228,7 @@ export function readHandshakeCookie(
|
|
|
227
228
|
|
|
228
229
|
/** Keyed SHA-256. Untruncated, unlike a cursor's: a cookie has room and this authorises a login. */
|
|
229
230
|
function sign(body: string, secret: string): string {
|
|
230
|
-
return
|
|
231
|
+
return hmac('sha256', secret, body, 'hex');
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
function parseBody(body: string, provider: OAuthProviderId): unknown {
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
renderFixShellArg,
|
|
10
10
|
renderThrowable,
|
|
11
11
|
} from '@ultimat3/core';
|
|
12
|
+
import { outboundResponse } from '@ultimat3/core/outbound';
|
|
12
13
|
import { isRecord } from './json';
|
|
13
14
|
import type { OAuthProvider, TokenEndpointAuth } from './oauth';
|
|
14
15
|
import { oauthExchangeFailed } from './oauth-errors';
|
|
@@ -71,7 +72,9 @@ export async function discoverOAuthProvider(
|
|
|
71
72
|
// `renderFixLiteral` is the wrong tool for a command, because its double quotes leave `$(…)`
|
|
72
73
|
// live.
|
|
73
74
|
const target = renderFixShellArg(url, '<issuer>/.well-known/openid-configuration');
|
|
74
|
-
const doFetch: OAuthFetch =
|
|
75
|
+
const doFetch: OAuthFetch =
|
|
76
|
+
input.fetch ??
|
|
77
|
+
((target, init) => globalThis.fetch(target, { ...init, signal: init.signal ?? null }));
|
|
75
78
|
// Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
|
|
76
79
|
// catch around this fetch renders every throw as a provider failure — so a typo in the app's
|
|
77
80
|
// own config read as the identity provider being unreachable.
|
|
@@ -84,11 +87,19 @@ export async function discoverOAuthProvider(
|
|
|
84
87
|
|
|
85
88
|
let response: Response;
|
|
86
89
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
// Through core's `outbound()`: `timeoutMs` bounds the whole call, the ambient request's signal
|
|
91
|
+
// ends it, a GET's 5xx is retried inside that bound (a POST never is), a 3xx is never followed.
|
|
92
|
+
response = await outboundResponse(
|
|
93
|
+
{
|
|
94
|
+
url: url,
|
|
95
|
+
vendor: input.id,
|
|
96
|
+
dial: 'named',
|
|
97
|
+
deadlineMs: timeoutMs,
|
|
98
|
+
method: 'GET',
|
|
99
|
+
headers: { Accept: 'application/json' },
|
|
100
|
+
},
|
|
101
|
+
{ fetch: doFetch },
|
|
102
|
+
);
|
|
92
103
|
} catch (error) {
|
|
93
104
|
throw oauthExchangeFailed({
|
|
94
105
|
provider: input.id,
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
renderThrowable,
|
|
13
13
|
systemClock,
|
|
14
14
|
} from '@ultimat3/core';
|
|
15
|
+
import { outboundResponse } from '@ultimat3/core/outbound';
|
|
15
16
|
import { type IdTokenClaims, verifyIdToken } from './id-token';
|
|
16
17
|
import { isRecord } from './json';
|
|
17
18
|
import { type IdTokenKeys, providerJwks } from './jwks';
|
|
@@ -185,7 +186,9 @@ async function postForm(
|
|
|
185
186
|
options: OAuthExchangeOptions,
|
|
186
187
|
authorization?: string,
|
|
187
188
|
): Promise<Record<string, unknown>> {
|
|
188
|
-
const doFetch: OAuthFetch =
|
|
189
|
+
const doFetch: OAuthFetch =
|
|
190
|
+
options.fetch ??
|
|
191
|
+
((input, init) => globalThis.fetch(input, { ...init, signal: init.signal ?? null }));
|
|
189
192
|
// Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
|
|
190
193
|
// catch around this fetch renders every throw as a provider failure — so a typo in the app's
|
|
191
194
|
// own config read as the identity provider being unreachable.
|
|
@@ -199,18 +202,26 @@ async function postForm(
|
|
|
199
202
|
|
|
200
203
|
let response: Response;
|
|
201
204
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
'
|
|
209
|
-
|
|
205
|
+
// Through core's `outbound()`: `timeoutMs` bounds the whole call, the ambient request's signal
|
|
206
|
+
// ends it, a GET's 5xx is retried inside that bound (a POST never is), a 3xx is never followed.
|
|
207
|
+
response = await outboundResponse(
|
|
208
|
+
{
|
|
209
|
+
url: url,
|
|
210
|
+
vendor: provider,
|
|
211
|
+
dial: 'named',
|
|
212
|
+
deadlineMs: timeoutMs,
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
// GitHub answers `application/x-www-form-urlencoded` unless asked for JSON.
|
|
216
|
+
Accept: 'application/json',
|
|
217
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
218
|
+
'User-Agent': OAUTH_USER_AGENT,
|
|
219
|
+
...(authorization === undefined ? {} : { Authorization: authorization }),
|
|
220
|
+
},
|
|
221
|
+
body: body.toString(),
|
|
210
222
|
},
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
});
|
|
223
|
+
{ fetch: doFetch },
|
|
224
|
+
);
|
|
214
225
|
} catch (error) {
|
|
215
226
|
// The rendered value goes to the LOG, never into the `detail:`.
|
|
216
227
|
//
|
package/src/oauth-profile.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// decides whether this login may attach itself to an existing account by address.
|
|
5
5
|
|
|
6
6
|
import { logger, renderFixShellArg, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { outboundResponse } from '@ultimat3/core/outbound';
|
|
7
8
|
import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
|
|
8
9
|
import { isRecord } from './json';
|
|
9
10
|
import type { OAuthProviderId } from './oauth';
|
|
@@ -53,7 +54,9 @@ async function getJson(
|
|
|
53
54
|
accessToken: string,
|
|
54
55
|
options: OAuthProfileOptions,
|
|
55
56
|
): Promise<{ ok: true; body: unknown } | { ok: false; status: number; detail: string }> {
|
|
56
|
-
const doFetch: OAuthFetch =
|
|
57
|
+
const doFetch: OAuthFetch =
|
|
58
|
+
options.fetch ??
|
|
59
|
+
((input, init) => globalThis.fetch(input, { ...init, signal: init.signal ?? null }));
|
|
57
60
|
// Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
|
|
58
61
|
// catch around this fetch renders every throw as a provider failure — so a typo in the app's
|
|
59
62
|
// own config read as the identity provider being unreachable.
|
|
@@ -65,15 +68,23 @@ async function getJson(
|
|
|
65
68
|
);
|
|
66
69
|
let response: Response;
|
|
67
70
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
// Through core's `outbound()`: `timeoutMs` bounds the whole call, the ambient request's signal
|
|
72
|
+
// ends it, a GET's 5xx is retried inside that bound (a POST never is), a 3xx is never followed.
|
|
73
|
+
response = await outboundResponse(
|
|
74
|
+
{
|
|
75
|
+
url: url,
|
|
76
|
+
vendor: provider,
|
|
77
|
+
dial: 'named',
|
|
78
|
+
deadlineMs: timeoutMs,
|
|
79
|
+
method: 'GET',
|
|
80
|
+
headers: {
|
|
81
|
+
Accept: 'application/json',
|
|
82
|
+
Authorization: `Bearer ${accessToken}`,
|
|
83
|
+
'User-Agent': OAUTH_USER_AGENT,
|
|
84
|
+
},
|
|
74
85
|
},
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
{ fetch: doFetch },
|
|
87
|
+
);
|
|
77
88
|
} catch (error) {
|
|
78
89
|
// `renderThrowable`, never `error.message` behind an `instanceof`: `fetch` is INJECTED here,
|
|
79
90
|
// so the rejection is whatever a driver or a proxy threw — and `instanceof` runs the value's
|
package/src/oauth-route.ts
CHANGED
|
@@ -376,10 +376,10 @@ async function callbackHandler(
|
|
|
376
376
|
const clear = hasOAuthProvider(segment) ? [clearHandshakeCookie(segment)] : [];
|
|
377
377
|
try {
|
|
378
378
|
const provider = assertEnabled(auth, segment);
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
credentialsFor(provider, options);
|
|
379
|
+
// A provider with no credentials answers 404 on THIS leg too: one oracle, closed on both halves
|
|
380
|
+
// — a 404 on start and a 500 on callback is the same enumeration one request later. And the
|
|
381
|
+
// answer is what the exchange uses, or `options.env` would answer the start leg alone.
|
|
382
|
+
const credentials = credentialsFor(provider, options);
|
|
383
383
|
const url = new URL(request.url);
|
|
384
384
|
assertNoProviderError(url, provider);
|
|
385
385
|
const result: LoginResult = await completeOAuthLogin(auth, {
|
|
@@ -388,7 +388,7 @@ async function callbackHandler(
|
|
|
388
388
|
state: url.searchParams.get('state') ?? '',
|
|
389
389
|
code: url.searchParams.get('code') ?? '',
|
|
390
390
|
},
|
|
391
|
-
|
|
391
|
+
credentials,
|
|
392
392
|
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
393
393
|
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
394
394
|
...(options.resolveGrants === undefined ? {} : { resolveGrants: options.resolveGrants }),
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Single responsibility: accepting an assertion against a STORED credential — owner, signature,
|
|
2
|
+
// user handle, and the signature counter's non-regression, advanced compare-and-set. Shared by
|
|
3
|
+
// sign-in and step-up, so the two can never disagree about what a valid assertion is.
|
|
4
|
+
|
|
5
|
+
import { logger } from '@ultimat3/core';
|
|
6
|
+
import type { PasskeyCredential } from './adapter';
|
|
7
|
+
import type { Auth } from './auth';
|
|
8
|
+
import type { PasskeyRelyingParty } from './passkey-config';
|
|
9
|
+
import { refusePasskey } from './passkey-errors';
|
|
10
|
+
import { type CeremonyEnvelope, type VerifiedAssertion, verifyAssertion } from './passkey-verify';
|
|
11
|
+
|
|
12
|
+
export interface AcceptedAssertion {
|
|
13
|
+
readonly credential: PasskeyCredential;
|
|
14
|
+
readonly assertion: VerifiedAssertion;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `expectedUserId` is the account the ceremony was issued to (step-up), or `null` when the
|
|
19
|
+
* credential names its own (usernameless sign-in) — then the returned `userHandle`, when the
|
|
20
|
+
* authenticator sent one, must agree with the stored owner.
|
|
21
|
+
*/
|
|
22
|
+
export async function acceptAssertion(
|
|
23
|
+
auth: Auth,
|
|
24
|
+
policy: PasskeyRelyingParty,
|
|
25
|
+
ceremony: 'login' | 'step-up',
|
|
26
|
+
envelope: CeremonyEnvelope,
|
|
27
|
+
expectedUserId: string | null,
|
|
28
|
+
): Promise<AcceptedAssertion> {
|
|
29
|
+
const credential = await auth.adapter.findPasskey(envelope.credentialId);
|
|
30
|
+
if (credential === null) throw refusePasskey(ceremony, 'credential-unknown');
|
|
31
|
+
if (expectedUserId !== null && credential.userId !== expectedUserId) {
|
|
32
|
+
throw refusePasskey(ceremony, 'credential-owner');
|
|
33
|
+
}
|
|
34
|
+
const verified = await verifyAssertion(policy, envelope, credential.publicKey);
|
|
35
|
+
if (!verified.ok) throw refusePasskey(ceremony, verified.check);
|
|
36
|
+
const assertion = verified.value;
|
|
37
|
+
if (assertion.userHandle !== null && assertion.userHandle !== credential.userId) {
|
|
38
|
+
throw refusePasskey(ceremony, 'user-handle');
|
|
39
|
+
}
|
|
40
|
+
// §7.2 step 21: a counter that did not move forward means two devices hold this key. Both
|
|
41
|
+
// zero is an authenticator that keeps no counter (most synced passkeys), which is allowed.
|
|
42
|
+
const counted = assertion.signCount !== 0 || credential.signCount !== 0;
|
|
43
|
+
if (counted && assertion.signCount <= credential.signCount) {
|
|
44
|
+
logger.warn('auth.passkey.counter_regressed', {
|
|
45
|
+
credentialId: credential.id,
|
|
46
|
+
stored: credential.signCount,
|
|
47
|
+
presented: assertion.signCount,
|
|
48
|
+
});
|
|
49
|
+
throw refusePasskey(ceremony, 'counter');
|
|
50
|
+
}
|
|
51
|
+
const advanced = await auth.adapter.advancePasskeyCounter(
|
|
52
|
+
credential.id,
|
|
53
|
+
credential.signCount,
|
|
54
|
+
assertion.signCount,
|
|
55
|
+
auth.clock.now(),
|
|
56
|
+
);
|
|
57
|
+
// Lost the compare-and-set: another assertion on this counter value landed first.
|
|
58
|
+
if (!advanced) throw refusePasskey(ceremony, 'counter-race');
|
|
59
|
+
return { credential, assertion };
|
|
60
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Single responsibility: a ceremony challenge's life — minted from the CSPRNG (or, for a step-up,
|
|
2
|
+
// derived from a nonce and the server's digest), stored as its sha256 with a TTL through the
|
|
3
|
+
// adapter, and redeemed ONCE by the hash `clientDataJSON` echoes. Never a process-local map: a
|
|
4
|
+
// fleet answers the options on one replica and the response on another.
|
|
5
|
+
|
|
6
|
+
import type { PasskeyCeremony, PasskeyChallengeRecord } from './adapter';
|
|
7
|
+
import type { Auth } from './auth';
|
|
8
|
+
import { base64Url } from './base64url';
|
|
9
|
+
import { passkeyChallengeExpired } from './passkey-errors';
|
|
10
|
+
import { randomBytes, sha256Hex } from './tokens';
|
|
11
|
+
|
|
12
|
+
export interface IssuedChallenge {
|
|
13
|
+
/** The base64url challenge to put in the options. */
|
|
14
|
+
readonly challenge: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ChallengeBinding {
|
|
18
|
+
readonly userId: string | null;
|
|
19
|
+
readonly digest?: string | undefined;
|
|
20
|
+
readonly nonce?: string | undefined;
|
|
21
|
+
/** A step-up's derived challenge; omitted, 32 random bytes are drawn. */
|
|
22
|
+
readonly challenge?: Uint8Array | undefined;
|
|
23
|
+
readonly ttlMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function issuePasskeyChallenge(
|
|
27
|
+
auth: Auth,
|
|
28
|
+
ceremony: PasskeyCeremony,
|
|
29
|
+
binding: ChallengeBinding,
|
|
30
|
+
): Promise<IssuedChallenge> {
|
|
31
|
+
const challenge = base64Url(binding.challenge ?? randomBytes(32));
|
|
32
|
+
const now = auth.clock.now();
|
|
33
|
+
await auth.adapter.putPasskeyChallenge({
|
|
34
|
+
id: sha256Hex(challenge),
|
|
35
|
+
ceremony,
|
|
36
|
+
userId: binding.userId,
|
|
37
|
+
digest: binding.digest ?? null,
|
|
38
|
+
nonce: binding.nonce ?? null,
|
|
39
|
+
expiresAt: new Date(now.getTime() + binding.ttlMs),
|
|
40
|
+
createdAt: now,
|
|
41
|
+
});
|
|
42
|
+
return { challenge };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Consumed first, judged second: an expired row is gone either way, and a replay of a redeemed
|
|
47
|
+
* one finds nothing. Unknown, spent and late are ONE refusal — which it was is not the caller's.
|
|
48
|
+
*/
|
|
49
|
+
export async function redeemPasskeyChallenge(
|
|
50
|
+
auth: Auth,
|
|
51
|
+
ceremony: PasskeyCeremony,
|
|
52
|
+
challenge: string,
|
|
53
|
+
): Promise<PasskeyChallengeRecord> {
|
|
54
|
+
const row = await auth.adapter.takePasskeyChallenge(sha256Hex(challenge), ceremony);
|
|
55
|
+
if (row === null || row.expiresAt.getTime() <= auth.clock.now().getTime()) {
|
|
56
|
+
throw passkeyChallengeExpired(ceremony);
|
|
57
|
+
}
|
|
58
|
+
return row;
|
|
59
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Single responsibility: the BROWSER half of a passkey ceremony — the server's JSON options into
|
|
2
|
+
// `navigator.credentials.create/get`, and the credential back out as JSON for the app's action.
|
|
3
|
+
// Imports only `base64url.ts` and types, so an island offering "Sign in with a passkey" pays for
|
|
4
|
+
// these lines and nothing of the server package (`@ultimat3/auth/client`, `client-bundle.test.ts`).
|
|
5
|
+
//
|
|
6
|
+
// const options = await client.passkeyLoginOptions({});
|
|
7
|
+
// const outcome = await getPasskey(options);
|
|
8
|
+
// if (outcome.status === 'ok') await client.passkeySignIn({ response: outcome.response });
|
|
9
|
+
|
|
10
|
+
import { base64Url, canonicalBase64UrlBytes } from './base64url';
|
|
11
|
+
import type {
|
|
12
|
+
PasskeyAssertionResponse,
|
|
13
|
+
PasskeyCreationOptions,
|
|
14
|
+
PasskeyCredentialDescriptor,
|
|
15
|
+
PasskeyRegistrationResponse,
|
|
16
|
+
PasskeyRequestOptions,
|
|
17
|
+
} from './passkey-wire';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every branch is an outcome to render, never an exception: `cancelled` is the person's choice
|
|
21
|
+
* (or the timeout), `unsupported` the browser's, `failed` anything else, named by the DOM error.
|
|
22
|
+
*/
|
|
23
|
+
export type PasskeyOutcome<T> =
|
|
24
|
+
| { readonly status: 'ok'; readonly response: T }
|
|
25
|
+
| { readonly status: 'cancelled' }
|
|
26
|
+
| { readonly status: 'unsupported' }
|
|
27
|
+
| { readonly status: 'failed'; readonly error: string };
|
|
28
|
+
|
|
29
|
+
/** The browser surface this touches — injectable, so a test runs a ceremony without a browser. */
|
|
30
|
+
export interface PasskeyClientHost {
|
|
31
|
+
create(options: CredentialCreationOptions): Promise<Credential | null>;
|
|
32
|
+
get(options: CredentialRequestOptions): Promise<Credential | null>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const browserHost = (): PasskeyClientHost | null => {
|
|
36
|
+
const container = globalThis.navigator?.credentials;
|
|
37
|
+
return container === undefined || globalThis.PublicKeyCredential === undefined ? null : container;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Whether this browser can run a ceremony at all — for hiding the button, not for security. */
|
|
41
|
+
export const passkeysSupported = (): boolean => browserHost() !== null;
|
|
42
|
+
|
|
43
|
+
const bytes = (field: string): Uint8Array<ArrayBuffer> =>
|
|
44
|
+
canonicalBase64UrlBytes(field) ?? new Uint8Array();
|
|
45
|
+
|
|
46
|
+
const TRANSPORTS: ReadonlySet<string> = new Set([
|
|
47
|
+
'usb',
|
|
48
|
+
'nfc',
|
|
49
|
+
'ble',
|
|
50
|
+
'smart-card',
|
|
51
|
+
'hybrid',
|
|
52
|
+
'internal',
|
|
53
|
+
]);
|
|
54
|
+
const isTransport = (value: string): value is AuthenticatorTransport => TRANSPORTS.has(value);
|
|
55
|
+
|
|
56
|
+
const descriptor = (row: PasskeyCredentialDescriptor): PublicKeyCredentialDescriptor => ({
|
|
57
|
+
type: 'public-key',
|
|
58
|
+
id: bytes(row.id),
|
|
59
|
+
transports: row.transports.filter(isTransport),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const text = (buffer: ArrayBuffer | null | undefined): string =>
|
|
63
|
+
buffer === null || buffer === undefined ? '' : base64Url(new Uint8Array(buffer));
|
|
64
|
+
|
|
65
|
+
/** Structural, not `instanceof`: a test host (and a polyfill) has no `PublicKeyCredential` class. */
|
|
66
|
+
const isPublicKey = (credential: Credential | null): credential is PublicKeyCredential =>
|
|
67
|
+
credential !== null &&
|
|
68
|
+
credential.type === 'public-key' &&
|
|
69
|
+
'rawId' in credential &&
|
|
70
|
+
credential.rawId instanceof ArrayBuffer &&
|
|
71
|
+
'response' in credential;
|
|
72
|
+
|
|
73
|
+
const outcomeOf = (error: unknown): PasskeyOutcome<never> => {
|
|
74
|
+
const name =
|
|
75
|
+
typeof error === 'object' && error !== null && 'name' in error ? String(error.name) : 'Error';
|
|
76
|
+
// `NotAllowedError` is what every browser answers for "the person closed the sheet" and for the
|
|
77
|
+
// timeout alike, deliberately indistinguishable (§13.4.7); `AbortError` is the page's own abort.
|
|
78
|
+
if (name === 'NotAllowedError' || name === 'AbortError') return { status: 'cancelled' };
|
|
79
|
+
return { status: 'failed', error: name };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Registration: `passkeyLogin(auth).registerOptions` in, `register`'s `response` out. */
|
|
83
|
+
export async function newPasskey(
|
|
84
|
+
options: PasskeyCreationOptions,
|
|
85
|
+
host: PasskeyClientHost | null = browserHost(),
|
|
86
|
+
): Promise<PasskeyOutcome<PasskeyRegistrationResponse>> {
|
|
87
|
+
if (host === null) return { status: 'unsupported' };
|
|
88
|
+
try {
|
|
89
|
+
const credential = await host.create({
|
|
90
|
+
publicKey: {
|
|
91
|
+
challenge: bytes(options.challenge),
|
|
92
|
+
rp: options.rp,
|
|
93
|
+
user: { ...options.user, id: bytes(options.user.id) },
|
|
94
|
+
pubKeyCredParams: [...options.pubKeyCredParams],
|
|
95
|
+
timeout: options.timeout,
|
|
96
|
+
attestation: options.attestation,
|
|
97
|
+
authenticatorSelection: options.authenticatorSelection,
|
|
98
|
+
excludeCredentials: options.excludeCredentials.map(descriptor),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
if (!isPublicKey(credential)) return { status: 'failed', error: 'NoCredential' };
|
|
102
|
+
const response = credential.response as AuthenticatorAttestationResponse;
|
|
103
|
+
return {
|
|
104
|
+
status: 'ok',
|
|
105
|
+
response: {
|
|
106
|
+
id: credential.id,
|
|
107
|
+
rawId: text(credential.rawId),
|
|
108
|
+
type: 'public-key',
|
|
109
|
+
response: {
|
|
110
|
+
clientDataJSON: text(response.clientDataJSON),
|
|
111
|
+
attestationObject: text(response.attestationObject),
|
|
112
|
+
transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return outcomeOf(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Sign-in and step-up: `loginOptions` / `stepUp.options` in, `login` / `verify`'s `response` out. */
|
|
122
|
+
export async function getPasskey(
|
|
123
|
+
options: PasskeyRequestOptions,
|
|
124
|
+
host: PasskeyClientHost | null = browserHost(),
|
|
125
|
+
): Promise<PasskeyOutcome<PasskeyAssertionResponse>> {
|
|
126
|
+
if (host === null) return { status: 'unsupported' };
|
|
127
|
+
try {
|
|
128
|
+
const credential = await host.get({
|
|
129
|
+
publicKey: {
|
|
130
|
+
challenge: bytes(options.challenge),
|
|
131
|
+
rpId: options.rpId,
|
|
132
|
+
timeout: options.timeout,
|
|
133
|
+
userVerification: options.userVerification,
|
|
134
|
+
allowCredentials: options.allowCredentials.map(descriptor),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
if (!isPublicKey(credential)) return { status: 'failed', error: 'NoCredential' };
|
|
138
|
+
const response = credential.response as AuthenticatorAssertionResponse;
|
|
139
|
+
return {
|
|
140
|
+
status: 'ok',
|
|
141
|
+
response: {
|
|
142
|
+
id: credential.id,
|
|
143
|
+
rawId: text(credential.rawId),
|
|
144
|
+
type: 'public-key',
|
|
145
|
+
response: {
|
|
146
|
+
clientDataJSON: text(response.clientDataJSON),
|
|
147
|
+
authenticatorData: text(response.authenticatorData),
|
|
148
|
+
signature: text(response.signature),
|
|
149
|
+
userHandle: response.userHandle === null ? null : text(response.userHandle),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return outcomeOf(error);
|
|
155
|
+
}
|
|
156
|
+
}
|