@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.
Files changed (47) hide show
  1. package/README.md +58 -34
  2. package/dist/react/useSignUp.d.ts +1 -0
  3. package/dist/react/useSignUp.d.ts.map +1 -1
  4. package/dist/server/auth.d.ts +2 -0
  5. package/dist/server/auth.d.ts.map +1 -1
  6. package/dist/server/auth.js +18 -1
  7. package/dist/server/brute-force.d.ts.map +1 -1
  8. package/dist/server/brute-force.js +29 -20
  9. package/dist/server/errors.d.ts +4 -0
  10. package/dist/server/errors.d.ts.map +1 -1
  11. package/dist/server/errors.js +8 -0
  12. package/dist/server/index.d.ts +2 -1
  13. package/dist/server/index.d.ts.map +1 -1
  14. package/dist/server/index.js +2 -1
  15. package/dist/server/oauth.d.ts +49 -0
  16. package/dist/server/oauth.d.ts.map +1 -0
  17. package/dist/server/oauth.js +223 -0
  18. package/dist/server/password-reset.d.ts +17 -6
  19. package/dist/server/password-reset.d.ts.map +1 -1
  20. package/dist/server/password-reset.js +72 -46
  21. package/dist/server/providers/github.d.ts +14 -0
  22. package/dist/server/providers/github.d.ts.map +1 -0
  23. package/dist/server/providers/github.js +73 -0
  24. package/dist/server/providers/google.d.ts +11 -0
  25. package/dist/server/providers/google.d.ts.map +1 -0
  26. package/dist/server/providers/google.js +53 -0
  27. package/dist/server/providers/vercel.d.ts +11 -0
  28. package/dist/server/providers/vercel.d.ts.map +1 -0
  29. package/dist/server/providers/vercel.js +47 -0
  30. package/dist/server/rate-limit.js +11 -11
  31. package/dist/server/session.js +1 -1
  32. package/dist/server/storage/database.d.ts +9 -0
  33. package/dist/server/storage/database.d.ts.map +1 -1
  34. package/dist/server/storage/database.js +30 -0
  35. package/dist/server/storage/in-memory.d.ts +4 -0
  36. package/dist/server/storage/in-memory.d.ts.map +1 -1
  37. package/dist/server/storage/in-memory.js +10 -0
  38. package/dist/server/storage/interface.d.ts +10 -0
  39. package/dist/server/storage/interface.d.ts.map +1 -1
  40. package/dist/types.d.ts +3 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/utils/database.d.ts.map +1 -1
  43. package/dist/utils/database.js +3 -0
  44. package/dist/utils/token.d.ts +9 -1
  45. package/dist/utils/token.d.ts.map +1 -1
  46. package/dist/utils/token.js +9 -1
  47. package/package.json +5 -5
package/README.md CHANGED
@@ -1,21 +1,17 @@
1
1
  # @revealui/auth
2
2
 
3
- **Status:** 🟡 Active Development | ⚠️ NOT Production Ready
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
- - Database-backed sessions (PostgreSQL/NeonDB)
14
- - Secure token handling (SHA-256 hashing, HTTP-only cookies)
15
- - CSRF protection (SameSite cookies)
16
- - Type-safe (TypeScript)
17
- - Framework agnostic (Next.js, TanStack Start)
18
- - React hooks for client-side usage
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-side (Next.js API Routes)
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
- export async function GET(request: NextRequest) {
35
- const session = await getSession(request.headers)
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
- return NextResponse.json({ user: session.user })
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-side (React Hooks)
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 MyComponent() {
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 <div>Not signed in</div>
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
- ## API Routes
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
- - `GET /api/auth/session` - Get current session
71
- - `POST /api/auth/sign-in` - Sign in with email/password
72
- - `POST /api/auth/sign-up` - Create new account
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
- ## Documentation
99
+ ## License
76
100
 
77
- See [Auth System Design](../../docs/reference/auth/AUTH_SYSTEM_DESIGN.md) for comprehensive documentation.
101
+ MIT
@@ -8,6 +8,7 @@ export interface SignUpInput {
8
8
  email: string;
9
9
  password: string;
10
10
  name: string;
11
+ tosAccepted: true;
11
12
  }
12
13
  export interface UseSignUpResult {
13
14
  signUp: (input: SignUpInput) => Promise<{
@@ -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;CACb;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"}
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"}
@@ -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;AAOH,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;CACnB,GACA,OAAO,CAAC,YAAY,CAAC,CAgIvB"}
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"}
@@ -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,CAiCf;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"}
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 now = Date.now();
49
- const entryData = await storage.get(storageKey);
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;
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
- entry.count++;
63
- // Lock account if threshold reached
64
- if (entry.count >= config.maxAttempts) {
65
- entry.lockUntil = now + config.lockDurationMs;
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)
@@ -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"}
@@ -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
+ }
@@ -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"}
@@ -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
+ }