@o3co/auth-provider-passkey 0.0.0 → 0.18.0-rc.1
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/LICENSE +201 -0
- package/README.md +2 -3
- package/mfa-webauthn/README.md +24 -0
- package/mfa-webauthn/src/kind.d.ts +12 -0
- package/mfa-webauthn/src/kind.js +105 -0
- package/mfa-webauthn/src/module.d.ts +2 -0
- package/mfa-webauthn/src/module.js +16 -0
- package/mfa-webauthn/src/name.d.ts +5 -0
- package/mfa-webauthn/src/name.js +7 -0
- package/mfa-webauthn/src/section.d.ts +10 -0
- package/mfa-webauthn/src/section.js +10 -0
- package/package.json +57 -3
- package/passkey-registration/README.md +27 -0
- package/passkey-registration/src/module.d.ts +2 -0
- package/passkey-registration/src/module.js +11 -0
- package/passkey-registration/src/routes.d.ts +13 -0
- package/passkey-registration/src/routes.js +84 -0
- package/passkey-registration/src/section.d.ts +6 -0
- package/passkey-registration/src/section.js +17 -0
- package/webauthn/README.md +47 -0
- package/webauthn/config.example +7 -0
- package/webauthn/src/ceremony/README.md +24 -0
- package/webauthn/src/ceremony/ceremony.d.ts +11 -0
- package/webauthn/src/ceremony/ceremony.js +190 -0
- package/webauthn/src/contract.d.ts +90 -0
- package/webauthn/src/contract.js +6 -0
- package/webauthn/src/module.d.ts +2 -0
- package/webauthn/src/module.js +35 -0
- package/webauthn/src/passkeys.d.ts +10 -0
- package/webauthn/src/passkeys.js +49 -0
- package/webauthn/src/records.d.ts +47 -0
- package/webauthn/src/records.js +188 -0
- package/webauthn/src/registration/README.md +19 -0
- package/webauthn/src/registration/registrations.d.ts +14 -0
- package/webauthn/src/registration/registrations.js +31 -0
- package/webauthn/src/section.d.ts +15 -0
- package/webauthn/src/section.js +32 -0
- package/webauthn/src/store.d.ts +55 -0
- package/webauthn/src/store.js +17 -0
- package/webauthn/src/testing.d.ts +147 -0
- package/webauthn/src/testing.js +354 -0
- package/webauthn-login/README.md +39 -0
- package/webauthn-login/src/audit-events.d.ts +7 -0
- package/webauthn-login/src/audit-events.js +7 -0
- package/webauthn-login/src/contract.d.ts +3 -0
- package/webauthn-login/src/contract.js +3 -0
- package/webauthn-login/src/grant.d.ts +14 -0
- package/webauthn-login/src/grant.js +95 -0
- package/webauthn-login/src/module.d.ts +2 -0
- package/webauthn-login/src/module.js +24 -0
- package/webauthn-login/src/options.d.ts +5 -0
- package/webauthn-login/src/options.js +29 -0
- package/webauthn-login/src/section.d.ts +6 -0
- package/webauthn-login/src/section.js +6 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
|
|
2
|
+
import { isUnavailable } from "@o3co/auth-provider-foundation/kernel";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { keptPasskeys } from "./records.js";
|
|
5
|
+
export const testOrigin = "https://rp.example";
|
|
6
|
+
/** The webauthn part of a test's configuration: a relying party at `rp.example`. */
|
|
7
|
+
export function webauthnConfig(settings = {}) {
|
|
8
|
+
return {
|
|
9
|
+
webauthn: { "rp-id": "rp.example", "rp-name": "RP", origins: [testOrigin], ...settings },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** CBOR for the few kinds WebAuthn uses: integers, text, bytes and maps. */
|
|
13
|
+
function cbor(value) {
|
|
14
|
+
const head = (major, n) => n < 24
|
|
15
|
+
? [(major << 5) | n]
|
|
16
|
+
: n < 256
|
|
17
|
+
? [(major << 5) | 24, n]
|
|
18
|
+
: [(major << 5) | 25, n >> 8, n & 255];
|
|
19
|
+
if (typeof value === "number")
|
|
20
|
+
return value >= 0 ? head(0, value) : head(1, -1 - value);
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
const bytes = Buffer.from(value);
|
|
23
|
+
return [...head(3, bytes.length), ...bytes];
|
|
24
|
+
}
|
|
25
|
+
if (value instanceof Uint8Array)
|
|
26
|
+
return [...head(2, value.length), ...value];
|
|
27
|
+
return [...head(5, value.size), ...[...value].flatMap(([k, v]) => [...cbor(k), ...cbor(v)])];
|
|
28
|
+
}
|
|
29
|
+
const b64 = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
30
|
+
/** Authenticator data flags: user present, user verified, backup eligible, backed up, attested data. */
|
|
31
|
+
const [UP, UV, BE, BS, AT] = [1, 4, 8, 16, 64];
|
|
32
|
+
/** A browser's refusal, named as `navigator.credentials` names it. */
|
|
33
|
+
const refused = (name, message) => new DOMException(message, name);
|
|
34
|
+
/** Whether a browser on `host` takes `rpId`: the host, or a domain above it but no single label. */
|
|
35
|
+
const servesHost = (rpId, host) => host === rpId || (host.endsWith(`.${rpId}`) && rpId.includes("."));
|
|
36
|
+
/**
|
|
37
|
+
* A software WebAuthn authenticator behind a browser: it makes ES256 (or ES384) credentials,
|
|
38
|
+
* attests with `none`, asserts with a count that goes up unless it keeps none, and checks the
|
|
39
|
+
* options as a browser does unless a twist says to overlook them.
|
|
40
|
+
*/
|
|
41
|
+
export function softwareAuthenticator(settings = {}) {
|
|
42
|
+
const kept = new Map();
|
|
43
|
+
const alg = settings.algorithm === "ES384" ? -35 : -7;
|
|
44
|
+
const canVerify = settings.userVerified !== false;
|
|
45
|
+
const canDiscover = settings.discoverable !== false;
|
|
46
|
+
const flags = (attested, verified) => UP | (verified ? UV : 0) | (settings.backupEligible ? BE | BS : 0) | (attested ? AT : 0);
|
|
47
|
+
/** Verifies the user unless told it is discouraged, and only when it can; required and unable is refused. */
|
|
48
|
+
const verifies = (asked, twist) => {
|
|
49
|
+
check(twist, asked === "required" && !canVerify, "NotAllowedError", "cannot verify the user");
|
|
50
|
+
return canVerify && asked !== "discouraged";
|
|
51
|
+
};
|
|
52
|
+
const authData = (rp, attested, verified, count, rest = []) => {
|
|
53
|
+
const counter = Buffer.alloc(4);
|
|
54
|
+
counter.writeUInt32BE(count);
|
|
55
|
+
const rpHash = createHash("sha256").update(rp).digest();
|
|
56
|
+
return Buffer.from([...rpHash, flags(attested, verified), ...counter, ...rest]);
|
|
57
|
+
};
|
|
58
|
+
const clientData = (type, challenge, client, twist) => {
|
|
59
|
+
// A browser names a top origin only for a page in a cross-origin frame.
|
|
60
|
+
if (client.topOrigin !== undefined && client.crossOrigin !== true && !twist.overlook) {
|
|
61
|
+
throw new Error("a top origin is reported only in a cross-origin frame");
|
|
62
|
+
}
|
|
63
|
+
const json = JSON.stringify({
|
|
64
|
+
type,
|
|
65
|
+
challenge,
|
|
66
|
+
origin: client.origin,
|
|
67
|
+
crossOrigin: client.crossOrigin ?? false,
|
|
68
|
+
...(client.topOrigin ? { topOrigin: client.topOrigin } : {}),
|
|
69
|
+
});
|
|
70
|
+
return twist.clientDataJSON ? twist.clientDataJSON(json) : b64(json);
|
|
71
|
+
};
|
|
72
|
+
/** The relying party a browser on the client's page takes, or its refusal. */
|
|
73
|
+
const rpFor = (asked, client, twist) => {
|
|
74
|
+
const host = new URL(client.origin).hostname;
|
|
75
|
+
const rpId = asked ?? host;
|
|
76
|
+
if (!twist.overlook && !servesHost(rpId, host)) {
|
|
77
|
+
throw refused("SecurityError", `${rpId} is not a domain of ${host}`);
|
|
78
|
+
}
|
|
79
|
+
return rpId;
|
|
80
|
+
};
|
|
81
|
+
/** Throws the refusal a browser answers where `broken`, unless told to overlook it. */
|
|
82
|
+
const check = (twist, broken, name, message) => {
|
|
83
|
+
if (broken && !twist.overlook)
|
|
84
|
+
throw refused(name, message);
|
|
85
|
+
};
|
|
86
|
+
const create = (options, client, twist = {}) => {
|
|
87
|
+
const rpId = rpFor(options.rp.id, client, twist);
|
|
88
|
+
const clientDataJSON = clientData("webauthn.create", options.challenge, client, twist);
|
|
89
|
+
const handleBytes = Buffer.from(options.user.id, "base64url").length;
|
|
90
|
+
check(twist, handleBytes < 1 || handleBytes > 64, "TypeError", "user.id is 1 to 64 bytes");
|
|
91
|
+
const offered = options.pubKeyCredParams.some((p) => p.type === "public-key" && p.alg === alg);
|
|
92
|
+
check(twist, !offered, "NotSupportedError", "no algorithm it has is offered");
|
|
93
|
+
const excluded = (options.excludeCredentials ?? []).some((c) => kept.get(c.id)?.rpId === rpId);
|
|
94
|
+
check(twist, excluded, "InvalidStateError", "it holds an excluded credential");
|
|
95
|
+
const selection = options.authenticatorSelection ?? {};
|
|
96
|
+
const residentKey = selection.residentKey ?? (selection.requireResidentKey ? "required" : "discouraged");
|
|
97
|
+
check(twist, residentKey === "required" && !canDiscover, "NotAllowedError", "not discoverable");
|
|
98
|
+
const verified = verifies(selection.userVerification, twist);
|
|
99
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", {
|
|
100
|
+
namedCurve: alg === -35 ? "P-384" : "P-256",
|
|
101
|
+
});
|
|
102
|
+
const jwk = publicKey.export({ format: "jwk" });
|
|
103
|
+
const cose = new Map([
|
|
104
|
+
[1, 2],
|
|
105
|
+
[3, alg],
|
|
106
|
+
[-1, alg === -35 ? 2 : 1],
|
|
107
|
+
[-2, Buffer.from(jwk.x ?? "", "base64url")],
|
|
108
|
+
[-3, Buffer.from(jwk.y ?? "", "base64url")],
|
|
109
|
+
]);
|
|
110
|
+
const id = twist.credentialId ? Buffer.from(twist.credentialId, "base64url") : randomBytes(16);
|
|
111
|
+
const attested = [...Buffer.alloc(16), id.length >> 8, id.length & 255, ...id, ...cbor(cose)];
|
|
112
|
+
const data = authData(twist.rpId ?? rpId, true, verified, 0, attested);
|
|
113
|
+
const discoverable = canDiscover && residentKey !== "discouraged";
|
|
114
|
+
kept.set(b64(id), { key: privateKey, handle: options.user.id, rpId, discoverable, count: 0 });
|
|
115
|
+
const attestationObject = cbor(new Map([
|
|
116
|
+
["fmt", "none"],
|
|
117
|
+
["attStmt", new Map()],
|
|
118
|
+
["authData", data],
|
|
119
|
+
]));
|
|
120
|
+
return {
|
|
121
|
+
id: b64(id),
|
|
122
|
+
rawId: b64(id),
|
|
123
|
+
type: "public-key",
|
|
124
|
+
response: {
|
|
125
|
+
clientDataJSON,
|
|
126
|
+
attestationObject: b64(Buffer.from(attestationObject)),
|
|
127
|
+
transports: ["internal"],
|
|
128
|
+
},
|
|
129
|
+
clientExtensionResults: {},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
const get = (options, client, twist = {}, pick) => {
|
|
133
|
+
const rpId = rpFor(options.rpId, client, twist);
|
|
134
|
+
const clientDataJSON = clientData("webauthn.get", options.challenge, client, twist);
|
|
135
|
+
const listed = (options.allowCredentials ?? []).map((c) => c.id);
|
|
136
|
+
// With none listed, a browser offers the discoverable credentials it holds for the party.
|
|
137
|
+
const offered = [...kept].filter(([id, c]) => twist.overlook
|
|
138
|
+
? true
|
|
139
|
+
: c.rpId === rpId && (listed.length ? listed.includes(id) : c.discoverable));
|
|
140
|
+
const chosen = offered.find(([id]) => id === pick) ?? (pick ? undefined : offered.at(-1));
|
|
141
|
+
if (!chosen)
|
|
142
|
+
throw refused("NotAllowedError", "no credential it holds is asked for");
|
|
143
|
+
const verified = verifies(options.userVerification, twist);
|
|
144
|
+
const [id, credential] = chosen;
|
|
145
|
+
if (!settings.countless)
|
|
146
|
+
credential.count += 1;
|
|
147
|
+
const count = twist.counter ?? credential.count;
|
|
148
|
+
const data = authData(twist.rpId ?? credential.rpId, false, verified, count);
|
|
149
|
+
const signed = Buffer.concat([
|
|
150
|
+
data,
|
|
151
|
+
createHash("sha256").update(Buffer.from(clientDataJSON, "base64url")).digest(),
|
|
152
|
+
]);
|
|
153
|
+
// An authenticator answers the handle only of a credential it keeps discoverable.
|
|
154
|
+
const own = credential.discoverable ? credential.handle : null;
|
|
155
|
+
const handle = twist.userHandle === undefined ? own : twist.userHandle;
|
|
156
|
+
return {
|
|
157
|
+
id,
|
|
158
|
+
rawId: id,
|
|
159
|
+
type: "public-key",
|
|
160
|
+
response: {
|
|
161
|
+
clientDataJSON,
|
|
162
|
+
authenticatorData: b64(data),
|
|
163
|
+
signature: b64(sign(alg === -35 ? "sha384" : "sha256", signed, credential.key)),
|
|
164
|
+
...(handle === null ? {} : { userHandle: handle }),
|
|
165
|
+
},
|
|
166
|
+
clientExtensionResults: {},
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
/** Answers creation options as `navigator.credentials.create` does. */
|
|
171
|
+
create,
|
|
172
|
+
/** Answers request options as `navigator.credentials.get` does, with `pick` if the user picks one. */
|
|
173
|
+
get,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const handleOf = () => randomBytes(32).toString("base64url");
|
|
177
|
+
/** What webauthn needs from any implementation: records kept whole and apart, without end, written by webauthn in one shape. */
|
|
178
|
+
export function passkeyStoreContract(name, open) {
|
|
179
|
+
let n = 0;
|
|
180
|
+
const fresh = () => `k-${++n}-${randomBytes(6).toString("hex")}`;
|
|
181
|
+
const held = (value) => value;
|
|
182
|
+
const counted = (record) => record.n;
|
|
183
|
+
const plus = (by) => (record) => ({
|
|
184
|
+
value: held({ n: counted(record) + by }),
|
|
185
|
+
});
|
|
186
|
+
describe(`webauthn's passkey store interface: ${name}`, () => {
|
|
187
|
+
it("keeps a record whole and once per key in each space, the two apart", async () => {
|
|
188
|
+
const { passkeys } = await open();
|
|
189
|
+
const key = fresh();
|
|
190
|
+
const odd = held({ state: "unheard-of", nested: { list: [1, "two"] } });
|
|
191
|
+
expect(await passkeys.credentials.save(key, odd)).toBe("created");
|
|
192
|
+
expect(await passkeys.credentials.save(key, held({ n: 1 }))).toBe("exists");
|
|
193
|
+
expect(await passkeys.subjects.read(key)).toBeUndefined();
|
|
194
|
+
expect(await passkeys.subjects.save(key, held({ n: 1 }))).toBe("created");
|
|
195
|
+
expect(await passkeys.credentials.read(key)).toEqual(odd);
|
|
196
|
+
expect(await passkeys.subjects.update(key, plus(1))).toEqual({
|
|
197
|
+
before: held({ n: 1 }),
|
|
198
|
+
after: held({ n: 2 }),
|
|
199
|
+
});
|
|
200
|
+
expect(await passkeys.credentials.update(fresh(), plus(1))).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
it("changes a record only from what it holds, again after a racing writer", async () => {
|
|
203
|
+
const place = await open();
|
|
204
|
+
const key = fresh();
|
|
205
|
+
await place.passkeys.subjects.save(key, held({ n: 1 }));
|
|
206
|
+
place.raceNextUpdate(key, (record) => held({ n: counted(record) * 10 }));
|
|
207
|
+
expect((await place.passkeys.subjects.update(key, plus(1)))?.after).toEqual(held({ n: 11 }));
|
|
208
|
+
});
|
|
209
|
+
it("keeps records without end, through a restart and through memory pressure", async () => {
|
|
210
|
+
const place = await open();
|
|
211
|
+
const key = fresh();
|
|
212
|
+
await place.passkeys.credentials.save(key, held({ n: 1 }));
|
|
213
|
+
place.advance(10 * 365 * 86_400_000);
|
|
214
|
+
await place.restart?.();
|
|
215
|
+
await place.pressMemory?.();
|
|
216
|
+
expect(await place.passkeys.credentials.read(key)).toEqual(held({ n: 1 }));
|
|
217
|
+
});
|
|
218
|
+
it("settles writes whose answers were lost, and keeps them through a failover", async (context) => {
|
|
219
|
+
const place = await open();
|
|
220
|
+
const { loseNextAnswer: lose, failover } = place;
|
|
221
|
+
if (!lose || !failover)
|
|
222
|
+
return context.skip();
|
|
223
|
+
const key = fresh();
|
|
224
|
+
lose();
|
|
225
|
+
expect(await place.passkeys.subjects.save(key, held({ n: 1 }))).toBe("created");
|
|
226
|
+
lose();
|
|
227
|
+
expect((await place.passkeys.subjects.update(key, plus(1)))?.after).toEqual(held({ n: 2 }));
|
|
228
|
+
await failover();
|
|
229
|
+
expect(await place.passkeys.subjects.read(key)).toEqual(held({ n: 2 }));
|
|
230
|
+
});
|
|
231
|
+
// The writer gives up once the clock passes its patience, so only the read could have kept it.
|
|
232
|
+
it("reads a record another request wrote only as a failover keeps it", async (context) => {
|
|
233
|
+
const place = await open();
|
|
234
|
+
const { duringNextSave, failover } = place;
|
|
235
|
+
if (!duringNextSave || !failover)
|
|
236
|
+
return context.skip();
|
|
237
|
+
const key = fresh();
|
|
238
|
+
let seen;
|
|
239
|
+
duringNextSave(async () => {
|
|
240
|
+
seen = await place.passkeys.credentials.read(key);
|
|
241
|
+
await failover();
|
|
242
|
+
place.advance(60_000);
|
|
243
|
+
});
|
|
244
|
+
await place.passkeys.credentials.save(key, held({ n: 1 })).catch(() => undefined);
|
|
245
|
+
expect(await place.passkeys.credentials.read(key)).toEqual(seen);
|
|
246
|
+
});
|
|
247
|
+
it("is never given a handle or a credential id webauthn would not write", async () => {
|
|
248
|
+
const { passkeys } = await open();
|
|
249
|
+
const records = keptPasskeys(passkeys);
|
|
250
|
+
const subject = fresh();
|
|
251
|
+
const padded = `${handleOf()}=`;
|
|
252
|
+
await expect(records.handleFor(subject, padded)).rejects.toThrow("a handle must be canonical");
|
|
253
|
+
expect(await records.holder(subject)).toBeUndefined();
|
|
254
|
+
const handle = await records.handleFor(subject, handleOf());
|
|
255
|
+
const credential = {
|
|
256
|
+
id: "AAAA",
|
|
257
|
+
publicKey: "pQ",
|
|
258
|
+
signCount: 0,
|
|
259
|
+
userHandle: handle,
|
|
260
|
+
backupEligible: false,
|
|
261
|
+
backedUp: false,
|
|
262
|
+
};
|
|
263
|
+
for (const [name, twisted] of Object.entries({
|
|
264
|
+
"id not canonical": { ...credential, id: "AAB" },
|
|
265
|
+
"id padded": { ...credential, id: "AAAA==" },
|
|
266
|
+
"handle padded": { ...credential, userHandle: `${handle}=` },
|
|
267
|
+
})) {
|
|
268
|
+
expect(await records.register(subject, twisted, 10), name).toBe("malformed");
|
|
269
|
+
}
|
|
270
|
+
expect(await records.passkey("AAB")).toBeUndefined();
|
|
271
|
+
expect((await records.holder(subject))?.credentials).toEqual([]);
|
|
272
|
+
expect(await records.register(subject, credential, 10)).toBe("registered");
|
|
273
|
+
expect((await records.passkey("AAAA"))?.subject).toBe(subject);
|
|
274
|
+
});
|
|
275
|
+
it("answers unavailable for a record not in the shape webauthn writes", async () => {
|
|
276
|
+
const { passkeys } = await open();
|
|
277
|
+
const keys = [];
|
|
278
|
+
const spied = (records) => ({
|
|
279
|
+
...records,
|
|
280
|
+
save: (key, record) => {
|
|
281
|
+
keys.push(key);
|
|
282
|
+
return records.save(key, record);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const [subject, id] = [fresh(), randomBytes(16).toString("base64url")];
|
|
286
|
+
const watched = {
|
|
287
|
+
credentials: spied(passkeys.credentials),
|
|
288
|
+
subjects: spied(passkeys.subjects),
|
|
289
|
+
};
|
|
290
|
+
const records = keptPasskeys(watched);
|
|
291
|
+
const userHandle = await records.handleFor(subject, handleOf());
|
|
292
|
+
const kept = {
|
|
293
|
+
id,
|
|
294
|
+
publicKey: "pQ",
|
|
295
|
+
signCount: 0,
|
|
296
|
+
userHandle,
|
|
297
|
+
backupEligible: false,
|
|
298
|
+
backedUp: false,
|
|
299
|
+
};
|
|
300
|
+
expect(await records.register(subject, kept, 10)).toBe("registered");
|
|
301
|
+
const [holderKey = "", credentialKey = ""] = keys;
|
|
302
|
+
const twist = (record) => ({ value: held({ ...record, v: 2 }) });
|
|
303
|
+
await passkeys.subjects.update(holderKey, twist);
|
|
304
|
+
await passkeys.credentials.update(credentialKey, twist);
|
|
305
|
+
for (const read of [() => records.holder(subject), () => records.passkey(id)]) {
|
|
306
|
+
await expect(read()).rejects.toSatisfy(isUnavailable);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/** What webauthn needs from any implementation: a challenge kept once for its lifetime, taken once, never in clear. */
|
|
312
|
+
export function challengeStoreContract(name, open) {
|
|
313
|
+
const fresh = () => randomBytes(32).toString("base64url");
|
|
314
|
+
const held = (value) => value;
|
|
315
|
+
const mark = held({ taken: true });
|
|
316
|
+
describe(`webauthn's challenge store interface: ${name}`, () => {
|
|
317
|
+
it("keeps a challenge once, and puts the mark in its place for the rest of its lifetime", async () => {
|
|
318
|
+
const place = await open();
|
|
319
|
+
const challenge = fresh();
|
|
320
|
+
const record = held({ kind: "assertion", nested: [1, "two"] });
|
|
321
|
+
expect(await place.challenges.open(challenge, record, 1000)).toBe("created");
|
|
322
|
+
expect(await place.challenges.open(challenge, held({ n: 1 }), 1000)).toBe("exists");
|
|
323
|
+
place.advance(500);
|
|
324
|
+
expect(await place.challenges.take(challenge, mark)).toEqual({ before: record, after: mark });
|
|
325
|
+
expect(await place.challenges.take(challenge, mark)).toEqual({ before: mark, after: mark });
|
|
326
|
+
place.advance(500);
|
|
327
|
+
expect(await place.challenges.take(challenge, mark)).toBeUndefined();
|
|
328
|
+
expect(await place.challenges.take(fresh(), mark)).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
it("never gives a challenge to what the place holds, in clear", async (context) => {
|
|
331
|
+
const place = await open();
|
|
332
|
+
const { holds } = place;
|
|
333
|
+
if (!holds)
|
|
334
|
+
return context.skip();
|
|
335
|
+
const [challenge, kept] = [fresh(), fresh()];
|
|
336
|
+
await place.challenges.open(challenge, held({ kept }), 60_000);
|
|
337
|
+
await place.challenges.take(challenge, mark);
|
|
338
|
+
expect([holds(kept), holds(challenge)]).toEqual([true, false]);
|
|
339
|
+
});
|
|
340
|
+
it("keeps a taken mark through a restart, a lost answer and a failover", async (context) => {
|
|
341
|
+
const place = await open();
|
|
342
|
+
const { restart, loseNextAnswer: lose, failover } = place;
|
|
343
|
+
if (!restart || !lose || !failover)
|
|
344
|
+
return context.skip();
|
|
345
|
+
const challenge = fresh();
|
|
346
|
+
await place.challenges.open(challenge, held({ n: 1 }), 60_000);
|
|
347
|
+
lose();
|
|
348
|
+
expect((await place.challenges.take(challenge, mark))?.before).toEqual(held({ n: 1 }));
|
|
349
|
+
await restart();
|
|
350
|
+
await failover();
|
|
351
|
+
expect((await place.challenges.take(challenge, mark))?.before).toEqual(mark);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# webauthn-login
|
|
2
|
+
|
|
3
|
+
## Responsibilities
|
|
4
|
+
- Contributes the passkey login grant `urn:o3co:oauth:grant-type:webauthn` to `grants`' grant extension point.
|
|
5
|
+
- Answers request options for a passkey login: the same for everyone, the challenge made for the client that asks.
|
|
6
|
+
- Declares the amr this login gives: `hwk`, which means only possession of the key and includes no user verification.
|
|
7
|
+
|
|
8
|
+
## Not here
|
|
9
|
+
- Checking a ceremony, and keeping credentials and challenges: `webauthn`.
|
|
10
|
+
- Client authentication, the body a grant takes, and whether it is allowed: `token-endpoint`.
|
|
11
|
+
- Refresh families: `refresh`. The token format: `tokens`.
|
|
12
|
+
- A browser login: there is none, so no session opens.
|
|
13
|
+
|
|
14
|
+
## Offers
|
|
15
|
+
- Composition definition, and `passkeyAmr` (`["hwk"]`).
|
|
16
|
+
- The grant takes a JSON body only: `assertion` (the authenticator's response, an object) and `scope`.
|
|
17
|
+
- Route `POST /oauth/webauthn/authentication/options`: takes JSON, a form or no body, and reads only `client_id`.
|
|
18
|
+
- It answers `passkeys`' options with `no-store`, their challenge made for that client.
|
|
19
|
+
- A missing `client_id`, or one that is not one non-empty string, is `400 invalid_request`.
|
|
20
|
+
- Audit event `passkey.login.refused`, with `client` and `reason`: a kind from a fixed list, one per refusal of `passkeys`, and `authentication_revoked`.
|
|
21
|
+
- Operator section: `requests` (1–100000, default 60) in `window-seconds` (1–86400, default 60), for the options route.
|
|
22
|
+
|
|
23
|
+
## Invariants
|
|
24
|
+
- Order: the assertion's shape, then `passkeys` (user handle, the challenge once and its client, signature, count), then the scope, then the subject's revocation boundary, then the access token, then the refresh token.
|
|
25
|
+
- A missing assertion is `invalid_request`; one that is not an object is `invalid_grant`.
|
|
26
|
+
- Every refusal of `passkeys`, and the revocation boundary, is the same `invalid_grant`, so no answer tells whether a credential is registered.
|
|
27
|
+
- A challenge made for another client is one of those refusals.
|
|
28
|
+
- The audit event holds no credential id and nothing else the request sent.
|
|
29
|
+
- The subject is the passkey's holder.
|
|
30
|
+
- The authentication time is when the challenge was made, and is what the revocation boundary is compared with.
|
|
31
|
+
- Scope and audience are the client's allowance (`client-allowance`); beyond it, `invalid_scope`.
|
|
32
|
+
- The access token is the client's, bound to the request's binding, with amr `hwk` and the authentication time, issued through `tokens`' issuance policies without a session; a refusal starts no family. The policies are first asked once `passkeys` has taken the challenge, since only then is the holder known.
|
|
33
|
+
- The amr is `hwk` whatever the passkey's backup eligibility; the second factor's rule (`swk` when backup eligible) does not apply to a login.
|
|
34
|
+
- The refresh token starts a family bound to no session, only for a client allowed `refresh_token`, after the access token; it carries the same amr and time.
|
|
35
|
+
- The options route lists no credential and reads no store but the challenge's.
|
|
36
|
+
- A store that cannot answer is `503`, never a refusal.
|
|
37
|
+
|
|
38
|
+
## Depends on
|
|
39
|
+
- `kernel`, `http`, `grants`, `client-allowance`, `webauthn`, `refresh`, `subject-revocation`, `issuer`, `rate-limit`, `audit`
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { auditEvents } from "@o3co/auth-provider-foundation/audit";
|
|
2
|
+
export default auditEvents({
|
|
3
|
+
"passkey.login.refused": {
|
|
4
|
+
attributes: ["client", "reason"],
|
|
5
|
+
when: "a passkey assertion is not accepted, with why (`reason`, a kind from a fixed list)",
|
|
6
|
+
},
|
|
7
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Audit } from "@o3co/auth-provider-foundation/audit";
|
|
2
|
+
import type { Grant } from "@o3co/auth-provider-oauth/grants";
|
|
3
|
+
import type { Lineage } from "@o3co/auth-provider-oauth/refresh";
|
|
4
|
+
import type { SubjectRevocation } from "@o3co/auth-provider-identity/subject-revocation";
|
|
5
|
+
import type { Passkeys } from "@o3co/auth-provider-passkey/webauthn";
|
|
6
|
+
export interface LoginParts {
|
|
7
|
+
readonly passkeys: Passkeys;
|
|
8
|
+
readonly lineage: Lineage;
|
|
9
|
+
readonly revocation: SubjectRevocation;
|
|
10
|
+
readonly audit: Pick<Audit, "record">;
|
|
11
|
+
readonly issuer: string;
|
|
12
|
+
}
|
|
13
|
+
/** The passkey login grant: an assertion of a kept passkey for the client's tokens. */
|
|
14
|
+
export declare function passkeyGrant(p: LoginParts): Grant;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { declaredAudit } from "@o3co/auth-provider-foundation/audit";
|
|
2
|
+
import { clientAllowance } from "@o3co/auth-provider-oauth/client-allowance";
|
|
3
|
+
import events from "./audit-events.js";
|
|
4
|
+
import { passkeyAmr, webauthnGrantType } from "./contract.js";
|
|
5
|
+
/** Each refusal of `passkeys`, as the audit records it. */
|
|
6
|
+
const reasons = {
|
|
7
|
+
malformed: "assertion_malformed",
|
|
8
|
+
challenge: "challenge_invalid",
|
|
9
|
+
origin: "origin_not_allowed",
|
|
10
|
+
"top-origin": "top_origin_not_allowed",
|
|
11
|
+
rp: "rp_mismatch",
|
|
12
|
+
algorithm: "algorithm_not_allowed",
|
|
13
|
+
signature: "signature_invalid",
|
|
14
|
+
"sign-count": "sign_count_not_increased",
|
|
15
|
+
"user-handle": "user_handle_mismatch",
|
|
16
|
+
"user-verification": "user_verification_missing",
|
|
17
|
+
"backup-eligibility": "backup_eligibility_changed",
|
|
18
|
+
"unknown-credential": "credential_unknown",
|
|
19
|
+
"no-user-handle": "user_handle_missing",
|
|
20
|
+
raced: "sign_count_raced",
|
|
21
|
+
};
|
|
22
|
+
const invalidGrant = (description) => ({
|
|
23
|
+
kind: "error",
|
|
24
|
+
error: "invalid_grant",
|
|
25
|
+
description,
|
|
26
|
+
});
|
|
27
|
+
/** The passkey login grant: an assertion of a kept passkey for the client's tokens. */
|
|
28
|
+
export function passkeyGrant(p) {
|
|
29
|
+
return {
|
|
30
|
+
grantType: webauthnGrantType,
|
|
31
|
+
bodies: ["json"],
|
|
32
|
+
handle: async ({ client, param, value, binding, issue }) => {
|
|
33
|
+
// One answer for every refusal, so it never tells whether a credential is registered.
|
|
34
|
+
const notAccepted = (reason) => {
|
|
35
|
+
declaredAudit(p.audit, events).record("passkey.login.refused", {
|
|
36
|
+
attributes: { client: client.id, reason },
|
|
37
|
+
});
|
|
38
|
+
return invalidGrant("passkey assertion not accepted");
|
|
39
|
+
};
|
|
40
|
+
const assertion = value("assertion");
|
|
41
|
+
if (assertion === undefined) {
|
|
42
|
+
return { kind: "error", error: "invalid_request", description: "assertion is required" };
|
|
43
|
+
}
|
|
44
|
+
if (typeof assertion !== "object" || assertion === null || Array.isArray(assertion)) {
|
|
45
|
+
return invalidGrant("assertion must be an object");
|
|
46
|
+
}
|
|
47
|
+
const asserted = await p.passkeys.assert(assertion, client.id);
|
|
48
|
+
if (asserted.kind === "refused")
|
|
49
|
+
return notAccepted(reasons[asserted.reason]);
|
|
50
|
+
// The authentication time is when the challenge was made.
|
|
51
|
+
const { subject, challengedAt: authenticatedAt } = asserted;
|
|
52
|
+
const allowed = clientAllowance.allowedIssuance(client, param("scope"), p.issuer);
|
|
53
|
+
if (allowed.kind === "error")
|
|
54
|
+
return allowed;
|
|
55
|
+
const { scope, audience } = allowed;
|
|
56
|
+
if ((await p.revocation.check(subject, authenticatedAt)) === "revoked") {
|
|
57
|
+
return notAccepted("authentication_revoked");
|
|
58
|
+
}
|
|
59
|
+
const amr = passkeyAmr;
|
|
60
|
+
// The holder is known only once `passkeys` has taken the challenge, so `issue` alone asks.
|
|
61
|
+
// issue binds the access token itself; the family is handed the binding here.
|
|
62
|
+
const issued = await issue({
|
|
63
|
+
subject,
|
|
64
|
+
issuedTo: client.id,
|
|
65
|
+
audience,
|
|
66
|
+
scope,
|
|
67
|
+
amr,
|
|
68
|
+
authenticatedAt,
|
|
69
|
+
});
|
|
70
|
+
if (issued.kind === "refused")
|
|
71
|
+
return issued;
|
|
72
|
+
const { access } = issued;
|
|
73
|
+
const refreshToken = await p.lineage.start({
|
|
74
|
+
client,
|
|
75
|
+
subject,
|
|
76
|
+
scope,
|
|
77
|
+
audience,
|
|
78
|
+
authenticatedAt,
|
|
79
|
+
amr,
|
|
80
|
+
...(binding ? { binding } : {}),
|
|
81
|
+
access: { id: access.id, expiresAt: access.expiresAt },
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
kind: "issued",
|
|
85
|
+
body: {
|
|
86
|
+
access_token: access.token,
|
|
87
|
+
token_type: access.tokenType,
|
|
88
|
+
expires_in: access.expiresIn,
|
|
89
|
+
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
|
90
|
+
...(scope.length > 0 ? { scope: scope.join(" ") } : {}),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { audit } from "@o3co/auth-provider-foundation/audit";
|
|
2
|
+
import { grants } from "@o3co/auth-provider-oauth/grants";
|
|
3
|
+
import { routes } from "@o3co/auth-provider-foundation/http";
|
|
4
|
+
import { issuer } from "@o3co/auth-provider-oauth/issuer";
|
|
5
|
+
import { defineModule } from "@o3co/auth-provider-foundation/kernel";
|
|
6
|
+
import { lineage } from "@o3co/auth-provider-oauth/refresh";
|
|
7
|
+
import { subjectRevocation } from "@o3co/auth-provider-identity/subject-revocation";
|
|
8
|
+
import { passkeys } from "@o3co/auth-provider-passkey/webauthn";
|
|
9
|
+
import { passkeyGrant } from "./grant.js";
|
|
10
|
+
import { optionsRoute } from "./options.js";
|
|
11
|
+
import { section } from "./section.js";
|
|
12
|
+
export default defineModule({ name: "webauthn-login", section, replicaSafe: true }, (m) => [
|
|
13
|
+
m.contribute(grants).by(async (c) => passkeyGrant({
|
|
14
|
+
passkeys: await c.get(passkeys),
|
|
15
|
+
lineage: await c.get(lineage),
|
|
16
|
+
revocation: await c.get(subjectRevocation),
|
|
17
|
+
audit: await c.get(audit),
|
|
18
|
+
issuer: (await c.get(issuer)).url,
|
|
19
|
+
})),
|
|
20
|
+
m.contribute(routes).by(async (c) => optionsRoute(await c.get(passkeys), {
|
|
21
|
+
limit: c.section.requests,
|
|
22
|
+
ms: c.section["window-seconds"] * 1000,
|
|
23
|
+
})),
|
|
24
|
+
]);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Route } from "@o3co/auth-provider-foundation/http";
|
|
2
|
+
import { type Window } from "@o3co/auth-provider-foundation/rate-limit";
|
|
3
|
+
import type { Passkeys } from "@o3co/auth-provider-passkey/webauthn";
|
|
4
|
+
/** Request options the same for everyone, whose challenge only the client named redeems. */
|
|
5
|
+
export declare function optionsRoute(passkeys: Passkeys, window: Window): Route;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { noStore, use } from "@o3co/auth-provider-foundation/http";
|
|
2
|
+
import { requestsPerSource } from "@o3co/auth-provider-foundation/rate-limit";
|
|
3
|
+
/** The client the options are for; false unless named as one non-empty string. */
|
|
4
|
+
function requesterOf({ fields, mistyped }) {
|
|
5
|
+
if (mistyped?.has("client_id") || fields.repeated().includes("client_id"))
|
|
6
|
+
return false;
|
|
7
|
+
const id = fields.single("client_id");
|
|
8
|
+
return id === undefined || id === "" ? false : id;
|
|
9
|
+
}
|
|
10
|
+
/** Request options the same for everyone, whose challenge only the client named redeems. */
|
|
11
|
+
export function optionsRoute(passkeys, window) {
|
|
12
|
+
return {
|
|
13
|
+
method: "POST",
|
|
14
|
+
path: "/oauth/webauthn/authentication/options",
|
|
15
|
+
uses: [use(requestsPerSource, { name: "webauthn-login", window })],
|
|
16
|
+
takes: ["json", "form", "none"],
|
|
17
|
+
handle: async (request) => {
|
|
18
|
+
const requester = requesterOf(request);
|
|
19
|
+
if (requester === false) {
|
|
20
|
+
return { status: 400, headers: noStore, body: { json: { error: "invalid_request" } } };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: noStore,
|
|
25
|
+
body: { json: (await passkeys.challenge(requester)).options },
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const section = z.strictObject({
|
|
3
|
+
/** Requests allowed per source in each window, on the options route. */
|
|
4
|
+
requests: z.number().int().min(1).max(100_000).default(60),
|
|
5
|
+
"window-seconds": z.number().int().min(1).max(86_400).default(60),
|
|
6
|
+
});
|