@boxxy/auth-oauth 0.2.5 → 0.2.7

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 (144) hide show
  1. package/README.md +12 -13
  2. package/dist/auth/create-auth-admin.d.ts +25 -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 +109 -41
  16. package/src/auth/create-security-event-log.ts +1 -3
  17. package/src/auth/format-security-event.ts +12 -11
  18. package/src/auth/normalize-client-name.ts +0 -2
  19. package/src/auth/open-auth-database.ts +8 -13
  20. package/src/http/create-public-door.ts +113 -29
  21. package/src/http/is-loopback-host.ts +0 -2
  22. package/src/http/normalize-public-url.ts +0 -2
  23. package/src/http/types.ts +11 -0
  24. package/src/index.ts +9 -5
  25. package/src/migrations/grant-rows-migration.ts +108 -0
  26. package/src/migrations/run-auth-migrations.ts +53 -0
  27. package/src/test-utils/authorize-test-client.ts +2 -194
  28. package/src/test-utils/build-stub-clock.ts +0 -2
  29. package/src/test-utils/pre-package-schema.ts +36 -0
  30. package/src/test-utils/register-test-cleanup.ts +0 -2
  31. package/src/test-utils/seed-pre-package-auth.ts +162 -0
  32. package/src/test-utils/setup-temp-dir.ts +0 -2
  33. package/src/utils/types.ts +0 -2
  34. package/dist/auth/answer-authorize-request.d.ts +0 -2
  35. package/dist/auth/answer-consent-request.d.ts +0 -10
  36. package/dist/auth/answer-enroll-request.d.ts +0 -9
  37. package/dist/auth/answer-login-request.d.ts +0 -9
  38. package/dist/auth/approval-window-seconds.d.ts +0 -1
  39. package/dist/auth/build-consent-binding.d.ts +0 -1
  40. package/dist/auth/create-approval-state.d.ts +0 -41
  41. package/dist/auth/create-enrollment-state.d.ts +0 -6
  42. package/dist/auth/create-enrollment-token.d.ts +0 -6
  43. package/dist/auth/create-owner-plugin.d.ts +0 -38
  44. package/dist/auth/create-passkey.d.ts +0 -11
  45. package/dist/auth/derive-token-hash.d.ts +0 -1
  46. package/dist/auth/is-allowed-redirect-uri.d.ts +0 -1
  47. package/dist/auth/load-web-authn.d.ts +0 -6
  48. package/dist/auth/mcp-auth-database.d.ts +0 -5
  49. package/dist/auth/mcp-auth-schema.d.ts +0 -54
  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/open-mcp-auth.ts +0 -138
  106. package/src/auth/passkey-script.ts +0 -50
  107. package/src/auth/read-client-name.ts +0 -17
  108. package/src/auth/read-client-views.ts +0 -27
  109. package/src/auth/read-enrollment-token.ts +0 -20
  110. package/src/auth/read-grant-views.ts +0 -53
  111. package/src/auth/read-owner-session-id.ts +0 -16
  112. package/src/auth/record-refused-exchange.ts +0 -74
  113. package/src/auth/remove-client.ts +0 -33
  114. package/src/auth/remove-owner-session.ts +0 -11
  115. package/src/auth/render-consent-page.ts +0 -61
  116. package/src/auth/render-enroll-page.ts +0 -59
  117. package/src/auth/render-login-page.ts +0 -58
  118. package/src/auth/revoke-grant.ts +0 -25
  119. package/src/auth/to-html-text.ts +0 -10
  120. package/src/auth/to-script-json.ts +0 -8
  121. package/src/auth/types.ts +0 -115
  122. package/src/auth/update-passkey-use.ts +0 -31
  123. package/src/auth/verify-oauth-query.ts +0 -41
  124. package/src/auth/verify-passkey-assertion.ts +0 -124
  125. package/src/auth/verify-passkey-registration.ts +0 -118
  126. package/src/auth/verify-session-passkey.ts +0 -47
  127. package/src/auth/write-grant-use.ts +0 -18
  128. package/src/http/answer-mcp-request.ts +0 -77
  129. package/src/http/build-page-response.ts +0 -26
  130. package/src/http/create-mcp-http-app.ts +0 -340
  131. package/src/http/pick-error-message.ts +0 -21
  132. package/src/http/public-door-paths.ts +0 -21
  133. package/src/http/read-token-access.ts +0 -44
  134. package/src/http/verify-access-token.ts +0 -46
  135. package/src/test-utils/build-mock-new-passkey.ts +0 -15
  136. package/src/test-utils/build-passkey-seeder.ts +0 -11
  137. package/src/test-utils/build-seeder.ts +0 -10
  138. package/src/test-utils/build-stub-authenticator.ts +0 -244
  139. package/src/test-utils/load-web-authn-early.ts +0 -8
  140. package/src/test-utils/seed-passkey.ts +0 -32
  141. package/src/test-utils/start-stub-telemetry-collector.ts +0 -27
  142. package/src/test-utils/types.ts +0 -11
  143. package/src/utils/is-record.ts +0 -5
  144. package/src/utils/parse-json-strings.ts +0 -15
