@boxxy/auth-oauth 0.0.1 → 0.2.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.
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,72 @@
1
+ // Copied from atc: packages/mcp/src/auth/format-security-event.ts at commit 4fe647cf.
2
+
3
+ import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
4
+ import { normalizeClientName } from './normalize-client-name.ts';
5
+ import type { SecurityEvent } from './types.ts';
6
+
7
+ export interface SuppressedSecurityEvent {
8
+ readonly kind: 'suppressed';
9
+ readonly reason: SecurityEvent['kind'];
10
+ readonly count: number;
11
+ }
12
+
13
+ // The paths an event may hold as its route.
14
+ const ROUTES: ReadonlySet<string> = new Set(
15
+ Object.values(PUBLIC_DOOR_PATHS).flatMap((group) => Object.values(group)),
16
+ );
17
+
18
+ export function formatSecurityEvent(
19
+ event: SecurityEvent | SuppressedSecurityEvent,
20
+ at: number,
21
+ ): string {
22
+ const head = { version: 1, ts: new Date(at).toISOString(), kind: event.kind };
23
+
24
+ if (event.kind === 'suppressed') {
25
+ return JSON.stringify({ ...head, reason: event.reason, count: event.count });
26
+ }
27
+
28
+ const requester = event.requester;
29
+
30
+ const fields = {
31
+ ...head,
32
+ outcome: event.outcome,
33
+ ...('reason' in event && event.reason !== undefined ? { reason: event.reason } : {}),
34
+ ...(event.kind === 'owner_auth'
35
+ ? { method: event.path === PUBLIC_DOOR_PATHS.browser.enroll ? 'enrollment_token' : 'passkey' }
36
+ : {}),
37
+ route: ROUTES.has(event.path) ? event.path : 'other',
38
+ ...(event.client === undefined
39
+ ? {}
40
+ : {
41
+ client: {
42
+ id: toEventText(event.client.id, 64),
43
+ name: toEventText(event.client.name, 100),
44
+ },
45
+ }),
46
+ ...(event.redirectHost === undefined
47
+ ? {}
48
+ : { redirectHost: toEventText(event.redirectHost, 255) }),
49
+ ...(event.correlationID === undefined ? {} : { correlationID: event.correlationID }),
50
+ ...(requester.socketAddress === null
51
+ ? {}
52
+ : { peer: { address: toEventText(requester.socketAddress, 45), provenance: 'socket' } }),
53
+ ...(requester.connectingIP === null
54
+ ? {}
55
+ : {
56
+ forwarded: {
57
+ address: toEventText(requester.connectingIP.address, 45),
58
+ header: 'cf-connecting-ip',
59
+ provenance: requester.connectingIP.provenance,
60
+ },
61
+ }),
62
+ ...(requester.userAgent === null
63
+ ? {}
64
+ : { userAgent: { summary: toEventText(requester.userAgent, 60), provenance: 'client' } }),
65
+ };
66
+
67
+ return JSON.stringify(fields);
68
+ }
69
+
70
+ function toEventText(value: string, maxLength: number): string {
71
+ return normalizeClientName(value, '', maxLength);
72
+ }
@@ -0,0 +1,19 @@
1
+ // Copied from atc: packages/mcp/src/auth/is-allowed-redirect-uri.ts at commit 4fe647cf.
2
+
3
+ import { isLoopbackHost } from '../http/is-loopback-host.ts';
4
+
5
+ export function isAllowedRedirectURI(raw: string): boolean {
6
+ let url: URL;
7
+
8
+ try {
9
+ url = new URL(raw);
10
+ } catch {
11
+ return false;
12
+ }
13
+
14
+ if (url.hash !== '' || url.username !== '' || url.password !== '') {
15
+ return false;
16
+ }
17
+
18
+ return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHost(url.hostname));
19
+ }
@@ -0,0 +1,19 @@
1
+ // Copied from atc: packages/mcp/src/auth/load-web-authn.ts at commit 4fe647cf.
2
+
3
+ import type {
4
+ verifyAuthenticationResponse,
5
+ verifyRegistrationResponse,
6
+ } from '@simplewebauthn/server';
7
+
8
+ export interface WebAuthn {
9
+ readonly verifyAuthenticationResponse: typeof verifyAuthenticationResponse;
10
+ readonly verifyRegistrationResponse: typeof verifyRegistrationResponse;
11
+ }
12
+
13
+ export async function loadWebAuthn(): Promise<WebAuthn> {
14
+ // the library imports this polyfill itself, but a compiled binary's bundle runs
15
+ // its certificate code before that import, so the polyfill loads first.
16
+ await import('reflect-metadata');
17
+
18
+ return import('@simplewebauthn/server');
19
+ }
@@ -0,0 +1,10 @@
1
+ // Copied from atc: packages/mcp/src/auth/mcp-auth-database.ts at commit 4fe647cf.
2
+
3
+ import type { Dialect } from 'kysely';
4
+
5
+ export interface MCPAuthDatabase {
6
+ readonly dialect: Dialect;
7
+
8
+ // releases the file, once Kysely is done with it.
9
+ readonly close: () => void;
10
+ }
@@ -0,0 +1,56 @@
1
+ // Copied from atc: packages/mcp/src/auth/mcp-auth-schema.ts at commit 4fe647cf.
2
+
3
+ // only the columns boxxy reads and writes itself: better-auth owns the tables and
4
+ // their migrations. Dates are ISO 8601 strings and string lists are JSON arrays.
5
+ export interface MCPAuthSchema {
6
+ readonly oauthClient: {
7
+ readonly id: string;
8
+ readonly clientId: string;
9
+ readonly name: string | null;
10
+ readonly redirectUris: string;
11
+ readonly createdAt: string | null;
12
+ };
13
+ readonly oauthAccessToken: {
14
+ readonly id: string;
15
+ readonly token: string;
16
+ readonly clientId: string;
17
+ readonly sessionId: string | null;
18
+ readonly authorizationCodeId: string | null;
19
+ };
20
+ readonly oauthRefreshToken: {
21
+ readonly id: string;
22
+ readonly clientId: string;
23
+ readonly sessionId: string | null;
24
+ readonly authorizationCodeId: string | null;
25
+ readonly scopes: string;
26
+ readonly createdAt: string;
27
+ readonly expiresAt: string;
28
+ readonly revoked: string | null;
29
+ };
30
+ readonly oauthConsent: { readonly id: string; readonly clientId: string };
31
+ readonly oauthResource: { readonly identifier: string };
32
+ readonly session: { readonly id: string; readonly expiresAt: string };
33
+
34
+ // When each grant last reached /mcp, keyed by the grant's authorization code id.
35
+ readonly boxxy_grant_use: { readonly grant_id: string; readonly last_used_at: string };
36
+
37
+ // The owner's passkeys: the credential id and its COSE public key, both
38
+ // base64url, the signature counter the authenticator last returned, and
39
+ // its transports as a JSON array.
40
+ readonly boxxy_passkey: {
41
+ readonly id: string;
42
+ readonly public_key: string;
43
+ readonly counter: number;
44
+ readonly transports: string;
45
+ readonly created_at: string;
46
+ readonly last_used_at: string | null;
47
+ };
48
+
49
+ // The hash of each enrollment token a host command minted and no passkey
50
+ // has used yet.
51
+ readonly boxxy_enrollment_token: {
52
+ readonly token_hash: string;
53
+ readonly created_at: string;
54
+ readonly expires_at: string;
55
+ };
56
+ }
@@ -0,0 +1,22 @@
1
+ // Copied from atc: packages/mcp/src/auth/normalize-client-name.ts at commit 4fe647cf.
2
+
3
+ export function normalizeClientName(
4
+ raw: unknown,
5
+ fallback = 'unnamed client',
6
+ maxLength = 100,
7
+ ): string {
8
+ if (typeof raw !== 'string') {
9
+ return fallback;
10
+ }
11
+
12
+ const folded = raw
13
+ .replaceAll(/[\t\n\v\f\r]/gu, ' ')
14
+ .replaceAll(/[\p{Cc}\p{Cf}]/gu, '')
15
+ .replaceAll(/\s+/gu, ' ')
16
+ .trim();
17
+
18
+ // The cut counts code points, so it never splits a surrogate pair.
19
+ const cut = folded.replace(new RegExp(`^(?<kept>.{${maxLength}}).+$`, 'su'), '$<kept>').trim();
20
+
21
+ return cut === '' ? fallback : cut;
22
+ }
@@ -0,0 +1,22 @@
1
+ // Adapted from atc: packages/mcp/src/testing/open-test-auth-database.ts at commit 4fe647cf.
2
+
3
+ import { Database } from 'bun:sqlite';
4
+ import { buildSQLiteDialect } from '@zgeoff/kysely-bun-sqlite';
5
+ import type { MCPAuthDatabase } from './mcp-auth-database.ts';
6
+
7
+ // A write waits for another process's lock, and the cascades from a client to its tokens need
8
+ // foreign keys.
9
+ export function openAuthDatabase(path: string): MCPAuthDatabase {
10
+ const sqlite = new Database(path, { create: true });
11
+
12
+ sqlite.run('PRAGMA journal_mode = WAL;');
13
+ sqlite.run('PRAGMA busy_timeout = 5000;');
14
+ sqlite.run('PRAGMA foreign_keys = ON;');
15
+
16
+ return {
17
+ dialect: buildSQLiteDialect(sqlite),
18
+ close: () => {
19
+ sqlite.close();
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,138 @@
1
+ // Copied from atc: packages/mcp/src/auth/open-mcp-auth.ts at commit 4fe647cf.
2
+
3
+ import { randomBytes } from 'node:crypto';
4
+ import { chmodSync, closeSync, openSync } from 'node:fs';
5
+ import { mcp } from '@better-auth/mcp';
6
+ import { oauthProvider } from '@better-auth/oauth-provider';
7
+ import type { OAuthOptions, Scope } from '@better-auth/oauth-provider';
8
+ import { grantScopes } from '@boxxy/contract';
9
+ import { betterAuth } from 'better-auth';
10
+ import type { BetterAuthPlugin } from 'better-auth';
11
+ import { getMigrations } from 'better-auth/db/migration';
12
+ import { Kysely, sql } from 'kysely';
13
+ import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
14
+ import { APPROVAL_WINDOW_SECONDS } from './approval-window-seconds.ts';
15
+ import { createOwnerPlugin } from './create-owner-plugin.ts';
16
+ import { deriveTokenHash } from './derive-token-hash.ts';
17
+ import type { MCPAuthDatabase } from './mcp-auth-database.ts';
18
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
19
+
20
+ interface MCPAuthOptions {
21
+ readonly dbPath: string;
22
+
23
+ // The public origin: the issuer, with `<origin>/mcp` the one resource tokens
24
+ // are bound to. Null opens the store to manage clients and grants only, with
25
+ // no resource served.
26
+ readonly origin: string | null;
27
+
28
+ // How long a rotated refresh token still answers with its successor; 0
29
+ // treats any reuse as replay.
30
+ readonly refreshReuseSeconds?: number;
31
+
32
+ // Opens the database file, which the entry wires to boxxy's SQLite opener.
33
+ readonly openDatabase: (path: string) => MCPAuthDatabase;
34
+ }
35
+
36
+ // The owner's session lives only to carry one approval from the code page
37
+ // through consent to the code exchange, which every token detaches from.
38
+ const OWNER_SESSION_SECONDS = 3 * APPROVAL_WINDOW_SECONDS;
39
+
40
+ export async function openMCPAuth(options: MCPAuthOptions) {
41
+ // better-auth reads these when it builds its context; an empty endpoint
42
+ // turns its telemetry into a no-op.
43
+ process.env['BETTER_AUTH_TELEMETRY'] = '0';
44
+ process.env['BETTER_AUTH_TELEMETRY_ENDPOINT'] = '';
45
+
46
+ // The file holds token hashes and owner sessions, so only its owner may
47
+ // read it. SQLite gives the WAL and shared-memory files the main file's
48
+ // mode, so the mode is set before SQLite opens it.
49
+ closeSync(openSync(options.dbPath, 'a', 0o600));
50
+ chmodSync(options.dbPath, 0o600);
51
+
52
+ const database = options.openDatabase(options.dbPath);
53
+
54
+ const db = new Kysely<MCPAuthSchema>({ dialect: database.dialect });
55
+
56
+ const providerOptions: OAuthOptions<Scope[]> = {
57
+ loginPage: PUBLIC_DOOR_PATHS.browser.login,
58
+ consentPage: PUBLIC_DOOR_PATHS.browser.consent,
59
+ scopes: [...grantScopes, 'offline_access'],
60
+ disableJwtPlugin: true,
61
+ allowDynamicClientRegistration: false,
62
+ grantTypes: ['authorization_code', 'refresh_token'],
63
+
64
+ // boxxy serves one resource, so every client may request it.
65
+ enforcePerClientResources: false,
66
+ accessTokenExpiresIn: 3600,
67
+ codeExpiresIn: APPROVAL_WINDOW_SECONDS,
68
+ refreshTokenReuseInterval: options.refreshReuseSeconds ?? 0,
69
+ storeTokens: { hash: (token: string) => Promise.resolve(deriveTokenHash(token)) },
70
+ };
71
+
72
+ const provider =
73
+ options.origin === null
74
+ ? oauthProvider(providerOptions)
75
+ : mcp({ ...providerOptions, resource: `${options.origin}${PUBLIC_DOOR_PATHS.machine.mcp}` });
76
+
77
+ const baseURL = options.origin ?? 'http://127.0.0.1';
78
+
79
+ const auth = betterAuth({
80
+ baseURL,
81
+ basePath: '/',
82
+ secret: randomBytes(32).toString('base64url'),
83
+ database: { db, type: 'sqlite' },
84
+ telemetry: { enabled: false },
85
+ logger: { level: 'error' },
86
+ rateLimit: { enabled: false },
87
+
88
+ // Every open runs the migrations itself, so the startup schema check
89
+ // would only report the tables a fresh database has yet to get.
90
+ advanced: { database: { validateSchema: false } },
91
+ trustedOrigins: [baseURL],
92
+ session: { expiresIn: OWNER_SESSION_SECONDS, disableSessionRefresh: true },
93
+
94
+ // The provider's own endpoint types do not satisfy the plugin type under
95
+ // exactOptionalPropertyTypes, so boxxy reaches them over HTTP-shaped requests.
96
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- better-auth's plugin type fails its own type under exactOptionalPropertyTypes
97
+ plugins: [provider as BetterAuthPlugin, createOwnerPlugin()],
98
+ });
99
+
100
+ const migrations = await getMigrations(auth.options);
101
+
102
+ await migrations.runMigrations();
103
+
104
+ await sql`create table if not exists boxxy_grant_use (grant_id text primary key not null, last_used_at text not null)`.execute(
105
+ db,
106
+ );
107
+
108
+ await sql`create table if not exists boxxy_passkey (id text primary key not null, public_key text not null, counter integer not null, transports text not null, created_at text not null, last_used_at text)`.execute(
109
+ db,
110
+ );
111
+
112
+ await sql`create table if not exists boxxy_enrollment_token (token_hash text primary key not null, created_at text not null, expires_at text not null)`.execute(
113
+ db,
114
+ );
115
+
116
+ // A resource served under an earlier public URL stays a valid token target
117
+ // until its row goes.
118
+ if (options.origin !== null) {
119
+ await db
120
+ .deleteFrom('oauthResource')
121
+ .where('identifier', '!=', `${options.origin}${PUBLIC_DOOR_PATHS.machine.mcp}`)
122
+ .execute();
123
+ }
124
+
125
+ // better-auth starts its context when it is built, and that start queries
126
+ // the database, so a close before it settles destroys the driver under it.
127
+ await auth.$context;
128
+
129
+ return {
130
+ auth,
131
+ db,
132
+ async close(): Promise<void> {
133
+ await db.destroy();
134
+
135
+ database.close();
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,50 @@
1
+ // Copied from atc: packages/mcp/src/auth/passkey-script.ts at commit 4fe647cf.
2
+
3
+ import { createHash } from 'node:crypto';
4
+
5
+ // runs the `create` or `get` ceremony a page's `boxxy-passkey` block holds and posts
6
+ // the credential's JSON with the form; a prompt that ends without one leaves the
7
+ // form unsent and the button ready again.
8
+ const SOURCE = `(() => {
9
+ const data = document.getElementById('boxxy-passkey');
10
+ const form = document.getElementById('boxxy-passkey-form');
11
+ const button = document.getElementById('boxxy-passkey-start');
12
+ const status = document.getElementById('boxxy-passkey-status');
13
+ if (data === null || form === null || button === null || status === null) return;
14
+ const spec = JSON.parse(data.textContent);
15
+ const toBytes = (value) => {
16
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
17
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
18
+ return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
19
+ };
20
+ const toText = (buffer) => {
21
+ let text = '';
22
+ for (const byte of new Uint8Array(buffer)) text += String.fromCharCode(byte);
23
+ return btoa(text).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
24
+ };
25
+ const toDescriptors = (list) => (list || []).map((item) => ({ ...item, id: toBytes(item.id) }));
26
+ button.addEventListener('click', async () => {
27
+ button.disabled = true;
28
+ status.textContent = '';
29
+ try {
30
+ const options = spec.options;
31
+ const credential = spec.mode === 'create'
32
+ ? await navigator.credentials.create({ publicKey: { ...options, challenge: toBytes(options.challenge), user: { ...options.user, id: toBytes(options.user.id) }, excludeCredentials: toDescriptors(options.excludeCredentials) } })
33
+ : await navigator.credentials.get({ publicKey: { ...options, challenge: toBytes(options.challenge), allowCredentials: toDescriptors(options.allowCredentials) } });
34
+ const reply = credential.response;
35
+ const response = spec.mode === 'create'
36
+ ? { clientDataJSON: toText(reply.clientDataJSON), attestationObject: toText(reply.attestationObject), transports: typeof reply.getTransports === 'function' ? reply.getTransports() : [] }
37
+ : { clientDataJSON: toText(reply.clientDataJSON), authenticatorData: toText(reply.authenticatorData), signature: toText(reply.signature), userHandle: reply.userHandle ? toText(reply.userHandle) : undefined };
38
+ form.elements.namedItem('credential').value = JSON.stringify({ id: credential.id, rawId: toText(credential.rawId), type: credential.type, response, clientExtensionResults: credential.getClientExtensionResults(), authenticatorAttachment: credential.authenticatorAttachment || undefined });
39
+ form.submit();
40
+ } catch (error) {
41
+ status.textContent = 'The passkey prompt ended without a passkey (' + (error && error.name ? error.name : 'Error') + '). Try again.';
42
+ button.disabled = false;
43
+ }
44
+ });
45
+ })();`;
46
+
47
+ export const PASSKEY_SCRIPT = {
48
+ source: SOURCE,
49
+ policySource: `'sha256-${createHash('sha256').update(SOURCE).digest('base64')}'`,
50
+ } as const;
@@ -0,0 +1,17 @@
1
+ // Copied from atc: packages/mcp/src/auth/read-client-name.ts at commit 4fe647cf.
2
+
3
+ import type { Kysely } from 'kysely';
4
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
5
+
6
+ export async function readClientName(
7
+ db: Kysely<MCPAuthSchema>,
8
+ clientID: string,
9
+ ): Promise<string | null> {
10
+ const row = await db
11
+ .selectFrom('oauthClient')
12
+ .select('name')
13
+ .where('clientId', '=', clientID)
14
+ .executeTakeFirst();
15
+
16
+ return row?.name ?? null;
17
+ }
@@ -0,0 +1,27 @@
1
+ // Copied from atc: packages/mcp/src/auth/read-client-views.ts at commit 4fe647cf.
2
+
3
+ import type { Kysely } from 'kysely';
4
+ import { parseJSONStrings } from '../utils/parse-json-strings.ts';
5
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
6
+
7
+ export interface ClientView {
8
+ readonly clientID: string;
9
+ readonly name: string;
10
+ readonly redirectURIs: readonly string[];
11
+ readonly createdAt: string | null;
12
+ }
13
+
14
+ export async function readClientViews(db: Kysely<MCPAuthSchema>): Promise<readonly ClientView[]> {
15
+ const rows = await db
16
+ .selectFrom('oauthClient')
17
+ .select(['clientId', 'name', 'redirectUris', 'createdAt'])
18
+ .orderBy('createdAt')
19
+ .execute();
20
+
21
+ return rows.map((row) => ({
22
+ clientID: row.clientId,
23
+ name: row.name ?? '',
24
+ redirectURIs: parseJSONStrings(row.redirectUris) ?? [],
25
+ createdAt: row.createdAt,
26
+ }));
27
+ }
@@ -0,0 +1,20 @@
1
+ // Copied from atc: packages/mcp/src/auth/read-enrollment-token.ts at commit 4fe647cf.
2
+
3
+ import type { Kysely } from 'kysely';
4
+ import { deriveTokenHash } from './derive-token-hash.ts';
5
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
6
+
7
+ export async function readEnrollmentToken(
8
+ db: Kysely<MCPAuthSchema>,
9
+ token: string,
10
+ now: number,
11
+ ): Promise<string | null> {
12
+ const row = await db
13
+ .selectFrom('boxxy_enrollment_token')
14
+ .select('token_hash')
15
+ .where('token_hash', '=', deriveTokenHash(token))
16
+ .where('expires_at', '>', new Date(now).toISOString())
17
+ .executeTakeFirst();
18
+
19
+ return row?.token_hash ?? null;
20
+ }
@@ -0,0 +1,53 @@
1
+ // Copied from atc: packages/mcp/src/auth/read-grant-views.ts at commit 4fe647cf.
2
+
3
+ import { grantScopes } from '@boxxy/contract';
4
+ import type { GrantScope } from '@boxxy/contract';
5
+ import type { Kysely } from 'kysely';
6
+ import { parseJSONStrings } from '../utils/parse-json-strings.ts';
7
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
8
+
9
+ export interface GrantView {
10
+ readonly grantID: string;
11
+ readonly clientID: string;
12
+ readonly clientName: string;
13
+ readonly scopes: readonly GrantScope[];
14
+ readonly createdAt: string;
15
+ readonly lastUsedAt: string | null;
16
+ }
17
+
18
+ export async function readGrantViews(db: Kysely<MCPAuthSchema>): Promise<readonly GrantView[]> {
19
+ const rows = await db
20
+ .selectFrom('oauthRefreshToken')
21
+ .leftJoin('oauthClient', 'oauthClient.clientId', 'oauthRefreshToken.clientId')
22
+ .leftJoin(
23
+ 'boxxy_grant_use',
24
+ 'boxxy_grant_use.grant_id',
25
+ 'oauthRefreshToken.authorizationCodeId',
26
+ )
27
+ .select([
28
+ 'oauthRefreshToken.authorizationCodeId',
29
+ 'oauthRefreshToken.clientId',
30
+ 'oauthRefreshToken.scopes',
31
+ 'oauthRefreshToken.createdAt',
32
+ 'oauthClient.name',
33
+ 'boxxy_grant_use.last_used_at',
34
+ ])
35
+ .where('oauthRefreshToken.revoked', 'is', null)
36
+ .where('oauthRefreshToken.expiresAt', '>', new Date().toISOString())
37
+ .where('oauthRefreshToken.authorizationCodeId', 'is not', null)
38
+ .orderBy('oauthRefreshToken.createdAt')
39
+ .execute();
40
+
41
+ return rows.map((row) => {
42
+ const scopes = parseJSONStrings(row.scopes) ?? [];
43
+
44
+ return {
45
+ grantID: row.authorizationCodeId ?? '',
46
+ clientID: row.clientId,
47
+ clientName: row.name ?? '',
48
+ scopes: grantScopes.filter((scope) => scopes.includes(scope)),
49
+ createdAt: row.createdAt,
50
+ lastUsedAt: row.last_used_at,
51
+ };
52
+ });
53
+ }
@@ -0,0 +1,16 @@
1
+ // Copied from atc: packages/mcp/src/auth/read-owner-session-id.ts at commit 4fe647cf.
2
+
3
+ import type { HTTPServerContext } from './types.ts';
4
+
5
+ export async function readOwnerSessionID(
6
+ ctx: HTTPServerContext,
7
+ cookie: string | null,
8
+ ): Promise<string | null> {
9
+ if (cookie === null || cookie === '') {
10
+ return null;
11
+ }
12
+
13
+ const found = await ctx.store.auth.api.getSession({ headers: new Headers({ cookie }) });
14
+
15
+ return found?.session.id ?? null;
16
+ }
@@ -0,0 +1,74 @@
1
+ // Copied from atc: packages/mcp/src/auth/record-refused-exchange.ts at commit 4fe647cf.
2
+
3
+ import { isRecord } from '../utils/is-record.ts';
4
+ import { normalizeClientName } from './normalize-client-name.ts';
5
+ import { readClientName } from './read-client-name.ts';
6
+ import type { HTTPServerContext, Requester, TokenExchangeReason } from './types.ts';
7
+
8
+ interface RefusedExchange {
9
+ // The refused request's body, read only for its client id.
10
+ readonly contentType: string | null;
11
+ readonly body: string;
12
+ readonly response: Response;
13
+ readonly path: string;
14
+ readonly requester: Requester;
15
+ }
16
+
17
+ // The event holds better-auth's error code from a fixed list, and the
18
+ // client's id and name only for a client boxxy holds. It never holds the
19
+ // code, the verifier, or a token the request carried.
20
+ export async function recordRefusedExchange(
21
+ ctx: Pick<HTTPServerContext, 'store' | 'security'>,
22
+ exchange: RefusedExchange,
23
+ ): Promise<void> {
24
+ const code = await readErrorCode(exchange.response);
25
+
26
+ const reason = isTokenExchangeError(code) ? code : 'other';
27
+ const clientID = findClientID(exchange);
28
+ const storedName = clientID === null ? null : await readClientName(ctx.store.db, clientID);
29
+
30
+ ctx.security.record({
31
+ kind: 'token_exchange',
32
+ outcome: 'rejected',
33
+ reason,
34
+ path: exchange.path,
35
+ requester: exchange.requester,
36
+ ...(clientID === null || storedName === null
37
+ ? {}
38
+ : { client: { id: clientID, name: normalizeClientName(storedName) } }),
39
+ });
40
+ }
41
+
42
+ async function readErrorCode(response: Response): Promise<string | null> {
43
+ try {
44
+ const body: unknown = await response.clone().json();
45
+
46
+ return isRecord(body) && typeof body['error'] === 'string' ? body['error'] : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // A fixed list keeps the reason a value a log rule can match, whatever text
53
+ // a future better-auth puts in its error field.
54
+ const TOKEN_EXCHANGE_ERRORS: ReadonlySet<string> = new Set([
55
+ 'invalid_request',
56
+ 'invalid_client',
57
+ 'invalid_grant',
58
+ 'unauthorized_client',
59
+ 'unsupported_grant_type',
60
+ 'invalid_scope',
61
+ 'invalid_target',
62
+ ]);
63
+
64
+ function isTokenExchangeError(code: string | null): code is TokenExchangeReason {
65
+ return code !== null && TOKEN_EXCHANGE_ERRORS.has(code);
66
+ }
67
+
68
+ function findClientID(exchange: RefusedExchange): string | null {
69
+ if (exchange.contentType?.startsWith('application/x-www-form-urlencoded') !== true) {
70
+ return null;
71
+ }
72
+
73
+ return new URLSearchParams(exchange.body).get('client_id');
74
+ }
@@ -0,0 +1,33 @@
1
+ // Copied from atc: packages/mcp/src/auth/remove-client.ts at commit 4fe647cf.
2
+
3
+ import type { Kysely } from 'kysely';
4
+ import type { MCPAuthSchema } from './mcp-auth-schema.ts';
5
+
6
+ export function removeClient(db: Kysely<MCPAuthSchema>, clientID: string): Promise<boolean> {
7
+ return db.transaction().execute(async (trx) => {
8
+ const grants = await trx
9
+ .selectFrom('oauthRefreshToken')
10
+ .select('authorizationCodeId')
11
+ .where('clientId', '=', clientID)
12
+ .execute();
13
+
14
+ const grantIDs = grants.flatMap((grant) =>
15
+ grant.authorizationCodeId === null ? [] : [grant.authorizationCodeId],
16
+ );
17
+
18
+ if (grantIDs.length > 0) {
19
+ await trx.deleteFrom('boxxy_grant_use').where('grant_id', 'in', grantIDs).execute();
20
+ }
21
+
22
+ await trx.deleteFrom('oauthAccessToken').where('clientId', '=', clientID).execute();
23
+ await trx.deleteFrom('oauthRefreshToken').where('clientId', '=', clientID).execute();
24
+ await trx.deleteFrom('oauthConsent').where('clientId', '=', clientID).execute();
25
+
26
+ const removed = await trx
27
+ .deleteFrom('oauthClient')
28
+ .where('clientId', '=', clientID)
29
+ .executeTakeFirst();
30
+
31
+ return removed.numDeletedRows > 0n;
32
+ });
33
+ }