@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
@@ -12,6 +12,7 @@ export interface PasswordResetResult {
12
12
  success: boolean;
13
13
  error?: string;
14
14
  token?: string;
15
+ tokenId?: string;
15
16
  }
16
17
  /**
17
18
  * Generates a password reset token for a user
@@ -23,22 +24,32 @@ export declare function generatePasswordResetToken(email: string): Promise<Passw
23
24
  /**
24
25
  * Validates a password reset token
25
26
  *
26
- * @param token - Reset token (plain text)
27
+ * Uses O(1) lookup by token ID, then verifies the token hash with timingSafeEqual
28
+ * against the single matching row.
29
+ *
30
+ * @param tokenId - Token row ID (from the reset URL)
31
+ * @param token - Reset token (plain text, from the reset URL)
27
32
  * @returns User ID if valid, null otherwise
28
33
  */
29
- export declare function validatePasswordResetToken(token: string): Promise<string | null>;
34
+ export declare function validatePasswordResetToken(tokenId: string, token: string): Promise<string | null>;
30
35
  /**
31
36
  * Resets password using a token
32
37
  *
33
- * @param token - Reset token (plain text)
38
+ * Uses O(1) lookup by token ID, then verifies the token hash.
39
+ *
40
+ * @param tokenId - Token row ID (from the reset URL)
41
+ * @param token - Reset token (plain text, from the reset URL)
34
42
  * @param newPassword - New password
35
43
  * @returns Success result
36
44
  */
37
- export declare function resetPasswordWithToken(token: string, newPassword: string): Promise<PasswordResetResult>;
45
+ export declare function resetPasswordWithToken(tokenId: string, token: string, newPassword: string): Promise<PasswordResetResult>;
38
46
  /**
39
47
  * Invalidates a password reset token
40
48
  *
41
- * @param token - Reset token (plain text)
49
+ * Uses O(1) lookup by token ID, then verifies the token hash before marking as used.
50
+ *
51
+ * @param tokenId - Token row ID (from the reset URL)
52
+ * @param token - Reset token (plain text, from the reset URL)
42
53
  */
43
- export declare function invalidatePasswordResetToken(token: string): Promise<void>;
54
+ export declare function invalidatePasswordResetToken(tokenId: string, token: string): Promise<void>;
44
55
  //# sourceMappingURL=password-reset.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"password-reset.d.ts","sourceRoot":"","sources":["../../src/server/password-reset.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAuBD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA4D5F;AAED;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAgCtF;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CA6D9B;AAED;;;;GAIG;AACH,wBAAsB,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0B/E"}
