@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,22 +1,20 @@
1
- // Adapted from atc: packages/mcp/src/http/start-mcp-http-server.ts at commit 4fe647cf.
2
-
1
+ import type { Database } from 'bun:sqlite';
3
2
  import { mkdirSync } from 'node:fs';
4
3
  import { dirname } from 'node:path';
5
- import { APPROVAL_WINDOW_SECONDS } from '../auth/approval-window-seconds.ts';
6
- import { createApprovalState } from '../auth/create-approval-state.ts';
4
+ import type { GrantScope } from '@boxxy/contract';
5
+ import { grantScopes } from '@boxxy/contract';
6
+ import { DEFAULT_PATHS, openMCPAuth } from '@zgeoff/mcp-auth';
7
+ import type { MCPAuth, Requester } from '@zgeoff/mcp-auth';
8
+ import { createMCPHTTPApp } from '@zgeoff/mcp-auth/elysia';
7
9
  import { createAuthAdmin } from '../auth/create-auth-admin.ts';
8
10
  import type { AuthAdmin } from '../auth/create-auth-admin.ts';
9
- import { createEnrollmentState } from '../auth/create-enrollment-state.ts';
10
11
  import { createSecurityEventLog } from '../auth/create-security-event-log.ts';
11
12
  import { openAuthDatabase } from '../auth/open-auth-database.ts';
12
- import { openMCPAuth } from '../auth/open-mcp-auth.ts';
13
- import type { HTTPServerContext, Requester } from '../auth/types.ts';
13
+ import { runAuthMigrations } from '../migrations/run-auth-migrations.ts';
14
14
  import type { AuthClock } from '../utils/types.ts';
15
- import type { McpServe } from './answer-mcp-request.ts';
16
- import { createMCPHTTPApp } from './create-mcp-http-app.ts';
17
15
  import { isLoopbackHost } from './is-loopback-host.ts';
18
16
  import { normalizePublicURL } from './normalize-public-url.ts';
19
- import { PUBLIC_DOOR_PATHS } from './public-door-paths.ts';
17
+ import type { McpServe } from './types.ts';
20
18
 
