@zgeoff/atc 2.8.1 → 2.9.0
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 +17 -2
- package/package.json +6 -3
- package/src/agents/agent-adapter.ts +3 -1
- package/src/cli.ts +114 -4
- package/src/client/daemon-client.ts +10 -0
- package/src/clients.ts +82 -0
- package/src/collect-redirect-uris.ts +16 -0
- package/src/daemon/build-fleet-events.ts +9 -0
- package/src/daemon/build-message-trail-entry.ts +54 -0
- package/src/daemon/build-report-trail-entry.ts +25 -0
- package/src/daemon/build-session-message-event.ts +0 -1
- package/src/daemon/daemon.ts +72 -11
- package/src/daemon/sessions.ts +13 -1
- package/src/daemon/start-headless-run.ts +3 -8
- package/src/daemon/start-headless-turn.ts +15 -3
- package/src/daemon/truncate-summary.ts +9 -0
- package/src/grants.ts +51 -0
- package/src/mcp/answer-authorize-request.ts +98 -0
- package/src/mcp/answer-consent-request.ts +166 -0
- package/src/mcp/answer-login-request.ts +108 -0
- package/src/mcp/answer-mcp-request.ts +161 -0
- package/src/mcp/answer-rpc-request.ts +137 -0
- package/src/mcp/approval-state.ts +155 -0
- package/src/mcp/build-consent-binding.ts +13 -0
- package/src/mcp/build-owner-plugin.ts +122 -0
- package/src/mcp/build-page-response.ts +29 -0
- package/src/mcp/build-tool-list.ts +21 -0
- package/src/mcp/collect-clients.ts +31 -0
- package/src/mcp/collect-grants.ts +55 -0
- package/src/mcp/collect-json-strings.ts +17 -0
- package/src/mcp/derive-token-hash.ts +9 -0
- package/src/mcp/find-client-name.ts +19 -0
- package/src/mcp/find-owner-session-id.ts +18 -0
- package/src/mcp/is-allowed-redirect-uri.ts +21 -0
- package/src/mcp/is-loopback-host.ts +13 -0
- package/src/mcp/is-supported-protocol-version.ts +13 -0
- package/src/mcp/mcp-tools.ts +278 -0
- package/src/mcp/mint-approval-code.ts +13 -0
- package/src/mcp/normalize-approval-code.ts +8 -0
- package/src/mcp/normalize-public-url.ts +33 -0
- package/src/mcp/open-mcp-auth.ts +145 -0
- package/src/mcp/pick-error-message.ts +22 -0
- package/src/mcp/pick-protocol-version.ts +9 -0
- package/src/mcp/reconnecting-caller.ts +121 -0
- package/src/mcp/remove-client.ts +36 -0
- package/src/mcp/render-consent-page.ts +65 -0
- package/src/mcp/render-login-page.ts +47 -0
- package/src/mcp/revoke-grant.ts +28 -0
- package/src/mcp/run-tool.ts +143 -0
- package/src/mcp/start-mcp-http-server.ts +330 -0
- package/src/mcp/to-html-text.ts +12 -0
- package/src/mcp/types.ts +78 -0
- package/src/mcp/verify-oauth-query.ts +42 -0
- package/src/mcp-http-server.ts +75 -0
- package/src/mcp-server.ts +22 -450
- package/src/parse-port.ts +18 -0
- package/src/shared/collect-mcp-http-config.ts +46 -0
- package/src/shared/config.ts +3 -1
- package/src/shared/grant-scope.ts +8 -0
- package/src/shared/load-mcp-http-config.ts +20 -0
- package/src/shared/normalize-client-name.ts +28 -0
- package/src/store/state-store.ts +65 -4
- package/src/store/trail-entry.ts +27 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { match } from 'ts-pattern';
|
|
2
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
3
|
+
import type { GrantScope } from '../shared/grant-scope';
|
|
4
|
+
import { isRecord } from '../shared/report';
|
|
5
|
+
import { buildToolList } from './build-tool-list';
|
|
6
|
+
import { MCP_TOOLS } from './mcp-tools';
|
|
7
|
+
import { pickProtocolVersion } from './pick-protocol-version';
|
|
8
|
+
import { runTool } from './run-tool';
|
|
9
|
+
import type { FleetCaller, ToolContext } from './types';
|
|
10
|
+
|
|
11
|
+
interface RPCDeps {
|
|
12
|
+
readonly caller: FleetCaller;
|
|
13
|
+
readonly build: string;
|
|
14
|
+
readonly toolContext: ToolContext;
|
|
15
|
+
|
|
16
|
+
// The scopes the caller holds; absent means every tool is allowed.
|
|
17
|
+
readonly scopes?: readonly GrantScope[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RPCOutcome =
|
|
21
|
+
| { readonly kind: 'reply'; readonly body: Readonly<Record<string, unknown>> }
|
|
22
|
+
| { readonly kind: 'accepted' }
|
|
23
|
+
| { readonly kind: 'invalid' }
|
|
24
|
+
| { readonly kind: 'forbidden'; readonly scope: GrantScope };
|
|
25
|
+
|
|
26
|
+
export async function answerRPCRequest(message: unknown, deps: RPCDeps): Promise<RPCOutcome> {
|
|
27
|
+
if (!isRecord(message) || typeof message['method'] !== 'string') {
|
|
28
|
+
return { kind: 'invalid' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const method = message['method'];
|
|
32
|
+
const id = message['id'];
|
|
33
|
+
|
|
34
|
+
// Notifications carry no id and get no response.
|
|
35
|
+
if (id === undefined || id === null) {
|
|
36
|
+
return { kind: 'accepted' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof id !== 'string' && typeof id !== 'number') {
|
|
40
|
+
return { kind: 'invalid' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const params = isRecord(message['params']) ? message['params'] : {};
|
|
44
|
+
const missingScope = method === 'tools/call' ? findMissingScope(params, deps.scopes) : null;
|
|
45
|
+
|
|
46
|
+
if (missingScope !== null) {
|
|
47
|
+
return { kind: 'forbidden', scope: missingScope };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const outcome = await match(method)
|
|
51
|
+
.with('initialize', () => ({
|
|
52
|
+
kind: 'reply' as const,
|
|
53
|
+
body: buildRPCResult(id, {
|
|
54
|
+
protocolVersion: pickProtocolVersion(params['protocolVersion']),
|
|
55
|
+
capabilities: { tools: {} },
|
|
56
|
+
serverInfo: { name: 'atc', version: deps.build },
|
|
57
|
+
}),
|
|
58
|
+
}))
|
|
59
|
+
.with('ping', () => ({ kind: 'reply' as const, body: buildRPCResult(id, {}) }))
|
|
60
|
+
.with('tools/list', () => ({
|
|
61
|
+
kind: 'reply' as const,
|
|
62
|
+
body: buildRPCResult(id, { tools: buildToolList() }),
|
|
63
|
+
}))
|
|
64
|
+
.with('tools/call', async () => {
|
|
65
|
+
const result = await answerToolCall(deps, params);
|
|
66
|
+
|
|
67
|
+
return { kind: 'reply' as const, body: buildRPCResult(id, result) };
|
|
68
|
+
})
|
|
69
|
+
.otherwise(() => ({
|
|
70
|
+
kind: 'reply' as const,
|
|
71
|
+
body: buildRPCError(id, -32_601, `unknown method '${method}'`),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
return outcome;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const STRICTEST_SCOPE: GrantScope = 'kill';
|
|
78
|
+
|
|
79
|
+
// The scope a tool call needs and the caller lacks. A tool with no listed
|
|
80
|
+
// scope fails closed: it needs the strictest scope, and only a caller holding
|
|
81
|
+
// that one gets the unknown name back as a tool error.
|
|
82
|
+
function findMissingScope(
|
|
83
|
+
params: Readonly<Record<string, unknown>>,
|
|
84
|
+
scopes: readonly GrantScope[] | undefined,
|
|
85
|
+
): GrantScope | null {
|
|
86
|
+
if (scopes === undefined) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tool = MCP_TOOLS.find((candidate) => candidate.name === params['name']);
|
|
91
|
+
const needed = tool === undefined ? STRICTEST_SCOPE : tool.scope;
|
|
92
|
+
|
|
93
|
+
return scopes.includes(needed) ? null : needed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function answerToolCall(
|
|
97
|
+
deps: RPCDeps,
|
|
98
|
+
params: Readonly<Record<string, unknown>>,
|
|
99
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
100
|
+
const name = typeof params['name'] === 'string' ? params['name'] : '';
|
|
101
|
+
const args = isRecord(params['arguments']) ? params['arguments'] : {};
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const text = await runTool(deps.caller, name, args, deps.toolContext);
|
|
105
|
+
|
|
106
|
+
return { content: [{ type: 'text', text }] };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return { content: [{ type: 'text', text: formatToolError(error) }], isError: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatToolError(error: unknown): string {
|
|
113
|
+
if (error instanceof DaemonError) {
|
|
114
|
+
return `${error.code}: ${error.message}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (error instanceof Error) {
|
|
118
|
+
return error.message;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return String(error);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildRPCResult(
|
|
125
|
+
id: string | number,
|
|
126
|
+
result: Readonly<Record<string, unknown>>,
|
|
127
|
+
): Readonly<Record<string, unknown>> {
|
|
128
|
+
return { jsonrpc: '2.0', id, result };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildRPCError(
|
|
132
|
+
id: string | number,
|
|
133
|
+
code: number,
|
|
134
|
+
message: string,
|
|
135
|
+
): Readonly<Record<string, unknown>> {
|
|
136
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
137
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { mintApprovalCode } from './mint-approval-code';
|
|
2
|
+
import { normalizeApprovalCode } from './normalize-approval-code';
|
|
3
|
+
|
|
4
|
+
interface ApprovalRequest {
|
|
5
|
+
// The signed authorization query the login page carries: one per request.
|
|
6
|
+
readonly key: string;
|
|
7
|
+
readonly clientID: string;
|
|
8
|
+
readonly clientName: string;
|
|
9
|
+
readonly redirectURI: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An authorization request waiting for the operator to type its approval code.
|
|
14
|
+
*/
|
|
15
|
+
export interface PendingApproval extends ApprovalRequest {
|
|
16
|
+
readonly approvalCode: string;
|
|
17
|
+
attempts: number;
|
|
18
|
+
readonly expiresAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// At most this many approvals wait at once across every client, and each
|
|
22
|
+
// client holds at most this many. Across every client, at most this many
|
|
23
|
+
// approvals start per minute, which bounds how fast approval lines print.
|
|
24
|
+
const MAX_PENDING = 16;
|
|
25
|
+
const MAX_PENDING_PER_CLIENT = 3;
|
|
26
|
+
const MAX_STARTS_PER_MINUTE = 10;
|
|
27
|
+
const MAX_ATTEMPTS = 5;
|
|
28
|
+
const MINUTE_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The approvals waiting for the operator, held in memory by the HTTP process.
|
|
32
|
+
* A client's new approval past its limit drops that client's oldest, and a
|
|
33
|
+
* full set of waiting approvals makes room by dropping the oldest overall, so
|
|
34
|
+
* no client can hold the operator's approvals hostage and a refused start
|
|
35
|
+
* lasts at most a minute. Once the operator types a code, the owner session
|
|
36
|
+
* it signed in holds the binding of the one request it approved until that
|
|
37
|
+
* request's consent is answered or the binding expires.
|
|
38
|
+
*/
|
|
39
|
+
export class ApprovalState {
|
|
40
|
+
private readonly pendingMs: number;
|
|
41
|
+
|
|
42
|
+
private readonly now: () => number;
|
|
43
|
+
|
|
44
|
+
private readonly pending = new Map<string, PendingApproval>();
|
|
45
|
+
|
|
46
|
+
// Owner session id to the binding of the request its approval code approved.
|
|
47
|
+
private readonly approved = new Map<string, { binding: string; expiresAt: number }>();
|
|
48
|
+
|
|
49
|
+
// Approval start times within the last minute, oldest first.
|
|
50
|
+
private startedAt: readonly number[] = [];
|
|
51
|
+
|
|
52
|
+
constructor(pendingMs: number, now: () => number) {
|
|
53
|
+
this.pendingMs = pendingMs;
|
|
54
|
+
this.now = now;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Returns null when the last minute already holds its share of starts.
|
|
58
|
+
createPending(request: ApprovalRequest): PendingApproval | null {
|
|
59
|
+
const now = this.now();
|
|
60
|
+
|
|
61
|
+
this.removeExpired(now);
|
|
62
|
+
|
|
63
|
+
this.startedAt = this.startedAt.filter((at) => now - at < MINUTE_MS);
|
|
64
|
+
|
|
65
|
+
if (this.startedAt.length >= MAX_STARTS_PER_MINUTE) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const own = [...this.pending].filter(([, approval]) => approval.clientID === request.clientID);
|
|
70
|
+
const [ownOldest] = own;
|
|
71
|
+
|
|
72
|
+
if (own.length >= MAX_PENDING_PER_CLIENT && ownOldest !== undefined) {
|
|
73
|
+
this.pending.delete(ownOldest[0]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const oldest = this.pending.keys().next();
|
|
77
|
+
|
|
78
|
+
if (this.pending.size >= MAX_PENDING && oldest.done !== true) {
|
|
79
|
+
this.pending.delete(oldest.value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const approval: PendingApproval = {
|
|
83
|
+
...request,
|
|
84
|
+
approvalCode: mintApprovalCode(),
|
|
85
|
+
attempts: 0,
|
|
86
|
+
expiresAt: now + this.pendingMs,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
this.pending.set(approval.key, approval);
|
|
90
|
+
|
|
91
|
+
this.startedAt = [...this.startedAt, now];
|
|
92
|
+
|
|
93
|
+
return approval;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
findPending(key: string): PendingApproval | null {
|
|
97
|
+
this.removeExpired(this.now());
|
|
98
|
+
|
|
99
|
+
return this.pending.get(key) ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A wrong code counts against the approval; the last allowed miss drops it.
|
|
103
|
+
verifyApprovalCode(key: string, typed: string): 'ok' | 'wrong' | 'locked' {
|
|
104
|
+
const approval = this.findPending(key);
|
|
105
|
+
|
|
106
|
+
if (approval === null) {
|
|
107
|
+
return 'locked';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (normalizeApprovalCode(typed) === approval.approvalCode) {
|
|
111
|
+
this.pending.delete(key);
|
|
112
|
+
|
|
113
|
+
return 'ok';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
approval.attempts += 1;
|
|
117
|
+
|
|
118
|
+
if (approval.attempts >= MAX_ATTEMPTS) {
|
|
119
|
+
this.pending.delete(key);
|
|
120
|
+
|
|
121
|
+
return 'locked';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return 'wrong';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
recordApproved(sessionID: string, binding: string): void {
|
|
128
|
+
this.approved.set(sessionID, { binding, expiresAt: this.now() + this.pendingMs });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The binding an owner session approved, or null once it expired or went.
|
|
132
|
+
findApproved(sessionID: string): string | null {
|
|
133
|
+
this.removeExpired(this.now());
|
|
134
|
+
|
|
135
|
+
return this.approved.get(sessionID)?.binding ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
removeApproved(sessionID: string): void {
|
|
139
|
+
this.approved.delete(sessionID);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private removeExpired(now: number): void {
|
|
143
|
+
for (const [key, approval] of this.pending) {
|
|
144
|
+
if (approval.expiresAt <= now) {
|
|
145
|
+
this.pending.delete(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const [sessionID, approved] of this.approved) {
|
|
150
|
+
if (approved.expiresAt <= now) {
|
|
151
|
+
this.approved.delete(sessionID);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of an authorization request that stay the same from its login
|
|
3
|
+
* page to its consent page: the client, where it returns, its state, and its
|
|
4
|
+
* PKCE challenge. better-auth signs each page's query afresh, so the binding
|
|
5
|
+
* between an approval code and the consent it allows is built from these.
|
|
6
|
+
*/
|
|
7
|
+
export function buildConsentBinding(oauthQuery: string): string {
|
|
8
|
+
const params = new URLSearchParams(oauthQuery);
|
|
9
|
+
|
|
10
|
+
return JSON.stringify(
|
|
11
|
+
['client_id', 'redirect_uri', 'state', 'code_challenge'].map((name) => params.get(name) ?? ''),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { getOAuthProviderApi } from '@better-auth/oauth-provider';
|
|
2
|
+
import type { BetterAuthPlugin } from 'better-auth';
|
|
3
|
+
import { createAuthEndpoint } from 'better-auth/api';
|
|
4
|
+
import { setSessionCookie } from 'better-auth/cookies';
|
|
5
|
+
import { generateRandomString } from 'better-auth/crypto';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { GRANT_SCOPES } from '../shared/grant-scope';
|
|
8
|
+
import { isAllowedRedirectURI } from './is-allowed-redirect-uri';
|
|
9
|
+
|
|
10
|
+
// atc has one user: the operator who runs it. better-auth needs an email for
|
|
11
|
+
// every user, so the owner gets one no mail ever reaches.
|
|
12
|
+
const OWNER_EMAIL = 'owner@atc.invalid';
|
|
13
|
+
const REDIRECT_URI = z.string().refine(isAllowedRedirectURI);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The better-auth plugin that makes the operator the one user. Its endpoints
|
|
17
|
+
* are server-only: better-auth refuses them over HTTP, so only atc's own
|
|
18
|
+
* routes reach them, and only after checking what they guard.
|
|
19
|
+
*
|
|
20
|
+
* - `signInOwner` starts an owner session and sets its cookie. atc calls it
|
|
21
|
+
* once the operator has typed the right approval code; with the signed
|
|
22
|
+
* authorization query in the body, better-auth then resumes the
|
|
23
|
+
* authorization.
|
|
24
|
+
* - `verifyMCPAccessToken` returns the introspection payload of an active
|
|
25
|
+
* access token and rejects any other token.
|
|
26
|
+
* - `createFixedClient` registers a public client: no secret, PKCE required,
|
|
27
|
+
* and every scope atc grants open to request.
|
|
28
|
+
*/
|
|
29
|
+
export function buildOwnerPlugin() {
|
|
30
|
+
return {
|
|
31
|
+
id: 'atc-owner',
|
|
32
|
+
endpoints: {
|
|
33
|
+
signInOwner: createAuthEndpoint(
|
|
34
|
+
'/atc/sign-in-owner',
|
|
35
|
+
{
|
|
36
|
+
method: 'POST',
|
|
37
|
+
body: z.object({ oauth_query: z.string().optional() }),
|
|
38
|
+
metadata: { SERVER_ONLY: true },
|
|
39
|
+
},
|
|
40
|
+
async (ctx) => {
|
|
41
|
+
const adapter = ctx.context.internalAdapter;
|
|
42
|
+
|
|
43
|
+
const existing = await adapter.findUserByEmail(OWNER_EMAIL);
|
|
44
|
+
|
|
45
|
+
const user =
|
|
46
|
+
existing === null
|
|
47
|
+
? await adapter.createUser(
|
|
48
|
+
{ email: OWNER_EMAIL, name: 'owner', emailVerified: true },
|
|
49
|
+
{ method: 'admin' },
|
|
50
|
+
)
|
|
51
|
+
: existing.user;
|
|
52
|
+
|
|
53
|
+
const session = await adapter.createSession(user.id);
|
|
54
|
+
|
|
55
|
+
await setSessionCookie(ctx, { session, user });
|
|
56
|
+
|
|
57
|
+
return ctx.json({ signedIn: true });
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
verifyMCPAccessToken: createAuthEndpoint(
|
|
61
|
+
'/atc/verify-access-token',
|
|
62
|
+
{
|
|
63
|
+
method: 'POST',
|
|
64
|
+
body: z.object({ token: z.string() }),
|
|
65
|
+
metadata: { SERVER_ONLY: true },
|
|
66
|
+
},
|
|
67
|
+
async (ctx) => {
|
|
68
|
+
const provider = ctx.context.getPlugin('oauth-provider');
|
|
69
|
+
|
|
70
|
+
if (provider === null) {
|
|
71
|
+
throw new Error('the oauth-provider plugin is not installed');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const payload = await getOAuthProviderApi(ctx, provider.options).requireActiveAccessToken(
|
|
75
|
+
ctx.body.token,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return ctx.json(payload);
|
|
79
|
+
},
|
|
80
|
+
),
|
|
81
|
+
createFixedClient: createAuthEndpoint(
|
|
82
|
+
'/atc/create-fixed-client',
|
|
83
|
+
{
|
|
84
|
+
method: 'POST',
|
|
85
|
+
body: z.object({
|
|
86
|
+
name: z.string().min(1),
|
|
87
|
+
redirectURIs: z.array(REDIRECT_URI).min(1),
|
|
88
|
+
}),
|
|
89
|
+
metadata: { SERVER_ONLY: true },
|
|
90
|
+
},
|
|
91
|
+
async (ctx) => {
|
|
92
|
+
const clientID = generateRandomString(32, 'a-z', 'A-Z', '0-9');
|
|
93
|
+
|
|
94
|
+
const now = new Date();
|
|
95
|
+
|
|
96
|
+
await ctx.context.adapter.create({
|
|
97
|
+
model: 'oauthClient',
|
|
98
|
+
data: {
|
|
99
|
+
clientId: clientID,
|
|
100
|
+
name: ctx.body.name,
|
|
101
|
+
redirectUris: ctx.body.redirectURIs,
|
|
102
|
+
scopes: [...GRANT_SCOPES, 'offline_access'],
|
|
103
|
+
clientCredentialsScopes: [],
|
|
104
|
+
tokenEndpointAuthMethod: 'none',
|
|
105
|
+
grantTypes: ['authorization_code', 'refresh_token'],
|
|
106
|
+
responseTypes: ['code'],
|
|
107
|
+
applicationType: ctx.body.redirectURIs.every((uri) => uri.startsWith('https:'))
|
|
108
|
+
? 'web'
|
|
109
|
+
: 'native',
|
|
110
|
+
requirePKCE: true,
|
|
111
|
+
disabled: false,
|
|
112
|
+
createdAt: now,
|
|
113
|
+
updatedAt: now,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return ctx.json({ clientID });
|
|
118
|
+
},
|
|
119
|
+
),
|
|
120
|
+
},
|
|
121
|
+
} satisfies BetterAuthPlugin;
|
|
122
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { toHTMLText } from './to-html-text';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An HTML response from the authorization server, sent with headers that
|
|
5
|
+
* keep it out of frames and caches and confine where its form may post.
|
|
6
|
+
* `body` is either a full page or a plain message, which is escaped and
|
|
7
|
+
* wrapped in a minimal page.
|
|
8
|
+
*/
|
|
9
|
+
export function buildPageResponse(
|
|
10
|
+
status: number,
|
|
11
|
+
body: { readonly page: string } | { readonly message: string },
|
|
12
|
+
formTargets: readonly string[] = [],
|
|
13
|
+
): Response {
|
|
14
|
+
const html =
|
|
15
|
+
'page' in body
|
|
16
|
+
? body.page
|
|
17
|
+
: `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>atc</title></head><body><p>${toHTMLText(body.message)}</p></body></html>`;
|
|
18
|
+
|
|
19
|
+
return new Response(html, {
|
|
20
|
+
status,
|
|
21
|
+
headers: {
|
|
22
|
+
'content-type': 'text/html; charset=utf-8',
|
|
23
|
+
'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; form-action 'self'${formTargets.map((target) => ` ${target}`).join('')}; frame-ancestors 'none'; base-uri 'none'`,
|
|
24
|
+
'x-frame-options': 'DENY',
|
|
25
|
+
'cache-control': 'no-store',
|
|
26
|
+
'referrer-policy': 'same-origin',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { MCP_TOOLS } from './mcp-tools';
|
|
2
|
+
|
|
3
|
+
interface MCPTool {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly description: string;
|
|
6
|
+
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
7
|
+
readonly annotations: {
|
|
8
|
+
readonly readOnlyHint: boolean;
|
|
9
|
+
readonly destructiveHint: boolean;
|
|
10
|
+
readonly openWorldHint: boolean;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildToolList(): readonly MCPTool[] {
|
|
15
|
+
return MCP_TOOLS.map((tool) => ({
|
|
16
|
+
name: tool.name,
|
|
17
|
+
description: tool.description,
|
|
18
|
+
inputSchema: tool.inputSchema,
|
|
19
|
+
annotations: tool.annotations,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import { collectJSONStrings } from './collect-json-strings';
|
|
3
|
+
import type { MCPAuthSchema } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A client added with `atc clients add`.
|
|
7
|
+
*/
|
|
8
|
+
export interface ClientView {
|
|
9
|
+
readonly clientID: string;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly redirectURIs: readonly string[];
|
|
12
|
+
readonly createdAt: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every client the authorization server knows, oldest first.
|
|
17
|
+
*/
|
|
18
|
+
export async function collectClients(db: Kysely<MCPAuthSchema>): Promise<readonly ClientView[]> {
|
|
19
|
+
const rows = await db
|
|
20
|
+
.selectFrom('oauthClient')
|
|
21
|
+
.select(['clientId', 'name', 'redirectUris', 'createdAt'])
|
|
22
|
+
.orderBy('createdAt')
|
|
23
|
+
.execute();
|
|
24
|
+
|
|
25
|
+
return rows.map((row) => ({
|
|
26
|
+
clientID: row.clientId,
|
|
27
|
+
name: row.name ?? '',
|
|
28
|
+
redirectURIs: collectJSONStrings(row.redirectUris),
|
|
29
|
+
createdAt: row.createdAt,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import { GRANT_SCOPES } from '../shared/grant-scope';
|
|
3
|
+
import type { GrantScope } from '../shared/grant-scope';
|
|
4
|
+
import { collectJSONStrings } from './collect-json-strings';
|
|
5
|
+
import type { MCPAuthSchema } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One authorization a client holds: every refresh token rotated from one
|
|
9
|
+
* authorization code, identified by that code's id.
|
|
10
|
+
*/
|
|
11
|
+
export interface GrantView {
|
|
12
|
+
readonly grantID: string;
|
|
13
|
+
readonly clientID: string;
|
|
14
|
+
readonly clientName: string;
|
|
15
|
+
readonly scopes: readonly GrantScope[];
|
|
16
|
+
readonly createdAt: string;
|
|
17
|
+
readonly lastUsedAt: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every grant with a live refresh token, oldest first: one not revoked or
|
|
22
|
+
* rotated away and not yet expired.
|
|
23
|
+
*/
|
|
24
|
+
export async function collectGrants(db: Kysely<MCPAuthSchema>): Promise<readonly GrantView[]> {
|
|
25
|
+
const rows = await db
|
|
26
|
+
.selectFrom('oauthRefreshToken')
|
|
27
|
+
.leftJoin('oauthClient', 'oauthClient.clientId', 'oauthRefreshToken.clientId')
|
|
28
|
+
.leftJoin('atc_grant_use', 'atc_grant_use.grant_id', 'oauthRefreshToken.authorizationCodeId')
|
|
29
|
+
.select([
|
|
30
|
+
'oauthRefreshToken.authorizationCodeId',
|
|
31
|
+
'oauthRefreshToken.clientId',
|
|
32
|
+
'oauthRefreshToken.scopes',
|
|
33
|
+
'oauthRefreshToken.createdAt',
|
|
34
|
+
'oauthClient.name',
|
|
35
|
+
'atc_grant_use.last_used_at',
|
|
36
|
+
])
|
|
37
|
+
.where('oauthRefreshToken.revoked', 'is', null)
|
|
38
|
+
.where('oauthRefreshToken.expiresAt', '>', new Date().toISOString())
|
|
39
|
+
.where('oauthRefreshToken.authorizationCodeId', 'is not', null)
|
|
40
|
+
.orderBy('oauthRefreshToken.createdAt')
|
|
41
|
+
.execute();
|
|
42
|
+
|
|
43
|
+
return rows.map((row) => {
|
|
44
|
+
const scopes = collectJSONStrings(row.scopes);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
grantID: row.authorizationCodeId ?? '',
|
|
48
|
+
clientID: row.clientId,
|
|
49
|
+
clientName: row.name ?? '',
|
|
50
|
+
scopes: GRANT_SCOPES.filter((scope) => scopes.includes(scope)),
|
|
51
|
+
createdAt: row.createdAt,
|
|
52
|
+
lastUsedAt: row.last_used_at,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The strings in a column holding a JSON array, which is how the
|
|
3
|
+
* authorization server stores a list. Anything else holds none.
|
|
4
|
+
*/
|
|
5
|
+
export function collectJSONStrings(raw: string): readonly string[] {
|
|
6
|
+
let parsed: unknown;
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
parsed = JSON.parse(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return Array.isArray(parsed)
|
|
15
|
+
? parsed.filter((item): item is string => typeof item === 'string')
|
|
16
|
+
: [];
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The SHA-256 of a token, base64url encoded: the only form of a token the
|
|
5
|
+
* authorization server stores.
|
|
6
|
+
*/
|
|
7
|
+
export function deriveTokenHash(token: string): string {
|
|
8
|
+
return createHash('sha256').update(token).digest('base64url');
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { MCPAuthSchema } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The name a client was added under, or null for an unknown client id or a
|
|
6
|
+
* client added without one.
|
|
7
|
+
*/
|
|
8
|
+
export async function findClientName(
|
|
9
|
+
db: Kysely<MCPAuthSchema>,
|
|
10
|
+
clientID: string,
|
|
11
|
+
): Promise<string | null> {
|
|
12
|
+
const row = await db
|
|
13
|
+
.selectFrom('oauthClient')
|
|
14
|
+
.select('name')
|
|
15
|
+
.where('clientId', '=', clientID)
|
|
16
|
+
.executeTakeFirst();
|
|
17
|
+
|
|
18
|
+
return row?.name ?? null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HTTPServerContext } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The id of the live owner session a request's cookie header carries, or
|
|
5
|
+
* null when it carries none, or one that is unsigned, expired, or deleted.
|
|
6
|
+
*/
|
|
7
|
+
export async function findOwnerSessionID(
|
|
8
|
+
ctx: HTTPServerContext,
|
|
9
|
+
cookie: string | null,
|
|
10
|
+
): Promise<string | null> {
|
|
11
|
+
if (cookie === null || cookie === '') {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const found = await ctx.store.auth.api.getSession({ headers: new Headers({ cookie }) });
|
|
16
|
+
|
|
17
|
+
return found?.session.id ?? null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isLoopbackHost } from './is-loopback-host';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a client may register a redirect URI: https, or http on a loopback
|
|
5
|
+
* host, with no fragment and no credentials.
|
|
6
|
+
*/
|
|
7
|
+
export function isAllowedRedirectURI(raw: string): boolean {
|
|
8
|
+
let url: URL;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(raw);
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (url.hash !== '' || url.username !== '' || url.password !== '') {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHost(url.hostname));
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a host name or address reaches only this machine: `localhost`, an
|
|
3
|
+
* IPv4 address in 127.0.0.0/8, or `::1` with or without the brackets a URL
|
|
4
|
+
* puts around it.
|
|
5
|
+
*/
|
|
6
|
+
export function isLoopbackHost(host: string): boolean {
|
|
7
|
+
return (
|
|
8
|
+
host === 'localhost' ||
|
|
9
|
+
host === '::1' ||
|
|
10
|
+
host === '[::1]' ||
|
|
11
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const SUPPORTED_PROTOCOL_VERSIONS: ReadonlySet<string> = new Set([
|
|
2
|
+
'2025-11-25',
|
|
3
|
+
'2025-06-18',
|
|
4
|
+
'2024-11-05',
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether atc's MCP server speaks a protocol version. It leaves out
|
|
9
|
+
* `2025-03-26`, which requires JSON-RPC batching that atc does not implement.
|
|
10
|
+
*/
|
|
11
|
+
export function isSupportedProtocolVersion(version: unknown): version is string {
|
|
12
|
+
return typeof version === 'string' && SUPPORTED_PROTOCOL_VERSIONS.has(version);
|
|
13
|
+
}
|