1
+ {"version":3,"file":"password-reset.d.ts","sourceRoot":"","sources":["../../src/server/password-reset.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAuBD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqE5F;AAED;;;;;;;;;GASG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqCxB;AAED;;;;;;;;;GASG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CA4E9B;AAED;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqChG"}
@@ -7,10 +7,10 @@
7
7
  import crypto from 'node:crypto';
8
8
  import { logger } from '@revealui/core/observability/logger';
9
9
  import { getClient } from '@revealui/db/client';
10
- import { passwordResetTokens, users } from '@revealui/db/schema';
10
+ import { passwordResetTokens, sessions, users } from '@revealui/db/schema';
11
11
  import bcrypt from 'bcryptjs';
12
12
  import { and, eq, gt, isNull } from 'drizzle-orm';
13
- const TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
13
+ const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
14
14
  /**
15
15
  * Hash a token using HMAC-SHA256 with a per-token salt.
16
16
  * The salt is stored in the DB alongside the hash; this defeats rainbow
@@ -46,6 +46,13 @@ export async function generatePasswordResetToken(email) {
46
46
  token: crypto.randomBytes(32).toString('hex'),
47
47
  };
48
48
  }
49
+ // Invalidate any existing unused reset tokens for this user before creating a new one.
50
+ // This limits active tokens to one per user, preventing table accumulation that would
51
+ // slow the time-bounded full-table scan in validatePasswordResetToken.
52
+ await db
53
+ .update(passwordResetTokens)
54
+ .set({ usedAt: new Date() })
55
+ .where(and(eq(passwordResetTokens.userId, user.id), isNull(passwordResetTokens.usedAt)));
49
56
  // Generate secure token with per-token salt
50
57
  const token = crypto.randomBytes(32).toString('hex');
51
58
  const tokenSalt = generateSalt();
@@ -63,6 +70,7 @@ export async function generatePasswordResetToken(email) {
63
70
  return {
64
71
  success: true,
65
72
  token,
73
+ tokenId: id,
66
74
  };
67
75
  }
68
76
  catch (error) {
@@ -86,29 +94,31 @@ export async function generatePasswordResetToken(email) {
86
94
  /**
87
95
  * Validates a password reset token
88
96
  *
89
- * @param token - Reset token (plain text)
97
+ * Uses O(1) lookup by token ID, then verifies the token hash with timingSafeEqual
98
+ * against the single matching row.
99
+ *
100
+ * @param tokenId - Token row ID (from the reset URL)
101
+ * @param token - Reset token (plain text, from the reset URL)
90
102
  * @returns User ID if valid, null otherwise
91
103
  */
92
- export async function validatePasswordResetToken(token) {
104
+ export async function validatePasswordResetToken(tokenId, token) {
93
105
  try {
94
106
  const db = getClient();
95
- // We cannot look up by hash directly without the salt.
96
- // Strategy: find unexpired unused tokens for any user, then verify via HMAC.
97
- // To avoid full-table scans we include a time filter. In practice, there are
98
- // very few active reset tokens at any given moment.
99
- //
100
- // A common alternative is to encode the token ID in the reset URL (e.g.,
101
- // /reset?id=<uuid>&token=<token>), but this requires a URL format change.
102
- // For now we do a time-bounded scan — acceptable at low token volume.
103
- const candidates = await db
107
+ // O(1) lookup by primary key, filtered to unexpired and unused tokens
108
+ const [entry] = await db
104
109
  .select()
105
110
  .from(passwordResetTokens)
106
- .where(and(gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)));
107
- for (const entry of candidates) {
108
- const expectedHash = hashToken(token, entry.tokenSalt);
109
- if (expectedHash === entry.tokenHash) {
110
- return entry.userId;
111
- }
111
+ .where(and(eq(passwordResetTokens.id, tokenId), gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)))
112
+ .limit(1);
113
+ if (!entry) {
114
+ return null;
115
+ }
116
+ // Verify the token hash using timing-safe comparison
117
+ const expectedHash = hashToken(token, entry.tokenSalt);
118
+ const expectedBuf = Buffer.from(expectedHash);
119
+ const actualBuf = Buffer.from(entry.tokenHash);
120
+ if (expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf)) {
121
+ return entry.userId;
112
122
  }
113
123
  return null;
114
124
  }
@@ -120,32 +130,38 @@ export async function validatePasswordResetToken(token) {
120
130
  /**
121
131
  * Resets password using a token
122
132
  *
123
- * @param token - Reset token (plain text)
133
+ * Uses O(1) lookup by token ID, then verifies the token hash.
134
+ *
135
+ * @param tokenId - Token row ID (from the reset URL)
136
+ * @param token - Reset token (plain text, from the reset URL)
124
137
  * @param newPassword - New password
125
138
  * @returns Success result
126
139
  */
127
- export async function resetPasswordWithToken(token, newPassword) {
140
+ export async function resetPasswordWithToken(tokenId, token, newPassword) {
128
141
  try {
129
142
  const db = getClient();
130
- // Find valid token by HMAC verification (same time-bounded scan as validatePasswordResetToken)
131
- const candidates = await db
143
+ // O(1) lookup by primary key, filtered to unexpired and unused tokens
144
+ const [entry] = await db
132
145
  .select()
133
146
  .from(passwordResetTokens)
134
- .where(and(gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)));
135
- let entry;
136
- for (const candidate of candidates) {
137
- const expectedHash = hashToken(token, candidate.tokenSalt);
138
- if (expectedHash === candidate.tokenHash) {
139
- entry = candidate;
140
- break;
141
- }
142
- }
147
+ .where(and(eq(passwordResetTokens.id, tokenId), gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)))
148
+ .limit(1);
143
149
  if (!entry) {
144
150
  return {
145
151
  success: false,
146
152
  error: 'Invalid or expired reset token',
147
153
  };
148
154
  }
155
+ // Verify the token hash using timing-safe comparison
156
+ const expectedHash = hashToken(token, entry.tokenSalt);
157
+ const expectedBuf = Buffer.from(expectedHash);
158
+ const actualBuf = Buffer.from(entry.tokenHash);
159
+ if (!(expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf))) {
160
+ return {
161
+ success: false,
162
+ error: 'Invalid or expired reset token',
163
+ };
164
+ }
149
165
  // Validate password strength
150
166
  const { validatePasswordStrength } = await import('./password-validation.js');
151
167
  const passwordValidation = validatePasswordStrength(newPassword);
@@ -159,6 +175,9 @@ export async function resetPasswordWithToken(token, newPassword) {
159
175
  const password = await bcrypt.hash(newPassword, 12);
160
176
  // Update user password
161
177
  await db.update(users).set({ password }).where(eq(users.id, entry.userId));
178
+ // Invalidate all existing sessions for this user so any attacker who had
179
+ // a compromised session can no longer use it after the password change.
180
+ await db.delete(sessions).where(eq(sessions.userId, entry.userId));
162
181
  // Mark token as used (single-use enforcement)
163
182
  await db
164
183
  .update(passwordResetTokens)
@@ -179,25 +198,32 @@ export async function resetPasswordWithToken(token, newPassword) {
179
198
  /**
180
199
  * Invalidates a password reset token
181
200
  *
182
- * @param token - Reset token (plain text)
201
+ * Uses O(1) lookup by token ID, then verifies the token hash before marking as used.
202
+ *
203
+ * @param tokenId - Token row ID (from the reset URL)
204
+ * @param token - Reset token (plain text, from the reset URL)
183
205
  */
184
- export async function invalidatePasswordResetToken(token) {
206
+ export async function invalidatePasswordResetToken(tokenId, token) {
185
207
  try {
186
208
  const db = getClient();
187
- // Find the token entry by HMAC verification (time-bounded scan)
188
- const candidates = await db
209
+ // O(1) lookup by primary key
210
+ const [entry] = await db
189
211
  .select()
190
212
  .from(passwordResetTokens)
191
- .where(and(gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)));
192
- for (const candidate of candidates) {
193
- const expectedHash = hashToken(token, candidate.tokenSalt);
194
- if (expectedHash === candidate.tokenHash) {
195
- await db
196
- .update(passwordResetTokens)
197
- .set({ usedAt: new Date() })
198
- .where(eq(passwordResetTokens.id, candidate.id));
199
- break;
200
- }
213
+ .where(and(eq(passwordResetTokens.id, tokenId), gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)))
214
+ .limit(1);
215
+ if (!entry) {
216
+ return;
217
+ }
218
+ // Verify the token hash before invalidating
219
+ const expectedHash = hashToken(token, entry.tokenSalt);
220
+ const expectedBuf = Buffer.from(expectedHash);
221
+ const actualBuf = Buffer.from(entry.tokenHash);
222
+ if (expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf)) {
223
+ await db
224
+ .update(passwordResetTokens)
225
+ .set({ usedAt: new Date() })
226
+ .where(eq(passwordResetTokens.id, entry.id));
201
227
  }
202
228
  }
203
229
  catch (error) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * GitHub OAuth Provider
3
+ *
4
+ * Uses native fetch — no additional npm dependencies.
5
+ * Scopes: read:user user:email
6
+ *
7
+ * Note: GitHub may return null email if user has set it private.
8
+ * In that case we fetch from /user/emails and pick the primary verified one.
9
+ */
10
+ import type { ProviderUser } from '../oauth.js';
11
+ export declare function buildAuthUrl(clientId: string, redirectUri: string, state: string): string;
12
+ export declare function exchangeCode(code: string, redirectUri: string): Promise<string>;
13
+ export declare function fetchUser(accessToken: string): Promise<ProviderUser>;
14
+ //# sourceMappingURL=github.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../../../src/server/providers/github.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAOzF;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA2C1E"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * GitHub OAuth Provider
3
+ *
4
+ * Uses native fetch — no additional npm dependencies.
5
+ * Scopes: read:user user:email
6
+ *
7
+ * Note: GitHub may return null email if user has set it private.
8
+ * In that case we fetch from /user/emails and pick the primary verified one.
9
+ */
10
+ export function buildAuthUrl(clientId, redirectUri, state) {
11
+ const url = new URL('https://github.com/login/oauth/authorize');
12
+ url.searchParams.set('client_id', clientId);
13
+ url.searchParams.set('redirect_uri', redirectUri);
14
+ url.searchParams.set('scope', 'read:user user:email');
15
+ url.searchParams.set('state', state);
16
+ return url.toString();
17
+ }
18
+ export async function exchangeCode(code, redirectUri) {
19
+ const response = await fetch('https://github.com/login/oauth/access_token', {
20
+ method: 'POST',
21
+ headers: {
22
+ 'Content-Type': 'application/x-www-form-urlencoded',
23
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
24
+ Accept: 'application/json',
25
+ },
26
+ body: new URLSearchParams({
27
+ code,
28
+ client_id: process.env.GITHUB_CLIENT_ID ?? '',
29
+ client_secret: process.env.GITHUB_CLIENT_SECRET ?? '',
30
+ redirect_uri: redirectUri,
31
+ }),
32
+ });
33
+ if (!response.ok) {
34
+ throw new Error(`GitHub token exchange failed: ${response.status}`);
35
+ }
36
+ const data = (await response.json());
37
+ if (data.error) {
38
+ throw new Error(`GitHub token exchange error: ${data.error}`);
39
+ }
40
+ if (!data.access_token || typeof data.access_token !== 'string') {
41
+ throw new Error('GitHub token exchange returned no access_token');
42
+ }
43
+ return data.access_token;
44
+ }
45
+ export async function fetchUser(accessToken) {
46
+ const headers = {
47
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
48
+ Authorization: `Bearer ${accessToken}`,
49
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
50
+ Accept: 'application/vnd.github+json',
51
+ };
52
+ const userResponse = await fetch('https://api.github.com/user', { headers });
53
+ if (!userResponse.ok) {
54
+ throw new Error(`GitHub user fetch failed: ${userResponse.status}`);
55
+ }
56
+ const user = (await userResponse.json());
57
+ let email = user.email ?? null;
58
+ // Fetch emails if not public
59
+ if (!email) {
60
+ const emailsResponse = await fetch('https://api.github.com/user/emails', { headers });
61
+ if (emailsResponse.ok) {
62
+ const emails = (await emailsResponse.json());
63
+ const primary = emails.find((e) => e.primary && e.verified);
64
+ email = primary?.email ?? null;
65
+ }
66
+ }
67
+ return {
68
+ id: String(user.id),
69
+ email,
70
+ name: user.name ?? user.login,
71
+ avatarUrl: user.avatar_url ?? null,
72
+ };
73
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Google OAuth 2.0 Provider
3
+ *
4
+ * Uses native fetch — no additional npm dependencies.
5
+ * Scopes: openid email profile
6
+ */
7
+ import type { ProviderUser } from '../oauth.js';
8
+ export declare function buildAuthUrl(clientId: string, redirectUri: string, state: string): string;
9
+ export declare function exchangeCode(code: string, redirectUri: string): Promise<string>;
10
+ export declare function fetchUser(accessToken: string): Promise<ProviderUser>;
11
+ //# sourceMappingURL=google.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google.d.ts","sourceRoot":"","sources":["../../../src/server/providers/google.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CASzF;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAsBrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAuB1E"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Google OAuth 2.0 Provider
3
+ *
4
+ * Uses native fetch — no additional npm dependencies.
5
+ * Scopes: openid email profile
6
+ */
7
+ export function buildAuthUrl(clientId, redirectUri, state) {
8
+ const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
9
+ url.searchParams.set('client_id', clientId);
10
+ url.searchParams.set('redirect_uri', redirectUri);
11
+ url.searchParams.set('response_type', 'code');
12
+ url.searchParams.set('scope', 'openid email profile');
13
+ url.searchParams.set('state', state);
14
+ url.searchParams.set('access_type', 'online');
15
+ return url.toString();
16
+ }
17
+ export async function exchangeCode(code, redirectUri) {
18
+ const response = await fetch('https://oauth2.googleapis.com/token', {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
21
+ body: new URLSearchParams({
22
+ code,
23
+ client_id: process.env.GOOGLE_CLIENT_ID ?? '',
24
+ client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '',
25
+ redirect_uri: redirectUri,
26
+ grant_type: 'authorization_code',
27
+ }),
28
+ });
29
+ if (!response.ok) {
30
+ throw new Error(`Google token exchange failed: ${response.status}`);
31
+ }
32
+ const data = (await response.json());
33
+ if (!data.access_token || typeof data.access_token !== 'string') {
34
+ throw new Error('Google token exchange returned no access_token');
35
+ }
36
+ return data.access_token;
37
+ }
38
+ export async function fetchUser(accessToken) {
39
+ const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
40
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
41
+ headers: { Authorization: `Bearer ${accessToken}` },
42
+ });
43
+ if (!response.ok) {
44
+ throw new Error(`Google userinfo fetch failed: ${response.status}`);
45
+ }
46
+ const data = (await response.json());
47
+ return {
48
+ id: data.sub,
49
+ email: data.email ?? null,
50
+ name: data.name ?? 'Google User',
51
+ avatarUrl: data.picture ?? null,
52
+ };
53
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Vercel OAuth Provider
3
+ *
4
+ * Uses native fetch — no additional npm dependencies.
5
+ * No scopes required — Vercel uses full access by default.
6
+ */
7
+ import type { ProviderUser } from '../oauth.js';
8
+ export declare function buildAuthUrl(clientId: string, redirectUri: string, state: string): string;
9
+ export declare function exchangeCode(code: string, redirectUri: string): Promise<string>;
10
+ export declare function fetchUser(accessToken: string): Promise<ProviderUser>;
11
+ //# sourceMappingURL=vercel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vercel.d.ts","sourceRoot":"","sources":["../../../src/server/providers/vercel.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMzF;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA2B1E"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Vercel OAuth Provider
3
+ *
4
+ * Uses native fetch — no additional npm dependencies.
5
+ * No scopes required — Vercel uses full access by default.
6
+ */
7
+ export function buildAuthUrl(clientId, redirectUri, state) {
8
+ const url = new URL('https://vercel.com/oauth/authorize');
9
+ url.searchParams.set('client_id', clientId);
10
+ url.searchParams.set('redirect_uri', redirectUri);
11
+ url.searchParams.set('state', state);
12
+ return url.toString();
13
+ }
14
+ export async function exchangeCode(code, redirectUri) {
15
+ const response = await fetch('https://api.vercel.com/v2/oauth/access_token', {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
18
+ body: new URLSearchParams({
19
+ code,
20
+ client_id: process.env.VERCEL_CLIENT_ID ?? '',
21
+ client_secret: process.env.VERCEL_CLIENT_SECRET ?? '',
22
+ redirect_uri: redirectUri,
23
+ }),
24
+ });
25
+ if (!response.ok) {
26
+ throw new Error(`Vercel token exchange failed: ${response.status}`);
27
+ }
28
+ const data = (await response.json());
29
+ return data.access_token;
30
+ }
31
+ export async function fetchUser(accessToken) {
32
+ const response = await fetch('https://api.vercel.com/v2/user', {
33
+ // biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
34
+ headers: { Authorization: `Bearer ${accessToken}` },
35
+ });
36
+ if (!response.ok) {
37
+ throw new Error(`Vercel user fetch failed: ${response.status}`);
38
+ }
39
+ const data = (await response.json());
40
+ const u = data.user;
41
+ return {
42
+ id: u.id,
43
+ email: u.email,
44
+ name: u.name ?? u.username ?? 'Vercel User',
45
+ avatarUrl: u.avatar ? `https://avatar.vercel.sh/${u.avatar}` : null,
46
+ };
47
+ }
@@ -62,17 +62,17 @@ export async function checkRateLimit(key, config = DEFAULT_CONFIG) {
62
62
  };
63
63
  // Check if blocked
64
64
  if (config.blockDurationMs && currentEntry.count >= config.maxAttempts) {
65
- const blockUntil = currentEntry.resetAt + (config.blockDurationMs - config.windowMs);
66
- if (now < blockUntil) {
67
- return {
68
- allowed: false,
69
- remaining: 0,
70
- resetAt: blockUntil,
71
- };
72
- }
73
- // Block expired, reset
74
- currentEntry.count = 0;
75
- currentEntry.resetAt = now + config.windowMs;
65
+ const blockUntil = now + config.blockDurationMs;
66
+ // Extend the storage TTL so the entry outlives the block period.
67
+ // Without this, the entry expires at resetAt (end of the rate-limit window)
68
+ // before the block expires, letting the user back in prematurely.
69
+ const blockTtlSeconds = Math.ceil(config.blockDurationMs / 1000);
70
+ await storage.set(storageKey, serializeEntry(currentEntry), blockTtlSeconds);
71
+ return {
72
+ allowed: false,
73
+ remaining: 0,
74
+ resetAt: blockUntil,
75
+ };
76
76
  }
77
77
  // Check if within window
78
78
  if (currentEntry.count >= config.maxAttempts) {
@@ -235,7 +235,7 @@ function extractSessionToken(cookieHeader) {
235
235
  if (!sessionCookie) {
236
236
  return null;
237
237
  }
238
- return sessionCookie.substring('revealui-session='.length);
238
+ return decodeURIComponent(sessionCookie.substring('revealui-session='.length));
239
239
  }
240
240
  /**
241
241
  * Generate a secure random session token
@@ -13,5 +13,14 @@ export declare class DatabaseStorage implements Storage {
13
13
  del(key: string): Promise<void>;
14
14
  incr(key: string): Promise<number>;
15
15
  exists(key: string): Promise<boolean>;
16
+ /**
17
+ * Atomically read and update a value using a database transaction.
18
+ * Falls back to non-atomic get-then-set if transactions are unavailable
19
+ * (e.g., Neon HTTP mode in environments that don't support advisory locks).
20
+ */
21
+ atomicUpdate(key: string, updater: (existing: string | null) => {
22
+ value: string;
23
+ ttlSeconds: number;
24
+ }): Promise<void>;
16
25
  }
17
26
  //# sourceMappingURL=database.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../../src/server/storage/database.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAE7C,qBAAa,eAAgB,YAAW,OAAO;IAC7C,OAAO,CAAC,EAAE,CAAU;gBAER,gBAAgB,CAAC,EAAE,MAAM;IAkB/B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAcxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAOlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAI5C"}
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../../src/server/storage/database.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAE7C,qBAAa,eAAgB,YAAW,OAAO;IAC7C,OAAO,CAAC,EAAE,CAAU;gBAER,gBAAgB,CAAC,EAAE,MAAM;IAkB/B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAcxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAOlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK3C;;;;OAIG;IACG,YAAY,CAChB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAC1E,OAAO,CAAC,IAAI,CAAC;CAwBjB"}
@@ -69,4 +69,34 @@ export class DatabaseStorage {
69
69
  const result = await this.get(key);
70
70
  return result !== null;
71
71
  }
72
+ /**
73
+ * Atomically read and update a value using a database transaction.
74
+ * Falls back to non-atomic get-then-set if transactions are unavailable
75
+ * (e.g., Neon HTTP mode in environments that don't support advisory locks).
76
+ */
77
+ async atomicUpdate(key, updater) {
78
+ try {
79
+ await this.db.transaction(async (tx) => {
80
+ const now = new Date();
81
+ const result = await tx.query.rateLimits.findFirst({
82
+ where: and(eq(rateLimits.key, key), gte(rateLimits.resetAt, now)),
83
+ });
84
+ const { value, ttlSeconds } = updater(result?.value ?? null);
85
+ const resetAt = new Date(Date.now() + ttlSeconds * 1000);
86
+ await tx
87
+ .insert(rateLimits)
88
+ .values({ key, value, resetAt })
89
+ .onConflictDoUpdate({
90
+ target: rateLimits.key,
91
+ set: { value, resetAt, updatedAt: new Date() },
92
+ });
93
+ });
94
+ }
95
+ catch {
96
+ // Transaction not supported (e.g., Neon HTTP serverless) — fall back to non-atomic
97
+ const existing = await this.get(key);
98
+ const { value, ttlSeconds } = updater(existing);
99
+ await this.set(key, value, ttlSeconds);
100
+ }
101
+ }
72
102
  }
@@ -12,6 +12,10 @@ export declare class InMemoryStorage implements Storage {
12
12
  del(key: string): Promise<void>;
13
13
  incr(key: string): Promise<number>;
14
14
  exists(key: string): Promise<boolean>;
15
+ atomicUpdate(key: string, updater: (existing: string | null) => {
16
+ value: string;
17
+ ttlSeconds: number;
18
+ }): Promise<void>;
15
19
  /**
16
20
  * Clean up expired entries (should be called periodically)
17
21
  */
@@ -1 +1 @@
1
- {"version":3,"file":"in-memory.d.ts","sourceRoot":"","sources":["../../../src/server/storage/in-memory.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAO7C,qBAAa,eAAgB,YAAW,OAAO;IAC7C,OAAO,CAAC,KAAK,CAAuC;IAG9C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAiBxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAgB3C;;OAEG;IACH,OAAO,IAAI,IAAI;IASf;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd"}
1
+ {"version":3,"file":"in-memory.d.ts","sourceRoot":"","sources":["../../../src/server/storage/in-memory.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAO7C,qBAAa,eAAgB,YAAW,OAAO;IAC7C,OAAO,CAAC,KAAK,CAAuC;IAG9C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAiBxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAiBrC,YAAY,CAChB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAC1E,OAAO,CAAC,IAAI,CAAC;IAUhB;;OAEG;IACH,OAAO,IAAI,IAAI;IASf;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd"}
@@ -50,6 +50,16 @@ export class InMemoryStorage {
50
50
  }
51
51
  return true;
52
52
  }
53
+ // eslint-disable-next-line @typescript-eslint/require-await
54
+ async atomicUpdate(key, updater) {
55
+ // Read synchronously from the Map to avoid yielding to the event loop between
56
+ // read and write (JavaScript is single-threaded; no I/O = no interleaving).
57
+ const now = Date.now();
58
+ const entry = this.store.get(key);
59
+ const existing = entry && (!entry.expiresAt || entry.expiresAt >= now) ? entry.value : null;
60
+ const { value, ttlSeconds } = updater(existing);
61
+ this.store.set(key, { value, expiresAt: now + ttlSeconds * 1000 });
62
+ }
53
63
  /**
54
64
  * Clean up expired entries (should be called periodically)
55
65
  */
@@ -32,5 +32,15 @@ export interface Storage {
32
32
  * Set multiple key-value pairs
33
33
  */
34
34
  mset?(pairs: Array<[string, string]>, ttlSeconds?: number): Promise<void>;
35
+ /**
36
+ * Atomically read and update a value.
37
+ * The updater receives the current value (or null) and returns the new value + TTL.
38
+ * Implementations that support DB transactions should do so; others fall back to
39
+ * a non-atomic get-then-set, which is safe for low-concurrency scenarios.
40
+ */
41
+ atomicUpdate?(key: string, updater: (existing: string | null) => {
42
+ value: string;
43
+ ttlSeconds: number;
44
+ }): Promise<void>;
35
45
  }
36
46
  //# sourceMappingURL=interface.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"interface.d.ts","sourceRoot":"","sources":["../../../src/server/storage/interface.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,OAAO;IACtB;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAExC;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAEnE;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE/B;;OAEG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAElC;;OAEG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAErC;;OAEG;IACH,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,CAAA;IAEjD;;OAEG;IACH,IAAI,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1E"}
1
+ {"version":3,"file":"interface.d.ts","sourceRoot":"","sources":["../../../src/server/storage/interface.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,OAAO;IACtB;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAExC;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAEnE;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE/B;;OAEG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAElC;;OAEG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAErC;;OAEG;IACH,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,CAAA;IAEjD;;OAEG;IACH,IAAI,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAEzE;;;;;OAKG;IACH,YAAY,CAAC,CACX,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,KAAK;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAC1E,OAAO,CAAC,IAAI,CAAC,CAAA;CACjB"}
package/dist/types.d.ts CHANGED
@@ -22,6 +22,9 @@ export interface User {
22
22
  agentModel: string | null;
23
23
  agentCapabilities: string[] | null;
24
24
  agentConfig: unknown;
25
+ emailVerified: boolean;
26
+ emailVerificationToken: string | null;
27
+ emailVerifiedAt: Date | null;
25
28
  preferences: unknown;
26
29
  createdAt: Date;
27
30
  updatedAt: Date;