@boxxy/auth-oauth 0.2.6 → 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.
- package/README.md +12 -13
- package/dist/auth/create-auth-admin.d.ts +25 -12
- package/dist/auth/create-security-event-log.d.ts +1 -1
- package/dist/auth/format-security-event.d.ts +10 -2
- package/dist/auth/open-auth-database.d.ts +2 -2
- package/dist/http/create-public-door.d.ts +5 -1
- package/dist/http/{answer-mcp-request.d.ts → types.d.ts} +0 -2
- package/dist/index.d.ts +2 -5
- package/dist/migrations/grant-rows-migration.d.ts +2 -0
- package/dist/migrations/run-auth-migrations.d.ts +2 -0
- package/dist/test-utils/authorize-test-client.d.ts +1 -13
- package/dist/test-utils/pre-package-schema.d.ts +1 -0
- package/dist/test-utils/seed-pre-package-auth.d.ts +21 -0
- package/package.json +7 -13
- package/src/auth/create-auth-admin.ts +109 -41
- package/src/auth/create-security-event-log.ts +1 -3
- package/src/auth/format-security-event.ts +12 -11
- package/src/auth/normalize-client-name.ts +0 -2
- package/src/auth/open-auth-database.ts +8 -13
- package/src/http/create-public-door.ts +113 -29
- package/src/http/is-loopback-host.ts +0 -2
- package/src/http/normalize-public-url.ts +0 -2
- package/src/http/types.ts +11 -0
- package/src/index.ts +9 -5
- package/src/migrations/grant-rows-migration.ts +108 -0
- package/src/migrations/run-auth-migrations.ts +53 -0
- package/src/test-utils/authorize-test-client.ts +2 -194
- package/src/test-utils/build-stub-clock.ts +0 -2
- package/src/test-utils/pre-package-schema.ts +36 -0
- package/src/test-utils/register-test-cleanup.ts +0 -2
- package/src/test-utils/seed-pre-package-auth.ts +162 -0
- package/src/test-utils/setup-temp-dir.ts +0 -2
- package/src/utils/types.ts +0 -2
- package/dist/auth/answer-authorize-request.d.ts +0 -2
- package/dist/auth/answer-consent-request.d.ts +0 -10
- package/dist/auth/answer-enroll-request.d.ts +0 -9
- package/dist/auth/answer-login-request.d.ts +0 -9
- package/dist/auth/approval-window-seconds.d.ts +0 -1
- package/dist/auth/build-consent-binding.d.ts +0 -1
- package/dist/auth/create-approval-state.d.ts +0 -41
- package/dist/auth/create-enrollment-state.d.ts +0 -6
- package/dist/auth/create-enrollment-token.d.ts +0 -6
- package/dist/auth/create-owner-plugin.d.ts +0 -38
- package/dist/auth/create-passkey.d.ts +0 -11
- package/dist/auth/derive-token-hash.d.ts +0 -1
- package/dist/auth/is-allowed-redirect-uri.d.ts +0 -1
- package/dist/auth/load-web-authn.d.ts +0 -6
- package/dist/auth/mcp-auth-database.d.ts +0 -5
- package/dist/auth/mcp-auth-schema.d.ts +0 -54
- package/dist/auth/open-mcp-auth.d.ts +0 -80
- package/dist/auth/passkey-script.d.ts +0 -4
- package/dist/auth/read-client-name.d.ts +0 -3
- package/dist/auth/read-client-views.d.ts +0 -9
- package/dist/auth/read-enrollment-token.d.ts +0 -3
- package/dist/auth/read-grant-views.d.ts +0 -12
- package/dist/auth/read-owner-session-id.d.ts +0 -2
- package/dist/auth/record-refused-exchange.d.ts +0 -10
- package/dist/auth/remove-client.d.ts +0 -3
- package/dist/auth/remove-owner-session.d.ts +0 -3
- package/dist/auth/render-consent-page.d.ts +0 -9
- package/dist/auth/render-enroll-page.d.ts +0 -10
- package/dist/auth/render-login-page.d.ts +0 -9
- package/dist/auth/revoke-grant.d.ts +0 -3
- package/dist/auth/to-html-text.d.ts +0 -1
- package/dist/auth/to-script-json.d.ts +0 -1
- package/dist/auth/types.d.ts +0 -79
- package/dist/auth/update-passkey-use.d.ts +0 -10
- package/dist/auth/verify-oauth-query.d.ts +0 -1
- package/dist/auth/verify-passkey-assertion.d.ts +0 -11
- package/dist/auth/verify-passkey-registration.d.ts +0 -14
- package/dist/auth/verify-session-passkey.d.ts +0 -8
- package/dist/auth/write-grant-use.d.ts +0 -3
- package/dist/http/build-page-response.d.ts +0 -5
- package/dist/http/create-mcp-http-app.d.ts +0 -92
- package/dist/http/pick-error-message.d.ts +0 -1
- package/dist/http/public-door-paths.d.ts +0 -17
- package/dist/http/read-token-access.d.ts +0 -8
- package/dist/http/verify-access-token.d.ts +0 -2
- package/dist/test-utils/build-mock-new-passkey.d.ts +0 -2
- package/dist/test-utils/build-passkey-seeder.d.ts +0 -5
- package/dist/test-utils/build-seeder.d.ts +0 -5
- package/dist/test-utils/build-stub-authenticator.d.ts +0 -48
- package/dist/test-utils/load-web-authn-early.d.ts +0 -1
- package/dist/test-utils/seed-passkey.d.ts +0 -4
- package/dist/test-utils/start-stub-telemetry-collector.d.ts +0 -5
- package/dist/test-utils/types.d.ts +0 -7
- package/dist/utils/is-record.d.ts +0 -1
- package/dist/utils/parse-json-strings.d.ts +0 -1
- package/src/auth/answer-authorize-request.ts +0 -137
- package/src/auth/answer-consent-request.ts +0 -189
- package/src/auth/answer-enroll-request.ts +0 -190
- package/src/auth/answer-login-request.ts +0 -256
- package/src/auth/approval-window-seconds.ts +0 -5
- package/src/auth/build-consent-binding.ts +0 -13
- package/src/auth/create-approval-state.ts +0 -221
- package/src/auth/create-enrollment-state.ts +0 -56
- package/src/auth/create-enrollment-token.ts +0 -28
- package/src/auth/create-owner-plugin.ts +0 -113
- package/src/auth/create-passkey.ts +0 -48
- package/src/auth/derive-token-hash.ts +0 -7
- package/src/auth/is-allowed-redirect-uri.ts +0 -19
- package/src/auth/load-web-authn.ts +0 -19
- package/src/auth/mcp-auth-database.ts +0 -10
- package/src/auth/mcp-auth-schema.ts +0 -56
- package/src/auth/open-mcp-auth.ts +0 -138
- package/src/auth/passkey-script.ts +0 -50
- package/src/auth/read-client-name.ts +0 -17
- package/src/auth/read-client-views.ts +0 -27
- package/src/auth/read-enrollment-token.ts +0 -20
- package/src/auth/read-grant-views.ts +0 -53
- package/src/auth/read-owner-session-id.ts +0 -16
- package/src/auth/record-refused-exchange.ts +0 -74
- package/src/auth/remove-client.ts +0 -33
- package/src/auth/remove-owner-session.ts +0 -11
- package/src/auth/render-consent-page.ts +0 -61
- package/src/auth/render-enroll-page.ts +0 -59
- package/src/auth/render-login-page.ts +0 -58
- package/src/auth/revoke-grant.ts +0 -25
- package/src/auth/to-html-text.ts +0 -10
- package/src/auth/to-script-json.ts +0 -8
- package/src/auth/types.ts +0 -115
- package/src/auth/update-passkey-use.ts +0 -31
- package/src/auth/verify-oauth-query.ts +0 -41
- package/src/auth/verify-passkey-assertion.ts +0 -124
- package/src/auth/verify-passkey-registration.ts +0 -118
- package/src/auth/verify-session-passkey.ts +0 -47
- package/src/auth/write-grant-use.ts +0 -18
- package/src/http/answer-mcp-request.ts +0 -77
- package/src/http/build-page-response.ts +0 -26
- package/src/http/create-mcp-http-app.ts +0 -340
- package/src/http/pick-error-message.ts +0 -21
- package/src/http/public-door-paths.ts +0 -21
- package/src/http/read-token-access.ts +0 -44
- package/src/http/verify-access-token.ts +0 -46
- package/src/test-utils/build-mock-new-passkey.ts +0 -15
- package/src/test-utils/build-passkey-seeder.ts +0 -11
- package/src/test-utils/build-seeder.ts +0 -10
- package/src/test-utils/build-stub-authenticator.ts +0 -244
- package/src/test-utils/load-web-authn-early.ts +0 -8
- package/src/test-utils/seed-passkey.ts +0 -32
- package/src/test-utils/start-stub-telemetry-collector.ts +0 -27
- package/src/test-utils/types.ts +0 -11
- package/src/utils/is-record.ts +0 -5
- package/src/utils/parse-json-strings.ts +0 -15
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
// Copied from atc: packages/mcp/src/http/create-mcp-http-app.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
|
-
import { Elysia } from 'elysia';
|
|
4
|
-
import invariant from 'tiny-invariant';
|
|
5
|
-
import { match } from 'ts-pattern';
|
|
6
|
-
import { answerAuthorizeRequest } from '../auth/answer-authorize-request.ts';
|
|
7
|
-
import { answerConsentRequest } from '../auth/answer-consent-request.ts';
|
|
8
|
-
import { answerEnrollRequest } from '../auth/answer-enroll-request.ts';
|
|
9
|
-
import { answerLoginRequest } from '../auth/answer-login-request.ts';
|
|
10
|
-
import { recordRefusedExchange } from '../auth/record-refused-exchange.ts';
|
|
11
|
-
import type { HTTPServerContext, Requester, SecurityEvent } from '../auth/types.ts';
|
|
12
|
-
import { isRecord } from '../utils/is-record.ts';
|
|
13
|
-
import { answerMCPRequest } from './answer-mcp-request.ts';
|
|
14
|
-
import type { McpServe } from './answer-mcp-request.ts';
|
|
15
|
-
import { buildPageResponse } from './build-page-response.ts';
|
|
16
|
-
import { pickErrorMessage } from './pick-error-message.ts';
|
|
17
|
-
import { PUBLIC_DOOR_PATHS } from './public-door-paths.ts';
|
|
18
|
-
|
|
19
|
-
interface MCPHTTPAppOptions {
|
|
20
|
-
// The Host header values the server answers to.
|
|
21
|
-
readonly hosts: ReadonlySet<string>;
|
|
22
|
-
|
|
23
|
-
// The origins a browser form or an MCP request may come from.
|
|
24
|
-
readonly origins: ReadonlySet<string>;
|
|
25
|
-
|
|
26
|
-
// The routes' context once the authorization server's database is open,
|
|
27
|
-
// and null before.
|
|
28
|
-
readonly readContext: () => HTTPServerContext | null;
|
|
29
|
-
|
|
30
|
-
// Where a request came from, for its security events.
|
|
31
|
-
readonly readRequester: (request: Request) => Requester;
|
|
32
|
-
|
|
33
|
-
// Answers an MCP request whose bearer token verified.
|
|
34
|
-
readonly serveMCP: McpServe;
|
|
35
|
-
|
|
36
|
-
// Receives each refused Host or origin.
|
|
37
|
-
readonly security: { readonly record: (event: SecurityEvent) => void };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// The browser pages: passkey sign-in, passkey enrollment, and consent.
|
|
41
|
-
// the part of Elysia's context that the routes read
|
|
42
|
-
interface RouteContext {
|
|
43
|
-
readonly request: Request;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const PAGE_PATHS = [
|
|
47
|
-
PUBLIC_DOOR_PATHS.browser.login,
|
|
48
|
-
PUBLIC_DOOR_PATHS.browser.enroll,
|
|
49
|
-
PUBLIC_DOOR_PATHS.browser.consent,
|
|
50
|
-
] as const;
|
|
51
|
-
|
|
52
|
-
// Both paths serve the protected resource metadata for `<origin>/mcp`.
|
|
53
|
-
const RESOURCE_METADATA_PATHS = [
|
|
54
|
-
PUBLIC_DOOR_PATHS.machine.resourceMetadata,
|
|
55
|
-
PUBLIC_DOOR_PATHS.machine.mcpResourceMetadata,
|
|
56
|
-
] as const;
|
|
57
|
-
|
|
58
|
-
export function createMCPHTTPApp(options: MCPHTTPAppOptions) {
|
|
59
|
-
const requireContext = (): HTTPServerContext => {
|
|
60
|
-
const ctx = options.readContext();
|
|
61
|
-
|
|
62
|
-
invariant(ctx, 'a route ran before the authorization server opened');
|
|
63
|
-
|
|
64
|
-
return ctx;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const app = new Elysia()
|
|
68
|
-
.onRequest((context: RouteContext) => {
|
|
69
|
-
const path = new URL(context.request.url).pathname;
|
|
70
|
-
|
|
71
|
-
const host = context.request.headers.get('host');
|
|
72
|
-
|
|
73
|
-
if (host === null || !options.hosts.has(host)) {
|
|
74
|
-
options.security.record({
|
|
75
|
-
kind: 'authorize_invalid',
|
|
76
|
-
outcome: 'rejected',
|
|
77
|
-
reason: 'bad_host',
|
|
78
|
-
path,
|
|
79
|
-
requester: options.readRequester(context.request),
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
return new Response(null, { status: 403 });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Elysia answers with any value a hook returns but undefined, null included
|
|
86
|
-
return options.readContext() === null ? new Response(null, { status: 503 }) : undefined;
|
|
87
|
-
})
|
|
88
|
-
.onError(() => new Response(null, { status: 503 }))
|
|
89
|
-
.post(PUBLIC_DOOR_PATHS.machine.revoke, (context: RouteContext) =>
|
|
90
|
-
requireContext().store.auth.handler(toPublicRequest(requireContext(), context.request)),
|
|
91
|
-
)
|
|
92
|
-
.get(PUBLIC_DOOR_PATHS.machine.authorizationServerMetadata, (context: RouteContext) =>
|
|
93
|
-
answerMetadataRequest(requireContext(), context.request, 'authorization-server'),
|
|
94
|
-
)
|
|
95
|
-
.get(PUBLIC_DOOR_PATHS.browser.authorize, (context: RouteContext) =>
|
|
96
|
-
answerAuthorizeRequest(
|
|
97
|
-
requireContext(),
|
|
98
|
-
new URL(context.request.url),
|
|
99
|
-
options.readRequester(context.request),
|
|
100
|
-
),
|
|
101
|
-
)
|
|
102
|
-
.post(PUBLIC_DOOR_PATHS.machine.token, (context: RouteContext) =>
|
|
103
|
-
answerTokenRequest(requireContext(), context.request, options.readRequester(context.request)),
|
|
104
|
-
)
|
|
105
|
-
.get(PUBLIC_DOOR_PATHS.browser.error, (context: RouteContext) => {
|
|
106
|
-
const error = new URL(context.request.url).searchParams.get('error');
|
|
107
|
-
|
|
108
|
-
return buildPageResponse(400, { message: pickErrorMessage(error) });
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
for (const path of RESOURCE_METADATA_PATHS) {
|
|
112
|
-
app.get(path, (context: RouteContext) =>
|
|
113
|
-
answerMetadataRequest(requireContext(), context.request, 'resource'),
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
for (const path of PAGE_PATHS) {
|
|
118
|
-
app.get(path, (context: RouteContext) =>
|
|
119
|
-
answerPageRequest(requireContext(), {
|
|
120
|
-
path,
|
|
121
|
-
request: context.request,
|
|
122
|
-
body: '',
|
|
123
|
-
requester: options.readRequester(context.request),
|
|
124
|
-
}),
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
app.post(
|
|
128
|
-
path,
|
|
129
|
-
{
|
|
130
|
-
// a form posts from the server's own pages, so a post with no origin
|
|
131
|
-
// or another one is a forged form
|
|
132
|
-
beforeHandle: (context: RouteContext) => {
|
|
133
|
-
const origin = context.request.headers.get('origin');
|
|
134
|
-
const isForged = origin === null || !options.origins.has(origin);
|
|
135
|
-
|
|
136
|
-
if (isForged) {
|
|
137
|
-
recordBadOrigin(options, context.request);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return isForged
|
|
141
|
-
? buildPageResponse(403, { message: 'The form came from an unexpected origin.' })
|
|
142
|
-
: undefined;
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
async (context: RouteContext) => {
|
|
146
|
-
const body = await context.request.text();
|
|
147
|
-
|
|
148
|
-
return answerPageRequest(requireContext(), {
|
|
149
|
-
path,
|
|
150
|
-
request: context.request,
|
|
151
|
-
body,
|
|
152
|
-
requester: options.readRequester(context.request),
|
|
153
|
-
});
|
|
154
|
-
},
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
app.all(
|
|
159
|
-
PUBLIC_DOOR_PATHS.machine.mcp,
|
|
160
|
-
{
|
|
161
|
-
beforeHandle: (context: RouteContext) => {
|
|
162
|
-
const origin = context.request.headers.get('origin');
|
|
163
|
-
const isForeign = origin !== null && !options.origins.has(origin);
|
|
164
|
-
|
|
165
|
-
if (isForeign) {
|
|
166
|
-
recordBadOrigin(options, context.request);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return isForeign ? new Response(null, { status: 403 }) : undefined;
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
(context: RouteContext) => {
|
|
173
|
-
if (context.request.method !== 'POST') {
|
|
174
|
-
return new Response(null, { status: 405, headers: { allow: 'POST' } });
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return answerMCPRequest(
|
|
178
|
-
requireContext(),
|
|
179
|
-
context.request,
|
|
180
|
-
options.readRequester(context.request),
|
|
181
|
-
options.serveMCP,
|
|
182
|
-
);
|
|
183
|
-
},
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
// every other path, the RPC route and the health check included, answers 404
|
|
187
|
-
app.all('*', () => new Response(null, { status: 404 }));
|
|
188
|
-
|
|
189
|
-
return app;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// better-auth builds every URL it returns from its base URL, so a request
|
|
193
|
-
// that reached the local address is handed over as if it came to the public
|
|
194
|
-
// origin.
|
|
195
|
-
function toPublicRequest(ctx: HTTPServerContext, request: Request): Request {
|
|
196
|
-
const url = new URL(request.url);
|
|
197
|
-
|
|
198
|
-
return new Request(`${ctx.origin}${url.pathname}${url.search}`, request);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// The metadata advertises only what boxxy's clients can use: public clients
|
|
202
|
-
// with no client authentication, no introspection endpoint, and Bearer
|
|
203
|
-
// tokens alone, so nothing about DPoP.
|
|
204
|
-
async function answerMetadataRequest(
|
|
205
|
-
ctx: HTTPServerContext,
|
|
206
|
-
request: Request,
|
|
207
|
-
kind: 'authorization-server' | 'resource',
|
|
208
|
-
): Promise<Response> {
|
|
209
|
-
const response = await ctx.store.auth.handler(toPublicRequest(ctx, request));
|
|
210
|
-
const metadata: unknown = await response.json();
|
|
211
|
-
|
|
212
|
-
if (!isRecord(metadata)) {
|
|
213
|
-
return new Response(null, { status: 503 });
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const advertised = Object.fromEntries(
|
|
217
|
-
Object.entries(metadata).filter(
|
|
218
|
-
([key]) => !key.startsWith('introspection_') && !key.startsWith('dpop_'),
|
|
219
|
-
),
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
if (kind === 'resource') {
|
|
223
|
-
return Response.json(advertised, { status: response.status });
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return Response.json({
|
|
227
|
-
...advertised,
|
|
228
|
-
token_endpoint_auth_methods_supported: ['none'],
|
|
229
|
-
revocation_endpoint_auth_methods_supported: ['none'],
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// better-auth binds a code exchange's tokens to the owner session that approved it.
|
|
234
|
-
async function answerTokenRequest(
|
|
235
|
-
ctx: HTTPServerContext,
|
|
236
|
-
request: Request,
|
|
237
|
-
requester: Requester,
|
|
238
|
-
): Promise<Response> {
|
|
239
|
-
// the body is read once here, so the refusal's event can name its client.
|
|
240
|
-
const body = await request.text();
|
|
241
|
-
|
|
242
|
-
const sent = new Request(request.url, { method: request.method, headers: request.headers, body });
|
|
243
|
-
|
|
244
|
-
const response = await ctx.store.auth.handler(toPublicRequest(ctx, sent));
|
|
245
|
-
|
|
246
|
-
if (!response.ok) {
|
|
247
|
-
await recordRefusedExchange(ctx, {
|
|
248
|
-
contentType: request.headers.get('content-type'),
|
|
249
|
-
body,
|
|
250
|
-
response,
|
|
251
|
-
path: new URL(request.url).pathname,
|
|
252
|
-
requester,
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
return response;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const attached = await ctx.store.db
|
|
259
|
-
.selectFrom('oauthAccessToken')
|
|
260
|
-
.select('sessionId')
|
|
261
|
-
.where('sessionId', 'is not', null)
|
|
262
|
-
.union(
|
|
263
|
-
ctx.store.db
|
|
264
|
-
.selectFrom('oauthRefreshToken')
|
|
265
|
-
.select('sessionId')
|
|
266
|
-
.where('sessionId', 'is not', null),
|
|
267
|
-
)
|
|
268
|
-
.execute();
|
|
269
|
-
|
|
270
|
-
const sessionIDs = attached.flatMap((row) => (row.sessionId === null ? [] : [row.sessionId]));
|
|
271
|
-
|
|
272
|
-
// detached tokens outlive their session, so a session's end never ends a grant.
|
|
273
|
-
await ctx.store.db
|
|
274
|
-
.updateTable('oauthAccessToken')
|
|
275
|
-
.set({ sessionId: null })
|
|
276
|
-
.where('sessionId', 'is not', null)
|
|
277
|
-
.execute();
|
|
278
|
-
|
|
279
|
-
await ctx.store.db
|
|
280
|
-
.updateTable('oauthRefreshToken')
|
|
281
|
-
.set({ sessionId: null })
|
|
282
|
-
.where('sessionId', 'is not', null)
|
|
283
|
-
.execute();
|
|
284
|
-
|
|
285
|
-
const now = new Date(ctx.clock.now()).toISOString();
|
|
286
|
-
|
|
287
|
-
// the session's one authorization is done.
|
|
288
|
-
await ctx.store.db
|
|
289
|
-
.deleteFrom('session')
|
|
290
|
-
.where((eb) =>
|
|
291
|
-
eb.or([
|
|
292
|
-
eb('expiresAt', '<', now),
|
|
293
|
-
...(sessionIDs.length === 0 ? [] : [eb('id', 'in', sessionIDs)]),
|
|
294
|
-
]),
|
|
295
|
-
)
|
|
296
|
-
.execute();
|
|
297
|
-
|
|
298
|
-
return response;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
interface PageRequest {
|
|
302
|
-
readonly path: (typeof PAGE_PATHS)[number];
|
|
303
|
-
readonly request: Request;
|
|
304
|
-
readonly body: string;
|
|
305
|
-
readonly requester: Requester;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function answerPageRequest(ctx: HTTPServerContext, page: PageRequest): Promise<Response> {
|
|
309
|
-
const method = page.request.method === 'POST' ? 'POST' : 'GET';
|
|
310
|
-
|
|
311
|
-
const url = new URL(page.request.url);
|
|
312
|
-
|
|
313
|
-
return match(page.path)
|
|
314
|
-
.with(PUBLIC_DOOR_PATHS.browser.login, () =>
|
|
315
|
-
answerLoginRequest(ctx, { method, url, body: page.body, requester: page.requester }),
|
|
316
|
-
)
|
|
317
|
-
.with(PUBLIC_DOOR_PATHS.browser.enroll, () =>
|
|
318
|
-
answerEnrollRequest(ctx, { method, url, body: page.body, requester: page.requester }),
|
|
319
|
-
)
|
|
320
|
-
.with(PUBLIC_DOOR_PATHS.browser.consent, () =>
|
|
321
|
-
answerConsentRequest(ctx, {
|
|
322
|
-
method,
|
|
323
|
-
url,
|
|
324
|
-
cookie: page.request.headers.get('cookie'),
|
|
325
|
-
body: page.body,
|
|
326
|
-
requester: page.requester,
|
|
327
|
-
}),
|
|
328
|
-
)
|
|
329
|
-
.exhaustive();
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function recordBadOrigin(options: MCPHTTPAppOptions, request: Request): void {
|
|
333
|
-
options.security.record({
|
|
334
|
-
kind: 'authorize_invalid',
|
|
335
|
-
outcome: 'rejected',
|
|
336
|
-
reason: 'bad_origin',
|
|
337
|
-
path: new URL(request.url).pathname,
|
|
338
|
-
requester: options.readRequester(request),
|
|
339
|
-
});
|
|
340
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// Copied from atc: packages/mcp/src/http/pick-error-message.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
|
-
// The error codes better-auth sends to the error page, each with the fixed
|
|
4
|
-
// sentence boxxy shows for it.
|
|
5
|
-
const ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
|
6
|
-
invalid_client: 'the client is not one added to boxxy.',
|
|
7
|
-
client_disabled: 'the client is disabled.',
|
|
8
|
-
invalid_redirect: 'the redirect URI is not one the client was added with.',
|
|
9
|
-
unauthorized_client: 'the client may not use the authorization code grant.',
|
|
10
|
-
unsupported_response_type: 'boxxy supports only the authorization code response type.',
|
|
11
|
-
invalid_request: 'the request is malformed.',
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export function pickErrorMessage(code: string | null): string {
|
|
15
|
-
// the page never shows text from the request, so a link to it cannot make
|
|
16
|
-
// boxxy's origin display words an attacker chose.
|
|
17
|
-
const reason =
|
|
18
|
-
code !== null && Object.hasOwn(ERROR_MESSAGES, code) ? ERROR_MESSAGES[code] : undefined;
|
|
19
|
-
|
|
20
|
-
return `boxxy refused this authorization request: ${reason ?? 'the request is not valid.'}`;
|
|
21
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// Adapted from atc: packages/contract/src/gateway-paths.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
|
-
// The routes of the public door: the browser pages a person opens, and the routes a machine
|
|
4
|
-
// client calls. The door serves nothing else.
|
|
5
|
-
export const PUBLIC_DOOR_PATHS = {
|
|
6
|
-
browser: {
|
|
7
|
-
authorize: '/oauth2/authorize',
|
|
8
|
-
login: '/login',
|
|
9
|
-
enroll: '/enroll',
|
|
10
|
-
consent: '/consent',
|
|
11
|
-
error: '/error',
|
|
12
|
-
},
|
|
13
|
-
machine: {
|
|
14
|
-
mcp: '/mcp',
|
|
15
|
-
token: '/oauth2/token',
|
|
16
|
-
revoke: '/oauth2/revoke',
|
|
17
|
-
authorizationServerMetadata: '/.well-known/oauth-authorization-server',
|
|
18
|
-
resourceMetadata: '/.well-known/oauth-protected-resource',
|
|
19
|
-
mcpResourceMetadata: '/.well-known/oauth-protected-resource/mcp',
|
|
20
|
-
},
|
|
21
|
-
} as const;
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// Copied from atc: packages/mcp/src/http/read-token-access.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
|
-
import { grantScopes } from '@boxxy/contract';
|
|
4
|
-
import type { GrantScope } from '@boxxy/contract';
|
|
5
|
-
import { isAPIError } from 'better-auth/api';
|
|
6
|
-
import type { HTTPServerContext } from '../auth/types.ts';
|
|
7
|
-
import { isRecord } from '../utils/is-record.ts';
|
|
8
|
-
|
|
9
|
-
interface TokenAccess {
|
|
10
|
-
readonly clientID: string;
|
|
11
|
-
readonly scopes: readonly GrantScope[];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// An inactive, unknown, expired or revoked token, one bound to another
|
|
15
|
-
// resource, or one without a client id reads as null. Reading records no use.
|
|
16
|
-
export async function readTokenAccess(
|
|
17
|
-
ctx: HTTPServerContext,
|
|
18
|
-
token: string,
|
|
19
|
-
): Promise<TokenAccess | null> {
|
|
20
|
-
let payload: unknown;
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
payload = await ctx.store.auth.api.verifyMCPAccessToken({ body: { token } });
|
|
24
|
-
} catch (error) {
|
|
25
|
-
if (isAPIError(error)) {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
throw error;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (!isRecord(payload) || ![payload['aud']].flat().includes(ctx.resource)) {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const granted = typeof payload['scope'] === 'string' ? payload['scope'].split(' ') : [];
|
|
37
|
-
const clientID = typeof payload['client_id'] === 'string' ? payload['client_id'] : '';
|
|
38
|
-
|
|
39
|
-
if (clientID === '') {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return { clientID, scopes: grantScopes.filter((scope) => granted.includes(scope)) };
|
|
44
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
// Copied from atc: packages/mcp/src/http/verify-access-token.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
|
-
import { deriveTokenHash } from '../auth/derive-token-hash.ts';
|
|
4
|
-
import { normalizeClientName } from '../auth/normalize-client-name.ts';
|
|
5
|
-
import { readClientName } from '../auth/read-client-name.ts';
|
|
6
|
-
import type { HTTPServerContext, VerifiedAccess } from '../auth/types.ts';
|
|
7
|
-
import { writeGrantUse } from '../auth/write-grant-use.ts';
|
|
8
|
-
import { readTokenAccess } from './read-token-access.ts';
|
|
9
|
-
|
|
10
|
-
// Verifying stamps when the token's grant was last used.
|
|
11
|
-
export async function verifyAccessToken(
|
|
12
|
-
ctx: HTTPServerContext,
|
|
13
|
-
token: string,
|
|
14
|
-
): Promise<VerifiedAccess | null> {
|
|
15
|
-
const access = await readTokenAccess(ctx, token);
|
|
16
|
-
|
|
17
|
-
if (access === null) {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
await upsertGrantUse(ctx, token);
|
|
22
|
-
|
|
23
|
-
const storedName = await readClientName(ctx.store.db, access.clientID);
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
clientID: access.clientID,
|
|
27
|
-
clientName: normalizeClientName(storedName, 'remote'),
|
|
28
|
-
scopes: access.scopes,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function upsertGrantUse(ctx: HTTPServerContext, token: string): Promise<void> {
|
|
33
|
-
const row = await ctx.store.db
|
|
34
|
-
.selectFrom('oauthAccessToken')
|
|
35
|
-
.select('authorizationCodeId')
|
|
36
|
-
.where('token', '=', deriveTokenHash(token))
|
|
37
|
-
.executeTakeFirst();
|
|
38
|
-
|
|
39
|
-
const grantID = row?.authorizationCodeId ?? null;
|
|
40
|
-
|
|
41
|
-
if (grantID === null) {
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
await writeGrantUse(ctx.store.db, grantID, ctx.clock.now());
|
|
46
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// Copied from atc: packages/mcp/src/test-utils/build-mock-new-passkey.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
|
-
import type { NewPasskey } from '../auth/create-passkey.ts';
|
|
4
|
-
|
|
5
|
-
export function buildMockNewPasskey(overrides: Partial<NewPasskey> = {}): NewPasskey {
|
|
6
|
-
return {
|
|
7
|
-
tokenHash: 'a'.repeat(64),
|
|
8
|
-
id: 'credential-0001',
|
|
9
|
-
publicKey: 'AQID',
|
|
10
|
-
counter: 0,
|
|
11
|
-
transports: ['internal'],
|
|
12
|
-
now: Date.parse('2026-01-01T00:00:00Z'),
|
|
13
|
-
...overrides,
|
|
14
|
-
};
|
|
15
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
// Copied from atc: packages/mcp/src/test-utils/build-passkey-seeder.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
|
-
import { seedPasskey } from './seed-passkey.ts';
|
|
4
|
-
import type { PasskeyInput } from './seed-passkey.ts';
|
|
5
|
-
import type { MCPAuthSeedDeps } from './types.ts';
|
|
6
|
-
|
|
7
|
-
export function buildPasskeySeeder(deps: MCPAuthSeedDeps) {
|
|
8
|
-
return {
|
|
9
|
-
passkey: (input?: PasskeyInput) => seedPasskey(deps, input),
|
|
10
|
-
};
|
|
11
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// Copied from atc: packages/mcp/src/test-utils/build-seeder.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
|
-
import { buildPasskeySeeder } from './build-passkey-seeder.ts';
|
|
4
|
-
import type { MCPAuthSeedDeps } from './types.ts';
|
|
5
|
-
|
|
6
|
-
export type TestDeps = MCPAuthSeedDeps;
|
|
7
|
-
|
|
8
|
-
export function buildSeeder(deps: TestDeps) {
|
|
9
|
-
return buildPasskeySeeder(deps);
|
|
10
|
-
}
|