@boxxy/auth-oauth 0.2.6 → 0.2.8

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 (145) hide show
  1. package/README.md +12 -13
  2. package/dist/auth/create-auth-admin.d.ts +22 -12
  3. package/dist/auth/create-security-event-log.d.ts +1 -1
  4. package/dist/auth/format-security-event.d.ts +10 -2
  5. package/dist/auth/open-auth-database.d.ts +2 -2
  6. package/dist/http/create-public-door.d.ts +5 -1
  7. package/dist/http/{answer-mcp-request.d.ts → types.d.ts} +0 -2
  8. package/dist/index.d.ts +2 -5
  9. package/dist/migrations/grant-rows-migration.d.ts +2 -0
  10. package/dist/migrations/run-auth-migrations.d.ts +2 -0
  11. package/dist/test-utils/authorize-test-client.d.ts +1 -13
  12. package/dist/test-utils/pre-package-schema.d.ts +1 -0
  13. package/dist/test-utils/seed-pre-package-auth.d.ts +21 -0
  14. package/package.json +7 -13
  15. package/src/auth/create-auth-admin.ts +91 -42
  16. package/src/auth/create-security-event-log.ts +1 -3
  17. package/src/auth/format-security-event.ts +11 -11
  18. package/src/auth/open-auth-database.ts +8 -13
  19. package/src/http/create-public-door.ts +113 -29
  20. package/src/http/is-loopback-host.ts +0 -2
  21. package/src/http/normalize-public-url.ts +0 -2
  22. package/src/http/types.ts +11 -0
  23. package/src/index.ts +9 -5
  24. package/src/migrations/grant-rows-migration.ts +108 -0
  25. package/src/migrations/run-auth-migrations.ts +53 -0
  26. package/src/test-utils/authorize-test-client.ts +2 -194
  27. package/src/test-utils/build-stub-clock.ts +0 -2
  28. package/src/test-utils/pre-package-schema.ts +36 -0
  29. package/src/test-utils/register-test-cleanup.ts +0 -2
  30. package/src/test-utils/seed-pre-package-auth.ts +162 -0
  31. package/src/test-utils/setup-temp-dir.ts +0 -2
  32. package/src/utils/types.ts +0 -2
  33. package/dist/auth/answer-authorize-request.d.ts +0 -2
  34. package/dist/auth/answer-consent-request.d.ts +0 -10
  35. package/dist/auth/answer-enroll-request.d.ts +0 -9
  36. package/dist/auth/answer-login-request.d.ts +0 -9
  37. package/dist/auth/approval-window-seconds.d.ts +0 -1
  38. package/dist/auth/build-consent-binding.d.ts +0 -1
  39. package/dist/auth/create-approval-state.d.ts +0 -41
  40. package/dist/auth/create-enrollment-state.d.ts +0 -6
  41. package/dist/auth/create-enrollment-token.d.ts +0 -6
  42. package/dist/auth/create-owner-plugin.d.ts +0 -38
  43. package/dist/auth/create-passkey.d.ts +0 -11
  44. package/dist/auth/derive-token-hash.d.ts +0 -1
  45. package/dist/auth/is-allowed-redirect-uri.d.ts +0 -1
  46. package/dist/auth/load-web-authn.d.ts +0 -6
  47. package/dist/auth/mcp-auth-database.d.ts +0 -5
  48. package/dist/auth/mcp-auth-schema.d.ts +0 -54
  49. package/dist/auth/normalize-client-name.d.ts +0 -1
  50. package/dist/auth/open-mcp-auth.d.ts +0 -80
  51. package/dist/auth/passkey-script.d.ts +0 -4
  52. package/dist/auth/read-client-name.d.ts +0 -3
  53. package/dist/auth/read-client-views.d.ts +0 -9
  54. package/dist/auth/read-enrollment-token.d.ts +0 -3
  55. package/dist/auth/read-grant-views.d.ts +0 -12
  56. package/dist/auth/read-owner-session-id.d.ts +0 -2
  57. package/dist/auth/record-refused-exchange.d.ts +0 -10
  58. package/dist/auth/remove-client.d.ts +0 -3
  59. package/dist/auth/remove-owner-session.d.ts +0 -3
  60. package/dist/auth/render-consent-page.d.ts +0 -9
  61. package/dist/auth/render-enroll-page.d.ts +0 -10
  62. package/dist/auth/render-login-page.d.ts +0 -9
  63. package/dist/auth/revoke-grant.d.ts +0 -3
  64. package/dist/auth/to-html-text.d.ts +0 -1
  65. package/dist/auth/to-script-json.d.ts +0 -1
  66. package/dist/auth/types.d.ts +0 -79
  67. package/dist/auth/update-passkey-use.d.ts +0 -10
  68. package/dist/auth/verify-oauth-query.d.ts +0 -1
  69. package/dist/auth/verify-passkey-assertion.d.ts +0 -11
  70. package/dist/auth/verify-passkey-registration.d.ts +0 -14
  71. package/dist/auth/verify-session-passkey.d.ts +0 -8
  72. package/dist/auth/write-grant-use.d.ts +0 -3
  73. package/dist/http/build-page-response.d.ts +0 -5
  74. package/dist/http/create-mcp-http-app.d.ts +0 -92
  75. package/dist/http/pick-error-message.d.ts +0 -1
  76. package/dist/http/public-door-paths.d.ts +0 -17
  77. package/dist/http/read-token-access.d.ts +0 -8
  78. package/dist/http/verify-access-token.d.ts +0 -2
  79. package/dist/test-utils/build-mock-new-passkey.d.ts +0 -2
  80. package/dist/test-utils/build-passkey-seeder.d.ts +0 -5
  81. package/dist/test-utils/build-seeder.d.ts +0 -5
  82. package/dist/test-utils/build-stub-authenticator.d.ts +0 -48
  83. package/dist/test-utils/load-web-authn-early.d.ts +0 -1
  84. package/dist/test-utils/seed-passkey.d.ts +0 -4
  85. package/dist/test-utils/start-stub-telemetry-collector.d.ts +0 -5
  86. package/dist/test-utils/types.d.ts +0 -7
  87. package/dist/utils/is-record.d.ts +0 -1
  88. package/dist/utils/parse-json-strings.d.ts +0 -1
  89. package/src/auth/answer-authorize-request.ts +0 -137
  90. package/src/auth/answer-consent-request.ts +0 -189
  91. package/src/auth/answer-enroll-request.ts +0 -190
  92. package/src/auth/answer-login-request.ts +0 -256
  93. package/src/auth/approval-window-seconds.ts +0 -5
  94. package/src/auth/build-consent-binding.ts +0 -13
  95. package/src/auth/create-approval-state.ts +0 -221
  96. package/src/auth/create-enrollment-state.ts +0 -56
  97. package/src/auth/create-enrollment-token.ts +0 -28
  98. package/src/auth/create-owner-plugin.ts +0 -113
  99. package/src/auth/create-passkey.ts +0 -48
  100. package/src/auth/derive-token-hash.ts +0 -7
  101. package/src/auth/is-allowed-redirect-uri.ts +0 -19
  102. package/src/auth/load-web-authn.ts +0 -19
  103. package/src/auth/mcp-auth-database.ts +0 -10
  104. package/src/auth/mcp-auth-schema.ts +0 -56
  105. package/src/auth/normalize-client-name.ts +0 -22
  106. package/src/auth/open-mcp-auth.ts +0 -138
  107. package/src/auth/passkey-script.ts +0 -50
  108. package/src/auth/read-client-name.ts +0 -17
  109. package/src/auth/read-client-views.ts +0 -27
  110. package/src/auth/read-enrollment-token.ts +0 -20
  111. package/src/auth/read-grant-views.ts +0 -53
  112. package/src/auth/read-owner-session-id.ts +0 -16
  113. package/src/auth/record-refused-exchange.ts +0 -74
  114. package/src/auth/remove-client.ts +0 -33
  115. package/src/auth/remove-owner-session.ts +0 -11
  116. package/src/auth/render-consent-page.ts +0 -61
  117. package/src/auth/render-enroll-page.ts +0 -59
  118. package/src/auth/render-login-page.ts +0 -58
  119. package/src/auth/revoke-grant.ts +0 -25
  120. package/src/auth/to-html-text.ts +0 -10
  121. package/src/auth/to-script-json.ts +0 -8
  122. package/src/auth/types.ts +0 -115
  123. package/src/auth/update-passkey-use.ts +0 -31
  124. package/src/auth/verify-oauth-query.ts +0 -41
  125. package/src/auth/verify-passkey-assertion.ts +0 -124
  126. package/src/auth/verify-passkey-registration.ts +0 -118
  127. package/src/auth/verify-session-passkey.ts +0 -47
  128. package/src/auth/write-grant-use.ts +0 -18
  129. package/src/http/answer-mcp-request.ts +0 -77
  130. package/src/http/build-page-response.ts +0 -26
  131. package/src/http/create-mcp-http-app.ts +0 -340
  132. package/src/http/pick-error-message.ts +0 -21
  133. package/src/http/public-door-paths.ts +0 -21
  134. package/src/http/read-token-access.ts +0 -44
  135. package/src/http/verify-access-token.ts +0 -46
  136. package/src/test-utils/build-mock-new-passkey.ts +0 -15
  137. package/src/test-utils/build-passkey-seeder.ts +0 -11
  138. package/src/test-utils/build-seeder.ts +0 -10
  139. package/src/test-utils/build-stub-authenticator.ts +0 -244
  140. package/src/test-utils/load-web-authn-early.ts +0 -8
  141. package/src/test-utils/seed-passkey.ts +0 -32
  142. package/src/test-utils/start-stub-telemetry-collector.ts +0 -27
  143. package/src/test-utils/types.ts +0 -11
  144. package/src/utils/is-record.ts +0 -5
  145. package/src/utils/parse-json-strings.ts +0 -15
