@ouraihub/aether-worker 0.0.0-stage → 0.1.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.
@@ -0,0 +1,398 @@
1
+ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
2
+ import type { Actor } from '../context.js'
3
+ import type { StoredDocument } from '../storage.js'
4
+ import type { ApiKeyRecord, GrantRecord, McpServiceOptions, McpSettings } from './types.js'
5
+ import {
6
+ equal,
7
+ forbidden,
8
+ hash,
9
+ notFound,
10
+ object,
11
+ random,
12
+ strings,
13
+ text,
14
+ unique,
15
+ validActor,
16
+ } from './common.js'
17
+
18
+ interface KeyState {
19
+ record: ApiKeyRecord
20
+ hash: string
21
+ }
22
+ interface GrantState {
23
+ record: GrantRecord
24
+ refreshHash: string
25
+ refreshExpiresAt: number
26
+ generation: number
27
+ }
28
+ interface TokenIndex {
29
+ owner: Actor
30
+ id: string
31
+ kind: 'key' | 'access'
32
+ expiresAt: number | null
33
+ }
34
+ export interface Authentication {
35
+ actor: Actor
36
+ tokenId: string
37
+ scopes: readonly string[]
38
+ }
39
+
40
+ function keyRecord(value: unknown): value is ApiKeyRecord {
41
+ return (
42
+ object(value) &&
43
+ typeof value.id === 'string' &&
44
+ typeof value.name === 'string' &&
45
+ strings(value.scopes) &&
46
+ typeof value.prefix === 'string' &&
47
+ typeof value.createdAt === 'number' &&
48
+ nullableTime(value.expiresAt) &&
49
+ nullableTime(value.revokedAt)
50
+ )
51
+ }
52
+ function grantRecord(value: unknown): value is GrantRecord {
53
+ return (
54
+ object(value) &&
55
+ typeof value.id === 'string' &&
56
+ typeof value.applicationId === 'string' &&
57
+ (value.tenantId === null || typeof value.tenantId === 'string') &&
58
+ typeof value.subjectId === 'string' &&
59
+ typeof value.clientId === 'string' &&
60
+ typeof value.redirectUri === 'string' &&
61
+ strings(value.scopes) &&
62
+ typeof value.createdAt === 'number' &&
63
+ nullableTime(value.expiresAt) &&
64
+ nullableTime(value.revokedAt)
65
+ )
66
+ }
67
+ function nullableTime(value: unknown): value is number | null {
68
+ return value === null || (typeof value === 'number' && Number.isFinite(value))
69
+ }
70
+ function readKey(document: StoredDocument): KeyState {
71
+ const value = document.value
72
+ if (!object(value) || !keyRecord(value.record) || typeof value.hash !== 'string')
73
+ throw new KitFailure('internal', 'Invalid key storage')
74
+ return { record: value.record, hash: value.hash }
75
+ }
76
+ function readGrant(document: StoredDocument): GrantState {
77
+ const value = document.value
78
+ if (
79
+ !object(value) ||
80
+ !grantRecord(value.record) ||
81
+ typeof value.refreshHash !== 'string' ||
82
+ typeof value.refreshExpiresAt !== 'number' ||
83
+ typeof value.generation !== 'number'
84
+ )
85
+ throw new KitFailure('internal', 'Invalid grant storage')
86
+ return {
87
+ record: value.record,
88
+ refreshHash: value.refreshHash,
89
+ refreshExpiresAt: value.refreshExpiresAt,
90
+ generation: value.generation,
91
+ }
92
+ }
93
+
94
+ export class McpAuth {
95
+ readonly system: Actor
96
+ readonly options: McpServiceOptions
97
+ constructor(options: McpServiceOptions) {
98
+ this.options = options
99
+ this.system = {
100
+ subjectId: 'aether:mcp:registry',
101
+ applicationId: options.applicationId,
102
+ tenantId: null,
103
+ kind: 'service',
104
+ sessionId: null,
105
+ scopes: [],
106
+ }
107
+ }
108
+ now(): number {
109
+ return this.options.now?.() ?? Date.now()
110
+ }
111
+ actor(actor: Actor): void {
112
+ if (
113
+ !validActor(actor, this.options.applicationId) ||
114
+ actor.kind !== 'user' ||
115
+ actor.subjectId === this.system.subjectId
116
+ )
117
+ forbidden()
118
+ }
119
+ requested(value: unknown, actor?: Actor): string[] {
120
+ if (!strings(value) || value.length === 0 || value.length > 100)
121
+ throw badRequest('scopes', 'invalid')
122
+ const scopes = unique(value)
123
+ if (
124
+ scopes.some(
125
+ (scope) =>
126
+ !this.options.scopes.some((known) => known.id === scope) ||
127
+ (actor && !actor.scopes.includes(scope)),
128
+ )
129
+ )
130
+ forbidden()
131
+ return scopes
132
+ }
133
+ async settings(owner: Actor): Promise<McpSettings> {
134
+ const doc = await this.options.store.get(owner, 'mcp-settings', 'settings')
135
+ if (!doc) return { enabled: false, enabledScopes: [] }
136
+ if (
137
+ !object(doc.value) ||
138
+ typeof doc.value.enabled !== 'boolean' ||
139
+ !strings(doc.value.enabledScopes)
140
+ )
141
+ throw new KitFailure('internal', 'Invalid MCP settings')
142
+ return { enabled: doc.value.enabled, enabledScopes: doc.value.enabledScopes }
143
+ }
144
+ async saveSettings(actor: Actor, value: Record<string, unknown>): Promise<McpSettings> {
145
+ this.actor(actor)
146
+ if (typeof value.enabled !== 'boolean' || !strings(value.enabledScopes))
147
+ throw badRequest('settings', 'invalid')
148
+ const enabledScopes = value.enabledScopes.length
149
+ ? this.requested(value.enabledScopes, actor)
150
+ : []
151
+ const current = await this.options.store.get(actor, 'mcp-settings', 'settings')
152
+ const settings = { enabled: value.enabled, enabledScopes }
153
+ await this.options.store.compareAndSet(
154
+ actor,
155
+ 'mcp-settings',
156
+ 'settings',
157
+ current?.revision ?? null,
158
+ settings,
159
+ )
160
+ return settings
161
+ }
162
+ async keys(actor: Actor): Promise<ApiKeyRecord[]> {
163
+ this.actor(actor)
164
+ const result: ApiKeyRecord[] = []
165
+ let after: string | undefined
166
+ while (true) {
167
+ const page = await this.options.store.list(actor, 'mcp-keys', {
168
+ ...(after === undefined ? {} : { after }),
169
+ limit: 200,
170
+ })
171
+ result.push(...page.map((doc) => readKey(doc).record))
172
+ if (page.length < 200) return result
173
+ const next = page[page.length - 1]?.key
174
+ if (!next || (after !== undefined && next <= after))
175
+ throw new KitFailure('internal', 'Invalid key pagination')
176
+ after = next
177
+ }
178
+ }
179
+ async grants(actor: Actor): Promise<GrantRecord[]> {
180
+ this.actor(actor)
181
+ const result: GrantRecord[] = []
182
+ let after: string | undefined
183
+ while (true) {
184
+ const page = await this.options.store.list(actor, 'mcp-grants', {
185
+ ...(after === undefined ? {} : { after }),
186
+ limit: 200,
187
+ })
188
+ result.push(...page.map((doc) => readGrant(doc).record))
189
+ if (page.length < 200) return result
190
+ const next = page[page.length - 1]?.key
191
+ if (!next || (after !== undefined && next <= after))
192
+ throw new KitFailure('internal', 'Invalid grant pagination')
193
+ after = next
194
+ }
195
+ }
196
+ async issueKey(
197
+ actor: Actor,
198
+ input: Record<string, unknown>,
199
+ request: Request,
200
+ ): Promise<{ token: string; record: ApiKeyRecord }> {
201
+ this.actor(actor)
202
+ const name = text(input.name, 'name', 128).trim()
203
+ const scopes = this.requested(input.scopes, actor)
204
+ if (!(await this.options.authorizeConsent(actor, scopes, request))) forbidden()
205
+ const now = this.now()
206
+ const expiresAt = input.expiresAt === undefined ? now + 90 * 86400000 : input.expiresAt
207
+ if (
208
+ expiresAt !== null &&
209
+ (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt <= now)
210
+ )
211
+ throw badRequest('expiresAt', 'invalid')
212
+ const id = crypto.randomUUID()
213
+ const token = `aether_key_${random()}`
214
+ const digest = await hash(token)
215
+ const record: ApiKeyRecord = {
216
+ id,
217
+ name,
218
+ scopes,
219
+ prefix: `${token.slice(0, 16)}…`,
220
+ createdAt: now,
221
+ expiresAt,
222
+ revokedAt: null,
223
+ }
224
+ await this.options.store.compareAndSet(actor, 'mcp-keys', id, null, { record, hash: digest })
225
+ await this.index(digest, { owner: actor, id, kind: 'key', expiresAt })
226
+ return { token, record }
227
+ }
228
+ async revoke(actor: Actor, kind: 'key' | 'grant', id: string): Promise<void> {
229
+ this.actor(actor)
230
+ const collection = kind === 'key' ? 'mcp-keys' : 'mcp-grants'
231
+ const document = await this.options.store.get(actor, collection, id)
232
+ if (!document) notFound()
233
+ const state = kind === 'key' ? readKey(document) : readGrant(document)
234
+ if (state.record.revokedAt !== null) return
235
+ await this.options.store.compareAndSet(actor, collection, id, document.revision, {
236
+ ...state,
237
+ record: { ...state.record, revokedAt: this.now() },
238
+ })
239
+ }
240
+ private async index(digest: string, value: TokenIndex): Promise<void> {
241
+ await this.options.store.compareAndSet(this.system, 'mcp-token-index', digest, null, {
242
+ ...value,
243
+ audience: `${this.options.origin}/mcp`,
244
+ })
245
+ }
246
+ async authenticate(token: string): Promise<Authentication | null> {
247
+ if (token.length > 512) return null
248
+ const digest = await hash(token)
249
+ const doc = await this.options.store.get(this.system, 'mcp-token-index', digest)
250
+ if (!doc || !object(doc.value)) return null
251
+ const value = doc.value
252
+ if (
253
+ value.audience !== `${this.options.origin}/mcp` ||
254
+ !validActor(value.owner, this.options.applicationId) ||
255
+ value.owner.kind !== 'user' ||
256
+ typeof value.id !== 'string' ||
257
+ !nullableTime(value.expiresAt) ||
258
+ (value.expiresAt !== null && value.expiresAt <= this.now())
259
+ )
260
+ return null
261
+ const owner = value.owner
262
+ let scopes: readonly string[]
263
+ if (value.kind === 'key') {
264
+ const key = await this.options.store.get(owner, 'mcp-keys', value.id)
265
+ if (!key) return null
266
+ const state = readKey(key)
267
+ if (
268
+ state.record.revokedAt !== null ||
269
+ (state.record.expiresAt !== null && state.record.expiresAt <= this.now()) ||
270
+ !equal(state.hash, digest)
271
+ )
272
+ return null
273
+ scopes = state.record.scopes
274
+ } else if (value.kind === 'access') {
275
+ const grant = await this.options.store.get(owner, 'mcp-grants', value.id)
276
+ if (!grant) return null
277
+ const state = readGrant(grant)
278
+ if (!this.activeGrant(state, owner)) return null
279
+ scopes = state.record.scopes
280
+ } else return null
281
+ const settings = await this.settings(owner)
282
+ if (!settings.enabled) return null
283
+ const effective = scopes.filter(
284
+ (scope) =>
285
+ settings.enabledScopes.includes(scope) &&
286
+ this.options.scopes.some((known) => known.id === scope),
287
+ )
288
+ return {
289
+ actor: { ...owner, kind: 'mcp_client', sessionId: null, scopes: effective },
290
+ tokenId: value.id,
291
+ scopes: effective,
292
+ }
293
+ }
294
+ private activeGrant(state: GrantState, owner: Actor): boolean {
295
+ return (
296
+ state.record.applicationId === owner.applicationId &&
297
+ state.record.tenantId === owner.tenantId &&
298
+ state.record.subjectId === owner.subjectId &&
299
+ state.record.revokedAt === null &&
300
+ (state.record.expiresAt === null || state.record.expiresAt > this.now())
301
+ )
302
+ }
303
+ async createGrant(
304
+ owner: Actor,
305
+ clientId: string,
306
+ redirectUri: string,
307
+ scopes: readonly string[],
308
+ ): Promise<GrantRecord> {
309
+ const now = this.now()
310
+ const record: GrantRecord = {
311
+ id: crypto.randomUUID(),
312
+ applicationId: owner.applicationId,
313
+ tenantId: owner.tenantId,
314
+ subjectId: owner.subjectId,
315
+ clientId,
316
+ redirectUri,
317
+ scopes,
318
+ createdAt: now,
319
+ expiresAt: now + 30 * 86400000,
320
+ revokedAt: null,
321
+ }
322
+ await this.options.store.compareAndSet(owner, 'mcp-grants', record.id, null, {
323
+ record,
324
+ refreshHash: '',
325
+ refreshExpiresAt: 0,
326
+ generation: 0,
327
+ })
328
+ return record
329
+ }
330
+ async issueTokens(
331
+ owner: Actor,
332
+ grantId: string,
333
+ refresh?: string,
334
+ clientId?: string,
335
+ ): Promise<Record<string, unknown>> {
336
+ const document = await this.options.store.get(owner, 'mcp-grants', grantId)
337
+ if (!document) throw new KitFailure('unauthorized', 'Invalid grant')
338
+ const state = readGrant(document)
339
+ if (
340
+ !this.activeGrant(state, owner) ||
341
+ (clientId !== undefined && state.record.clientId !== clientId)
342
+ )
343
+ throw new KitFailure('unauthorized', 'Invalid grant')
344
+ if (
345
+ refresh !== undefined &&
346
+ (state.refreshExpiresAt <= this.now() || !equal(state.refreshHash, await hash(refresh)))
347
+ )
348
+ throw new KitFailure('unauthorized', 'Invalid grant')
349
+ const accessToken = `aether_access_${random()}`
350
+ const refreshToken = `aether_refresh_${random()}`
351
+ const now = this.now()
352
+ const updated: GrantState = {
353
+ ...state,
354
+ refreshHash: await hash(refreshToken),
355
+ refreshExpiresAt: Math.min(now + 30 * 86400000, state.record.expiresAt ?? Infinity),
356
+ generation: state.generation + 1,
357
+ }
358
+ await this.options.store.compareAndSet(owner, 'mcp-grants', grantId, document.revision, updated)
359
+ await this.index(await hash(accessToken), {
360
+ owner,
361
+ id: grantId,
362
+ kind: 'access',
363
+ expiresAt: now + 3600000,
364
+ })
365
+ await this.options.store.compareAndSet(
366
+ this.system,
367
+ 'mcp-refresh-index',
368
+ await hash(refreshToken),
369
+ null,
370
+ {
371
+ owner,
372
+ grantId,
373
+ clientId: state.record.clientId,
374
+ audience: `${this.options.origin}/mcp`,
375
+ },
376
+ )
377
+ return {
378
+ access_token: accessToken,
379
+ token_type: 'Bearer',
380
+ expires_in: 3600,
381
+ refresh_token: refreshToken,
382
+ scope: state.record.scopes.join(' '),
383
+ }
384
+ }
385
+ async refresh(token: string, clientId: string): Promise<Record<string, unknown>> {
386
+ const doc = await this.options.store.get(this.system, 'mcp-refresh-index', await hash(token))
387
+ if (
388
+ !doc ||
389
+ !object(doc.value) ||
390
+ doc.value.audience !== `${this.options.origin}/mcp` ||
391
+ !validActor(doc.value.owner, this.options.applicationId) ||
392
+ typeof doc.value.grantId !== 'string' ||
393
+ doc.value.clientId !== clientId
394
+ )
395
+ throw new KitFailure('unauthorized', 'Invalid grant')
396
+ return this.issueTokens(doc.value.owner, doc.value.grantId, token, clientId)
397
+ }
398
+ }