@boxxy/auth-oauth 0.0.1 → 0.2.2

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 (142) hide show
  1. package/README.md +47 -2
  2. package/dist/auth/answer-authorize-request.d.ts +2 -0
  3. package/dist/auth/answer-consent-request.d.ts +10 -0
  4. package/dist/auth/answer-enroll-request.d.ts +9 -0
  5. package/dist/auth/answer-login-request.d.ts +9 -0
  6. package/dist/auth/approval-window-seconds.d.ts +1 -0
  7. package/dist/auth/build-consent-binding.d.ts +1 -0
  8. package/dist/auth/create-approval-state.d.ts +41 -0
  9. package/dist/auth/create-auth-admin.d.ts +33 -0
  10. package/dist/auth/create-enrollment-state.d.ts +6 -0
  11. package/dist/auth/create-enrollment-token.d.ts +6 -0
  12. package/dist/auth/create-owner-plugin.d.ts +38 -0
  13. package/dist/auth/create-passkey.d.ts +11 -0
  14. package/dist/auth/create-security-event-log.d.ts +12 -0
  15. package/dist/auth/derive-token-hash.d.ts +1 -0
  16. package/dist/auth/format-security-event.d.ts +7 -0
  17. package/dist/auth/is-allowed-redirect-uri.d.ts +1 -0
  18. package/dist/auth/load-web-authn.d.ts +6 -0
  19. package/dist/auth/mcp-auth-database.d.ts +5 -0
  20. package/dist/auth/mcp-auth-schema.d.ts +54 -0
  21. package/dist/auth/normalize-client-name.d.ts +1 -0
  22. package/dist/auth/open-auth-database.d.ts +2 -0
  23. package/dist/auth/open-mcp-auth.d.ts +80 -0
  24. package/dist/auth/passkey-script.d.ts +4 -0
  25. package/dist/auth/read-client-name.d.ts +3 -0
  26. package/dist/auth/read-client-views.d.ts +9 -0
  27. package/dist/auth/read-enrollment-token.d.ts +3 -0
  28. package/dist/auth/read-grant-views.d.ts +12 -0
  29. package/dist/auth/read-owner-session-id.d.ts +2 -0
  30. package/dist/auth/record-refused-exchange.d.ts +10 -0
  31. package/dist/auth/remove-client.d.ts +3 -0
  32. package/dist/auth/remove-owner-session.d.ts +3 -0
  33. package/dist/auth/render-consent-page.d.ts +9 -0
  34. package/dist/auth/render-enroll-page.d.ts +10 -0
  35. package/dist/auth/render-login-page.d.ts +9 -0
  36. package/dist/auth/revoke-grant.d.ts +3 -0
  37. package/dist/auth/to-html-text.d.ts +1 -0
  38. package/dist/auth/to-script-json.d.ts +1 -0
  39. package/dist/auth/types.d.ts +79 -0
  40. package/dist/auth/update-passkey-use.d.ts +10 -0
  41. package/dist/auth/verify-oauth-query.d.ts +1 -0
  42. package/dist/auth/verify-passkey-assertion.d.ts +11 -0
  43. package/dist/auth/verify-passkey-registration.d.ts +14 -0
  44. package/dist/auth/verify-session-passkey.d.ts +8 -0
  45. package/dist/auth/write-grant-use.d.ts +3 -0
  46. package/dist/http/answer-mcp-request.d.ts +9 -0
  47. package/dist/http/build-page-response.d.ts +5 -0
  48. package/dist/http/create-mcp-http-app.d.ts +92 -0
  49. package/dist/http/create-public-door.d.ts +20 -0
  50. package/dist/http/is-loopback-host.d.ts +1 -0
  51. package/dist/http/normalize-public-url.d.ts +1 -0
  52. package/dist/http/pick-error-message.d.ts +1 -0
  53. package/dist/http/public-door-paths.d.ts +17 -0
  54. package/dist/http/read-token-access.d.ts +8 -0
  55. package/dist/http/verify-access-token.d.ts +2 -0
  56. package/dist/index.d.ts +8 -0
  57. package/dist/test-utils/authorize-test-client.d.ts +13 -0
  58. package/dist/test-utils/build-mock-new-passkey.d.ts +2 -0
  59. package/dist/test-utils/build-passkey-seeder.d.ts +5 -0
  60. package/dist/test-utils/build-seeder.d.ts +5 -0
  61. package/dist/test-utils/build-stub-authenticator.d.ts +48 -0
  62. package/dist/test-utils/build-stub-clock.d.ts +7 -0
  63. package/dist/test-utils/load-web-authn-early.d.ts +1 -0
  64. package/dist/test-utils/register-test-cleanup.d.ts +1 -0
  65. package/dist/test-utils/seed-passkey.d.ts +4 -0
  66. package/dist/test-utils/setup-temp-dir.d.ts +6 -0
  67. package/dist/test-utils/start-stub-telemetry-collector.d.ts +5 -0
  68. package/dist/test-utils/types.d.ts +7 -0
  69. package/dist/utils/is-record.d.ts +1 -0
  70. package/dist/utils/parse-json-strings.d.ts +1 -0
  71. package/dist/utils/types.d.ts +4 -0
  72. package/package.json +42 -1
  73. package/src/auth/answer-authorize-request.ts +137 -0
  74. package/src/auth/answer-consent-request.ts +189 -0
  75. package/src/auth/answer-enroll-request.ts +190 -0
  76. package/src/auth/answer-login-request.ts +256 -0
  77. package/src/auth/approval-window-seconds.ts +5 -0
  78. package/src/auth/build-consent-binding.ts +13 -0
  79. package/src/auth/create-approval-state.ts +221 -0
  80. package/src/auth/create-auth-admin.ts +78 -0
  81. package/src/auth/create-enrollment-state.ts +56 -0
  82. package/src/auth/create-enrollment-token.ts +28 -0
  83. package/src/auth/create-owner-plugin.ts +113 -0
  84. package/src/auth/create-passkey.ts +48 -0
  85. package/src/auth/create-security-event-log.ts +97 -0
  86. package/src/auth/derive-token-hash.ts +7 -0
  87. package/src/auth/format-security-event.ts +72 -0
  88. package/src/auth/is-allowed-redirect-uri.ts +19 -0
  89. package/src/auth/load-web-authn.ts +19 -0
  90. package/src/auth/mcp-auth-database.ts +10 -0
  91. package/src/auth/mcp-auth-schema.ts +56 -0
  92. package/src/auth/normalize-client-name.ts +22 -0
  93. package/src/auth/open-auth-database.ts +22 -0
  94. package/src/auth/open-mcp-auth.ts +138 -0
  95. package/src/auth/passkey-script.ts +50 -0
  96. package/src/auth/read-client-name.ts +17 -0
  97. package/src/auth/read-client-views.ts +27 -0
  98. package/src/auth/read-enrollment-token.ts +20 -0
  99. package/src/auth/read-grant-views.ts +53 -0
  100. package/src/auth/read-owner-session-id.ts +16 -0
  101. package/src/auth/record-refused-exchange.ts +74 -0
  102. package/src/auth/remove-client.ts +33 -0
  103. package/src/auth/remove-owner-session.ts +11 -0
  104. package/src/auth/render-consent-page.ts +61 -0
  105. package/src/auth/render-enroll-page.ts +59 -0
  106. package/src/auth/render-login-page.ts +58 -0
  107. package/src/auth/revoke-grant.ts +25 -0
  108. package/src/auth/to-html-text.ts +10 -0
  109. package/src/auth/to-script-json.ts +8 -0
  110. package/src/auth/types.ts +115 -0
  111. package/src/auth/update-passkey-use.ts +31 -0
  112. package/src/auth/verify-oauth-query.ts +41 -0
  113. package/src/auth/verify-passkey-assertion.ts +124 -0
  114. package/src/auth/verify-passkey-registration.ts +118 -0
  115. package/src/auth/verify-session-passkey.ts +47 -0
  116. package/src/auth/write-grant-use.ts +18 -0
  117. package/src/http/answer-mcp-request.ts +77 -0
  118. package/src/http/build-page-response.ts +26 -0
  119. package/src/http/create-mcp-http-app.ts +340 -0
  120. package/src/http/create-public-door.ts +150 -0
  121. package/src/http/is-loopback-host.ts +10 -0
  122. package/src/http/normalize-public-url.ts +29 -0
  123. package/src/http/pick-error-message.ts +21 -0
  124. package/src/http/public-door-paths.ts +21 -0
  125. package/src/http/read-token-access.ts +44 -0
  126. package/src/http/verify-access-token.ts +46 -0
  127. package/src/index.ts +8 -0
  128. package/src/test-utils/authorize-test-client.ts +194 -0
  129. package/src/test-utils/build-mock-new-passkey.ts +15 -0
  130. package/src/test-utils/build-passkey-seeder.ts +11 -0
  131. package/src/test-utils/build-seeder.ts +10 -0
  132. package/src/test-utils/build-stub-authenticator.ts +244 -0
  133. package/src/test-utils/build-stub-clock.ts +65 -0
  134. package/src/test-utils/load-web-authn-early.ts +8 -0
  135. package/src/test-utils/register-test-cleanup.ts +55 -0
  136. package/src/test-utils/seed-passkey.ts +32 -0
  137. package/src/test-utils/setup-temp-dir.ts +24 -0
  138. package/src/test-utils/start-stub-telemetry-collector.ts +27 -0
  139. package/src/test-utils/types.ts +11 -0
  140. package/src/utils/is-record.ts +5 -0
  141. package/src/utils/parse-json-strings.ts +15 -0
  142. package/src/utils/types.ts +8 -0
