@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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Single responsibility: `authorizationServer()` — one OAuth 2.1 authorization server for an app's
|
|
2
|
+
// MCP endpoint, public API, CLI or partner integrations alike. It returns plain `routes` for
|
|
3
|
+
// `runtime.routes` and the calls the app's own consent and device pages make; consent itself,
|
|
4
|
+
// who may be a client and what a subject becomes as an `Actor` stay the app's (axiom 8).
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
clientDirectory,
|
|
8
|
+
type RegisterClientInput,
|
|
9
|
+
type RegisteredClient,
|
|
10
|
+
storeClient,
|
|
11
|
+
} from './authorization-server-clients';
|
|
12
|
+
import {
|
|
13
|
+
type AuthorizationServerOptions,
|
|
14
|
+
resolveAuthServerConfig,
|
|
15
|
+
} from './authorization-server-config';
|
|
16
|
+
import { consentInvalid } from './authorization-server-errors';
|
|
17
|
+
import {
|
|
18
|
+
type ReadRequestResult,
|
|
19
|
+
redirectWith,
|
|
20
|
+
screenAuthorization,
|
|
21
|
+
} from './authorization-server-request';
|
|
22
|
+
import { authServerMetadata, authServerRoutes } from './authorization-server-routes';
|
|
23
|
+
import { resolveAccess, resolvedGrant } from './authorization-server-tokens';
|
|
24
|
+
import type { AuthorizationRequest, ResolvedGrant } from './authorization-server-types';
|
|
25
|
+
import type { AuthServerRoute } from './authorization-server-wire';
|
|
26
|
+
import { type DeviceReading, decideDevice, readDevice } from './grant-device';
|
|
27
|
+
import { randomToken, sha256Hex } from './tokens';
|
|
28
|
+
|
|
29
|
+
/** RFC 6749 §4.1.2 says "maximum of 10 minutes"; two is enough for a redirect and a POST. */
|
|
30
|
+
export const AUTH_CODE_TTL_MS = 2 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
export interface AuthorizationServer {
|
|
33
|
+
readonly issuer: string;
|
|
34
|
+
/** Spread into `runtime.routes`. Public; no cookie authenticates them. */
|
|
35
|
+
readonly routes: readonly AuthServerRoute[];
|
|
36
|
+
/** The RFC 8414 document the metadata route serves. */
|
|
37
|
+
readonly metadata: Readonly<Record<string, unknown>>;
|
|
38
|
+
/** The consent page's read of its own URL: what to show, or the refusal to show instead. */
|
|
39
|
+
readRequest(url: string | URL): Promise<ReadRequestResult>;
|
|
40
|
+
/** The consent action's approval: the redirect URL carrying `code`, `state` and `iss`. */
|
|
41
|
+
approve(input: {
|
|
42
|
+
readonly subject: string;
|
|
43
|
+
readonly request: AuthorizationRequest;
|
|
44
|
+
/** What the person granted — the request's scopes or fewer. Defaults to all of them. */
|
|
45
|
+
readonly scopes?: readonly string[] | undefined;
|
|
46
|
+
}): Promise<string>;
|
|
47
|
+
/** The redirect URL carrying `error=access_denied`. */
|
|
48
|
+
deny(input: { readonly request: AuthorizationRequest }): Promise<string>;
|
|
49
|
+
/** `key` is the caller's bucket for user-code guesses: their address or session id. */
|
|
50
|
+
readDevice(input: { readonly userCode: string; readonly key: string }): Promise<DeviceReading>;
|
|
51
|
+
approveDevice(input: {
|
|
52
|
+
readonly subject: string;
|
|
53
|
+
readonly userCode: string;
|
|
54
|
+
readonly key: string;
|
|
55
|
+
readonly scopes: readonly string[];
|
|
56
|
+
}): Promise<void>;
|
|
57
|
+
denyDevice(input: { readonly userCode: string; readonly key: string }): Promise<void>;
|
|
58
|
+
/** A client the app registers itself — a partner's machine client, a first-party CLI. */
|
|
59
|
+
registerClient(input: RegisterClientInput): Promise<RegisteredClient>;
|
|
60
|
+
/** A live access token's grant, or `null`: unknown, expired and revoked look alike. */
|
|
61
|
+
resolve(token: string): Promise<ResolvedGrant | null>;
|
|
62
|
+
/** Every grant of `subject` (of one client when named), dead on the next lookup. */
|
|
63
|
+
revoke(input: {
|
|
64
|
+
readonly subject: string;
|
|
65
|
+
readonly clientId?: string | undefined;
|
|
66
|
+
}): Promise<number>;
|
|
67
|
+
/** Run from the app's own `task`: nothing here runs on a timer. */
|
|
68
|
+
purge(input: { readonly before: Date }): Promise<number>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The request as the query a client would have sent — what `approve` screens a second time. */
|
|
72
|
+
function requestParams(request: AuthorizationRequest): URLSearchParams {
|
|
73
|
+
const params = new URLSearchParams({
|
|
74
|
+
response_type: 'code',
|
|
75
|
+
client_id: request.client.clientId,
|
|
76
|
+
redirect_uri: request.redirectUri,
|
|
77
|
+
code_challenge: request.codeChallenge,
|
|
78
|
+
code_challenge_method: 'S256',
|
|
79
|
+
scope: request.scopes.join(' '),
|
|
80
|
+
});
|
|
81
|
+
if (request.state !== null) params.set('state', request.state);
|
|
82
|
+
if (request.resource !== null) params.set('resource', request.resource);
|
|
83
|
+
return params;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function authorizationServer(options: AuthorizationServerOptions): AuthorizationServer {
|
|
87
|
+
const config = resolveAuthServerConfig(options);
|
|
88
|
+
const directory = clientDirectory(config);
|
|
89
|
+
const read = async (params: URLSearchParams): Promise<ReadRequestResult> => {
|
|
90
|
+
const clientId = params.get('client_id');
|
|
91
|
+
const client = clientId === null ? null : await directory.find(clientId);
|
|
92
|
+
return screenAuthorization(config, client, params);
|
|
93
|
+
};
|
|
94
|
+
/** The request handed back is re-screened against the store: a forged object proves nothing. */
|
|
95
|
+
const rescreen = async (request: AuthorizationRequest): Promise<AuthorizationRequest> => {
|
|
96
|
+
const again = await read(requestParams(request));
|
|
97
|
+
if (!again.ok) throw consentInvalid(again.description);
|
|
98
|
+
return again.request;
|
|
99
|
+
};
|
|
100
|
+
const subjectOf = (subject: string): string => {
|
|
101
|
+
if (subject.trim() === '') throw consentInvalid('the subject is empty');
|
|
102
|
+
return subject;
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
issuer: config.issuer,
|
|
106
|
+
routes: authServerRoutes(config, directory),
|
|
107
|
+
metadata: authServerMetadata(config),
|
|
108
|
+
readRequest: (url) => read(new URL(url).searchParams),
|
|
109
|
+
async approve(input) {
|
|
110
|
+
const subject = subjectOf(input.subject);
|
|
111
|
+
const request = await rescreen(input.request);
|
|
112
|
+
const scopes = [...new Set(input.scopes ?? request.scopes)];
|
|
113
|
+
if (scopes.length === 0 || !scopes.every((scope) => request.scopes.includes(scope))) {
|
|
114
|
+
throw consentInvalid('the approved scopes are empty or not a subset of the requested ones');
|
|
115
|
+
}
|
|
116
|
+
const now = config.clock.now();
|
|
117
|
+
const code = randomToken(32);
|
|
118
|
+
const grantId = `g_${randomToken(16)}`;
|
|
119
|
+
// The grant exists before its code is redeemed, so a replayed code always has one to revoke.
|
|
120
|
+
await config.store.putGrant({
|
|
121
|
+
id: grantId,
|
|
122
|
+
clientId: request.client.clientId,
|
|
123
|
+
subject,
|
|
124
|
+
scopes,
|
|
125
|
+
resource: request.resource,
|
|
126
|
+
createdAt: now,
|
|
127
|
+
refreshedAt: now,
|
|
128
|
+
revokedAt: null,
|
|
129
|
+
});
|
|
130
|
+
await config.store.putCode({
|
|
131
|
+
codeHash: sha256Hex(code),
|
|
132
|
+
grantId,
|
|
133
|
+
clientId: request.client.clientId,
|
|
134
|
+
subject,
|
|
135
|
+
scopes,
|
|
136
|
+
resource: request.resource,
|
|
137
|
+
redirectUri: request.redirectUri,
|
|
138
|
+
codeChallenge: request.codeChallenge,
|
|
139
|
+
expiresAt: new Date(now.getTime() + AUTH_CODE_TTL_MS),
|
|
140
|
+
usedAt: null,
|
|
141
|
+
});
|
|
142
|
+
return redirectWith(request.redirectUri, {
|
|
143
|
+
code,
|
|
144
|
+
iss: config.issuer,
|
|
145
|
+
...(request.state === null ? {} : { state: request.state }),
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
async deny(input) {
|
|
149
|
+
const request = await rescreen(input.request);
|
|
150
|
+
return redirectWith(request.redirectUri, {
|
|
151
|
+
error: 'access_denied',
|
|
152
|
+
iss: config.issuer,
|
|
153
|
+
...(request.state === null ? {} : { state: request.state }),
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
readDevice: (input) => readDevice(config, input.userCode, input.key),
|
|
157
|
+
approveDevice: (input) =>
|
|
158
|
+
decideDevice(config, {
|
|
159
|
+
userCode: input.userCode,
|
|
160
|
+
key: input.key,
|
|
161
|
+
approve: { subject: subjectOf(input.subject), scopes: input.scopes },
|
|
162
|
+
}),
|
|
163
|
+
denyDevice: (input) =>
|
|
164
|
+
decideDevice(config, { userCode: input.userCode, key: input.key, approve: null }),
|
|
165
|
+
registerClient: (input) => storeClient(config, input),
|
|
166
|
+
async resolve(token) {
|
|
167
|
+
const live = await resolveAccess(config, token);
|
|
168
|
+
return live === null ? null : resolvedGrant(live.grant);
|
|
169
|
+
},
|
|
170
|
+
revoke: (input) =>
|
|
171
|
+
config.store.revokeGrants(
|
|
172
|
+
{ subject: input.subject, clientId: input.clientId },
|
|
173
|
+
config.clock.now(),
|
|
174
|
+
),
|
|
175
|
+
purge: ({ before }) =>
|
|
176
|
+
config.store.purge(before, new Date(before.getTime() - config.refreshIdleMs)),
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/base64url.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Single responsibility: base64url, both directions, with NO imports — so the server half
|
|
2
|
+
// (`tokens.ts` re-exports it) and the browser entry (`client.ts`) share one implementation and the
|
|
3
|
+
// client bundle pays for nothing else. WebAuthn speaks base64url on every field of its JSON wire.
|
|
4
|
+
|
|
5
|
+
const BASE64URL_UNSAFE = /[+/=]/g;
|
|
6
|
+
const BASE64URL_REPLACEMENTS: Readonly<Record<string, string>> = { '+': '-', '/': '_', '=': '' };
|
|
7
|
+
|
|
8
|
+
export function base64Url(bytes: Uint8Array): string {
|
|
9
|
+
let binary = '';
|
|
10
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
11
|
+
return btoa(binary).replace(BASE64URL_UNSAFE, (char) => BASE64URL_REPLACEMENTS[char] ?? '');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The inverse of `base64Url`, for the segments of a JWT. Answers `null` rather than throwing
|
|
16
|
+
* because every caller is reading an attacker-supplied string — a `URIError` or an `InvalidCharacterError`
|
|
17
|
+
* escaping from here would turn a coded refusal into a 500, the same reason `readCookie` never throws.
|
|
18
|
+
*/
|
|
19
|
+
export function base64UrlBytes(segment: string): Uint8Array<ArrayBuffer> | null {
|
|
20
|
+
const padded = segment
|
|
21
|
+
.replaceAll('-', '+')
|
|
22
|
+
.replaceAll('_', '/')
|
|
23
|
+
.padEnd(Math.ceil(segment.length / 4) * 4, '=');
|
|
24
|
+
try {
|
|
25
|
+
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `base64UrlBytes`, refusing every spelling but the canonical one: `atob` forgives padding,
|
|
33
|
+
* whitespace and the standard alphabet, so two strings would name one credential id. A WebAuthn
|
|
34
|
+
* field is compared as text in places (the challenge in `clientDataJSON`), so it has ONE spelling.
|
|
35
|
+
*/
|
|
36
|
+
export function canonicalBase64UrlBytes(segment: unknown): Uint8Array<ArrayBuffer> | null {
|
|
37
|
+
if (typeof segment !== 'string' || !/^[A-Za-z0-9_-]*$/.test(segment)) return null;
|
|
38
|
+
const bytes = base64UrlBytes(segment);
|
|
39
|
+
return bytes !== null && base64Url(bytes) === segment ? bytes : null;
|
|
40
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Single responsibility: an `x_users` unique violation as the code `MemoryAuthAdapter` answers. One
|
|
1
|
+
// Single responsibility: an `x_users` (or `x_passkeys`) unique violation as the code `MemoryAuthAdapter` answers. One
|
|
2
2
|
// duplicate signup must not be `X_AUTH_WRITE_FAILED` under `x dev` and `X_DB_UNIQUE_VIOLATION` in
|
|
3
3
|
// production: an app that branches on the code passes its tests and misses on Postgres.
|
|
4
4
|
|
|
@@ -7,14 +7,17 @@ import { SQLSTATE, sqlState } from '@ultimat3/db';
|
|
|
7
7
|
import { type AuthError, authUniqueViolation } from './errors';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* The constraints `tables.ts` declares on `x_users`, by the names Postgres gives them. Closed: a
|
|
10
|
+
* The constraints `tables.ts` declares on `x_users` and `x_passkeys`, by the names Postgres gives them. Closed: a
|
|
11
11
|
* unique index an app added to the table is the app's, and its violation travels on as the
|
|
12
12
|
* database error it is rather than being reported as a column it does not cover.
|
|
13
13
|
*/
|
|
14
|
-
const
|
|
15
|
-
['x_users_email_key', 'email'],
|
|
16
|
-
['x_users_external_id_key', 'external_id'],
|
|
17
|
-
['x_users_pkey', 'id'],
|
|
14
|
+
const UNIQUE_COLUMNS: ReadonlyMap<string, readonly [table: string, column: string]> = new Map([
|
|
15
|
+
['x_users_email_key', ['x_users', 'email']],
|
|
16
|
+
['x_users_external_id_key', ['x_users', 'external_id']],
|
|
17
|
+
['x_users_pkey', ['x_users', 'id']],
|
|
18
|
+
// A credential id is globally unique (WebAuthn §7.1 step 22): a second registration of one.
|
|
19
|
+
['x_passkeys_pkey', ['x_passkeys', 'id']],
|
|
20
|
+
['x_passkey_evidence_pkey', ['x_passkey_evidence', 'id']],
|
|
18
21
|
]);
|
|
19
22
|
|
|
20
23
|
/** How deep `DbError.sourceError` may nest before the read stops — db's own bound. */
|
|
@@ -32,12 +35,12 @@ function violatedConstraint(error: unknown): string | undefined {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
* `undefined` for everything that is not one of
|
|
38
|
+
* `undefined` for everything that is not one of those tables' own unique constraints, so the caller
|
|
36
39
|
* rethrows what it caught. The SQLSTATE is read through db's `sqlState`, which decides by where
|
|
37
40
|
* the error came from, never by the shape of a `code` field.
|
|
38
41
|
*/
|
|
39
42
|
export function usersUniqueViolation(operation: string, error: unknown): AuthError | undefined {
|
|
40
43
|
if (sqlState(error) !== SQLSTATE.uniqueViolation) return undefined;
|
|
41
|
-
const
|
|
42
|
-
return
|
|
44
|
+
const named = UNIQUE_COLUMNS.get(violatedConstraint(error) ?? '');
|
|
45
|
+
return named === undefined ? undefined : authUniqueViolation(operation, named[0], named[1]);
|
|
43
46
|
}
|
package/src/builtin-adapter.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
AuthUser,
|
|
14
14
|
AuthVerification,
|
|
15
15
|
CreateUserInput,
|
|
16
|
+
PasskeyStore,
|
|
16
17
|
SessionPatch,
|
|
17
18
|
StoredMfaSecret,
|
|
18
19
|
UserPatch,
|
|
@@ -20,6 +21,7 @@ import type {
|
|
|
20
21
|
} from './adapter';
|
|
21
22
|
import { usersUniqueViolation } from './builtin-adapter-violation';
|
|
22
23
|
import { authWriteFailed } from './errors';
|
|
24
|
+
import { postgresPasskeyStore } from './passkey-postgres';
|
|
23
25
|
|
|
24
26
|
type Row = Readonly<Record<string, unknown>>;
|
|
25
27
|
|
|
@@ -119,18 +121,41 @@ const toApiKey = (row: Row): AuthApiKeyRecord => ({
|
|
|
119
121
|
*/
|
|
120
122
|
export class PostgresAuthAdapter implements AuthAdapter {
|
|
121
123
|
readonly name = 'builtin-postgres';
|
|
122
|
-
readonly #
|
|
124
|
+
readonly #client: DbClient | undefined;
|
|
123
125
|
readonly #clock: Clock;
|
|
126
|
+
readonly #passkeys: PasskeyStore = postgresPasskeyStore(() => this.#db);
|
|
127
|
+
// The passkey members are `passkey-postgres.ts`'s, bound once: closures over the store, no `this`.
|
|
128
|
+
readonly putPasskey: PasskeyStore['putPasskey'] = this.#passkeys.putPasskey;
|
|
129
|
+
readonly findPasskey: PasskeyStore['findPasskey'] = this.#passkeys.findPasskey;
|
|
130
|
+
readonly listPasskeys: PasskeyStore['listPasskeys'] = this.#passkeys.listPasskeys;
|
|
131
|
+
readonly advancePasskeyCounter: PasskeyStore['advancePasskeyCounter'] =
|
|
132
|
+
this.#passkeys.advancePasskeyCounter;
|
|
133
|
+
readonly deletePasskey: PasskeyStore['deletePasskey'] = this.#passkeys.deletePasskey;
|
|
134
|
+
readonly putPasskeyChallenge: PasskeyStore['putPasskeyChallenge'] =
|
|
135
|
+
this.#passkeys.putPasskeyChallenge;
|
|
136
|
+
readonly takePasskeyChallenge: PasskeyStore['takePasskeyChallenge'] =
|
|
137
|
+
this.#passkeys.takePasskeyChallenge;
|
|
138
|
+
readonly putPasskeyEvidence: PasskeyStore['putPasskeyEvidence'] =
|
|
139
|
+
this.#passkeys.putPasskeyEvidence;
|
|
140
|
+
readonly listPasskeyEvidence: PasskeyStore['listPasskeyEvidence'] =
|
|
141
|
+
this.#passkeys.listPasskeyEvidence;
|
|
124
142
|
|
|
125
143
|
/**
|
|
144
|
+
* `client` omitted, every call resolves the ambient `db()` when it runs, never once here: a
|
|
145
|
+
* memoized `appAuth` is built by the process's first caller, and a client captured then
|
|
146
|
+
* ignored every later `setDbClient` and every open `withTransaction`. Passed, it is pinned.
|
|
126
147
|
* `clock` is the one `MemoryAuthAdapter` takes, for the same stamp: hand over the clock
|
|
127
148
|
* `defineAuth` was given, so a redemption is dated by the clock that judged its expiry.
|
|
128
149
|
*/
|
|
129
|
-
constructor(client
|
|
130
|
-
this.#
|
|
150
|
+
constructor(client?: DbClient, clock: Clock = systemClock) {
|
|
151
|
+
this.#client = client;
|
|
131
152
|
this.#clock = clock;
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
get #db(): DbClient {
|
|
156
|
+
return this.#client ?? db();
|
|
157
|
+
}
|
|
158
|
+
|
|
134
159
|
/** A write to `x_users`, with the table's own unique violations answered as this package's. */
|
|
135
160
|
async #writeUser(operation: string, statement: Parameters<DbClient['one']>[0]) {
|
|
136
161
|
try {
|
|
@@ -427,10 +452,11 @@ export class PostgresAuthAdapter implements AuthAdapter {
|
|
|
427
452
|
|
|
428
453
|
/**
|
|
429
454
|
* The one way to build the Postgres adapter — the twin of `memoryAuthAdapter()`. The class is a
|
|
430
|
-
* type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
|
|
455
|
+
* type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling. No
|
|
456
|
+
* `client`: each call reads the ambient `db()` as it runs, so build it once and keep it.
|
|
431
457
|
*/
|
|
432
458
|
export function postgresAuthAdapter(
|
|
433
|
-
client
|
|
459
|
+
client?: DbClient,
|
|
434
460
|
clock: Clock = systemClock,
|
|
435
461
|
): PostgresAuthAdapter {
|
|
436
462
|
return new PostgresAuthAdapter(client, clock);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Single responsibility: the CBOR ENCODER for the subset `cbor.ts` decodes — what the software
|
|
2
|
+
// authenticator writes an attestation object and a COSE key with. Shortest-form heads, definite
|
|
3
|
+
// lengths, keys in the order given: canonical enough for every map an authenticator sends.
|
|
4
|
+
|
|
5
|
+
import type { CborValue } from './cbor';
|
|
6
|
+
|
|
7
|
+
const head = (major: number, value: number): number[] => {
|
|
8
|
+
if (value < 24) return [(major << 5) | value];
|
|
9
|
+
if (value < 0x100) return [(major << 5) | 24, value];
|
|
10
|
+
if (value < 0x10000) return [(major << 5) | 25, value >> 8, value & 0xff];
|
|
11
|
+
if (value >= 2 ** 32) {
|
|
12
|
+
const bytes: number[] = [];
|
|
13
|
+
for (let rest = value, index = 0; index < 8; index += 1, rest = Math.floor(rest / 256)) {
|
|
14
|
+
bytes.unshift(rest % 256);
|
|
15
|
+
}
|
|
16
|
+
return [(major << 5) | 27, ...bytes];
|
|
17
|
+
}
|
|
18
|
+
return [
|
|
19
|
+
(major << 5) | 26,
|
|
20
|
+
(value >>> 24) & 0xff,
|
|
21
|
+
(value >> 16) & 0xff,
|
|
22
|
+
(value >> 8) & 0xff,
|
|
23
|
+
value & 0xff,
|
|
24
|
+
];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** One CBOR item, for the subset `cbor.ts` reads. */
|
|
28
|
+
export function cborEncode(value: CborValue): Uint8Array {
|
|
29
|
+
const out: number[] = [];
|
|
30
|
+
const walk = (item: CborValue): void => {
|
|
31
|
+
if (typeof item === 'number') out.push(...(item >= 0 ? head(0, item) : head(1, -1 - item)));
|
|
32
|
+
else if (typeof item === 'string') {
|
|
33
|
+
const bytes = new TextEncoder().encode(item);
|
|
34
|
+
out.push(...head(3, bytes.length), ...bytes);
|
|
35
|
+
} else if (item instanceof Uint8Array) out.push(...head(2, item.length), ...item);
|
|
36
|
+
else if (item === false) out.push(0xf4);
|
|
37
|
+
else if (item === true) out.push(0xf5);
|
|
38
|
+
else if (item === null) out.push(0xf6);
|
|
39
|
+
else if (item instanceof Map) {
|
|
40
|
+
out.push(...head(5, item.size));
|
|
41
|
+
for (const [key, entry] of item) {
|
|
42
|
+
walk(key);
|
|
43
|
+
walk(entry);
|
|
44
|
+
}
|
|
45
|
+
} else if (Array.isArray(item)) {
|
|
46
|
+
out.push(...head(4, item.length));
|
|
47
|
+
for (const entry of item) walk(entry);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(value);
|
|
51
|
+
return Uint8Array.from(out);
|
|
52
|
+
}
|
package/src/cbor.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Single responsibility: the CBOR subset WebAuthn sends (RFC 8949, CTAP2 canonical form) —
|
|
2
|
+
// definite-length integers, byte and text strings, arrays, maps and the three simple values —
|
|
3
|
+
// decoded from attacker-supplied bytes. Anything else (tags, floats, indefinite lengths) is a
|
|
4
|
+
// refusal, never a guess: the attestation object and the COSE key use none of it.
|
|
5
|
+
|
|
6
|
+
/** A decoded item. Maps are a `Map`, never a record indexed by a key the sender chose. */
|
|
7
|
+
export type CborValue =
|
|
8
|
+
| number
|
|
9
|
+
| string
|
|
10
|
+
| boolean
|
|
11
|
+
| null
|
|
12
|
+
| Uint8Array
|
|
13
|
+
| readonly CborValue[]
|
|
14
|
+
| ReadonlyMap<number | string, CborValue>;
|
|
15
|
+
|
|
16
|
+
/** What one item decoded to, and the offset of the first byte after it. */
|
|
17
|
+
export interface CborItem {
|
|
18
|
+
readonly value: CborValue;
|
|
19
|
+
readonly end: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A nested key inside a COSE key is depth 2; anything near this is not WebAuthn. */
|
|
23
|
+
const MAX_DEPTH = 8;
|
|
24
|
+
/** Items in one array or map. A COSE key has five; an attestation object three. */
|
|
25
|
+
const MAX_ITEMS = 64;
|
|
26
|
+
|
|
27
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
28
|
+
|
|
29
|
+
/** `null` for every way the bytes are not one well-formed item of the subset. */
|
|
30
|
+
export function decodeCborItem(bytes: Uint8Array, start = 0): CborItem | null {
|
|
31
|
+
try {
|
|
32
|
+
return item(bytes, start, 0);
|
|
33
|
+
} catch {
|
|
34
|
+
// Only `TextDecoder`'s fatal throw reaches here: invalid UTF-8 in a text string.
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The whole buffer as exactly one item — trailing bytes are a refusal. */
|
|
40
|
+
export function decodeCbor(bytes: Uint8Array): CborValue | undefined {
|
|
41
|
+
const decoded = decodeCborItem(bytes);
|
|
42
|
+
return decoded !== null && decoded.end === bytes.length ? decoded.value : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Head {
|
|
46
|
+
readonly major: number;
|
|
47
|
+
readonly argument: number;
|
|
48
|
+
readonly next: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function head(bytes: Uint8Array, at: number): Head | null {
|
|
52
|
+
const initial = bytes[at];
|
|
53
|
+
if (initial === undefined) return null;
|
|
54
|
+
const major = initial >> 5;
|
|
55
|
+
const info = initial & 0x1f;
|
|
56
|
+
if (info < 24) return { major, argument: info, next: at + 1 };
|
|
57
|
+
const width = info === 24 ? 1 : info === 25 ? 2 : info === 26 ? 4 : info === 27 ? 8 : 0;
|
|
58
|
+
// 28-30 are reserved, 31 is indefinite length: neither is in the subset.
|
|
59
|
+
if (width === 0 || at + 1 + width > bytes.length) return null;
|
|
60
|
+
let argument = 0;
|
|
61
|
+
for (let index = 1; index <= width; index += 1) {
|
|
62
|
+
argument = argument * 256 + (bytes[at + index] ?? 0);
|
|
63
|
+
}
|
|
64
|
+
if (!Number.isSafeInteger(argument)) return null;
|
|
65
|
+
return { major, argument, next: at + 1 + width };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function item(bytes: Uint8Array, at: number, depth: number): CborItem | null {
|
|
69
|
+
if (depth > MAX_DEPTH) return null;
|
|
70
|
+
const parsed = head(bytes, at);
|
|
71
|
+
if (parsed === null) return null;
|
|
72
|
+
const { major, argument, next } = parsed;
|
|
73
|
+
switch (major) {
|
|
74
|
+
case 0:
|
|
75
|
+
return { value: argument, end: next };
|
|
76
|
+
case 1:
|
|
77
|
+
return { value: -1 - argument, end: next };
|
|
78
|
+
case 2:
|
|
79
|
+
case 3: {
|
|
80
|
+
const end = next + argument;
|
|
81
|
+
if (end > bytes.length) return null;
|
|
82
|
+
const slice = bytes.slice(next, end);
|
|
83
|
+
return { value: major === 2 ? slice : decoder.decode(slice), end };
|
|
84
|
+
}
|
|
85
|
+
case 4:
|
|
86
|
+
return list(bytes, next, argument, depth);
|
|
87
|
+
case 5:
|
|
88
|
+
return map(bytes, next, argument, depth);
|
|
89
|
+
case 7:
|
|
90
|
+
if (argument === 20) return { value: false, end: next };
|
|
91
|
+
if (argument === 21) return { value: true, end: next };
|
|
92
|
+
if (argument === 22) return { value: null, end: next };
|
|
93
|
+
return null;
|
|
94
|
+
default:
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function list(bytes: Uint8Array, at: number, count: number, depth: number): CborItem | null {
|
|
100
|
+
if (count > MAX_ITEMS) return null;
|
|
101
|
+
const values: CborValue[] = [];
|
|
102
|
+
let cursor = at;
|
|
103
|
+
for (let index = 0; index < count; index += 1) {
|
|
104
|
+
const entry = item(bytes, cursor, depth + 1);
|
|
105
|
+
if (entry === null) return null;
|
|
106
|
+
values.push(entry.value);
|
|
107
|
+
cursor = entry.end;
|
|
108
|
+
}
|
|
109
|
+
return { value: values, end: cursor };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function map(bytes: Uint8Array, at: number, count: number, depth: number): CborItem | null {
|
|
113
|
+
if (count > MAX_ITEMS) return null;
|
|
114
|
+
const entries = new Map<number | string, CborValue>();
|
|
115
|
+
let cursor = at;
|
|
116
|
+
for (let index = 0; index < count; index += 1) {
|
|
117
|
+
const key = item(bytes, cursor, depth + 1);
|
|
118
|
+
if (key === null || (typeof key.value !== 'number' && typeof key.value !== 'string')) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// A repeated key is how two parsers disagree about one object: refused, never last-wins.
|
|
122
|
+
if (entries.has(key.value)) return null;
|
|
123
|
+
const entry = item(bytes, key.end, depth + 1);
|
|
124
|
+
if (entry === null) return null;
|
|
125
|
+
entries.set(key.value, entry.value);
|
|
126
|
+
cursor = entry.end;
|
|
127
|
+
}
|
|
128
|
+
return { value: entries, end: cursor };
|
|
129
|
+
}
|
package/src/client-scope.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the HTML is cacheable by a browser and readable by every script on the page.
|
|
5
5
|
|
|
6
6
|
import type { Actor } from '@ultimat3/core';
|
|
7
|
-
import { logger } from '@ultimat3/core';
|
|
7
|
+
import { hmac, logger } from '@ultimat3/core';
|
|
8
8
|
import { AuthError } from './errors';
|
|
9
9
|
|
|
10
10
|
/** 128 bits: a scope only has to differ between principals, never to authenticate anyone. */
|
|
@@ -35,10 +35,7 @@ export function clientScopeOf(actor: Actor, options: ClientScopeOptions = {}): s
|
|
|
35
35
|
actor.onBehalfOf?.actorKind ?? null,
|
|
36
36
|
actor.onBehalfOf?.actorId ?? null,
|
|
37
37
|
]);
|
|
38
|
-
return
|
|
39
|
-
.update(material)
|
|
40
|
-
.digest('hex')
|
|
41
|
-
.slice(0, SCOPE_HEX_CHARS);
|
|
38
|
+
return hmac('sha256', scopeKey(options), material, 'hex').slice(0, SCOPE_HEX_CHARS);
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
function scopeKey(options: ClientScopeOptions): string {
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// `@ultimat3/auth/client` — THE browser entry of this package: the passkey ceremony's browser half
|
|
2
|
+
// and the wire types it speaks, and nothing that drags the server half along. The `.` barrel
|
|
3
|
+
// reaches `@ultimat3/db`, argon2 and the error table; this entry's graph is `passkey-client.ts`
|
|
4
|
+
// and `base64url.ts`, held there by `client-bundle.test.ts`.
|
|
5
|
+
//
|
|
6
|
+
// import { newPasskey, getPasskey } from '@ultimat3/auth/client';
|
|
7
|
+
|
|
8
|
+
export type { PasskeyClientHost, PasskeyOutcome } from './passkey-client';
|
|
9
|
+
export { getPasskey, newPasskey, passkeysSupported } from './passkey-client';
|
|
10
|
+
export type {
|
|
11
|
+
PasskeyAssertionResponse,
|
|
12
|
+
PasskeyCreationOptions,
|
|
13
|
+
PasskeyCredentialDescriptor,
|
|
14
|
+
PasskeyRegistrationResponse,
|
|
15
|
+
PasskeyRequestOptions,
|
|
16
|
+
} from './passkey-wire';
|