@donkeylabs/cli 1.1.13 → 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 +35 -1
- package/templates/sveltekit-app/src/server/plugins/auth/index.ts +815 -0
- package/templates/sveltekit-app/src/server/plugins/auth/migrations/001_create_users.ts +25 -0
- package/templates/sveltekit-app/src/server/plugins/auth/migrations/002_create_sessions.ts +32 -0
- 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 +66 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/login.handler.ts +20 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/logout.handler.ts +19 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/me.handler.ts +23 -0
- 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 +21 -0
- package/templates/sveltekit-app/src/server/routes/auth/handlers/update-profile.handler.ts +24 -0
- package/templates/sveltekit-app/src/server/routes/auth/index.ts +74 -0
package/package.json
CHANGED
|
@@ -5,8 +5,10 @@ import { BunSqliteDialect } from "kysely-bun-sqlite";
|
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
6
6
|
import { demoPlugin } from "./plugins/demo";
|
|
7
7
|
import { workflowDemoPlugin } from "./plugins/workflow-demo";
|
|
8
|
+
import { authPlugin } from "./plugins/auth";
|
|
8
9
|
import demoRoutes from "./routes/demo";
|
|
9
10
|
import { exampleRouter } from "./routes/example";
|
|
11
|
+
import { authRouter } from "./routes/auth";
|
|
10
12
|
|
|
11
13
|
// Simple in-memory database
|
|
12
14
|
const db = new Kysely<{}>({
|
|
@@ -22,11 +24,43 @@ export const server = new AppServer({
|
|
|
22
24
|
},
|
|
23
25
|
});
|
|
24
26
|
|
|
25
|
-
//
|
|
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());
|
|
26
59
|
server.registerPlugin(demoPlugin);
|
|
27
60
|
server.registerPlugin(workflowDemoPlugin);
|
|
28
61
|
|
|
29
62
|
// Register routes
|
|
63
|
+
server.use(authRouter);
|
|
30
64
|
server.use(demoRoutes);
|
|
31
65
|
server.use(exampleRouter);
|
|
32
66
|
|
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Plugin - Configurable authentication with multiple strategies
|
|
3
|
+
*
|
|
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
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createPlugin, createMiddleware } from "@donkeylabs/server";
|
|
16
|
+
import type { ColumnType } from "kysely";
|
|
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
|
+
|
|
90
|
+
// =============================================================================
|
|
91
|
+
// DATABASE SCHEMA TYPES
|
|
92
|
+
// =============================================================================
|
|
93
|
+
|
|
94
|
+
interface UsersTable {
|
|
95
|
+
id: string;
|
|
96
|
+
email: string;
|
|
97
|
+
password_hash: string;
|
|
98
|
+
name: string | null;
|
|
99
|
+
created_at: ColumnType<string, string | undefined, never>;
|
|
100
|
+
updated_at: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface SessionsTable {
|
|
104
|
+
id: string;
|
|
105
|
+
user_id: string;
|
|
106
|
+
expires_at: string;
|
|
107
|
+
created_at: ColumnType<string, string | undefined, never>;
|
|
108
|
+
}
|
|
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
|
+
|
|
118
|
+
interface AuthSchema {
|
|
119
|
+
users: UsersTable;
|
|
120
|
+
sessions: SessionsTable;
|
|
121
|
+
refresh_tokens: RefreshTokensTable;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// =============================================================================
|
|
125
|
+
// EXPORTED TYPES
|
|
126
|
+
// =============================================================================
|
|
127
|
+
|
|
128
|
+
export interface AuthUser {
|
|
129
|
+
id: string;
|
|
130
|
+
email: string;
|
|
131
|
+
name: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
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
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// =============================================================================
|
|
248
|
+
// PLUGIN DEFINITION
|
|
249
|
+
// =============================================================================
|
|
250
|
+
|
|
251
|
+
export const authPlugin = createPlugin
|
|
252
|
+
.withSchema<AuthSchema>()
|
|
253
|
+
.withConfig<AuthConfig>()
|
|
254
|
+
.define({
|
|
255
|
+
name: "auth",
|
|
256
|
+
|
|
257
|
+
customErrors: {
|
|
258
|
+
InvalidCredentials: {
|
|
259
|
+
status: 401,
|
|
260
|
+
code: "INVALID_CREDENTIALS",
|
|
261
|
+
message: "Invalid email or password",
|
|
262
|
+
},
|
|
263
|
+
EmailAlreadyExists: {
|
|
264
|
+
status: 409,
|
|
265
|
+
code: "EMAIL_EXISTS",
|
|
266
|
+
message: "An account with this email already exists",
|
|
267
|
+
},
|
|
268
|
+
InvalidToken: {
|
|
269
|
+
status: 401,
|
|
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.",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
service: async (ctx) => {
|
|
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
|
+
}
|
|
443
|
+
|
|
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
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Register a new user
|
|
463
|
+
*/
|
|
464
|
+
register: async (data: {
|
|
465
|
+
email: string;
|
|
466
|
+
password: string;
|
|
467
|
+
name?: string;
|
|
468
|
+
}): Promise<AuthResult> => {
|
|
469
|
+
const { email, password, name } = data;
|
|
470
|
+
|
|
471
|
+
const existing = await ctx.db
|
|
472
|
+
.selectFrom("users")
|
|
473
|
+
.where("email", "=", email.toLowerCase())
|
|
474
|
+
.selectAll()
|
|
475
|
+
.executeTakeFirst();
|
|
476
|
+
|
|
477
|
+
if (existing) {
|
|
478
|
+
throw ctx.errors.EmailAlreadyExists();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const passwordHash = await Bun.password.hash(password, {
|
|
482
|
+
algorithm: "bcrypt",
|
|
483
|
+
cost: bcryptCost,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const userId = crypto.randomUUID();
|
|
487
|
+
const now = new Date().toISOString();
|
|
488
|
+
|
|
489
|
+
await ctx.db
|
|
490
|
+
.insertInto("users")
|
|
491
|
+
.values({
|
|
492
|
+
id: userId,
|
|
493
|
+
email: email.toLowerCase(),
|
|
494
|
+
password_hash: passwordHash,
|
|
495
|
+
name: name || null,
|
|
496
|
+
created_at: now,
|
|
497
|
+
updated_at: now,
|
|
498
|
+
})
|
|
499
|
+
.execute();
|
|
500
|
+
|
|
501
|
+
const user: AuthUser = {
|
|
502
|
+
id: userId,
|
|
503
|
+
email: email.toLowerCase(),
|
|
504
|
+
name: name || null,
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const tokens = await createTokens(user);
|
|
508
|
+
|
|
509
|
+
ctx.core.logger.info("User registered", { userId, email, strategy });
|
|
510
|
+
|
|
511
|
+
return { user, tokens };
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Login with email and password
|
|
516
|
+
*/
|
|
517
|
+
login: async (data: {
|
|
518
|
+
email: string;
|
|
519
|
+
password: string;
|
|
520
|
+
}): Promise<AuthResult> => {
|
|
521
|
+
const { email, password } = data;
|
|
522
|
+
|
|
523
|
+
const dbUser = await ctx.db
|
|
524
|
+
.selectFrom("users")
|
|
525
|
+
.where("email", "=", email.toLowerCase())
|
|
526
|
+
.selectAll()
|
|
527
|
+
.executeTakeFirst();
|
|
528
|
+
|
|
529
|
+
if (!dbUser) {
|
|
530
|
+
throw ctx.errors.InvalidCredentials();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const valid = await Bun.password.verify(password, dbUser.password_hash);
|
|
534
|
+
if (!valid) {
|
|
535
|
+
throw ctx.errors.InvalidCredentials();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const user: AuthUser = {
|
|
539
|
+
id: dbUser.id,
|
|
540
|
+
email: dbUser.email,
|
|
541
|
+
name: dbUser.name,
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const tokens = await createTokens(user);
|
|
545
|
+
|
|
546
|
+
ctx.core.logger.info("User logged in", { userId: user.id, strategy });
|
|
547
|
+
|
|
548
|
+
return { user, tokens };
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Refresh access token (refresh-token strategy only)
|
|
553
|
+
*/
|
|
554
|
+
refresh: async (refreshToken: string): Promise<AuthTokens> => {
|
|
555
|
+
if (strategy !== "refresh-token") {
|
|
556
|
+
throw new Error("refresh() only available with refresh-token strategy");
|
|
557
|
+
}
|
|
558
|
+
|
|
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)
|
|
568
|
+
.selectAll()
|
|
569
|
+
.execute();
|
|
570
|
+
|
|
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
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!validToken) {
|
|
580
|
+
throw ctx.errors.RefreshTokenExpired();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Get user
|
|
584
|
+
const user = await ctx.db
|
|
585
|
+
.selectFrom("users")
|
|
586
|
+
.where("id", "=", payload.sub)
|
|
587
|
+
.selectAll()
|
|
588
|
+
.executeTakeFirst();
|
|
589
|
+
|
|
590
|
+
if (!user) {
|
|
591
|
+
throw ctx.errors.InvalidToken();
|
|
592
|
+
}
|
|
593
|
+
|
|
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,
|
|
598
|
+
email: user.email,
|
|
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),
|
|
611
|
+
};
|
|
612
|
+
},
|
|
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
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Get user by ID
|
|
653
|
+
*/
|
|
654
|
+
getUserById: async (userId: string): Promise<AuthUser | null> => {
|
|
655
|
+
const user = await ctx.db
|
|
656
|
+
.selectFrom("users")
|
|
657
|
+
.where("id", "=", userId)
|
|
658
|
+
.selectAll()
|
|
659
|
+
.executeTakeFirst();
|
|
660
|
+
|
|
661
|
+
if (!user) return null;
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
id: user.id,
|
|
665
|
+
email: user.email,
|
|
666
|
+
name: user.name,
|
|
667
|
+
};
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Update user profile
|
|
672
|
+
*/
|
|
673
|
+
updateProfile: async (
|
|
674
|
+
userId: string,
|
|
675
|
+
data: { name?: string; email?: string }
|
|
676
|
+
): Promise<AuthUser> => {
|
|
677
|
+
const updates: Record<string, string> = {
|
|
678
|
+
updated_at: new Date().toISOString(),
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
if (data.name !== undefined) {
|
|
682
|
+
updates.name = data.name;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (data.email !== undefined) {
|
|
686
|
+
const existing = await ctx.db
|
|
687
|
+
.selectFrom("users")
|
|
688
|
+
.where("email", "=", data.email.toLowerCase())
|
|
689
|
+
.where("id", "!=", userId)
|
|
690
|
+
.selectAll()
|
|
691
|
+
.executeTakeFirst();
|
|
692
|
+
|
|
693
|
+
if (existing) {
|
|
694
|
+
throw ctx.errors.EmailAlreadyExists();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
updates.email = data.email.toLowerCase();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
await ctx.db
|
|
701
|
+
.updateTable("users")
|
|
702
|
+
.set(updates)
|
|
703
|
+
.where("id", "=", userId)
|
|
704
|
+
.execute();
|
|
705
|
+
|
|
706
|
+
const user = await ctx.db
|
|
707
|
+
.selectFrom("users")
|
|
708
|
+
.where("id", "=", userId)
|
|
709
|
+
.selectAll()
|
|
710
|
+
.executeTakeFirstOrThrow();
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
id: user.id,
|
|
714
|
+
email: user.email,
|
|
715
|
+
name: user.name,
|
|
716
|
+
};
|
|
717
|
+
},
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Cleanup expired sessions/tokens
|
|
721
|
+
*/
|
|
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
|
+
}
|
|
739
|
+
|
|
740
|
+
if (count > 0) {
|
|
741
|
+
ctx.core.logger.info("Auth cleanup completed", { count, strategy });
|
|
742
|
+
}
|
|
743
|
+
return count;
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
},
|
|
747
|
+
|
|
748
|
+
middleware: (ctx, service) => ({
|
|
749
|
+
/**
|
|
750
|
+
* Auth middleware - validates token from cookie/header
|
|
751
|
+
*/
|
|
752
|
+
auth: createMiddleware<{ required?: boolean }>(
|
|
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
|
+
}
|
|
767
|
+
|
|
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
|
+
}
|
|
775
|
+
|
|
776
|
+
if (token) {
|
|
777
|
+
const user = await service.validateToken(token);
|
|
778
|
+
if (user) {
|
|
779
|
+
(reqCtx as any).user = user;
|
|
780
|
+
(reqCtx as any).token = token;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (middlewareConfig?.required && !(reqCtx as any).user) {
|
|
785
|
+
return Response.json(
|
|
786
|
+
{ error: "Unauthorized", code: "UNAUTHORIZED" },
|
|
787
|
+
{ status: 401 }
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return next();
|
|
792
|
+
}
|
|
793
|
+
),
|
|
794
|
+
}),
|
|
795
|
+
|
|
796
|
+
init: async (ctx, service) => {
|
|
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
|
+
});
|
|
814
|
+
},
|
|
815
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
4
|
+
await db.schema
|
|
5
|
+
.createTable("users")
|
|
6
|
+
.ifNotExists()
|
|
7
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
8
|
+
.addColumn("email", "text", (col) => col.notNull().unique())
|
|
9
|
+
.addColumn("password_hash", "text", (col) => col.notNull())
|
|
10
|
+
.addColumn("name", "text")
|
|
11
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
12
|
+
.addColumn("updated_at", "text", (col) => col.notNull())
|
|
13
|
+
.execute();
|
|
14
|
+
|
|
15
|
+
await db.schema
|
|
16
|
+
.createIndex("idx_users_email")
|
|
17
|
+
.ifNotExists()
|
|
18
|
+
.on("users")
|
|
19
|
+
.column("email")
|
|
20
|
+
.execute();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
24
|
+
await db.schema.dropTable("users").ifExists().execute();
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
4
|
+
await db.schema
|
|
5
|
+
.createTable("sessions")
|
|
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("expires_at", "text", (col) => col.notNull())
|
|
12
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
13
|
+
.execute();
|
|
14
|
+
|
|
15
|
+
await db.schema
|
|
16
|
+
.createIndex("idx_sessions_user_id")
|
|
17
|
+
.ifNotExists()
|
|
18
|
+
.on("sessions")
|
|
19
|
+
.column("user_id")
|
|
20
|
+
.execute();
|
|
21
|
+
|
|
22
|
+
await db.schema
|
|
23
|
+
.createIndex("idx_sessions_expires_at")
|
|
24
|
+
.ifNotExists()
|
|
25
|
+
.on("sessions")
|
|
26
|
+
.column("expires_at")
|
|
27
|
+
.execute();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
31
|
+
await db.schema.dropTable("sessions").ifExists().execute();
|
|
32
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// INPUT SCHEMAS
|
|
5
|
+
// =============================================================================
|
|
6
|
+
|
|
7
|
+
export const registerSchema = z.object({
|
|
8
|
+
email: z.string().email("Invalid email address"),
|
|
9
|
+
password: z.string().min(8, "Password must be at least 8 characters"),
|
|
10
|
+
name: z.string().min(1).optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const loginSchema = z.object({
|
|
14
|
+
email: z.string().email("Invalid email address"),
|
|
15
|
+
password: z.string().min(1, "Password is required"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const refreshSchema = z.object({
|
|
19
|
+
refreshToken: z.string(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const updateProfileSchema = z.object({
|
|
23
|
+
name: z.string().min(1).optional(),
|
|
24
|
+
email: z.string().email().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// OUTPUT SCHEMAS
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
export const userSchema = z.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
email: z.string(),
|
|
34
|
+
name: z.string().nullable(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const tokensSchema = z.object({
|
|
38
|
+
accessToken: z.string(),
|
|
39
|
+
refreshToken: z.string().optional(),
|
|
40
|
+
expiresIn: z.number(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const authResponseSchema = z.object({
|
|
44
|
+
user: userSchema,
|
|
45
|
+
tokens: tokensSchema,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const refreshResponseSchema = tokensSchema;
|
|
49
|
+
|
|
50
|
+
export const meResponseSchema = userSchema.nullable();
|
|
51
|
+
|
|
52
|
+
export const logoutResponseSchema = z.object({
|
|
53
|
+
success: z.boolean(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// DERIVED TYPES
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
export type RegisterInput = z.infer<typeof registerSchema>;
|
|
61
|
+
export type LoginInput = z.infer<typeof loginSchema>;
|
|
62
|
+
export type RefreshInput = z.infer<typeof refreshSchema>;
|
|
63
|
+
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
|
64
|
+
export type User = z.infer<typeof userSchema>;
|
|
65
|
+
export type Tokens = z.infer<typeof tokensSchema>;
|
|
66
|
+
export type AuthResponse = z.infer<typeof authResponseSchema>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Handler, Routes, AppContext } from "$server/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Login Handler - Authenticate user and create session/token
|
|
5
|
+
*/
|
|
6
|
+
export class LoginHandler implements Handler<Routes.Auth.Login> {
|
|
7
|
+
constructor(private ctx: AppContext) {}
|
|
8
|
+
|
|
9
|
+
async handle(input: Routes.Auth.Login.Input): Promise<Routes.Auth.Login.Output> {
|
|
10
|
+
const result = await this.ctx.plugins.auth.login({
|
|
11
|
+
email: input.email,
|
|
12
|
+
password: input.password,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
user: result.user,
|
|
17
|
+
tokens: result.tokens,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Handler, Routes, AppContext } from "$server/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Logout Handler - Invalidate current session/token
|
|
5
|
+
*/
|
|
6
|
+
export class LogoutHandler implements Handler<Routes.Auth.Logout> {
|
|
7
|
+
constructor(private ctx: AppContext) {}
|
|
8
|
+
|
|
9
|
+
async handle(_input: Routes.Auth.Logout.Input): Promise<Routes.Auth.Logout.Output> {
|
|
10
|
+
// Get token from request context (set by auth middleware)
|
|
11
|
+
const token = (this.ctx as any).token;
|
|
12
|
+
|
|
13
|
+
if (token) {
|
|
14
|
+
await this.ctx.plugins.auth.logout(token);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { success: true };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Handler, Routes, AppContext } from "$server/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Me Handler - Get current authenticated user
|
|
5
|
+
*/
|
|
6
|
+
export class MeHandler implements Handler<Routes.Auth.Me> {
|
|
7
|
+
constructor(private ctx: AppContext) {}
|
|
8
|
+
|
|
9
|
+
async handle(_input: Routes.Auth.Me.Input): Promise<Routes.Auth.Me.Output> {
|
|
10
|
+
// Get user from request context (set by auth middleware)
|
|
11
|
+
const user = (this.ctx as any).user;
|
|
12
|
+
|
|
13
|
+
if (!user) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
id: user.id,
|
|
19
|
+
email: user.email,
|
|
20
|
+
name: user.name,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Handler, Routes, AppContext } from "$server/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register Handler - Create a new user account
|
|
5
|
+
*/
|
|
6
|
+
export class RegisterHandler implements Handler<Routes.Auth.Register> {
|
|
7
|
+
constructor(private ctx: AppContext) {}
|
|
8
|
+
|
|
9
|
+
async handle(input: Routes.Auth.Register.Input): Promise<Routes.Auth.Register.Output> {
|
|
10
|
+
const result = await this.ctx.plugins.auth.register({
|
|
11
|
+
email: input.email,
|
|
12
|
+
password: input.password,
|
|
13
|
+
name: input.name,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
user: result.user,
|
|
18
|
+
tokens: result.tokens,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Handler, Routes, AppContext } from "$server/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Update Profile Handler - Update current user's profile
|
|
5
|
+
*/
|
|
6
|
+
export class UpdateProfileHandler implements Handler<Routes.Auth.UpdateProfile> {
|
|
7
|
+
constructor(private ctx: AppContext) {}
|
|
8
|
+
|
|
9
|
+
async handle(input: Routes.Auth.UpdateProfile.Input): Promise<Routes.Auth.UpdateProfile.Output> {
|
|
10
|
+
// Get user from request context (set by auth middleware)
|
|
11
|
+
const user = (this.ctx as any).user;
|
|
12
|
+
|
|
13
|
+
if (!user) {
|
|
14
|
+
throw this.ctx.errors.Unauthorized("Not authenticated");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const updated = await this.ctx.plugins.auth.updateProfile(user.id, {
|
|
18
|
+
name: input.name,
|
|
19
|
+
email: input.email,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return updated;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Router - Authentication endpoints
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - auth.register - Create new account
|
|
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)
|
|
9
|
+
* - auth.me - Get current user (optional auth)
|
|
10
|
+
* - auth.updateProfile - Update profile (requires auth)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createRouter } from "@donkeylabs/server";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import {
|
|
16
|
+
registerSchema,
|
|
17
|
+
loginSchema,
|
|
18
|
+
refreshSchema,
|
|
19
|
+
updateProfileSchema,
|
|
20
|
+
authResponseSchema,
|
|
21
|
+
refreshResponseSchema,
|
|
22
|
+
userSchema,
|
|
23
|
+
logoutResponseSchema,
|
|
24
|
+
} from "./auth.schemas";
|
|
25
|
+
import { RegisterHandler } from "./handlers/register.handler";
|
|
26
|
+
import { LoginHandler } from "./handlers/login.handler";
|
|
27
|
+
import { RefreshHandler } from "./handlers/refresh.handler";
|
|
28
|
+
import { LogoutHandler } from "./handlers/logout.handler";
|
|
29
|
+
import { MeHandler } from "./handlers/me.handler";
|
|
30
|
+
import { UpdateProfileHandler } from "./handlers/update-profile.handler";
|
|
31
|
+
|
|
32
|
+
export const authRouter = createRouter("auth")
|
|
33
|
+
|
|
34
|
+
// Public routes
|
|
35
|
+
.route("register").typed({
|
|
36
|
+
input: registerSchema,
|
|
37
|
+
output: authResponseSchema,
|
|
38
|
+
handle: RegisterHandler,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
.route("login").typed({
|
|
42
|
+
input: loginSchema,
|
|
43
|
+
output: authResponseSchema,
|
|
44
|
+
handle: LoginHandler,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Refresh token (for refresh-token strategy)
|
|
48
|
+
.route("refresh").typed({
|
|
49
|
+
input: refreshSchema,
|
|
50
|
+
output: refreshResponseSchema,
|
|
51
|
+
handle: RefreshHandler,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Optional auth - returns user if logged in, null otherwise
|
|
55
|
+
.middleware.auth({ required: false })
|
|
56
|
+
.route("me").typed({
|
|
57
|
+
input: z.object({}),
|
|
58
|
+
output: userSchema.nullable(),
|
|
59
|
+
handle: MeHandler,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Protected routes - require authentication
|
|
63
|
+
.middleware.auth({ required: true })
|
|
64
|
+
.route("logout").typed({
|
|
65
|
+
input: z.object({}),
|
|
66
|
+
output: logoutResponseSchema,
|
|
67
|
+
handle: LogoutHandler,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
.route("updateProfile").typed({
|
|
71
|
+
input: updateProfileSchema,
|
|
72
|
+
output: userSchema,
|
|
73
|
+
handle: UpdateProfileHandler,
|
|
74
|
+
});
|