@everystack/auth 0.1.0 → 0.2.1
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/README.md +1 -7
- package/package.json +33 -22
- package/src/api.ts +42 -8
- package/src/client/index.tsx +39 -7
- package/src/cookies.ts +3 -0
- package/src/csrf.ts +57 -0
- package/src/index.ts +276 -46
- package/src/jwt/index.ts +4 -3
- package/src/passwords.ts +1 -1
- package/src/plugin.ts +264 -0
- package/src/postgres.ts +310 -59
- package/src/refresh.ts +121 -57
- package/src/schema.ts +222 -130
- package/src/sql.ts +715 -73
- package/src/testing.ts +232 -0
package/src/index.ts
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* const auth = createAuthHandlers(db, { users, profiles }, jwtSecret, { mode: 'postgres' });
|
|
19
19
|
*
|
|
20
20
|
* With DB-backed refresh tokens (recommended for production):
|
|
21
|
-
* import {
|
|
21
|
+
* import { refreshTokens } from '@everystack/auth/schema';
|
|
22
22
|
*
|
|
23
|
-
* const auth = createAuthHandlers(db, { users, profiles,
|
|
23
|
+
* const auth = createAuthHandlers(db, { users, profiles, refreshTokens }, jwtSecret);
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { createJwt } from './jwt';
|
|
@@ -32,16 +32,46 @@ import {
|
|
|
32
32
|
revokeAllRefreshTokens,
|
|
33
33
|
TokenError,
|
|
34
34
|
} from './refresh';
|
|
35
|
-
import type {
|
|
35
|
+
import type { RefreshTokenOptions } from './refresh';
|
|
36
|
+
import type { OAuthProvider, OAuthUserInfo } from './oauth';
|
|
36
37
|
export type { OAuthProvider, OAuthUserInfo } from './oauth';
|
|
37
38
|
export { createAppleProvider, createGoogleProvider } from './oauth';
|
|
38
39
|
import { createPostgresAuthHandlers } from './postgres';
|
|
39
40
|
import { getRefreshFromCookies } from './cookies';
|
|
40
41
|
import { authResponse, signoutResponse } from './response';
|
|
41
42
|
import type { CookieConfig } from './response';
|
|
43
|
+
import type { EnrichClaimsFn } from './plugin';
|
|
44
|
+
export type { EnrichClaimsFn } from './plugin';
|
|
45
|
+
import { validateCsrfOrigin } from './csrf';
|
|
42
46
|
|
|
43
47
|
const TOKEN_EXPIRY_SEC = 3600; // 1h
|
|
44
|
-
const REFRESH_EXPIRY_SEC =
|
|
48
|
+
const REFRESH_EXPIRY_SEC = 30 * 24 * 3600; // 30d
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Dummy bcrypt hash for timing-safe user enumeration protection.
|
|
52
|
+
* When a user is not found, we still run bcrypt against this hash
|
|
53
|
+
* so response time is constant regardless of whether the email exists.
|
|
54
|
+
*/
|
|
55
|
+
const DUMMY_BCRYPT_HASH = '$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012345';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the password hash from a user row.
|
|
59
|
+
* Supports both default schema (encryptedPassword) and app schemas (passwordHash).
|
|
60
|
+
*/
|
|
61
|
+
export function getPasswordHash(user: any): string | null {
|
|
62
|
+
return user.encryptedPassword ?? user.passwordHash ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build insert values with the correct password column name.
|
|
67
|
+
* Checks which column exists on the Drizzle table definition.
|
|
68
|
+
*/
|
|
69
|
+
export function withPassword(users: any, values: Record<string, any>, hash: string | null): Record<string, any> {
|
|
70
|
+
if ('encryptedPassword' in users) {
|
|
71
|
+
return { ...values, encryptedPassword: hash };
|
|
72
|
+
}
|
|
73
|
+
return { ...values, passwordHash: hash };
|
|
74
|
+
}
|
|
45
75
|
|
|
46
76
|
export interface TokenPayload {
|
|
47
77
|
sub: string;
|
|
@@ -59,13 +89,52 @@ export interface AuthHandlers {
|
|
|
59
89
|
revokeRefreshTokens: (userId: string) => Promise<void>;
|
|
60
90
|
}
|
|
61
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Callbacks for OAuth user resolution. Enables apps to control where
|
|
94
|
+
* OAuth provider links are stored (their tables, their schema).
|
|
95
|
+
*/
|
|
96
|
+
export interface OAuthCallbacks {
|
|
97
|
+
/** Find an existing user linked to this OAuth provider + UID. */
|
|
98
|
+
findByProvider: (provider: string, providerUid: string) => Promise<{ userId: string } | null>;
|
|
99
|
+
/** Create a new user from OAuth info. Return the created user. */
|
|
100
|
+
createFromOAuth: (
|
|
101
|
+
userInfo: OAuthUserInfo,
|
|
102
|
+
provider: string,
|
|
103
|
+
) => Promise<{
|
|
104
|
+
id: string;
|
|
105
|
+
email: string;
|
|
106
|
+
username?: string;
|
|
107
|
+
displayName?: string;
|
|
108
|
+
avatarUrl?: string;
|
|
109
|
+
role?: string;
|
|
110
|
+
}>;
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
export interface AuthOptions {
|
|
63
114
|
mode?: 'node' | 'postgres';
|
|
64
115
|
/** postgres.js sql client — required when mode is 'postgres' */
|
|
65
116
|
sql?: any;
|
|
66
|
-
|
|
117
|
+
/** Repository ID for multi-tenant scoping. When set, refresh tokens and
|
|
118
|
+
* device registrations are isolated per repository. */
|
|
119
|
+
repositoryId?: string;
|
|
120
|
+
oauth?: {
|
|
121
|
+
providers: OAuthProvider[];
|
|
122
|
+
/** Callback-based OAuth user resolution (recommended).
|
|
123
|
+
* When provided, identities/credentials tables are not needed. */
|
|
124
|
+
callbacks?: OAuthCallbacks;
|
|
125
|
+
/** Optional nonce validator for web OAuth flows.
|
|
126
|
+
* Called with the nonce from the request body and verified user info.
|
|
127
|
+
* Return true if valid, false to reject. */
|
|
128
|
+
validateNonce?: (nonce: string, userInfo: OAuthUserInfo) => Promise<boolean>;
|
|
129
|
+
};
|
|
67
130
|
/** When set, auth responses also set HttpOnly cookies. `true` uses defaults. */
|
|
68
131
|
cookies?: CookieConfig;
|
|
132
|
+
/** When true, signup returns the same 200 response whether the email exists or not.
|
|
133
|
+
* Prevents email enumeration attacks at the cost of no immediate feedback. */
|
|
134
|
+
uniformSignupResponse?: boolean;
|
|
135
|
+
/** Callback to add extra claims (client_id, aud) during token signing.
|
|
136
|
+
* Receives base claims and the original request; returns extra claims to merge. */
|
|
137
|
+
enrichClaims?: EnrichClaimsFn;
|
|
69
138
|
}
|
|
70
139
|
|
|
71
140
|
export {
|
|
@@ -76,24 +145,49 @@ export {
|
|
|
76
145
|
} from './passwords';
|
|
77
146
|
import { hashPassword, verifyPassword } from './passwords';
|
|
78
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Extract client IP and user agent from a request for refresh token metadata.
|
|
150
|
+
* When repositoryId is provided, it's included for multi-tenant scoping.
|
|
151
|
+
*/
|
|
152
|
+
function extractRequestMeta(request: Request, repositoryId?: string): RefreshTokenOptions {
|
|
153
|
+
const forwarded = request.headers.get('x-forwarded-for');
|
|
154
|
+
const ipAddress = forwarded ? forwarded.split(',')[0].trim() : undefined;
|
|
155
|
+
const userAgent = request.headers.get('user-agent') ?? undefined;
|
|
156
|
+
return { repositoryId, ipAddress, userAgent };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Run CSRF origin check if cookies are configured with allowedOrigins. */
|
|
160
|
+
function csrfCheck(request: Request, cookies?: CookieConfig): Response | null {
|
|
161
|
+
if (!cookies || cookies === true) return null;
|
|
162
|
+
return validateCsrfOrigin(request, cookies.allowedOrigins);
|
|
163
|
+
}
|
|
164
|
+
|
|
79
165
|
/**
|
|
80
166
|
* Create JWT auth handlers for signup, signin, refresh, and token verification.
|
|
81
167
|
*
|
|
82
168
|
* When `options.mode` is `'postgres'`, delegates password hashing and JWT signing
|
|
83
169
|
* to PostgreSQL functions via pgcrypto. Requires auth SQL functions from `@everystack/auth/sql`.
|
|
84
170
|
*
|
|
85
|
-
* When `schema.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
171
|
+
* When `schema.refreshTokens` is provided, refresh tokens are stored as SHA-256
|
|
172
|
+
* hashes with one-time-use rotation and reuse detection. When omitted, refresh
|
|
173
|
+
* tokens are stateless JWTs (original behavior).
|
|
88
174
|
*
|
|
89
175
|
* @param db - Drizzle database instance
|
|
90
|
-
* @param schema - Object with `users`, `profiles`, and optional `
|
|
176
|
+
* @param schema - Object with `users`, `profiles`, and optional `refreshTokens` table reference
|
|
91
177
|
* @param jwtSecret - Secret key for JWT signing (Uint8Array)
|
|
92
178
|
* @param options - Mode ('node' | 'postgres') and OAuth config
|
|
93
179
|
*/
|
|
94
180
|
export function createAuthHandlers(
|
|
95
181
|
db: any,
|
|
96
|
-
schema: {
|
|
182
|
+
schema: {
|
|
183
|
+
users: any;
|
|
184
|
+
profiles: any;
|
|
185
|
+
refreshTokens?: any;
|
|
186
|
+
/** Legacy OAuth tables. Use `oauth.callbacks` instead for new apps. */
|
|
187
|
+
credentials?: any;
|
|
188
|
+
/** Legacy OAuth tables. Use `oauth.callbacks` instead for new apps. */
|
|
189
|
+
identities?: any;
|
|
190
|
+
},
|
|
97
191
|
jwtSecret: Uint8Array,
|
|
98
192
|
options?: AuthOptions
|
|
99
193
|
): AuthHandlers {
|
|
@@ -102,6 +196,7 @@ export function createAuthHandlers(
|
|
|
102
196
|
}
|
|
103
197
|
|
|
104
198
|
const { users, profiles } = schema;
|
|
199
|
+
const refreshTable = schema.refreshTokens;
|
|
105
200
|
|
|
106
201
|
const accessJwt = createJwt({
|
|
107
202
|
secret: jwtSecret,
|
|
@@ -114,8 +209,15 @@ export function createAuthHandlers(
|
|
|
114
209
|
ttlSec: REFRESH_EXPIRY_SEC,
|
|
115
210
|
});
|
|
116
211
|
|
|
117
|
-
async function signToken(payload: TokenPayload): Promise<string> {
|
|
118
|
-
|
|
212
|
+
async function signToken(payload: TokenPayload, request?: Request): Promise<string> {
|
|
213
|
+
let claims: Record<string, unknown> = payload as unknown as Record<string, unknown>;
|
|
214
|
+
if (options?.enrichClaims && request) {
|
|
215
|
+
const extra = await options.enrichClaims(claims, request);
|
|
216
|
+
if (extra && Object.keys(extra).length > 0) {
|
|
217
|
+
claims = { ...claims, ...extra };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return accessJwt.sign(claims);
|
|
119
221
|
}
|
|
120
222
|
|
|
121
223
|
async function signRefreshToken(payload: { sub: string }): Promise<string> {
|
|
@@ -129,6 +231,10 @@ export function createAuthHandlers(
|
|
|
129
231
|
},
|
|
130
232
|
|
|
131
233
|
signup: async (request: Request): Promise<Response> => {
|
|
234
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
235
|
+
if (csrfError) return csrfError;
|
|
236
|
+
|
|
237
|
+
const enrichRequest = options?.enrichClaims ? request.clone() : undefined;
|
|
132
238
|
const { email, password, username, displayName } = await request.json();
|
|
133
239
|
|
|
134
240
|
if (!email || !password || !username || !displayName) {
|
|
@@ -144,15 +250,21 @@ export function createAuthHandlers(
|
|
|
144
250
|
.where(eq(users.email, email))
|
|
145
251
|
.limit(1);
|
|
146
252
|
if (existing.length > 0) {
|
|
253
|
+
if (options?.uniformSignupResponse) {
|
|
254
|
+
return Response.json(
|
|
255
|
+
{ message: 'Check your email to continue.' },
|
|
256
|
+
{ status: 200 }
|
|
257
|
+
);
|
|
258
|
+
}
|
|
147
259
|
return Response.json(
|
|
148
260
|
{ message: 'Email already registered' },
|
|
149
261
|
{ status: 409 }
|
|
150
262
|
);
|
|
151
263
|
}
|
|
152
264
|
|
|
153
|
-
const
|
|
265
|
+
const hash = await hashPassword(password);
|
|
154
266
|
const { user, profile } = await db.transaction(async (tx: any) => {
|
|
155
|
-
const [u] = await tx.insert(users).values({ email,
|
|
267
|
+
const [u] = await tx.insert(users).values(withPassword(users, { email }, hash)).returning();
|
|
156
268
|
const [p] = await tx.insert(profiles).values({ id: u.id, username, displayName }).returning();
|
|
157
269
|
return { user: u, profile: p };
|
|
158
270
|
});
|
|
@@ -161,9 +273,10 @@ export function createAuthHandlers(
|
|
|
161
273
|
sub: user.id,
|
|
162
274
|
email: user.email,
|
|
163
275
|
role: user.role ?? 'authenticated',
|
|
164
|
-
});
|
|
165
|
-
const
|
|
166
|
-
|
|
276
|
+
}, enrichRequest);
|
|
277
|
+
const meta = extractRequestMeta(request, options?.repositoryId);
|
|
278
|
+
const refreshToken = refreshTable
|
|
279
|
+
? (await createRefreshToken(db, refreshTable, user.id, undefined, meta)).token
|
|
167
280
|
: await signRefreshToken({ sub: user.id });
|
|
168
281
|
|
|
169
282
|
return authResponse(
|
|
@@ -184,6 +297,10 @@ export function createAuthHandlers(
|
|
|
184
297
|
},
|
|
185
298
|
|
|
186
299
|
signin: async (request: Request): Promise<Response> => {
|
|
300
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
301
|
+
if (csrfError) return csrfError;
|
|
302
|
+
|
|
303
|
+
const enrichRequest = options?.enrichClaims ? request.clone() : undefined;
|
|
187
304
|
const { email, password } = await request.json();
|
|
188
305
|
|
|
189
306
|
if (!email || !password) {
|
|
@@ -198,14 +315,18 @@ export function createAuthHandlers(
|
|
|
198
315
|
.from(users)
|
|
199
316
|
.where(eq(users.email, email))
|
|
200
317
|
.limit(1);
|
|
318
|
+
|
|
201
319
|
if (!user) {
|
|
320
|
+
// Timing-safe: run bcrypt even when user doesn't exist
|
|
321
|
+
// so response time is constant regardless of email existence
|
|
322
|
+
await verifyPassword(password, DUMMY_BCRYPT_HASH);
|
|
202
323
|
return Response.json(
|
|
203
324
|
{ message: 'Invalid email or password' },
|
|
204
325
|
{ status: 401 }
|
|
205
326
|
);
|
|
206
327
|
}
|
|
207
328
|
|
|
208
|
-
const valid = await verifyPassword(password, user
|
|
329
|
+
const valid = await verifyPassword(password, getPasswordHash(user)!);
|
|
209
330
|
if (!valid) {
|
|
210
331
|
return Response.json(
|
|
211
332
|
{ message: 'Invalid email or password' },
|
|
@@ -223,9 +344,10 @@ export function createAuthHandlers(
|
|
|
223
344
|
sub: user.id,
|
|
224
345
|
email: user.email,
|
|
225
346
|
role: user.role ?? 'authenticated',
|
|
226
|
-
});
|
|
227
|
-
const
|
|
228
|
-
|
|
347
|
+
}, enrichRequest);
|
|
348
|
+
const meta = extractRequestMeta(request, options?.repositoryId);
|
|
349
|
+
const refreshToken = refreshTable
|
|
350
|
+
? (await createRefreshToken(db, refreshTable, user.id, undefined, meta)).token
|
|
229
351
|
: await signRefreshToken({ sub: user.id });
|
|
230
352
|
|
|
231
353
|
return authResponse(
|
|
@@ -246,6 +368,9 @@ export function createAuthHandlers(
|
|
|
246
368
|
},
|
|
247
369
|
|
|
248
370
|
refresh: async (request: Request): Promise<Response> => {
|
|
371
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
372
|
+
if (csrfError) return csrfError;
|
|
373
|
+
|
|
249
374
|
// Read refresh token from JSON body (mobile) or cookie (web SSR)
|
|
250
375
|
let refreshToken: string | undefined;
|
|
251
376
|
try {
|
|
@@ -264,10 +389,14 @@ export function createAuthHandlers(
|
|
|
264
389
|
);
|
|
265
390
|
}
|
|
266
391
|
|
|
267
|
-
if (
|
|
392
|
+
if (refreshTable) {
|
|
268
393
|
// DB-backed rotation path
|
|
269
394
|
try {
|
|
270
|
-
const
|
|
395
|
+
const meta = extractRequestMeta(request, options?.repositoryId);
|
|
396
|
+
const result = await rotateRefreshToken(db, refreshTable, refreshToken, {
|
|
397
|
+
ipAddress: meta.ipAddress,
|
|
398
|
+
userAgent: meta.userAgent,
|
|
399
|
+
});
|
|
271
400
|
|
|
272
401
|
const [user] = await db
|
|
273
402
|
.select()
|
|
@@ -275,7 +404,7 @@ export function createAuthHandlers(
|
|
|
275
404
|
.where(eq(users.id, result.userId))
|
|
276
405
|
.limit(1);
|
|
277
406
|
if (!user) {
|
|
278
|
-
await revokeAllRefreshTokens(db,
|
|
407
|
+
await revokeAllRefreshTokens(db, refreshTable, result.userId, options?.repositoryId);
|
|
279
408
|
return Response.json({ message: 'User not found' }, { status: 401 });
|
|
280
409
|
}
|
|
281
410
|
|
|
@@ -283,7 +412,7 @@ export function createAuthHandlers(
|
|
|
283
412
|
sub: user.id,
|
|
284
413
|
email: user.email,
|
|
285
414
|
role: user.role ?? 'authenticated',
|
|
286
|
-
});
|
|
415
|
+
}, request);
|
|
287
416
|
|
|
288
417
|
return authResponse(
|
|
289
418
|
{ token, refreshToken: result.token },
|
|
@@ -321,7 +450,7 @@ export function createAuthHandlers(
|
|
|
321
450
|
sub: user.id,
|
|
322
451
|
email: user.email,
|
|
323
452
|
role: user.role ?? 'authenticated',
|
|
324
|
-
});
|
|
453
|
+
}, request);
|
|
325
454
|
const newRefreshToken = await signRefreshToken({ sub: user.id });
|
|
326
455
|
|
|
327
456
|
return authResponse(
|
|
@@ -340,14 +469,21 @@ export function createAuthHandlers(
|
|
|
340
469
|
|
|
341
470
|
oauth: options?.oauth?.providers.length
|
|
342
471
|
? async (request: Request): Promise<Response> => {
|
|
343
|
-
|
|
472
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
473
|
+
if (csrfError) return csrfError;
|
|
474
|
+
|
|
475
|
+
const enrichRequest = options?.enrichClaims ? request.clone() : undefined;
|
|
476
|
+
const oauthCallbacks = options?.oauth?.callbacks;
|
|
477
|
+
|
|
478
|
+
// Require either callbacks or legacy tables
|
|
479
|
+
if (!oauthCallbacks && (!schema.credentials || !schema.identities)) {
|
|
344
480
|
return Response.json(
|
|
345
|
-
{ message: 'OAuth not configured:
|
|
481
|
+
{ message: 'OAuth not configured: provide oauth.callbacks or credentials/identities tables' },
|
|
346
482
|
{ status: 500 }
|
|
347
483
|
);
|
|
348
484
|
}
|
|
349
485
|
|
|
350
|
-
let body: { provider?: string; token?: string };
|
|
486
|
+
let body: { provider?: string; token?: string; nonce?: string };
|
|
351
487
|
try {
|
|
352
488
|
body = await request.json();
|
|
353
489
|
} catch {
|
|
@@ -357,7 +493,7 @@ export function createAuthHandlers(
|
|
|
357
493
|
);
|
|
358
494
|
}
|
|
359
495
|
|
|
360
|
-
const { provider: providerName, token } = body;
|
|
496
|
+
const { provider: providerName, token, nonce } = body;
|
|
361
497
|
if (!providerName || !token) {
|
|
362
498
|
return Response.json(
|
|
363
499
|
{ message: 'provider and token are required' },
|
|
@@ -383,8 +519,100 @@ export function createAuthHandlers(
|
|
|
383
519
|
);
|
|
384
520
|
}
|
|
385
521
|
|
|
386
|
-
//
|
|
387
|
-
|
|
522
|
+
// Optional nonce validation for web OAuth flows
|
|
523
|
+
if (options?.oauth?.validateNonce) {
|
|
524
|
+
if (!nonce) {
|
|
525
|
+
return Response.json(
|
|
526
|
+
{ message: 'Nonce is required when nonce validation is configured' },
|
|
527
|
+
{ status: 400 }
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
const nonceValid = await options.oauth.validateNonce(nonce, userInfo);
|
|
531
|
+
if (!nonceValid) {
|
|
532
|
+
return Response.json(
|
|
533
|
+
{ message: 'Nonce validation failed' },
|
|
534
|
+
{ status: 401 }
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const meta = extractRequestMeta(request, options?.repositoryId);
|
|
540
|
+
|
|
541
|
+
if (oauthCallbacks) {
|
|
542
|
+
// Callback-based path (recommended) — no identities/credentials tables needed
|
|
543
|
+
const existing = await oauthCallbacks.findByProvider(providerName, userInfo.uid);
|
|
544
|
+
|
|
545
|
+
if (existing) {
|
|
546
|
+
const [existingUser] = await db
|
|
547
|
+
.select()
|
|
548
|
+
.from(users)
|
|
549
|
+
.where(eq(users.id, existing.userId))
|
|
550
|
+
.limit(1);
|
|
551
|
+
|
|
552
|
+
const [profile] = await db
|
|
553
|
+
.select()
|
|
554
|
+
.from(profiles)
|
|
555
|
+
.where(eq(profiles.id, existing.userId))
|
|
556
|
+
.limit(1);
|
|
557
|
+
|
|
558
|
+
const accessToken = await signToken({
|
|
559
|
+
sub: existingUser.id,
|
|
560
|
+
email: existingUser.email,
|
|
561
|
+
role: existingUser.role ?? 'authenticated',
|
|
562
|
+
}, enrichRequest);
|
|
563
|
+
const refreshTokenStr = refreshTable
|
|
564
|
+
? (await createRefreshToken(db, refreshTable, existingUser.id, undefined, meta)).token
|
|
565
|
+
: await signRefreshToken({ sub: existingUser.id });
|
|
566
|
+
|
|
567
|
+
return authResponse(
|
|
568
|
+
{
|
|
569
|
+
token: accessToken,
|
|
570
|
+
refreshToken: refreshTokenStr,
|
|
571
|
+
user: {
|
|
572
|
+
id: existingUser.id,
|
|
573
|
+
email: existingUser.email,
|
|
574
|
+
username: profile?.username,
|
|
575
|
+
displayName: profile?.displayName,
|
|
576
|
+
avatarUrl: profile?.avatarUrl,
|
|
577
|
+
role: existingUser.role ?? 'authenticated',
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
200,
|
|
581
|
+
options?.cookies,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// New user via callback
|
|
586
|
+
const newUser = await oauthCallbacks.createFromOAuth(userInfo, providerName);
|
|
587
|
+
|
|
588
|
+
const accessToken = await signToken({
|
|
589
|
+
sub: newUser.id,
|
|
590
|
+
email: newUser.email,
|
|
591
|
+
role: newUser.role ?? 'authenticated',
|
|
592
|
+
}, enrichRequest);
|
|
593
|
+
const refreshTokenStr = refreshTable
|
|
594
|
+
? (await createRefreshToken(db, refreshTable, newUser.id, undefined, meta)).token
|
|
595
|
+
: await signRefreshToken({ sub: newUser.id });
|
|
596
|
+
|
|
597
|
+
return authResponse(
|
|
598
|
+
{
|
|
599
|
+
token: accessToken,
|
|
600
|
+
refreshToken: refreshTokenStr,
|
|
601
|
+
user: {
|
|
602
|
+
id: newUser.id,
|
|
603
|
+
email: newUser.email,
|
|
604
|
+
username: newUser.username,
|
|
605
|
+
displayName: newUser.displayName,
|
|
606
|
+
avatarUrl: newUser.avatarUrl,
|
|
607
|
+
role: newUser.role ?? 'authenticated',
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
200,
|
|
611
|
+
options?.cookies,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Legacy path: identities + credentials tables
|
|
388
616
|
const existingResults = await db
|
|
389
617
|
.select({
|
|
390
618
|
credentialId: schema.credentials.id,
|
|
@@ -401,7 +629,6 @@ export function createAuthHandlers(
|
|
|
401
629
|
const existingAccount = existingResults[0];
|
|
402
630
|
|
|
403
631
|
if (existingAccount) {
|
|
404
|
-
// Existing linked account — sign in the associated user
|
|
405
632
|
const [existingUser] = await db
|
|
406
633
|
.select()
|
|
407
634
|
.from(users)
|
|
@@ -418,15 +645,15 @@ export function createAuthHandlers(
|
|
|
418
645
|
sub: existingUser.id,
|
|
419
646
|
email: existingUser.email,
|
|
420
647
|
role: existingUser.role ?? 'authenticated',
|
|
421
|
-
});
|
|
422
|
-
const
|
|
423
|
-
? (await createRefreshToken(db,
|
|
648
|
+
}, enrichRequest);
|
|
649
|
+
const refreshTokenStr = refreshTable
|
|
650
|
+
? (await createRefreshToken(db, refreshTable, existingUser.id, undefined, meta)).token
|
|
424
651
|
: await signRefreshToken({ sub: existingUser.id });
|
|
425
652
|
|
|
426
653
|
return authResponse(
|
|
427
654
|
{
|
|
428
655
|
token: accessToken,
|
|
429
|
-
refreshToken,
|
|
656
|
+
refreshToken: refreshTokenStr,
|
|
430
657
|
user: {
|
|
431
658
|
id: existingUser.id,
|
|
432
659
|
email: existingUser.email,
|
|
@@ -441,7 +668,7 @@ export function createAuthHandlers(
|
|
|
441
668
|
);
|
|
442
669
|
}
|
|
443
670
|
|
|
444
|
-
// New user — create account + identity + OAuth credential
|
|
671
|
+
// New user — create account + identity + OAuth credential (legacy path)
|
|
445
672
|
const displayName = [userInfo.firstName, userInfo.lastName]
|
|
446
673
|
.filter(Boolean)
|
|
447
674
|
.join(' ') || userInfo.email.split('@')[0];
|
|
@@ -450,7 +677,7 @@ export function createAuthHandlers(
|
|
|
450
677
|
const { user, profile } = await db.transaction(async (tx: any) => {
|
|
451
678
|
const [u] = await tx
|
|
452
679
|
.insert(users)
|
|
453
|
-
.values({ email: userInfo.email,
|
|
680
|
+
.values(withPassword(users, { email: userInfo.email }, null))
|
|
454
681
|
.returning();
|
|
455
682
|
|
|
456
683
|
const [ident] = await tx
|
|
@@ -489,15 +716,15 @@ export function createAuthHandlers(
|
|
|
489
716
|
sub: user.id,
|
|
490
717
|
email: user.email,
|
|
491
718
|
role: user.role ?? 'authenticated',
|
|
492
|
-
});
|
|
493
|
-
const
|
|
494
|
-
? (await createRefreshToken(db,
|
|
719
|
+
}, enrichRequest);
|
|
720
|
+
const refreshTokenStr = refreshTable
|
|
721
|
+
? (await createRefreshToken(db, refreshTable, user.id, undefined, meta)).token
|
|
495
722
|
: await signRefreshToken({ sub: user.id });
|
|
496
723
|
|
|
497
724
|
return authResponse(
|
|
498
725
|
{
|
|
499
726
|
token: accessToken,
|
|
500
|
-
refreshToken,
|
|
727
|
+
refreshToken: refreshTokenStr,
|
|
501
728
|
user: {
|
|
502
729
|
id: user.id,
|
|
503
730
|
email: user.email,
|
|
@@ -513,13 +740,16 @@ export function createAuthHandlers(
|
|
|
513
740
|
}
|
|
514
741
|
: undefined,
|
|
515
742
|
|
|
516
|
-
signout: async (
|
|
743
|
+
signout: async (request: Request): Promise<Response> => {
|
|
744
|
+
const csrfError = csrfCheck(request, options?.cookies);
|
|
745
|
+
if (csrfError) return csrfError;
|
|
746
|
+
|
|
517
747
|
return signoutResponse(options?.cookies);
|
|
518
748
|
},
|
|
519
749
|
|
|
520
750
|
revokeRefreshTokens: async (userId: string): Promise<void> => {
|
|
521
|
-
if (
|
|
522
|
-
await revokeAllRefreshTokens(db,
|
|
751
|
+
if (refreshTable) {
|
|
752
|
+
await revokeAllRefreshTokens(db, refreshTable, userId, options?.repositoryId);
|
|
523
753
|
}
|
|
524
754
|
},
|
|
525
755
|
};
|
package/src/jwt/index.ts
CHANGED
|
@@ -89,15 +89,16 @@ export function createJwt<
|
|
|
89
89
|
if (parts.length !== 3) return null;
|
|
90
90
|
const [headerB64, payloadB64, sigB64] = parts;
|
|
91
91
|
|
|
92
|
-
// Header must
|
|
93
|
-
//
|
|
92
|
+
// Header must specify HS256. This rejects alg=none, alg=RS256 confusion,
|
|
93
|
+
// and any other algorithm variation. typ is optional per RFC 7519 §5.1 but
|
|
94
|
+
// if present must be "JWT".
|
|
94
95
|
let header: { alg?: string; typ?: string };
|
|
95
96
|
try {
|
|
96
97
|
header = JSON.parse(textDecode(base64urlDecode(headerB64)));
|
|
97
98
|
} catch {
|
|
98
99
|
return null;
|
|
99
100
|
}
|
|
100
|
-
if (header.alg !== 'HS256' || header.typ !== 'JWT') return null;
|
|
101
|
+
if (header.alg !== 'HS256' || (header.typ && header.typ !== 'JWT')) return null;
|
|
101
102
|
|
|
102
103
|
const expected = await hmac(secretBytes, textEncode(`${headerB64}.${payloadB64}`));
|
|
103
104
|
let actual: Uint8Array;
|
package/src/passwords.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import bcrypt from 'bcryptjs';
|
|
20
20
|
|
|
21
21
|
const DEFAULT_BCRYPT_COST = 10;
|
|
22
|
-
export const BCRYPT_COST = Math.max(
|
|
22
|
+
export const BCRYPT_COST = Math.max(10, Math.min(31,
|
|
23
23
|
parseInt(process.env.BCRYPT_COST || '', 10) || DEFAULT_BCRYPT_COST
|
|
24
24
|
));
|
|
25
25
|
export const BCRYPT_MAX_BYTES = 72;
|