@udibo/oauth2 0.42.0 → 0.43.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/README.md +1 -0
- package/dist/identity/errors.d.ts +2 -2
- package/dist/identity/errors.js +2 -0
- package/dist/identity/events.d.ts +44 -2
- package/dist/identity/events.js +2 -1
- package/dist/identity/passkeys/mod.d.ts +55 -0
- package/dist/identity/passkeys/mod.js +55 -0
- package/dist/identity/passkeys/service.d.ts +469 -0
- package/dist/identity/passkeys/service.js +558 -0
- package/dist/react/components/internal.d.ts +5 -1
- package/dist/react/components/internal.js +9 -0
- package/dist/react/components/mfa-challenge-form.d.ts +9 -3
- package/dist/react/components/mfa-challenge-form.js +3 -3
- package/dist/react/components/mod.d.ts +1 -1
- package/dist/react/components/sign-in-form.d.ts +11 -3
- package/dist/react/components/sign-in-form.js +4 -4
- package/dist/react/components/types.d.ts +12 -0
- package/dist/testing/contract/mod.d.ts +5 -3
- package/dist/testing/contract/mod.js +5 -3
- package/dist/testing/contract/passkey-store.d.ts +66 -0
- package/dist/testing/contract/passkey-store.js +196 -0
- package/dist/testing/mod.d.ts +5 -0
- package/dist/testing/mod.js +5 -0
- package/dist/testing/passkey-authenticator.d.ts +86 -0
- package/dist/testing/passkey-authenticator.js +199 -0
- package/package.json +7 -1
|
@@ -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.
|
|
3
|
+
"version": "0.43.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",
|