@boxxy/auth-oauth 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- 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 +52 -4
- 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,221 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/create-approval-state.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
interface ApprovalRequest {
|
|
6
|
+
// The signed authorization query the login page carries: one per request.
|
|
7
|
+
readonly key: string;
|
|
8
|
+
readonly clientID: string;
|
|
9
|
+
readonly clientName: string;
|
|
10
|
+
readonly redirectURI: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PendingApproval extends ApprovalRequest {
|
|
14
|
+
// Random, minted with the approval and never taken from the request, so
|
|
15
|
+
// the security events of one authorization can be matched up.
|
|
16
|
+
readonly correlationID: string;
|
|
17
|
+
failures: number;
|
|
18
|
+
readonly expiresAt: number;
|
|
19
|
+
|
|
20
|
+
// The one WebAuthn challenge this request's login page holds, if any.
|
|
21
|
+
challenge: { readonly value: string; readonly expiresAt: number } | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type TakenChallenge =
|
|
25
|
+
| { readonly kind: 'taken'; readonly challenge: string }
|
|
26
|
+
| { readonly kind: 'expired' }
|
|
27
|
+
| { readonly kind: 'missing' };
|
|
28
|
+
|
|
29
|
+
// At most this many approvals wait at once across every client, and each
|
|
30
|
+
// client holds at most this many. Across every client, at most this many
|
|
31
|
+
// approvals start per minute.
|
|
32
|
+
const MAX_PENDING = 16;
|
|
33
|
+
const MAX_PENDING_PER_CLIENT = 3;
|
|
34
|
+
const MAX_STARTS_PER_MINUTE = 10;
|
|
35
|
+
const MAX_FAILURES = 5;
|
|
36
|
+
const MINUTE_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
// How long a login page's challenge stays valid, unless its request
|
|
39
|
+
// expires sooner.
|
|
40
|
+
const CHALLENGE_MS = 5 * MINUTE_MS;
|
|
41
|
+
|
|
42
|
+
interface ApprovedRequest {
|
|
43
|
+
readonly binding: string;
|
|
44
|
+
readonly correlationID: string;
|
|
45
|
+
readonly expiresAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ApprovalState {
|
|
49
|
+
// Returns null when the last minute already holds its share of starts.
|
|
50
|
+
readonly createPending: (request: ApprovalRequest) => PendingApproval | null;
|
|
51
|
+
|
|
52
|
+
readonly findPending: (key: string) => PendingApproval | null;
|
|
53
|
+
|
|
54
|
+
// A fresh random challenge for the request's login page, replacing any
|
|
55
|
+
// earlier one, or null when the request no longer waits.
|
|
56
|
+
readonly mintChallenge: (key: string) => string | null;
|
|
57
|
+
|
|
58
|
+
// Takes the request's challenge, so it verifies at most one assertion.
|
|
59
|
+
readonly claimChallenge: (key: string) => TakenChallenge;
|
|
60
|
+
|
|
61
|
+
// A failed sign-in counts against the request; the last allowed failure
|
|
62
|
+
// drops it.
|
|
63
|
+
readonly recordFailure: (key: string) => 'retry' | 'locked';
|
|
64
|
+
|
|
65
|
+
readonly removePending: (key: string) => void;
|
|
66
|
+
|
|
67
|
+
readonly recordApproved: (
|
|
68
|
+
sessionID: string,
|
|
69
|
+
approved: Omit<ApprovedRequest, 'expiresAt'>,
|
|
70
|
+
) => void;
|
|
71
|
+
|
|
72
|
+
// What an owner session approved, or null once it expired or went.
|
|
73
|
+
readonly findApproved: (sessionID: string) => ApprovedRequest | null;
|
|
74
|
+
|
|
75
|
+
readonly removeApproved: (sessionID: string) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createApprovalState(pendingMs: number, now: () => number): ApprovalState {
|
|
79
|
+
const pending = new Map<string, PendingApproval>();
|
|
80
|
+
|
|
81
|
+
// Owner session id to the binding of the request its sign-in approved.
|
|
82
|
+
const approved = new Map<string, ApprovedRequest>();
|
|
83
|
+
|
|
84
|
+
// Approval start times within the last minute, oldest first.
|
|
85
|
+
let startedAt: readonly number[] = [];
|
|
86
|
+
|
|
87
|
+
const dropExpired = (at: number): void => {
|
|
88
|
+
for (const [key, approval] of pending) {
|
|
89
|
+
if (approval.expiresAt <= at) {
|
|
90
|
+
pending.delete(key);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const [sessionID, request] of approved) {
|
|
95
|
+
if (request.expiresAt <= at) {
|
|
96
|
+
approved.delete(sessionID);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const findPending = (key: string): PendingApproval | null => {
|
|
102
|
+
dropExpired(now());
|
|
103
|
+
|
|
104
|
+
return pending.get(key) ?? null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
createPending: (request) => {
|
|
109
|
+
const current = now();
|
|
110
|
+
|
|
111
|
+
dropExpired(current);
|
|
112
|
+
|
|
113
|
+
startedAt = startedAt.filter((at) => current - at < MINUTE_MS);
|
|
114
|
+
|
|
115
|
+
if (startedAt.length >= MAX_STARTS_PER_MINUTE) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const own = [...pending].filter(([, approval]) => approval.clientID === request.clientID);
|
|
120
|
+
const [ownOldest] = own;
|
|
121
|
+
|
|
122
|
+
// a new approval drops the oldest rather than being refused, so no client can
|
|
123
|
+
// hold the owner's approvals hostage.
|
|
124
|
+
if (own.length >= MAX_PENDING_PER_CLIENT && ownOldest !== undefined) {
|
|
125
|
+
pending.delete(ownOldest[0]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const oldest = pending.keys().next();
|
|
129
|
+
|
|
130
|
+
if (pending.size >= MAX_PENDING && oldest.done !== true) {
|
|
131
|
+
pending.delete(oldest.value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const approval: PendingApproval = {
|
|
135
|
+
...request,
|
|
136
|
+
correlationID: crypto.randomUUID(),
|
|
137
|
+
failures: 0,
|
|
138
|
+
expiresAt: current + pendingMs,
|
|
139
|
+
challenge: null,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
pending.set(approval.key, approval);
|
|
143
|
+
|
|
144
|
+
startedAt = [...startedAt, current];
|
|
145
|
+
|
|
146
|
+
return approval;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
findPending,
|
|
150
|
+
|
|
151
|
+
mintChallenge: (key) => {
|
|
152
|
+
const approval = findPending(key);
|
|
153
|
+
|
|
154
|
+
if (approval === null) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const value = randomBytes(32).toString('base64url');
|
|
159
|
+
|
|
160
|
+
approval.challenge = {
|
|
161
|
+
value,
|
|
162
|
+
expiresAt: Math.min(now() + CHALLENGE_MS, approval.expiresAt),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return value;
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
claimChallenge: (key) => {
|
|
169
|
+
const approval = findPending(key);
|
|
170
|
+
const challenge = approval?.challenge ?? null;
|
|
171
|
+
|
|
172
|
+
if (approval === null || challenge === null) {
|
|
173
|
+
return { kind: 'missing' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
approval.challenge = null;
|
|
177
|
+
|
|
178
|
+
if (challenge.expiresAt <= now()) {
|
|
179
|
+
return { kind: 'expired' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { kind: 'taken', challenge: challenge.value };
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
recordFailure: (key) => {
|
|
186
|
+
const approval = findPending(key);
|
|
187
|
+
|
|
188
|
+
if (approval === null) {
|
|
189
|
+
return 'locked';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
approval.failures += 1;
|
|
193
|
+
|
|
194
|
+
if (approval.failures >= MAX_FAILURES) {
|
|
195
|
+
pending.delete(key);
|
|
196
|
+
|
|
197
|
+
return 'locked';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return 'retry';
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
removePending: (key) => {
|
|
204
|
+
pending.delete(key);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
recordApproved: (sessionID, request) => {
|
|
208
|
+
approved.set(sessionID, { ...request, expiresAt: now() + pendingMs });
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
findApproved: (sessionID) => {
|
|
212
|
+
dropExpired(now());
|
|
213
|
+
|
|
214
|
+
return approved.get(sessionID) ?? null;
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
removeApproved: (sessionID) => {
|
|
218
|
+
approved.delete(sessionID);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { PUBLIC_DOOR_PATHS } from '../http/public-door-paths.ts';
|
|
2
|
+
import type { AuthClock } from '../utils/types.ts';
|
|
3
|
+
import { createEnrollmentToken } from './create-enrollment-token.ts';
|
|
4
|
+
import { isAllowedRedirectURI } from './is-allowed-redirect-uri.ts';
|
|
5
|
+
import { normalizeClientName } from './normalize-client-name.ts';
|
|
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>>;
|
|
15
|
+
|
|
16
|
+
export interface AuthAdminInput {
|
|
17
|
+
readonly store: MCPAuth;
|
|
18
|
+
readonly origin: string;
|
|
19
|
+
readonly clock: AuthClock;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type AddClientAnswer =
|
|
23
|
+
| { readonly kind: 'added'; readonly clientID: string; readonly name: string }
|
|
24
|
+
| { readonly kind: 'refused'; readonly redirectURI: string };
|
|
25
|
+
|
|
26
|
+
export interface EnrollmentLink {
|
|
27
|
+
readonly token: string;
|
|
28
|
+
readonly link: string;
|
|
29
|
+
readonly expiresAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// What the owner's commands do to the authorization database: add and remove clients, revoke
|
|
33
|
+
// grants, and mint the token that enrolls a passkey.
|
|
34
|
+
export interface AuthAdmin {
|
|
35
|
+
readonly enroll: () => Promise<EnrollmentLink>;
|
|
36
|
+
readonly addClient: (name: string, redirectURIs: readonly string[]) => Promise<AddClientAnswer>;
|
|
37
|
+
readonly listClients: () => Promise<readonly ClientView[]>;
|
|
38
|
+
readonly removeClient: (clientID: string) => Promise<boolean>;
|
|
39
|
+
readonly listGrants: () => Promise<readonly GrantView[]>;
|
|
40
|
+
readonly revokeGrant: (grantID: string) => Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createAuthAdmin(input: AuthAdminInput): AuthAdmin {
|
|
44
|
+
const store = input.store;
|
|
45
|
+
const origin = input.origin;
|
|
46
|
+
const clock = input.clock;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
enroll: async () => {
|
|
50
|
+
const created = await createEnrollmentToken(store.db, clock.now());
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
token: created.token,
|
|
54
|
+
link: `${origin}${PUBLIC_DOOR_PATHS.browser.enroll}?token=${created.token}`,
|
|
55
|
+
expiresAt: created.expiresAt,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
addClient: async (name, redirectURIs) => {
|
|
59
|
+
const refused = redirectURIs.find((uri) => !isAllowedRedirectURI(uri));
|
|
60
|
+
|
|
61
|
+
if (refused !== undefined) {
|
|
62
|
+
return { kind: 'refused', redirectURI: refused };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const clientName = normalizeClientName(name);
|
|
66
|
+
|
|
67
|
+
const created = await store.auth.api.createFixedClient({
|
|
68
|
+
body: { name: clientName, redirectURIs: [...redirectURIs] },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return { kind: 'added', clientID: created.clientID, name: clientName };
|
|
72
|
+
},
|
|
73
|
+
listClients: () => readClientViews(store.db),
|
|
74
|
+
removeClient: (clientID) => removeClient(store.db, clientID),
|
|
75
|
+
listGrants: () => readGrantViews(store.db),
|
|
76
|
+
revokeGrant: (grantID) => revokeGrant(store.db, grantID),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/create-enrollment-state.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import type { TakenChallenge } from './create-approval-state.ts';
|
|
5
|
+
|
|
6
|
+
// At most this many enrollment challenges wait at once; a new one past the
|
|
7
|
+
// limit drops the oldest.
|
|
8
|
+
const MAX_CHALLENGES = 8;
|
|
9
|
+
|
|
10
|
+
// How long an enrollment page's challenge stays valid.
|
|
11
|
+
const CHALLENGE_MS = 5 * 60_000;
|
|
12
|
+
|
|
13
|
+
export interface EnrollmentState {
|
|
14
|
+
readonly mintChallenge: (tokenHash: string) => string;
|
|
15
|
+
|
|
16
|
+
// Takes the token's challenge, so it verifies at most one registration.
|
|
17
|
+
readonly claimChallenge: (tokenHash: string) => TakenChallenge;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createEnrollmentState(now: () => number): EnrollmentState {
|
|
21
|
+
const challenges = new Map<string, { readonly value: string; readonly expiresAt: number }>();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
mintChallenge: (tokenHash) => {
|
|
25
|
+
challenges.delete(tokenHash);
|
|
26
|
+
|
|
27
|
+
const oldest = challenges.keys().next();
|
|
28
|
+
|
|
29
|
+
if (challenges.size >= MAX_CHALLENGES && oldest.done !== true) {
|
|
30
|
+
challenges.delete(oldest.value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const value = randomBytes(32).toString('base64url');
|
|
34
|
+
|
|
35
|
+
challenges.set(tokenHash, { value, expiresAt: now() + CHALLENGE_MS });
|
|
36
|
+
|
|
37
|
+
return value;
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
claimChallenge: (tokenHash) => {
|
|
41
|
+
const challenge = challenges.get(tokenHash);
|
|
42
|
+
|
|
43
|
+
if (challenge === undefined) {
|
|
44
|
+
return { kind: 'missing' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
challenges.delete(tokenHash);
|
|
48
|
+
|
|
49
|
+
if (challenge.expiresAt <= now()) {
|
|
50
|
+
return { kind: 'expired' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { kind: 'taken', challenge: challenge.value };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/create-enrollment-token.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import type { Kysely } from 'kysely';
|
|
5
|
+
import { deriveTokenHash } from './derive-token-hash.ts';
|
|
6
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
7
|
+
|
|
8
|
+
// How long an enrollment token stays usable after the host command mints it.
|
|
9
|
+
const ENROLLMENT_TOKEN_MS = 15 * 60_000;
|
|
10
|
+
|
|
11
|
+
export async function createEnrollmentToken(
|
|
12
|
+
db: Kysely<MCPAuthSchema>,
|
|
13
|
+
now: number,
|
|
14
|
+
): Promise<{ readonly token: string; readonly expiresAt: number }> {
|
|
15
|
+
const token = `boxxy-enroll-${randomBytes(32).toString('base64url')}`;
|
|
16
|
+
const expiresAt = now + ENROLLMENT_TOKEN_MS;
|
|
17
|
+
|
|
18
|
+
await db
|
|
19
|
+
.insertInto('boxxy_enrollment_token')
|
|
20
|
+
.values({
|
|
21
|
+
token_hash: deriveTokenHash(token),
|
|
22
|
+
created_at: new Date(now).toISOString(),
|
|
23
|
+
expires_at: new Date(expiresAt).toISOString(),
|
|
24
|
+
})
|
|
25
|
+
.execute();
|
|
26
|
+
|
|
27
|
+
return { token, expiresAt };
|
|
28
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/create-owner-plugin.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import { getOAuthProviderApi } from '@better-auth/oauth-provider';
|
|
4
|
+
import { grantScopes } from '@boxxy/contract';
|
|
5
|
+
import type { BetterAuthPlugin } from 'better-auth';
|
|
6
|
+
import { createAuthEndpoint } from 'better-auth/api';
|
|
7
|
+
import { setSessionCookie } from 'better-auth/cookies';
|
|
8
|
+
import { generateRandomString } from 'better-auth/crypto';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { isAllowedRedirectURI } from './is-allowed-redirect-uri.ts';
|
|
11
|
+
|
|
12
|
+
// boxxy has one user: the owner who runs it. better-auth needs an email for
|
|
13
|
+
// every user, so the owner gets one no mail ever reaches.
|
|
14
|
+
const OWNER_EMAIL = 'owner@boxxy.invalid';
|
|
15
|
+
const REDIRECT_URI = z.string().refine(isAllowedRedirectURI);
|
|
16
|
+
|
|
17
|
+
export function createOwnerPlugin() {
|
|
18
|
+
return {
|
|
19
|
+
id: 'boxxy-owner',
|
|
20
|
+
|
|
21
|
+
// every endpoint is server-only: better-auth refuses it over HTTP, so only boxxy's
|
|
22
|
+
// own routes reach it, after checking what it guards.
|
|
23
|
+
endpoints: {
|
|
24
|
+
signInOwner: createAuthEndpoint(
|
|
25
|
+
'/boxxy/sign-in-owner',
|
|
26
|
+
{
|
|
27
|
+
method: 'POST',
|
|
28
|
+
body: z.object({ oauth_query: z.string().optional() }),
|
|
29
|
+
metadata: { SERVER_ONLY: true },
|
|
30
|
+
},
|
|
31
|
+
async (ctx) => {
|
|
32
|
+
const adapter = ctx.context.internalAdapter;
|
|
33
|
+
|
|
34
|
+
const existing = await adapter.findUserByEmail(OWNER_EMAIL);
|
|
35
|
+
|
|
36
|
+
const user =
|
|
37
|
+
existing === null
|
|
38
|
+
? await adapter.createUser(
|
|
39
|
+
{ email: OWNER_EMAIL, name: 'owner', emailVerified: true },
|
|
40
|
+
{ method: 'admin' },
|
|
41
|
+
)
|
|
42
|
+
: existing.user;
|
|
43
|
+
|
|
44
|
+
const session = await adapter.createSession(user.id);
|
|
45
|
+
|
|
46
|
+
await setSessionCookie(ctx, { session, user });
|
|
47
|
+
|
|
48
|
+
return ctx.json({ signedIn: true });
|
|
49
|
+
},
|
|
50
|
+
),
|
|
51
|
+
verifyMCPAccessToken: createAuthEndpoint(
|
|
52
|
+
'/boxxy/verify-access-token',
|
|
53
|
+
{
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body: z.object({ token: z.string() }),
|
|
56
|
+
metadata: { SERVER_ONLY: true },
|
|
57
|
+
},
|
|
58
|
+
async (ctx) => {
|
|
59
|
+
const provider = ctx.context.getPlugin('oauth-provider');
|
|
60
|
+
|
|
61
|
+
if (provider === null) {
|
|
62
|
+
throw new Error('the oauth-provider plugin is not installed');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const payload = await getOAuthProviderApi(ctx, provider.options).requireActiveAccessToken(
|
|
66
|
+
ctx.body.token,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return ctx.json(payload);
|
|
70
|
+
},
|
|
71
|
+
),
|
|
72
|
+
createFixedClient: createAuthEndpoint(
|
|
73
|
+
'/boxxy/create-fixed-client',
|
|
74
|
+
{
|
|
75
|
+
method: 'POST',
|
|
76
|
+
body: z.object({
|
|
77
|
+
name: z.string().min(1),
|
|
78
|
+
redirectURIs: z.array(REDIRECT_URI).min(1),
|
|
79
|
+
}),
|
|
80
|
+
metadata: { SERVER_ONLY: true },
|
|
81
|
+
},
|
|
82
|
+
async (ctx) => {
|
|
83
|
+
const clientID = generateRandomString(32, 'a-z', 'A-Z', '0-9');
|
|
84
|
+
|
|
85
|
+
const now = new Date();
|
|
86
|
+
|
|
87
|
+
await ctx.context.adapter.create({
|
|
88
|
+
model: 'oauthClient',
|
|
89
|
+
data: {
|
|
90
|
+
clientId: clientID,
|
|
91
|
+
name: ctx.body.name,
|
|
92
|
+
redirectUris: ctx.body.redirectURIs,
|
|
93
|
+
scopes: [...grantScopes, 'offline_access'],
|
|
94
|
+
clientCredentialsScopes: [],
|
|
95
|
+
tokenEndpointAuthMethod: 'none',
|
|
96
|
+
grantTypes: ['authorization_code', 'refresh_token'],
|
|
97
|
+
responseTypes: ['code'],
|
|
98
|
+
applicationType: ctx.body.redirectURIs.every((uri) => uri.startsWith('https:'))
|
|
99
|
+
? 'web'
|
|
100
|
+
: 'native',
|
|
101
|
+
requirePKCE: true,
|
|
102
|
+
disabled: false,
|
|
103
|
+
createdAt: now,
|
|
104
|
+
updatedAt: now,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return ctx.json({ clientID });
|
|
109
|
+
},
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
} satisfies BetterAuthPlugin;
|
|
113
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/create-passkey.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import type { MCPAuthSchema } from './mcp-auth-schema.ts';
|
|
5
|
+
|
|
6
|
+
export interface NewPasskey {
|
|
7
|
+
// The hash of the enrollment token the passkey was enrolled with.
|
|
8
|
+
readonly tokenHash: string;
|
|
9
|
+
|
|
10
|
+
// The credential id and its COSE public key, both base64url.
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly publicKey: string;
|
|
13
|
+
readonly counter: number;
|
|
14
|
+
readonly transports: readonly string[];
|
|
15
|
+
|
|
16
|
+
// Epoch milliseconds.
|
|
17
|
+
readonly now: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createPasskey(db: Kysely<MCPAuthSchema>, passkey: NewPasskey): Promise<boolean> {
|
|
21
|
+
return db.transaction().execute(async (trx) => {
|
|
22
|
+
// spending the token in the same transaction as the insert lets one token
|
|
23
|
+
// enroll one passkey.
|
|
24
|
+
const spent = await trx
|
|
25
|
+
.deleteFrom('boxxy_enrollment_token')
|
|
26
|
+
.where('token_hash', '=', passkey.tokenHash)
|
|
27
|
+
.where('expires_at', '>', new Date(passkey.now).toISOString())
|
|
28
|
+
.executeTakeFirst();
|
|
29
|
+
|
|
30
|
+
if (spent.numDeletedRows === 0n) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await trx
|
|
35
|
+
.insertInto('boxxy_passkey')
|
|
36
|
+
.values({
|
|
37
|
+
id: passkey.id,
|
|
38
|
+
public_key: passkey.publicKey,
|
|
39
|
+
counter: passkey.counter,
|
|
40
|
+
transports: JSON.stringify(passkey.transports),
|
|
41
|
+
created_at: new Date(passkey.now).toISOString(),
|
|
42
|
+
last_used_at: null,
|
|
43
|
+
})
|
|
44
|
+
.execute();
|
|
45
|
+
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Copied from atc: packages/mcp/src/auth/create-security-event-log.ts at commit 4fe647cf.
|
|
2
|
+
|
|
3
|
+
import type { AuthClock } from '../utils/types.ts';
|
|
4
|
+
import { formatSecurityEvent } from './format-security-event.ts';
|
|
5
|
+
import type { SecurityEvent } from './types.ts';
|
|
6
|
+
|
|
7
|
+
interface SecurityEventLogOptions {
|
|
8
|
+
// Receives each `{json}` line.
|
|
9
|
+
readonly print: (line: string) => void;
|
|
10
|
+
readonly clock: AuthClock;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface KindWindow {
|
|
14
|
+
readonly startedAt: number;
|
|
15
|
+
printed: number;
|
|
16
|
+
dropped: number;
|
|
17
|
+
cancelFlush: (() => void) | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Each kind prints at most this many events per window; the rest are
|
|
21
|
+
// counted and written as one `suppressed` event when the window ends.
|
|
22
|
+
const MAX_EVENTS_PER_WINDOW = 20;
|
|
23
|
+
const WINDOW_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
export interface SecurityEventLog {
|
|
26
|
+
readonly record: (event: SecurityEvent) => void;
|
|
27
|
+
readonly drain: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createSecurityEventLog(opts: SecurityEventLogOptions): SecurityEventLog {
|
|
31
|
+
const windows = new Map<SecurityEvent['kind'], KindWindow>();
|
|
32
|
+
|
|
33
|
+
const printSuppressed = (
|
|
34
|
+
kind: SecurityEvent['kind'],
|
|
35
|
+
window: Readonly<KindWindow>,
|
|
36
|
+
now: number,
|
|
37
|
+
): void => {
|
|
38
|
+
window.cancelFlush?.();
|
|
39
|
+
|
|
40
|
+
if (window.dropped > 0) {
|
|
41
|
+
opts.print(
|
|
42
|
+
formatSecurityEvent({ kind: 'suppressed', reason: kind, count: window.dropped }, now),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const drainEnded = (now: number): void => {
|
|
48
|
+
for (const [kind, window] of windows) {
|
|
49
|
+
if (now - window.startedAt >= WINDOW_MS) {
|
|
50
|
+
windows.delete(kind);
|
|
51
|
+
|
|
52
|
+
printSuppressed(kind, window, now);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
record: (event) => {
|
|
59
|
+
const now = opts.clock.now();
|
|
60
|
+
|
|
61
|
+
drainEnded(now);
|
|
62
|
+
|
|
63
|
+
let window = windows.get(event.kind);
|
|
64
|
+
|
|
65
|
+
if (window === undefined) {
|
|
66
|
+
window = { startedAt: now, printed: 0, dropped: 0, cancelFlush: null };
|
|
67
|
+
|
|
68
|
+
windows.set(event.kind, window);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (window.printed < MAX_EVENTS_PER_WINDOW) {
|
|
72
|
+
window.printed++;
|
|
73
|
+
|
|
74
|
+
opts.print(formatSecurityEvent(event, now));
|
|
75
|
+
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
window.dropped++;
|
|
80
|
+
|
|
81
|
+
window.cancelFlush ??= opts.clock.schedule(
|
|
82
|
+
() => {
|
|
83
|
+
drainEnded(opts.clock.now());
|
|
84
|
+
},
|
|
85
|
+
window.startedAt + WINDOW_MS - now,
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
drain: () => {
|
|
90
|
+
for (const [kind, window] of windows) {
|
|
91
|
+
printSuppressed(kind, window, opts.clock.now());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
windows.clear();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|