21
19
  export interface PublicDoorOptions {
22
20
  // The origin clients reach the door at: the OAuth issuer.
@@ -33,6 +31,9 @@ export interface PublicDoorOptions {
33
31
  // Receives one JSON line per sign-in outcome or refused token.
34
32
  readonly writeSecurityLine: (line: string) => void;
35
33
 
34
+ // Receives a failed sweep of the expired tokens and the sign-ins that never finished.
35
+ readonly onSweepError: (error: unknown) => void;
36
+
36
37
  // The address of the socket a request came in on, or null.
37
38
  readonly readPeer: (request: Request) => string | null;
38
39
 
@@ -48,6 +49,9 @@ export interface PublicDoor {
48
49
  // The door answers to the loopback address on this port as well as to its public host.
49
50
  readonly addLocalPort: (port: number) => void;
50
51
  readonly admin: AuthAdmin;
52
+
53
+ // The OAuth server itself, for the test helpers that sign a client in.
54
+ readonly auth: MCPAuth<GrantScope>;
51
55
  readonly stop: () => Promise<void>;
52
56
  }
53
57
 
@@ -62,8 +66,12 @@ const systemClock: AuthClock = {
62
66
  },
63
67
  };
64
68
 
65
- // Opens the authorization database and builds the door's routes. Throws on a public URL that is
66
- // not an https origin, or not a loopback address while the door listens beyond loopback.
69
+ // The door serves no path of its own beside the OAuth server's, and no route lives under this.
70
+ const NO_APP_PREFIX = '/.boxxy-none';
71
+
72
+ // Opens and migrates the authorization database and builds the door's routes. Throws on a public
73
+ // URL that is not an https origin, or not a loopback address while the door listens beyond
74
+ // loopback.
67
75
  export async function createPublicDoor(options: PublicDoorOptions): Promise<PublicDoor> {
68
76
  const clock = options.clock ?? systemClock;
69
77
  const origin = normalizePublicURL(options.publicURL);
@@ -104,34 +112,43 @@ export async function createPublicDoor(options: PublicDoorOptions): Promise<Publ
104
112
 
105
113
  mkdirSync(dirname(options.dbPath), { recursive: true });
106
114
 
107
- const store = await openMCPAuth({
108
- dbPath: options.dbPath,
109
- openDatabase: openAuthDatabase,
110
- origin,
115
+ const sqlite = openAuthDatabase(options.dbPath);
116
+
117
+ const auth = await openAuth(sqlite, origin, clock, security).catch((error: unknown) => {
118
+ sqlite.close();
119
+ throw error;
111
120
  });
112
121
 
113
122
  const hosts = new Set<string>([new URL(origin).host]);
114
123
  const origins = new Set<string>([origin]);
115
124
 
116
- const context: HTTPServerContext = {
117
- origin,
118
- resource: `${origin}${PUBLIC_DOOR_PATHS.machine.mcp}`,
119
- store,
120
- approvals: createApprovalState(APPROVAL_WINDOW_SECONDS * 1000, () => clock.now()),
121
- enrollments: createEnrollmentState(() => clock.now()),
122
- security,
123
- clock,
124
- };
125
+ const metadataURL = `${origin}${DEFAULT_PATHS.machine.mcpResourceMetadata}`;
125
126
 
126
127
  const app = createMCPHTTPApp({
128
+ auth,
127
129
  hosts,
128
130
  origins,
129
- readContext: () => context,
130
131
  readRequester,
131
- serveMCP: options.serveMCP,
132
132
  security,
133
+ mcp: {
134
+ // The adapter answers only a request whose bearer token verified, so the header holds one.
135
+ // oxlint-disable-next-line zgeoff/function-verb -- the adapter's option names this method fetch
136
+ fetch: (request, access) =>
137
+ options.serveMCP(request, {
138
+ token:
139
+ /^Bearer (?<token>\S+)$/iu.exec(request.headers.get('authorization') ?? '')?.groups?.[
140
+ 'token'
141
+ ] ?? '',
142
+ clientId: access.clientID,
143
+ scopes: [...access.scopes],
144
+ resourceMetadataUrl: metadataURL,
145
+ }),
146
+ },
147
+ besidePrefix: NO_APP_PREFIX,
133
148
  });
134
149
 
150
+ const sweep = startSweep(auth, clock, options.onSweepError);
151
+
135
152
  return {
136
153
  answerRequest: (request) => app.handle(request),
137
154
  addLocalPort: (port) => {
@@ -140,11 +157,78 @@ export async function createPublicDoor(options: PublicDoorOptions): Promise<Publ
140
157
  origins.add(`http://127.0.0.1:${port}`);
141
158
  origins.add(`http://localhost:${port}`);
142
159
  },
143
- admin: createAuthAdmin({ store, origin, clock }),
160
+ admin: createAuthAdmin({ auth, origin }),
161
+ auth,
144
162
  stop: async () => {
145
- await store.close();
163
+ sweep.stop();
146
164
 
165
+ await auth.close();
166
+
167
+ sqlite.close();
147
168
  security.drain();
148
169
  },
149
170
  };
150
171
  }
172
+
173
+ async function openAuth(
174
+ sqlite: Database,
175
+ origin: string,
176
+ clock: AuthClock,
177
+ security: ReturnType<typeof createSecurityEventLog>,
178
+ ): Promise<MCPAuth<GrantScope>> {
179
+ await runAuthMigrations(sqlite);
180
+
181
+ return openMCPAuth({
182
+ database: sqlite,
183
+ origin,
184
+ productName: 'boxxy',
185
+ scopes: grantScopes,
186
+ paths: DEFAULT_PATHS,
187
+ tables: { passkey: 'boxxy_passkey', enrollmentToken: 'boxxy_enrollment_token' },
188
+ clock,
189
+ security,
190
+
191
+ // Each MCP request builds its own server and holds no stream open, so a revoke has nothing to
192
+ // end beyond the tokens the package deletes.
193
+ onGrantRevoked: () => {},
194
+ });
195
+ }
196
+
197
+ // Each passkey login makes a grant row, which stays until a sweep finds it unfinished an hour on.
198
+ const SWEEP_MS = 15 * 60_000;
199
+
200
+ function startSweep(
201
+ auth: MCPAuth<GrantScope>,
202
+ clock: AuthClock,
203
+ onError: (error: unknown) => void,
204
+ ): { readonly stop: () => void } {
205
+ let cancel: (() => void) | null = null;
206
+ let isStopped = false;
207
+
208
+ const runSweep = async (): Promise<void> => {
209
+ try {
210
+ await auth.removeExpired();
211
+ } catch (error) {
212
+ onError(error);
213
+ }
214
+
215
+ if (!isStopped) {
216
+ scheduleNext();
217
+ }
218
+ };
219
+
220
+ const scheduleNext = (): void => {
221
+ cancel = clock.schedule(() => {
222
+ void runSweep();
223
+ }, SWEEP_MS);
224
+ };
225
+
226
+ scheduleNext();
227
+
228
+ return {
229
+ stop: () => {
230
+ isStopped = true;
231
+ cancel?.();
232
+ },
233
+ };
234
+ }
@@ -1,5 +1,3 @@
1
- // Copied from atc: packages/mcp/src/http/is-loopback-host.ts at commit 4fe647cf.
2
-
3
1
  export function isLoopbackHost(host: string): boolean {
4
2
  return (
5
3
  host === 'localhost' ||
@@ -1,5 +1,3 @@
1
- // Copied from atc: packages/mcp/src/http/normalize-public-url.ts at commit 4fe647cf.
2
-
3
1
  import { isLoopbackHost } from './is-loopback-host.ts';
4
2
 
5
3
  export function normalizePublicURL(raw: string): string {
@@ -0,0 +1,11 @@
1
+ // What the MCP transport reads of a verified token: the scopes it holds, and the resource metadata
2
+ // that a scope challenge points at.
3
+ export interface McpAuthInfo {
4
+ readonly token: string;
5
+ readonly clientId: string;
6
+ readonly scopes: string[];
7
+ readonly resourceMetadataUrl: string;
8
+ }
9
+
10
+ // Serves one MCP request for a token that verified.
11
+ export type McpServe = (request: Request, authInfo: McpAuthInfo) => Promise<Response>;
package/src/index.ts CHANGED
@@ -1,8 +1,12 @@
1
- export type { AddClientAnswer, AuthAdmin, EnrollmentLink } from './auth/create-auth-admin.ts';
2
- export type { McpAuthInfo, McpServe } from './http/answer-mcp-request.ts';
1
+ export type {
2
+ AddClientAnswer,
3
+ AuthAdmin,
4
+ AuthGrantView,
5
+ EnrollmentLink,
6
+ RemoveClientAnswer,
7
+ } from './auth/create-auth-admin.ts';
8
+
9
+ export type { McpAuthInfo, McpServe } from './http/types.ts';
3
10
  export type { PublicDoor, PublicDoorOptions } from './http/create-public-door.ts';
4
11
  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
12
  export { isLoopbackHost } from './http/is-loopback-host.ts';
@@ -0,0 +1,108 @@
1
+ import { sql } from 'kysely';
2
+ import type { Kysely } from 'kysely';
3
+ import type { Migration } from 'kysely/migration';
4
+
5
+ // The time better-auth writes, as ISO 8601 text in UTC with milliseconds, so the text compares in
6
+ // time order.
7
+ const now = sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
8
+
9
+ // better-auth 1.7.15 made the token and session tables with `userId` referencing `user(id)` on
10
+ // delete cascade, so only `user` needs a rebuild.
11
+ const statements = [
12
+ sql`CREATE TEMP TABLE boxxy_grant_token AS
13
+ SELECT authorizationCodeId AS grantId, clientId, scopes, createdAt, expiresAt, revoked
14
+ FROM oauthRefreshToken WHERE authorizationCodeId IS NOT NULL
15
+ UNION ALL
16
+ SELECT authorizationCodeId, clientId, scopes, createdAt, expiresAt, revoked
17
+ FROM oauthAccessToken WHERE authorizationCodeId IS NOT NULL`,
18
+
19
+ // SQLite cannot add a NOT NULL column with no default, so the table is made anew, in the shape
20
+ // the package's better-auth gives a new database.
21
+ sql`CREATE TABLE boxxy_grant_user (
22
+ "id" text not null primary key,
23
+ "name" text not null,
24
+ "email" text not null unique,
25
+ "emailVerified" integer not null,
26
+ "image" text,
27
+ "createdAt" date not null,
28
+ "updatedAt" date not null,
29
+ "clientId" text not null,
30
+ "scopes" text,
31
+ "approvedBy" text not null,
32
+ "limits" text,
33
+ "lastUsedAt" date
34
+ )`,
35
+
36
+ // With max(), SQLite takes the other bare columns from the row that holds the maximum, so each
37
+ // grant takes the scopes of its newest live token.
38
+ sql`INSERT INTO boxxy_grant_user
39
+ (id, name, email, emailVerified, image, createdAt, updatedAt, clientId, scopes, approvedBy, limits, lastUsedAt)
40
+ SELECT
41
+ live.grantId,
42
+ coalesce(client.name, live.clientId),
43
+ live.grantId || '@grant.invalid',
44
+ 1,
45
+ NULL,
46
+ first.createdAt,
47
+ first.createdAt,
48
+ live.clientId,
49
+ (SELECT json_group_array(scope.value) FROM json_each(live.scopes) AS scope
50
+ WHERE scope.value <> 'offline_access'),
51
+ 'passkey',
52
+ NULL,
53
+ used.last_used_at
54
+ FROM (
55
+ SELECT grantId, clientId, scopes, max(createdAt) AS newestAt FROM boxxy_grant_token
56
+ WHERE revoked IS NULL AND expiresAt > ${now}
57
+ GROUP BY grantId
58
+ ) AS live
59
+ JOIN (
60
+ SELECT grantId, min(createdAt) AS createdAt FROM boxxy_grant_token GROUP BY grantId
61
+ ) AS first ON first.grantId = live.grantId
62
+ LEFT JOIN oauthClient AS client ON client.clientId = live.clientId
63
+ LEFT JOIN boxxy_grant_use AS used ON used.grant_id = live.grantId`,
64
+
65
+ sql`UPDATE oauthAccessToken SET userId = authorizationCodeId, sessionId = NULL
66
+ WHERE authorizationCodeId IN (SELECT id FROM boxxy_grant_user)`,
67
+ sql`DELETE FROM oauthAccessToken
68
+ WHERE authorizationCodeId IS NULL OR authorizationCodeId NOT IN (SELECT id FROM boxxy_grant_user)`,
69
+ sql`UPDATE oauthRefreshToken SET userId = authorizationCodeId, sessionId = NULL
70
+ WHERE authorizationCodeId IN (SELECT id FROM boxxy_grant_user)`,
71
+ sql`DELETE FROM oauthRefreshToken
72
+ WHERE authorizationCodeId IS NULL OR authorizationCodeId NOT IN (SELECT id FROM boxxy_grant_user)`,
73
+
74
+ // Every session, account and consent belonged to the owner row, and the foreign keys are off, so
75
+ // its delete cascades to none of them.
76
+ sql`DELETE FROM session`,
77
+ sql`DELETE FROM account`,
78
+ sql`DELETE FROM oauthConsent`,
79
+ sql`UPDATE oauthClient SET userId = NULL`,
80
+ sql`DROP TABLE "user"`,
81
+ sql`ALTER TABLE boxxy_grant_user RENAME TO "user"`,
82
+ sql`DROP TABLE boxxy_grant_use`,
83
+ sql`DROP TABLE temp.boxxy_grant_token`,
84
+ ];
85
+
86
+ export const grantRowsMigration: Migration = {
87
+ up: async (db: Kysely<unknown>) => {
88
+ const table =
89
+ await sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user'`.execute(db);
90
+
91
+ // A new database has no tables yet: better-auth makes them when the package opens it.
92
+ if (table.rows.length === 0) {
93
+ return;
94
+ }
95
+
96
+ for (const statement of statements) {
97
+ await statement.execute(db);
98
+ }
99
+
100
+ const broken = await sql`PRAGMA foreign_key_check`.execute(db);
101
+
102
+ if (broken.rows.length > 0) {
103
+ throw new Error(
104
+ `the grant migration left ${broken.rows.length} rows whose foreign key points at no row`,
105
+ );
106
+ }
107
+ },
108
+ };
@@ -0,0 +1,53 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import { buildSQLiteDialect } from '@zgeoff/kysely-bun-sqlite';
3
+ import { Kysely, sql } from 'kysely';
4
+ import { Migrator } from 'kysely/migration';
5
+ import { grantRowsMigration } from './grant-rows-migration.ts';
6
+
7
+ // Kysely's SQLite adapter reports no transactional DDL, so its Migrator opens no transaction; the
8
+ // runner opens one itself, around every migration and its record. SQLite ignores the foreign_keys
9
+ // pragma inside a transaction, so the keys go off before it, since a dropped table runs cascades.
10
+ export async function runAuthMigrations(sqlite: Database): Promise<void> {
11
+ sqlite.run('PRAGMA foreign_keys = OFF;');
12
+
13
+ const db = new Kysely<unknown>({ dialect: buildSQLiteDialect(sqlite) });
14
+
15
+ try {
16
+ await db.connection().execute(async (connection) => {
17
+ await sql`BEGIN IMMEDIATE`.execute(connection);
18
+
19
+ try {
20
+ await runMigrator(connection);
21
+
22
+ await sql`COMMIT`.execute(connection);
23
+ } catch (error) {
24
+ await sql`ROLLBACK`.execute(connection);
25
+
26
+ throw error;
27
+ }
28
+ });
29
+ } finally {
30
+ await db.destroy();
31
+
32
+ sqlite.run('PRAGMA foreign_keys = ON;');
33
+ }
34
+ }
35
+
36
+ // Kysely runs the migrations in the order of their names, and skips each one the file has run.
37
+ const migrations = {
38
+ '0001-grant-rows': grantRowsMigration,
39
+ };
40
+
41
+ async function runMigrator(db: Kysely<unknown>): Promise<void> {
42
+ const migrator = new Migrator({
43
+ db,
44
+ provider: { getMigrations: () => Promise.resolve(migrations) },
45
+ disableTransactions: true,
46
+ });
47
+
48
+ const outcome = await migrator.migrateToLatest();
49
+
50
+ if (outcome.error !== undefined) {
51
+ throw new Error('the auth database migrations failed', { cause: outcome.error });
52
+ }
53
+ }
@@ -1,194 +1,2 @@
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
- }
1
+ // Signs a client in through a door's OAuth server, with a stub passkey: pass it `door.auth`.
2
+ export { authorizeTestClient } from '@zgeoff/mcp-auth/testing';
@@ -1,5 +1,3 @@
1
- // Copied from atc: packages/test-utils/src/build-stub-clock.ts at commit 4fe647cf.
2
-
3
1
  import type { AuthClock } from '../utils/types.ts';
4
2
 
5
3
  interface StubTimer {
@@ -0,0 +1,36 @@
1
+ // The schema of the auth database before boxxy moved to `@zgeoff/mcp-auth`, as better-auth 1.7.15
2
+ // and boxxy's own tables made it.
3
+ export const PRE_PACKAGE_SCHEMA = [
4
+ 'CREATE TABLE "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null)',
5
+ 'CREATE TABLE "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade)',
6
+ 'CREATE TABLE "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null)',
7
+ 'CREATE TABLE "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date not null, "updatedAt" date not null)',
8
+ 'CREATE TABLE "oauthClient" ("id" text not null primary key, "clientId" text not null unique, "clientSecret" text, "clientDiscoveryId" text, "disabled" integer, "skipConsent" integer, "enableEndSession" integer, "subjectType" text, "scopes" text, "clientCredentialsScopes" text, "userId" text references "user" ("id") on delete cascade, "createdAt" date, "updatedAt" date, "name" text, "uri" text, "icon" text, "contacts" text, "tos" text, "policy" text, "softwareId" text, "softwareVersion" text, "softwareStatement" text, "redirectUris" text not null, "postLogoutRedirectUris" text, "backchannelLogoutUri" text, "backchannelLogoutSessionRequired" integer, "tokenEndpointAuthMethod" text, "applicationType" text, "jwks" text, "jwksUri" text, "grantTypes" text, "responseTypes" text, "requirePKCE" integer, "dpopBoundAccessTokens" integer, "referenceId" text, "metadata" text)',
9
+ 'CREATE TABLE "oauthResource" ("id" text not null primary key, "identifier" text not null unique, "name" text not null, "accessTokenTtl" integer, "refreshTokenTtl" integer, "signingAlgorithm" text, "signingKeyId" text, "allowedScopes" text, "customClaims" text, "dpopBoundAccessTokensRequired" integer, "disabled" integer, "createdAt" date, "updatedAt" date, "policyVersion" integer, "metadata" text)',
10
+ 'CREATE TABLE "oauthClientResource" ("id" text not null primary key, "clientId" text not null references "oauthClient" ("clientId") on delete cascade, "resourceId" text not null references "oauthResource" ("identifier") on delete cascade, "metadata" text, "createdAt" date)',
11
+ 'CREATE TABLE "oauthRefreshToken" ("id" text not null primary key, "token" text not null unique, "clientId" text not null references "oauthClient" ("clientId") on delete cascade, "sessionId" text references "session" ("id") on delete set null, "userId" text not null references "user" ("id") on delete cascade, "referenceId" text, "authorizationCodeId" text, "resources" text, "requestedUserInfoClaims" text, "expiresAt" date not null, "createdAt" date not null, "revoked" date, "rotatedAt" date, "rotationReplayResponse" text, "rotationReplayExpiresAt" date, "authTime" date, "confirmation" text, "scopes" text not null)',
12
+ 'CREATE TABLE "oauthAccessToken" ("id" text not null primary key, "token" text not null unique, "clientId" text not null references "oauthClient" ("clientId") on delete cascade, "sessionId" text references "session" ("id") on delete set null, "userId" text references "user" ("id") on delete cascade, "referenceId" text, "authorizationCodeId" text, "resources" text, "requestedUserInfoClaims" text, "refreshId" text references "oauthRefreshToken" ("id") on delete cascade, "expiresAt" date not null, "createdAt" date not null, "revoked" date, "confirmation" text, "scopes" text not null)',
13
+ 'CREATE TABLE "oauthConsent" ("id" text not null primary key, "clientId" text not null references "oauthClient" ("clientId") on delete cascade, "userId" text references "user" ("id") on delete cascade, "referenceId" text, "resources" text, "requestedUserInfoClaims" text, "scopes" text not null, "createdAt" date not null, "updatedAt" date not null)',
14
+ 'CREATE TABLE "oauthClientAssertion" ("id" text not null primary key, "expiresAt" date not null)',
15
+ 'CREATE INDEX "session_userId_idx" on "session" ("userId")',
16
+ 'CREATE INDEX "account_userId_idx" on "account" ("userId")',
17
+ 'CREATE INDEX "verification_identifier_idx" on "verification" ("identifier")',
18
+ 'CREATE INDEX "oauthClient_userId_idx" on "oauthClient" ("userId")',
19
+ 'CREATE INDEX "oauthClientResource_clientId_idx" on "oauthClientResource" ("clientId")',
20
+ 'CREATE INDEX "oauthClientResource_resourceId_idx" on "oauthClientResource" ("resourceId")',
21
+ 'CREATE INDEX "oauthRefreshToken_clientId_idx" on "oauthRefreshToken" ("clientId")',
22
+ 'CREATE INDEX "oauthRefreshToken_sessionId_idx" on "oauthRefreshToken" ("sessionId")',
23
+ 'CREATE INDEX "oauthRefreshToken_userId_idx" on "oauthRefreshToken" ("userId")',
24
+ 'CREATE INDEX "oauthRefreshToken_authorizationCodeId_idx" on "oauthRefreshToken" ("authorizationCodeId")',
25
+ 'CREATE INDEX "oauthAccessToken_clientId_idx" on "oauthAccessToken" ("clientId")',
26
+ 'CREATE INDEX "oauthAccessToken_sessionId_idx" on "oauthAccessToken" ("sessionId")',
27
+ 'CREATE INDEX "oauthAccessToken_userId_idx" on "oauthAccessToken" ("userId")',
28
+ 'CREATE INDEX "oauthAccessToken_authorizationCodeId_idx" on "oauthAccessToken" ("authorizationCodeId")',
29
+ 'CREATE INDEX "oauthAccessToken_refreshId_idx" on "oauthAccessToken" ("refreshId")',
30
+ 'CREATE INDEX "oauthConsent_clientId_idx" on "oauthConsent" ("clientId")',
31
+ 'CREATE INDEX "oauthConsent_userId_idx" on "oauthConsent" ("userId")',
32
+ 'CREATE UNIQUE INDEX "oauthClientResource_clientId_resourceId_uidx" on "oauthClientResource" ("clientId", "resourceId")',
33
+ 'CREATE TABLE boxxy_grant_use (grant_id text primary key not null, last_used_at text not null)',
34
+ 'CREATE TABLE 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)',
35
+ 'CREATE TABLE boxxy_enrollment_token (token_hash text primary key not null, created_at text not null, expires_at text not null)',
36
+ ];
@@ -1,5 +1,3 @@
1
- // Copied from atc: packages/test-utils/src/register-test-cleanup.ts at commit 4fe647cf.
2
-
3
1
  import { onTestFinished } from 'bun:test';
4
2
 
5
3
  // The releases the current test registered, in registration order; null