@boxxy/auth-oauth 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/README.md
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
boxxy's public door: it lets the owner's assistant reach the owner's MCP tools from anywhere, with a
|
|
15
|
+
grant that the owner approved with a passkey. The OAuth server is
|
|
16
|
+
[`@zgeoff/mcp-auth`](https://www.npmjs.com/package/@zgeoff/mcp-auth); this package opens it on
|
|
17
|
+
boxxy's auth database, with boxxy's scopes and table names.
|
|
16
18
|
|
|
17
19
|
## Install
|
|
18
20
|
|
|
@@ -26,21 +28,18 @@ The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
|
26
28
|
|
|
27
29
|
## Features
|
|
28
30
|
|
|
29
|
-
- `createPublicDoor(options)` opens the auth database and returns the door:
|
|
30
|
-
request, `addLocalPort` for the loopback address, `admin` for the owner's
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- It checks the `Host` and `Origin` headers, verifies each bearer token
|
|
35
|
-
|
|
31
|
+
- `createPublicDoor(options)` opens and migrates the auth database and returns the door:
|
|
32
|
+
`answerRequest` for each request, `addLocalPort` for the loopback address, `admin` for the owner's
|
|
33
|
+
commands, `auth` for the test helpers, and `stop`.
|
|
34
|
+
- The door serves MCP at `/mcp` and at `/`, the OAuth routes, the two `.well-known` documents and
|
|
35
|
+
the sign-in pages, and answers 404 on any other path.
|
|
36
|
+
- It checks the `Host` and `Origin` headers, verifies each bearer token, and passes the verified
|
|
37
|
+
token to the `serveMCP` callback that the app gives it.
|
|
36
38
|
- It writes each sign-in outcome and each refused token to `writeSecurityLine` as a JSON line.
|
|
37
39
|
- `admin` enrolls a passkey, adds and removes clients, and lists and revokes grants.
|
|
38
|
-
- `./test-utils/authorize-test-client`
|
|
40
|
+
- `./test-utils/authorize-test-client` signs a client in through `door.auth` with a stub passkey,
|
|
39
41
|
for tests.
|
|
40
42
|
|
|
41
|
-
The code is a copy of the OAuth server in atc's `@atc/mcp`. Each copied file names its source path
|
|
42
|
-
and commit in its first line.
|
|
43
|
-
|
|
44
43
|
## Documentation
|
|
45
44
|
|
|
46
45
|
[The public door](../../docs/design/boxxy/v1-api-and-auth.md#the-public-door) describes the
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
import type { ClientView } from './read-client-views.ts';
|
|
4
|
-
import type { GrantView } from './read-grant-views.ts';
|
|
5
|
-
type MCPAuth = Awaited<ReturnType<typeof openMCPAuth>>;
|
|
1
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
2
|
+
import type { ClientView, MCPAuth } from '@zgeoff/mcp-auth';
|
|
6
3
|
export interface AuthAdminInput {
|
|
7
|
-
readonly
|
|
4
|
+
readonly auth: MCPAuth<GrantScope>;
|
|
8
5
|
readonly origin: string;
|
|
9
|
-
readonly clock: AuthClock;
|
|
10
6
|
}
|
|
11
7
|
export type AddClientAnswer = {
|
|
12
8
|
readonly kind: 'added';
|
|
@@ -14,20 +10,37 @@ export type AddClientAnswer = {
|
|
|
14
10
|
readonly name: string;
|
|
15
11
|
} | {
|
|
16
12
|
readonly kind: 'refused';
|
|
17
|
-
readonly
|
|
13
|
+
readonly message: string;
|
|
14
|
+
} | {
|
|
15
|
+
readonly kind: 'conflict';
|
|
16
|
+
readonly name: string;
|
|
17
|
+
};
|
|
18
|
+
export type RemoveClientAnswer = {
|
|
19
|
+
readonly kind: 'removed';
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: 'not-found';
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: 'ambiguous';
|
|
24
|
+
readonly name: string;
|
|
18
25
|
};
|
|
19
26
|
export interface EnrollmentLink {
|
|
20
27
|
readonly token: string;
|
|
21
28
|
readonly link: string;
|
|
22
|
-
|
|
29
|
+
}
|
|
30
|
+
export interface AuthGrantView {
|
|
31
|
+
readonly grantID: string;
|
|
32
|
+
readonly clientID: string;
|
|
33
|
+
readonly clientName: string;
|
|
34
|
+
readonly scopes: readonly GrantScope[];
|
|
35
|
+
readonly createdAt: string;
|
|
36
|
+
readonly lastUsedAt: string | null;
|
|
23
37
|
}
|
|
24
38
|
export interface AuthAdmin {
|
|
25
39
|
readonly enroll: () => Promise<EnrollmentLink>;
|
|
26
40
|
readonly addClient: (name: string, redirectURIs: readonly string[]) => Promise<AddClientAnswer>;
|
|
27
41
|
readonly listClients: () => Promise<readonly ClientView[]>;
|
|
28
|
-
readonly removeClient: (clientID: string) => Promise<
|
|
29
|
-
readonly listGrants: () => Promise<readonly
|
|
42
|
+
readonly removeClient: (clientID: string) => Promise<RemoveClientAnswer>;
|
|
43
|
+
readonly listGrants: () => Promise<readonly AuthGrantView[]>;
|
|
30
44
|
readonly revokeGrant: (grantID: string) => Promise<boolean>;
|
|
31
45
|
}
|
|
32
46
|
export declare function createAuthAdmin(input: AuthAdminInput): AuthAdmin;
|
|
33
|
-
export {};
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import type { SecurityEvent } from '
|
|
1
|
+
import type { SecurityEvent } from '@zgeoff/mcp-auth';
|
|
2
|
+
type LoggedSecurityEvent = Omit<SecurityEvent, 'client' | 'correlationID'> & {
|
|
3
|
+
readonly client?: {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
};
|
|
7
|
+
readonly correlationID?: string;
|
|
8
|
+
};
|
|
2
9
|
export interface SuppressedSecurityEvent {
|
|
3
10
|
readonly kind: 'suppressed';
|
|
4
11
|
readonly reason: SecurityEvent['kind'];
|
|
5
12
|
readonly count: number;
|
|
6
13
|
}
|
|
7
|
-
export declare function formatSecurityEvent(event:
|
|
14
|
+
export declare function formatSecurityEvent(event: LoggedSecurityEvent | SuppressedSecurityEvent, at: number): string;
|
|
15
|
+
export {};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import
|
|
2
|
-
export declare function openAuthDatabase(path: string):
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
export declare function openAuthDatabase(path: string): Database;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
2
|
+
import type { MCPAuth } from '@zgeoff/mcp-auth';
|
|
1
3
|
import type { AuthAdmin } from '../auth/create-auth-admin.ts';
|
|
2
4
|
import type { AuthClock } from '../utils/types.ts';
|
|
3
|
-
import type { McpServe } from './
|
|
5
|
+
import type { McpServe } from './types.ts';
|
|
4
6
|
export interface PublicDoorOptions {
|
|
5
7
|
readonly publicURL: string;
|
|
6
8
|
readonly hostname: string;
|
|
7
9
|
readonly dbPath: string;
|
|
8
10
|
readonly trustForwardedFrom: readonly string[];
|
|
9
11
|
readonly writeSecurityLine: (line: string) => void;
|
|
12
|
+
readonly onSweepError: (error: unknown) => void;
|
|
10
13
|
readonly readPeer: (request: Request) => string | null;
|
|
11
14
|
readonly serveMCP: McpServe;
|
|
12
15
|
readonly clock?: AuthClock;
|
|
@@ -15,6 +18,7 @@ export interface PublicDoor {
|
|
|
15
18
|
readonly answerRequest: (request: Request) => Promise<Response>;
|
|
16
19
|
readonly addLocalPort: (port: number) => void;
|
|
17
20
|
readonly admin: AuthAdmin;
|
|
21
|
+
readonly auth: MCPAuth<GrantScope>;
|
|
18
22
|
readonly stop: () => Promise<void>;
|
|
19
23
|
}
|
|
20
24
|
export declare function createPublicDoor(options: PublicDoorOptions): Promise<PublicDoor>;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { HTTPServerContext, Requester } from '../auth/types.ts';
|
|
2
1
|
export interface McpAuthInfo {
|
|
3
2
|
readonly token: string;
|
|
4
3
|
readonly clientId: string;
|
|
@@ -6,4 +5,3 @@ export interface McpAuthInfo {
|
|
|
6
5
|
readonly resourceMetadataUrl: string;
|
|
7
6
|
}
|
|
8
7
|
export type McpServe = (request: Request, authInfo: McpAuthInfo) => Promise<Response>;
|
|
9
|
-
export declare function answerMCPRequest(ctx: HTTPServerContext, request: Request, requester: Requester, serve: McpServe): Promise<Response>;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
export type { AddClientAnswer, AuthAdmin, EnrollmentLink } from './auth/create-auth-admin.ts';
|
|
2
|
-
export type { McpAuthInfo, McpServe } from './http/
|
|
1
|
+
export type { AddClientAnswer, AuthAdmin, AuthGrantView, EnrollmentLink, RemoveClientAnswer, } from './auth/create-auth-admin.ts';
|
|
2
|
+
export type { McpAuthInfo, McpServe } from './http/types.ts';
|
|
3
3
|
export type { PublicDoor, PublicDoorOptions } from './http/create-public-door.ts';
|
|
4
4
|
export { createPublicDoor } from './http/create-public-door.ts';
|
|
5
|
-
export type { ClientView } from './auth/read-client-views.ts';
|
|
6
|
-
export type { GrantView } from './auth/read-grant-views.ts';
|
|
7
|
-
export type { VerifiedAccess } from './auth/types.ts';
|
|
8
5
|
export { isLoopbackHost } from './http/is-loopback-host.ts';
|
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
2
|
-
interface AuthorizeInput {
|
|
3
|
-
readonly origin: string;
|
|
4
|
-
readonly requested: readonly string[];
|
|
5
|
-
readonly ticked: readonly string[];
|
|
6
|
-
}
|
|
7
|
-
export interface TestGrant {
|
|
8
|
-
readonly clientID: string;
|
|
9
|
-
readonly accessToken: string;
|
|
10
|
-
readonly refreshToken: string;
|
|
11
|
-
}
|
|
12
|
-
export declare function authorizeTestClient(door: PublicDoor, input: AuthorizeInput): Promise<TestGrant>;
|
|
13
|
-
export {};
|
|
1
|
+
export { authorizeTestClient } from '@zgeoff/mcp-auth/testing';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PRE_PACKAGE_SCHEMA: string[];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
export interface PrePackageGrant {
|
|
3
|
+
readonly clientName: string;
|
|
4
|
+
readonly scopes: readonly string[];
|
|
5
|
+
readonly refreshes: number;
|
|
6
|
+
readonly lastUsedAt: string | null;
|
|
7
|
+
readonly isExpired?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface PrePackageAuth {
|
|
10
|
+
readonly origin: string;
|
|
11
|
+
readonly grants: readonly PrePackageGrant[];
|
|
12
|
+
readonly passkeyID: string;
|
|
13
|
+
readonly now: number;
|
|
14
|
+
}
|
|
15
|
+
export interface SeededPrePackageGrant {
|
|
16
|
+
readonly grantID: string;
|
|
17
|
+
readonly clientID: string;
|
|
18
|
+
readonly accessToken: string;
|
|
19
|
+
readonly refreshToken: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function seedPrePackageAuth(sqlite: Database, input: PrePackageAuth): SeededPrePackageGrant[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/auth-oauth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "boxxy's public door: an OAuth server for the owner's MCP endpoint, with passkey sign-in",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/boxxy/tree/main/adapters/auth-oauth#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
@@ -31,21 +31,15 @@
|
|
|
31
31
|
"typecheck": "tsrs --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@
|
|
35
|
-
"@better-auth/oauth-provider": "1.7.15",
|
|
36
|
-
"@boxxy/contract": "0.2.5",
|
|
37
|
-
"@simplewebauthn/server": "14.0.3",
|
|
34
|
+
"@boxxy/contract": "0.2.7",
|
|
38
35
|
"@zgeoff/kysely-bun-sqlite": "0.3.2",
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
36
|
+
"@zgeoff/mcp-auth": "0.1.0",
|
|
37
|
+
"@zgeoff/std": "0.4.3",
|
|
38
|
+
"elysia": "2.0.0-rc.3",
|
|
39
|
+
"exact-mirror": "1.2.7",
|
|
42
40
|
"kysely": "0.29.8",
|
|
43
41
|
"openapi-types": "12.1.3",
|
|
44
|
-
"
|
|
45
|
-
"tiny-invariant": "1.3.3",
|
|
46
|
-
"ts-pattern": "5.9.2",
|
|
47
|
-
"typebox": "1.4.3",
|
|
48
|
-
"zod": "4.6.7"
|
|
42
|
+
"typebox": "1.4.3"
|
|
49
43
|
},
|
|
50
44
|
"engines": {
|
|
51
45
|
"bun": ">=1.3",
|
|
@@ -1,32 +1,40 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type { openMCPAuth } from './open-mcp-auth.ts';
|
|
7
|
-
import { readClientViews } from './read-client-views.ts';
|
|
8
|
-
import type { ClientView } from './read-client-views.ts';
|
|
9
|
-
import { readGrantViews } from './read-grant-views.ts';
|
|
10
|
-
import type { GrantView } from './read-grant-views.ts';
|
|
11
|
-
import { removeClient } from './remove-client.ts';
|
|
12
|
-
import { revokeGrant } from './revoke-grant.ts';
|
|
13
|
-
|
|
14
|
-
type MCPAuth = Awaited<ReturnType<typeof openMCPAuth>>;
|
|
1
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
2
|
+
import { grantScopes } from '@boxxy/contract';
|
|
3
|
+
import { MCPAuthError } from '@zgeoff/mcp-auth';
|
|
4
|
+
import type { ClientView, GrantID, GrantView, MCPAuth } from '@zgeoff/mcp-auth';
|
|
5
|
+
import { toTagged } from '@zgeoff/std';
|
|
15
6
|
|
|
16
7
|
export interface AuthAdminInput {
|
|
17
|
-
readonly
|
|
8
|
+
readonly auth: MCPAuth<GrantScope>;
|
|
18
9
|
readonly origin: string;
|
|
19
|
-
readonly clock: AuthClock;
|
|
20
10
|
}
|
|
21
11
|
|
|
22
12
|
export type AddClientAnswer =
|
|
23
13
|
| { readonly kind: 'added'; readonly clientID: string; readonly name: string }
|
|
24
|
-
|
|
14
|
+
|
|
15
|
+
// the message says which rule the name or a redirect URI broke
|
|
16
|
+
| { readonly kind: 'refused'; readonly message: string }
|
|
17
|
+
| { readonly kind: 'conflict'; readonly name: string };
|
|
18
|
+
|
|
19
|
+
export type RemoveClientAnswer =
|
|
20
|
+
| { readonly kind: 'removed' }
|
|
21
|
+
| { readonly kind: 'not-found' }
|
|
22
|
+
|
|
23
|
+
// another client has the same name, and the OAuth server removes a client by its name
|
|
24
|
+
| { readonly kind: 'ambiguous'; readonly name: string };
|
|
25
25
|
|
|
26
26
|
export interface EnrollmentLink {
|
|
27
27
|
readonly token: string;
|
|
28
28
|
readonly link: string;
|
|
29
|
-
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AuthGrantView {
|
|
32
|
+
readonly grantID: string;
|
|
33
|
+
readonly clientID: string;
|
|
34
|
+
readonly clientName: string;
|
|
35
|
+
readonly scopes: readonly GrantScope[];
|
|
36
|
+
readonly createdAt: string;
|
|
37
|
+
readonly lastUsedAt: string | null;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
// What the owner's commands do to the authorization database: add and remove clients, revoke
|
|
@@ -35,44 +43,104 @@ export interface AuthAdmin {
|
|
|
35
43
|
readonly enroll: () => Promise<EnrollmentLink>;
|
|
36
44
|
readonly addClient: (name: string, redirectURIs: readonly string[]) => Promise<AddClientAnswer>;
|
|
37
45
|
readonly listClients: () => Promise<readonly ClientView[]>;
|
|
38
|
-
readonly removeClient: (clientID: string) => Promise<
|
|
39
|
-
readonly listGrants: () => Promise<readonly
|
|
46
|
+
readonly removeClient: (clientID: string) => Promise<RemoveClientAnswer>;
|
|
47
|
+
readonly listGrants: () => Promise<readonly AuthGrantView[]>;
|
|
40
48
|
readonly revokeGrant: (grantID: string) => Promise<boolean>;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export function createAuthAdmin(input: AuthAdminInput): AuthAdmin {
|
|
44
|
-
const
|
|
45
|
-
const origin = input.origin;
|
|
46
|
-
const clock = input.clock;
|
|
52
|
+
const auth = input.auth;
|
|
47
53
|
|
|
48
54
|
return {
|
|
49
55
|
enroll: async () => {
|
|
50
|
-
const
|
|
56
|
+
const token = await auth.passkeys.createEnrollmentToken();
|
|
57
|
+
|
|
58
|
+
const link = new URL(`${input.origin}${auth.paths.browser.enroll}`);
|
|
59
|
+
|
|
60
|
+
link.searchParams.set('token', token);
|
|
51
61
|
|
|
52
|
-
return {
|
|
53
|
-
token: created.token,
|
|
54
|
-
link: `${origin}${PUBLIC_DOOR_PATHS.browser.enroll}?token=${created.token}`,
|
|
55
|
-
expiresAt: created.expiresAt,
|
|
56
|
-
};
|
|
62
|
+
return { token, link: link.href };
|
|
57
63
|
},
|
|
58
64
|
addClient: async (name, redirectURIs) => {
|
|
59
|
-
|
|
65
|
+
try {
|
|
66
|
+
const added = await auth.clients.add(name, redirectURIs);
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
return { kind: 'added', clientID: added.clientID, name: added.name };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return pickAddRefusal(error, name);
|
|
63
71
|
}
|
|
72
|
+
},
|
|
73
|
+
listClients: () => auth.clients.list(),
|
|
74
|
+
removeClient: async (clientID) => {
|
|
75
|
+
const clients = await auth.clients.list();
|
|
76
|
+
|
|
77
|
+
const answer = pickClientToRemove(clients, clientID);
|
|
64
78
|
|
|
65
|
-
|
|
79
|
+
if (answer.kind === 'remove') {
|
|
80
|
+
await auth.clients.remove(answer.name);
|
|
66
81
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
});
|
|
82
|
+
return { kind: 'removed' };
|
|
83
|
+
}
|
|
70
84
|
|
|
71
|
-
return
|
|
85
|
+
return answer;
|
|
72
86
|
},
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
listGrants: async () => {
|
|
88
|
+
const grants = await auth.grants.list();
|
|
89
|
+
|
|
90
|
+
return grants.map((grant) => toAuthGrantView(grant));
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// The package lists only grants with a live refresh token, and a grant can hold a live access
|
|
94
|
+
// token without one, so the revoke goes to the package by ID.
|
|
95
|
+
revokeGrant: (grantID) => auth.grants.revoke(toTagged<GrantID>(grantID)),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The package refuses a bad name or redirect URI, and a taken name, with a coded error; any other
|
|
100
|
+
// error is not the owner's input.
|
|
101
|
+
function pickAddRefusal(error: unknown, name: string): AddClientAnswer {
|
|
102
|
+
if (!(error instanceof MCPAuthError)) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (error.code === 'conflict') {
|
|
107
|
+
return { kind: 'conflict', name };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (error.code === 'invalid-client-name' || error.code === 'invalid-redirect-uri') {
|
|
111
|
+
return { kind: 'refused', message: error.message };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type ClientToRemove =
|
|
118
|
+
| { readonly kind: 'remove'; readonly name: string }
|
|
119
|
+
| Exclude<RemoveClientAnswer, { readonly kind: 'removed' }>;
|
|
120
|
+
|
|
121
|
+
function pickClientToRemove(clients: readonly ClientView[], clientID: string): ClientToRemove {
|
|
122
|
+
const client = clients.find((view) => view.clientID === clientID);
|
|
123
|
+
|
|
124
|
+
if (client === undefined) {
|
|
125
|
+
return { kind: 'not-found' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isShared = clients.some(
|
|
129
|
+
(view) => view.name === client.name && view.clientID !== client.clientID,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return isShared
|
|
133
|
+
? { kind: 'ambiguous', name: client.name }
|
|
134
|
+
: { kind: 'remove', name: client.name };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toAuthGrantView(grant: GrantView): AuthGrantView {
|
|
138
|
+
return {
|
|
139
|
+
grantID: grant.grantID,
|
|
140
|
+
clientID: grant.clientID,
|
|
141
|
+
clientName: grant.clientName,
|
|
142
|
+
scopes: grantScopes.filter((scope) => grant.scopes.includes(scope)),
|
|
143
|
+
createdAt: grant.createdAt,
|
|
144
|
+
lastUsedAt: grant.lastUsedAt,
|
|
77
145
|
};
|
|
78
146
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { SecurityEvent } from '@zgeoff/mcp-auth';
|
|
3
2
|
import type { AuthClock } from '../utils/types.ts';
|
|
4
3
|
import { formatSecurityEvent } from './format-security-event.ts';
|
|
5
|
-
import type { SecurityEvent } from './types.ts';
|
|
6
4
|
|
|
7
5
|
interface SecurityEventLogOptions {
|
|
8
6
|
// Receives each `{json}` line.
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
|
|
1
|
+
import { DEFAULT_PATHS } from '@zgeoff/mcp-auth';
|
|
2
|
+
import type { SecurityEvent } from '@zgeoff/mcp-auth';
|
|
4
3
|
import { normalizeClientName } from './normalize-client-name.ts';
|
|
5
|
-
|
|
4
|
+
|
|
5
|
+
// The IDs in an event are tagged strings, and a line needs only their text.
|
|
6
|
+
type LoggedSecurityEvent = Omit<SecurityEvent, 'client' | 'correlationID'> & {
|
|
7
|
+
readonly client?: { readonly id: string; readonly name: string };
|
|
8
|
+
readonly correlationID?: string;
|
|
9
|
+
};
|
|
6
10
|
|
|
7
11
|
export interface SuppressedSecurityEvent {
|
|
8
12
|
readonly kind: 'suppressed';
|
|
@@ -12,11 +16,11 @@ export interface SuppressedSecurityEvent {
|
|
|
12
16
|
|
|
13
17
|
// The paths an event may hold as its route.
|
|
14
18
|
const ROUTES: ReadonlySet<string> = new Set(
|
|
15
|
-
Object.values(
|
|
19
|
+
Object.values(DEFAULT_PATHS).flatMap((group) => Object.values(group)),
|
|
16
20
|
);
|
|
17
21
|
|
|
18
22
|
export function formatSecurityEvent(
|
|
19
|
-
event:
|
|
23
|
+
event: LoggedSecurityEvent | SuppressedSecurityEvent,
|
|
20
24
|
at: number,
|
|
21
25
|
): string {
|
|
22
26
|
const head = { version: 1, ts: new Date(at).toISOString(), kind: event.kind };
|
|
@@ -30,9 +34,9 @@ export function formatSecurityEvent(
|
|
|
30
34
|
const fields = {
|
|
31
35
|
...head,
|
|
32
36
|
outcome: event.outcome,
|
|
33
|
-
...(
|
|
37
|
+
...(event.reason === undefined ? {} : { reason: event.reason }),
|
|
34
38
|
...(event.kind === 'owner_auth'
|
|
35
|
-
? { method: event.path ===
|
|
39
|
+
? { method: event.path === DEFAULT_PATHS.browser.enroll ? 'enrollment_token' : 'passkey' }
|
|
36
40
|
: {}),
|
|
37
41
|
route: ROUTES.has(event.path) ? event.path : 'other',
|
|
38
42
|
...(event.client === undefined
|
|
@@ -43,9 +47,6 @@ export function formatSecurityEvent(
|
|
|
43
47
|
name: toEventText(event.client.name, 100),
|
|
44
48
|
},
|
|
45
49
|
}),
|
|
46
|
-
...(event.redirectHost === undefined
|
|
47
|
-
? {}
|
|
48
|
-
: { redirectHost: toEventText(event.redirectHost, 255) }),
|
|
49
50
|
...(event.correlationID === undefined ? {} : { correlationID: event.correlationID }),
|
|
50
51
|
...(requester.socketAddress === null
|
|
51
52
|
? {}
|
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
// Adapted from atc: packages/mcp/src/testing/open-test-auth-database.ts at commit 4fe647cf.
|
|
2
|
-
|
|
3
1
|
import { Database } from 'bun:sqlite';
|
|
4
|
-
import {
|
|
5
|
-
|
|
2
|
+
import { chmodSync, closeSync, openSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
// The file holds token hashes and sessions, so only its owner may read it. SQLite gives the WAL and
|
|
5
|
+
// shared-memory files the main file's mode, so the mode is set before SQLite opens the file.
|
|
6
|
+
export function openAuthDatabase(path: string): Database {
|
|
7
|
+
closeSync(openSync(path, 'a', 0o600));
|
|
8
|
+
chmodSync(path, 0o600);
|
|
6
9
|
|
|
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
10
|
const sqlite = new Database(path, { create: true });
|
|
11
11
|
|
|
12
12
|
sqlite.run('PRAGMA journal_mode = WAL;');
|
|
13
13
|
sqlite.run('PRAGMA busy_timeout = 5000;');
|
|
14
14
|
sqlite.run('PRAGMA foreign_keys = ON;');
|
|
15
15
|
|
|
16
|
-
return
|
|
17
|
-
dialect: buildSQLiteDialect(sqlite),
|
|
18
|
-
close: () => {
|
|
19
|
-
sqlite.close();
|
|
20
|
-
},
|
|
21
|
-
};
|
|
16
|
+
return sqlite;
|
|
22
17
|
}
|