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