@agentuity/auth 0.1.8 → 0.1.10

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.
@@ -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
- }