@@ -0,0 +1,29 @@
1
+ // Copied from atc: packages/mcp/src/http/normalize-public-url.ts at commit 4fe647cf.
2
+
3
+ import { isLoopbackHost } from './is-loopback-host.ts';
4
+
5
+ export function normalizePublicURL(raw: string): string {
6
+ let url: URL;
7
+
8
+ try {
9
+ url = new URL(raw);
10
+ } catch {
11
+ throw new Error(`the public URL '${raw}' is not a URL`);
12
+ }
13
+
14
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHost(url.hostname))) {
15
+ throw new Error(`the public URL '${raw}' must use https unless its host is loopback`);
16
+ }
17
+
18
+ if ((url.pathname !== '/' && url.pathname !== '') || url.search !== '' || url.hash !== '') {
19
+ throw new Error(
20
+ `the public URL '${raw}' must be a bare origin, with no path, query, or fragment`,
21
+ );
22
+ }
23
+
24
+ if (url.username !== '' || url.password !== '') {
25
+ throw new Error(`the public URL '${raw}' must not carry credentials`);
26
+ }
27
+
28
+ return url.origin;
29
+ }
@@ -0,0 +1,21 @@
1
+ // Copied from atc: packages/mcp/src/http/pick-error-message.ts at commit 4fe647cf.
2
+
3
+ // The error codes better-auth sends to the error page, each with the fixed
4
+ // sentence boxxy shows for it.
5
+ const ERROR_MESSAGES: Readonly<Record<string, string>> = {
6
+ invalid_client: 'the client is not one added to boxxy.',
7
+ client_disabled: 'the client is disabled.',
8
+ invalid_redirect: 'the redirect URI is not one the client was added with.',
9
+ unauthorized_client: 'the client may not use the authorization code grant.',
10
+ unsupported_response_type: 'boxxy supports only the authorization code response type.',
11
+ invalid_request: 'the request is malformed.',
12
+ };
13
+
14
+ export function pickErrorMessage(code: string | null): string {
15
+ // the page never shows text from the request, so a link to it cannot make
16
+ // boxxy's origin display words an attacker chose.
17
+ const reason =
18
+ code !== null && Object.hasOwn(ERROR_MESSAGES, code) ? ERROR_MESSAGES[code] : undefined;
19
+
20
+ return `boxxy refused this authorization request: ${reason ?? 'the request is not valid.'}`;
21
+ }
@@ -0,0 +1,21 @@
1
+ // Adapted from atc: packages/contract/src/gateway-paths.ts at commit 4fe647cf.
2
+
3
+ // The routes of the public door: the browser pages a person opens, and the routes a machine
4
+ // client calls. The door serves nothing else.
5
+ export const PUBLIC_DOOR_PATHS = {
6
+ browser: {
7
+ authorize: '/oauth2/authorize',
8
+ login: '/login',
9
+ enroll: '/enroll',
10
+ consent: '/consent',
11
+ error: '/error',
12
+ },
13
+ machine: {
14
+ mcp: '/mcp',
15
+ token: '/oauth2/token',
16
+ revoke: '/oauth2/revoke',
17
+ authorizationServerMetadata: '/.well-known/oauth-authorization-server',
18
+ resourceMetadata: '/.well-known/oauth-protected-resource',
19
+ mcpResourceMetadata: '/.well-known/oauth-protected-resource/mcp',
20
+ },
21
+ } as const;
@@ -0,0 +1,44 @@
1
+ // Copied from atc: packages/mcp/src/http/read-token-access.ts at commit 4fe647cf.
2
+
3
+ import { grantScopes } from '@boxxy/contract';
4
+ import type { GrantScope } from '@boxxy/contract';
5
+ import { isAPIError } from 'better-auth/api';
6
+ import type { HTTPServerContext } from '../auth/types.ts';
7
+ import { isRecord } from '../utils/is-record.ts';
8
+
9
+ interface TokenAccess {
10
+ readonly clientID: string;
11
+ readonly scopes: readonly GrantScope[];
12
+ }
13
+
14
+ // An inactive, unknown, expired or revoked token, one bound to another
15
+ // resource, or one without a client id reads as null. Reading records no use.
16
+ export async function readTokenAccess(
17
+ ctx: HTTPServerContext,
18
+ token: string,
19
+ ): Promise<TokenAccess | null> {
20
+ let payload: unknown;
21
+
22
+ try {
23
+ payload = await ctx.store.auth.api.verifyMCPAccessToken({ body: { token } });
24
+ } catch (error) {
25
+ if (isAPIError(error)) {
26
+ return null;
27
+ }
28
+
29
+ throw error;
30
+ }
31
+
32
+ if (!isRecord(payload) || ![payload['aud']].flat().includes(ctx.resource)) {
33
+ return null;
34
+ }
35
+
36
+ const granted = typeof payload['scope'] === 'string' ? payload['scope'].split(' ') : [];
37
+ const clientID = typeof payload['client_id'] === 'string' ? payload['client_id'] : '';
38
+
39
+ if (clientID === '') {
40
+ return null;
41
+ }
42
+
43
+ return { clientID, scopes: grantScopes.filter((scope) => granted.includes(scope)) };
44
+ }
@@ -0,0 +1,46 @@
1
+ // Copied from atc: packages/mcp/src/http/verify-access-token.ts at commit 4fe647cf.
2
+
3
+ import { deriveTokenHash } from '../auth/derive-token-hash.ts';
4
+ import { normalizeClientName } from '../auth/normalize-client-name.ts';
5
+ import { readClientName } from '../auth/read-client-name.ts';
6
+ import type { HTTPServerContext, VerifiedAccess } from '../auth/types.ts';
7
+ import { writeGrantUse } from '../auth/write-grant-use.ts';
8
+ import { readTokenAccess } from './read-token-access.ts';
9
+
10
+ // Verifying stamps when the token's grant was last used.
11
+ export async function verifyAccessToken(
12
+ ctx: HTTPServerContext,
13
+ token: string,
14
+ ): Promise<VerifiedAccess | null> {
15
+ const access = await readTokenAccess(ctx, token);
16
+
17
+ if (access === null) {
18
+ return null;
19
+ }
20
+
21
+ await upsertGrantUse(ctx, token);
22
+
23
+ const storedName = await readClientName(ctx.store.db, access.clientID);
24
+
25
+ return {
26
+ clientID: access.clientID,
27
+ clientName: normalizeClientName(storedName, 'remote'),
28
+ scopes: access.scopes,
29
+ };
30
+ }
31
+
32
+ async function upsertGrantUse(ctx: HTTPServerContext, token: string): Promise<void> {
33
+ const row = await ctx.store.db
34
+ .selectFrom('oauthAccessToken')
35
+ .select('authorizationCodeId')
36
+ .where('token', '=', deriveTokenHash(token))
37
+ .executeTakeFirst();
38
+
39
+ const grantID = row?.authorizationCodeId ?? null;
40
+
41
+ if (grantID === null) {
42
+ return;
43
+ }
44
+
45
+ await writeGrantUse(ctx.store.db, grantID, ctx.clock.now());
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type { AddClientAnswer, AuthAdmin, EnrollmentLink } from './auth/create-auth-admin.ts';
2
+ export type { McpAuthInfo, McpServe } from './http/answer-mcp-request.ts';
3
+ export type { PublicDoor, PublicDoorOptions } from './http/create-public-door.ts';
4
+ export { createPublicDoor } from './http/create-public-door.ts';
5
+ export type { ClientView } from './auth/read-client-views.ts';
6
+ export type { GrantView } from './auth/read-grant-views.ts';
7
+ export type { VerifiedAccess } from './auth/types.ts';
8
+ export { isLoopbackHost } from './http/is-loopback-host.ts';
@@ -0,0 +1,194 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import type { PublicDoor } from '../http/create-public-door.ts';
4
+ import { buildStubAuthenticator } from './build-stub-authenticator.ts';
5
+
6
+ interface AuthorizeInput {
7
+ // the origin the door was created with, such as `https://boxxy.example`
8
+ readonly origin: string;
9
+
10
+ // the scopes the client asks for, and the ones the owner ticks on the consent page
11
+ readonly requested: readonly string[];
12
+ readonly ticked: readonly string[];
13
+ }
14
+
15
+ export interface TestGrant {
16
+ readonly clientID: string;
17
+ readonly accessToken: string;
18
+ readonly refreshToken: string;
19
+ }
20
+
21
+ const tokenSchema = z.object({ access_token: z.string(), refresh_token: z.string() });
22
+
23
+ // Runs the whole sign-in against a door, the way a client and the owner's browser would: enrolls a
24
+ // passkey, adds a client, then authorizes it with a passkey sign-in, consent and a code exchange.
25
+ export async function authorizeTestClient(
26
+ door: PublicDoor,
27
+ input: AuthorizeInput,
28
+ ): Promise<TestGrant> {
29
+ const authenticator = buildStubAuthenticator();
30
+
31
+ const host = new URL(input.origin).host;
32
+
33
+ const redirectURI = 'https://client.example/callback';
34
+
35
+ const send = (path: string, init: RequestInit & { readonly cookie?: string } = {}) => {
36
+ const headers = new Headers(init.headers);
37
+
38
+ headers.set('host', host);
39
+
40
+ if (init.method === 'POST') {
41
+ headers.set('origin', input.origin);
42
+ }
43
+
44
+ if (init.cookie !== undefined) {
45
+ headers.set('cookie', init.cookie);
46
+ }
47
+
48
+ return door.answerRequest(new Request(`${input.origin}${path}`, { ...init, headers }));
49
+ };
50
+
51
+ const postForm = (
52
+ path: string,
53
+ fields: Readonly<Record<string, string | string[]>>,
54
+ cookie?: string,
55
+ ) => {
56
+ const body = new URLSearchParams();
57
+
58
+ for (const [name, value] of Object.entries(fields)) {
59
+ for (const item of [value].flat()) {
60
+ body.append(name, item);
61
+ }
62
+ }
63
+
64
+ return send(path, {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
67
+ body,
68
+ ...(cookie === undefined ? {} : { cookie }),
69
+ });
70
+ };
71
+
72
+ const enrollment = await door.admin.enroll();
73
+ const enrolling = await postForm('/enroll', { token: enrollment.token });
74
+ const enrollingPage = await enrolling.text();
75
+
76
+ const creation = readOptions(enrollingPage);
77
+
78
+ const registration = await authenticator.create(
79
+ {
80
+ challenge: String(creation['challenge']),
81
+ rp: { id: new URL(input.origin).hostname },
82
+ user: { id: 'b3duZXI' },
83
+ },
84
+ { origin: input.origin },
85
+ );
86
+
87
+ const enrolled = await postForm('/enroll', {
88
+ token: enrollment.token,
89
+ credential: JSON.stringify(registration),
90
+ });
91
+
92
+ if (enrolled.status !== 200) {
93
+ throw new Error(`the stub passkey did not enroll: ${enrolled.status}`);
94
+ }
95
+
96
+ const added = await door.admin.addClient('test client', [redirectURI]);
97
+
98
+ if (added.kind !== 'added') {
99
+ throw new Error('the test client was refused');
100
+ }
101
+
102
+ const verifier = randomBytes(32).toString('base64url');
103
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
104
+
105
+ const authorizeQuery = new URLSearchParams({
106
+ response_type: 'code',
107
+ client_id: added.clientID,
108
+ redirect_uri: redirectURI,
109
+ scope: input.requested.join(' '),
110
+ state: 'state-1',
111
+ code_challenge: challenge,
112
+ code_challenge_method: 'S256',
113
+ });
114
+
115
+ const authorized = await send(`/oauth2/authorize?${authorizeQuery.toString()}`);
116
+
117
+ const loginLocation = new URL(authorized.headers.get('location') ?? '', input.origin);
118
+
119
+ const oauthQuery = loginLocation.search.slice(1);
120
+
121
+ const loginPage = await send(`${loginLocation.pathname}${loginLocation.search}`);
122
+ const loginHtml = await loginPage.text();
123
+
124
+ const request = readOptions(loginHtml);
125
+
126
+ const assertion = await authenticator.signAssertion(
127
+ { challenge: String(request['challenge']), rpId: new URL(input.origin).hostname },
128
+ { origin: input.origin },
129
+ );
130
+
131
+ const signedIn = await postForm('/login', {
132
+ oauth_query: oauthQuery,
133
+ credential: JSON.stringify(assertion),
134
+ });
135
+
136
+ const cookie = signedIn.headers
137
+ .getSetCookie()
138
+ .map((line) => line.split(';')[0])
139
+ .join('; ');
140
+
141
+ const consentLocation = new URL(signedIn.headers.get('location') ?? '', input.origin);
142
+
143
+ const consented = await postForm(
144
+ '/consent',
145
+ {
146
+ oauth_query: consentLocation.search.slice(1),
147
+ decision: 'approve',
148
+ scope: [...input.ticked],
149
+ },
150
+ cookie,
151
+ );
152
+
153
+ const code = new URL(consented.headers.get('location') ?? '', redirectURI).searchParams.get(
154
+ 'code',
155
+ );
156
+
157
+ if (code === null) {
158
+ throw new Error(`the consent issued no code: ${consented.status}`);
159
+ }
160
+
161
+ const exchanged = await postForm('/oauth2/token', {
162
+ grant_type: 'authorization_code',
163
+ code,
164
+ redirect_uri: redirectURI,
165
+ client_id: added.clientID,
166
+ code_verifier: verifier,
167
+ resource: `${input.origin}/mcp`,
168
+ });
169
+
170
+ const exchangedBody: unknown = await exchanged.json();
171
+
172
+ const tokens = tokenSchema.parse(exchangedBody);
173
+
174
+ return {
175
+ clientID: added.clientID,
176
+ accessToken: tokens.access_token,
177
+ refreshToken: tokens.refresh_token,
178
+ };
179
+ }
180
+
181
+ const scriptSchema = z.object({ options: z.record(z.string(), z.unknown()) });
182
+
183
+ // The WebAuthn options that a page carries in its passkey script block.
184
+ function readOptions(page: string): Record<string, unknown> {
185
+ const block = /<script type="application\/json" id="boxxy-passkey">(?<json>.*?)<\/script>/su.exec(
186
+ page,
187
+ )?.groups?.['json'];
188
+
189
+ if (block === undefined) {
190
+ throw new Error('the page carries no passkey options');
191
+ }
192
+
193
+ return scriptSchema.parse(JSON.parse(block.replaceAll(String.raw`\u003c`, '<'))).options;
194
+ }
@@ -0,0 +1,15 @@
1
+ // Copied from atc: packages/mcp/src/test-utils/build-mock-new-passkey.ts at commit 4fe647cf.
2
+
3
+ import type { NewPasskey } from '../auth/create-passkey.ts';
4
+
5
+ export function buildMockNewPasskey(overrides: Partial<NewPasskey> = {}): NewPasskey {
6
+ return {
7
+ tokenHash: 'a'.repeat(64),
8
+ id: 'credential-0001',
9
+ publicKey: 'AQID',
10
+ counter: 0,
11
+ transports: ['internal'],
12
+ now: Date.parse('2026-01-01T00:00:00Z'),
13
+ ...overrides,
14
+ };
15
+ }
@@ -0,0 +1,11 @@
1
+ // Copied from atc: packages/mcp/src/test-utils/build-passkey-seeder.ts at commit 4fe647cf.
2
+
3
+ import { seedPasskey } from './seed-passkey.ts';
4
+ import type { PasskeyInput } from './seed-passkey.ts';
5
+ import type { MCPAuthSeedDeps } from './types.ts';
6
+
7
+ export function buildPasskeySeeder(deps: MCPAuthSeedDeps) {
8
+ return {
9
+ passkey: (input?: PasskeyInput) => seedPasskey(deps, input),
10
+ };
11
+ }
@@ -0,0 +1,10 @@
1
+ // Copied from atc: packages/mcp/src/test-utils/build-seeder.ts at commit 4fe647cf.
2
+
3
+ import { buildPasskeySeeder } from './build-passkey-seeder.ts';
4
+ import type { MCPAuthSeedDeps } from './types.ts';
5
+
6
+ export type TestDeps = MCPAuthSeedDeps;
7
+
8
+ export function buildSeeder(deps: TestDeps) {
9
+ return buildPasskeySeeder(deps);
10
+ }
@@ -0,0 +1,244 @@
1
+ // Copied from atc: packages/test-utils/src/build-stub-authenticator.ts at commit 4fe647cf.
2
+
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+
5
+ // The parts of the creation and request options a browser passes to
6
+ // navigator.credentials that an authenticator reads.
7
+ interface CreationOptions {
8
+ readonly challenge: string;
9
+ readonly rp: { readonly id?: string };
10
+ readonly user: { readonly id: string };
11
+ }
12
+
13
+ interface RequestOptions {
14
+ readonly challenge: string;
15
+ readonly rpId?: string;
16
+ }
17
+
18
+ // What a browser and its authenticator put in a response, each defaulting
19
+ // to the honest value: the page's origin as given, the RP ID the options
20
+ // hold, and a verified user.
21
+ interface Ceremony {
22
+ readonly origin: string;
23
+ readonly rpID?: string;
24
+ readonly userVerified?: boolean;
25
+ }
26
+
27
+ export interface StubRegistration {
28
+ readonly id: string;
29
+ readonly rawId: string;
30
+ readonly type: 'public-key';
31
+ readonly response: {
32
+ readonly clientDataJSON: string;
33
+ readonly attestationObject: string;
34
+ readonly transports: readonly string[];
35
+ };
36
+ readonly clientExtensionResults: Readonly<Record<string, never>>;
37
+ readonly authenticatorAttachment: 'platform';
38
+ }
39
+
40
+ export interface StubAssertion {
41
+ readonly id: string;
42
+ readonly rawId: string;
43
+ readonly type: 'public-key';
44
+ readonly response: {
45
+ readonly clientDataJSON: string;
46
+ readonly authenticatorData: string;
47
+ readonly signature: string;
48
+ readonly userHandle: string;
49
+ };
50
+ readonly clientExtensionResults: Readonly<Record<string, never>>;
51
+ }
52
+
53
+ const FLAG_AT = 0x40;
54
+
55
+ type CBORValue = number | string | Buffer | ReadonlyMap<number | string, CBORValue>;
56
+
57
+ export function buildStubAuthenticator() {
58
+ const credentialID = randomBytes(16);
59
+
60
+ const keys = crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
61
+ 'sign',
62
+ 'verify',
63
+ ]);
64
+
65
+ let userHandle: string | null = null;
66
+ let counter = 0;
67
+
68
+ return {
69
+ credentialID: credentialID.toString('base64url'),
70
+ async create(options: CreationOptions, ceremony: Ceremony): Promise<StubRegistration> {
71
+ const pair = await keys;
72
+ const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
73
+
74
+ userHandle = options.user.id;
75
+
76
+ // A COSE EC2 key: kty 2, alg -7 (ES256), crv 1 (P-256), x, y.
77
+ const coseKey = encodeCBOR(
78
+ new Map<number, CBORValue>([
79
+ [1, 2],
80
+ [3, -7],
81
+ [-1, 1],
82
+ [-2, Buffer.from(jwk.x ?? '', 'base64url')],
83
+ [-3, Buffer.from(jwk.y ?? '', 'base64url')],
84
+ ]),
85
+ );
86
+
87
+ const length = Buffer.alloc(2);
88
+
89
+ length.writeUInt16BE(credentialID.length);
90
+
91
+ const authData = Buffer.concat([
92
+ buildAuthenticatorHeader(ceremony.rpID ?? options.rp.id ?? '', ceremony, 0, FLAG_AT),
93
+ Buffer.alloc(16),
94
+ length,
95
+ credentialID,
96
+ coseKey,
97
+ ]);
98
+
99
+ const attestationObject = encodeCBOR(
100
+ new Map<string, CBORValue>([
101
+ ['fmt', 'none'],
102
+ ['attStmt', new Map()],
103
+ ['authData', authData],
104
+ ]),
105
+ );
106
+
107
+ return {
108
+ id: credentialID.toString('base64url'),
109
+ rawId: credentialID.toString('base64url'),
110
+ type: 'public-key',
111
+ response: {
112
+ clientDataJSON: buildClientData('webauthn.create', options.challenge, ceremony),
113
+ attestationObject: attestationObject.toString('base64url'),
114
+ transports: ['internal'],
115
+ },
116
+ clientExtensionResults: {},
117
+ authenticatorAttachment: 'platform',
118
+ };
119
+ },
120
+ async signAssertion(options: RequestOptions, ceremony: Ceremony): Promise<StubAssertion> {
121
+ if (userHandle === null) {
122
+ throw new Error('the stub authenticator holds no credential yet');
123
+ }
124
+
125
+ const pair = await keys;
126
+
127
+ counter += 1;
128
+
129
+ const authenticatorData = buildAuthenticatorHeader(
130
+ ceremony.rpID ?? options.rpId ?? '',
131
+ ceremony,
132
+ counter,
133
+ 0,
134
+ );
135
+
136
+ const clientDataJSON = buildClientData('webauthn.get', options.challenge, ceremony);
137
+
138
+ const signed = Buffer.concat([
139
+ authenticatorData,
140
+ createHash('sha256').update(Buffer.from(clientDataJSON, 'base64url')).digest(),
141
+ ]);
142
+
143
+ const raw = await crypto.subtle.sign(
144
+ { name: 'ECDSA', hash: 'SHA-256' },
145
+ pair.privateKey,
146
+ signed,
147
+ );
148
+
149
+ return {
150
+ id: credentialID.toString('base64url'),
151
+ rawId: credentialID.toString('base64url'),
152
+ type: 'public-key',
153
+ response: {
154
+ clientDataJSON,
155
+ authenticatorData: authenticatorData.toString('base64url'),
156
+ signature: toDERSignature(Buffer.from(raw)).toString('base64url'),
157
+ userHandle,
158
+ },
159
+ clientExtensionResults: {},
160
+ };
161
+ },
162
+ };
163
+ }
164
+
165
+ // The CBOR subset a COSE key and a `none` attestation need: integers,
166
+ // byte strings, text strings, and maps.
167
+ function encodeCBOR(value: CBORValue): Buffer {
168
+ if (typeof value === 'number') {
169
+ return value >= 0 ? encodeCBORHead(0, value) : encodeCBORHead(1, -1 - value);
170
+ }
171
+
172
+ if (typeof value === 'string') {
173
+ const text = Buffer.from(value);
174
+
175
+ return Buffer.concat([encodeCBORHead(3, text.length), text]);
176
+ }
177
+
178
+ if (Buffer.isBuffer(value)) {
179
+ return Buffer.concat([encodeCBORHead(2, value.length), value]);
180
+ }
181
+
182
+ return Buffer.concat([
183
+ encodeCBORHead(5, value.size),
184
+ ...[...value].flatMap(([key, item]) => [encodeCBOR(key), encodeCBOR(item)]),
185
+ ]);
186
+ }
187
+
188
+ function encodeCBORHead(major: number, length: number): Buffer {
189
+ if (length < 24) {
190
+ return Buffer.from([(major << 5) | length]);
191
+ }
192
+
193
+ if (length < 256) {
194
+ return Buffer.from([(major << 5) | 24, length]);
195
+ }
196
+
197
+ const head = Buffer.alloc(3);
198
+
199
+ head.writeUInt8((major << 5) | 25);
200
+ head.writeUInt16BE(length, 1);
201
+
202
+ return head;
203
+ }
204
+
205
+ const FLAG_UP = 0x01;
206
+ const FLAG_UV = 0x04;
207
+
208
+ // The RP ID hash, the flags with user presence always set, and the
209
+ // big-endian signature counter.
210
+ function buildAuthenticatorHeader(
211
+ rpID: string,
212
+ ceremony: Ceremony,
213
+ signCount: number,
214
+ extraFlags: number,
215
+ ): Buffer {
216
+ const flags = FLAG_UP | (ceremony.userVerified === false ? 0 : FLAG_UV) | extraFlags;
217
+ const count = Buffer.alloc(4);
218
+
219
+ count.writeUInt32BE(signCount);
220
+
221
+ return Buffer.concat([createHash('sha256').update(rpID).digest(), Buffer.from([flags]), count]);
222
+ }
223
+
224
+ function buildClientData(type: string, challenge: string, ceremony: Ceremony): string {
225
+ return Buffer.from(
226
+ JSON.stringify({ type, challenge, origin: ceremony.origin, crossOrigin: false }),
227
+ ).toString('base64url');
228
+ }
229
+
230
+ // WebCrypto signs ECDSA as r and s side by side; WebAuthn carries the
231
+ // signature as an ASN.1 DER sequence of two integers.
232
+ function toDERSignature(raw: Buffer): Buffer {
233
+ const integers = [raw.subarray(0, 32), raw.subarray(32)].map((part) => {
234
+ const start = part.findIndex((byte) => byte !== 0);
235
+ const trimmed = start === -1 ? Buffer.from([0]) : part.subarray(start);
236
+ const value = (trimmed[0] ?? 0) >= 0x80 ? Buffer.concat([Buffer.from([0]), trimmed]) : trimmed;
237
+
238
+ return Buffer.concat([Buffer.from([0x02, value.length]), value]);
239
+ });
240
+
241
+ const body = Buffer.concat(integers);
242
+
243
+ return Buffer.concat([Buffer.from([0x30, body.length]), body]);
244
+ }