@@ -1,189 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/answer-consent-request.ts at commit 4fe647cf.
2
-
3
- import { grantScopes } from '@boxxy/contract';
4
- import { buildPageResponse } from '../http/build-page-response.ts';
5
- import { isRecord } from '../utils/is-record.ts';
6
- import { APPROVAL_WINDOW_SECONDS } from './approval-window-seconds.ts';
7
- import { buildConsentBinding } from './build-consent-binding.ts';
8
- import { normalizeClientName } from './normalize-client-name.ts';
9
- import { readClientName } from './read-client-name.ts';
10
- import { readOwnerSessionID } from './read-owner-session-id.ts';
11
- import { removeOwnerSession } from './remove-owner-session.ts';
12
- import { renderConsentPage } from './render-consent-page.ts';
13
- import type { HTTPServerContext, Requester, SecurityOutcome } from './types.ts';
14
- import { verifyOAuthQuery } from './verify-oauth-query.ts';
15
-
16
- interface ConsentRequest {
17
- readonly method: 'GET' | 'POST';
18
- readonly url: URL;
19
- readonly cookie: string | null;
20
- readonly body: string;
21
- readonly requester: Requester;
22
- }
23
-
24
- export async function answerConsentRequest(
25
- ctx: HTTPServerContext,
26
- request: ConsentRequest,
27
- ): Promise<Response> {
28
- const form = new URLSearchParams(request.body);
29
-
30
- const oauthQuery =
31
- request.method === 'GET' ? request.url.search.slice(1) : (form.get('oauth_query') ?? '');
32
-
33
- const verified = await verifyConsentRequest(ctx, oauthQuery, request.cookie);
34
-
35
- if (verified === null) {
36
- return buildPageResponse(400, {
37
- message: 'This approval expired or was already used. Start again from the client.',
38
- });
39
- }
40
-
41
- if (request.method === 'GET') {
42
- const params = new URLSearchParams(oauthQuery);
43
-
44
- const redirectURI = params.get('redirect_uri') ?? '';
45
- const requested = (params.get('scope') ?? '').split(' ');
46
-
47
- const storedName = await readClientName(ctx.store.db, params.get('client_id') ?? '');
48
-
49
- const page = renderConsentPage({
50
- oauthQuery,
51
- clientName: normalizeClientName(storedName),
52
- redirectURI,
53
- scopes: grantScopes.filter((scope) => requested.includes(scope)),
54
- });
55
-
56
- // The form's answer redirects to the client, which the page's form-action
57
- // policy has to allow.
58
- const formTargets = URL.canParse(redirectURI) ? [new URL(redirectURI).origin] : [];
59
-
60
- return buildPageResponse(200, { page }, formTargets);
61
- }
62
-
63
- const sessionID = verified.sessionID;
64
-
65
- // either answer ends the session's binding, so one sign-in approves one request.
66
- ctx.approvals.removeApproved(sessionID);
67
-
68
- const ticked = form.getAll('scope');
69
- const scopes = grantScopes.filter((scope) => ticked.includes(scope));
70
- const allow = form.get('decision') === 'approve';
71
- const accept = allow && scopes.length > 0;
72
-
73
- const choice = JSON.stringify({
74
- accept,
75
- ...(accept ? { scope: [...scopes, 'offline_access'].join(' ') } : {}),
76
- oauth_query: oauthQuery,
77
- });
78
-
79
- const forwarded = new Request(`${ctx.origin}/oauth2/consent`, {
80
- method: 'POST',
81
- headers: {
82
- 'content-type': 'application/json',
83
- accept: 'application/json',
84
- origin: ctx.origin,
85
- ...(request.cookie === null ? {} : { cookie: request.cookie }),
86
- },
87
- body: choice,
88
- });
89
-
90
- const consented = await ctx.store.auth.handler(forwarded);
91
- const answer: unknown = await consented.json();
92
-
93
- const next = isRecord(answer) ? (answer['url'] ?? answer['redirect_uri']) : undefined;
94
-
95
- const issuedCode =
96
- consented.ok && typeof next === 'string' && new URL(next).searchParams.has('code');
97
-
98
- // the code exchange still needs the session, so it stays until the code expires;
99
- // an answer that issued no code deletes it now.
100
- await (issuedCode
101
- ? ctx.store.db
102
- .updateTable('session')
103
- .set({
104
- expiresAt: new Date(ctx.clock.now() + APPROVAL_WINDOW_SECONDS * 1000).toISOString(),
105
- })
106
- .where('id', '=', sessionID)
107
- .execute()
108
- : removeOwnerSession(ctx.store.db, sessionID));
109
-
110
- const params = new URLSearchParams(oauthQuery);
111
-
112
- const clientID = params.get('client_id') ?? '';
113
- const redirectURI = params.get('redirect_uri') ?? '';
114
-
115
- const storedName = await readClientName(ctx.store.db, clientID);
116
-
117
- ctx.security.record({
118
- ...pickConsentOutcome(issuedCode, allow && !accept),
119
- path: request.url.pathname,
120
- requester: request.requester,
121
- client: { id: clientID, name: normalizeClientName(storedName) },
122
- ...(URL.canParse(redirectURI) ? { redirectHost: new URL(redirectURI).host } : {}),
123
- correlationID: verified.correlationID,
124
- });
125
-
126
- if (!consented.ok || typeof next !== 'string') {
127
- return buildPageResponse(400, {
128
- message: 'boxxy could not record this choice. Start again from the client.',
129
- });
130
- }
131
-
132
- const authContext = await ctx.store.auth.$context;
133
-
134
- const sessionCookie = authContext.authCookies.sessionToken;
135
- const secure = sessionCookie.attributes.secure === true ? '; Secure' : '';
136
-
137
- return new Response(null, {
138
- status: 302,
139
- headers: {
140
- location: next,
141
- 'set-cookie': `${sessionCookie.name}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${secure}`,
142
- },
143
- });
144
- }
145
-
146
- async function verifyConsentRequest(
147
- ctx: HTTPServerContext,
148
- oauthQuery: string,
149
- cookie: string | null,
150
- ): Promise<{ sessionID: string; correlationID: string } | null> {
151
- const authContext = await ctx.store.auth.$context;
152
-
153
- if (!(await verifyOAuthQuery(oauthQuery, authContext.secret, ctx.clock.now()))) {
154
- return null;
155
- }
156
-
157
- // a query still asking for a login is one better-auth signed for the login page,
158
- // which no passkey sign-in carried past.
159
- const prompt = (new URLSearchParams(oauthQuery).get('prompt') ?? '').split(' ');
160
-
161
- if (prompt.includes('login')) {
162
- return null;
163
- }
164
-
165
- const sessionID = await readOwnerSessionID(ctx, cookie);
166
-
167
- const approved = sessionID === null ? null : ctx.approvals.findApproved(sessionID);
168
-
169
- if (
170
- sessionID === null ||
171
- approved === null ||
172
- approved.binding !== buildConsentBinding(oauthQuery)
173
- ) {
174
- return null;
175
- }
176
-
177
- return { sessionID, correlationID: approved.correlationID };
178
- }
179
-
180
- // `noneTicked` is an allow sent with no scope ticked, which denies.
181
- function pickConsentOutcome(issuedCode: boolean, noneTicked: boolean): SecurityOutcome {
182
- if (issuedCode) {
183
- return { kind: 'consent', outcome: 'allowed' };
184
- }
185
-
186
- return noneTicked
187
- ? { kind: 'consent', outcome: 'denied', reason: 'none_ticked' }
188
- : { kind: 'consent', outcome: 'denied' };
189
- }
@@ -1,190 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/answer-enroll-request.ts at commit 4fe647cf.
2
-
3
- import { buildPageResponse } from '../http/build-page-response.ts';
4
- import { createPasskey } from './create-passkey.ts';
5
- import { PASSKEY_SCRIPT } from './passkey-script.ts';
6
- import { readEnrollmentToken } from './read-enrollment-token.ts';
7
- import { renderEnrollPage } from './render-enroll-page.ts';
8
- import type { HTTPServerContext, Requester } from './types.ts';
9
- import { verifyPasskeyRegistration } from './verify-passkey-registration.ts';
10
-
11
- interface EnrollRequest {
12
- readonly method: 'GET' | 'POST';
13
- readonly url: URL;
14
- readonly body: string;
15
- readonly requester: Requester;
16
- }
17
-
18
- export async function answerEnrollRequest(
19
- ctx: HTTPServerContext,
20
- request: EnrollRequest,
21
- ): Promise<Response> {
22
- if (request.method === 'GET') {
23
- const prefill = request.url.searchParams.get('token');
24
-
25
- return buildPageResponse(200, {
26
- page: renderEnrollPage({
27
- ceremony: null,
28
- error: null,
29
- ...(prefill === null ? {} : { prefill }),
30
- }),
31
- });
32
- }
33
-
34
- const form = new URLSearchParams(request.body);
35
-
36
- const token = (form.get('token') ?? '').trim();
37
- const context = { path: request.url.pathname, requester: request.requester };
38
-
39
- const tokenHash =
40
- token === '' ? null : await readEnrollmentToken(ctx.store.db, token, ctx.clock.now());
41
-
42
- if (tokenHash === null) {
43
- ctx.security.record({
44
- kind: 'owner_auth',
45
- outcome: 'failed',
46
- reason: 'invalid_token',
47
- ...context,
48
- });
49
-
50
- return buildPageResponse(400, {
51
- page: renderEnrollPage({
52
- ceremony: null,
53
- error:
54
- 'That enrollment token is unknown, used, or expired. Run the enroll command on the host again for a new one.',
55
- }),
56
- });
57
- }
58
-
59
- if (!form.has('credential')) {
60
- return answerCeremony(ctx, { status: 200, token, tokenHash, error: null });
61
- }
62
-
63
- const taken = ctx.enrollments.claimChallenge(tokenHash);
64
-
65
- const registered =
66
- taken.kind === 'taken'
67
- ? await verifyPasskeyRegistration({
68
- credential: form.get('credential') ?? '',
69
- challenge: taken.challenge,
70
- origin: ctx.origin,
71
- rpID: new URL(ctx.origin).hostname,
72
- })
73
- : null;
74
-
75
- if (registered === null) {
76
- const expired = taken.kind === 'expired';
77
-
78
- ctx.security.record({
79
- kind: 'owner_auth',
80
- outcome: 'failed',
81
- reason: expired ? 'expired' : 'assertion_failed',
82
- ...context,
83
- });
84
-
85
- const error = expired
86
- ? 'The enrollment took too long. Try again.'
87
- : 'boxxy could not enroll that passkey. Try again.';
88
-
89
- return answerCeremony(ctx, { status: 400, token, tokenHash, error });
90
- }
91
-
92
- let created: boolean;
93
-
94
- try {
95
- created = await createPasskey(ctx.store.db, {
96
- tokenHash,
97
- id: registered.id,
98
- publicKey: Buffer.from(registered.publicKey).toString('base64url'),
99
- counter: registered.counter,
100
- transports: registered.transports,
101
- now: ctx.clock.now(),
102
- });
103
- } catch {
104
- ctx.security.record({
105
- kind: 'owner_auth',
106
- outcome: 'failed',
107
- reason: 'assertion_failed',
108
- ...context,
109
- });
110
-
111
- return answerCeremony(ctx, {
112
- status: 400,
113
- token,
114
- tokenHash,
115
- error: 'That passkey is already enrolled. Use another device, or sign in with it.',
116
- });
117
- }
118
-
119
- if (!created) {
120
- ctx.security.record({
121
- kind: 'owner_auth',
122
- outcome: 'failed',
123
- reason: 'invalid_token',
124
- ...context,
125
- });
126
-
127
- return buildPageResponse(400, {
128
- page: renderEnrollPage({
129
- ceremony: null,
130
- error:
131
- 'That enrollment token is unknown, used, or expired. Run the enroll command on the host again for a new one.',
132
- }),
133
- });
134
- }
135
-
136
- ctx.security.record({ kind: 'owner_auth', outcome: 'succeeded', ...context });
137
-
138
- return buildPageResponse(200, {
139
- message: 'Passkey enrolled. Start the connection again from your client and sign in with it.',
140
- });
141
- }
142
-
143
- // How long the browser's passkey prompt waits, in milliseconds.
144
- const PROMPT_TIMEOUT_MS = 120_000;
145
-
146
- // The single owner's WebAuthn user handle, the same for every passkey, so
147
- // an authenticator files each one under the one boxxy account.
148
- const OWNER_HANDLE = Buffer.from('boxxy-owner').toString('base64url');
149
-
150
- interface AnswerCeremonyParams {
151
- readonly status: number;
152
- readonly token: string;
153
- readonly tokenHash: string;
154
- readonly error: string | null;
155
- }
156
-
157
- async function answerCeremony(
158
- ctx: HTTPServerContext,
159
- params: AnswerCeremonyParams,
160
- ): Promise<Response> {
161
- const enrolled = await ctx.store.db.selectFrom('boxxy_passkey').select('id').execute();
162
-
163
- const options = {
164
- challenge: ctx.enrollments.mintChallenge(params.tokenHash),
165
- rp: { id: new URL(ctx.origin).hostname, name: 'boxxy' },
166
- user: { id: OWNER_HANDLE, name: 'owner', displayName: 'boxxy owner' },
167
- pubKeyCredParams: [
168
- { type: 'public-key', alg: -8 },
169
- { type: 'public-key', alg: -7 },
170
- { type: 'public-key', alg: -257 },
171
- ],
172
- timeout: PROMPT_TIMEOUT_MS,
173
-
174
- // an authenticator that already holds an enrolled passkey refuses to make a second.
175
- excludeCredentials: enrolled.map((row) => ({ type: 'public-key', id: row.id })),
176
- authenticatorSelection: {
177
- residentKey: 'required',
178
- requireResidentKey: true,
179
- userVerification: 'required',
180
- },
181
- attestation: 'none',
182
- };
183
-
184
- return buildPageResponse(
185
- params.status,
186
- { page: renderEnrollPage({ ceremony: { token: params.token, options }, error: params.error }) },
187
- [],
188
- [PASSKEY_SCRIPT.policySource],
189
- );
190
- }
@@ -1,256 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/answer-login-request.ts at commit 4fe647cf.
2
-
3
- import { buildPageResponse } from '../http/build-page-response.ts';
4
- import { isRecord } from '../utils/is-record.ts';
5
- import { buildConsentBinding } from './build-consent-binding.ts';
6
- import { PASSKEY_SCRIPT } from './passkey-script.ts';
7
- import { readOwnerSessionID } from './read-owner-session-id.ts';
8
- import { renderLoginPage } from './render-login-page.ts';
9
- import type { HTTPServerContext, OwnerAuthFailure, Requester } from './types.ts';
10
- import { verifyPasskeyAssertion } from './verify-passkey-assertion.ts';
11
- import { verifySessionPasskey } from './verify-session-passkey.ts';
12
-
13
- interface LoginRequest {
14
- readonly method: 'GET' | 'POST';
15
- readonly url: URL;
16
- readonly body: string;
17
- readonly requester: Requester;
18
- }
19
-
20
- export async function answerLoginRequest(
21
- ctx: HTTPServerContext,
22
- request: LoginRequest,
23
- ): Promise<Response> {
24
- const form = new URLSearchParams(request.body);
25
-
26
- const oauthQuery =
27
- request.method === 'GET' ? request.url.search.slice(1) : (form.get('oauth_query') ?? '');
28
-
29
- const approval = ctx.approvals.findPending(oauthQuery);
30
-
31
- if (approval === null) {
32
- if (request.method === 'POST') {
33
- ctx.security.record({
34
- kind: 'owner_auth',
35
- outcome: 'failed',
36
- reason: 'expired',
37
- path: request.url.pathname,
38
- requester: request.requester,
39
- });
40
- }
41
-
42
- return buildPageResponse(400, {
43
- message: 'This approval expired or was already used. Start again from the client.',
44
- });
45
- }
46
-
47
- const view = {
48
- oauthQuery,
49
- clientName: approval.clientName,
50
- redirectURI: approval.redirectURI,
51
- };
52
-
53
- const enrolled = await probePasskey(ctx);
54
-
55
- if (request.method === 'GET') {
56
- return enrolled
57
- ? buildSignInResponse(ctx, 200, view, null)
58
- : buildPageResponse(200, { page: renderLoginPage({ ...view, options: null, error: null }) });
59
- }
60
-
61
- // Read before the assertion is checked, since a sign-in or the last
62
- // failure ends the approval.
63
- const context = {
64
- path: request.url.pathname,
65
- requester: request.requester,
66
- client: { id: approval.clientID, name: approval.clientName },
67
- correlationID: approval.correlationID,
68
- };
69
-
70
- if (!enrolled) {
71
- ctx.security.record({
72
- kind: 'owner_auth',
73
- outcome: 'failed',
74
- reason: 'not_enrolled',
75
- ...context,
76
- });
77
-
78
- return buildPageResponse(400, {
79
- page: renderLoginPage({ ...view, options: null, error: null }),
80
- });
81
- }
82
-
83
- // the challenge is taken whether or not the assertion verifies, so it counts once.
84
- const taken = ctx.approvals.claimChallenge(oauthQuery);
85
-
86
- const passkeyID =
87
- taken.kind === 'taken'
88
- ? await verifyAssertion(ctx, form.get('credential') ?? '', taken.challenge)
89
- : null;
90
-
91
- if (passkeyID === null) {
92
- const reason: OwnerAuthFailure = taken.kind === 'expired' ? 'expired' : 'assertion_failed';
93
-
94
- if (ctx.approvals.recordFailure(oauthQuery) === 'locked') {
95
- ctx.security.record({ kind: 'owner_auth', outcome: 'failed', reason: 'locked', ...context });
96
-
97
- return buildPageResponse(400, {
98
- message: 'Too many failed sign-ins. Start again from the client.',
99
- });
100
- }
101
-
102
- ctx.security.record({ kind: 'owner_auth', outcome: 'failed', reason, ...context });
103
-
104
- const error =
105
- reason === 'expired'
106
- ? 'The sign-in took too long. Try again.'
107
- : 'That passkey did not sign you in. Try again with a passkey enrolled for boxxy.';
108
-
109
- return buildSignInResponse(ctx, 400, view, error);
110
- }
111
-
112
- ctx.approvals.removePending(oauthQuery);
113
-
114
- // Resuming the authorization reads the request better-auth was called with.
115
- const apiHeaders = new Headers({
116
- accept: 'application/json',
117
- 'content-type': 'application/json',
118
- });
119
-
120
- const signedIn = await ctx.store.auth.api.signInOwner({
121
- body: { oauth_query: oauthQuery },
122
- headers: apiHeaders,
123
- request: new Request(`${ctx.origin}/boxxy/sign-in-owner`, {
124
- method: 'POST',
125
- headers: apiHeaders,
126
- }),
127
- asResponse: true,
128
- });
129
-
130
- const resumed: unknown = await signedIn.json();
131
-
132
- const next = isRecord(resumed) ? resumed['url'] : undefined;
133
-
134
- if (!signedIn.ok || typeof next !== 'string') {
135
- ctx.security.record({ kind: 'owner_auth', outcome: 'failed', ...context });
136
-
137
- return buildPageResponse(400, {
138
- message: 'boxxy could not continue this approval. Start again from the client.',
139
- });
140
- }
141
-
142
- const setCookies = signedIn.headers.getSetCookie();
143
-
144
- const sessionID = await readOwnerSessionID(
145
- ctx,
146
- setCookies.map((line) => line.split(';')[0]).join('; '),
147
- );
148
-
149
- if (sessionID === null) {
150
- ctx.security.record({ kind: 'owner_auth', outcome: 'failed', ...context });
151
-
152
- return buildPageResponse(400, {
153
- message: 'boxxy could not continue this approval. Start again from the client.',
154
- });
155
- }
156
-
157
- // A passkey removal or reset that committed after the assertion verified
158
- // and before the session existed deleted no session, so the session
159
- // carries on only while the passkey that signed is still enrolled.
160
- if (!(await verifySessionPasskey(ctx.store.db, { sessionID, passkeyID }))) {
161
- ctx.security.record({
162
- kind: 'owner_auth',
163
- outcome: 'failed',
164
- reason: 'not_enrolled',
165
- ...context,
166
- });
167
-
168
- return buildPageResponse(400, {
169
- message: 'The passkey that signed you in is no longer enrolled. Start again from the client.',
170
- });
171
- }
172
-
173
- ctx.approvals.recordApproved(sessionID, {
174
- binding: buildConsentBinding(oauthQuery),
175
- correlationID: approval.correlationID,
176
- });
177
-
178
- ctx.security.record({ kind: 'owner_auth', outcome: 'succeeded', ...context });
179
-
180
- const headers = new Headers({ location: new URL(next, ctx.origin).href });
181
-
182
- for (const cookie of setCookies) {
183
- headers.append('set-cookie', cookie);
184
- }
185
-
186
- return new Response(null, { status: 302, headers });
187
- }
188
-
189
- async function probePasskey(ctx: HTTPServerContext): Promise<boolean> {
190
- const row = await ctx.store.db
191
- .selectFrom('boxxy_passkey')
192
- .select('id')
193
- .limit(1)
194
- .executeTakeFirst();
195
-
196
- return row !== undefined;
197
- }
198
-
199
- // How long the browser's passkey prompt waits, in milliseconds.
200
- const PROMPT_TIMEOUT_MS = 120_000;
201
-
202
- // What the sign-in page shows about its request.
203
- interface LoginView {
204
- readonly oauthQuery: string;
205
- readonly clientName: string;
206
- readonly redirectURI: string;
207
- }
208
-
209
- // The sign-in page with a fresh challenge for its request. The options
210
- // list no credential, so the authenticator offers the passkeys it holds
211
- // for boxxy's RP ID, and they require user verification.
212
- function buildSignInResponse(
213
- ctx: HTTPServerContext,
214
- status: number,
215
- view: LoginView,
216
- error: string | null,
217
- ): Response {
218
- const challenge = ctx.approvals.mintChallenge(view.oauthQuery);
219
-
220
- if (challenge === null) {
221
- return buildPageResponse(400, {
222
- message: 'This approval expired or was already used. Start again from the client.',
223
- });
224
- }
225
-
226
- const options = {
227
- challenge,
228
- rpId: new URL(ctx.origin).hostname,
229
- timeout: PROMPT_TIMEOUT_MS,
230
- userVerification: 'required',
231
- allowCredentials: [],
232
- };
233
-
234
- return buildPageResponse(
235
- status,
236
- { page: renderLoginPage({ ...view, options, error }) },
237
- [],
238
- [PASSKEY_SCRIPT.policySource],
239
- );
240
- }
241
-
242
- // The enrolled passkey the posted credential is an assertion of, for the
243
- // challenge the caller claimed, or null.
244
- function verifyAssertion(
245
- ctx: HTTPServerContext,
246
- credential: string,
247
- challenge: string,
248
- ): Promise<string | null> {
249
- return verifyPasskeyAssertion(ctx.store.db, {
250
- credential,
251
- challenge,
252
- origin: ctx.origin,
253
- rpID: new URL(ctx.origin).hostname,
254
- now: ctx.clock.now(),
255
- });
256
- }
@@ -1,5 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/approval-window-seconds.ts at commit 4fe647cf.
2
-
3
- // an approval page, its signed query, and the authorization code it leads to
4
- // each stay valid this long.
5
- export const APPROVAL_WINDOW_SECONDS = 600;
@@ -1,13 +0,0 @@
1
- // Copied from atc: packages/mcp/src/auth/build-consent-binding.ts at commit 4fe647cf.
2
-
3
- export function buildConsentBinding(oauthQuery: string): string {
4
- const params = new URLSearchParams(oauthQuery);
5
-
6
- // better-auth signs each page's query afresh, so the binding holds only the
7
- // fields that stay the same from the login page to the consent page.
8
- return JSON.stringify(
9
- ['client_id', 'redirect_uri', 'scope', 'resource', 'state', 'code_challenge'].map(
10
- (name) => params.get(name) ?? '',
11
- ),
12
- );
13
- }