@everystack/auth 0.1.0
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 +256 -0
- package/package.json +104 -0
- package/src/api.ts +65 -0
- package/src/cff/index.ts +157 -0
- package/src/client/index.tsx +139 -0
- package/src/context.ts +30 -0
- package/src/cookies.ts +93 -0
- package/src/index.ts +526 -0
- package/src/jwt/hmac-node.ts +11 -0
- package/src/jwt/hmac-web.ts +17 -0
- package/src/jwt/hmac.ts +10 -0
- package/src/jwt/index.ts +182 -0
- package/src/jwt/jwt.sql +177 -0
- package/src/oauth/index.ts +7 -0
- package/src/oauth/providers/apple.ts +43 -0
- package/src/oauth/providers/google.ts +49 -0
- package/src/oauth/types.ts +17 -0
- package/src/oauth/verify.ts +193 -0
- package/src/oauth.ts +6 -0
- package/src/passwords.ts +70 -0
- package/src/postgres.ts +504 -0
- package/src/refresh.ts +204 -0
- package/src/response.ts +50 -0
- package/src/schema.ts +224 -0
- package/src/sql.ts +261 -0
package/src/passwords.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing — bcrypt, configurable cost (default 10, OWASP minimum).
|
|
3
|
+
*
|
|
4
|
+
* Why bcrypt:
|
|
5
|
+
* - OWASP-aligned (cost ≥10 is the recommendation).
|
|
6
|
+
* - Byte-compatible with Postgres pgcrypto `crypt(p, gen_salt('bf', N))` →
|
|
7
|
+
* hashes are interchangeable between JS and SQL paths.
|
|
8
|
+
* - Pure-JS implementation (`bcryptjs`), no native build, runs on Lambda + Bun
|
|
9
|
+
* + Cloudflare Workers + browsers without polyfill.
|
|
10
|
+
*
|
|
11
|
+
* Cost is configurable per-site via BCRYPT_COST env var (default: 10).
|
|
12
|
+
* Each increment doubles the work: cost 10 ≈ 86ms, 12 ≈ 394ms, 14 ≈ 1.5s.
|
|
13
|
+
*
|
|
14
|
+
* 72-byte limit is enforced rather than silently truncated. Apps must validate
|
|
15
|
+
* input length before calling. Reject early and loud is the OWASP-correct
|
|
16
|
+
* stance — silent truncation makes "the last 30 chars of my 100-char password
|
|
17
|
+
* don't matter" a real bug.
|
|
18
|
+
*/
|
|
19
|
+
import bcrypt from 'bcryptjs';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_BCRYPT_COST = 10;
|
|
22
|
+
export const BCRYPT_COST = Math.max(4, Math.min(31,
|
|
23
|
+
parseInt(process.env.BCRYPT_COST || '', 10) || DEFAULT_BCRYPT_COST
|
|
24
|
+
));
|
|
25
|
+
export const BCRYPT_MAX_BYTES = 72;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hash a password with bcrypt.
|
|
29
|
+
*
|
|
30
|
+
* Produces $2a$ hashes for pgcrypto compatibility. bcryptjs emits $2a$
|
|
31
|
+
* by default (bcrypt.genSalt uses prefix '2a'), so no normalization needed.
|
|
32
|
+
* If a future library change switches to $2b$, the replace below catches it.
|
|
33
|
+
*
|
|
34
|
+
* @throws {Error} If password is empty or longer than 72 UTF-8 bytes.
|
|
35
|
+
*/
|
|
36
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
37
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
38
|
+
throw new Error('password must be a non-empty string');
|
|
39
|
+
}
|
|
40
|
+
const byteLen = new TextEncoder().encode(password).length;
|
|
41
|
+
if (byteLen > BCRYPT_MAX_BYTES) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`password exceeds bcrypt's 72 bytes limit (got ${byteLen} bytes)`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const hash = await bcrypt.hash(password, BCRYPT_COST);
|
|
47
|
+
// Normalize $2b$ → $2a$ for pgcrypto compat (algorithms are byte-identical).
|
|
48
|
+
return hash.replace(/^\$2b\$/, '$2a$');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Verify a password against a stored bcrypt hash.
|
|
53
|
+
*
|
|
54
|
+
* Returns false (never throws) for any malformed or unrecognized stored value.
|
|
55
|
+
* Accepts $2a$ (pgcrypto / older bcrypt), $2b$ (modern), $2y$ (PHP) prefixes —
|
|
56
|
+
* all are byte-identical OpenBSD bcrypt.
|
|
57
|
+
*/
|
|
58
|
+
export async function verifyPassword(
|
|
59
|
+
password: string,
|
|
60
|
+
stored: string
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
if (typeof password !== 'string' || password.length === 0) return false;
|
|
63
|
+
if (typeof stored !== 'string' || stored.length === 0) return false;
|
|
64
|
+
if (!/^\$2[aby]\$\d{2}\$/.test(stored)) return false;
|
|
65
|
+
try {
|
|
66
|
+
return await bcrypt.compare(password, stored);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/postgres.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL-backed auth handlers using pgcrypto.
|
|
3
|
+
*
|
|
4
|
+
* Password hashing: crypt() / gen_salt('bf') via auth.hash_password()
|
|
5
|
+
* JWT signing: hmac() via auth.sign_jwt()
|
|
6
|
+
* Refresh tokens: auth.generate_refresh_token() / auth.validate_refresh_token()
|
|
7
|
+
*
|
|
8
|
+
* Uses postgres.js tagged templates directly (not drizzle's sql) because
|
|
9
|
+
* drizzle expands arrays into individual params instead of PostgreSQL arrays.
|
|
10
|
+
* Drizzle db is still used for ORM operations (insert/select/update).
|
|
11
|
+
*
|
|
12
|
+
* Requires: pgcrypto extension + auth functions (see @everystack/auth/sql)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { and, eq } from 'drizzle-orm';
|
|
16
|
+
import { createJwt } from './jwt';
|
|
17
|
+
import { hmacNode } from './jwt/hmac-node';
|
|
18
|
+
import type { AuthHandlers, AuthOptions, TokenPayload } from './index';
|
|
19
|
+
import type { OAuthProvider } from './oauth';
|
|
20
|
+
import { getRefreshFromCookies } from './cookies';
|
|
21
|
+
import { authResponse, signoutResponse } from './response';
|
|
22
|
+
|
|
23
|
+
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
|
24
|
+
|
|
25
|
+
export function createPostgresAuthHandlers(
|
|
26
|
+
db: any,
|
|
27
|
+
schema: { users: any; profiles: any; sessions?: any; credentials?: any; identities?: any },
|
|
28
|
+
jwtSecret: Uint8Array,
|
|
29
|
+
options?: AuthOptions,
|
|
30
|
+
): AuthHandlers {
|
|
31
|
+
const pgSql = options?.sql;
|
|
32
|
+
if (!pgSql) {
|
|
33
|
+
throw new Error("mode: 'postgres' requires options.sql (postgres.js client)");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { users, profiles } = schema;
|
|
37
|
+
const jwtSecretString = new TextDecoder().decode(jwtSecret);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sign JWT via PostgreSQL pgcrypto hmac().
|
|
41
|
+
* Requires GUC app.jwt_secret — uses pgSql.begin() for transaction-local setting.
|
|
42
|
+
*/
|
|
43
|
+
async function signJwtPg(payload: Record<string, unknown>): Promise<string> {
|
|
44
|
+
const now = Math.floor(Date.now() / 1000);
|
|
45
|
+
const fullPayload = {
|
|
46
|
+
...payload,
|
|
47
|
+
iat: now,
|
|
48
|
+
exp: now + TOKEN_EXPIRY_SECONDS,
|
|
49
|
+
};
|
|
50
|
+
const payloadJson = JSON.stringify(fullPayload);
|
|
51
|
+
const rows = await pgSql.begin(async (sql: any) => {
|
|
52
|
+
await sql`SELECT set_config('app.jwt_secret', ${jwtSecretString}, true)`;
|
|
53
|
+
return sql`SELECT auth.sign_jwt(${payloadJson}::jsonb) as sign_jwt`;
|
|
54
|
+
});
|
|
55
|
+
return rows[0].sign_jwt;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hash password via PostgreSQL pgcrypto crypt()/gen_salt('bf').
|
|
60
|
+
*/
|
|
61
|
+
async function hashPasswordPg(password: string): Promise<string> {
|
|
62
|
+
const rows = await pgSql`SELECT auth.hash_password(${password}) as hash_password`;
|
|
63
|
+
return rows[0].hash_password;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Verify password via PostgreSQL pgcrypto crypt().
|
|
68
|
+
*/
|
|
69
|
+
async function verifyPasswordPg(password: string, hash: string): Promise<boolean> {
|
|
70
|
+
const rows = await pgSql`SELECT auth.verify_password(${password}, ${hash}) as verify_password`;
|
|
71
|
+
return rows[0].verify_password;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate refresh token via PostgreSQL.
|
|
76
|
+
*/
|
|
77
|
+
async function generateRefreshTokenPg(userId: string): Promise<string> {
|
|
78
|
+
const rows = await pgSql`SELECT auth.generate_refresh_token(${userId}::uuid) as generate_refresh_token`;
|
|
79
|
+
return rows[0].generate_refresh_token;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate and rotate refresh token via PostgreSQL.
|
|
84
|
+
* The SQL function handles its own transactional locking (FOR UPDATE).
|
|
85
|
+
* Returns { userId, token, familyId }.
|
|
86
|
+
*/
|
|
87
|
+
async function validateRefreshTokenPg(token: string): Promise<{ userId: string; token: string; familyId: string }> {
|
|
88
|
+
const rows = await pgSql`SELECT auth.validate_refresh_token(${token}) as validate_refresh_token`;
|
|
89
|
+
const result = rows[0].validate_refresh_token;
|
|
90
|
+
return typeof result === 'string' ? JSON.parse(result) : result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Revoke all refresh tokens for a user.
|
|
95
|
+
*/
|
|
96
|
+
async function revokeRefreshTokensPg(userId: string): Promise<void> {
|
|
97
|
+
await pgSql`SELECT auth.revoke_refresh_tokens(${userId}::uuid)`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// JS-side verifier (compatible with HS256 tokens minted by SQL or JS).
|
|
101
|
+
const verifier = createJwt({
|
|
102
|
+
secret: jwtSecret,
|
|
103
|
+
hmac: hmacNode,
|
|
104
|
+
ttlSec: TOKEN_EXPIRY_SECONDS,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
verifyToken: async (token: string): Promise<TokenPayload | null> => {
|
|
109
|
+
const claims = await verifier.verify(token);
|
|
110
|
+
return (claims as TokenPayload | null) ?? null;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
signup: async (request: Request): Promise<Response> => {
|
|
114
|
+
const { email, password, username, displayName } = await request.json();
|
|
115
|
+
|
|
116
|
+
if (!email || !password || !username || !displayName) {
|
|
117
|
+
return Response.json(
|
|
118
|
+
{ message: 'email, password, username, and displayName are required' },
|
|
119
|
+
{ status: 400 },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const existing = await db
|
|
124
|
+
.select()
|
|
125
|
+
.from(users)
|
|
126
|
+
.where(eq(users.email, email))
|
|
127
|
+
.limit(1);
|
|
128
|
+
if (existing.length > 0) {
|
|
129
|
+
return Response.json(
|
|
130
|
+
{ message: 'Email already registered' },
|
|
131
|
+
{ status: 409 },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const passwordHash = await hashPasswordPg(password);
|
|
136
|
+
const { user, profile } = await db.transaction(async (tx: any) => {
|
|
137
|
+
const [u] = await tx.insert(users).values({ email, passwordHash }).returning();
|
|
138
|
+
const [p] = await tx.insert(profiles).values({ id: u.id, username, displayName }).returning();
|
|
139
|
+
return { user: u, profile: p };
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const token = await signJwtPg({
|
|
143
|
+
sub: user.id,
|
|
144
|
+
email: user.email,
|
|
145
|
+
role: user.role ?? 'authenticated',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const refreshToken = schema.sessions
|
|
149
|
+
? await generateRefreshTokenPg(user.id)
|
|
150
|
+
: await signJwtPg({ sub: user.id });
|
|
151
|
+
|
|
152
|
+
return authResponse(
|
|
153
|
+
{
|
|
154
|
+
token,
|
|
155
|
+
refreshToken,
|
|
156
|
+
user: {
|
|
157
|
+
id: user.id,
|
|
158
|
+
email: user.email,
|
|
159
|
+
username: profile.username,
|
|
160
|
+
displayName: profile.displayName,
|
|
161
|
+
role: user.role ?? 'authenticated',
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
201,
|
|
165
|
+
options?.cookies,
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
signin: async (request: Request): Promise<Response> => {
|
|
170
|
+
const { email, password } = await request.json();
|
|
171
|
+
|
|
172
|
+
if (!email || !password) {
|
|
173
|
+
return Response.json(
|
|
174
|
+
{ message: 'email and password are required' },
|
|
175
|
+
{ status: 400 },
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const [user] = await db
|
|
180
|
+
.select()
|
|
181
|
+
.from(users)
|
|
182
|
+
.where(eq(users.email, email))
|
|
183
|
+
.limit(1);
|
|
184
|
+
if (!user) {
|
|
185
|
+
return Response.json(
|
|
186
|
+
{ message: 'Invalid email or password' },
|
|
187
|
+
{ status: 401 },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const valid = await verifyPasswordPg(password, user.passwordHash);
|
|
192
|
+
if (!valid) {
|
|
193
|
+
return Response.json(
|
|
194
|
+
{ message: 'Invalid email or password' },
|
|
195
|
+
{ status: 401 },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const [profile] = await db
|
|
200
|
+
.select()
|
|
201
|
+
.from(profiles)
|
|
202
|
+
.where(eq(profiles.id, user.id))
|
|
203
|
+
.limit(1);
|
|
204
|
+
|
|
205
|
+
const token = await signJwtPg({
|
|
206
|
+
sub: user.id,
|
|
207
|
+
email: user.email,
|
|
208
|
+
role: user.role ?? 'authenticated',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const refreshToken = schema.sessions
|
|
212
|
+
? await generateRefreshTokenPg(user.id)
|
|
213
|
+
: await signJwtPg({ sub: user.id });
|
|
214
|
+
|
|
215
|
+
return authResponse(
|
|
216
|
+
{
|
|
217
|
+
token,
|
|
218
|
+
refreshToken,
|
|
219
|
+
user: {
|
|
220
|
+
id: user.id,
|
|
221
|
+
email: user.email,
|
|
222
|
+
username: profile?.username,
|
|
223
|
+
displayName: profile?.displayName,
|
|
224
|
+
role: user.role ?? 'authenticated',
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
200,
|
|
228
|
+
options?.cookies,
|
|
229
|
+
);
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
refresh: async (request: Request): Promise<Response> => {
|
|
233
|
+
// Read refresh token from JSON body (mobile) or cookie (web SSR)
|
|
234
|
+
let refreshToken: string | undefined;
|
|
235
|
+
try {
|
|
236
|
+
const body = await request.clone().json();
|
|
237
|
+
refreshToken = body?.refreshToken;
|
|
238
|
+
} catch {
|
|
239
|
+
// Empty body is fine — fall through to cookie
|
|
240
|
+
}
|
|
241
|
+
if (!refreshToken) {
|
|
242
|
+
refreshToken = getRefreshFromCookies(request.headers.get('cookie')) ?? undefined;
|
|
243
|
+
}
|
|
244
|
+
if (!refreshToken) {
|
|
245
|
+
return Response.json(
|
|
246
|
+
{ message: 'refreshToken is required' },
|
|
247
|
+
{ status: 400 },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (schema.sessions) {
|
|
252
|
+
// DB-backed rotation via SQL function
|
|
253
|
+
try {
|
|
254
|
+
const result = await validateRefreshTokenPg(refreshToken);
|
|
255
|
+
|
|
256
|
+
const [user] = await db
|
|
257
|
+
.select()
|
|
258
|
+
.from(users)
|
|
259
|
+
.where(eq(users.id, result.userId))
|
|
260
|
+
.limit(1);
|
|
261
|
+
if (!user) {
|
|
262
|
+
return Response.json({ message: 'User not found' }, { status: 401 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const token = await signJwtPg({
|
|
266
|
+
sub: user.id,
|
|
267
|
+
email: user.email,
|
|
268
|
+
role: user.role ?? 'authenticated',
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return authResponse(
|
|
272
|
+
{ token, refreshToken: result.token },
|
|
273
|
+
200,
|
|
274
|
+
options?.cookies,
|
|
275
|
+
);
|
|
276
|
+
} catch {
|
|
277
|
+
return Response.json(
|
|
278
|
+
{ message: 'Invalid or expired refresh token' },
|
|
279
|
+
{ status: 401 },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
// Stateless JWT-based refresh (fallback)
|
|
284
|
+
try {
|
|
285
|
+
const payload = await verifier.verify(refreshToken);
|
|
286
|
+
if (!payload) {
|
|
287
|
+
return Response.json({ message: 'Invalid refresh token' }, { status: 401 });
|
|
288
|
+
}
|
|
289
|
+
const userId = payload.sub as string;
|
|
290
|
+
|
|
291
|
+
const [user] = await db
|
|
292
|
+
.select()
|
|
293
|
+
.from(users)
|
|
294
|
+
.where(eq(users.id, userId))
|
|
295
|
+
.limit(1);
|
|
296
|
+
if (!user) {
|
|
297
|
+
return Response.json({ message: 'User not found' }, { status: 401 });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const token = await signJwtPg({
|
|
301
|
+
sub: user.id,
|
|
302
|
+
email: user.email,
|
|
303
|
+
role: user.role ?? 'authenticated',
|
|
304
|
+
});
|
|
305
|
+
const newRefreshToken = await signJwtPg({ sub: user.id });
|
|
306
|
+
|
|
307
|
+
return authResponse(
|
|
308
|
+
{ token, refreshToken: newRefreshToken },
|
|
309
|
+
200,
|
|
310
|
+
options?.cookies,
|
|
311
|
+
);
|
|
312
|
+
} catch {
|
|
313
|
+
return Response.json(
|
|
314
|
+
{ message: 'Invalid or expired refresh token' },
|
|
315
|
+
{ status: 401 },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
oauth: options?.oauth?.providers.length
|
|
322
|
+
? async (request: Request): Promise<Response> => {
|
|
323
|
+
if (!schema.credentials || !schema.identities) {
|
|
324
|
+
return Response.json(
|
|
325
|
+
{ message: 'OAuth not configured: credentials and identities tables required in schema' },
|
|
326
|
+
{ status: 500 },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let body: { provider?: string; token?: string };
|
|
331
|
+
try {
|
|
332
|
+
body = await request.json();
|
|
333
|
+
} catch {
|
|
334
|
+
return Response.json(
|
|
335
|
+
{ message: 'Invalid request body' },
|
|
336
|
+
{ status: 400 },
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const { provider: providerName, token } = body;
|
|
341
|
+
if (!providerName || !token) {
|
|
342
|
+
return Response.json(
|
|
343
|
+
{ message: 'provider and token are required' },
|
|
344
|
+
{ status: 400 },
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const provider = options!.oauth!.providers.find(
|
|
349
|
+
(p: OAuthProvider) => p.name === providerName,
|
|
350
|
+
);
|
|
351
|
+
if (!provider) {
|
|
352
|
+
return Response.json(
|
|
353
|
+
{ message: `Unsupported provider: ${providerName}` },
|
|
354
|
+
{ status: 400 },
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const userInfo = await provider.verify(token);
|
|
359
|
+
if (!userInfo) {
|
|
360
|
+
return Response.json(
|
|
361
|
+
{ message: 'Token verification failed' },
|
|
362
|
+
{ status: 401 },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Look up existing OAuth credential by (type='oauth', credentialId=providerUid)
|
|
367
|
+
const existingResults = await db
|
|
368
|
+
.select({
|
|
369
|
+
credentialId: schema.credentials.id,
|
|
370
|
+
userId: schema.identities.userId,
|
|
371
|
+
})
|
|
372
|
+
.from(schema.credentials)
|
|
373
|
+
.innerJoin(schema.identities, eq(schema.credentials.identityId, schema.identities.id))
|
|
374
|
+
.where(and(
|
|
375
|
+
eq(schema.credentials.type, 'oauth'),
|
|
376
|
+
eq(schema.credentials.credentialId, userInfo.uid),
|
|
377
|
+
))
|
|
378
|
+
.limit(1);
|
|
379
|
+
|
|
380
|
+
const existingAccount = existingResults[0];
|
|
381
|
+
|
|
382
|
+
if (existingAccount) {
|
|
383
|
+
const [existingUser] = await db
|
|
384
|
+
.select()
|
|
385
|
+
.from(users)
|
|
386
|
+
.where(eq(users.id, existingAccount.userId))
|
|
387
|
+
.limit(1);
|
|
388
|
+
|
|
389
|
+
const [profile] = await db
|
|
390
|
+
.select()
|
|
391
|
+
.from(profiles)
|
|
392
|
+
.where(eq(profiles.id, existingAccount.userId))
|
|
393
|
+
.limit(1);
|
|
394
|
+
|
|
395
|
+
const accessToken = await signJwtPg({
|
|
396
|
+
sub: existingUser.id,
|
|
397
|
+
email: existingUser.email,
|
|
398
|
+
role: existingUser.role ?? 'authenticated',
|
|
399
|
+
});
|
|
400
|
+
const refreshToken = schema.sessions
|
|
401
|
+
? await generateRefreshTokenPg(existingUser.id)
|
|
402
|
+
: await signJwtPg({ sub: existingUser.id });
|
|
403
|
+
|
|
404
|
+
return authResponse(
|
|
405
|
+
{
|
|
406
|
+
token: accessToken,
|
|
407
|
+
refreshToken,
|
|
408
|
+
user: {
|
|
409
|
+
id: existingUser.id,
|
|
410
|
+
email: existingUser.email,
|
|
411
|
+
username: profile?.username,
|
|
412
|
+
displayName: profile?.displayName,
|
|
413
|
+
avatarUrl: profile?.avatarUrl,
|
|
414
|
+
role: existingUser.role ?? 'authenticated',
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
200,
|
|
418
|
+
options?.cookies,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// New user — create account + identity + OAuth credential
|
|
423
|
+
const displayName = [userInfo.firstName, userInfo.lastName]
|
|
424
|
+
.filter(Boolean)
|
|
425
|
+
.join(' ') || userInfo.email.split('@')[0];
|
|
426
|
+
const username = userInfo.email.split('@')[0];
|
|
427
|
+
|
|
428
|
+
const { user, profile } = await db.transaction(async (tx: any) => {
|
|
429
|
+
const [u] = await tx
|
|
430
|
+
.insert(users)
|
|
431
|
+
.values({ email: userInfo.email, passwordHash: null })
|
|
432
|
+
.returning();
|
|
433
|
+
|
|
434
|
+
const [ident] = await tx
|
|
435
|
+
.insert(schema.identities)
|
|
436
|
+
.values({
|
|
437
|
+
type: 'user',
|
|
438
|
+
userId: u.id,
|
|
439
|
+
name: userInfo.email,
|
|
440
|
+
})
|
|
441
|
+
.returning();
|
|
442
|
+
|
|
443
|
+
await tx
|
|
444
|
+
.insert(schema.credentials)
|
|
445
|
+
.values({
|
|
446
|
+
type: 'oauth',
|
|
447
|
+
identityId: ident.id,
|
|
448
|
+
credentialId: userInfo.uid,
|
|
449
|
+
name: providerName,
|
|
450
|
+
metadata: { email: userInfo.email },
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const [p] = await tx
|
|
454
|
+
.insert(profiles)
|
|
455
|
+
.values({
|
|
456
|
+
id: u.id,
|
|
457
|
+
username,
|
|
458
|
+
displayName,
|
|
459
|
+
avatarUrl: userInfo.avatarUrl || null,
|
|
460
|
+
})
|
|
461
|
+
.returning();
|
|
462
|
+
|
|
463
|
+
return { user: u, profile: p };
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const accessToken = await signJwtPg({
|
|
467
|
+
sub: user.id,
|
|
468
|
+
email: user.email,
|
|
469
|
+
role: user.role ?? 'authenticated',
|
|
470
|
+
});
|
|
471
|
+
const refreshToken = schema.sessions
|
|
472
|
+
? await generateRefreshTokenPg(user.id)
|
|
473
|
+
: await signJwtPg({ sub: user.id });
|
|
474
|
+
|
|
475
|
+
return authResponse(
|
|
476
|
+
{
|
|
477
|
+
token: accessToken,
|
|
478
|
+
refreshToken,
|
|
479
|
+
user: {
|
|
480
|
+
id: user.id,
|
|
481
|
+
email: user.email,
|
|
482
|
+
username: profile.username,
|
|
483
|
+
displayName: profile.displayName,
|
|
484
|
+
avatarUrl: profile.avatarUrl,
|
|
485
|
+
role: user.role ?? 'authenticated',
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
200,
|
|
489
|
+
options?.cookies,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
: undefined,
|
|
493
|
+
|
|
494
|
+
signout: async (_request: Request): Promise<Response> => {
|
|
495
|
+
return signoutResponse(options?.cookies);
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
revokeRefreshTokens: async (userId: string): Promise<void> => {
|
|
499
|
+
if (schema.sessions) {
|
|
500
|
+
await revokeRefreshTokensPg(userId);
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|