@@ -1,138 +0,0 @@
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
- }
@@ -1,50 +0,0 @@
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;
@@ -1,17 +0,0 @@
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
- }
@@ -1,27 +0,0 @@
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
- }
@@ -1,20 +0,0 @@
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
- }
@@ -1,53 +0,0 @@
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
- }
@@ -1,16 +0,0 @@
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
- }
@@ -1,74 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
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
- }
@@ -1,11 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/remove-owner-session.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 removeOwnerSession(
7
- db: Kysely<MCPAuthSchema>,
8
- sessionID: string,
9
- ): Promise<void> {
10
- await db.deleteFrom('session').where('id', '=', sessionID).execute();
11
- }
@@ -1,61 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/render-consent-page.ts at commit 4fe647cf.
2
-
3
- import type { GrantScope } from '@boxxy/contract';
4
- import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
5
- import { toHTMLText } from './to-html-text.ts';
6
-
7
- interface ConsentPageView {
8
- // The signed authorization query the page posts back.
9
- readonly oauthQuery: string;
10
- readonly clientName: string;
11
- readonly redirectURI: string;
12
- readonly scopes: readonly GrantScope[];
13
- }
14
-
15
- const SCOPE_LABELS: Readonly<Record<GrantScope, string>> = {
16
- read: 'Read items, their cost and times, and the runner status',
17
- write:
18
- 'File and change items, add notes, retry, wake and close them, and pause or resume the runner',
19
- };
20
-
21
- // Only reading starts granted; every scope that lets the client act starts
22
- // withheld.
23
- const GRANTED_BY_DEFAULT: ReadonlySet<GrantScope> = new Set<GrantScope>(['read']);
24
-
25
- export function renderConsentPage(view: ConsentPageView): string {
26
- const scopeRows = view.scopes
27
- .map((scope) => {
28
- const checked = GRANTED_BY_DEFAULT.has(scope) ? ' checked' : '';
29
-
30
- return `<label><input type="checkbox" name="scope" value="${scope}"${checked}> ${toHTMLText(SCOPE_LABELS[scope])}</label>`;
31
- })
32
- .join('\n');
33
-
34
- return `<!doctype html>
35
- <html lang="en">
36
- <head>
37
- <meta charset="utf-8">
38
- <meta name="viewport" content="width=device-width, initial-scale=1">
39
- <title>Choose what to allow</title>
40
- <style>
41
- body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
42
- label { display: block; margin: 0.25rem 0; }
43
- code { word-break: break-all; }
44
- button { font: inherit; margin-right: 0.5rem; }
45
- </style>
46
- </head>
47
- <body>
48
- <h1>Choose what to allow</h1>
49
- <p><strong>${toHTMLText(view.clientName)}</strong> returns to <code>${toHTMLText(view.redirectURI)}</code> with the access you allow here.</p>
50
- <form method="post" action="${PUBLIC_DOOR_PATHS.browser.consent}">
51
- <input type="hidden" name="oauth_query" value="${toHTMLText(view.oauthQuery)}">
52
- <fieldset>
53
- <legend>Allow it to</legend>
54
- ${scopeRows}
55
- </fieldset>
56
- <p><button type="submit" name="decision" value="approve">Allow</button><button type="submit" name="decision" value="deny">Deny</button></p>
57
- </form>
58
- </body>
59
- </html>
60
- `;
61
- }
@@ -1,59 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/render-enroll-page.ts at commit 4fe647cf.
2
-
3
- import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
4
- import { PASSKEY_SCRIPT } from './passkey-script.ts';
5
- import { toHTMLText } from './to-html-text.ts';
6
- import { toScriptJSON } from './to-script-json.ts';
7
-
8
- interface EnrollPageView {
9
- // The enrollment token the owner typed, once it checked out, with the
10
- // WebAuthn creation options for its challenge; null asks for the token.
11
- readonly ceremony: { readonly token: string; readonly options: unknown } | null;
12
- readonly error: string | null;
13
-
14
- // The token a link carried, to fill in the form; absent when the owner types it
15
- readonly prefill?: string;
16
- }
17
-
18
- export function renderEnrollPage(view: EnrollPageView): string {
19
- const error = view.error === null ? '' : `<p class="error">${toHTMLText(view.error)}</p>`;
20
-
21
- const step =
22
- view.ceremony === null
23
- ? `<p>Type the enrollment token that <code>boxxy auth enroll</code> printed, or open the link it printed.</p>
24
- <form method="post" action="${PUBLIC_DOOR_PATHS.browser.enroll}">
25
- <label>Enrollment token <input name="token" autocomplete="off" autofocus required${view.prefill === undefined ? '' : ` value="${toHTMLText(view.prefill)}"`}></label>
26
- <button type="submit">Continue</button>
27
- </form>`
28
- : `<p>Create a passkey for boxxy. Your device asks you to verify yourself.</p>
29
- <script type="application/json" id="boxxy-passkey">${toScriptJSON({ mode: 'create', options: view.ceremony.options })}</script>
30
- <form method="post" action="${PUBLIC_DOOR_PATHS.browser.enroll}" id="boxxy-passkey-form">
31
- <input type="hidden" name="token" value="${toHTMLText(view.ceremony.token)}">
32
- <input type="hidden" name="credential" value="">
33
- <button type="button" id="boxxy-passkey-start" autofocus>Create a passkey</button>
34
- </form>
35
- <p id="boxxy-passkey-status" class="error" role="status"></p>
36
- <script>${PASSKEY_SCRIPT.source}</script>`;
37
-
38
- return `<!doctype html>
39
- <html lang="en">
40
- <head>
41
- <meta charset="utf-8">
42
- <meta name="viewport" content="width=device-width, initial-scale=1">
43
- <title>Enroll a passkey for boxxy</title>
44
- <style>
45
- body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
46
- input[name="token"] { font: inherit; font-family: ui-monospace, monospace; padding: 0.25rem; width: 100%; }
47
- .error { color: #b00020; }
48
- code { word-break: break-all; }
49
- button { font: inherit; }
50
- </style>
51
- </head>
52
- <body>
53
- <h1>Enroll a passkey for boxxy</h1>
54
- ${error}
55
- ${step}
56
- </body>
57
- </html>
58
- `;
59
- }
@@ -1,58 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/render-login-page.ts at commit 4fe647cf.
2
-
3
- import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
4
- import { PASSKEY_SCRIPT } from './passkey-script.ts';
5
- import { toHTMLText } from './to-html-text.ts';
6
- import { toScriptJSON } from './to-script-json.ts';
7
-
8
- interface LoginPageView {
9
- // The signed authorization query the page posts back.
10
- readonly oauthQuery: string;
11
- readonly clientName: string;
12
- readonly redirectURI: string;
13
-
14
- // The WebAuthn request options for this page's challenge, or null when
15
- // no passkey is enrolled, so there is nothing to sign in with.
16
- readonly options: unknown;
17
- readonly error: string | null;
18
- }
19
-
20
- export function renderLoginPage(view: LoginPageView): string {
21
- const error = view.error === null ? '' : `<p class="error">${toHTMLText(view.error)}</p>`;
22
-
23
- const signIn =
24
- view.options === null
25
- ? `<p class="error">No passkey is enrolled, so nobody can sign in. On the host that runs boxxy, run <code>boxxy auth enroll</code>, then follow what it prints.</p>`
26
- : `<script type="application/json" id="boxxy-passkey">${toScriptJSON({ mode: 'get', options: view.options })}</script>
27
- <form method="post" action="${PUBLIC_DOOR_PATHS.browser.login}" id="boxxy-passkey-form">
28
- <input type="hidden" name="oauth_query" value="${toHTMLText(view.oauthQuery)}">
29
- <input type="hidden" name="credential" value="">
30
- <button type="button" id="boxxy-passkey-start" autofocus>Sign in with a passkey</button>
31
- </form>
32
- <p id="boxxy-passkey-status" class="error" role="status"></p>
33
- <script>${PASSKEY_SCRIPT.source}</script>`;
34
-
35
- return `<!doctype html>
36
- <html lang="en">
37
- <head>
38
- <meta charset="utf-8">
39
- <meta name="viewport" content="width=device-width, initial-scale=1">
40
- <title>Sign in to boxxy</title>
41
- <style>
42
- body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
43
- .error { color: #b00020; }
44
- code { word-break: break-all; }
45
- button { font: inherit; }
46
- </style>
47
- </head>
48
- <body>
49
- <h1>Sign in to boxxy</h1>
50
- <p><strong>${toHTMLText(view.clientName)}</strong> wants access to your boxxy work.</p>
51
- <p>After approval it returns to <code>${toHTMLText(view.redirectURI)}</code>.</p>
52
- <p>Sign in with your passkey. You choose what to allow on the next page.</p>
53
- ${error}
54
- ${signIn}
55
- </body>
56
- </html>
57
- `;
58
- }
@@ -1,25 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/revoke-grant.ts at commit 4fe647cf.
2
-
3
- import type { Kysely } from 'kysely';
4
- import type { MCPAuthSchema } from './mcp-auth-schema.ts';
5
-
6
- export function revokeGrant(db: Kysely<MCPAuthSchema>, grantID: string): Promise<boolean> {
7
- return db.transaction().execute(async (trx) => {
8
- const grant = await trx
9
- .selectFrom('oauthRefreshToken')
10
- .select('clientId')
11
- .where('authorizationCodeId', '=', grantID)
12
- .executeTakeFirst();
13
-
14
- if (grant === undefined) {
15
- return false;
16
- }
17
-
18
- await trx.deleteFrom('oauthAccessToken').where('authorizationCodeId', '=', grantID).execute();
19
- await trx.deleteFrom('oauthRefreshToken').where('authorizationCodeId', '=', grantID).execute();
20
- await trx.deleteFrom('oauthConsent').where('clientId', '=', grant.clientId).execute();
21
- await trx.deleteFrom('boxxy_grant_use').where('grant_id', '=', grantID).execute();
22
-
23
- return true;
24
- });
25
- }
@@ -1,10 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/to-html-text.ts at commit 4fe647cf.
2
-
3
- export function toHTMLText(text: string): string {
4
- return text
5
- .replaceAll('&', '&amp;')
6
- .replaceAll('<', '&lt;')
7
- .replaceAll('>', '&gt;')
8
- .replaceAll('"', '&quot;')
9
- .replaceAll("'", '&#39;');
10
- }