@boxxy/auth-oauth 0.0.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -2
- package/dist/auth/answer-authorize-request.d.ts +2 -0
- package/dist/auth/answer-consent-request.d.ts +10 -0
- package/dist/auth/answer-enroll-request.d.ts +9 -0
- package/dist/auth/answer-login-request.d.ts +9 -0
- package/dist/auth/approval-window-seconds.d.ts +1 -0
- package/dist/auth/build-consent-binding.d.ts +1 -0
- package/dist/auth/create-approval-state.d.ts +41 -0
- package/dist/auth/create-auth-admin.d.ts +33 -0
- package/dist/auth/create-enrollment-state.d.ts +6 -0
- package/dist/auth/create-enrollment-token.d.ts +6 -0
- package/dist/auth/create-owner-plugin.d.ts +38 -0
- package/dist/auth/create-passkey.d.ts +11 -0
- package/dist/auth/create-security-event-log.d.ts +12 -0
- package/dist/auth/derive-token-hash.d.ts +1 -0
- package/dist/auth/format-security-event.d.ts +7 -0
- package/dist/auth/is-allowed-redirect-uri.d.ts +1 -0
- package/dist/auth/load-web-authn.d.ts +6 -0
- package/dist/auth/mcp-auth-database.d.ts +5 -0
- package/dist/auth/mcp-auth-schema.d.ts +54 -0
- package/dist/auth/normalize-client-name.d.ts +1 -0
- package/dist/auth/open-auth-database.d.ts +2 -0
- package/dist/auth/open-mcp-auth.d.ts +80 -0
- package/dist/auth/passkey-script.d.ts +4 -0
- package/dist/auth/read-client-name.d.ts +3 -0
- package/dist/auth/read-client-views.d.ts +9 -0
- package/dist/auth/read-enrollment-token.d.ts +3 -0
- package/dist/auth/read-grant-views.d.ts +12 -0
- package/dist/auth/read-owner-session-id.d.ts +2 -0
- package/dist/auth/record-refused-exchange.d.ts +10 -0
- package/dist/auth/remove-client.d.ts +3 -0
- package/dist/auth/remove-owner-session.d.ts +3 -0
- package/dist/auth/render-consent-page.d.ts +9 -0
- package/dist/auth/render-enroll-page.d.ts +10 -0
- package/dist/auth/render-login-page.d.ts +9 -0
- package/dist/auth/revoke-grant.d.ts +3 -0
- package/dist/auth/to-html-text.d.ts +1 -0
- package/dist/auth/to-script-json.d.ts +1 -0
- package/dist/auth/types.d.ts +79 -0
- package/dist/auth/update-passkey-use.d.ts +10 -0
- package/dist/auth/verify-oauth-query.d.ts +1 -0
- package/dist/auth/verify-passkey-assertion.d.ts +11 -0
- package/dist/auth/verify-passkey-registration.d.ts +14 -0
- package/dist/auth/verify-session-passkey.d.ts +8 -0
- package/dist/auth/write-grant-use.d.ts +3 -0
- package/dist/http/answer-mcp-request.d.ts +9 -0
- package/dist/http/build-page-response.d.ts +5 -0
- package/dist/http/create-mcp-http-app.d.ts +92 -0
- package/dist/http/create-public-door.d.ts +20 -0
- package/dist/http/is-loopback-host.d.ts +1 -0
- package/dist/http/normalize-public-url.d.ts +1 -0
- package/dist/http/pick-error-message.d.ts +1 -0
- package/dist/http/public-door-paths.d.ts +17 -0
- package/dist/http/read-token-access.d.ts +8 -0
- package/dist/http/verify-access-token.d.ts +2 -0
- package/dist/index.d.ts +8 -0
- package/dist/test-utils/authorize-test-client.d.ts +13 -0
- package/dist/test-utils/build-mock-new-passkey.d.ts +2 -0
- package/dist/test-utils/build-passkey-seeder.d.ts +5 -0
- package/dist/test-utils/build-seeder.d.ts +5 -0
- package/dist/test-utils/build-stub-authenticator.d.ts +48 -0
- package/dist/test-utils/build-stub-clock.d.ts +7 -0
- package/dist/test-utils/load-web-authn-early.d.ts +1 -0
- package/dist/test-utils/register-test-cleanup.d.ts +1 -0
- package/dist/test-utils/seed-passkey.d.ts +4 -0
- package/dist/test-utils/setup-temp-dir.d.ts +6 -0
- package/dist/test-utils/start-stub-telemetry-collector.d.ts +5 -0
- package/dist/test-utils/types.d.ts +7 -0
- package/dist/utils/is-record.d.ts +1 -0
- package/dist/utils/parse-json-strings.d.ts +1 -0
- package/dist/utils/types.d.ts +4 -0
- package/package.json +42 -1
- package/src/auth/answer-authorize-request.ts +137 -0
- package/src/auth/answer-consent-request.ts +189 -0
- package/src/auth/answer-enroll-request.ts +190 -0
- package/src/auth/answer-login-request.ts +256 -0
- package/src/auth/approval-window-seconds.ts +5 -0
- package/src/auth/build-consent-binding.ts +13 -0
- package/src/auth/create-approval-state.ts +221 -0
- package/src/auth/create-auth-admin.ts +78 -0
- package/src/auth/create-enrollment-state.ts +56 -0
- package/src/auth/create-enrollment-token.ts +28 -0
- package/src/auth/create-owner-plugin.ts +113 -0
- package/src/auth/create-passkey.ts +48 -0
- package/src/auth/create-security-event-log.ts +97 -0
- package/src/auth/derive-token-hash.ts +7 -0
- package/src/auth/format-security-event.ts +72 -0
- package/src/auth/is-allowed-redirect-uri.ts +19 -0
- package/src/auth/load-web-authn.ts +19 -0
- package/src/auth/mcp-auth-database.ts +10 -0
- package/src/auth/mcp-auth-schema.ts +56 -0
- package/src/auth/normalize-client-name.ts +22 -0
- package/src/auth/open-auth-database.ts +22 -0
- package/src/auth/open-mcp-auth.ts +138 -0
- package/src/auth/passkey-script.ts +50 -0
- package/src/auth/read-client-name.ts +17 -0
- package/src/auth/read-client-views.ts +27 -0
- package/src/auth/read-enrollment-token.ts +20 -0
- package/src/auth/read-grant-views.ts +53 -0
- package/src/auth/read-owner-session-id.ts +16 -0
- package/src/auth/record-refused-exchange.ts +74 -0
- package/src/auth/remove-client.ts +33 -0
- package/src/auth/remove-owner-session.ts +11 -0
- package/src/auth/render-consent-page.ts +61 -0
- package/src/auth/render-enroll-page.ts +59 -0
- package/src/auth/render-login-page.ts +58 -0
- package/src/auth/revoke-grant.ts +25 -0
- package/src/auth/to-html-text.ts +10 -0
- package/src/auth/to-script-json.ts +8 -0
- package/src/auth/types.ts +115 -0
- package/src/auth/update-passkey-use.ts +31 -0
- package/src/auth/verify-oauth-query.ts +41 -0
- package/src/auth/verify-passkey-assertion.ts +124 -0
- package/src/auth/verify-passkey-registration.ts +118 -0
- package/src/auth/verify-session-passkey.ts +47 -0
- package/src/auth/write-grant-use.ts +18 -0
- package/src/http/answer-mcp-request.ts +77 -0
- package/src/http/build-page-response.ts +26 -0
- package/src/http/create-mcp-http-app.ts +340 -0
- package/src/http/create-public-door.ts +150 -0
- package/src/http/is-loopback-host.ts +10 -0
- package/src/http/normalize-public-url.ts +29 -0
- package/src/http/pick-error-message.ts +21 -0
- package/src/http/public-door-paths.ts +21 -0
- package/src/http/read-token-access.ts +44 -0
- package/src/http/verify-access-token.ts +46 -0
- package/src/index.ts +8 -0
- package/src/test-utils/authorize-test-client.ts +194 -0
- package/src/test-utils/build-mock-new-passkey.ts +15 -0
- package/src/test-utils/build-passkey-seeder.ts +11 -0
- package/src/test-utils/build-seeder.ts +10 -0
- package/src/test-utils/build-stub-authenticator.ts +244 -0
- package/src/test-utils/build-stub-clock.ts +65 -0
- package/src/test-utils/load-web-authn-early.ts +8 -0
- package/src/test-utils/register-test-cleanup.ts +55 -0
- package/src/test-utils/seed-passkey.ts +32 -0
- package/src/test-utils/setup-temp-dir.ts +24 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +27 -0
- package/src/test-utils/types.ts +11 -0
- package/src/utils/is-record.ts +5 -0
- package/src/utils/parse-json-strings.ts +15 -0
- package/src/utils/types.ts +8 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/verify-session-passkey.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
5
|
+
|
|
6
|
+
interface SessionPasskey {
|
|
7
|
+
// The owner session a passkey sign-in just created.
|
|
8
|
+
readonly sessionID: string;
|
|
9
|
+
|
|
10
|
+
// The passkey whose assertion signed the owner in.
|
|
11
|
+
readonly passkeyID: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function verifySessionPasskey(
|
|
15
|
+
db: Kysely<MCPAuthSchema>,
|
|
16
|
+
check: SessionPasskey,
|
|
17
|
+
): Promise<boolean> {
|
|
18
|
+
try {
|
|
19
|
+
return await db.transaction().execute(async (trx) => {
|
|
20
|
+
const passkey = await trx
|
|
21
|
+
.selectFrom('boxxy_passkey')
|
|
22
|
+
.select('id')
|
|
23
|
+
.where('id', '=', check.passkeyID)
|
|
24
|
+
.executeTakeFirst();
|
|
25
|
+
|
|
26
|
+
const session = await trx
|
|
27
|
+
.selectFrom('session')
|
|
28
|
+
.select('id')
|
|
29
|
+
.where('id', '=', check.sessionID)
|
|
30
|
+
.executeTakeFirst();
|
|
31
|
+
|
|
32
|
+
if (passkey !== undefined && session !== undefined) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await trx.deleteFrom('session').where('id', '=', check.sessionID).execute();
|
|
37
|
+
|
|
38
|
+
return false;
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
// a transaction that fails, such as one a concurrent write keeps busy, still
|
|
42
|
+
// removes the session.
|
|
43
|
+
await db.deleteFrom('session').where('id', '=', check.sessionID).execute();
|
|
44
|
+
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/write-grant-use.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
5
|
+
|
|
6
|
+
export async function writeGrantUse(
|
|
7
|
+
db: Kysely<MCPAuthSchema>,
|
|
8
|
+
grantID: string,
|
|
9
|
+
now: number,
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
const lastUsedAt = new Date(now).toISOString();
|
|
12
|
+
|
|
13
|
+
await db
|
|
14
|
+
.insertInto('boxxy_grant_use')
|
|
15
|
+
.values({ grant_id: grantID, last_used_at: lastUsedAt })
|
|
16
|
+
.onConflict((conflict) => conflict.column('grant_id').doUpdateSet({ last_used_at: lastUsedAt }))
|
|
17
|
+
.execute();
|
|
18
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/http/answer-mcp-request.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { HTTPServerContext, Requester } from '../auth/types.ts';
|
|
4
|
+
import { PUBLIC_DOOR_PATHS } from './public-door-paths.ts';
|
|
5
|
+
import { verifyAccessToken } from './verify-access-token.ts';
|
|
6
|
+
|
|
7
|
+
// What the MCP transport reads of a verified token: the scopes it holds, and the resource metadata
|
|
8
|
+
// that a scope challenge points at.
|
|
9
|
+
export interface McpAuthInfo {
|
|
10
|
+
readonly token: string;
|
|
11
|
+
readonly clientId: string;
|
|
12
|
+
readonly scopes: string[];
|
|
13
|
+
readonly resourceMetadataUrl: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Serves one MCP request for a token that verified.
|
|
17
|
+
export type McpServe = (request: Request, authInfo: McpAuthInfo) => Promise<Response>;
|
|
18
|
+
|
|
19
|
+
// `requester` is where the request came from, for its security events.
|
|
20
|
+
export async function answerMCPRequest(
|
|
21
|
+
ctx: HTTPServerContext,
|
|
22
|
+
request: Request,
|
|
23
|
+
requester: Requester,
|
|
24
|
+
serve: McpServe,
|
|
25
|
+
): Promise<Response> {
|
|
26
|
+
const path = new URL(request.url).pathname;
|
|
27
|
+
|
|
28
|
+
const metadataURL = `${ctx.origin}${PUBLIC_DOOR_PATHS.machine.mcpResourceMetadata}`;
|
|
29
|
+
const authorization = request.headers.get('authorization');
|
|
30
|
+
const token = /^Bearer (?<token>\S+)$/iu.exec(authorization ?? '')?.groups?.['token'];
|
|
31
|
+
|
|
32
|
+
if (token === undefined) {
|
|
33
|
+
// a request with no header at all is a client discovering the
|
|
34
|
+
// authorization server, so only a header that holds no bearer token is
|
|
35
|
+
// recorded.
|
|
36
|
+
if (authorization !== null) {
|
|
37
|
+
ctx.security.record({
|
|
38
|
+
kind: 'bearer',
|
|
39
|
+
outcome: 'rejected',
|
|
40
|
+
reason: 'malformed',
|
|
41
|
+
path,
|
|
42
|
+
requester,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return new Response(null, {
|
|
47
|
+
status: 401,
|
|
48
|
+
headers: { 'www-authenticate': `Bearer resource_metadata="${metadataURL}"` },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const access = await verifyAccessToken(ctx, token);
|
|
53
|
+
|
|
54
|
+
if (access === null) {
|
|
55
|
+
ctx.security.record({
|
|
56
|
+
kind: 'bearer',
|
|
57
|
+
outcome: 'rejected',
|
|
58
|
+
reason: 'invalid_token',
|
|
59
|
+
path,
|
|
60
|
+
requester,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return new Response(null, {
|
|
64
|
+
status: 401,
|
|
65
|
+
headers: {
|
|
66
|
+
'www-authenticate': `Bearer error="invalid_token", resource_metadata="${metadataURL}"`,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return serve(request, {
|
|
72
|
+
token,
|
|
73
|
+
clientId: access.clientID,
|
|
74
|
+
scopes: [...access.scopes],
|
|
75
|
+
resourceMetadataUrl: metadataURL,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/http/build-page-response.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { toHTMLText } from '../auth/to-html-text.ts';
|
|
4
|
+
|
|
5
|
+
export function buildPageResponse(
|
|
6
|
+
status: number,
|
|
7
|
+
body: { readonly page: string } | { readonly message: string },
|
|
8
|
+
formTargets: readonly string[] = [],
|
|
9
|
+
scriptSources: readonly string[] = [],
|
|
10
|
+
): Response {
|
|
11
|
+
const html =
|
|
12
|
+
'page' in body
|
|
13
|
+
? body.page
|
|
14
|
+
: `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>boxxy</title></head><body><p>${toHTMLText(body.message)}</p></body></html>`;
|
|
15
|
+
|
|
16
|
+
return new Response(html, {
|
|
17
|
+
status,
|
|
18
|
+
headers: {
|
|
19
|
+
'content-type': 'text/html; charset=utf-8',
|
|
20
|
+
'content-security-policy': `default-src 'none'; style-src 'unsafe-inline';${scriptSources.length === 0 ? '' : ` script-src ${scriptSources.join(' ')};`} form-action 'self'${formTargets.map((target) => ` ${target}`).join('')}; frame-ancestors 'none'; base-uri 'none'`,
|
|
21
|
+
'x-frame-options': 'DENY',
|
|
22
|
+
'cache-control': 'no-store',
|
|
23
|
+
'referrer-policy': 'same-origin',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Adapted from atc: packages/mcp/src/http/start-mcp-http-server.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
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';
|
|
7
|
+
import { createAuthAdmin } from '../auth/create-auth-admin.ts';
|
|
8
|
+
import type { AuthAdmin } from '../auth/create-auth-admin.ts';
|
|
9
|
+
import { createEnrollmentState } from '../auth/create-enrollment-state.ts';
|
|
10
|
+
import { createSecurityEventLog } from '../auth/create-security-event-log.ts';
|
|
11
|
+
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';
|
|
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
|
+
import { isLoopbackHost } from './is-loopback-host.ts';
|
|
18
|
+
import { normalizePublicURL } from './normalize-public-url.ts';
|
|
19
|
+
import { PUBLIC_DOOR_PATHS } from './public-door-paths.ts';
|
|
20
|
+
|
|
21
|
+
export interface PublicDoorOptions {
|
|
22
|
+
// The origin clients reach the door at: the OAuth issuer.
|
|
23
|
+
readonly publicURL: string;
|
|
24
|
+
|
|
25
|
+
// The address the door listens on, which decides whether the public URL has to be https.
|
|
26
|
+
readonly hostname: string;
|
|
27
|
+
readonly dbPath: string;
|
|
28
|
+
|
|
29
|
+
// The socket peers whose `CF-Connecting-IP` header a security event labels `trusted`, each
|
|
30
|
+
// exactly as the socket reports its address.
|
|
31
|
+
readonly trustForwardedFrom: readonly string[];
|
|
32
|
+
|
|
33
|
+
// Receives one JSON line per sign-in outcome or refused token.
|
|
34
|
+
readonly writeSecurityLine: (line: string) => void;
|
|
35
|
+
|
|
36
|
+
// The address of the socket a request came in on, or null.
|
|
37
|
+
readonly readPeer: (request: Request) => string | null;
|
|
38
|
+
|
|
39
|
+
// Answers an MCP request whose bearer token verified.
|
|
40
|
+
readonly serveMCP: McpServe;
|
|
41
|
+
readonly clock?: AuthClock;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PublicDoor {
|
|
45
|
+
// Answers a request of the door; it serves nothing but the MCP and OAuth routes.
|
|
46
|
+
readonly answerRequest: (request: Request) => Promise<Response>;
|
|
47
|
+
|
|
48
|
+
// The door answers to the loopback address on this port as well as to its public host.
|
|
49
|
+
readonly addLocalPort: (port: number) => void;
|
|
50
|
+
readonly admin: AuthAdmin;
|
|
51
|
+
readonly stop: () => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const systemClock: AuthClock = {
|
|
55
|
+
now: () => Date.now(),
|
|
56
|
+
schedule: (callback, delayMs) => {
|
|
57
|
+
const timer = setTimeout(callback, delayMs);
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
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.
|
|
67
|
+
export async function createPublicDoor(options: PublicDoorOptions): Promise<PublicDoor> {
|
|
68
|
+
const clock = options.clock ?? systemClock;
|
|
69
|
+
const origin = normalizePublicURL(options.publicURL);
|
|
70
|
+
|
|
71
|
+
// The door speaks plain HTTP, so a listener other machines can reach sits behind something that
|
|
72
|
+
// terminates TLS, and the public URL clients use is that https origin.
|
|
73
|
+
if (!isLoopbackHost(options.hostname) && !origin.startsWith('https:')) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`listening on '${options.hostname}' reaches beyond this machine, so the public door needs an https public URL`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const security = createSecurityEventLog({ print: options.writeSecurityLine, clock });
|
|
80
|
+
|
|
81
|
+
const trustedProxies: ReadonlySet<string> = new Set(options.trustForwardedFrom);
|
|
82
|
+
|
|
83
|
+
// The socket's peer is the requester, or the proxy or tunnel in front of the door. Only a listed
|
|
84
|
+
// peer vouches for the forwarded address.
|
|
85
|
+
const readRequester = (request: Request): Requester => {
|
|
86
|
+
const socketAddress = options.readPeer(request);
|
|
87
|
+
const connectingIP = request.headers.get('cf-connecting-ip');
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
socketAddress,
|
|
91
|
+
connectingIP:
|
|
92
|
+
connectingIP === null
|
|
93
|
+
? null
|
|
94
|
+
: {
|
|
95
|
+
address: connectingIP,
|
|
96
|
+
provenance:
|
|
97
|
+
socketAddress !== null && trustedProxies.has(socketAddress)
|
|
98
|
+
? 'trusted'
|
|
99
|
+
: 'unverified',
|
|
100
|
+
},
|
|
101
|
+
userAgent: request.headers.get('user-agent'),
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
mkdirSync(dirname(options.dbPath), { recursive: true });
|
|
106
|
+
|
|
107
|
+
const store = await openMCPAuth({
|
|
108
|
+
dbPath: options.dbPath,
|
|
109
|
+
openDatabase: openAuthDatabase,
|
|
110
|
+
origin,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const hosts = new Set<string>([new URL(origin).host]);
|
|
114
|
+
const origins = new Set<string>([origin]);
|
|
115
|
+
|
|
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
|
+
|
|
126
|
+
const app = createMCPHTTPApp({
|
|
127
|
+
hosts,
|
|
128
|
+
origins,
|
|
129
|
+
readContext: () => context,
|
|
130
|
+
readRequester,
|
|
131
|
+
serveMCP: options.serveMCP,
|
|
132
|
+
security,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
answerRequest: (request) => app.handle(request),
|
|
137
|
+
addLocalPort: (port) => {
|
|
138
|
+
hosts.add(`127.0.0.1:${port}`);
|
|
139
|
+
hosts.add(`localhost:${port}`);
|
|
140
|
+
origins.add(`http://127.0.0.1:${port}`);
|
|
141
|
+
origins.add(`http://localhost:${port}`);
|
|
142
|
+
},
|
|
143
|
+
admin: createAuthAdmin({ store, origin, clock }),
|
|
144
|
+
stop: async () => {
|
|
145
|
+
await store.close();
|
|
146
|
+
|
|
147
|
+
security.drain();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/http/is-loopback-host.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
export function isLoopbackHost(host: string): boolean {
|
|
4
|
+
return (
|
|
5
|
+
host === 'localhost' ||
|
|
6
|
+
host === '::1' ||
|
|
7
|
+
host === '[::1]' ||
|
|
8
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(host)
|
|
9
|
+
);
|
|
10
|
+
}
|