@boxxy/auth-oauth 0.2.8 → 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 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,3 @@
1
+ import type { GrantScope } from '@boxxy/contract';
2
+ import type { ParsedServiceTokenFile } from '@zgeoff/mcp-auth';
3
+ export declare function parseServiceTokenText(text: string): ParsedServiceTokenFile<GrantScope>;
@@ -0,0 +1,3 @@
1
+ import type { GrantScope } from '@boxxy/contract';
2
+ import type { ParsedServiceTokenFile } from '@zgeoff/mcp-auth';
3
+ export declare function readServiceTokenFile(path: string): Promise<ParsedServiceTokenFile<GrantScope>>;
@@ -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.8",
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.8",
34
+ "@boxxy/contract": "0.2.9",
35
35
  "@zgeoff/kysely-bun-sqlite": "0.3.2",
36
- "@zgeoff/mcp-auth": "0.2.1",
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",
@@ -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 not an https origin, or not a loopback address while the door listens beyond
74
- // loopback.
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((error: unknown) => {
118
- sqlite.close();
119
- throw error;
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 openAuth(
174
- sqlite: Database,
175
- origin: string,
176
- clock: AuthClock,
177
- security: ReturnType<typeof createSecurityEventLog>,
178
- ): Promise<MCPAuth<GrantScope>> {
179
- await runAuthMigrations(sqlite);
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
- export type { PublicDoor, PublicDoorOptions } from './http/create-public-door.ts';
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
+ }