@donkeylabs/cli 1.1.14 → 1.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/cli",
3
- "version": "1.1.14",
3
+ "version": "1.1.15",
4
4
  "type": "module",
5
5
  "description": "CLI for @donkeylabs/server - project scaffolding and code generation",
6
6
  "main": "./src/index.ts",
@@ -24,8 +24,38 @@ export const server = new AppServer({
24
24
  },
25
25
  });
26
26
 
27
- // Register plugins
28
- server.registerPlugin(authPlugin); // Auth first - other plugins may depend on it
27
+ // =============================================================================
28
+ // AUTH PLUGIN CONFIGURATION
29
+ // =============================================================================
30
+ // Choose your auth strategy:
31
+ //
32
+ // 1. SESSION (default) - Stateful, stores sessions in database
33
+ // Best for: Web apps, server-rendered pages
34
+ // server.registerPlugin(authPlugin());
35
+ //
36
+ // 2. JWT - Stateless tokens, no database lookup needed
37
+ // Best for: Mobile apps, microservices, APIs
38
+ // server.registerPlugin(authPlugin({
39
+ // strategy: "jwt",
40
+ // jwt: { secret: process.env.JWT_SECRET! },
41
+ // }));
42
+ //
43
+ // 3. REFRESH-TOKEN - Short-lived access + long-lived refresh token
44
+ // Best for: SPAs, mobile apps needing token refresh
45
+ // server.registerPlugin(authPlugin({
46
+ // strategy: "refresh-token",
47
+ // jwt: {
48
+ // secret: process.env.JWT_SECRET!,
49
+ // accessExpiry: "15m",
50
+ // refreshExpiry: "30d",
51
+ // },
52
+ // cookie: { httpOnly: true, secure: true },
53
+ // }));
54
+ //
55
+ // =============================================================================
56
+
57
+ // Using default session strategy for this template
58
+ server.registerPlugin(authPlugin());
29
59
  server.registerPlugin(demoPlugin);
30
60
  server.registerPlugin(workflowDemoPlugin);
31
61
 
@@ -1,16 +1,92 @@
1
1
  /**
2
- * Auth Plugin - User authentication with sessions
2
+ * Auth Plugin - Configurable authentication with multiple strategies
3
3
  *
4
- * Provides:
5
- * - User registration and login
6
- * - Password hashing with bcrypt
7
- * - Session-based authentication
8
- * - Auth middleware for protected routes
4
+ * Strategies:
5
+ * - session: Stateful, database sessions (default)
6
+ * - jwt: Stateless JWT tokens
7
+ * - refresh-token: Access token + refresh token pattern
8
+ *
9
+ * Storage:
10
+ * - cookie: HTTP-only cookies (recommended for web)
11
+ * - header: Authorization header (for APIs/mobile)
12
+ * - both: Support both methods
9
13
  */
10
14
 
11
15
  import { createPlugin, createMiddleware } from "@donkeylabs/server";
12
16
  import type { ColumnType } from "kysely";
13
17
 
18
+ // =============================================================================
19
+ // CONFIGURATION TYPES
20
+ // =============================================================================
21
+
22
+ export type AuthStrategy = "session" | "jwt" | "refresh-token";
23
+ export type TokenStorage = "cookie" | "header" | "both";
24
+
25
+ export interface AuthConfig {
26
+ /**
27
+ * Authentication strategy
28
+ * - session: Stateful, stores sessions in database
29
+ * - jwt: Stateless, token contains user info
30
+ * - refresh-token: Short-lived access + long-lived refresh token
31
+ * @default "session"
32
+ */
33
+ strategy?: AuthStrategy;
34
+
35
+ /**
36
+ * How tokens/sessions are transmitted
37
+ * - cookie: HTTP-only cookies (secure for web apps)
38
+ * - header: Authorization header (for APIs/mobile)
39
+ * - both: Accept both methods
40
+ * @default "both"
41
+ */
42
+ storage?: TokenStorage;
43
+
44
+ /**
45
+ * Cookie configuration (when storage includes cookies)
46
+ */
47
+ cookie?: {
48
+ /** Cookie name prefix @default "auth" */
49
+ name?: string;
50
+ /** HTTP-only flag @default true */
51
+ httpOnly?: boolean;
52
+ /** Secure flag (HTTPS only) @default true in production */
53
+ secure?: boolean;
54
+ /** SameSite policy @default "lax" */
55
+ sameSite?: "strict" | "lax" | "none";
56
+ /** Cookie path @default "/" */
57
+ path?: string;
58
+ /** Cookie domain (optional) */
59
+ domain?: string;
60
+ };
61
+
62
+ /**
63
+ * JWT configuration (for jwt and refresh-token strategies)
64
+ */
65
+ jwt?: {
66
+ /** Secret key for signing tokens (required for jwt/refresh-token) */
67
+ secret: string;
68
+ /** Access token expiry @default "15m" for refresh-token, "7d" for jwt */
69
+ accessExpiry?: string;
70
+ /** Refresh token expiry @default "30d" (refresh-token strategy only) */
71
+ refreshExpiry?: string;
72
+ /** Token issuer (optional) */
73
+ issuer?: string;
74
+ };
75
+
76
+ /**
77
+ * Session configuration (for session strategy)
78
+ */
79
+ session?: {
80
+ /** Session duration @default "7d" */
81
+ expiry?: string;
82
+ };
83
+
84
+ /**
85
+ * Password hashing cost @default 10
86
+ */
87
+ bcryptCost?: number;
88
+ }
89
+
14
90
  // =============================================================================
