@boxxy/auth-oauth 0.0.1 → 0.2.2
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,72 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/format-security-event.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
|
|
4
|
+
import { normalizeClientName } from './normalize-client-name.ts';
|
|
5
|
+
import type { SecurityEvent } from './types.ts';
|
|
6
|
+
|
|
7
|
+
export interface SuppressedSecurityEvent {
|
|
8
|
+
readonly kind: 'suppressed';
|
|
9
|
+
readonly reason: SecurityEvent['kind'];
|
|
10
|
+
readonly count: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The paths an event may hold as its route.
|
|
14
|
+
const ROUTES: ReadonlySet<string> = new Set(
|
|
15
|
+
Object.values(PUBLIC_DOOR_PATHS).flatMap((group) => Object.values(group)),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export function formatSecurityEvent(
|
|
19
|
+
event: SecurityEvent | SuppressedSecurityEvent,
|
|
20
|
+
at: number,
|
|
21
|
+
): string {
|
|
22
|
+
const head = { version: 1, ts: new Date(at).toISOString(), kind: event.kind };
|
|
23
|
+
|
|
24
|
+
if (event.kind === 'suppressed') {
|
|
25
|
+
return JSON.stringify({ ...head, reason: event.reason, count: event.count });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const requester = event.requester;
|
|
29
|
+
|
|
30
|
+
const fields = {
|
|
31
|
+
...head,
|
|
32
|
+
outcome: event.outcome,
|
|
33
|
+
...('reason' in event && event.reason !== undefined ? { reason: event.reason } : {}),
|
|
34
|
+
...(event.kind === 'owner_auth'
|
|
35
|
+
? { method: event.path === PUBLIC_DOOR_PATHS.browser.enroll ? 'enrollment_token' : 'passkey' }
|
|
36
|
+
: {}),
|
|
37
|
+
route: ROUTES.has(event.path) ? event.path : 'other',
|
|
38
|
+
...(event.client === undefined
|
|
39
|
+
? {}
|
|
40
|
+
: {
|
|
41
|
+
client: {
|
|
42
|
+
id: toEventText(event.client.id, 64),
|
|
43
|
+
name: toEventText(event.client.name, 100),
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
...(event.redirectHost === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { redirectHost: toEventText(event.redirectHost, 255) }),
|
|
49
|
+
...(event.correlationID === undefined ? {} : { correlationID: event.correlationID }),
|
|
50
|
+
...(requester.socketAddress === null
|
|
51
|
+
? {}
|
|
52
|
+
: { peer: { address: toEventText(requester.socketAddress, 45), provenance: 'socket' } }),
|
|
53
|
+
...(requester.connectingIP === null
|
|
54
|
+
? {}
|
|
55
|
+
: {
|
|
56
|
+
forwarded: {
|
|
57
|
+
address: toEventText(requester.connectingIP.address, 45),
|
|
58
|
+
header: 'cf-connecting-ip',
|
|
59
|
+
provenance: requester.connectingIP.provenance,
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
...(requester.userAgent === null
|
|
63
|
+
? {}
|
|
64
|
+
: { userAgent: { summary: toEventText(requester.userAgent, 60), provenance: 'client' } }),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return JSON.stringify(fields);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toEventText(value: string, maxLength: number): string {
|
|
71
|
+
return normalizeClientName(value, '', maxLength);
|
|
72
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/is-allowed-redirect-uri.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { isLoopbackHost } from '../http/is-loopback-host.ts';
|
|
4
|
+
|
|
5
|
+
export function isAllowedRedirectURI(raw: string): boolean {
|
|
6
|
+
let url: URL;
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
url = new URL(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (url.hash !== '' || url.username !== '' || url.password !== '') {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHost(url.hostname));
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/load-web-authn.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
verifyAuthenticationResponse,
|
|
5
|
+
verifyRegistrationResponse,
|
|
6
|
+
} from '@simplewebauthn/server';
|
|
7
|
+
|
|
8
|
+
export interface WebAuthn {
|
|
9
|
+
readonly verifyAuthenticationResponse: typeof verifyAuthenticationResponse;
|
|
10
|
+
readonly verifyRegistrationResponse: typeof verifyRegistrationResponse;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function loadWebAuthn(): Promise<WebAuthn> {
|
|
14
|
+
// the library imports this polyfill itself, but a compiled binary's bundle runs
|
|
15
|
+
// its certificate code before that import, so the polyfill loads first.
|
|
16
|
+
await import('reflect-metadata');
|
|
17
|
+
|
|
18
|
+
return import('@simplewebauthn/server');
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/mcp-auth-database.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { Dialect } from 'kysely';
|
|
4
|
+
|
|
5
|
+
export interface MCPAuthDatabase {
|
|
6
|
+
readonly dialect: Dialect;
|
|
7
|
+
|
|
8
|
+
// releases the file, once Kysely is done with it.
|
|
9
|
+
readonly close: () => void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/mcp-auth-schema.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
// only the columns boxxy reads and writes itself: better-auth owns the tables and
|
|
4
|
+
// their migrations. Dates are ISO 8601 strings and string lists are JSON arrays.
|
|
5
|
+
export interface MCPAuthSchema {
|
|
6
|
+
readonly oauthClient: {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly clientId: string;
|
|
9
|
+
readonly name: string | null;
|
|
10
|
+
readonly redirectUris: string;
|
|
11
|
+
readonly createdAt: string | null;
|
|
12
|
+
};
|
|
13
|
+
readonly oauthAccessToken: {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly token: string;
|
|
16
|
+
readonly clientId: string;
|
|
17
|
+
readonly sessionId: string | null;
|
|
18
|
+
readonly authorizationCodeId: string | null;
|
|
19
|
+
};
|
|
20
|
+
readonly oauthRefreshToken: {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly clientId: string;
|
|
23
|
+
readonly sessionId: string | null;
|
|
24
|
+
readonly authorizationCodeId: string | null;
|
|
25
|
+
readonly scopes: string;
|
|
26
|
+
readonly createdAt: string;
|
|
27
|
+
readonly expiresAt: string;
|
|
28
|
+
readonly revoked: string | null;
|
|
29
|
+
};
|
|
30
|
+
readonly oauthConsent: { readonly id: string; readonly clientId: string };
|
|
31
|
+
readonly oauthResource: { readonly identifier: string };
|
|
32
|
+
readonly session: { readonly id: string; readonly expiresAt: string };
|
|
33
|
+
|
|
34
|
+
// When each grant last reached /mcp, keyed by the grant's authorization code id.
|
|
35
|
+
readonly boxxy_grant_use: { readonly grant_id: string; readonly last_used_at: string };
|
|
36
|
+
|
|
37
|
+
// The owner's passkeys: the credential id and its COSE public key, both
|
|
38
|
+
// base64url, the signature counter the authenticator last returned, and
|
|
39
|
+
// its transports as a JSON array.
|
|
40
|
+
readonly boxxy_passkey: {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly public_key: string;
|
|
43
|
+
readonly counter: number;
|
|
44
|
+
readonly transports: string;
|
|
45
|
+
readonly created_at: string;
|
|
46
|
+
readonly last_used_at: string | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// The hash of each enrollment token a host command minted and no passkey
|
|
50
|
+
// has used yet.
|
|
51
|
+
readonly boxxy_enrollment_token: {
|
|
52
|
+
readonly token_hash: string;
|
|
53
|
+
readonly created_at: string;
|
|
54
|
+
readonly expires_at: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/normalize-client-name.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
export function normalizeClientName(
|
|
4
|
+
raw: unknown,
|
|
5
|
+
fallback = 'unnamed client',
|
|
6
|
+
maxLength = 100,
|
|
7
|
+
): string {
|
|
8
|
+
if (typeof raw !== 'string') {
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const folded = raw
|
|
13
|
+
.replaceAll(/[\t\n\v\f\r]/gu, ' ')
|
|
14
|
+
.replaceAll(/[\p{Cc}\p{Cf}]/gu, '')
|
|
15
|
+
.replaceAll(/\s+/gu, ' ')
|
|
16
|
+
.trim();
|
|
17
|
+
|
|
18
|
+
// The cut counts code points, so it never splits a surrogate pair.
|
|
19
|
+
const cut = folded.replace(new RegExp(`^(?<kept>.{${maxLength}}).+$`, 'su'), '$<kept>').trim();
|
|
20
|
+
|
|
21
|
+
return cut === '' ? fallback : cut;
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Adapted from atc: packages/mcp/src/testing/open-test-auth-database.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { Database } from 'bun:sqlite';
|
|
4
|
+
import { buildSQLiteDialect } from '@zgeoff/kysely-bun-sqlite';
|
|
5
|
+
import type { MCPAuthDatabase } from './mcp-auth-database.ts';
|
|
6
|
+
|
|
7
|
+
// A write waits for another process's lock, and the cascades from a client to its tokens need
|
|
8
|
+
// foreign keys.
|
|
9
|
+
export function openAuthDatabase(path: string): MCPAuthDatabase {
|
|
10
|
+
const sqlite = new Database(path, { create: true });
|
|
11
|
+
|
|
12
|
+
sqlite.run('PRAGMA journal_mode = WAL;');
|
|
13
|
+
sqlite.run('PRAGMA busy_timeout = 5000;');
|
|
14
|
+
sqlite.run('PRAGMA foreign_keys = ON;');
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
dialect: buildSQLiteDialect(sqlite),
|
|
18
|
+
close: () => {
|
|
19
|
+
sqlite.close();
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/open-mcp-auth.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { chmodSync, closeSync, openSync } from 'node:fs';
|
|
5
|
+
import { mcp } from '@better-auth/mcp';
|
|
6
|
+
import { oauthProvider } from '@better-auth/oauth-provider';
|
|
7
|
+
import type { OAuthOptions, Scope } from '@better-auth/oauth-provider';
|
|
8
|
+
import { grantScopes } from '@boxxy/contract';
|
|
9
|
+
import { betterAuth } from 'better-auth';
|
|
10
|
+
import type { BetterAuthPlugin } from 'better-auth';
|
|
11
|
+
import { getMigrations } from 'better-auth/db/migration';
|
|
12
|
+
import { Kysely, sql } from 'kysely';
|
|
13
|
+
import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
|
|
14
|
+
import { APPROVAL_WINDOW_SECONDS } from './approval-window-seconds.ts';
|
|
15
|
+
import { createOwnerPlugin } from './create-owner-plugin.ts';
|
|
16
|
+
import { deriveTokenHash } from './derive-token-hash.ts';
|
|
17
|
+
import type { MCPAuthDatabase } from './mcp-auth-database.ts';
|
|
18
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
19
|
+
|
|
20
|
+
interface MCPAuthOptions {
|
|
21
|
+
readonly dbPath: string;
|
|
22
|
+
|
|
23
|
+
// The public origin: the issuer, with `<origin>/mcp` the one resource tokens
|
|
24
|
+
// are bound to. Null opens the store to manage clients and grants only, with
|
|
25
|
+
// no resource served.
|
|
26
|
+
readonly origin: string | null;
|
|
27
|
+
|
|
28
|
+
// How long a rotated refresh token still answers with its successor; 0
|
|
29
|
+
// treats any reuse as replay.
|
|
30
|
+
readonly refreshReuseSeconds?: number;
|
|
31
|
+
|
|
32
|
+
// Opens the database file, which the entry wires to boxxy's SQLite opener.
|
|
33
|
+
readonly openDatabase: (path: string) => MCPAuthDatabase;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The owner's session lives only to carry one approval from the code page
|
|
37
|
+
// through consent to the code exchange, which every token detaches from.
|
|
38
|
+
const OWNER_SESSION_SECONDS = 3 * APPROVAL_WINDOW_SECONDS;
|
|
39
|
+
|
|
40
|
+
export async function openMCPAuth(options: MCPAuthOptions) {
|
|
41
|
+
// better-auth reads these when it builds its context; an empty endpoint
|
|
42
|
+
// turns its telemetry into a no-op.
|
|
43
|
+
process.env['BETTER_AUTH_TELEMETRY'] = '0';
|
|
44
|
+
process.env['BETTER_AUTH_TELEMETRY_ENDPOINT'] = '';
|
|
45
|
+
|
|
46
|
+
// The file holds token hashes and owner sessions, so only its owner may
|
|
47
|
+
// read it. SQLite gives the WAL and shared-memory files the main file's
|
|
48
|
+
// mode, so the mode is set before SQLite opens it.
|
|
49
|
+
closeSync(openSync(options.dbPath, 'a', 0o600));
|
|
50
|
+
chmodSync(options.dbPath, 0o600);
|
|
51
|
+
|
|
52
|
+
const database = options.openDatabase(options.dbPath);
|
|
53
|
+
|
|
54
|
+
const db = new Kysely<MCPAuthSchema>({ dialect: database.dialect });
|
|
55
|
+
|
|
56
|
+
const providerOptions: OAuthOptions<Scope[]> = {
|
|
57
|
+
loginPage: PUBLIC_DOOR_PATHS.browser.login,
|
|
58
|
+
consentPage: PUBLIC_DOOR_PATHS.browser.consent,
|
|
59
|
+
scopes: [...grantScopes, 'offline_access'],
|
|
60
|
+
disableJwtPlugin: true,
|
|
61
|
+
allowDynamicClientRegistration: false,
|
|
62
|
+
grantTypes: ['authorization_code', 'refresh_token'],
|
|
63
|
+
|
|
64
|
+
// boxxy serves one resource, so every client may request it.
|
|
65
|
+
enforcePerClientResources: false,
|
|
66
|
+
accessTokenExpiresIn: 3600,
|
|
67
|
+
codeExpiresIn: APPROVAL_WINDOW_SECONDS,
|
|
68
|
+
refreshTokenReuseInterval: options.refreshReuseSeconds ?? 0,
|
|
69
|
+
storeTokens: { hash: (token: string) => Promise.resolve(deriveTokenHash(token)) },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const provider =
|
|
73
|
+
options.origin === null
|
|
74
|
+
? oauthProvider(providerOptions)
|
|
75
|
+
: mcp({ ...providerOptions, resource: `${options.origin}${PUBLIC_DOOR_PATHS.machine.mcp}` });
|
|
76
|
+
|
|
77
|
+
const baseURL = options.origin ?? 'http://127.0.0.1';
|
|
78
|
+
|
|
79
|
+
const auth = betterAuth({
|
|
80
|
+
baseURL,
|
|
81
|
+
basePath: '/',
|
|
82
|
+
secret: randomBytes(32).toString('base64url'),
|
|
83
|
+
database: { db, type: 'sqlite' },
|
|
84
|
+
telemetry: { enabled: false },
|
|
85
|
+
logger: { level: 'error' },
|
|
86
|
+
rateLimit: { enabled: false },
|
|
87
|
+
|
|
88
|
+
// Every open runs the migrations itself, so the startup schema check
|
|
89
|
+
// would only report the tables a fresh database has yet to get.
|
|
90
|
+
advanced: { database: { validateSchema: false } },
|
|
91
|
+
trustedOrigins: [baseURL],
|
|
92
|
+
session: { expiresIn: OWNER_SESSION_SECONDS, disableSessionRefresh: true },
|
|
93
|
+
|
|
94
|
+
// The provider's own endpoint types do not satisfy the plugin type under
|
|
95
|
+
// exactOptionalPropertyTypes, so boxxy reaches them over HTTP-shaped requests.
|
|
96
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- better-auth's plugin type fails its own type under exactOptionalPropertyTypes
|
|
97
|
+
plugins: [provider as BetterAuthPlugin, createOwnerPlugin()],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const migrations = await getMigrations(auth.options);
|
|
101
|
+
|
|
102
|
+
await migrations.runMigrations();
|
|
103
|
+
|
|
104
|
+
await sql`create table if not exists boxxy_grant_use (grant_id text primary key not null, last_used_at text not null)`.execute(
|
|
105
|
+
db,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await sql`create table if not exists boxxy_passkey (id text primary key not null, public_key text not null, counter integer not null, transports text not null, created_at text not null, last_used_at text)`.execute(
|
|
109
|
+
db,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await sql`create table if not exists boxxy_enrollment_token (token_hash text primary key not null, created_at text not null, expires_at text not null)`.execute(
|
|
113
|
+
db,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// A resource served under an earlier public URL stays a valid token target
|
|
117
|
+
// until its row goes.
|
|
118
|
+
if (options.origin !== null) {
|
|
119
|
+
await db
|
|
120
|
+
.deleteFrom('oauthResource')
|
|
121
|
+
.where('identifier', '!=', `${options.origin}${PUBLIC_DOOR_PATHS.machine.mcp}`)
|
|
122
|
+
.execute();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// better-auth starts its context when it is built, and that start queries
|
|
126
|
+
// the database, so a close before it settles destroys the driver under it.
|
|
127
|
+
await auth.$context;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
auth,
|
|
131
|
+
db,
|
|
132
|
+
async close(): Promise<void> {
|
|
133
|
+
await db.destroy();
|
|
134
|
+
|
|
135
|
+
database.close();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/passkey-script.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// runs the `create` or `get` ceremony a page's `boxxy-passkey` block holds and posts
|
|
6
|
+
// the credential's JSON with the form; a prompt that ends without one leaves the
|
|
7
|
+
// form unsent and the button ready again.
|
|
8
|
+
const SOURCE = `(() => {
|
|
9
|
+
const data = document.getElementById('boxxy-passkey');
|
|
10
|
+
const form = document.getElementById('boxxy-passkey-form');
|
|
11
|
+
const button = document.getElementById('boxxy-passkey-start');
|
|
12
|
+
const status = document.getElementById('boxxy-passkey-status');
|
|
13
|
+
if (data === null || form === null || button === null || status === null) return;
|
|
14
|
+
const spec = JSON.parse(data.textContent);
|
|
15
|
+
const toBytes = (value) => {
|
|
16
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
17
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
18
|
+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
19
|
+
};
|
|
20
|
+
const toText = (buffer) => {
|
|
21
|
+
let text = '';
|
|
22
|
+
for (const byte of new Uint8Array(buffer)) text += String.fromCharCode(byte);
|
|
23
|
+
return btoa(text).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
|
24
|
+
};
|
|
25
|
+
const toDescriptors = (list) => (list || []).map((item) => ({ ...item, id: toBytes(item.id) }));
|
|
26
|
+
button.addEventListener('click', async () => {
|
|
27
|
+
button.disabled = true;
|
|
28
|
+
status.textContent = '';
|
|
29
|
+
try {
|
|
30
|
+
const options = spec.options;
|
|
31
|
+
const credential = spec.mode === 'create'
|
|
32
|
+
? await navigator.credentials.create({ publicKey: { ...options, challenge: toBytes(options.challenge), user: { ...options.user, id: toBytes(options.user.id) }, excludeCredentials: toDescriptors(options.excludeCredentials) } })
|
|
33
|
+
: await navigator.credentials.get({ publicKey: { ...options, challenge: toBytes(options.challenge), allowCredentials: toDescriptors(options.allowCredentials) } });
|
|
34
|
+
const reply = credential.response;
|
|
35
|
+
const response = spec.mode === 'create'
|
|
36
|
+
? { clientDataJSON: toText(reply.clientDataJSON), attestationObject: toText(reply.attestationObject), transports: typeof reply.getTransports === 'function' ? reply.getTransports() : [] }
|
|
37
|
+
: { clientDataJSON: toText(reply.clientDataJSON), authenticatorData: toText(reply.authenticatorData), signature: toText(reply.signature), userHandle: reply.userHandle ? toText(reply.userHandle) : undefined };
|
|
38
|
+
form.elements.namedItem('credential').value = JSON.stringify({ id: credential.id, rawId: toText(credential.rawId), type: credential.type, response, clientExtensionResults: credential.getClientExtensionResults(), authenticatorAttachment: credential.authenticatorAttachment || undefined });
|
|
39
|
+
form.submit();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
status.textContent = 'The passkey prompt ended without a passkey (' + (error && error.name ? error.name : 'Error') + '). Try again.';
|
|
42
|
+
button.disabled = false;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
})();`;
|
|
46
|
+
|
|
47
|
+
export const PASSKEY_SCRIPT = {
|
|
48
|
+
source: SOURCE,
|
|
49
|
+
policySource: `'sha256-${createHash('sha256').update(SOURCE).digest('base64')}'`,
|
|
50
|
+
} as const;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/read-client-name.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
5
|
+
|
|
6
|
+
export async function readClientName(
|
|
7
|
+
db: Kysely<MCPAuthSchema>,
|
|
8
|
+
clientID: string,
|
|
9
|
+
): Promise<string | null> {
|
|
10
|
+
const row = await db
|
|
11
|
+
.selectFrom('oauthClient')
|
|
12
|
+
.select('name')
|
|
13
|
+
.where('clientId', '=', clientID)
|
|
14
|
+
.executeTakeFirst();
|
|
15
|
+
|
|
16
|
+
return row?.name ?? null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/read-client-views.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import { parseJSONStrings } from '../utils/parse-json-strings.ts';
|
|
5
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
6
|
+
|
|
7
|
+
export interface ClientView {
|
|
8
|
+
readonly clientID: string;
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly redirectURIs: readonly string[];
|
|
11
|
+
readonly createdAt: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readClientViews(db: Kysely<MCPAuthSchema>): Promise<readonly ClientView[]> {
|
|
15
|
+
const rows = await db
|
|
16
|
+
.selectFrom('oauthClient')
|
|
17
|
+
.select(['clientId', 'name', 'redirectUris', 'createdAt'])
|
|
18
|
+
.orderBy('createdAt')
|
|
19
|
+
.execute();
|
|
20
|
+
|
|
21
|
+
return rows.map((row) => ({
|
|
22
|
+
clientID: row.clientId,
|
|
23
|
+
name: row.name ?? '',
|
|
24
|
+
redirectURIs: parseJSONStrings(row.redirectUris) ?? [],
|
|
25
|
+
createdAt: row.createdAt,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/read-enrollment-token.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import { deriveTokenHash } from './derive-token-hash.ts';
|
|
5
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
6
|
+
|
|
7
|
+
export async function readEnrollmentToken(
|
|
8
|
+
db: Kysely<MCPAuthSchema>,
|
|
9
|
+
token: string,
|
|
10
|
+
now: number,
|
|
11
|
+
): Promise<string | null> {
|
|
12
|
+
const row = await db
|
|
13
|
+
.selectFrom('boxxy_enrollment_token')
|
|
14
|
+
.select('token_hash')
|
|
15
|
+
.where('token_hash', '=', deriveTokenHash(token))
|
|
16
|
+
.where('expires_at', '>', new Date(now).toISOString())
|
|
17
|
+
.executeTakeFirst();
|
|
18
|
+
|
|
19
|
+
return row?.token_hash ?? null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/read-grant-views.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { grantScopes } from '@boxxy/contract';
|
|
4
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
5
|
+
import type { Kysely } from 'kysely';
|
|
6
|
+
import { parseJSONStrings } from '../utils/parse-json-strings.ts';
|
|
7
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
8
|
+
|
|
9
|
+
export interface GrantView {
|
|
10
|
+
readonly grantID: string;
|
|
11
|
+
readonly clientID: string;
|
|
12
|
+
readonly clientName: string;
|
|
13
|
+
readonly scopes: readonly GrantScope[];
|
|
14
|
+
readonly createdAt: string;
|
|
15
|
+
readonly lastUsedAt: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readGrantViews(db: Kysely<MCPAuthSchema>): Promise<readonly GrantView[]> {
|
|
19
|
+
const rows = await db
|
|
20
|
+
.selectFrom('oauthRefreshToken')
|
|
21
|
+
.leftJoin('oauthClient', 'oauthClient.clientId', 'oauthRefreshToken.clientId')
|
|
22
|
+
.leftJoin(
|
|
23
|
+
'boxxy_grant_use',
|
|
24
|
+
'boxxy_grant_use.grant_id',
|
|
25
|
+
'oauthRefreshToken.authorizationCodeId',
|
|
26
|
+
)
|
|
27
|
+
.select([
|
|
28
|
+
'oauthRefreshToken.authorizationCodeId',
|
|
29
|
+
'oauthRefreshToken.clientId',
|
|
30
|
+
'oauthRefreshToken.scopes',
|
|
31
|
+
'oauthRefreshToken.createdAt',
|
|
32
|
+
'oauthClient.name',
|
|
33
|
+
'boxxy_grant_use.last_used_at',
|
|
34
|
+
])
|
|
35
|
+
.where('oauthRefreshToken.revoked', 'is', null)
|
|
36
|
+
.where('oauthRefreshToken.expiresAt', '>', new Date().toISOString())
|
|
37
|
+
.where('oauthRefreshToken.authorizationCodeId', 'is not', null)
|
|
38
|
+
.orderBy('oauthRefreshToken.createdAt')
|
|
39
|
+
.execute();
|
|
40
|
+
|
|
41
|
+
return rows.map((row) => {
|
|
42
|
+
const scopes = parseJSONStrings(row.scopes) ?? [];
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
grantID: row.authorizationCodeId ?? '',
|
|
46
|
+
clientID: row.clientId,
|
|
47
|
+
clientName: row.name ?? '',
|
|
48
|
+
scopes: grantScopes.filter((scope) => scopes.includes(scope)),
|
|
49
|
+
createdAt: row.createdAt,
|
|
50
|
+
lastUsedAt: row.last_used_at,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/read-owner-session-id.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { HTTPServerContext } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export async function readOwnerSessionID(
|
|
6
|
+
ctx: HTTPServerContext,
|
|
7
|
+
cookie: string | null,
|
|
8
|
+
): Promise<string | null> {
|
|
9
|
+
if (cookie === null || cookie === '') {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const found = await ctx.store.auth.api.getSession({ headers: new Headers({ cookie }) });
|
|
14
|
+
|
|
15
|
+
return found?.session.id ?? null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/record-refused-exchange.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { isRecord } from '../utils/is-record.ts';
|
|
4
|
+
import { normalizeClientName } from './normalize-client-name.ts';
|
|
5
|
+
import { readClientName } from './read-client-name.ts';
|
|
6
|
+
import type { HTTPServerContext, Requester, TokenExchangeReason } from './types.ts';
|
|
7
|
+
|
|
8
|
+
interface RefusedExchange {
|
|
9
|
+
// The refused request's body, read only for its client id.
|
|
10
|
+
readonly contentType: string | null;
|
|
11
|
+
readonly body: string;
|
|
12
|
+
readonly response: Response;
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly requester: Requester;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The event holds better-auth's error code from a fixed list, and the
|
|
18
|
+
// client's id and name only for a client boxxy holds. It never holds the
|
|
19
|
+
// code, the verifier, or a token the request carried.
|
|
20
|
+
export async function recordRefusedExchange(
|
|
21
|
+
ctx: Pick<HTTPServerContext, 'store' | 'security'>,
|
|
22
|
+
exchange: RefusedExchange,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const code = await readErrorCode(exchange.response);
|
|
25
|
+
|
|
26
|
+
const reason = isTokenExchangeError(code) ? code : 'other';
|
|
27
|
+
const clientID = findClientID(exchange);
|
|
28
|
+
const storedName = clientID === null ? null : await readClientName(ctx.store.db, clientID);
|
|
29
|
+
|
|
30
|
+
ctx.security.record({
|
|
31
|
+
kind: 'token_exchange',
|
|
32
|
+
outcome: 'rejected',
|
|
33
|
+
reason,
|
|
34
|
+
path: exchange.path,
|
|
35
|
+
requester: exchange.requester,
|
|
36
|
+
...(clientID === null || storedName === null
|
|
37
|
+
? {}
|
|
38
|
+
: { client: { id: clientID, name: normalizeClientName(storedName) } }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readErrorCode(response: Response): Promise<string | null> {
|
|
43
|
+
try {
|
|
44
|
+
const body: unknown = await response.clone().json();
|
|
45
|
+
|
|
46
|
+
return isRecord(body) && typeof body['error'] === 'string' ? body['error'] : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A fixed list keeps the reason a value a log rule can match, whatever text
|
|
53
|
+
// a future better-auth puts in its error field.
|
|
54
|
+
const TOKEN_EXCHANGE_ERRORS: ReadonlySet<string> = new Set([
|
|
55
|
+
'invalid_request',
|
|
56
|
+
'invalid_client',
|
|
57
|
+
'invalid_grant',
|
|
58
|
+
'unauthorized_client',
|
|
59
|
+
'unsupported_grant_type',
|
|
60
|
+
'invalid_scope',
|
|
61
|
+
'invalid_target',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function isTokenExchangeError(code: string | null): code is TokenExchangeReason {
|
|
65
|
+
return code !== null && TOKEN_EXCHANGE_ERRORS.has(code);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findClientID(exchange: RefusedExchange): string | null {
|
|
69
|
+
if (exchange.contentType?.startsWith('application/x-www-form-urlencoded') !== true) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new URLSearchParams(exchange.body).get('client_id');
|
|
74
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/remove-client.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
5
|
+
|
|
6
|
+
export function removeClient(db: Kysely<MCPAuthSchema>, clientID: string): Promise<boolean> {
|
|
7
|
+
return db.transaction().execute(async (trx) => {
|
|
8
|
+
const grants = await trx
|
|
9
|
+
.selectFrom('oauthRefreshToken')
|
|
10
|
+
.select('authorizationCodeId')
|
|
11
|
+
.where('clientId', '=', clientID)
|
|
12
|
+
.execute();
|
|
13
|
+
|
|
14
|
+
const grantIDs = grants.flatMap((grant) =>
|
|
15
|
+
grant.authorizationCodeId === null ? [] : [grant.authorizationCodeId],
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (grantIDs.length > 0) {
|
|
19
|
+
await trx.deleteFrom('boxxy_grant_use').where('grant_id', 'in', grantIDs).execute();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await trx.deleteFrom('oauthAccessToken').where('clientId', '=', clientID).execute();
|
|
23
|
+
await trx.deleteFrom('oauthRefreshToken').where('clientId', '=', clientID).execute();
|
|
24
|
+
await trx.deleteFrom('oauthConsent').where('clientId', '=', clientID).execute();
|
|
25
|
+
|
|
26
|
+
const removed = await trx
|
|
27
|
+
.deleteFrom('oauthClient')
|
|
28
|
+
.where('clientId', '=', clientID)
|
|
29
|
+
.executeTakeFirst();
|
|
30
|
+
|
|
31
|
+
return removed.numDeletedRows > 0n;
|
|
32
|
+
});
|
|
33
|
+
}
|