@udibo/oauth2 0.41.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.
Files changed (47) hide show
  1. package/README.md +1 -0
  2. package/dist/errors.d.ts +5 -0
  3. package/dist/errors.js +9 -0
  4. package/dist/identity/errors.d.ts +2 -2
  5. package/dist/identity/errors.js +2 -0
  6. package/dist/identity/events.d.ts +44 -2
  7. package/dist/identity/events.js +2 -1
  8. package/dist/identity/passkeys/mod.d.ts +55 -0
  9. package/dist/identity/passkeys/mod.js +55 -0
  10. package/dist/identity/passkeys/service.d.ts +469 -0
  11. package/dist/identity/passkeys/service.js +558 -0
  12. package/dist/models/token.d.ts +7 -0
  13. package/dist/react/components/internal.d.ts +5 -1
  14. package/dist/react/components/internal.js +9 -0
  15. package/dist/react/components/mfa-challenge-form.d.ts +9 -3
  16. package/dist/react/components/mfa-challenge-form.js +3 -3
  17. package/dist/react/components/mod.d.ts +1 -1
  18. package/dist/react/components/sign-in-form.d.ts +11 -3
  19. package/dist/react/components/sign-in-form.js +4 -4
  20. package/dist/react/components/types.d.ts +12 -0
  21. package/dist/server/_resource-indicator.d.ts +7 -0
  22. package/dist/server/_resource-indicator.js +67 -0
  23. package/dist/server/authorization-server.js +2 -0
  24. package/dist/server/grants/client-credentials.d.ts +6 -0
  25. package/dist/server/grants/client-credentials.js +22 -3
  26. package/dist/server/grants/grant.d.ts +4 -2
  27. package/dist/server/grants/grant.js +17 -3
  28. package/dist/server/grants/refresh-token.d.ts +2 -0
  29. package/dist/server/grants/refresh-token.js +8 -1
  30. package/dist/server/mod.d.ts +1 -1
  31. package/dist/server/mod.js +1 -1
  32. package/dist/server/services/token.d.ts +20 -2
  33. package/dist/server/services/token.js +1 -1
  34. package/dist/server/signing-keys.d.ts +3 -1
  35. package/dist/server/signing-keys.js +2 -2
  36. package/dist/testing/contract/mod.d.ts +5 -3
  37. package/dist/testing/contract/mod.js +5 -3
  38. package/dist/testing/contract/passkey-store.d.ts +66 -0
  39. package/dist/testing/contract/passkey-store.js +196 -0
  40. package/dist/testing/contract/token.d.ts +7 -0
  41. package/dist/testing/contract/token.js +17 -0
  42. package/dist/testing/mod.d.ts +5 -0
  43. package/dist/testing/mod.js +5 -0
  44. package/dist/testing/passkey-authenticator.d.ts +86 -0
  45. package/dist/testing/passkey-authenticator.js +199 -0
  46. package/dist/testing/services.js +4 -0
  47. package/package.json +7 -1
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Contract test suites for {@link PasskeyStore} and
3
+ * {@link PasskeyChallengeStore} implementations.
4
+ *
5
+ * `PasskeyService` relies on three atomic operations: `addPasskey` (credential
6
+ * uniqueness and the per-user cap in one step), `recordUse` (compare-and-set
7
+ * on the sign count), and the challenge store's `take` (read-and-delete, which
8
+ * makes a challenge single-use). These suites check each with concurrent
9
+ * calls.
10
+ *
11
+ * @example Verify database-backed stores
12
+ * ```ts
13
+ * import {
14
+ * runPasskeyChallengeStoreContractTests,
15
+ * runPasskeyStoreContractTests,
16
+ * } from "@udibo/oauth2/testing/contract";
17
+ * import type {
18
+ * PasskeyChallengeStore,
19
+ * PasskeyStore,
20
+ * } from "@udibo/oauth2/identity/passkeys";
21
+ *
22
+ * declare function freshPasskeyStore(): Promise<PasskeyStore>;
23
+ * declare function freshChallengeStore(): Promise<PasskeyChallengeStore>;
24
+ *
25
+ * runPasskeyStoreContractTests({ makeStore: freshPasskeyStore });
26
+ * runPasskeyChallengeStoreContractTests({ makeStore: freshChallengeStore });
27
+ * ```
28
+ *
29
+ * @module
30
+ */
31
+ import { beforeEach, describe, expect, it } from "vitest";
32
+ import { CONCURRENT_CALLERS, race } from "./_race.js";
33
+ function passkey(userId, credentialId, rpId = "login.example.com") {
34
+ return {
35
+ credentialId,
36
+ userId,
37
+ rpId,
38
+ publicKey: new Uint8Array([1, 2, 3, 4]),
39
+ signCount: 0,
40
+ transports: ["internal", "hybrid"],
41
+ aaguid: "00000000-0000-0000-0000-000000000000",
42
+ multiDevice: true,
43
+ backedUp: true,
44
+ name: "Laptop",
45
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
46
+ lastUsedAt: null,
47
+ };
48
+ }
49
+ /**
50
+ * Runs the {@link PasskeyStore} contract suite. Call it from your own test
51
+ * file; it registers `describe` / `it` blocks.
52
+ */
53
+ export function runPasskeyStoreContractTests(options) {
54
+ const USER = options.userId ?? "user-1";
55
+ const OTHER_USER = options.otherUserId ?? "user-2";
56
+ describe(options.describeName ?? "PasskeyStore contract", () => {
57
+ let store;
58
+ beforeEach(async () => {
59
+ store = await options.makeStore();
60
+ });
61
+ it("stores and returns a passkey with its fields intact", async () => {
62
+ expect(await store.addPasskey(passkey(USER, "cred-a"), 25)).toBe("added");
63
+ const found = await store.findPasskey("cred-a");
64
+ expect(found).toStrictEqual(passkey(USER, "cred-a"));
65
+ });
66
+ it("lists a user's passkeys oldest first, filtered by RP ID", async () => {
67
+ await store.addPasskey({
68
+ ...passkey(USER, "cred-a"),
69
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
70
+ }, 25);
71
+ await store.addPasskey({
72
+ ...passkey(USER, "cred-b", "other.example.com"),
73
+ createdAt: new Date("2026-01-02T00:00:00.000Z"),
74
+ }, 25);
75
+ await store.addPasskey(passkey(OTHER_USER, "cred-c"), 25);
76
+ const all = await store.listPasskeys(USER);
77
+ expect(all.map((stored) => stored.credentialId)).toStrictEqual([
78
+ "cred-a",
79
+ "cred-b",
80
+ ]);
81
+ const scoped = await store.listPasskeys(USER, "login.example.com");
82
+ expect(scoped.map((stored) => stored.credentialId)).toStrictEqual([
83
+ "cred-a",
84
+ ]);
85
+ });
86
+ it("refuses a credential id that is already registered", async () => {
87
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
88
+ expect(await store.addPasskey(passkey(OTHER_USER, "cred-a"), 25)).toBe("duplicate");
89
+ expect((await store.findPasskey("cred-a"))?.userId).toBe(USER);
90
+ });
91
+ it("treats a credential id as unique across relying parties", async () => {
92
+ await store.addPasskey(passkey(USER, "cred-a", "login.example.com"), 25);
93
+ expect(await store.addPasskey(passkey(OTHER_USER, "cred-a", "other.example.com"), 25)).toBe("duplicate");
94
+ expect((await store.findPasskey("cred-a"))?.rpId).toBe("login.example.com");
95
+ });
96
+ it("refuses to record a use of a removed passkey", async () => {
97
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
98
+ await store.removePasskey(USER, "cred-a");
99
+ expect(await store.recordUse("cred-a", {
100
+ previousSignCount: 0,
101
+ signCount: 1,
102
+ backedUp: true,
103
+ usedAt: new Date(),
104
+ })).toBe(false);
105
+ });
106
+ it("refuses a passkey over the per-user limit", async () => {
107
+ await store.addPasskey(passkey(USER, "cred-a"), 2);
108
+ await store.addPasskey(passkey(USER, "cred-b"), 2);
109
+ expect(await store.addPasskey(passkey(USER, "cred-c"), 2)).toBe("limit_reached");
110
+ expect(await store.addPasskey(passkey(OTHER_USER, "cred-d"), 2)).toBe("added");
111
+ });
112
+ it("holds the per-user limit under concurrent registration", async () => {
113
+ const results = await race((caller) => store.addPasskey(passkey(USER, `cred-${caller}`), 3));
114
+ expect(results.filter((result) => result === "added")).toHaveLength(3);
115
+ expect(await store.listPasskeys(USER)).toHaveLength(3);
116
+ });
117
+ it("adds one passkey when the same credential races itself", async () => {
118
+ const results = await race(() => store.addPasskey(passkey(USER, "cred-a"), 25));
119
+ expect(results.filter((result) => result === "added")).toHaveLength(1);
120
+ expect(results.filter((result) => result === "duplicate")).toHaveLength(CONCURRENT_CALLERS - 1);
121
+ });
122
+ it("records a use only while the sign count still matches", async () => {
123
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
124
+ const usedAt = new Date("2026-02-01T00:00:00.000Z");
125
+ expect(await store.recordUse("cred-a", {
126
+ previousSignCount: 0,
127
+ signCount: 5,
128
+ backedUp: false,
129
+ usedAt,
130
+ })).toBe(true);
131
+ const stored = await store.findPasskey("cred-a");
132
+ expect(stored?.signCount).toBe(5);
133
+ expect(stored?.backedUp).toBe(false);
134
+ expect(stored?.lastUsedAt).toStrictEqual(usedAt);
135
+ expect(await store.recordUse("cred-a", {
136
+ previousSignCount: 0,
137
+ signCount: 6,
138
+ backedUp: false,
139
+ usedAt,
140
+ })).toBe(false);
141
+ expect((await store.findPasskey("cred-a"))?.signCount).toBe(5);
142
+ });
143
+ it("lets one of several concurrent uses with the same counter win", async () => {
144
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
145
+ const results = await race((caller) => store.recordUse("cred-a", {
146
+ previousSignCount: 0,
147
+ signCount: caller + 1,
148
+ backedUp: true,
149
+ usedAt: new Date(),
150
+ }));
151
+ expect(results.filter(Boolean)).toHaveLength(1);
152
+ });
153
+ it("renames and removes only the owner's passkey", async () => {
154
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
155
+ expect(await store.renamePasskey(OTHER_USER, "cred-a", "Mine")).toBe(false);
156
+ expect(await store.removePasskey(OTHER_USER, "cred-a")).toBe(false);
157
+ expect(await store.renamePasskey(USER, "cred-a", "Phone")).toBe(true);
158
+ expect((await store.findPasskey("cred-a"))?.name).toBe("Phone");
159
+ expect(await store.removePasskey(USER, "cred-a")).toBe(true);
160
+ expect(await store.findPasskey("cred-a")).toBe(undefined);
161
+ expect(await store.removePasskey(USER, "cred-a")).toBe(false);
162
+ });
163
+ });
164
+ }
165
+ /**
166
+ * Runs the {@link PasskeyChallengeStore} contract suite. Call it from your own
167
+ * test file; it registers `describe` / `it` blocks.
168
+ */
169
+ export function runPasskeyChallengeStoreContractTests(options) {
170
+ describe(options.describeName ?? "PasskeyChallengeStore contract", () => {
171
+ let store;
172
+ const challenge = {
173
+ challenge: "c2lnbi1tZQ",
174
+ ceremony: "authentication",
175
+ rpId: "login.example.com",
176
+ userId: "user-1",
177
+ expiresAt: Date.now() + 60_000,
178
+ };
179
+ beforeEach(async () => {
180
+ store = await options.makeStore();
181
+ });
182
+ it("returns a stored challenge once", async () => {
183
+ await store.put("ceremony-1", challenge);
184
+ expect(await store.take("ceremony-1")).toStrictEqual(challenge);
185
+ expect(await store.take("ceremony-1")).toBe(undefined);
186
+ });
187
+ it("returns undefined for an unknown ceremony", async () => {
188
+ expect(await store.take("missing")).toBe(undefined);
189
+ });
190
+ it("hands a challenge to exactly one of several concurrent takers", async () => {
191
+ await store.put("ceremony-1", challenge);
192
+ const results = await race(() => store.take("ceremony-1"));
193
+ expect(results.filter((result) => result !== undefined)).toHaveLength(1);
194
+ });
195
+ });
196
+ }
@@ -54,6 +54,13 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
54
54
  * vocabulary, for example `"openid"`. Defaults to `"read"`.