15
91
  // DATABASE SCHEMA TYPES
16
92
  // =============================================================================
@@ -31,13 +107,22 @@ interface SessionsTable {
31
107
  created_at: ColumnType<string, string | undefined, never>;
32
108
  }
33
109
 
110
+ interface RefreshTokensTable {
111
+ id: string;
112
+ user_id: string;
113
+ token_hash: string;
114
+ expires_at: string;
115
+ created_at: ColumnType<string, string | undefined, never>;
116
+ }
117
+
34
118
  interface AuthSchema {
35
119
  users: UsersTable;
36
120
  sessions: SessionsTable;
121
+ refresh_tokens: RefreshTokensTable;
37
122
  }
38
123
 
39
124
  // =============================================================================
40
- // TYPES
125
+ // EXPORTED TYPES
41
126
  // =============================================================================
42
127
 
43
128
  export interface AuthUser {
@@ -46,10 +131,117 @@ export interface AuthUser {
46
131
  name: string | null;
47
132
  }
48
133
 
49
- export interface Session {
50
- id: string;
51
- userId: string;
52
- expiresAt: Date;
134
+ export interface AuthTokens {
135
+ accessToken: string;
136
+ refreshToken?: string;
137
+ expiresIn: number;
138
+ }
139
+
140
+ export interface AuthResult {
141
+ user: AuthUser;
142
+ tokens: AuthTokens;
143
+ }
144
+
145
+ // =============================================================================
146
+ // HELPERS
147
+ // =============================================================================
148
+
149
+ function parseExpiry(expiry: string): number {
150
+ const match = expiry.match(/^(\d+)(s|m|h|d)$/);
151
+ if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days
152
+
153
+ const value = parseInt(match[1]);
154
+ const unit = match[2];
155
+
156
+ switch (unit) {
157
+ case "s": return value * 1000;
158
+ case "m": return value * 60 * 1000;
159
+ case "h": return value * 60 * 60 * 1000;
160
+ case "d": return value * 24 * 60 * 60 * 1000;
161
+ default: return 7 * 24 * 60 * 60 * 1000;
162
+ }
163
+ }
164
+
165
+ interface JWTPayload {
166
+ sub: string;
167
+ email: string;
168
+ name: string | null;
169
+ iat: number;
170
+ exp: number;
171
+ iss?: string;
172
+ type?: "access" | "refresh";
173
+ }
174
+
175
+ function base64UrlEncode(data: string): string {
176
+ return btoa(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
177
+ }
178
+
179
+ function base64UrlDecode(data: string): string {
180
+ const padded = data + "=".repeat((4 - (data.length % 4)) % 4);
181
+ return atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
182
+ }
183
+
184
+ async function signJWT(payload: JWTPayload, secret: string): Promise<string> {
185
+ const header = { alg: "HS256", typ: "JWT" };
186
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
187
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
188
+
189
+ const data = `${encodedHeader}.${encodedPayload}`;
190
+ const encoder = new TextEncoder();
191
+ const key = await crypto.subtle.importKey(
192
+ "raw",
193
+ encoder.encode(secret),
194
+ { name: "HMAC", hash: "SHA-256" },
195
+ false,
196
+ ["sign"]
197
+ );
198
+
199
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
200
+ const encodedSignature = base64UrlEncode(
201
+ String.fromCharCode(...new Uint8Array(signature))
202
+ );
203
+
204
+ return `${data}.${encodedSignature}`;
205
+ }
206
+
207
+ async function verifyJWT(token: string, secret: string): Promise<JWTPayload | null> {
208
+ try {
209
+ const [encodedHeader, encodedPayload, encodedSignature] = token.split(".");
210
+ if (!encodedHeader || !encodedPayload || !encodedSignature) return null;
211
+
212
+ const data = `${encodedHeader}.${encodedPayload}`;
213
+ const encoder = new TextEncoder();
214
+ const key = await crypto.subtle.importKey(
215
+ "raw",
216
+ encoder.encode(secret),
217
+ { name: "HMAC", hash: "SHA-256" },
218
+ false,
219
+ ["verify"]
220
+ );
221
+
222
+ const signature = Uint8Array.from(
223
+ base64UrlDecode(encodedSignature),
224
+ (c) => c.charCodeAt(0)
225
+ );
226
+
227
+ const valid = await crypto.subtle.verify(
228
+ "HMAC",
229
+ key,
230
+ signature,
231
+ encoder.encode(data)
232
+ );
233
+
234
+ if (!valid) return null;
235
+
236
+ const payload: JWTPayload = JSON.parse(base64UrlDecode(encodedPayload));
237
+
238
+ // Check expiry
239
+ if (payload.exp * 1000 < Date.now()) return null;
240
+
241
+ return payload;
242
+ } catch {
243
+ return null;
244
+ }
53
245
  }
54
246
 
55
247
  // =============================================================================
@@ -58,10 +250,10 @@ export interface Session {
58
250
 
59
251
  export const authPlugin = createPlugin
60
252
  .withSchema<AuthSchema>()
253
+ .withConfig<AuthConfig>()
61
254
  .define({
62
255
  name: "auth",
63
256
 
64
- // Custom errors for auth failures
65
257
  customErrors: {
66
258
  InvalidCredentials: {
67
259
  status: 401,
@@ -73,17 +265,199 @@ export const authPlugin = createPlugin
73
265
  code: "EMAIL_EXISTS",
74
266
  message: "An account with this email already exists",
75
267
  },
76
- SessionExpired: {
268
+ InvalidToken: {
77
269
  status: 401,
78
- code: "SESSION_EXPIRED",
79
- message: "Your session has expired. Please log in again.",
270
+ code: "INVALID_TOKEN",
271
+ message: "Invalid or expired token",
272
+ },
273
+ RefreshTokenExpired: {
274
+ status: 401,
275
+ code: "REFRESH_TOKEN_EXPIRED",
276
+ message: "Refresh token has expired. Please log in again.",
80
277
  },
81
278
  },
82
279
 
83
280
  service: async (ctx) => {
84
- const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
281
+ const config = ctx.config || {};
282
+ const strategy = config.strategy || "session";
283
+ const storage = config.storage || "both";
284
+ const bcryptCost = config.bcryptCost || 10;
285
+
286
+ // Expiry defaults based on strategy
287
+ const accessExpiryMs = parseExpiry(
288
+ config.jwt?.accessExpiry ||
289
+ (strategy === "refresh-token" ? "15m" : "7d")
290
+ );
291
+ const refreshExpiryMs = parseExpiry(config.jwt?.refreshExpiry || "30d");
292
+ const sessionExpiryMs = parseExpiry(config.session?.expiry || "7d");
293
+
294
+ const jwtSecret = config.jwt?.secret || "";
295
+
296
+ // Validate config
297
+ if ((strategy === "jwt" || strategy === "refresh-token") && !jwtSecret) {
298
+ throw new Error("Auth plugin: jwt.secret is required for jwt/refresh-token strategy");
299
+ }
300
+
301
+ /**
302
+ * Create tokens based on strategy
303
+ */
304
+ async function createTokens(user: AuthUser): Promise<AuthTokens> {
305
+ const now = Math.floor(Date.now() / 1000);
306
+
307
+ if (strategy === "jwt") {
308
+ const payload: JWTPayload = {
309
+ sub: user.id,
310
+ email: user.email,
311
+ name: user.name,
312
+ iat: now,
313
+ exp: now + Math.floor(accessExpiryMs / 1000),
314
+ iss: config.jwt?.issuer,
315
+ };
316
+
317
+ const accessToken = await signJWT(payload, jwtSecret);
318
+ return {
319
+ accessToken,
320
+ expiresIn: Math.floor(accessExpiryMs / 1000),
321
+ };
322
+ }
323
+
324
+ if (strategy === "refresh-token") {
325
+ // Access token (short-lived)
326
+ const accessPayload: JWTPayload = {
327
+ sub: user.id,
328
+ email: user.email,
329
+ name: user.name,
330
+ iat: now,
331
+ exp: now + Math.floor(accessExpiryMs / 1000),
332
+ iss: config.jwt?.issuer,
333
+ type: "access",
334
+ };
335
+
336
+ // Refresh token (long-lived, stored in DB)
337
+ const refreshTokenId = crypto.randomUUID();
338
+ const refreshPayload: JWTPayload = {
339
+ sub: user.id,
340
+ email: user.email,
341
+ name: user.name,
342
+ iat: now,
343
+ exp: now + Math.floor(refreshExpiryMs / 1000),
344
+ iss: config.jwt?.issuer,
345
+ type: "refresh",
346
+ };
347
+
348
+ const accessToken = await signJWT(accessPayload, jwtSecret);
349
+ const refreshToken = await signJWT(refreshPayload, jwtSecret);
350
+
351
+ // Hash and store refresh token
352
+ const tokenHash = await Bun.password.hash(refreshToken, {
353
+ algorithm: "bcrypt",
354
+ cost: 4, // Lower cost for refresh tokens (checked less often)
355
+ });
356
+
357
+ await ctx.db
358
+ .insertInto("refresh_tokens")
359
+ .values({
360
+ id: refreshTokenId,
361
+ user_id: user.id,
362
+ token_hash: tokenHash,
363
+ expires_at: new Date(Date.now() + refreshExpiryMs).toISOString(),
364
+ created_at: new Date().toISOString(),
365
+ })
366
+ .execute();
367
+
368
+ return {
369
+ accessToken,
370
+ refreshToken,
371
+ expiresIn: Math.floor(accessExpiryMs / 1000),
372
+ };
373
+ }
374
+
375
+ // Session strategy - create DB session
376
+ const sessionId = crypto.randomUUID();
377
+ const expiresAt = new Date(Date.now() + sessionExpiryMs);
378
+
379
+ await ctx.db
380
+ .insertInto("sessions")
381
+ .values({
382
+ id: sessionId,
383
+ user_id: user.id,
384
+ expires_at: expiresAt.toISOString(),
385
+ created_at: new Date().toISOString(),
386
+ })
387
+ .execute();
388
+
389
+ return {
390
+ accessToken: sessionId,
391
+ expiresIn: Math.floor(sessionExpiryMs / 1000),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Validate token and return user
397
+ */
398
+ async function validateToken(token: string): Promise<AuthUser | null> {
399
+ if (strategy === "jwt" || strategy === "refresh-token") {
400
+ const payload = await verifyJWT(token, jwtSecret);
401
+ if (!payload) return null;
402
+
403
+ // For refresh-token strategy, only accept access tokens in middleware
404
+ if (strategy === "refresh-token" && payload.type !== "access") {
405
+ return null;
406
+ }
407
+
408
+ return {
409
+ id: payload.sub,
410
+ email: payload.email,
411
+ name: payload.name,
412
+ };
413
+ }
414
+
415
+ // Session strategy - lookup in DB
416
+ const session = await ctx.db
417
+ .selectFrom("sessions")
418
+ .where("id", "=", token)
419
+ .selectAll()
420
+ .executeTakeFirst();
421
+
422
+ if (!session) return null;
423
+
424
+ if (new Date(session.expires_at) < new Date()) {
425
+ await ctx.db.deleteFrom("sessions").where("id", "=", token).execute();
426
+ return null;
427
+ }
428
+
429
+ const user = await ctx.db
430
+ .selectFrom("users")
431
+ .where("id", "=", session.user_id)
432
+ .selectAll()
433
+ .executeTakeFirst();
434
+
435
+ if (!user) return null;
436
+
437
+ return {
438
+ id: user.id,
439
+ email: user.email,
440
+ name: user.name,
441
+ };
442
+ }
85
443
 
86
444
  return {
445
+ /** Get current strategy */
446
+ getStrategy: () => strategy,
447
+
448
+ /** Get storage type */
449
+ getStorage: () => storage,
450
+
451
+ /** Get cookie config */
452
+ getCookieConfig: () => ({
453
+ name: config.cookie?.name || "auth",
454
+ httpOnly: config.cookie?.httpOnly ?? true,
455
+ secure: config.cookie?.secure ?? process.env.NODE_ENV === "production",
456
+ sameSite: config.cookie?.sameSite || "lax",
457
+ path: config.cookie?.path || "/",
458
+ domain: config.cookie?.domain,
459
+ }),
460
+
87
461
  /**
88
462
  * Register a new user
89
463
  */
@@ -91,10 +465,9 @@ export const authPlugin = createPlugin
91
465
  email: string;
92
466
  password: string;
93
467
  name?: string;
94
- }): Promise<{ user: AuthUser; sessionId: string }> => {
468
+ }): Promise<AuthResult> => {
95
469
  const { email, password, name } = data;
96
470
 
97
- // Check if email already exists
98
471
  const existing = await ctx.db
99
472
  .selectFrom("users")
100
473
  .where("email", "=", email.toLowerCase())
@@ -105,16 +478,14 @@ export const authPlugin = createPlugin
105
478
  throw ctx.errors.EmailAlreadyExists();
106
479
  }
107
480
 
108
- // Hash password
109
481
  const passwordHash = await Bun.password.hash(password, {
110
482
  algorithm: "bcrypt",
111
- cost: 10,
483
+ cost: bcryptCost,
112
484
  });
113
485
 
114
486
  const userId = crypto.randomUUID();
115
487
  const now = new Date().toISOString();
116
488
 
117
- // Create user
118
489
  await ctx.db
119
490
  .insertInto("users")
120
491
  .values({
@@ -127,26 +498,17 @@ export const authPlugin = createPlugin
127
498
  })
128
499
  .execute();
129
500
 
130
- // Create session
131
- const sessionId = crypto.randomUUID();
132
- const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
501
+ const user: AuthUser = {
502
+ id: userId,
503
+ email: email.toLowerCase(),
504
+ name: name || null,
505
+ };
133
506
 
134
- await ctx.db
135
- .insertInto("sessions")
136
- .values({
137
- id: sessionId,
138
- user_id: userId,
139
- expires_at: expiresAt.toISOString(),
140
- created_at: now,
141
- })
142
- .execute();
507
+ const tokens = await createTokens(user);
143
508
 
144
- ctx.core.logger.info("User registered", { userId, email });
509
+ ctx.core.logger.info("User registered", { userId, email, strategy });
145
510
 
146
- return {
147
- user: { id: userId, email: email.toLowerCase(), name: name || null },
148
- sessionId,
149
- };
511
+ return { user, tokens };
150
512
  },
151
513
 
152
514
  /**
@@ -155,100 +517,137 @@ export const authPlugin = createPlugin
155
517
  login: async (data: {
156
518
  email: string;
157
519
  password: string;
158
- }): Promise<{ user: AuthUser; sessionId: string }> => {
520
+ }): Promise<AuthResult> => {
159
521
  const { email, password } = data;
160
522
 
161
- // Find user
162
- const user = await ctx.db
523
+ const dbUser = await ctx.db
163
524
  .selectFrom("users")
164
525
  .where("email", "=", email.toLowerCase())
165
526
  .selectAll()
166
527
  .executeTakeFirst();
167
528
 
168
- if (!user) {
529
+ if (!dbUser) {
169
530
  throw ctx.errors.InvalidCredentials();
170
531
  }
171
532
 
172
- // Verify password
173
- const valid = await Bun.password.verify(password, user.password_hash);
533
+ const valid = await Bun.password.verify(password, dbUser.password_hash);
174
534
  if (!valid) {
175
535
  throw ctx.errors.InvalidCredentials();
176
536
  }
177
537
 
178
- // Create session
179
- const sessionId = crypto.randomUUID();
180
- const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
538
+ const user: AuthUser = {
539
+ id: dbUser.id,
540
+ email: dbUser.email,
541
+ name: dbUser.name,
542
+ };
181
543
 
182
- await ctx.db
183
- .insertInto("sessions")
184
- .values({
185
- id: sessionId,
186
- user_id: user.id,
187
- expires_at: expiresAt.toISOString(),
188
- created_at: new Date().toISOString(),
189
- })
190
- .execute();
544
+ const tokens = await createTokens(user);
191
545
 
192
- ctx.core.logger.info("User logged in", { userId: user.id });
546
+ ctx.core.logger.info("User logged in", { userId: user.id, strategy });
193
547
 
194
- return {
195
- user: { id: user.id, email: user.email, name: user.name },
196
- sessionId,
197
- };
548
+ return { user, tokens };
198
549
  },
199
550
 
200
551
  /**
201
- * Logout - invalidate session
552
+ * Refresh access token (refresh-token strategy only)
202
553
  */
203
- logout: async (sessionId: string): Promise<void> => {
204
- await ctx.db
205
- .deleteFrom("sessions")
206
- .where("id", "=", sessionId)
207
- .execute();
208
- },
554
+ refresh: async (refreshToken: string): Promise<AuthTokens> => {
555
+ if (strategy !== "refresh-token") {
556
+ throw new Error("refresh() only available with refresh-token strategy");
557
+ }
209
558
 
210
- /**
211
- * Validate session and get user
212
- */
213
- validateSession: async (sessionId: string): Promise<AuthUser | null> => {
214
- const session = await ctx.db
215
- .selectFrom("sessions")
216
- .where("id", "=", sessionId)
559
+ const payload = await verifyJWT(refreshToken, jwtSecret);
560
+ if (!payload || payload.type !== "refresh") {
561
+ throw ctx.errors.InvalidToken();
562
+ }
563
+
564
+ // Verify refresh token exists in DB (not revoked)
565
+ const stored = await ctx.db
566
+ .selectFrom("refresh_tokens")
567
+ .where("user_id", "=", payload.sub)
217
568
  .selectAll()
218
- .executeTakeFirst();
569
+ .execute();
219
570
 
220
- if (!session) {
221
- return null;
571
+ let validToken = false;
572
+ for (const t of stored) {
573
+ if (await Bun.password.verify(refreshToken, t.token_hash)) {
574
+ validToken = true;
575
+ break;
576
+ }
222
577
  }
223
578
 
224
- // Check if expired
225
- if (new Date(session.expires_at) < new Date()) {
226
- // Clean up expired session
227
- await ctx.db
228
- .deleteFrom("sessions")
229
- .where("id", "=", sessionId)
230
- .execute();
231
- return null;
579
+ if (!validToken) {
580
+ throw ctx.errors.RefreshTokenExpired();
232
581
  }
233
582
 
234
583
  // Get user
235
584
  const user = await ctx.db
236
585
  .selectFrom("users")
237
- .where("id", "=", session.user_id)
586
+ .where("id", "=", payload.sub)
238
587
  .selectAll()
239
588
  .executeTakeFirst();
240
589
 
241
590
  if (!user) {
242
- return null;
591
+ throw ctx.errors.InvalidToken();
243
592
  }
244
593
 
245
- return {
246
- id: user.id,
594
+ // Create new access token (keep same refresh token)
595
+ const now = Math.floor(Date.now() / 1000);
596
+ const accessPayload: JWTPayload = {
597
+ sub: user.id,
247
598
  email: user.email,
248
599
  name: user.name,
600
+ iat: now,
601
+ exp: now + Math.floor(accessExpiryMs / 1000),
602
+ iss: config.jwt?.issuer,
603
+ type: "access",
604
+ };
605
+
606
+ const accessToken = await signJWT(accessPayload, jwtSecret);
607
+
608
+ return {
609
+ accessToken,
610
+ expiresIn: Math.floor(accessExpiryMs / 1000),
249
611
  };
250
612
  },
251
613
 
614
+ /**
615
+ * Logout - invalidate session/refresh token
616
+ */
617
+ logout: async (token: string): Promise<void> => {
618
+ if (strategy === "session") {
619
+ await ctx.db.deleteFrom("sessions").where("id", "=", token).execute();
620
+ } else if (strategy === "refresh-token") {
621
+ // For refresh-token, we receive the refresh token to revoke
622
+ const payload = await verifyJWT(token, jwtSecret);
623
+ if (payload) {
624
+ // Delete all refresh tokens for this user (logout everywhere)
625
+ // Or could do selective deletion by matching hash
626
+ await ctx.db
627
+ .deleteFrom("refresh_tokens")
628
+ .where("user_id", "=", payload.sub)
629
+ .execute();
630
+ }
631
+ }
632
+ // JWT strategy: tokens are stateless, nothing to invalidate
633
+ },
634
+
635
+ /**
636
+ * Logout from all devices
637
+ */
638
+ logoutAll: async (userId: string): Promise<void> => {
639
+ if (strategy === "session") {
640
+ await ctx.db.deleteFrom("sessions").where("user_id", "=", userId).execute();
641
+ } else if (strategy === "refresh-token") {
642
+ await ctx.db.deleteFrom("refresh_tokens").where("user_id", "=", userId).execute();
643
+ }
644
+ },
645
+
646
+ /**
647
+ * Validate token/session and get user
648
+ */
649
+ validateToken,
650
+
252
651
  /**
253
652
  * Get user by ID
254
653
  */
@@ -259,9 +658,7 @@ export const authPlugin = createPlugin
259
658
  .selectAll()
260
659
  .executeTakeFirst();
261
660
 
262
- if (!user) {
263
- return null;
264
- }
661
+ if (!user) return null;
265
662
 
266
663
  return {
267
664
  id: user.id,
@@ -286,7 +683,6 @@ export const authPlugin = createPlugin
286
683
  }
287
684
 
288
685
  if (data.email !== undefined) {
289
- // Check if email is taken by another user
290
686
  const existing = await ctx.db
291
687
  .selectFrom("users")
292
688
  .where("email", "=", data.email.toLowerCase())
@@ -321,52 +717,71 @@ export const authPlugin = createPlugin
321
717
  },
322
718
 
323
719
  /**
324
- * Delete all expired sessions (cleanup job)
720
+ * Cleanup expired sessions/tokens
325
721
  */
326
- cleanupExpiredSessions: async (): Promise<number> => {
327
- const result = await ctx.db
328
- .deleteFrom("sessions")
329
- .where("expires_at", "<", new Date().toISOString())
330
- .executeTakeFirst();
722
+ cleanup: async (): Promise<number> => {
723
+ const now = new Date().toISOString();
724
+ let count = 0;
725
+
726
+ if (strategy === "session") {
727
+ const result = await ctx.db
728
+ .deleteFrom("sessions")
729
+ .where("expires_at", "<", now)
730
+ .executeTakeFirst();
731
+ count = Number(result.numDeletedRows);
732
+ } else if (strategy === "refresh-token") {
733
+ const result = await ctx.db
734
+ .deleteFrom("refresh_tokens")
735
+ .where("expires_at", "<", now)
736
+ .executeTakeFirst();
737
+ count = Number(result.numDeletedRows);
738
+ }
331
739
 
332
- const count = Number(result.numDeletedRows);
333
740
  if (count > 0) {
334
- ctx.core.logger.info("Cleaned up expired sessions", { count });
741
+ ctx.core.logger.info("Auth cleanup completed", { count, strategy });
335
742
  }
336
743
  return count;
337
744
  },
338
745
  };
339
746
  },
340
747
 
341
- /**
342
- * Auth middleware - validates session from cookie/header
343
- */
344
748
  middleware: (ctx, service) => ({
345
749
  /**
346
- * Require authentication middleware
347
- * Sets ctx.user if valid session found
348
- * Returns 401 if required and no valid session
750
+ * Auth middleware - validates token from cookie/header
349
751
  */
350
752
  auth: createMiddleware<{ required?: boolean }>(
351
- async (req, reqCtx, next, config) => {
352
- // Get session ID from cookie or Authorization header
353
- const cookies = req.headers.get("cookie") || "";
354
- const cookieMatch = cookies.match(/session=([^;]+)/);
355
- const headerToken = req.headers.get("authorization")?.replace("Bearer ", "");
753
+ async (req, reqCtx, next, middlewareConfig) => {
754
+ const storage = service.getStorage();
755
+ const cookieConfig = service.getCookieConfig();
756
+
757
+ let token: string | null = null;
758
+
759
+ // Try cookie first (if enabled)
760
+ if (storage === "cookie" || storage === "both") {
761
+ const cookies = req.headers.get("cookie") || "";
762
+ const cookieMatch = cookies.match(
763
+ new RegExp(`${cookieConfig.name}=([^;]+)`)
764
+ );
765
+ token = cookieMatch?.[1] || null;
766
+ }
356
767
 
357
- const sessionId = cookieMatch?.[1] || headerToken;
768
+ // Try header (if enabled and no cookie found)
769
+ if (!token && (storage === "header" || storage === "both")) {
770
+ const authHeader = req.headers.get("authorization");
771
+ if (authHeader?.startsWith("Bearer ")) {
772
+ token = authHeader.slice(7);
773
+ }
774
+ }
358
775
 
359
- if (sessionId) {
360
- const user = await service.validateSession(sessionId);
776
+ if (token) {
777
+ const user = await service.validateToken(token);
361
778
  if (user) {
362
- // Set user on request context
363
779
  (reqCtx as any).user = user;
364
- (reqCtx as any).sessionId = sessionId;
780
+ (reqCtx as any).token = token;
365
781
  }
366
782
  }
367
783
 
368
- // If auth is required and no user, return 401
369
- if (config?.required && !(reqCtx as any).user) {
784
+ if (middlewareConfig?.required && !(reqCtx as any).user) {
370
785
  return Response.json(
371
786
  { error: "Unauthorized", code: "UNAUTHORIZED" },
372
787
  { status: 401 }
@@ -378,19 +793,23 @@ export const authPlugin = createPlugin
378
793
  ),
379
794
  }),
380
795
 
381
- /**
382
- * Initialize cleanup cron job
383
- */
384
796
  init: async (ctx, service) => {
385
- // Clean up expired sessions daily at 3am
386
- ctx.core.cron.schedule(
387
- "0 3 * * *",
388
- async () => {
389
- await service.cleanupExpiredSessions();
390
- },
391
- { name: "auth-session-cleanup" }
392
- );
393
-
394
- ctx.core.logger.info("Auth plugin initialized");
797
+ const strategy = service.getStrategy();
798
+
799
+ // Schedule cleanup based on strategy
800
+ if (strategy === "session" || strategy === "refresh-token") {
801
+ ctx.core.cron.schedule(
802
+ "0 3 * * *", // Daily at 3am
803
+ async () => {
804
+ await service.cleanup();
805
+ },
806
+ { name: "auth-cleanup" }
807
+ );
808
+ }
809
+
810
+ ctx.core.logger.info("Auth plugin initialized", {
811
+ strategy,
812
+ storage: service.getStorage(),
813
+ });
395
814
  },
396
815
  });
@@ -0,0 +1,33 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ export async function up(db: Kysely<any>): Promise<void> {
4
+ await db.schema
5
+ .createTable("refresh_tokens")
6
+ .ifNotExists()
7
+ .addColumn("id", "text", (col) => col.primaryKey())
8
+ .addColumn("user_id", "text", (col) =>
9
+ col.notNull().references("users.id").onDelete("cascade")
10
+ )
11
+ .addColumn("token_hash", "text", (col) => col.notNull())
12
+ .addColumn("expires_at", "text", (col) => col.notNull())
13
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
14
+ .execute();
15
+
16
+ await db.schema
17
+ .createIndex("idx_refresh_tokens_user_id")
18
+ .ifNotExists()
19
+ .on("refresh_tokens")
20
+ .column("user_id")
21
+ .execute();
22
+
23
+ await db.schema
24
+ .createIndex("idx_refresh_tokens_expires_at")
25
+ .ifNotExists()
26
+ .on("refresh_tokens")
27
+ .column("expires_at")
28
+ .execute();
29
+ }
30
+
31
+ export async function down(db: Kysely<any>): Promise<void> {
32
+ await db.schema.dropTable("refresh_tokens").ifExists().execute();
33
+ }
@@ -15,6 +15,10 @@ export const loginSchema = z.object({
15
15
  password: z.string().min(1, "Password is required"),
16
16
  });
17
17
 
18
+ export const refreshSchema = z.object({
19
+ refreshToken: z.string(),
20
+ });
21
+
18
22
  export const updateProfileSchema = z.object({
19
23
  name: z.string().min(1).optional(),
20
24
  email: z.string().email().optional(),
@@ -30,11 +34,19 @@ export const userSchema = z.object({
30
34
  name: z.string().nullable(),
31
35
  });
32
36
 
37
+ export const tokensSchema = z.object({
38
+ accessToken: z.string(),
39
+ refreshToken: z.string().optional(),
40
+ expiresIn: z.number(),
41
+ });
42
+
33
43
  export const authResponseSchema = z.object({
34
44
  user: userSchema,
35
- sessionId: z.string(),
45
+ tokens: tokensSchema,
36
46
  });
37
47
 
48
+ export const refreshResponseSchema = tokensSchema;
49
+
38
50
  export const meResponseSchema = userSchema.nullable();
39
51
 
40
52
  export const logoutResponseSchema = z.object({
@@ -47,6 +59,8 @@ export const logoutResponseSchema = z.object({
47
59
 
48
60
  export type RegisterInput = z.infer<typeof registerSchema>;
49
61
  export type LoginInput = z.infer<typeof loginSchema>;
62
+ export type RefreshInput = z.infer<typeof refreshSchema>;
50
63
  export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
51
64
  export type User = z.infer<typeof userSchema>;
65
+ export type Tokens = z.infer<typeof tokensSchema>;
52
66
  export type AuthResponse = z.infer<typeof authResponseSchema>;
@@ -1,7 +1,7 @@
1
1
  import type { Handler, Routes, AppContext } from "$server/api";
2
2
 
3
3
  /**
4
- * Login Handler - Authenticate user and create session
4
+ * Login Handler - Authenticate user and create session/token
5
5
  */
6
6
  export class LoginHandler implements Handler<Routes.Auth.Login> {
7
7
  constructor(private ctx: AppContext) {}
@@ -14,7 +14,7 @@ export class LoginHandler implements Handler<Routes.Auth.Login> {
14
14
 
15
15
  return {
16
16
  user: result.user,
17
- sessionId: result.sessionId,
17
+ tokens: result.tokens,
18
18
  };
19
19
  }
20
20
  }
@@ -1,17 +1,17 @@
1
1
  import type { Handler, Routes, AppContext } from "$server/api";
2
2
 
3
3
  /**
4
- * Logout Handler - Invalidate current session
4
+ * Logout Handler - Invalidate current session/token
5
5
  */
6
6
  export class LogoutHandler implements Handler<Routes.Auth.Logout> {
7
7
  constructor(private ctx: AppContext) {}
8
8
 
9
9
  async handle(_input: Routes.Auth.Logout.Input): Promise<Routes.Auth.Logout.Output> {
10
- // Get session ID from request context (set by auth middleware)
11
- const sessionId = (this.ctx as any).sessionId;
10
+ // Get token from request context (set by auth middleware)
11
+ const token = (this.ctx as any).token;
12
12
 
13
- if (sessionId) {
14
- await this.ctx.plugins.auth.logout(sessionId);
13
+ if (token) {
14
+ await this.ctx.plugins.auth.logout(token);
15
15
  }
16
16
 
17
17
  return { success: true };
@@ -0,0 +1,19 @@
1
+ import type { Handler, Routes, AppContext } from "$server/api";
2
+
3
+ /**
4
+ * Refresh Handler - Get new access token using refresh token
5
+ * Only available with refresh-token strategy
6
+ */
7
+ export class RefreshHandler implements Handler<Routes.Auth.Refresh> {
8
+ constructor(private ctx: AppContext) {}
9
+
10
+ async handle(input: Routes.Auth.Refresh.Input): Promise<Routes.Auth.Refresh.Output> {
11
+ const tokens = await this.ctx.plugins.auth.refresh(input.refreshToken);
12
+
13
+ return {
14
+ accessToken: tokens.accessToken,
15
+ refreshToken: tokens.refreshToken,
16
+ expiresIn: tokens.expiresIn,
17
+ };
18
+ }
19
+ }
@@ -15,7 +15,7 @@ export class RegisterHandler implements Handler<Routes.Auth.Register> {
15
15
 
16
16
  return {
17
17
  user: result.user,
18
- sessionId: result.sessionId,
18
+ tokens: result.tokens,
19
19
  };
20
20
  }
21
21
  }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Provides:
5
5
  * - auth.register - Create new account
6
- * - auth.login - Login and get session
7
- * - auth.logout - Invalidate session (requires auth)
6
+ * - auth.login - Login and get tokens
7
+ * - auth.refresh - Refresh access token (refresh-token strategy only)
8
+ * - auth.logout - Invalidate session/token (requires auth)
8
9
  * - auth.me - Get current user (optional auth)
9
10
  * - auth.updateProfile - Update profile (requires auth)
10
11
  */
@@ -14,13 +15,16 @@ import { z } from "zod";
14
15
  import {
15
16
  registerSchema,
16
17
  loginSchema,
18
+ refreshSchema,
17
19
  updateProfileSchema,
18
20
  authResponseSchema,
21
+ refreshResponseSchema,
19
22
  userSchema,
20
23
  logoutResponseSchema,
21
24
  } from "./auth.schemas";
22
25
  import { RegisterHandler } from "./handlers/register.handler";
23
26
  import { LoginHandler } from "./handlers/login.handler";
27
+ import { RefreshHandler } from "./handlers/refresh.handler";
24
28
  import { LogoutHandler } from "./handlers/logout.handler";
25
29
  import { MeHandler } from "./handlers/me.handler";
26
30
  import { UpdateProfileHandler } from "./handlers/update-profile.handler";
@@ -40,6 +44,13 @@ export const authRouter = createRouter("auth")
40
44
  handle: LoginHandler,
41
45
  })
42
46
 
47
+ // Refresh token (for refresh-token strategy)
48
+ .route("refresh").typed({
49
+ input: refreshSchema,
50
+ output: refreshResponseSchema,
51
+ handle: RefreshHandler,
52
+ })
53
+
43
54
  // Optional auth - returns user if logged in, null otherwise
44
55
  .middleware.auth({ required: false })
45
56
  .route("me").typed({