auth-v2-workspace.ts246 lines · main
1/**
2 * Briven Auth v2 — workspace helpers (Phase 1 + Phase 8 security surface).
3 *
4 * SuperTokens-shaped product packaging on top of Briven multi-project Doltgres:
5 * list every project the operator can manage, with Auth enabled flag + live
6 * provider toggles (read-after-write proof for "save sticks").
7 * Phase 8: 2FA / backup-code gate + password policy via the yellow Security page.
8 */
9
10import { listProjectsForUser } from './projects.js';
11import {
12 getAuthConfig,
13 isAuthEnabled,
14 updateAuthConfig,
15 type AuthConfig,
16} from './tenant-config-store.js';
17import { invalidateAuthInstance } from './auth-tenant-pool.js';
18import {
19 getPasswordPolicy,
20 setPasswordPolicy,
21 type PasswordPolicy,
22} from './auth-password-policy.js';
23
24export interface AuthV2ProviderFlags {
25 emailPassword: boolean;
26 magicLink: boolean;
27 emailOtp: boolean;
28 passkey: boolean;
29}
30
31/** Phase 8.1 — tenant 2FA switch (backup codes ship with the twoFactor plugin). */
32export interface AuthV2TwoFactorFlags {
33 enabled: boolean;
34 required: boolean;
35 /** Always 10 when twoFactor is on — Better Auth backupCodeOptions.amount */
36 backupCodeCount: number;
37}
38
39export interface AuthV2ProjectRow {
40 id: string;
41 slug: string;
42 name: string;
43 authEnabled: boolean;
44 providers: AuthV2ProviderFlags | null;
45 /** true if config read failed (DB not ready / auth tables missing) */
46 error?: boolean;
47}
48
49/** Pure map — unit-tested; used after every save for read-back proof. */
50export function flagsFromConfig(config: AuthConfig): AuthV2ProviderFlags {
51 return {
52 emailPassword: config.providers.emailPassword.enabled,
53 magicLink: config.providers.magicLink.enabled,
54 emailOtp: config.providers.emailOtp.enabled,
55 passkey: config.providers.passkey.enabled,
56 };
57}
58
59/** At least one core method must stay on (Phase 1 product rule). */
60export function hasAtLeastOneProvider(flags: AuthV2ProviderFlags): boolean {
61 return flags.emailPassword || flags.magicLink || flags.emailOtp || flags.passkey;
62}
63
64export function twoFactorFromConfig(config: AuthConfig): AuthV2TwoFactorFlags {
65 return {
66 enabled: config.twoFactor.enabled,
67 required: config.twoFactor.required,
68 // Better Auth twoFactor plugin: backupCodeOptions.amount = 10
69 backupCodeCount: config.twoFactor.enabled ? 10 : 0,
70 };
71}
72
73/**
74 * Required cannot stay on if 2FA is off (would lock everyone out of enroll).
75 */
76export function normalizeTwoFactorFlags(input: {
77 enabled: boolean;
78 required: boolean;
79}): AuthV2TwoFactorFlags {
80 const enabled = input.enabled === true;
81 const required = enabled && input.required === true;
82 return {
83 enabled,
84 required,
85 backupCodeCount: enabled ? 10 : 0,
86 };
87}
88
89/**
90 * All projects the user belongs to, with Auth enable + core provider flags.
91 * Failures on a single project do not fail the whole workspace list.
92 */
93export async function listAuthV2Workspace(userId: string): Promise<{
94 projects: AuthV2ProjectRow[];
95}> {
96 const projects = await listProjectsForUser(userId);
97 const rows: AuthV2ProjectRow[] = await Promise.all(
98 projects.map(async (p) => {
99 const name = (p as { name?: string | null }).name?.trim() || p.slug;
100 try {
101 const enabled = await isAuthEnabled(p.id);
102 if (!enabled) {
103 return {
104 id: p.id,
105 slug: p.slug,
106 name,
107 authEnabled: false,
108 providers: null,
109 };
110 }
111 const config = await getAuthConfig(p.id);
112 return {
113 id: p.id,
114 slug: p.slug,
115 name,
116 authEnabled: true,
117 providers: flagsFromConfig(config),
118 };
119 } catch {
120 return {
121 id: p.id,
122 slug: p.slug,
123 name,
124 authEnabled: false,
125 providers: null,
126 error: true,
127 };
128 }
129 }),
130 );
131 return { projects: rows };
132}
133
134/**
135 * Save core passwordless + password toggles, invalidate live instance,
136 * re-read config so the UI can prove the save stuck.
137 */
138export async function saveAuthV2Providers(
139 projectId: string,
140 flags: AuthV2ProviderFlags,
141): Promise<{ enabled: boolean; providers: AuthV2ProviderFlags; config: AuthConfig }> {
142 const enabled = await isAuthEnabled(projectId);
143 if (!enabled) {
144 throw new Error('auth_not_enabled');
145 }
146
147 await updateAuthConfig(projectId, {
148 providers: {
149 emailPassword: { enabled: flags.emailPassword },
150 magicLink: {
151 enabled: flags.magicLink,
152 expiryMinutes: 15,
153 },
154 emailOtp: {
155 enabled: flags.emailOtp,
156 codeLength: 6,
157 expiryMinutes: 5,
158 },
159 passkey: { enabled: flags.passkey },
160 },
161 });
162 await invalidateAuthInstance(projectId);
163
164 // Proof: re-read from DB (not the in-memory merge result alone)
165 const config = await getAuthConfig(projectId);
166 return {
167 enabled: true,
168 providers: flagsFromConfig(config),
169 config,
170 };
171}
172
173/**
174 * Snapshot for one project (config + enabled) after enable or for detail pages.
175 */
176export async function getAuthV2ProjectSnapshot(projectId: string): Promise<{
177 enabled: boolean;
178 providers: AuthV2ProviderFlags | null;
179 twoFactor: AuthV2TwoFactorFlags | null;
180 passwordPolicy: PasswordPolicy | null;
181 config: AuthConfig | null;
182}> {
183 const enabled = await isAuthEnabled(projectId);
184 if (!enabled) {
185 return {
186 enabled: false,
187 providers: null,
188 twoFactor: null,
189 passwordPolicy: null,
190 config: null,
191 };
192 }
193 const config = await getAuthConfig(projectId);
194 let passwordPolicy: PasswordPolicy | null = null;
195 try {
196 passwordPolicy = await getPasswordPolicy(projectId);
197 } catch {
198 passwordPolicy = null;
199 }
200 return {
201 enabled: true,
202 providers: flagsFromConfig(config),
203 twoFactor: twoFactorFromConfig(config),
204 passwordPolicy,
205 config,
206 };
207}
208
209/**
210 * Phase 8.1 — save 2FA toggles (enables Better Auth twoFactor + 10 backup codes).
211 * Returns live flags after invalidate + re-read.
212 */
213export async function saveAuthV2TwoFactor(
214 projectId: string,
215 input: { enabled: boolean; required: boolean },
216): Promise<{ enabled: boolean; twoFactor: AuthV2TwoFactorFlags }> {
217 const authOn = await isAuthEnabled(projectId);
218 if (!authOn) throw new Error('auth_not_enabled');
219
220 const flags = normalizeTwoFactorFlags(input);
221 await updateAuthConfig(projectId, {
222 twoFactor: {
223 enabled: flags.enabled,
224 required: flags.required,
225 issuer: null,
226 },
227 });
228 await invalidateAuthInstance(projectId);
229
230 const config = await getAuthConfig(projectId);
231 return { enabled: true, twoFactor: twoFactorFromConfig(config) };
232}
233
234/**
235 * Phase 8.4 surface — password policy with read-back proof.
236 */
237export async function saveAuthV2PasswordPolicy(
238 projectId: string,
239 patch: Partial<PasswordPolicy>,
240): Promise<{ enabled: boolean; passwordPolicy: PasswordPolicy }> {
241 const authOn = await isAuthEnabled(projectId);
242 if (!authOn) throw new Error('auth_not_enabled');
243
244 const passwordPolicy = await setPasswordPolicy(projectId, patch);
245 return { enabled: true, passwordPolicy };
246}