55
55
  */
56
56
  supportedScope?: string;
57
+ /**
58
+ * A resource URI whose exact token save/read round trip to check. Supply it
59
+ * when this store supports opted-in resource-bound machine issuance; leave
60
+ * absent for stores that only support legacy issuance. This tests storage,
61
+ * not the application's resource authorization policy.
62
+ */
63
+ supportedResource?: string;
57
64
  /**
58
65
  * Returns a new rotation-family id the store accepts as a
59
66
  * {@linkcode RefreshToken.familyId}, distinct on every call. The family cases
@@ -89,6 +89,23 @@ export function runTokenServiceContractTests(options) {
89
89
  assert(claimed);
90
90
  await tokenService.save(next);
91
91
  }
92
+ if (options.supportedResource !== undefined) {
93
+ for (const withUser of [false, true]) {
94
+ it(`preserves the exact resource through save and lookup with${withUser ? "" : "out"} a user`, async () => {
95
+ const resource = options.supportedResource;
96
+ const token = {
97
+ accessToken: await tokenService.generateAccessToken(client, withUser ? user : undefined),
98
+ client,
99
+ scope: makeScope(supportedScope),
100
+ resource,
101
+ ...(withUser ? { user } : {}),
102
+ };
103
+ expect((await tokenService.save(token)).resource).toBe(resource);
104
+ const read = await tokenService.getToken(token.accessToken);
105
+ expect(read?.resource, "a store must retain the accepted target, not just return its input from save").toBe(resource);
106
+ });
107
+ }
108
+ }
92
109
  describe("generateAccessToken / generateRefreshToken", () => {
93
110
  it("produces unique access tokens", async () => {
94
111
  const a = await tokenService.generateAccessToken(client, user);
@@ -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, type MemoryAuthorizationCodeServiceOptions, MemoryClientService, MemoryDeviceAuthorizationService, type MemoryDeviceAuthorizationServiceOptions, MemoryTokenService, type MemoryTokenServiceOptions, MemoryUserService, type MemoryUserServiceOptions, type MemoryUserShape, } from "./services.ts";
44
48
  export { createMemoryAuthorizationServer, type CreateMemoryAuthorizationServerOptions, type MemoryAuthorizationServerClientSeed, type MemoryAuthorizationServerGrants, type MemoryAuthorizationServerHarness, type MemoryAuthorizationServerUserSeed, } from "./server.ts";
45
49
  export { createFakeTenant, type FakeTenant, type FakeTenantClient, type FakeTenantGrant, type FakeTenantLinkedAccount, type FakeTenantMachinePermission, type FakeTenantMembership, type FakeTenantOptions, type FakeTenantOrganization, type FakeTenantOrganizationRole, type FakeTenantSignIn, type FakeTenantSubject, type FakeTenantTokenRequest, type FakeTenantUser, } from "./tenant.ts";
50
+ export { type TestPasskeyAuthenticateOptions, TestPasskeyAuthenticator, type TestPasskeyAuthenticatorOptions, type TestPasskeyCredential, } from "./passkey-authenticator.ts";
@@ -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
+ }
@@ -307,6 +307,8 @@ export class MemoryTokenService {
307
307
  scope: stored.scope,
308
308
  authenticationContext: snapshotAuthenticationContext(stored.authenticationContext),
309
309
  };
310
+ if (stored.resource !== undefined)
311
+ token.resource = stored.resource;
310
312
  if (stored.refreshTokenHash) {
311
313
  return {
312
314
  ...token,
@@ -352,6 +354,8 @@ export class MemoryTokenService {
352
354
  scope: token.scope,
353
355
  authenticationContext: snapshotAuthenticationContext(token.authenticationContext),
354
356
  };
357
+ if (token.resource !== undefined)
358
+ stored.resource = token.resource;
355
359
  if (token.code)
356
360
  stored.codeHash = await sha256Hash(token.code);
357
361
  if (token.codeVerifierHash) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.41.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",