@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 +1 -1
- package/templates/sveltekit-app/src/server/index.ts +32 -2
- package/templates/sveltekit-app/src/server/plugins/auth/index.ts +552 -133
- package/templates/sveltekit-app/src/server/plugins/auth/migrations/003_create_refresh_tokens.ts +33 -0
- package/templates/sveltekit-app/src/server/routes/auth/auth.schemas.ts +15 -1
- package/templates/sveltekit-app/src/server/routes/auth/handlers/login.handler.ts +2 -2
- package/templates/sveltekit-app/src/server/routes/auth/handlers/logout.handler.ts +5 -5
- package/templates/sveltekit-app/src/server/routes/auth/handlers/refresh.handler.ts +19 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/register.handler.ts +1 -1
- package/templates/sveltekit-app/src/server/routes/auth/index.ts +13 -2
package/package.json
CHANGED
|
@@ -24,8 +24,38 @@ export const server = new AppServer({
|
|
|
24
24
|
},
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
|
|
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 -
|
|
2
|
+
* Auth Plugin - Configurable authentication with multiple strategies
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
268
|
+
InvalidToken: {
|
|
77
269
|
status: 401,
|
|
78
|
-
code: "
|
|
79
|
-
message: "
|
|
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
|
|
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<
|
|
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:
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
501
|
+
const user: AuthUser = {
|
|
502
|
+
id: userId,
|
|
503
|
+
email: email.toLowerCase(),
|
|
504
|
+
name: name || null,
|
|
505
|
+
};
|
|
133
506
|
|
|
134
|
-
await
|
|
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<
|
|
520
|
+
}): Promise<AuthResult> => {
|
|
159
521
|
const { email, password } = data;
|
|
160
522
|
|
|
161
|
-
|
|
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 (!
|
|
529
|
+
if (!dbUser) {
|
|
169
530
|
throw ctx.errors.InvalidCredentials();
|
|
170
531
|
}
|
|
171
532
|
|
|
172
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
538
|
+
const user: AuthUser = {
|
|
539
|
+
id: dbUser.id,
|
|
540
|
+
email: dbUser.email,
|
|
541
|
+
name: dbUser.name,
|
|
542
|
+
};
|
|
181
543
|
|
|
182
|
-
await
|
|
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
|
-
*
|
|
552
|
+
* Refresh access token (refresh-token strategy only)
|
|
202
553
|
*/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
.
|
|
569
|
+
.execute();
|
|
219
570
|
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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", "=",
|
|
586
|
+
.where("id", "=", payload.sub)
|
|
238
587
|
.selectAll()
|
|
239
588
|
.executeTakeFirst();
|
|
240
589
|
|
|
241
590
|
if (!user) {
|
|
242
|
-
|
|
591
|
+
throw ctx.errors.InvalidToken();
|
|
243
592
|
}
|
|
244
593
|
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
*
|
|
720
|
+
* Cleanup expired sessions/tokens
|
|
325
721
|
*/
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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("
|
|
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
|
-
*
|
|
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,
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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 (
|
|
360
|
-
const user = await service.
|
|
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).
|
|
780
|
+
(reqCtx as any).token = token;
|
|
365
781
|
}
|
|
366
782
|
}
|
|
367
783
|
|
|
368
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
});
|
package/templates/sveltekit-app/src/server/plugins/auth/migrations/003_create_refresh_tokens.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
const
|
|
10
|
+
// Get token from request context (set by auth middleware)
|
|
11
|
+
const token = (this.ctx as any).token;
|
|
12
12
|
|
|
13
|
-
if (
|
|
14
|
-
await this.ctx.plugins.auth.logout(
|
|
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
|
+
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides:
|
|
5
5
|
* - auth.register - Create new account
|
|
6
|
-
* - auth.login - Login and get
|
|
7
|
-
* - 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({
|