@revealui/auth 0.2.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 +58 -34
- package/dist/react/useSignUp.d.ts +1 -0
- package/dist/react/useSignUp.d.ts.map +1 -1
- package/dist/server/auth.d.ts +2 -0
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +18 -1
- package/dist/server/brute-force.d.ts.map +1 -1
- package/dist/server/brute-force.js +29 -20
- package/dist/server/errors.d.ts +4 -0
- package/dist/server/errors.d.ts.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -1
- package/dist/server/oauth.d.ts +49 -0
- package/dist/server/oauth.d.ts.map +1 -0
- package/dist/server/oauth.js +223 -0
- package/dist/server/password-reset.d.ts +17 -6
- package/dist/server/password-reset.d.ts.map +1 -1
- package/dist/server/password-reset.js +72 -46
- package/dist/server/providers/github.d.ts +14 -0
- package/dist/server/providers/github.d.ts.map +1 -0
- package/dist/server/providers/github.js +73 -0
- package/dist/server/providers/google.d.ts +11 -0
- package/dist/server/providers/google.d.ts.map +1 -0
- package/dist/server/providers/google.js +53 -0
- package/dist/server/providers/vercel.d.ts +11 -0
- package/dist/server/providers/vercel.d.ts.map +1 -0
- package/dist/server/providers/vercel.js +47 -0
- package/dist/server/rate-limit.js +11 -11
- package/dist/server/session.js +1 -1
- package/dist/server/storage/database.d.ts +9 -0
- package/dist/server/storage/database.d.ts.map +1 -1
- package/dist/server/storage/database.js +30 -0
- package/dist/server/storage/in-memory.d.ts +4 -0
- package/dist/server/storage/in-memory.d.ts.map +1 -1
- package/dist/server/storage/in-memory.js +10 -0
- package/dist/server/storage/interface.d.ts +10 -0
- package/dist/server/storage/interface.d.ts.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/database.d.ts.map +1 -1
- package/dist/utils/database.js +3 -0
- package/dist/utils/token.d.ts +9 -1
- package/dist/utils/token.d.ts.map +1 -1
- package/dist/utils/token.js +9 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
# @revealui/auth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
See [Project Status](../../docs/PROJECT_STATUS.md) for framework readiness.
|
|
6
|
-
|
|
7
|
-
Authentication system for RevealUI - database-backed sessions with Better Auth patterns.
|
|
8
|
-
|
|
9
|
-
> **⚠️ Security Note:** Auth implementation exists but requires independent security audit before production use.
|
|
3
|
+
Session-based authentication for RevealUI — database-backed sessions, rate limiting, brute force protection, and password reset.
|
|
10
4
|
|
|
11
5
|
## Features
|
|
12
6
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
7
|
+
- **Database Sessions** — PostgreSQL/NeonDB-backed sessions with SHA-256 token hashing
|
|
8
|
+
- **Secure Cookies** — HTTP-only, SameSite, secure flag, cross-subdomain support
|
|
9
|
+
- **Rate Limiting** — Configurable per-endpoint rate limits stored in database
|
|
10
|
+
- **Brute Force Protection** — Progressive lockout on failed sign-in attempts
|
|
11
|
+
- **Password Reset** — Token-based password reset flow with email integration
|
|
12
|
+
- **Password Validation** — Strength requirements and common password checks
|
|
13
|
+
- **React Hooks** — Client-side session management (`useSession`, `useSignIn`, `useSignOut`)
|
|
14
|
+
- **Framework Agnostic** — Works with Next.js, Hono, and other Node.js frameworks
|
|
19
15
|
|
|
20
16
|
## Installation
|
|
21
17
|
|
|
@@ -25,36 +21,34 @@ pnpm add @revealui/auth
|
|
|
25
21
|
|
|
26
22
|
## Usage
|
|
27
23
|
|
|
28
|
-
### Server-
|
|
24
|
+
### Server-Side
|
|
29
25
|
|
|
30
26
|
```typescript
|
|
31
|
-
import { getSession } from '@revealui/auth/server'
|
|
32
|
-
import { type NextRequest, NextResponse } from 'next/server'
|
|
27
|
+
import { getSession, signIn, signOut, createSession } from '@revealui/auth/server'
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (!session) {
|
|
38
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
39
|
-
}
|
|
29
|
+
// Validate session from request headers
|
|
30
|
+
const session = await getSession(request.headers)
|
|
40
31
|
|
|
41
|
-
|
|
42
|
-
}
|
|
32
|
+
// Sign in with email/password
|
|
33
|
+
const result = await signIn({ email, password })
|
|
34
|
+
|
|
35
|
+
// Sign out (invalidate session)
|
|
36
|
+
await signOut(sessionToken)
|
|
43
37
|
```
|
|
44
38
|
|
|
45
|
-
### Client-
|
|
39
|
+
### Client-Side (React)
|
|
46
40
|
|
|
47
41
|
```typescript
|
|
48
42
|
'use client'
|
|
49
43
|
import { useSession, useSignIn, useSignOut } from '@revealui/auth/react'
|
|
50
44
|
|
|
51
|
-
function
|
|
45
|
+
function AuthComponent() {
|
|
52
46
|
const { data: session, isLoading } = useSession()
|
|
53
47
|
const { signIn } = useSignIn()
|
|
54
48
|
const { signOut } = useSignOut()
|
|
55
49
|
|
|
56
50
|
if (isLoading) return <div>Loading...</div>
|
|
57
|
-
if (!session) return <
|
|
51
|
+
if (!session) return <button onClick={() => signIn({ email, password })}>Sign In</button>
|
|
58
52
|
|
|
59
53
|
return (
|
|
60
54
|
<div>
|
|
@@ -65,13 +59,43 @@ function MyComponent() {
|
|
|
65
59
|
}
|
|
66
60
|
```
|
|
67
61
|
|
|
68
|
-
##
|
|
62
|
+
## Exports
|
|
63
|
+
|
|
64
|
+
| Subpath | Contents |
|
|
65
|
+
|---------|----------|
|
|
66
|
+
| `@revealui/auth/server` | Server-side auth (session CRUD, sign in/out, rate limiting, brute force) |
|
|
67
|
+
| `@revealui/auth/client` | Client-side utilities |
|
|
68
|
+
| `@revealui/auth/react` | React hooks (`useSession`, `useSignIn`, `useSignOut`) |
|
|
69
|
+
|
|
70
|
+
## Security
|
|
71
|
+
|
|
72
|
+
- Passwords hashed with bcrypt
|
|
73
|
+
- Session tokens hashed with SHA-256 before storage
|
|
74
|
+
- HTTP-only cookies prevent XSS token theft
|
|
75
|
+
- SameSite cookie attribute prevents CSRF
|
|
76
|
+
- Rate limiting prevents abuse (configurable per endpoint)
|
|
77
|
+
- Brute force protection with progressive lockout
|
|
78
|
+
- Cookie domain supports cross-subdomain auth (e.g. `.revealui.com`)
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Build
|
|
84
|
+
pnpm build
|
|
85
|
+
|
|
86
|
+
# Type check
|
|
87
|
+
pnpm typecheck
|
|
88
|
+
|
|
89
|
+
# Run tests
|
|
90
|
+
pnpm test
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Related
|
|
69
94
|
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
- `POST /api/auth/sign-out` - Sign out
|
|
95
|
+
- [Core Package](../core/README.md) — CMS engine (uses auth for access control)
|
|
96
|
+
- [DB Package](../db/README.md) — Database schema (sessions, users, rate_limits tables)
|
|
97
|
+
- [Auth Guide](../../docs/AUTH.md) — Architecture, usage patterns, and security design
|
|
74
98
|
|
|
75
|
-
##
|
|
99
|
+
## License
|
|
76
100
|
|
|
77
|
-
|
|
101
|
+
MIT
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSignUp.d.ts","sourceRoot":"","sources":["../../src/react/useSignUp.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAiBvC,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"useSignUp.d.ts","sourceRoot":"","sources":["../../src/react/useSignUp.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAiBvC,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,IAAI,CAAA;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,IAAI,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC1F,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;CACpB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,SAAS,IAAI,eAAe,CAoD3C"}
|
package/dist/server/auth.d.ts
CHANGED
|
@@ -40,5 +40,7 @@ export declare function isSignupAllowed(email: string): boolean;
|
|
|
40
40
|
export declare function signUp(email: string, password: string, name: string, options?: {
|
|
41
41
|
userAgent?: string;
|
|
42
42
|
ipAddress?: string;
|
|
43
|
+
tosAcceptedAt?: Date;
|
|
44
|
+
tosVersion?: string;
|
|
43
45
|
}): Promise<SignUpResult>;
|
|
44
46
|
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAQH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAQ,MAAM,aAAa,CAAA;AAMnE;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAC1B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IACR,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,GACA,OAAO,CAAC,YAAY,CAAC,CAwHvB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAiBtD;AAED;;;;;;;;GAQG;AACH,wBAAsB,MAAM,CAC1B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;IACR,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,IAAI,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,GACA,OAAO,CAAC,YAAY,CAAC,CAkJvB"}
|
package/dist/server/auth.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Sign in and sign up functionality with password hashing.
|
|
5
5
|
*/
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
7
|
import { logger } from '@revealui/core/observability/logger';
|
|
7
8
|
import { getClient } from '@revealui/db/client';
|
|
8
9
|
import { users } from '@revealui/db/schema';
|
|
@@ -238,6 +239,14 @@ export async function signUp(email, password, name, options) {
|
|
|
238
239
|
error: 'Failed to process password',
|
|
239
240
|
};
|
|
240
241
|
}
|
|
242
|
+
// Generate email verification token.
|
|
243
|
+
// Store the SHA-256 hash in the DB; send the raw token in the email link.
|
|
244
|
+
// A DB breach cannot be used to verify arbitrary emails without the raw token.
|
|
245
|
+
const rawEmailVerificationToken = randomBytes(32).toString('hex');
|
|
246
|
+
const emailVerificationToken = createHash('sha256')
|
|
247
|
+
.update(rawEmailVerificationToken)
|
|
248
|
+
.digest('hex');
|
|
249
|
+
const emailVerificationTokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
|
241
250
|
// Create user
|
|
242
251
|
let user;
|
|
243
252
|
try {
|
|
@@ -248,6 +257,11 @@ export async function signUp(email, password, name, options) {
|
|
|
248
257
|
email,
|
|
249
258
|
name,
|
|
250
259
|
password: hashedPassword,
|
|
260
|
+
emailVerified: false,
|
|
261
|
+
emailVerificationToken,
|
|
262
|
+
emailVerificationTokenExpiresAt,
|
|
263
|
+
tosAcceptedAt: options?.tosAcceptedAt ?? null,
|
|
264
|
+
tosVersion: options?.tosVersion ?? null,
|
|
251
265
|
})
|
|
252
266
|
.returning();
|
|
253
267
|
user = result[0];
|
|
@@ -281,9 +295,12 @@ export async function signUp(email, password, name, options) {
|
|
|
281
295
|
error: 'Failed to create session',
|
|
282
296
|
};
|
|
283
297
|
}
|
|
298
|
+
// Return the raw (unhashed) token so the caller can include it in the
|
|
299
|
+
// verification email link. The DB holds only the hash.
|
|
300
|
+
const userWithRawToken = { ...user, emailVerificationToken: rawEmailVerificationToken };
|
|
284
301
|
return {
|
|
285
302
|
success: true,
|
|
286
|
-
user,
|
|
303
|
+
user: userWithRawToken,
|
|
287
304
|
sessionToken: token,
|
|
288
305
|
};
|
|
289
306
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"brute-force.d.ts","sourceRoot":"","sources":["../../src/server/brute-force.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAUH,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAqCD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,gBAAiC,GACxC,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"brute-force.d.ts","sourceRoot":"","sources":["../../src/server/brute-force.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAUH,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAqCD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,gBAAiC,GACxC,OAAO,CAAC,IAAI,CAAC,CA0Cf;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAItE;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,gBAAiC,GACxC,OAAO,CAAC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC,CA6C7E;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ1E"}
|
|
@@ -45,28 +45,37 @@ function getStorageKey(email) {
|
|
|
45
45
|
export async function recordFailedAttempt(email, config = DEFAULT_CONFIG) {
|
|
46
46
|
const storage = getStorage();
|
|
47
47
|
const storageKey = getStorageKey(email);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
48
|
+
const updater = (entryData) => {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const entry = deserializeEntry(entryData) || { count: 0, windowStart: now };
|
|
51
|
+
// Reset if lock expired
|
|
52
|
+
if (entry.lockUntil && entry.lockUntil < now) {
|
|
53
|
+
entry.count = 0;
|
|
54
|
+
entry.lockUntil = undefined;
|
|
55
|
+
entry.windowStart = now;
|
|
56
|
+
}
|
|
57
|
+
// Reset if window expired
|
|
58
|
+
if (now - entry.windowStart > config.windowMs) {
|
|
59
|
+
entry.count = 0;
|
|
60
|
+
entry.windowStart = now;
|
|
61
|
+
}
|
|
62
|
+
entry.count++;
|
|
63
|
+
// Lock account if threshold reached
|
|
64
|
+
if (entry.count >= config.maxAttempts) {
|
|
65
|
+
entry.lockUntil = now + config.lockDurationMs;
|
|
66
|
+
}
|
|
67
|
+
const ttlSeconds = Math.ceil(Math.max(config.windowMs, entry.lockUntil ? entry.lockUntil - now : config.windowMs) / 1000);
|
|
68
|
+
return { value: serializeEntry(entry), ttlSeconds };
|
|
69
|
+
};
|
|
70
|
+
if (storage.atomicUpdate) {
|
|
71
|
+
await storage.atomicUpdate(storageKey, updater);
|
|
61
72
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
else {
|
|
74
|
+
// Fallback for storage backends that don't support atomic updates
|
|
75
|
+
const existing = await storage.get(storageKey);
|
|
76
|
+
const { value, ttlSeconds } = updater(existing);
|
|
77
|
+
await storage.set(storageKey, value, ttlSeconds);
|
|
66
78
|
}
|
|
67
|
-
// Store with TTL (window duration or lock duration, whichever is longer)
|
|
68
|
-
const ttlSeconds = Math.ceil(Math.max(config.windowMs, entry.lockUntil ? entry.lockUntil - now : config.windowMs) / 1000);
|
|
69
|
-
await storage.set(storageKey, serializeEntry(entry), ttlSeconds);
|
|
70
79
|
}
|
|
71
80
|
/**
|
|
72
81
|
* Clears failed attempts for an email (on successful login)
|
package/dist/server/errors.d.ts
CHANGED
|
@@ -21,4 +21,8 @@ export declare class DatabaseError extends AuthError {
|
|
|
21
21
|
export declare class TokenError extends AuthError {
|
|
22
22
|
constructor(message?: string, statusCode?: number);
|
|
23
23
|
}
|
|
24
|
+
export declare class OAuthAccountConflictError extends AuthError {
|
|
25
|
+
email: string;
|
|
26
|
+
constructor(email: string);
|
|
27
|
+
}
|
|
24
28
|
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/server/errors.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,qBAAa,SAAU,SAAQ,KAAK;IAGzB,IAAI,EAAE,MAAM;IACZ,UAAU,EAAE,MAAM;gBAFzB,OAAO,EAAE,MAAM,EACR,IAAI,EAAE,MAAM,EACZ,UAAU,GAAE,MAAY;CAKlC;AAED,qBAAa,YAAa,SAAQ,SAAS;gBAC7B,OAAO,GAAE,MAAwB,EAAE,UAAU,GAAE,MAAY;CAIxE;AAED,qBAAa,mBAAoB,SAAQ,SAAS;gBACpC,OAAO,GAAE,MAAgC,EAAE,UAAU,GAAE,MAAY;CAIhF;AAED,qBAAa,aAAc,SAAQ,SAAS;IACnC,aAAa,CAAC,EAAE,KAAK,CAAA;gBAEhB,OAAO,GAAE,MAAyB,EAAE,aAAa,CAAC,EAAE,OAAO;CAOxE;AAED,qBAAa,UAAW,SAAQ,SAAS;gBAC3B,OAAO,GAAE,MAAsB,EAAE,UAAU,GAAE,MAAY;CAItE"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/server/errors.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,qBAAa,SAAU,SAAQ,KAAK;IAGzB,IAAI,EAAE,MAAM;IACZ,UAAU,EAAE,MAAM;gBAFzB,OAAO,EAAE,MAAM,EACR,IAAI,EAAE,MAAM,EACZ,UAAU,GAAE,MAAY;CAKlC;AAED,qBAAa,YAAa,SAAQ,SAAS;gBAC7B,OAAO,GAAE,MAAwB,EAAE,UAAU,GAAE,MAAY;CAIxE;AAED,qBAAa,mBAAoB,SAAQ,SAAS;gBACpC,OAAO,GAAE,MAAgC,EAAE,UAAU,GAAE,MAAY;CAIhF;AAED,qBAAa,aAAc,SAAQ,SAAS;IACnC,aAAa,CAAC,EAAE,KAAK,CAAA;gBAEhB,OAAO,GAAE,MAAyB,EAAE,aAAa,CAAC,EAAE,OAAO;CAOxE;AAED,qBAAa,UAAW,SAAQ,SAAS;gBAC3B,OAAO,GAAE,MAAsB,EAAE,UAAU,GAAE,MAAY;CAItE;AAED,qBAAa,yBAA0B,SAAQ,SAAS;IAC/C,KAAK,EAAE,MAAM,CAAA;gBAER,KAAK,EAAE,MAAM;CAS1B"}
|
package/dist/server/errors.js
CHANGED
|
@@ -41,3 +41,11 @@ export class TokenError extends AuthError {
|
|
|
41
41
|
this.name = 'TokenError';
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
export class OAuthAccountConflictError extends AuthError {
|
|
45
|
+
email;
|
|
46
|
+
constructor(email) {
|
|
47
|
+
super('An account with this email already exists. Sign in with your password or original provider.', 'OAUTH_ACCOUNT_CONFLICT', 409);
|
|
48
|
+
this.name = 'OAuthAccountConflictError';
|
|
49
|
+
this.email = email;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
export type { SignInResult, SignUpResult } from '../types.js';
|
|
8
8
|
export { isSignupAllowed, signIn, signUp } from './auth.js';
|
|
9
9
|
export { clearFailedAttempts, getFailedAttemptCount, isAccountLocked, recordFailedAttempt, } from './brute-force.js';
|
|
10
|
-
export { AuthError, AuthenticationError, DatabaseError, SessionError, TokenError, } from './errors.js';
|
|
10
|
+
export { AuthError, AuthenticationError, DatabaseError, OAuthAccountConflictError, SessionError, TokenError, } from './errors.js';
|
|
11
|
+
export { buildAuthUrl, exchangeCode, fetchProviderUser, generateOAuthState, type ProviderUser, upsertOAuthUser, verifyOAuthState, } from './oauth.js';
|
|
11
12
|
export type { PasswordResetResult, PasswordResetToken } from './password-reset.js';
|
|
12
13
|
export { generatePasswordResetToken, invalidatePasswordResetToken, resetPasswordWithToken, validatePasswordResetToken, } from './password-reset.js';
|
|
13
14
|
export { meetsMinimumPasswordRequirements, validatePasswordStrength, } from './password-validation.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAC3D,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,eAAe,EACf,mBAAmB,GACpB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,YAAY,EACZ,UAAU,GACX,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAClF,OAAO,EACL,0BAA0B,EAC1B,4BAA4B,EAC5B,sBAAsB,EACtB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,gCAAgC,EAChC,wBAAwB,GACzB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,cAAc,GACf,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,aAAa,EACb,qBAAqB,EACrB,aAAa,EACb,UAAU,GACX,MAAM,cAAc,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAC3D,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,eAAe,EACf,mBAAmB,GACpB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,yBAAyB,EACzB,YAAY,EACZ,UAAU,GACX,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,kBAAkB,EAClB,KAAK,YAAY,EACjB,eAAe,EACf,gBAAgB,GACjB,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAClF,OAAO,EACL,0BAA0B,EAC1B,4BAA4B,EAC5B,sBAAsB,EACtB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,gCAAgC,EAChC,wBAAwB,GACzB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,cAAc,GACf,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,aAAa,EACb,qBAAqB,EACrB,aAAa,EACb,UAAU,GACX,MAAM,cAAc,CAAA"}
|
package/dist/server/index.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { isSignupAllowed, signIn, signUp } from './auth.js';
|
|
8
8
|
export { clearFailedAttempts, getFailedAttemptCount, isAccountLocked, recordFailedAttempt, } from './brute-force.js';
|
|
9
|
-
export { AuthError, AuthenticationError, DatabaseError, SessionError, TokenError, } from './errors.js';
|
|
9
|
+
export { AuthError, AuthenticationError, DatabaseError, OAuthAccountConflictError, SessionError, TokenError, } from './errors.js';
|
|
10
|
+
export { buildAuthUrl, exchangeCode, fetchProviderUser, generateOAuthState, upsertOAuthUser, verifyOAuthState, } from './oauth.js';
|
|
10
11
|
export { generatePasswordResetToken, invalidatePasswordResetToken, resetPasswordWithToken, validatePasswordResetToken, } from './password-reset.js';
|
|
11
12
|
export { meetsMinimumPasswordRequirements, validatePasswordStrength, } from './password-validation.js';
|
|
12
13
|
export { checkRateLimit, getRateLimitStatus, resetRateLimit, } from './rate-limit.js';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Core — State Management + User Upsert
|
|
3
|
+
*
|
|
4
|
+
* CSRF state: signed cookie using HMAC-SHA256 over a base64url payload.
|
|
5
|
+
* Provider dispatch: routes to Google / GitHub / Vercel provider modules.
|
|
6
|
+
* User upsert: links OAuth identities to local users via oauth_accounts table.
|
|
7
|
+
*/
|
|
8
|
+
import type { User } from '../types.js';
|
|
9
|
+
export interface ProviderUser {
|
|
10
|
+
id: string;
|
|
11
|
+
email: string | null;
|
|
12
|
+
name: string;
|
|
13
|
+
avatarUrl: string | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generate a signed OAuth state token.
|
|
17
|
+
*
|
|
18
|
+
* State encodes provider + redirectTo + nonce as base64url JSON.
|
|
19
|
+
* Cookie value is `<state>.<hmac>` — the HMAC is over the state string
|
|
20
|
+
* using REVEALUI_SECRET, providing CSRF protection without a DB table.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateOAuthState(provider: string, redirectTo: string): {
|
|
23
|
+
state: string;
|
|
24
|
+
cookieValue: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Verify a signed OAuth state token from the callback.
|
|
28
|
+
*
|
|
29
|
+
* Returns the decoded provider + redirectTo if valid, null otherwise.
|
|
30
|
+
*/
|
|
31
|
+
export declare function verifyOAuthState(state: string | null | undefined, cookieValue: string | null | undefined): {
|
|
32
|
+
provider: string;
|
|
33
|
+
redirectTo: string;
|
|
34
|
+
} | null;
|
|
35
|
+
export declare function buildAuthUrl(provider: string, redirectUri: string, state: string): string;
|
|
36
|
+
export declare function exchangeCode(provider: string, code: string, redirectUri: string): Promise<string>;
|
|
37
|
+
export declare function fetchProviderUser(provider: string, accessToken: string): Promise<ProviderUser>;
|
|
38
|
+
/**
|
|
39
|
+
* Find or create a local user for the given OAuth identity.
|
|
40
|
+
*
|
|
41
|
+
* Flow:
|
|
42
|
+
* 1. Look up oauth_accounts by (provider, providerUserId) → get userId
|
|
43
|
+
* 2. If found: refresh metadata + return user
|
|
44
|
+
* 3. If not found: check users by email → link if match
|
|
45
|
+
* 4. If no match: create new user (role: 'admin', no password)
|
|
46
|
+
* 5. Insert oauth_accounts row
|
|
47
|
+
*/
|
|
48
|
+
export declare function upsertOAuthUser(provider: string, providerUser: ProviderUser): Promise<User>;
|
|
49
|
+
//# sourceMappingURL=oauth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/server/oauth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAMvC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB;AAMD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAaxC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACrC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA2CjD;AAwBD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CASzF;AAED,wBAAsB,YAAY,CAChC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,YAAY,CAAC,CAQvB;AAMD;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA4FjG"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Core — State Management + User Upsert
|
|
3
|
+
*
|
|
4
|
+
* CSRF state: signed cookie using HMAC-SHA256 over a base64url payload.
|
|
5
|
+
* Provider dispatch: routes to Google / GitHub / Vercel provider modules.
|
|
6
|
+
* User upsert: links OAuth identities to local users via oauth_accounts table.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { logger } from '@revealui/core/observability/logger';
|
|
10
|
+
import { getClient } from '@revealui/db/client';
|
|
11
|
+
import { oauthAccounts, users } from '@revealui/db/schema';
|
|
12
|
+
import { and, eq } from 'drizzle-orm';
|
|
13
|
+
import { OAuthAccountConflictError } from './errors.js';
|
|
14
|
+
import * as github from './providers/github.js';
|
|
15
|
+
import * as google from './providers/google.js';
|
|
16
|
+
import * as vercel from './providers/vercel.js';
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// CSRF State
|
|
19
|
+
// =============================================================================
|
|
20
|
+
/**
|
|
21
|
+
* Generate a signed OAuth state token.
|
|
22
|
+
*
|
|
23
|
+
* State encodes provider + redirectTo + nonce as base64url JSON.
|
|
24
|
+
* Cookie value is `<state>.<hmac>` — the HMAC is over the state string
|
|
25
|
+
* using REVEALUI_SECRET, providing CSRF protection without a DB table.
|
|
26
|
+
*/
|
|
27
|
+
export function generateOAuthState(provider, redirectTo) {
|
|
28
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
29
|
+
const payload = JSON.stringify({ provider, redirectTo, nonce });
|
|
30
|
+
const state = Buffer.from(payload).toString('base64url');
|
|
31
|
+
const secret = process.env.REVEALUI_SECRET;
|
|
32
|
+
if (!secret) {
|
|
33
|
+
throw new Error('REVEALUI_SECRET is required for OAuth state signing. ' +
|
|
34
|
+
'Set it in your environment variables.');
|
|
35
|
+
}
|
|
36
|
+
const hmac = crypto.createHmac('sha256', secret).update(state).digest('hex');
|
|
37
|
+
return { state, cookieValue: `${state}.${hmac}` };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verify a signed OAuth state token from the callback.
|
|
41
|
+
*
|
|
42
|
+
* Returns the decoded provider + redirectTo if valid, null otherwise.
|
|
43
|
+
*/
|
|
44
|
+
export function verifyOAuthState(state, cookieValue) {
|
|
45
|
+
if (!(state && cookieValue))
|
|
46
|
+
return null;
|
|
47
|
+
const dotIdx = cookieValue.lastIndexOf('.');
|
|
48
|
+
if (dotIdx === -1)
|
|
49
|
+
return null;
|
|
50
|
+
const storedState = cookieValue.substring(0, dotIdx);
|
|
51
|
+
const storedHmac = cookieValue.substring(dotIdx + 1);
|
|
52
|
+
// State from query param must match what's in the cookie
|
|
53
|
+
if (storedState !== state)
|
|
54
|
+
return null;
|
|
55
|
+
const secret = process.env.REVEALUI_SECRET;
|
|
56
|
+
if (!secret) {
|
|
57
|
+
throw new Error('REVEALUI_SECRET is required for OAuth state verification. ' +
|
|
58
|
+
'Set it in your environment variables.');
|
|
59
|
+
}
|
|
60
|
+
const expectedHmac = crypto.createHmac('sha256', secret).update(state).digest('hex');
|
|
61
|
+
// Both are hex-encoded SHA-256 HMACs — must be exactly 64 hex characters.
|
|
62
|
+
// Reject wrong-length inputs immediately; do NOT pad (padding enables forged matches
|
|
63
|
+
// where a short storedHmac is zero-padded to collide with the expected hash).
|
|
64
|
+
if (storedHmac.length !== 64 || expectedHmac.length !== 64)
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
if (!crypto.timingSafeEqual(Buffer.from(storedHmac, 'hex'), Buffer.from(expectedHmac, 'hex'))) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(Buffer.from(state, 'base64url').toString());
|
|
76
|
+
return { provider: parsed.provider, redirectTo: parsed.redirectTo };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// Provider Dispatch
|
|
84
|
+
// =============================================================================
|
|
85
|
+
const PROVIDERS = ['google', 'github', 'vercel'];
|
|
86
|
+
function isProvider(p) {
|
|
87
|
+
return PROVIDERS.includes(p);
|
|
88
|
+
}
|
|
89
|
+
function getClientId(provider) {
|
|
90
|
+
const map = {
|
|
91
|
+
google: process.env.GOOGLE_CLIENT_ID,
|
|
92
|
+
github: process.env.GITHUB_CLIENT_ID,
|
|
93
|
+
vercel: process.env.VERCEL_CLIENT_ID,
|
|
94
|
+
};
|
|
95
|
+
const id = map[provider];
|
|
96
|
+
if (!id)
|
|
97
|
+
throw new Error(`Missing client ID for provider: ${provider}`);
|
|
98
|
+
return id;
|
|
99
|
+
}
|
|
100
|
+
export function buildAuthUrl(provider, redirectUri, state) {
|
|
101
|
+
if (!isProvider(provider))
|
|
102
|
+
throw new Error(`Unknown provider: ${provider}`);
|
|
103
|
+
const clientId = getClientId(provider);
|
|
104
|
+
const builders = {
|
|
105
|
+
google: google.buildAuthUrl,
|
|
106
|
+
github: github.buildAuthUrl,
|
|
107
|
+
vercel: vercel.buildAuthUrl,
|
|
108
|
+
};
|
|
109
|
+
return builders[provider](clientId, redirectUri, state);
|
|
110
|
+
}
|
|
111
|
+
export async function exchangeCode(provider, code, redirectUri) {
|
|
112
|
+
if (!isProvider(provider))
|
|
113
|
+
throw new Error(`Unknown provider: ${provider}`);
|
|
114
|
+
const exchangers = {
|
|
115
|
+
google: google.exchangeCode,
|
|
116
|
+
github: github.exchangeCode,
|
|
117
|
+
vercel: vercel.exchangeCode,
|
|
118
|
+
};
|
|
119
|
+
return exchangers[provider](code, redirectUri);
|
|
120
|
+
}
|
|
121
|
+
export async function fetchProviderUser(provider, accessToken) {
|
|
122
|
+
if (!isProvider(provider))
|
|
123
|
+
throw new Error(`Unknown provider: ${provider}`);
|
|
124
|
+
const fetchers = {
|
|
125
|
+
google: google.fetchUser,
|
|
126
|
+
github: github.fetchUser,
|
|
127
|
+
vercel: vercel.fetchUser,
|
|
128
|
+
};
|
|
129
|
+
return fetchers[provider](accessToken);
|
|
130
|
+
}
|
|
131
|
+
// =============================================================================
|
|
132
|
+
// User Upsert
|
|
133
|
+
// =============================================================================
|
|
134
|
+
/**
|
|
135
|
+
* Find or create a local user for the given OAuth identity.
|
|
136
|
+
*
|
|
137
|
+
* Flow:
|
|
138
|
+
* 1. Look up oauth_accounts by (provider, providerUserId) → get userId
|
|
139
|
+
* 2. If found: refresh metadata + return user
|
|
140
|
+
* 3. If not found: check users by email → link if match
|
|
141
|
+
* 4. If no match: create new user (role: 'admin', no password)
|
|
142
|
+
* 5. Insert oauth_accounts row
|
|
143
|
+
*/
|
|
144
|
+
export async function upsertOAuthUser(provider, providerUser) {
|
|
145
|
+
const db = getClient();
|
|
146
|
+
// 1. Check for existing linked account
|
|
147
|
+
const [existingAccount] = await db
|
|
148
|
+
.select()
|
|
149
|
+
.from(oauthAccounts)
|
|
150
|
+
.where(and(eq(oauthAccounts.provider, provider), eq(oauthAccounts.providerUserId, providerUser.id)))
|
|
151
|
+
.limit(1);
|
|
152
|
+
if (existingAccount) {
|
|
153
|
+
// Refresh provider metadata (name/email/avatar may have changed)
|
|
154
|
+
await db
|
|
155
|
+
.update(oauthAccounts)
|
|
156
|
+
.set({
|
|
157
|
+
providerEmail: providerUser.email,
|
|
158
|
+
providerName: providerUser.name,
|
|
159
|
+
providerAvatarUrl: providerUser.avatarUrl,
|
|
160
|
+
updatedAt: new Date(),
|
|
161
|
+
})
|
|
162
|
+
.where(eq(oauthAccounts.id, existingAccount.id));
|
|
163
|
+
const [user] = await db
|
|
164
|
+
.select()
|
|
165
|
+
.from(users)
|
|
166
|
+
.where(eq(users.id, existingAccount.userId))
|
|
167
|
+
.limit(1);
|
|
168
|
+
if (!user) {
|
|
169
|
+
logger.error(`oauth_accounts row ${existingAccount.id} references missing user ${existingAccount.userId}`);
|
|
170
|
+
throw new Error('OAuth account references a deleted user');
|
|
171
|
+
}
|
|
172
|
+
return user;
|
|
173
|
+
}
|
|
174
|
+
// 2. Check for existing user by email — BLOCK auto-linking
|
|
175
|
+
// If an account with this email already exists but was not linked via OAuth,
|
|
176
|
+
// reject the login. Auto-linking is an account takeover vector: an attacker
|
|
177
|
+
// who controls a provider email instantly owns the existing account.
|
|
178
|
+
// Explicit linking (from an authenticated session) is a future feature.
|
|
179
|
+
let userId;
|
|
180
|
+
let isNewUser = false;
|
|
181
|
+
if (providerUser.email) {
|
|
182
|
+
const [existingUser] = await db
|
|
183
|
+
.select()
|
|
184
|
+
.from(users)
|
|
185
|
+
.where(eq(users.email, providerUser.email))
|
|
186
|
+
.limit(1);
|
|
187
|
+
if (existingUser) {
|
|
188
|
+
throw new OAuthAccountConflictError(providerUser.email);
|
|
189
|
+
}
|
|
190
|
+
isNewUser = true;
|
|
191
|
+
userId = crypto.randomUUID();
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
isNewUser = true;
|
|
195
|
+
userId = crypto.randomUUID();
|
|
196
|
+
}
|
|
197
|
+
// 3. Create user if none found
|
|
198
|
+
if (isNewUser) {
|
|
199
|
+
await db.insert(users).values({
|
|
200
|
+
id: userId,
|
|
201
|
+
name: providerUser.name,
|
|
202
|
+
email: providerUser.email,
|
|
203
|
+
avatarUrl: providerUser.avatarUrl,
|
|
204
|
+
password: null,
|
|
205
|
+
role: 'user',
|
|
206
|
+
status: 'active',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
// 4. Insert oauth_accounts link
|
|
210
|
+
await db.insert(oauthAccounts).values({
|
|
211
|
+
id: crypto.randomUUID(),
|
|
212
|
+
userId,
|
|
213
|
+
provider,
|
|
214
|
+
providerUserId: providerUser.id,
|
|
215
|
+
providerEmail: providerUser.email,
|
|
216
|
+
providerName: providerUser.name,
|
|
217
|
+
providerAvatarUrl: providerUser.avatarUrl,
|
|
218
|
+
});
|
|
219
|
+
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
220
|
+
if (!user)
|
|
221
|
+
throw new Error('Failed to fetch upserted OAuth user');
|
|
222
|
+
return user;
|
|
223
|
+
}
|