@agentuity/auth 0.1.7 → 0.1.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/dist/agentuity/config.d.ts +160 -150
- package/dist/agentuity/config.d.ts.map +1 -1
- package/dist/agentuity/config.js +1 -1
- package/dist/agentuity/config.js.map +1 -1
- package/dist/agentuity/react.d.ts +34 -34
- package/package.json +28 -8
- package/AGENTS.md +0 -117
- package/src/agentuity/config.ts +0 -401
- package/src/agentuity/plugins/api-key.ts +0 -158
- package/src/agentuity/plugins/index.ts +0 -35
- package/src/agentuity/plugins/jwt.ts +0 -30
- package/src/agentuity/plugins/organization.ts +0 -345
- package/src/agentuity/react.tsx +0 -366
- package/src/agentuity/server.ts +0 -734
- package/src/agentuity/types.ts +0 -201
- package/src/index.ts +0 -86
- package/src/schema.ts +0 -270
- package/src/types.ts +0 -30
- package/test/agentuity/config.test.ts +0 -621
- package/test/agentuity/server.test.ts +0 -537
- package/test/schema.test.ts +0 -147
- package/tsconfig.json +0 -13
- package/tsconfig.test.json +0 -11
- package/tsconfig.tsbuildinfo +0 -1
package/src/agentuity/server.ts
DELETED
|
@@ -1,734 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth Hono middleware and handlers for @agentuity/auth.
|
|
3
|
-
*
|
|
4
|
-
* Provides session and API key authentication middleware for Hono applications.
|
|
5
|
-
*
|
|
6
|
-
* @module agentuity/server
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { Context, MiddlewareHandler } from 'hono';
|
|
10
|
-
import { context, trace, SpanStatusCode } from '@opentelemetry/api';
|
|
11
|
-
|
|
12
|
-
import type { AuthBase } from './config';
|
|
13
|
-
import type {
|
|
14
|
-
AuthUser,
|
|
15
|
-
AuthSession,
|
|
16
|
-
AuthOrgContext,
|
|
17
|
-
AuthApiKeyContext,
|
|
18
|
-
AuthMethod,
|
|
19
|
-
AuthInterface,
|
|
20
|
-
} from './types';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Configuration for OpenTelemetry span attributes.
|
|
24
|
-
* All attributes are included by default. Set to `false` to opt-out of specific PII.
|
|
25
|
-
*/
|
|
26
|
-
export interface OtelSpansConfig {
|
|
27
|
-
/**
|
|
28
|
-
* Include user email in spans (`auth.user.email`).
|
|
29
|
-
* @default true
|
|
30
|
-
*/
|
|
31
|
-
email?: boolean;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Include organization name in spans (`auth.org.name`).
|
|
35
|
-
* @default true
|
|
36
|
-
*/
|
|
37
|
-
orgName?: boolean;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface AuthMiddlewareOptions {
|
|
41
|
-
/**
|
|
42
|
-
* If true, don't return 401 on missing auth - just continue without auth context.
|
|
43
|
-
* Useful for routes that work for both authenticated and anonymous users.
|
|
44
|
-
*/
|
|
45
|
-
optional?: boolean;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Configure which attributes are included in OpenTelemetry spans.
|
|
49
|
-
* All PII attributes are included by default. Use this to opt-out of specific fields.
|
|
50
|
-
*
|
|
51
|
-
* @example Disable email in spans
|
|
52
|
-
* ```typescript
|
|
53
|
-
* createSessionMiddleware(auth, { otelSpans: { email: false } })
|
|
54
|
-
* ```
|
|
55
|
-
*/
|
|
56
|
-
otelSpans?: OtelSpansConfig;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Require that the authenticated user has one of the given org roles.
|
|
60
|
-
* If the user is authenticated but lacks the required role, a 403 is returned.
|
|
61
|
-
* Only applies when authentication succeeds (ignored for optional + anonymous).
|
|
62
|
-
*
|
|
63
|
-
* @example Require admin or owner role
|
|
64
|
-
* ```typescript
|
|
65
|
-
* createSessionMiddleware(auth, { hasOrgRole: ['admin', 'owner'] })
|
|
66
|
-
* ```
|
|
67
|
-
*/
|
|
68
|
-
hasOrgRole?: string | string[];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface ApiKeyMiddlewareOptions {
|
|
72
|
-
/**
|
|
73
|
-
* If true, don't return 401 on missing/invalid API key - just continue without auth context.
|
|
74
|
-
*/
|
|
75
|
-
optional?: boolean;
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Configure which attributes are included in OpenTelemetry spans.
|
|
79
|
-
* All PII attributes are included by default. Use this to opt-out of specific fields.
|
|
80
|
-
*
|
|
81
|
-
* @example Disable email in spans
|
|
82
|
-
* ```typescript
|
|
83
|
-
* createApiKeyMiddleware(auth, { otelSpans: { email: false } })
|
|
84
|
-
* ```
|
|
85
|
-
*/
|
|
86
|
-
otelSpans?: OtelSpansConfig;
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Require that the API key has specific permissions.
|
|
90
|
-
* If the API key lacks any required permission, a 403 is returned.
|
|
91
|
-
*
|
|
92
|
-
* @example Require project write permission
|
|
93
|
-
* ```typescript
|
|
94
|
-
* createApiKeyMiddleware(auth, { hasPermission: { project: 'write' } })
|
|
95
|
-
* ```
|
|
96
|
-
*
|
|
97
|
-
* @example Require multiple permissions
|
|
98
|
-
* ```typescript
|
|
99
|
-
* createApiKeyMiddleware(auth, {
|
|
100
|
-
* hasPermission: { project: ['read', 'write'], admin: '*' }
|
|
101
|
-
* })
|
|
102
|
-
* ```
|
|
103
|
-
*/
|
|
104
|
-
hasPermission?: Record<string, string | string[]>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Hono context variables set by the middleware.
|
|
109
|
-
*/
|
|
110
|
-
export type AuthEnv = {
|
|
111
|
-
Variables: {
|
|
112
|
-
auth: AuthInterface<AuthUser>;
|
|
113
|
-
user: AuthUser | null;
|
|
114
|
-
authSession: AuthSession | null;
|
|
115
|
-
org: AuthOrgContext | null;
|
|
116
|
-
};
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
// =============================================================================
|
|
120
|
-
// Helpers
|
|
121
|
-
// =============================================================================
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Derive minimal org context from session.
|
|
125
|
-
* Full org data is fetched lazily via getOrg().
|
|
126
|
-
*/
|
|
127
|
-
function deriveOrgContext(session: AuthSession | null): AuthOrgContext | null {
|
|
128
|
-
if (!session?.activeOrganizationId) return null;
|
|
129
|
-
return { id: session.activeOrganizationId };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Extract API key from request headers.
|
|
134
|
-
* Checks x-agentuity-auth-api-key header and Authorization: ApiKey header.
|
|
135
|
-
*/
|
|
136
|
-
function getApiKeyFromRequest(c: Context): string | null {
|
|
137
|
-
const customHeader =
|
|
138
|
-
c.req.header('x-agentuity-auth-api-key') ?? c.req.header('X-Agentuity-Auth-Api-Key');
|
|
139
|
-
if (customHeader) return customHeader.trim();
|
|
140
|
-
|
|
141
|
-
const authHeader = c.req.header('Authorization');
|
|
142
|
-
if (!authHeader) return null;
|
|
143
|
-
|
|
144
|
-
const lower = authHeader.toLowerCase();
|
|
145
|
-
if (lower.startsWith('apikey ')) {
|
|
146
|
-
return authHeader.slice('apikey '.length).trim() || null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Determine auth method from request headers.
|
|
154
|
-
*/
|
|
155
|
-
function getAuthMethod(c: Context): AuthMethod {
|
|
156
|
-
const apiKey = getApiKeyFromRequest(c);
|
|
157
|
-
if (apiKey) return 'api-key';
|
|
158
|
-
|
|
159
|
-
const authHeader = c.req.header('Authorization');
|
|
160
|
-
if (authHeader?.toLowerCase().startsWith('bearer ')) return 'bearer';
|
|
161
|
-
|
|
162
|
-
return 'session';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function buildAuthInterface(
|
|
166
|
-
c: Context,
|
|
167
|
-
auth: AuthBase,
|
|
168
|
-
user: AuthUser,
|
|
169
|
-
session: AuthSession | null,
|
|
170
|
-
org: AuthOrgContext | null,
|
|
171
|
-
authMethod: AuthMethod,
|
|
172
|
-
apiKeyContext: AuthApiKeyContext | null
|
|
173
|
-
): AuthInterface<AuthUser> {
|
|
174
|
-
let cachedFullOrg: AuthOrgContext | null | undefined = undefined;
|
|
175
|
-
const permissions = apiKeyContext?.permissions ?? null;
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
async getUser() {
|
|
179
|
-
return user;
|
|
180
|
-
},
|
|
181
|
-
|
|
182
|
-
async getToken() {
|
|
183
|
-
const header = c.req.header('Authorization');
|
|
184
|
-
if (!header) return null;
|
|
185
|
-
return header.replace(/^Bearer\s+/i, '') || null;
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
raw: {
|
|
189
|
-
user,
|
|
190
|
-
session,
|
|
191
|
-
org,
|
|
192
|
-
},
|
|
193
|
-
|
|
194
|
-
org,
|
|
195
|
-
|
|
196
|
-
async getOrg() {
|
|
197
|
-
if (cachedFullOrg !== undefined) {
|
|
198
|
-
return cachedFullOrg;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (!org) {
|
|
202
|
-
cachedFullOrg = null;
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Fetch full org data lazily
|
|
207
|
-
try {
|
|
208
|
-
const fullOrg = await auth.api.getFullOrganization({
|
|
209
|
-
headers: c.req.raw.headers,
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
if (!fullOrg) {
|
|
213
|
-
cachedFullOrg = null;
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Find the current user's role in the org
|
|
218
|
-
const members = (fullOrg.members ?? []) as Array<{
|
|
219
|
-
userId: string;
|
|
220
|
-
role: string;
|
|
221
|
-
id: string;
|
|
222
|
-
}>;
|
|
223
|
-
const currentMember = members.find((m) => m.userId === user.id);
|
|
224
|
-
|
|
225
|
-
cachedFullOrg = {
|
|
226
|
-
id: fullOrg.id,
|
|
227
|
-
slug: fullOrg.slug ?? null,
|
|
228
|
-
name: fullOrg.name ?? null,
|
|
229
|
-
role: currentMember?.role ?? null,
|
|
230
|
-
memberId: currentMember?.id ?? null,
|
|
231
|
-
metadata: (fullOrg as Record<string, unknown>).metadata ?? null,
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
return cachedFullOrg;
|
|
235
|
-
} catch {
|
|
236
|
-
cachedFullOrg = org;
|
|
237
|
-
return org;
|
|
238
|
-
}
|
|
239
|
-
},
|
|
240
|
-
|
|
241
|
-
async getOrgRole() {
|
|
242
|
-
const org = await this.getOrg();
|
|
243
|
-
return org?.role ?? null;
|
|
244
|
-
},
|
|
245
|
-
|
|
246
|
-
async hasOrgRole(...roles: string[]) {
|
|
247
|
-
const role = await this.getOrgRole();
|
|
248
|
-
return !!role && roles.includes(role);
|
|
249
|
-
},
|
|
250
|
-
|
|
251
|
-
// API key helpers
|
|
252
|
-
authMethod,
|
|
253
|
-
apiKey: apiKeyContext,
|
|
254
|
-
|
|
255
|
-
hasPermission(resource: string, ...actions: string[]) {
|
|
256
|
-
if (!permissions) return false;
|
|
257
|
-
const resourcePerms = permissions[resource] ?? [];
|
|
258
|
-
|
|
259
|
-
if (actions.length === 0) {
|
|
260
|
-
return resourcePerms.length > 0;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return actions.every(
|
|
264
|
-
(action) => resourcePerms.includes(action) || resourcePerms.includes('*')
|
|
265
|
-
);
|
|
266
|
-
},
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Build a null/anonymous auth wrapper for optional middleware.
|
|
272
|
-
*/
|
|
273
|
-
function buildAnonymousAuth(): AuthInterface<AuthUser> {
|
|
274
|
-
return {
|
|
275
|
-
async getUser() {
|
|
276
|
-
throw new Error('Not authenticated');
|
|
277
|
-
},
|
|
278
|
-
|
|
279
|
-
async getToken() {
|
|
280
|
-
return null;
|
|
281
|
-
},
|
|
282
|
-
|
|
283
|
-
raw: {
|
|
284
|
-
user: null as unknown as AuthUser,
|
|
285
|
-
session: null as unknown as AuthSession,
|
|
286
|
-
org: null,
|
|
287
|
-
},
|
|
288
|
-
|
|
289
|
-
org: null,
|
|
290
|
-
|
|
291
|
-
async getOrg() {
|
|
292
|
-
return null;
|
|
293
|
-
},
|
|
294
|
-
|
|
295
|
-
async getOrgRole() {
|
|
296
|
-
return null;
|
|
297
|
-
},
|
|
298
|
-
|
|
299
|
-
async hasOrgRole() {
|
|
300
|
-
return false;
|
|
301
|
-
},
|
|
302
|
-
|
|
303
|
-
authMethod: 'session',
|
|
304
|
-
apiKey: null,
|
|
305
|
-
|
|
306
|
-
hasPermission() {
|
|
307
|
-
return false;
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// =============================================================================
|
|
313
|
-
// Session Middleware
|
|
314
|
-
// =============================================================================
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Create Hono middleware that validates sessions.
|
|
318
|
-
*
|
|
319
|
-
* Sets context variables (`user`, `session`, `org`, `auth`) for authenticated requests.
|
|
320
|
-
*
|
|
321
|
-
* OpenTelemetry spans are automatically enriched with auth attributes:
|
|
322
|
-
* - `auth.user.id` - User ID (always included)
|
|
323
|
-
* - `auth.user.email` - User email (included by default, opt-out via `otelSpans.email: false`)
|
|
324
|
-
* - `auth.method` - 'session' or 'bearer' (always included)
|
|
325
|
-
* - `auth.provider` - 'Auth' (always included)
|
|
326
|
-
* - `auth.org.id` - Active organization ID (always included if set)
|
|
327
|
-
* - `auth.org.name` - Organization name (included by default, opt-out via `otelSpans.orgName: false`)
|
|
328
|
-
*
|
|
329
|
-
* @example Basic usage
|
|
330
|
-
* ```typescript
|
|
331
|
-
* import { createSessionMiddleware } from '@agentuity/auth';
|
|
332
|
-
* import { auth } from './auth';
|
|
333
|
-
*
|
|
334
|
-
* const app = new Hono();
|
|
335
|
-
* app.use('/api/*', createSessionMiddleware(auth));
|
|
336
|
-
*
|
|
337
|
-
* app.get('/api/me', (c) => {
|
|
338
|
-
* const user = c.var.user;
|
|
339
|
-
* if (!user) return c.json({ error: 'Unauthorized' }, 401);
|
|
340
|
-
* return c.json({ id: user.id });
|
|
341
|
-
* });
|
|
342
|
-
* ```
|
|
343
|
-
*
|
|
344
|
-
* @example Using auth wrapper with org role check
|
|
345
|
-
* ```typescript
|
|
346
|
-
* app.get('/api/admin', createSessionMiddleware(auth, { hasOrgRole: ['admin', 'owner'] }), async (c) => {
|
|
347
|
-
* const user = await c.var.auth.getUser();
|
|
348
|
-
* return c.json({ id: user.id, message: 'Welcome admin!' });
|
|
349
|
-
* });
|
|
350
|
-
* ```
|
|
351
|
-
*/
|
|
352
|
-
export function createSessionMiddleware(
|
|
353
|
-
auth: AuthBase,
|
|
354
|
-
options: AuthMiddlewareOptions = {}
|
|
355
|
-
): MiddlewareHandler<AuthEnv> {
|
|
356
|
-
const { optional = false, otelSpans = {}, hasOrgRole } = options;
|
|
357
|
-
const includeEmail = otelSpans.email !== false;
|
|
358
|
-
const includeOrgName = otelSpans.orgName !== false;
|
|
359
|
-
|
|
360
|
-
return async (c, next) => {
|
|
361
|
-
const span = trace.getSpan(context.active());
|
|
362
|
-
|
|
363
|
-
try {
|
|
364
|
-
const result = await auth.api.getSession({
|
|
365
|
-
headers: c.req.raw.headers,
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
if (!result) {
|
|
369
|
-
if (optional) {
|
|
370
|
-
c.set('user', null);
|
|
371
|
-
c.set('authSession', null);
|
|
372
|
-
c.set('org', null);
|
|
373
|
-
c.set('auth', buildAnonymousAuth());
|
|
374
|
-
span?.addEvent('auth.anonymous');
|
|
375
|
-
await next();
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
span?.addEvent('auth.unauthorized', { reason: 'no_session' });
|
|
380
|
-
span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Unauthorized' });
|
|
381
|
-
return c.json({ error: 'Unauthorized' }, 401);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Cast to full types - BetterAuth returns complete objects at runtime
|
|
385
|
-
const user = result.user as AuthUser;
|
|
386
|
-
const session = result.session as AuthSession;
|
|
387
|
-
const org = deriveOrgContext(session);
|
|
388
|
-
const authMethod = getAuthMethod(c);
|
|
389
|
-
|
|
390
|
-
c.set('user', user);
|
|
391
|
-
c.set('authSession', session);
|
|
392
|
-
c.set('org', org);
|
|
393
|
-
|
|
394
|
-
if (span) {
|
|
395
|
-
span.setAttributes({
|
|
396
|
-
'auth.user.id': user.id ?? '',
|
|
397
|
-
'auth.method': authMethod,
|
|
398
|
-
'auth.provider': 'AgentuityAuth',
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
if (includeEmail && user.email) {
|
|
402
|
-
span.setAttribute('auth.user.email', user.email);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (org) {
|
|
406
|
-
span.setAttribute('auth.org.id', org.id);
|
|
407
|
-
if (includeOrgName && org.name) {
|
|
408
|
-
span.setAttribute('auth.org.name', org.name);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
c.set('auth', buildAuthInterface(c, auth, user, session, org, authMethod, null));
|
|
414
|
-
|
|
415
|
-
// Enforce org role if requested
|
|
416
|
-
if (hasOrgRole) {
|
|
417
|
-
const requiredRoles = Array.isArray(hasOrgRole) ? hasOrgRole : [hasOrgRole];
|
|
418
|
-
const hasRole = await c.var.auth.hasOrgRole(...requiredRoles);
|
|
419
|
-
|
|
420
|
-
if (!hasRole) {
|
|
421
|
-
span?.addEvent('auth.forbidden', {
|
|
422
|
-
reason: 'missing_org_role',
|
|
423
|
-
required_roles: requiredRoles,
|
|
424
|
-
});
|
|
425
|
-
span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Forbidden' });
|
|
426
|
-
return c.json({ error: 'Forbidden: insufficient organization role' }, 403);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
await next();
|
|
431
|
-
} catch (error) {
|
|
432
|
-
console.error('[Agentuity Auth] Session validation failed:', error);
|
|
433
|
-
|
|
434
|
-
span?.recordException(error as Error);
|
|
435
|
-
span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Auth validation failed' });
|
|
436
|
-
|
|
437
|
-
if (optional) {
|
|
438
|
-
c.set('user', null);
|
|
439
|
-
c.set('authSession', null);
|
|
440
|
-
c.set('org', null);
|
|
441
|
-
c.set('auth', buildAnonymousAuth());
|
|
442
|
-
await next();
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
return c.json({ error: 'Unauthorized' }, 401);
|
|
446
|
-
}
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// =============================================================================
|
|
451
|
-
// API Key Middleware
|
|
452
|
-
// =============================================================================
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Create Hono middleware that validates API keys.
|
|
456
|
-
*
|
|
457
|
-
* This middleware ONLY accepts API key authentication via:
|
|
458
|
-
* - `x-agentuity-auth-api-key` header (preferred)
|
|
459
|
-
* - `Authorization: ApiKey <key>` header
|
|
460
|
-
*
|
|
461
|
-
* It does NOT use sessions. For routes that accept both session and API key,
|
|
462
|
-
* compose with createSessionMiddleware using `{ optional: true }`.
|
|
463
|
-
*
|
|
464
|
-
* @example API key only route with permission check
|
|
465
|
-
* ```typescript
|
|
466
|
-
* import { createApiKeyMiddleware } from '@agentuity/auth';
|
|
467
|
-
*
|
|
468
|
-
* app.post('/webhooks/*', createApiKeyMiddleware(auth, {
|
|
469
|
-
* hasPermission: { webhook: 'write' }
|
|
470
|
-
* }));
|
|
471
|
-
*
|
|
472
|
-
* app.post('/webhooks/github', async (c) => {
|
|
473
|
-
* // Permission already verified by middleware
|
|
474
|
-
* return c.json({ success: true });
|
|
475
|
-
* });
|
|
476
|
-
* ```
|
|
477
|
-
*
|
|
478
|
-
* @example Either session OR API key (compose with optional)
|
|
479
|
-
* ```typescript
|
|
480
|
-
* app.use('/api/*', createSessionMiddleware(auth, { optional: true }));
|
|
481
|
-
* app.use('/api/*', createApiKeyMiddleware(auth, { optional: true }));
|
|
482
|
-
*
|
|
483
|
-
* app.get('/api/data', async (c) => {
|
|
484
|
-
* // Works with session OR API key
|
|
485
|
-
* if (!c.var.user) return c.json({ error: 'Unauthorized' }, 401);
|
|
486
|
-
* return c.json({ data: '...' });
|
|
487
|
-
* });
|
|
488
|
-
* ```
|
|
489
|
-
*/
|
|
490
|
-
export function createApiKeyMiddleware(
|
|
491
|
-
auth: AuthBase,
|
|
492
|
-
options: ApiKeyMiddlewareOptions = {}
|
|
493
|
-
): MiddlewareHandler<AuthEnv> {
|
|
494
|
-
const { optional = false, otelSpans = {}, hasPermission } = options;
|
|
495
|
-
const includeEmail = otelSpans.email !== false;
|
|
496
|
-
|
|
497
|
-
return async (c, next) => {
|
|
498
|
-
const span = trace.getSpan(context.active());
|
|
499
|
-
const apiKeyToken = getApiKeyFromRequest(c);
|
|
500
|
-
|
|
501
|
-
if (!apiKeyToken) {
|
|
502
|
-
if (optional) {
|
|
503
|
-
await next();
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
span?.addEvent('auth.unauthorized', { reason: 'no_api_key' });
|
|
508
|
-
span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Unauthorized' });
|
|
509
|
-
return c.json({ error: 'Unauthorized: API key required' }, 401);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
try {
|
|
513
|
-
const result = await auth.api.verifyApiKey({
|
|
514
|
-
body: { key: apiKeyToken },
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
if (!result.valid || !result.key) {
|
|
518
|
-
if (optional) {
|
|
519
|
-
await next();
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
span?.addEvent('auth.unauthorized', { reason: 'invalid_api_key' });
|
|
524
|
-
span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Unauthorized' });
|
|
525
|
-
return c.json({ error: 'Unauthorized: Invalid API key' }, 401);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const keyData = result.key;
|
|
529
|
-
const userId = keyData.userId;
|
|
530
|
-
|
|
531
|
-
const apiKeyContext: AuthApiKeyContext = {
|
|
532
|
-
id: keyData.id,
|
|
533
|
-
name: keyData.name ?? null,
|
|
534
|
-
permissions: keyData.permissions ?? {},
|
|
535
|
-
userId: userId ?? null,
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
let user: AuthUser | null = null;
|
|
539
|
-
if (userId) {
|
|
540
|
-
try {
|
|
541
|
-
// NOTE: BetterAuth's API key plugin supports resolving a user by ID via
|
|
542
|
-
// `auth.api.getSession({ headers: { 'x-user-id': ... } })` without cookies.
|
|
543
|
-
// The API key itself is the primary authenticator; this call is only
|
|
544
|
-
// used to hydrate the user object for convenience (and is allowed to fail).
|
|
545
|
-
const sessionResult = await auth.api.getSession({
|
|
546
|
-
headers: new Headers({ 'x-user-id': userId }),
|
|
547
|
-
});
|
|
548
|
-
if (sessionResult?.user) {
|
|
549
|
-
// Cast to full type - BetterAuth returns complete objects at runtime
|
|
550
|
-
user = sessionResult.user as AuthUser;
|
|
551
|
-
}
|
|
552
|
-
} catch {
|
|
553
|
-
// User fetch failed, continue with null user
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
c.set('user', user);
|
|
558
|
-
c.set('authSession', null);
|
|
559
|
-
c.set('org', null);
|
|
560
|
-
|
|
561
|
-
if (span) {
|
|
562
|
-
span.setAttributes({
|
|
563
|
-
'auth.method': 'api-key',
|
|
564
|
-
'auth.provider': 'AgentuityAuth',
|
|
565
|
-
'auth.api_key.id': apiKeyContext.id,
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
if (user?.id) {
|
|
569
|
-
span.setAttribute('auth.user.id', user.id);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
if (includeEmail && user?.email) {
|
|
573
|
-
span.setAttribute('auth.user.email', user.email);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (user) {
|
|
578
|
-
c.set('auth', buildAuthInterface(c, auth, user, null, null, 'api-key', apiKeyContext));
|
|
579
|
-
} else {
|
|
580
|
-
const anonAuth = buildAnonymousAuth();
|
|
581
|
-
c.set('auth', {
|
|
582
|
-
...anonAuth,
|
|
583
|
-
authMethod: 'api-key',
|
|
584
|
-
apiKey: apiKeyContext,
|
|
585
|
-
hasPermission(resource: string, ...actions: string[]) {
|
|
586
|
-
const perms = apiKeyContext.permissions[resource] ?? [];
|
|
587
|
-
if (actions.length === 0) return perms.length > 0;
|
|
588
|
-
return actions.every((a) => perms.includes(a) || perms.includes('*'));
|
|
589
|
-
},
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// Enforce permissions if requested
|
|
594
|
-
if (hasPermission) {
|
|
595
|
-
for (const [resource, actions] of Object.entries(hasPermission)) {
|
|
596
|
-
const actionList = Array.isArray(actions) ? actions : [actions];
|
|
597
|
-
const hasPerm = c.var.auth.hasPermission(resource, ...actionList);
|
|
598
|
-
|
|
599
|
-
if (!hasPerm) {
|
|
600
|
-
span?.addEvent('auth.forbidden', {
|
|
601
|
-
reason: 'missing_permission',
|
|
602
|
-
resource,
|
|
603
|
-
actions: actionList,
|
|
604
|
-
});
|
|
605
|
-
span?.setStatus({ code: SpanStatusCode.ERROR, message: 'Forbidden' });
|
|
606
|
-
return c.json({ error: 'Forbidden: insufficient API key permissions' }, 403);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
await next();
|
|
612
|
-
} catch (error) {
|
|
613
|
-
console.error('[Agentuity Auth] API key validation failed:', error);
|
|
614
|
-
|
|
615
|
-
span?.recordException(error as Error);
|
|
616
|
-
span?.setStatus({ code: SpanStatusCode.ERROR, message: 'API key validation failed' });
|
|
617
|
-
|
|
618
|
-
if (optional) {
|
|
619
|
-
await next();
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
return c.json({ error: 'Unauthorized: API key validation failed' }, 401);
|
|
623
|
-
}
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// =============================================================================
|
|
628
|
-
// Auth Handler
|
|
629
|
-
// =============================================================================
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Default headers to forward from BetterAuth responses.
|
|
633
|
-
* This prevents unintended headers from leaking through while preserving auth-essential ones.
|
|
634
|
-
*/
|
|
635
|
-
const DEFAULT_FORWARDED_HEADERS = [
|
|
636
|
-
'set-cookie',
|
|
637
|
-
'content-type',
|
|
638
|
-
'location',
|
|
639
|
-
'cache-control',
|
|
640
|
-
'pragma',
|
|
641
|
-
'expires',
|
|
642
|
-
'vary',
|
|
643
|
-
'etag',
|
|
644
|
-
'last-modified',
|
|
645
|
-
];
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Configuration options for mounting auth routes.
|
|
649
|
-
*/
|
|
650
|
-
export interface MountAuthRoutesOptions {
|
|
651
|
-
/**
|
|
652
|
-
* Headers to forward from auth responses to the client.
|
|
653
|
-
* Only headers in this list will be forwarded (case-insensitive).
|
|
654
|
-
* `set-cookie` is always forwarded with append behavior regardless of this setting.
|
|
655
|
-
*
|
|
656
|
-
* @default ['set-cookie', 'content-type', 'location', 'cache-control', 'pragma', 'expires', 'vary', 'etag', 'last-modified']
|
|
657
|
-
*/
|
|
658
|
-
allowList?: string[];
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/**
|
|
662
|
-
* Mount auth routes with proper cookie handling and header filtering.
|
|
663
|
-
*
|
|
664
|
-
* This wrapper handles cookie merging between auth responses and other middleware.
|
|
665
|
-
* It ensures both session cookies AND other cookies (like thread cookies)
|
|
666
|
-
* are preserved while preventing unintended headers from leaking through.
|
|
667
|
-
*
|
|
668
|
-
* @example Basic usage
|
|
669
|
-
* ```typescript
|
|
670
|
-
* import { mountAuthRoutes } from '@agentuity/auth';
|
|
671
|
-
* import { auth } from './auth';
|
|
672
|
-
*
|
|
673
|
-
* const api = createRouter();
|
|
674
|
-
*
|
|
675
|
-
* // Mount all auth routes (sign-in, sign-up, sign-out, session, etc.)
|
|
676
|
-
* api.on(['GET', 'POST'], '/api/auth/*', mountAuthRoutes(auth));
|
|
677
|
-
* ```
|
|
678
|
-
*
|
|
679
|
-
* @example With custom header allowlist
|
|
680
|
-
* ```typescript
|
|
681
|
-
* api.on(['GET', 'POST'], '/api/auth/*', mountAuthRoutes(auth, {
|
|
682
|
-
* allowList: ['set-cookie', 'content-type', 'location', 'x-custom-header']
|
|
683
|
-
* }));
|
|
684
|
-
* ```
|
|
685
|
-
*/
|
|
686
|
-
export function mountAuthRoutes(
|
|
687
|
-
auth: AuthBase,
|
|
688
|
-
options: MountAuthRoutesOptions = {}
|
|
689
|
-
): (c: Context) => Promise<Response> {
|
|
690
|
-
const allowList = new Set(
|
|
691
|
-
(options.allowList ?? DEFAULT_FORWARDED_HEADERS).map((h) => h.toLowerCase())
|
|
692
|
-
);
|
|
693
|
-
// Always ensure set-cookie is in the allowlist
|
|
694
|
-
allowList.add('set-cookie');
|
|
695
|
-
|
|
696
|
-
return async (c: Context): Promise<Response> => {
|
|
697
|
-
const response = await auth.handler(c.req.raw);
|
|
698
|
-
|
|
699
|
-
response.headers.forEach((value: string, key: string) => {
|
|
700
|
-
const lower = key.toLowerCase();
|
|
701
|
-
|
|
702
|
-
// Only forward headers in the allowlist
|
|
703
|
-
if (!allowList.has(lower)) {
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
if (lower === 'set-cookie') {
|
|
708
|
-
// Preserve existing cookies (e.g., Agentuity thread cookies)
|
|
709
|
-
c.header('set-cookie', value, { append: true });
|
|
710
|
-
} else {
|
|
711
|
-
c.header(key, value);
|
|
712
|
-
}
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
const body = await response.text();
|
|
716
|
-
return new Response(body, {
|
|
717
|
-
status: response.status,
|
|
718
|
-
headers: c.res.headers,
|
|
719
|
-
});
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// =============================================================================
|
|
724
|
-
// Type Augmentation
|
|
725
|
-
// =============================================================================
|
|
726
|
-
|
|
727
|
-
declare module 'hono' {
|
|
728
|
-
interface ContextVariableMap {
|
|
729
|
-
auth: AuthInterface<AuthUser>;
|
|
730
|
-
user: AuthUser | null;
|
|
731
|
-
authSession: AuthSession | null;
|
|
732
|
-
org: AuthOrgContext | null;
|
|
733
|
-
}
|
|
734
|
-
}
|