@udibo/oauth2 0.42.0 → 0.44.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.
@@ -35,6 +35,10 @@
35
35
  * Identity tenant can test its own routes without running the identity
36
36
  * service.
37
37
  *
38
+ * - **A software passkey authenticator.** {@link TestPasskeyAuthenticator}
39
+ * answers `PasskeyService` options with real signed WebAuthn responses, so
40
+ * passkey registration and sign-in can be tested without a browser.
41
+ *
38
42
  * Nothing in this module is an OAuth2 primitive — only testing support.
39
43
  * Production code should never import from here.
40
44
  *
@@ -43,3 +47,4 @@
43
47
  export { MemoryAuthorizationCodeService, MemoryClientService, MemoryDeviceAuthorizationService, MemoryTokenService, MemoryUserService, } from "./services.js";
44
48
  export { createMemoryAuthorizationServer, } from "./server.js";
45
49
  export { createFakeTenant, } from "./tenant.js";
50
+ export { TestPasskeyAuthenticator, } from "./passkey-authenticator.js";
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A software WebAuthn authenticator for tests: it answers registration and
3
+ * authentication options with real, signed ES256 responses, the way a browser
4
+ * and a platform authenticator would.
5
+ *
6
+ * @module
7
+ */
8
+ import type { PasskeyAuthenticationOptions, PasskeyAuthenticationResponse, PasskeyRegistrationOptions, PasskeyRegistrationResponse } from "../identity/passkeys/service.ts";
9
+ /** Options for {@link TestPasskeyAuthenticator}. */
10
+ export interface TestPasskeyAuthenticatorOptions {
11
+ /** The origin the simulated browser reports, such as `https://login.example.com`. */
12
+ origin: string;
13
+ /** Whether the authenticator verifies the person (the UV flag). Defaults to `true`. */
14
+ userVerified?: boolean;
15
+ /**
16
+ * Whether passkeys are synced (backup-eligible and backed up), like a
17
+ * password manager's. Defaults to `true`. `false` behaves like a security key.
18
+ */
19
+ synced?: boolean;
20
+ /**
21
+ * Whether the signature counter increases on each use, like a security key.
22
+ * Defaults to `false`: synced passkeys report `0`.
23
+ */
24
+ countSignatures?: boolean;
25
+ }
26
+ /** Per-call overrides for {@link TestPasskeyAuthenticator.authenticate}. */
27
+ export interface TestPasskeyAuthenticateOptions {
28
+ /** Use this credential instead of the first one the options allow. */
29
+ credentialId?: string;
30
+ /** Report this origin instead of the authenticator's, as a phishing page would. */
31
+ origin?: string;
32
+ /** Override the UV flag for this assertion. */
33
+ userVerified?: boolean;
34
+ /** Report this sign count instead of the next one. */
35
+ signCount?: number;
36
+ }
37
+ /** A credential the {@link TestPasskeyAuthenticator} holds. */
38
+ export interface TestPasskeyCredential {
39
+ /** The credential id, base64url. */
40
+ credentialId: string;
41
+ /** The RP ID it was created for. */
42
+ rpId: string;
43
+ /** The user handle, base64url. */
44
+ userHandle: string;
45
+ /** The current signature counter. */
46
+ signCount: number;
47
+ }
48
+ /**
49
+ * A software authenticator for tests. {@link register} answers
50
+ * `PasskeyService.startRegistration` options and {@link authenticate} answers
51
+ * `startAuthentication` options with responses `PasskeyService` verifies like
52
+ * a browser's. Keys are P-256 (ES256) and the attestation format is `none`.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { TestPasskeyAuthenticator } from "@udibo/oauth2/testing";
57
+ * import type { PasskeyRegistrationOptions } from "@udibo/oauth2/identity/passkeys";
58
+ *
59
+ * declare const options: PasskeyRegistrationOptions;
60
+ * const authenticator = new TestPasskeyAuthenticator({
61
+ * origin: "https://login.example.com",
62
+ * });
63
+ * const response = await authenticator.register(options);
64
+ * ```
65
+ */
66
+ export declare class TestPasskeyAuthenticator {
67
+ #private;
68
+ /** Creates an authenticator with no credentials. */
69
+ constructor(options: TestPasskeyAuthenticatorOptions);
70
+ /** The credentials created so far. */
71
+ get credentials(): TestPasskeyCredential[];
72
+ /**
73
+ * Create a credential for the options' RP ID and user, and return the
74
+ * response `navigator.credentials.create()` would.
75
+ */
76
+ register(options: PasskeyRegistrationOptions, overrides?: {
77
+ origin?: string;
78
+ }): Promise<PasskeyRegistrationResponse>;
79
+ /**
80
+ * Sign the options' challenge with a held credential and return the
81
+ * response `navigator.credentials.get()` would.
82
+ *
83
+ * @throws {Error} when no held credential matches the options.
84
+ */
85
+ authenticate(options: PasskeyAuthenticationOptions, overrides?: TestPasskeyAuthenticateOptions): Promise<PasskeyAuthenticationResponse>;
86
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * A software WebAuthn authenticator for tests: it answers registration and
3
+ * authentication options with real, signed ES256 responses, the way a browser
4
+ * and a platform authenticator would.
5
+ *
6
+ * @module
7
+ */
8
+ import { base64urlDecode, base64urlEncode } from "../utils/crypto.js";
9
+ /**
10
+ * A software authenticator for tests. {@link register} answers
11
+ * `PasskeyService.startRegistration` options and {@link authenticate} answers
12
+ * `startAuthentication` options with responses `PasskeyService` verifies like
13
+ * a browser's. Keys are P-256 (ES256) and the attestation format is `none`.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { TestPasskeyAuthenticator } from "@udibo/oauth2/testing";
18
+ * import type { PasskeyRegistrationOptions } from "@udibo/oauth2/identity/passkeys";
19
+ *
20
+ * declare const options: PasskeyRegistrationOptions;
21
+ * const authenticator = new TestPasskeyAuthenticator({
22
+ * origin: "https://login.example.com",
23
+ * });
24
+ * const response = await authenticator.register(options);
25
+ * ```
26
+ */
27
+ export class TestPasskeyAuthenticator {
28
+ #origin;
29
+ #userVerified;
30
+ #synced;
31
+ #countSignatures;
32
+ #credentials = [];
33
+ /** Creates an authenticator with no credentials. */
34
+ constructor(options) {
35
+ this.#origin = options.origin;
36
+ this.#userVerified = options.userVerified ?? true;
37
+ this.#synced = options.synced ?? true;
38
+ this.#countSignatures = options.countSignatures ?? false;
39
+ }
40
+ /** The credentials created so far. */
41
+ get credentials() {
42
+ return this.#credentials.map(({ credentialId, rpId, userHandle, signCount }) => ({
43
+ credentialId,
44
+ rpId,
45
+ userHandle,
46
+ signCount,
47
+ }));
48
+ }
49
+ /**
50
+ * Create a credential for the options' RP ID and user, and return the
51
+ * response `navigator.credentials.create()` would.
52
+ */
53
+ async register(options, overrides = {}) {
54
+ const rpId = options.rp.id;
55
+ if (!rpId)
56
+ throw new TypeError("registration options carry no rp.id");
57
+ const keyPair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]));
58
+ const credentialIdBytes = crypto.getRandomValues(new Uint8Array(16));
59
+ const credentialId = base64urlEncode(credentialIdBytes);
60
+ const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
61
+ const coseKey = encodeCbor(new Map([
62
+ [1, 2],
63
+ [3, -7],
64
+ [-1, 1],
65
+ [-2, base64urlDecode(jwk.x ?? "")],
66
+ [-3, base64urlDecode(jwk.y ?? "")],
67
+ ]));
68
+ const signCount = this.#countSignatures ? 1 : 0;
69
+ const attestedCredential = concat(new Uint8Array(16), new Uint8Array([
70
+ credentialIdBytes.length >> 8,
71
+ credentialIdBytes.length & 0xff,
72
+ ]), credentialIdBytes, coseKey);
73
+ const authenticatorData = concat(await this.#authenticatorDataHeader(rpId, signCount, this.#userVerified, true), attestedCredential);
74
+ const clientDataJSON = clientData("webauthn.create", options.challenge, overrides.origin ?? this.#origin);
75
+ const attestationObject = encodeCbor(new Map([
76
+ ["fmt", "none"],
77
+ ["attStmt", new Map()],
78
+ ["authData", authenticatorData],
79
+ ]));
80
+ this.#credentials.push({
81
+ credentialId,
82
+ rpId,
83
+ userHandle: options.user.id,
84
+ signCount,
85
+ privateKey: keyPair.privateKey,
86
+ });
87
+ return {
88
+ id: credentialId,
89
+ rawId: credentialId,
90
+ type: "public-key",
91
+ clientExtensionResults: {},
92
+ authenticatorAttachment: "platform",
93
+ response: {
94
+ clientDataJSON: base64urlEncode(clientDataJSON),
95
+ attestationObject: base64urlEncode(attestationObject),
96
+ transports: ["internal", "hybrid"],
97
+ },
98
+ };
99
+ }
100
+ /**
101
+ * Sign the options' challenge with a held credential and return the
102
+ * response `navigator.credentials.get()` would.
103
+ *
104
+ * @throws {Error} when no held credential matches the options.
105
+ */
106
+ async authenticate(options, overrides = {}) {
107
+ const allowed = new Set((options.allowCredentials ?? []).map((credential) => credential.id));
108
+ const credential = this.#credentials.find((candidate) => candidate.rpId === options.rpId &&
109
+ (overrides.credentialId === undefined
110
+ ? allowed.size === 0 || allowed.has(candidate.credentialId)
111
+ : candidate.credentialId === overrides.credentialId));
112
+ if (!credential)
113
+ throw new Error("no matching test passkey");
114
+ if (this.#countSignatures)
115
+ credential.signCount += 1;
116
+ const authenticatorData = await this.#authenticatorDataHeader(credential.rpId, overrides.signCount ?? credential.signCount, overrides.userVerified ?? this.#userVerified, false);
117
+ const clientDataJSON = clientData("webauthn.get", options.challenge, overrides.origin ?? this.#origin);
118
+ const signed = concat(authenticatorData, new Uint8Array(await crypto.subtle.digest("SHA-256", toBuffer(clientDataJSON))));
119
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, credential.privateKey, toBuffer(signed)));
120
+ return {
121
+ id: credential.credentialId,
122
+ rawId: credential.credentialId,
123
+ type: "public-key",
124
+ clientExtensionResults: {},
125
+ authenticatorAttachment: "platform",
126
+ response: {
127
+ clientDataJSON: base64urlEncode(clientDataJSON),
128
+ authenticatorData: base64urlEncode(authenticatorData),
129
+ signature: base64urlEncode(derSignature(signature)),
130
+ userHandle: credential.userHandle,
131
+ },
132
+ };
133
+ }
134
+ async #authenticatorDataHeader(rpId, signCount, userVerified, attestedCredentialData) {
135
+ const rpIdHash = new Uint8Array(await crypto.subtle.digest("SHA-256", toBuffer(new TextEncoder().encode(rpId))));
136
+ const flags = 0x01 |
137
+ (userVerified ? 0x04 : 0) |
138
+ (this.#synced ? 0x08 | 0x10 : 0) |
139
+ (attestedCredentialData ? 0x40 : 0);
140
+ const counter = new Uint8Array(4);
141
+ new DataView(counter.buffer).setUint32(0, signCount);
142
+ return concat(rpIdHash, new Uint8Array([flags]), counter);
143
+ }
144
+ }
145
+ function clientData(type, challenge, origin) {
146
+ return new TextEncoder().encode(JSON.stringify({ type, challenge, origin, crossOrigin: false }));
147
+ }
148
+ function concat(...parts) {
149
+ const out = new Uint8Array(parts.reduce((size, part) => size + part.length, 0));
150
+ let offset = 0;
151
+ for (const part of parts) {
152
+ out.set(part, offset);
153
+ offset += part.length;
154
+ }
155
+ return out;
156
+ }
157
+ function toBuffer(bytes) {
158
+ return bytes.slice().buffer;
159
+ }
160
+ function derSignature(raw) {
161
+ const integer = (bytes) => {
162
+ let start = 0;
163
+ while (start < bytes.length - 1 && bytes[start] === 0)
164
+ start += 1;
165
+ const trimmed = bytes.slice(start);
166
+ const padded = (trimmed[0] ?? 0) & 0x80 ? concat(new Uint8Array([0]), trimmed) : trimmed;
167
+ return concat(new Uint8Array([0x02, padded.length]), padded);
168
+ };
169
+ const body = concat(integer(raw.slice(0, 32)), integer(raw.slice(32)));
170
+ return concat(new Uint8Array([0x30, body.length]), body);
171
+ }
172
+ function encodeCbor(value) {
173
+ if (typeof value === "number") {
174
+ return value >= 0 ? cborHead(0, value) : cborHead(1, -1 - value);
175
+ }
176
+ if (typeof value === "string") {
177
+ const bytes = new TextEncoder().encode(value);
178
+ return concat(cborHead(3, bytes.length), bytes);
179
+ }
180
+ if (value instanceof Uint8Array) {
181
+ return concat(cborHead(2, value.length), value);
182
+ }
183
+ if (value instanceof Map) {
184
+ const entries = [...value.entries()].flatMap(([key, entry]) => [
185
+ encodeCbor(key),
186
+ encodeCbor(entry),
187
+ ]);
188
+ return concat(cborHead(5, value.size), ...entries);
189
+ }
190
+ throw new TypeError("unsupported CBOR value");
191
+ }
192
+ function cborHead(major, length) {
193
+ const type = major << 5;
194
+ if (length < 24)
195
+ return new Uint8Array([type | length]);
196
+ if (length < 0x100)
197
+ return new Uint8Array([type | 24, length]);
198
+ return new Uint8Array([type | 25, length >> 8, length & 0xff]);
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "keywords": [
6
6
  "authorization-server",
@@ -67,6 +67,10 @@
67
67
  "types": "./dist/identity/mfa/mod.d.ts",
68
68
  "default": "./dist/identity/mfa/mod.js"
69
69
  },
70
+ "./identity/passkeys": {
71
+ "types": "./dist/identity/passkeys/mod.d.ts",
72
+ "default": "./dist/identity/passkeys/mod.js"
73
+ },
70
74
  "./identity/migration": {
71
75
  "types": "./dist/identity/migration.d.ts",
72
76
  "default": "./dist/identity/migration.js"
@@ -152,6 +156,7 @@
152
156
  },
153
157
  "dependencies": {
154
158
  "@hono/node-server": "^2.1.3",
159
+ "@simplewebauthn/server": "^14.0.3",
155
160
  "@udibo/http-error": "^0.12.0"
156
161
  },
157
162
  "devDependencies": {
@@ -162,6 +167,7 @@
162
167
  "@semantic-release/git": "10.0.1",
163
168
  "@semantic-release/github": "12.0.8",
164
169
  "@semantic-release/npm": "13.1.5",
170
+ "@simplewebauthn/browser": "^14.0.0",
165
171
  "@testing-library/dom": "^10.4.2",
166
172
  "@testing-library/react": "^16.3.3",
167
173
  "@types/node": "^26.6.4",