@boxxy/auth-oauth 0.2.7 → 0.2.9
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 +4 -0
- package/dist/auth/create-auth-admin.d.ts +0 -3
- package/dist/http/create-public-door.d.ts +6 -0
- package/dist/index.d.ts +3 -1
- package/dist/service-tokens/constants.d.ts +1 -0
- package/dist/service-tokens/parse-service-token-text.d.ts +3 -0
- package/dist/service-tokens/read-service-token-file.d.ts +3 -0
- package/dist/service-tokens/reload-service-tokens.d.ts +10 -0
- package/dist/service-tokens/start-service-token-reloads.d.ts +9 -0
- package/package.json +5 -4
- package/src/auth/create-auth-admin.ts +11 -30
- package/src/auth/format-security-event.ts +1 -2
- package/src/http/create-public-door.ts +72 -18
- package/src/index.ts +11 -1
- package/src/service-tokens/constants.ts +4 -0
- package/src/service-tokens/parse-service-token-text.ts +45 -0
- package/src/service-tokens/read-service-token-file.ts +22 -0
- package/src/service-tokens/reload-service-tokens.ts +33 -0
- package/src/service-tokens/start-service-token-reloads.ts +51 -0
- package/dist/auth/normalize-client-name.d.ts +0 -1
- package/src/auth/normalize-client-name.ts +0 -20
package/README.md
CHANGED
|
@@ -37,6 +37,10 @@ The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
|
37
37
|
token to the `serveMCP` callback that the app gives it.
|
|
38
38
|
- It writes each sign-in outcome and each refused token to `writeSecurityLine` as a JSON line.
|
|
39
39
|
- `admin` enrolls a passkey, adds and removes clients, and lists and revokes grants.
|
|
40
|
+
- With `serviceTokens`, the door signs in the service clients of a service-token file with the
|
|
41
|
+
`client_credentials` grant. It reads the file again on SIGHUP, and passes each outcome to
|
|
42
|
+
`onReload`: the clients replaced, or the old clients kept with the reason.
|
|
43
|
+
- `mintServiceSecret()` draws a service client's secret and its digest, for the file.
|
|
40
44
|
- `./test-utils/authorize-test-client` signs a client in through `door.auth` with a stub passkey,
|
|
41
45
|
for tests.
|
|
42
46
|
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { GrantScope } from '@boxxy/contract';
|
|
2
2
|
import type { MCPAuth } from '@zgeoff/mcp-auth';
|
|
3
3
|
import type { AuthAdmin } from '../auth/create-auth-admin.ts';
|
|
4
|
+
import type { ServiceTokenReload } from '../service-tokens/reload-service-tokens.ts';
|
|
4
5
|
import type { AuthClock } from '../utils/types.ts';
|
|
5
6
|
import type { McpServe } from './types.ts';
|
|
7
|
+
export interface ServiceTokensOptions {
|
|
8
|
+
readonly file: string;
|
|
9
|
+
readonly onReload: (reload: ServiceTokenReload) => void;
|
|
10
|
+
}
|
|
6
11
|
export interface PublicDoorOptions {
|
|
7
12
|
readonly publicURL: string;
|
|
8
13
|
readonly hostname: string;
|
|
@@ -13,6 +18,7 @@ export interface PublicDoorOptions {
|
|
|
13
18
|
readonly readPeer: (request: Request) => string | null;
|
|
14
19
|
readonly serveMCP: McpServe;
|
|
15
20
|
readonly clock?: AuthClock;
|
|
21
|
+
readonly serviceTokens?: ServiceTokensOptions;
|
|
16
22
|
}
|
|
17
23
|
export interface PublicDoor {
|
|
18
24
|
readonly answerRequest: (request: Request) => Promise<Response>;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type { AddClientAnswer, AuthAdmin, AuthGrantView, EnrollmentLink, RemoveClientAnswer, } from './auth/create-auth-admin.ts';
|
|
2
2
|
export type { McpAuthInfo, McpServe } from './http/types.ts';
|
|
3
|
-
export type { PublicDoor, PublicDoorOptions } from './http/create-public-door.ts';
|
|
3
|
+
export type { PublicDoor, PublicDoorOptions, ServiceTokensOptions, } from './http/create-public-door.ts';
|
|
4
4
|
export { createPublicDoor } from './http/create-public-door.ts';
|
|
5
5
|
export { isLoopbackHost } from './http/is-loopback-host.ts';
|
|
6
|
+
export type { ServiceTokenReload } from './service-tokens/reload-service-tokens.ts';
|
|
7
|
+
export { mintServiceSecret } from '@zgeoff/mcp-auth';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const serviceTokenPrefix = "boxxy_svc_";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
2
|
+
import type { MCPAuth } from '@zgeoff/mcp-auth';
|
|
3
|
+
export type ServiceTokenReload = {
|
|
4
|
+
readonly kind: 'replaced';
|
|
5
|
+
readonly clients: number;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: 'kept';
|
|
8
|
+
readonly reason: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function reloadServiceTokens(auth: Pick<MCPAuth<GrantScope>, 'replaceServiceTokens'>, path: string): Promise<ServiceTokenReload>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
2
|
+
import type { MCPAuth } from '@zgeoff/mcp-auth';
|
|
3
|
+
import type { ServiceTokenReload } from './reload-service-tokens.ts';
|
|
4
|
+
export interface ServiceTokenReloads {
|
|
5
|
+
readonly auth: Pick<MCPAuth<GrantScope>, 'replaceServiceTokens'>;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly onReload: (reload: ServiceTokenReload) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function startServiceTokenReloads(reloads: ServiceTokenReloads): () => Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/auth-oauth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
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,15 +31,16 @@
|
|
|
31
31
|
"typecheck": "tsrs --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@boxxy/contract": "0.2.
|
|
34
|
+
"@boxxy/contract": "0.2.9",
|
|
35
35
|
"@zgeoff/kysely-bun-sqlite": "0.3.2",
|
|
36
|
-
"@zgeoff/mcp-auth": "0.
|
|
36
|
+
"@zgeoff/mcp-auth": "0.2.2",
|
|
37
37
|
"@zgeoff/std": "0.4.3",
|
|
38
38
|
"elysia": "2.0.0-rc.3",
|
|
39
39
|
"exact-mirror": "1.2.7",
|
|
40
40
|
"kysely": "0.29.8",
|
|
41
41
|
"openapi-types": "12.1.3",
|
|
42
|
-
"typebox": "1.4.3"
|
|
42
|
+
"typebox": "1.4.3",
|
|
43
|
+
"zod": "4.6.7"
|
|
43
44
|
},
|
|
44
45
|
"engines": {
|
|
45
46
|
"bun": ">=1.3",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GrantScope } from '@boxxy/contract';
|
|
2
2
|
import { grantScopes } from '@boxxy/contract';
|
|
3
3
|
import { MCPAuthError } from '@zgeoff/mcp-auth';
|
|
4
|
-
import type { ClientView, GrantID, GrantView, MCPAuth } from '@zgeoff/mcp-auth';
|
|
4
|
+
import type { ClientView, GrantID, GrantView, MCPAuth, OAuthClientID } from '@zgeoff/mcp-auth';
|
|
5
5
|
import { toTagged } from '@zgeoff/std';
|
|
6
6
|
|
|
7
7
|
export interface AuthAdminInput {
|
|
@@ -16,12 +16,7 @@ export type AddClientAnswer =
|
|
|
16
16
|
| { readonly kind: 'refused'; readonly message: string }
|
|
17
17
|
| { readonly kind: 'conflict'; readonly name: string };
|
|
18
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 };
|
|
19
|
+
export type RemoveClientAnswer = { readonly kind: 'removed' } | { readonly kind: 'not-found' };
|
|
25
20
|
|
|
26
21
|
export interface EnrollmentLink {
|
|
27
22
|
readonly token: string;
|
|
@@ -72,17 +67,13 @@ export function createAuthAdmin(input: AuthAdminInput): AuthAdmin {
|
|
|
72
67
|
},
|
|
73
68
|
listClients: () => auth.clients.list(),
|
|
74
69
|
removeClient: async (clientID) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const answer = pickClientToRemove(clients, clientID);
|
|
78
|
-
|
|
79
|
-
if (answer.kind === 'remove') {
|
|
80
|
-
await auth.clients.remove(answer.name);
|
|
70
|
+
try {
|
|
71
|
+
await auth.clients.remove(toTagged<OAuthClientID>(clientID));
|
|
81
72
|
|
|
82
73
|
return { kind: 'removed' };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return pickRemoveRefusal(error);
|
|
83
76
|
}
|
|
84
|
-
|
|
85
|
-
return answer;
|
|
86
77
|
},
|
|
87
78
|
listGrants: async () => {
|
|
88
79
|
const grants = await auth.grants.list();
|
|
@@ -114,24 +105,14 @@ function pickAddRefusal(error: unknown, name: string): AddClientAnswer {
|
|
|
114
105
|
throw error;
|
|
115
106
|
}
|
|
116
107
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
function pickClientToRemove(clients: readonly ClientView[], clientID: string): ClientToRemove {
|
|
122
|
-
const client = clients.find((view) => view.clientID === clientID);
|
|
123
|
-
|
|
124
|
-
if (client === undefined) {
|
|
108
|
+
// The package refuses an unknown client ID with a coded error; any other error is not the owner's
|
|
109
|
+
// input.
|
|
110
|
+
function pickRemoveRefusal(error: unknown): RemoveClientAnswer {
|
|
111
|
+
if (error instanceof MCPAuthError && error.code === 'not-found') {
|
|
125
112
|
return { kind: 'not-found' };
|
|
126
113
|
}
|
|
127
114
|
|
|
128
|
-
|
|
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 };
|
|
115
|
+
throw error;
|
|
135
116
|
}
|
|
136
117
|
|
|
137
118
|
function toAuthGrantView(grant: GrantView): AuthGrantView {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { DEFAULT_PATHS } from '@zgeoff/mcp-auth';
|
|
1
|
+
import { DEFAULT_PATHS, normalizeClientName } from '@zgeoff/mcp-auth';
|
|
2
2
|
import type { SecurityEvent } from '@zgeoff/mcp-auth';
|
|
3
|
-
import { normalizeClientName } from './normalize-client-name.ts';
|
|
4
3
|
|
|
5
4
|
// The IDs in an event are tagged strings, and a line needs only their text.
|
|
6
5
|
type LoggedSecurityEvent = Omit<SecurityEvent, 'client' | 'correlationID'> & {
|
|
@@ -4,18 +4,27 @@ import { dirname } from 'node:path';
|
|
|
4
4
|
import type { GrantScope } from '@boxxy/contract';
|
|
5
5
|
import { grantScopes } from '@boxxy/contract';
|
|
6
6
|
import { DEFAULT_PATHS, openMCPAuth } from '@zgeoff/mcp-auth';
|
|
7
|
-
import type { MCPAuth, Requester } from '@zgeoff/mcp-auth';
|
|
7
|
+
import type { MCPAuth, Requester, ServiceTokenFile } from '@zgeoff/mcp-auth';
|
|
8
8
|
import { createMCPHTTPApp } from '@zgeoff/mcp-auth/elysia';
|
|
9
9
|
import { createAuthAdmin } from '../auth/create-auth-admin.ts';
|
|
10
10
|
import type { AuthAdmin } from '../auth/create-auth-admin.ts';
|
|
11
11
|
import { createSecurityEventLog } from '../auth/create-security-event-log.ts';
|
|
12
12
|
import { openAuthDatabase } from '../auth/open-auth-database.ts';
|
|
13
13
|
import { runAuthMigrations } from '../migrations/run-auth-migrations.ts';
|
|
14
|
+
import { serviceTokenPrefix } from '../service-tokens/constants.ts';
|
|
15
|
+
import { readServiceTokenFile } from '../service-tokens/read-service-token-file.ts';
|
|
16
|
+
import type { ServiceTokenReload } from '../service-tokens/reload-service-tokens.ts';
|
|
17
|
+
import { startServiceTokenReloads } from '../service-tokens/start-service-token-reloads.ts';
|
|
14
18
|
import type { AuthClock } from '../utils/types.ts';
|
|
15
19
|
import { isLoopbackHost } from './is-loopback-host.ts';
|
|
16
20
|
import { normalizePublicURL } from './normalize-public-url.ts';
|
|
17
21
|
import type { McpServe } from './types.ts';
|
|
18
22
|
|
|
23
|
+
export interface ServiceTokensOptions {
|
|
24
|
+
readonly file: string;
|
|
25
|
+
readonly onReload: (reload: ServiceTokenReload) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
export interface PublicDoorOptions {
|
|
20
29
|
// The origin clients reach the door at: the OAuth issuer.
|
|
21
30
|
readonly publicURL: string;
|
|
@@ -40,6 +49,11 @@ export interface PublicDoorOptions {
|
|
|
40
49
|
// Answers an MCP request whose bearer token verified.
|
|
41
50
|
readonly serveMCP: McpServe;
|
|
42
51
|
readonly clock?: AuthClock;
|
|
52
|
+
|
|
53
|
+
// The file of the service clients that sign in with the client_credentials grant. The door reads
|
|
54
|
+
// it at start, and again on each SIGHUP, and reports each reload. With none, the door takes no
|
|
55
|
+
// service client.
|
|
56
|
+
readonly serviceTokens?: ServiceTokensOptions;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
export interface PublicDoor {
|
|
@@ -70,8 +84,8 @@ const systemClock: AuthClock = {
|
|
|
70
84
|
const NO_APP_PREFIX = '/.boxxy-none';
|
|
71
85
|
|
|
72
86
|
// Opens and migrates the authorization database and builds the door's routes. Throws on a public
|
|
73
|
-
// URL that is
|
|
74
|
-
//
|
|
87
|
+
// URL that is neither https nor loopback while the door listens beyond loopback, and on a broken
|
|
88
|
+
// service-token file, so a deploy never serves without its service clients.
|
|
75
89
|
export async function createPublicDoor(options: PublicDoorOptions): Promise<PublicDoor> {
|
|
76
90
|
const clock = options.clock ?? systemClock;
|
|
77
91
|
const origin = normalizePublicURL(options.publicURL);
|
|
@@ -110,14 +124,21 @@ export async function createPublicDoor(options: PublicDoorOptions): Promise<Publ
|
|
|
110
124
|
};
|
|
111
125
|
};
|
|
112
126
|
|
|
127
|
+
const serviceTokens =
|
|
128
|
+
options.serviceTokens === undefined
|
|
129
|
+
? null
|
|
130
|
+
: await readStartFile(options.serviceTokens.file, origin);
|
|
131
|
+
|
|
113
132
|
mkdirSync(dirname(options.dbPath), { recursive: true });
|
|
114
133
|
|
|
115
134
|
const sqlite = openAuthDatabase(options.dbPath);
|
|
116
135
|
|
|
117
|
-
const auth = await openAuth(sqlite, origin, clock, security).catch(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
const auth = await openAuth({ sqlite, origin, clock, security, serviceTokens }).catch(
|
|
137
|
+
(error: unknown) => {
|
|
138
|
+
sqlite.close();
|
|
139
|
+
throw error;
|
|
140
|
+
},
|
|
141
|
+
);
|
|
121
142
|
|
|
122
143
|
const hosts = new Set<string>([new URL(origin).host]);
|
|
123
144
|
const origins = new Set<string>([origin]);
|
|
@@ -149,6 +170,15 @@ export async function createPublicDoor(options: PublicDoorOptions): Promise<Publ
|
|
|
149
170
|
|
|
150
171
|
const sweep = startSweep(auth, clock, options.onSweepError);
|
|
151
172
|
|
|
173
|
+
const stopReloads =
|
|
174
|
+
options.serviceTokens === undefined
|
|
175
|
+
? () => Promise.resolve()
|
|
176
|
+
: startServiceTokenReloads({
|
|
177
|
+
auth,
|
|
178
|
+
path: options.serviceTokens.file,
|
|
179
|
+
onReload: options.serviceTokens.onReload,
|
|
180
|
+
});
|
|
181
|
+
|
|
152
182
|
return {
|
|
153
183
|
answerRequest: (request) => app.handle(request),
|
|
154
184
|
addLocalPort: (port) => {
|
|
@@ -162,6 +192,8 @@ export async function createPublicDoor(options: PublicDoorOptions): Promise<Publ
|
|
|
162
192
|
stop: async () => {
|
|
163
193
|
sweep.stop();
|
|
164
194
|
|
|
195
|
+
await stopReloads();
|
|
196
|
+
|
|
165
197
|
await auth.close();
|
|
166
198
|
|
|
167
199
|
sqlite.close();
|
|
@@ -170,23 +202,45 @@ export async function createPublicDoor(options: PublicDoorOptions): Promise<Publ
|
|
|
170
202
|
};
|
|
171
203
|
}
|
|
172
204
|
|
|
173
|
-
async function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
205
|
+
async function readStartFile(path: string, origin: string): Promise<ServiceTokenFile<GrantScope>> {
|
|
206
|
+
const parsed = await readServiceTokenFile(path);
|
|
207
|
+
|
|
208
|
+
if (!parsed.ok) {
|
|
209
|
+
throw new Error(`the service-token file ${path} is not valid: ${parsed.reason}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (parsed.file.resource !== `${origin}/mcp`) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`the service-token file ${path} is not valid: it names the resource ${parsed.file.resource}, where the door serves ${origin}/mcp`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return parsed.file;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface AuthOpening {
|
|
222
|
+
readonly sqlite: Database;
|
|
223
|
+
readonly origin: string;
|
|
224
|
+
readonly clock: AuthClock;
|
|
225
|
+
readonly security: ReturnType<typeof createSecurityEventLog>;
|
|
226
|
+
readonly serviceTokens: ServiceTokenFile<GrantScope> | null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function openAuth(opening: AuthOpening): Promise<MCPAuth<GrantScope>> {
|
|
230
|
+
await runAuthMigrations(opening.sqlite);
|
|
180
231
|
|
|
181
232
|
return openMCPAuth({
|
|
182
|
-
database: sqlite,
|
|
183
|
-
origin,
|
|
233
|
+
database: opening.sqlite,
|
|
234
|
+
origin: opening.origin,
|
|
184
235
|
productName: 'boxxy',
|
|
185
236
|
scopes: grantScopes,
|
|
186
237
|
paths: DEFAULT_PATHS,
|
|
187
238
|
tables: { passkey: 'boxxy_passkey', enrollmentToken: 'boxxy_enrollment_token' },
|
|
188
|
-
clock,
|
|
189
|
-
security,
|
|
239
|
+
clock: opening.clock,
|
|
240
|
+
security: opening.security,
|
|
241
|
+
...(opening.serviceTokens === null
|
|
242
|
+
? {}
|
|
243
|
+
: { serviceTokens: { prefix: serviceTokenPrefix, file: opening.serviceTokens } }),
|
|
190
244
|
|
|
191
245
|
// Each MCP request builds its own server and holds no stream open, so a revoke has nothing to
|
|
192
246
|
// end beyond the tokens the package deletes.
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,16 @@ export type {
|
|
|
7
7
|
} from './auth/create-auth-admin.ts';
|
|
8
8
|
|
|
9
9
|
export type { McpAuthInfo, McpServe } from './http/types.ts';
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
PublicDoor,
|
|
13
|
+
PublicDoorOptions,
|
|
14
|
+
ServiceTokensOptions,
|
|
15
|
+
} from './http/create-public-door.ts';
|
|
16
|
+
|
|
11
17
|
export { createPublicDoor } from './http/create-public-door.ts';
|
|
12
18
|
export { isLoopbackHost } from './http/is-loopback-host.ts';
|
|
19
|
+
export type { ServiceTokenReload } from './service-tokens/reload-service-tokens.ts';
|
|
20
|
+
|
|
21
|
+
// The secret of a new service client, and the sha256 digest that its service-token file holds.
|
|
22
|
+
export { mintServiceSecret } from '@zgeoff/mcp-auth';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The auth core routes a bearer by its first characters, so it needs a static-token prefix, though
|
|
2
|
+
// boxxy refuses a file with a static token. It must not start with, or be the start of, the `svca_`
|
|
3
|
+
// of each access token that the core issues to a service client.
|
|
4
|
+
export const serviceTokenPrefix = 'boxxy_svc_';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
2
|
+
import { grantScopes } from '@boxxy/contract';
|
|
3
|
+
import { parseServiceTokenFile } from '@zgeoff/mcp-auth';
|
|
4
|
+
import type { ParsedServiceTokenFile } from '@zgeoff/mcp-auth';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { serviceTokenPrefix } from './constants.ts';
|
|
7
|
+
|
|
8
|
+
// The package's parser, with two departures that the public door's design explains: a file of no
|
|
9
|
+
// clients parses, so removing the last client revokes it, and a client with a static token is
|
|
10
|
+
// refused, since anyone who read the file could send that token. No reason quotes the file.
|
|
11
|
+
export function parseServiceTokenText(text: string): ParsedServiceTokenFile<GrantScope> {
|
|
12
|
+
const parsed = parseServiceTokenFile(text, { prefix: serviceTokenPrefix, scopes: grantScopes });
|
|
13
|
+
|
|
14
|
+
if (!parsed.ok) {
|
|
15
|
+
return findEmptyFile(text) ?? parsed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const holder = parsed.file.clients.find((client) => client.tokens.length > 0);
|
|
19
|
+
|
|
20
|
+
return holder === undefined
|
|
21
|
+
? parsed
|
|
22
|
+
: {
|
|
23
|
+
ok: false,
|
|
24
|
+
reason: `the client ${holder.name} holds a static token, and boxxy takes only secretDigests`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const emptyFileSchema = z.strictObject({
|
|
29
|
+
resource: z.string().min(1),
|
|
30
|
+
clients: z.strictObject({}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function findEmptyFile(text: string): ParsedServiceTokenFile<GrantScope> | null {
|
|
34
|
+
let raw: unknown;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
raw = JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const empty = emptyFileSchema.safeParse(raw);
|
|
43
|
+
|
|
44
|
+
return empty.success ? { ok: true, file: { resource: empty.data.resource, clients: [] } } : null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
3
|
+
import type { ParsedServiceTokenFile } from '@zgeoff/mcp-auth';
|
|
4
|
+
import { parseServiceTokenText } from './parse-service-token-text.ts';
|
|
5
|
+
|
|
6
|
+
// The service clients in the file at `path`. A file that is missing or fails to parse gives the
|
|
7
|
+
// reason, which never quotes the file.
|
|
8
|
+
export async function readServiceTokenFile(
|
|
9
|
+
path: string,
|
|
10
|
+
): Promise<ParsedServiceTokenFile<GrantScope>> {
|
|
11
|
+
let text: string;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
text = await readFile(path, 'utf8');
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const code = error instanceof Error && 'code' in error ? String(error.code) : 'unknown';
|
|
17
|
+
|
|
18
|
+
return { ok: false, reason: `the file cannot be read (${code})` };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return parseServiceTokenText(text);
|
|
22
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
2
|
+
import { MCPAuthError } from '@zgeoff/mcp-auth';
|
|
3
|
+
import type { MCPAuth } from '@zgeoff/mcp-auth';
|
|
4
|
+
import { readServiceTokenFile } from './read-service-token-file.ts';
|
|
5
|
+
|
|
6
|
+
export type ServiceTokenReload =
|
|
7
|
+
| { readonly kind: 'replaced'; readonly clients: number }
|
|
8
|
+
| { readonly kind: 'kept'; readonly reason: string };
|
|
9
|
+
|
|
10
|
+
// Reads the service-token file again and hands it to the auth core. A file that fails to read or
|
|
11
|
+
// parse, or that names another resource, leaves the clients the core holds as they were.
|
|
12
|
+
export async function reloadServiceTokens(
|
|
13
|
+
auth: Pick<MCPAuth<GrantScope>, 'replaceServiceTokens'>,
|
|
14
|
+
path: string,
|
|
15
|
+
): Promise<ServiceTokenReload> {
|
|
16
|
+
const parsed = await readServiceTokenFile(path);
|
|
17
|
+
|
|
18
|
+
if (!parsed.ok) {
|
|
19
|
+
return { kind: 'kept', reason: parsed.reason };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
auth.replaceServiceTokens(parsed.file);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof MCPAuthError) {
|
|
26
|
+
return { kind: 'kept', reason: error.message };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { kind: 'replaced', clients: parsed.file.clients.length };
|
|
33
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { GrantScope } from '@boxxy/contract';
|
|
2
|
+
import type { MCPAuth } from '@zgeoff/mcp-auth';
|
|
3
|
+
import { reloadServiceTokens } from './reload-service-tokens.ts';
|
|
4
|
+
import type { ServiceTokenReload } from './reload-service-tokens.ts';
|
|
5
|
+
|
|
6
|
+
export interface ServiceTokenReloads {
|
|
7
|
+
readonly auth: Pick<MCPAuth<GrantScope>, 'replaceServiceTokens'>;
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly onReload: (reload: ServiceTokenReload) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Each SIGHUP reads the service-token file again and reports the outcome. Reloads run one at a
|
|
13
|
+
// time, so an older read never lands after a newer one. The returned function stops listening and
|
|
14
|
+
// waits for the last reload.
|
|
15
|
+
export function startServiceTokenReloads(reloads: ServiceTokenReloads): () => Promise<void> {
|
|
16
|
+
let reloading = Promise.resolve();
|
|
17
|
+
|
|
18
|
+
const onHangUp = (): void => {
|
|
19
|
+
const previous = reloading;
|
|
20
|
+
|
|
21
|
+
reloading = (async () => {
|
|
22
|
+
await previous;
|
|
23
|
+
await runReload(reloads);
|
|
24
|
+
})();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
process.on('SIGHUP', onHangUp);
|
|
28
|
+
|
|
29
|
+
return async () => {
|
|
30
|
+
process.off('SIGHUP', onHangUp);
|
|
31
|
+
|
|
32
|
+
await reloading;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function runReload(reloads: ServiceTokenReloads): Promise<void> {
|
|
37
|
+
const reload = await reloadServiceTokens(reloads.auth, reloads.path).catch(
|
|
38
|
+
(error: unknown): ServiceTokenReload => ({
|
|
39
|
+
kind: 'kept',
|
|
40
|
+
reason: `the reload failed: ${String(error)}`,
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// a report that throws must not reject the chain, which would skip every later reload and fail
|
|
45
|
+
// the door's stop; stderr is the one place left to say so
|
|
46
|
+
try {
|
|
47
|
+
reloads.onReload(reload);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
process.stderr.write(`the service-token reload was not reported: ${String(error)}\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function normalizeClientName(raw: unknown, fallback?: string, maxLength?: number): string;
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export function normalizeClientName(
|
|
2
|
-
raw: unknown,
|
|
3
|
-
fallback = 'unnamed client',
|
|
4
|
-
maxLength = 100,
|
|
5
|
-
): string {
|
|
6
|
-
if (typeof raw !== 'string') {
|
|
7
|
-
return fallback;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const folded = raw
|
|
11
|
-
.replaceAll(/[\t\n\v\f\r]/gu, ' ')
|
|
12
|
-
.replaceAll(/[\p{Cc}\p{Cf}]/gu, '')
|
|
13
|
-
.replaceAll(/\s+/gu, ' ')
|
|
14
|
-
.trim();
|
|
15
|
-
|
|
16
|
-
// The cut counts code points, so it never splits a surrogate pair.
|
|
17
|
-
const cut = folded.replace(new RegExp(`^(?<kept>.{${maxLength}}).+$`, 'su'), '$<kept>').trim();
|
|
18
|
-
|
|
19
|
-
return cut === '' ? fallback : cut;
|
|
20
|
-
}
|