@revealui/auth 0.2.1 → 0.3.2
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/dist/index.d.ts.map +1 -1
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/useMFA.d.ts +83 -0
- package/dist/react/useMFA.d.ts.map +1 -0
- package/dist/react/useMFA.js +182 -0
- package/dist/react/usePasskey.d.ts +88 -0
- package/dist/react/usePasskey.d.ts.map +1 -0
- package/dist/react/usePasskey.js +203 -0
- package/dist/react/useSession.d.ts.map +1 -1
- package/dist/react/useSession.js +16 -5
- package/dist/react/useSignIn.d.ts +9 -3
- package/dist/react/useSignIn.d.ts.map +1 -1
- package/dist/react/useSignIn.js +32 -10
- package/dist/react/useSignOut.d.ts.map +1 -1
- package/dist/react/useSignUp.d.ts.map +1 -1
- package/dist/react/useSignUp.js +25 -9
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +85 -8
- package/dist/server/brute-force.d.ts +10 -1
- package/dist/server/brute-force.d.ts.map +1 -1
- package/dist/server/brute-force.js +17 -3
- package/dist/server/errors.d.ts.map +1 -1
- package/dist/server/index.d.ts +16 -6
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +11 -5
- package/dist/server/magic-link.d.ts +52 -0
- package/dist/server/magic-link.d.ts.map +1 -0
- package/dist/server/magic-link.js +111 -0
- package/dist/server/mfa.d.ts +87 -0
- package/dist/server/mfa.d.ts.map +1 -0
- package/dist/server/mfa.js +263 -0
- package/dist/server/oauth.d.ts +52 -4
- package/dist/server/oauth.d.ts.map +1 -1
- package/dist/server/oauth.js +165 -18
- package/dist/server/passkey.d.ts +132 -0
- package/dist/server/passkey.d.ts.map +1 -0
- package/dist/server/passkey.js +257 -0
- package/dist/server/password-reset.d.ts +15 -0
- package/dist/server/password-reset.d.ts.map +1 -1
- package/dist/server/password-reset.js +44 -1
- package/dist/server/password-validation.d.ts.map +1 -1
- package/dist/server/providers/github.d.ts.map +1 -1
- package/dist/server/providers/github.js +18 -5
- package/dist/server/providers/google.d.ts.map +1 -1
- package/dist/server/providers/google.js +18 -3
- package/dist/server/providers/vercel.d.ts.map +1 -1
- package/dist/server/providers/vercel.js +18 -3
- package/dist/server/rate-limit.d.ts +10 -1
- package/dist/server/rate-limit.d.ts.map +1 -1
- package/dist/server/rate-limit.js +61 -43
- package/dist/server/session.d.ts +65 -1
- package/dist/server/session.d.ts.map +1 -1
- package/dist/server/session.js +175 -7
- package/dist/server/signed-cookie.d.ts +32 -0
- package/dist/server/signed-cookie.d.ts.map +1 -0
- package/dist/server/signed-cookie.js +67 -0
- package/dist/server/storage/database.d.ts +1 -1
- package/dist/server/storage/database.d.ts.map +1 -1
- package/dist/server/storage/database.js +15 -7
- package/dist/server/storage/in-memory.d.ts.map +1 -1
- package/dist/server/storage/in-memory.js +7 -7
- package/dist/server/storage/index.d.ts +11 -3
- package/dist/server/storage/index.d.ts.map +1 -1
- package/dist/server/storage/index.js +18 -4
- package/dist/server/storage/interface.d.ts +1 -1
- package/dist/server/storage/interface.d.ts.map +1 -1
- package/dist/server/storage/interface.js +1 -1
- package/dist/types.d.ts +20 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/utils/database.d.ts.map +1 -1
- package/dist/utils/database.js +9 -2
- package/package.json +31 -13
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic Link Token Module
|
|
3
|
+
*
|
|
4
|
+
* Generates and verifies single-use, time-limited tokens for passwordless
|
|
5
|
+
* email authentication and account recovery flows.
|
|
6
|
+
*
|
|
7
|
+
* Tokens are stored in the database as HMAC-SHA256 hashes with per-token salts,
|
|
8
|
+
* following the same security pattern as password-reset.ts.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { getClient } from '@revealui/db/client';
|
|
12
|
+
import { magicLinks } from '@revealui/db/schema';
|
|
13
|
+
import { and, eq, gt, isNull, lt } from 'drizzle-orm';
|
|
14
|
+
const DEFAULT_CONFIG = {
|
|
15
|
+
tokenExpiryMs: 15 * 60 * 1000,
|
|
16
|
+
tempSessionDurationMs: 30 * 60 * 1000,
|
|
17
|
+
maxRequestsPerHour: 3,
|
|
18
|
+
};
|
|
19
|
+
let config = { ...DEFAULT_CONFIG };
|
|
20
|
+
export function configureMagicLink(overrides) {
|
|
21
|
+
config = { ...DEFAULT_CONFIG, ...overrides };
|
|
22
|
+
}
|
|
23
|
+
export function resetMagicLinkConfig() {
|
|
24
|
+
config = { ...DEFAULT_CONFIG };
|
|
25
|
+
}
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Crypto helpers (same pattern as password-reset.ts)
|
|
28
|
+
// =============================================================================
|
|
29
|
+
/**
|
|
30
|
+
* Hash a token using HMAC-SHA256 with a per-token salt.
|
|
31
|
+
* The salt is stored in the DB alongside the hash; this defeats rainbow
|
|
32
|
+
* table attacks even if the database is fully compromised.
|
|
33
|
+
*/
|
|
34
|
+
function hashToken(token, salt) {
|
|
35
|
+
return crypto.createHmac('sha256', salt).update(token).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Generate a 16-byte random salt (hex string).
|
|
39
|
+
*/
|
|
40
|
+
function generateSalt() {
|
|
41
|
+
return crypto.randomBytes(16).toString('hex');
|
|
42
|
+
}
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Public API
|
|
45
|
+
// =============================================================================
|
|
46
|
+
/**
|
|
47
|
+
* Creates a magic link token for a user.
|
|
48
|
+
*
|
|
49
|
+
* - Generates a 32-byte random token
|
|
50
|
+
* - Hashes it with HMAC-SHA256 + per-token salt
|
|
51
|
+
* - Cleans up expired magic links for the same user (opportunistic)
|
|
52
|
+
* - Inserts the hashed token into the database
|
|
53
|
+
*
|
|
54
|
+
* @param userId - User ID to create the magic link for
|
|
55
|
+
* @returns The plaintext token (to embed in the email link) and its expiry
|
|
56
|
+
*/
|
|
57
|
+
export async function createMagicLink(userId) {
|
|
58
|
+
const db = getClient();
|
|
59
|
+
// Generate secure token with per-token salt
|
|
60
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
61
|
+
const tokenSalt = generateSalt();
|
|
62
|
+
const tokenHash = hashToken(token, tokenSalt);
|
|
63
|
+
const expiresAt = new Date(Date.now() + config.tokenExpiryMs);
|
|
64
|
+
const id = crypto.randomUUID();
|
|
65
|
+
// Opportunistic cleanup: delete expired magic links for this user
|
|
66
|
+
await db
|
|
67
|
+
.delete(magicLinks)
|
|
68
|
+
.where(and(eq(magicLinks.userId, userId), lt(magicLinks.expiresAt, new Date())));
|
|
69
|
+
// Store hashed token + salt in database
|
|
70
|
+
await db.insert(magicLinks).values({
|
|
71
|
+
id,
|
|
72
|
+
userId,
|
|
73
|
+
tokenHash,
|
|
74
|
+
tokenSalt,
|
|
75
|
+
expiresAt,
|
|
76
|
+
});
|
|
77
|
+
return { token, expiresAt };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Verifies a magic link token.
|
|
81
|
+
*
|
|
82
|
+
* Selects all unexpired, unused magic links and checks each one against the
|
|
83
|
+
* provided token using HMAC-SHA256 + timingSafeEqual. This is a table scan
|
|
84
|
+
* by design (same approach as password-reset.ts validation). The table stays
|
|
85
|
+
* small due to opportunistic cleanup in createMagicLink.
|
|
86
|
+
*
|
|
87
|
+
* On match: marks the token as used and returns the userId.
|
|
88
|
+
* On no match: returns null.
|
|
89
|
+
*
|
|
90
|
+
* @param token - Plaintext token from the magic link URL
|
|
91
|
+
* @returns Object with userId if valid, null otherwise
|
|
92
|
+
*/
|
|
93
|
+
export async function verifyMagicLink(token) {
|
|
94
|
+
const db = getClient();
|
|
95
|
+
// Select all unexpired, unused magic links
|
|
96
|
+
const rows = await db
|
|
97
|
+
.select()
|
|
98
|
+
.from(magicLinks)
|
|
99
|
+
.where(and(isNull(magicLinks.usedAt), gt(magicLinks.expiresAt, new Date())));
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
const expectedHash = hashToken(token, row.tokenSalt);
|
|
102
|
+
const expectedBuf = Buffer.from(expectedHash);
|
|
103
|
+
const actualBuf = Buffer.from(row.tokenHash);
|
|
104
|
+
if (expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf)) {
|
|
105
|
+
// Mark token as used (single-use enforcement)
|
|
106
|
+
await db.update(magicLinks).set({ usedAt: new Date() }).where(eq(magicLinks.id, row.id));
|
|
107
|
+
return { userId: row.userId };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MFA/2FA — TOTP-based Multi-Factor Authentication
|
|
3
|
+
*
|
|
4
|
+
* Uses the timing-safe TOTP implementation from @revealui/core/security/auth.
|
|
5
|
+
* Backup codes are bcrypt-hashed for storage (one-time use, consumed on verify).
|
|
6
|
+
*/
|
|
7
|
+
export interface MFAConfig {
|
|
8
|
+
/** Number of backup codes to generate (default: 8) */
|
|
9
|
+
backupCodeCount: number;
|
|
10
|
+
/** Length of each backup code in bytes (default: 5, produces 10 hex chars) */
|
|
11
|
+
backupCodeLength: number;
|
|
12
|
+
/** Issuer name shown in authenticator apps */
|
|
13
|
+
issuer: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function configureMFA(overrides: Partial<MFAConfig>): void;
|
|
16
|
+
export declare function resetMFAConfig(): void;
|
|
17
|
+
export interface MFASetupResult {
|
|
18
|
+
success: boolean;
|
|
19
|
+
/** Base32-encoded TOTP secret (show once) */
|
|
20
|
+
secret?: string;
|
|
21
|
+
/** otpauth:// URI for QR code */
|
|
22
|
+
uri?: string;
|
|
23
|
+
/** Plaintext backup codes (show once) */
|
|
24
|
+
backupCodes?: string[];
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Initiate MFA setup for a user.
|
|
29
|
+
* Generates a TOTP secret and backup codes. The user must verify with a TOTP
|
|
30
|
+
* code before MFA is activated (see `verifyMFASetup`).
|
|
31
|
+
*/
|
|
32
|
+
export declare function initiateMFASetup(userId: string, email: string): Promise<MFASetupResult>;
|
|
33
|
+
/**
|
|
34
|
+
* Verify MFA setup by confirming the user's authenticator app works.
|
|
35
|
+
* This activates MFA on the account.
|
|
36
|
+
*/
|
|
37
|
+
export declare function verifyMFASetup(userId: string, code: string): Promise<{
|
|
38
|
+
success: boolean;
|
|
39
|
+
error?: string;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Verify a TOTP code during login (step 2 of MFA login flow).
|
|
43
|
+
*/
|
|
44
|
+
export declare function verifyMFACode(userId: string, code: string): Promise<{
|
|
45
|
+
success: boolean;
|
|
46
|
+
error?: string;
|
|
47
|
+
}>;
|
|
48
|
+
/**
|
|
49
|
+
* Verify a backup code (one-time use). Consumes the code on success.
|
|
50
|
+
*/
|
|
51
|
+
export declare function verifyBackupCode(userId: string, code: string): Promise<{
|
|
52
|
+
success: boolean;
|
|
53
|
+
remainingCodes?: number;
|
|
54
|
+
error?: string;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Regenerate backup codes (requires active MFA).
|
|
58
|
+
*/
|
|
59
|
+
export declare function regenerateBackupCodes(userId: string): Promise<{
|
|
60
|
+
success: boolean;
|
|
61
|
+
backupCodes?: string[];
|
|
62
|
+
error?: string;
|
|
63
|
+
}>;
|
|
64
|
+
/**
|
|
65
|
+
* Discriminated union for MFA disable re-authentication proof.
|
|
66
|
+
* - `password`: traditional password confirmation
|
|
67
|
+
* - `passkey`: WebAuthn assertion already verified by the API route
|
|
68
|
+
*/
|
|
69
|
+
export type MFADisableProof = {
|
|
70
|
+
method: 'password';
|
|
71
|
+
password: string;
|
|
72
|
+
} | {
|
|
73
|
+
method: 'passkey';
|
|
74
|
+
verified: true;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Disable MFA on a user account. Requires re-authentication proof.
|
|
78
|
+
*/
|
|
79
|
+
export declare function disableMFA(userId: string, proof: MFADisableProof): Promise<{
|
|
80
|
+
success: boolean;
|
|
81
|
+
error?: string;
|
|
82
|
+
}>;
|
|
83
|
+
/**
|
|
84
|
+
* Check if a user has MFA enabled.
|
|
85
|
+
*/
|
|
86
|
+
export declare function isMFAEnabled(userId: string): Promise<boolean>;
|
|
87
|
+
//# sourceMappingURL=mfa.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mfa.d.ts","sourceRoot":"","sources":["../../src/server/mfa.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAaH,MAAM,WAAW,SAAS;IACxB,sDAAsD;IACtD,eAAe,EAAE,MAAM,CAAC;IACxB,8EAA8E;IAC9E,gBAAgB,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD,wBAAgB,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC,GAAG,IAAI,CAEhE;AAED,wBAAgB,cAAc,IAAI,IAAI,CAErC;AA2CD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAuC7F;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAsC/C;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAmB/C;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCxE;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAwBvE;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GACvB;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACxC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,IAAI,CAAA;CAAE,CAAC;AAE1C;;GAEG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA+C/C;AAED;;GAEG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUnE"}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MFA/2FA — TOTP-based Multi-Factor Authentication
|
|
3
|
+
*
|
|
4
|
+
* Uses the timing-safe TOTP implementation from @revealui/core/security/auth.
|
|
5
|
+
* Backup codes are bcrypt-hashed for storage (one-time use, consumed on verify).
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { TwoFactorAuth } from '@revealui/core/security';
|
|
9
|
+
import { getClient } from '@revealui/db/client';
|
|
10
|
+
import { users } from '@revealui/db/schema';
|
|
11
|
+
import bcrypt from 'bcryptjs';
|
|
12
|
+
import { eq } from 'drizzle-orm';
|
|
13
|
+
const DEFAULT_MFA_CONFIG = {
|
|
14
|
+
backupCodeCount: 8,
|
|
15
|
+
backupCodeLength: 5,
|
|
16
|
+
issuer: 'RevealUI',
|
|
17
|
+
};
|
|
18
|
+
let config = { ...DEFAULT_MFA_CONFIG };
|
|
19
|
+
export function configureMFA(overrides) {
|
|
20
|
+
config = { ...DEFAULT_MFA_CONFIG, ...overrides };
|
|
21
|
+
}
|
|
22
|
+
export function resetMFAConfig() {
|
|
23
|
+
config = { ...DEFAULT_MFA_CONFIG };
|
|
24
|
+
}
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Backup Code Generation
|
|
27
|
+
// =============================================================================
|
|
28
|
+
/**
|
|
29
|
+
* Generate a set of plaintext backup codes.
|
|
30
|
+
* Returns both the plaintext codes (to show the user once) and bcrypt hashes (to store).
|
|
31
|
+
*/
|
|
32
|
+
async function generateBackupCodes() {
|
|
33
|
+
const plaintext = [];
|
|
34
|
+
const hashed = [];
|
|
35
|
+
for (let i = 0; i < config.backupCodeCount; i++) {
|
|
36
|
+
const code = randomBytes(config.backupCodeLength).toString('hex');
|
|
37
|
+
plaintext.push(code);
|
|
38
|
+
hashed.push(await bcrypt.hash(code, 10));
|
|
39
|
+
}
|
|
40
|
+
return { plaintext, hashed };
|
|
41
|
+
}
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// TOTP Provisioning URI
|
|
44
|
+
// =============================================================================
|
|
45
|
+
/**
|
|
46
|
+
* Build an otpauth:// URI for QR code generation in authenticator apps.
|
|
47
|
+
*/
|
|
48
|
+
function buildProvisioningUri(secret, email) {
|
|
49
|
+
const issuer = encodeURIComponent(config.issuer);
|
|
50
|
+
const account = encodeURIComponent(email);
|
|
51
|
+
return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Initiate MFA setup for a user.
|
|
55
|
+
* Generates a TOTP secret and backup codes. The user must verify with a TOTP
|
|
56
|
+
* code before MFA is activated (see `verifyMFASetup`).
|
|
57
|
+
*/
|
|
58
|
+
export async function initiateMFASetup(userId, email) {
|
|
59
|
+
const db = getClient();
|
|
60
|
+
// Check if MFA is already enabled
|
|
61
|
+
const [user] = await db
|
|
62
|
+
.select({ mfaEnabled: users.mfaEnabled })
|
|
63
|
+
.from(users)
|
|
64
|
+
.where(eq(users.id, userId))
|
|
65
|
+
.limit(1);
|
|
66
|
+
if (!user) {
|
|
67
|
+
return { success: false, error: 'User not found' };
|
|
68
|
+
}
|
|
69
|
+
if (user.mfaEnabled) {
|
|
70
|
+
return { success: false, error: 'MFA is already enabled' };
|
|
71
|
+
}
|
|
72
|
+
// Generate TOTP secret and backup codes
|
|
73
|
+
const secret = TwoFactorAuth.generateSecret();
|
|
74
|
+
const { plaintext, hashed } = await generateBackupCodes();
|
|
75
|
+
const uri = buildProvisioningUri(secret, email);
|
|
76
|
+
// Store secret and backup codes (MFA stays disabled until verified)
|
|
77
|
+
await db
|
|
78
|
+
.update(users)
|
|
79
|
+
.set({
|
|
80
|
+
mfaSecret: secret,
|
|
81
|
+
mfaBackupCodes: hashed,
|
|
82
|
+
updatedAt: new Date(),
|
|
83
|
+
})
|
|
84
|
+
.where(eq(users.id, userId));
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
secret,
|
|
88
|
+
uri,
|
|
89
|
+
backupCodes: plaintext,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Verify MFA setup by confirming the user's authenticator app works.
|
|
94
|
+
* This activates MFA on the account.
|
|
95
|
+
*/
|
|
96
|
+
export async function verifyMFASetup(userId, code) {
|
|
97
|
+
const db = getClient();
|
|
98
|
+
const [user] = await db
|
|
99
|
+
.select({ mfaSecret: users.mfaSecret, mfaEnabled: users.mfaEnabled })
|
|
100
|
+
.from(users)
|
|
101
|
+
.where(eq(users.id, userId))
|
|
102
|
+
.limit(1);
|
|
103
|
+
if (!user) {
|
|
104
|
+
return { success: false, error: 'User not found' };
|
|
105
|
+
}
|
|
106
|
+
if (user.mfaEnabled) {
|
|
107
|
+
return { success: false, error: 'MFA is already enabled' };
|
|
108
|
+
}
|
|
109
|
+
if (!user.mfaSecret) {
|
|
110
|
+
return { success: false, error: 'MFA setup not initiated' };
|
|
111
|
+
}
|
|
112
|
+
// Verify the TOTP code against the stored secret
|
|
113
|
+
const valid = TwoFactorAuth.verifyCode(user.mfaSecret, code);
|
|
114
|
+
if (!valid) {
|
|
115
|
+
return { success: false, error: 'Invalid verification code' };
|
|
116
|
+
}
|
|
117
|
+
// Activate MFA
|
|
118
|
+
await db
|
|
119
|
+
.update(users)
|
|
120
|
+
.set({
|
|
121
|
+
mfaEnabled: true,
|
|
122
|
+
mfaVerifiedAt: new Date(),
|
|
123
|
+
updatedAt: new Date(),
|
|
124
|
+
})
|
|
125
|
+
.where(eq(users.id, userId));
|
|
126
|
+
return { success: true };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Verify a TOTP code during login (step 2 of MFA login flow).
|
|
130
|
+
*/
|
|
131
|
+
export async function verifyMFACode(userId, code) {
|
|
132
|
+
const db = getClient();
|
|
133
|
+
const [user] = await db
|
|
134
|
+
.select({ mfaSecret: users.mfaSecret, mfaEnabled: users.mfaEnabled })
|
|
135
|
+
.from(users)
|
|
136
|
+
.where(eq(users.id, userId))
|
|
137
|
+
.limit(1);
|
|
138
|
+
if (!(user?.mfaEnabled && user.mfaSecret)) {
|
|
139
|
+
return { success: false, error: 'MFA not enabled' };
|
|
140
|
+
}
|
|
141
|
+
const valid = TwoFactorAuth.verifyCode(user.mfaSecret, code);
|
|
142
|
+
if (!valid) {
|
|
143
|
+
return { success: false, error: 'Invalid code' };
|
|
144
|
+
}
|
|
145
|
+
return { success: true };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Verify a backup code (one-time use). Consumes the code on success.
|
|
149
|
+
*/
|
|
150
|
+
export async function verifyBackupCode(userId, code) {
|
|
151
|
+
const db = getClient();
|
|
152
|
+
const [user] = await db
|
|
153
|
+
.select({ mfaBackupCodes: users.mfaBackupCodes, mfaEnabled: users.mfaEnabled })
|
|
154
|
+
.from(users)
|
|
155
|
+
.where(eq(users.id, userId))
|
|
156
|
+
.limit(1);
|
|
157
|
+
if (!user?.mfaEnabled) {
|
|
158
|
+
return { success: false, error: 'MFA not enabled' };
|
|
159
|
+
}
|
|
160
|
+
const storedCodes = (user.mfaBackupCodes ?? []);
|
|
161
|
+
if (storedCodes.length === 0) {
|
|
162
|
+
return { success: false, error: 'No backup codes available' };
|
|
163
|
+
}
|
|
164
|
+
// Find and consume the matching backup code
|
|
165
|
+
for (let i = 0; i < storedCodes.length; i++) {
|
|
166
|
+
const storedCode = storedCodes[i];
|
|
167
|
+
if (!storedCode)
|
|
168
|
+
continue;
|
|
169
|
+
const matches = await bcrypt.compare(code, storedCode);
|
|
170
|
+
if (matches) {
|
|
171
|
+
// Remove the consumed code
|
|
172
|
+
const remaining = [...storedCodes.slice(0, i), ...storedCodes.slice(i + 1)];
|
|
173
|
+
await db
|
|
174
|
+
.update(users)
|
|
175
|
+
.set({
|
|
176
|
+
mfaBackupCodes: remaining,
|
|
177
|
+
updatedAt: new Date(),
|
|
178
|
+
})
|
|
179
|
+
.where(eq(users.id, userId));
|
|
180
|
+
return { success: true, remainingCodes: remaining.length };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { success: false, error: 'Invalid backup code' };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Regenerate backup codes (requires active MFA).
|
|
187
|
+
*/
|
|
188
|
+
export async function regenerateBackupCodes(userId) {
|
|
189
|
+
const db = getClient();
|
|
190
|
+
const [user] = await db
|
|
191
|
+
.select({ mfaEnabled: users.mfaEnabled })
|
|
192
|
+
.from(users)
|
|
193
|
+
.where(eq(users.id, userId))
|
|
194
|
+
.limit(1);
|
|
195
|
+
if (!user?.mfaEnabled) {
|
|
196
|
+
return { success: false, error: 'MFA not enabled' };
|
|
197
|
+
}
|
|
198
|
+
const { plaintext, hashed } = await generateBackupCodes();
|
|
199
|
+
await db
|
|
200
|
+
.update(users)
|
|
201
|
+
.set({
|
|
202
|
+
mfaBackupCodes: hashed,
|
|
203
|
+
updatedAt: new Date(),
|
|
204
|
+
})
|
|
205
|
+
.where(eq(users.id, userId));
|
|
206
|
+
return { success: true, backupCodes: plaintext };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Disable MFA on a user account. Requires re-authentication proof.
|
|
210
|
+
*/
|
|
211
|
+
export async function disableMFA(userId, proof) {
|
|
212
|
+
const db = getClient();
|
|
213
|
+
const [user] = await db
|
|
214
|
+
.select({
|
|
215
|
+
mfaEnabled: users.mfaEnabled,
|
|
216
|
+
password: users.password,
|
|
217
|
+
})
|
|
218
|
+
.from(users)
|
|
219
|
+
.where(eq(users.id, userId))
|
|
220
|
+
.limit(1);
|
|
221
|
+
if (!user) {
|
|
222
|
+
return { success: false, error: 'User not found' };
|
|
223
|
+
}
|
|
224
|
+
if (!user.mfaEnabled) {
|
|
225
|
+
return { success: false, error: 'MFA is not enabled' };
|
|
226
|
+
}
|
|
227
|
+
// Verify re-authentication proof
|
|
228
|
+
if (proof.method === 'password') {
|
|
229
|
+
if (!user.password) {
|
|
230
|
+
return { success: false, error: 'Password verification required' };
|
|
231
|
+
}
|
|
232
|
+
const passwordValid = await bcrypt.compare(proof.password, user.password);
|
|
233
|
+
if (!passwordValid) {
|
|
234
|
+
return { success: false, error: 'Invalid password' };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// For passkey proof, the API route has already performed the WebAuthn assertion —
|
|
238
|
+
// the `verified: true` flag is trusted as a server-side signal.
|
|
239
|
+
// Clear all MFA data
|
|
240
|
+
await db
|
|
241
|
+
.update(users)
|
|
242
|
+
.set({
|
|
243
|
+
mfaEnabled: false,
|
|
244
|
+
mfaSecret: null,
|
|
245
|
+
mfaBackupCodes: null,
|
|
246
|
+
mfaVerifiedAt: null,
|
|
247
|
+
updatedAt: new Date(),
|
|
248
|
+
})
|
|
249
|
+
.where(eq(users.id, userId));
|
|
250
|
+
return { success: true };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Check if a user has MFA enabled.
|
|
254
|
+
*/
|
|
255
|
+
export async function isMFAEnabled(userId) {
|
|
256
|
+
const db = getClient();
|
|
257
|
+
const [user] = await db
|
|
258
|
+
.select({ mfaEnabled: users.mfaEnabled })
|
|
259
|
+
.from(users)
|
|
260
|
+
.where(eq(users.id, userId))
|
|
261
|
+
.limit(1);
|
|
262
|
+
return user?.mfaEnabled ?? false;
|
|
263
|
+
}
|
package/dist/server/oauth.d.ts
CHANGED
|
@@ -19,7 +19,9 @@ export interface ProviderUser {
|
|
|
19
19
|
* Cookie value is `<state>.<hmac>` — the HMAC is over the state string
|
|
20
20
|
* using REVEALUI_SECRET, providing CSRF protection without a DB table.
|
|
21
21
|
*/
|
|
22
|
-
export declare function generateOAuthState(provider: string, redirectTo: string
|
|
22
|
+
export declare function generateOAuthState(provider: string, redirectTo: string, options?: {
|
|
23
|
+
linkConsent?: boolean;
|
|
24
|
+
}): {
|
|
23
25
|
state: string;
|
|
24
26
|
cookieValue: string;
|
|
25
27
|
};
|
|
@@ -31,19 +33,65 @@ export declare function generateOAuthState(provider: string, redirectTo: string)
|
|
|
31
33
|
export declare function verifyOAuthState(state: string | null | undefined, cookieValue: string | null | undefined): {
|
|
32
34
|
provider: string;
|
|
33
35
|
redirectTo: string;
|
|
36
|
+
linkConsent?: boolean;
|
|
34
37
|
} | null;
|
|
35
38
|
export declare function buildAuthUrl(provider: string, redirectUri: string, state: string): string;
|
|
36
39
|
export declare function exchangeCode(provider: string, code: string, redirectUri: string): Promise<string>;
|
|
37
40
|
export declare function fetchProviderUser(provider: string, accessToken: string): Promise<ProviderUser>;
|
|
41
|
+
export interface UpsertOAuthOptions {
|
|
42
|
+
/**
|
|
43
|
+
* When true, the user has explicitly consented to link their OAuth
|
|
44
|
+
* provider to an existing local account with the same email.
|
|
45
|
+
* Without consent, an email-match throws OAuthAccountConflictError.
|
|
46
|
+
*/
|
|
47
|
+
linkConsent?: boolean;
|
|
48
|
+
}
|
|
38
49
|
/**
|
|
39
50
|
* Find or create a local user for the given OAuth identity.
|
|
40
51
|
*
|
|
41
52
|
* Flow:
|
|
42
53
|
* 1. Look up oauth_accounts by (provider, providerUserId) → get userId
|
|
43
54
|
* 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: '
|
|
55
|
+
* 3. If not found: check users by email → link if match (with consent) or throw
|
|
56
|
+
* 4. If no match: create new user (role: 'user', no password)
|
|
46
57
|
* 5. Insert oauth_accounts row
|
|
47
58
|
*/
|
|
48
|
-
export declare function upsertOAuthUser(provider: string, providerUser: ProviderUser): Promise<User>;
|
|
59
|
+
export declare function upsertOAuthUser(provider: string, providerUser: ProviderUser, options?: UpsertOAuthOptions): Promise<User>;
|
|
60
|
+
/**
|
|
61
|
+
* Link an OAuth provider to an existing authenticated user.
|
|
62
|
+
*
|
|
63
|
+
* Unlike upsertOAuthUser(), this function requires the caller to be
|
|
64
|
+
* authenticated and explicitly requests the link. This is safe because
|
|
65
|
+
* the user has already proven ownership of the local account via their
|
|
66
|
+
* session.
|
|
67
|
+
*
|
|
68
|
+
* @param userId - The authenticated user's ID (from session)
|
|
69
|
+
* @param provider - OAuth provider name
|
|
70
|
+
* @param providerUser - Profile returned by the OAuth provider
|
|
71
|
+
* @returns The linked user
|
|
72
|
+
* @throws Error if the provider account is already linked to a different user
|
|
73
|
+
*/
|
|
74
|
+
export declare function linkOAuthAccount(userId: string, provider: string, providerUser: ProviderUser): Promise<User>;
|
|
75
|
+
/**
|
|
76
|
+
* Unlink an OAuth provider from a user's account.
|
|
77
|
+
*
|
|
78
|
+
* Safety: refuses to unlink the last auth method (if user has no password
|
|
79
|
+
* and this is their only OAuth link, unlinking would lock them out).
|
|
80
|
+
*
|
|
81
|
+
* @param userId - The authenticated user's ID
|
|
82
|
+
* @param provider - The provider to unlink
|
|
83
|
+
* @throws Error if unlinking would leave the user with no authentication method
|
|
84
|
+
*/
|
|
85
|
+
export declare function unlinkOAuthAccount(userId: string, provider: string): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Get all linked OAuth providers for a user.
|
|
88
|
+
*
|
|
89
|
+
* @param userId - The user's ID
|
|
90
|
+
* @returns Array of linked provider info (provider name, email, avatar)
|
|
91
|
+
*/
|
|
92
|
+
export declare function getLinkedProviders(userId: string): Promise<Array<{
|
|
93
|
+
provider: string;
|
|
94
|
+
providerEmail: string | null;
|
|
95
|
+
providerName: string | null;
|
|
96
|
+
}>>;
|
|
49
97
|
//# sourceMappingURL=oauth.d.ts.map
|
|
@@ -1 +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,
|
|
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,CAAC;AAMxC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAMD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAClC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAkBxC;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,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAqDxE;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,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,YAAY,EAC1B,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAmGf;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,YAAY,GACzB,OAAO,CAAC,IAAI,CAAC,CAiEf;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsCxF;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAAC,CAajG"}
|