work-in-progress atproto PDS
typescript atproto pds atcute

refactor: split AccountManager into separate managers

mary.my.id 990835fb 3d904ebb

verified
+1872 -1704
+258
packages/danaus/src/accounts/invite-codes.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { InvalidRequestError } from '@atcute/xrpc-server'; 3 + 4 + import { and, asc, eq, gt, inArray, or, sql } from 'drizzle-orm'; 5 + 6 + import { TimeKeyset } from '#app/utils/keyset.ts'; 7 + import { generateInviteCode } from '#app/utils/token.ts'; 8 + 9 + import { t, type AccountDb } from './db'; 10 + 11 + export type InviteCode = typeof t.inviteCode.$inferSelect; 12 + export type InviteCodeUse = typeof t.inviteCodeUse.$inferSelect; 13 + 14 + export interface InviteCodeWithUses extends InviteCode { 15 + uses: InviteCodeUse[]; 16 + } 17 + 18 + const inviteCodeKeyset = new TimeKeyset(); 19 + 20 + interface InviteCodeManagerOptions { 21 + db: AccountDb; 22 + } 23 + 24 + interface ListInviteCodesOptions { 25 + limit?: number; 26 + cursor?: string; 27 + includeDisabled?: boolean; 28 + includeUsed?: boolean; 29 + } 30 + 31 + export class InviteCodeManager { 32 + readonly #db: AccountDb; 33 + 34 + constructor(options: InviteCodeManagerOptions) { 35 + this.#db = options.db; 36 + } 37 + 38 + /** 39 + * create invite codes. 40 + * @param count number of codes to create 41 + * @param availableUses uses per code (0 for unlimited) 42 + * @returns created invite codes 43 + */ 44 + createInviteCodes(count: number, availableUses: number): InviteCode[] { 45 + const now = new Date(); 46 + const codes: InviteCode[] = []; 47 + 48 + for (let i = 0; i < count; i++) { 49 + const code = generateInviteCode(); 50 + const inserted = this.#db 51 + .insert(t.inviteCode) 52 + .values({ 53 + code: code, 54 + available_uses: availableUses === 0 ? -1 : availableUses, 55 + disabled: false, 56 + created_at: now, 57 + }) 58 + .returning() 59 + .get(); 60 + 61 + if (inserted) { 62 + codes.push(inserted); 63 + } 64 + } 65 + 66 + return codes; 67 + } 68 + 69 + /** 70 + * check if an invite code is available for use. 71 + * @param code invite code 72 + * @throws InvalidRequestError if code is not available 73 + */ 74 + ensureInviteIsAvailable(code: string): void { 75 + const invite = this.#db.select().from(t.inviteCode).where(eq(t.inviteCode.code, code)).get(); 76 + 77 + if (!invite || invite.disabled) { 78 + throw new InvalidRequestError({ 79 + error: 'InvalidInviteCode', 80 + description: 'provided invite code not available', 81 + }); 82 + } 83 + 84 + // -1 means unlimited uses 85 + if (invite.available_uses !== -1) { 86 + const uses = 87 + this.#db 88 + .select({ count: sql<number>`count(*)` }) 89 + .from(t.inviteCodeUse) 90 + .where(eq(t.inviteCodeUse.code, code)) 91 + .get()?.count ?? 0; 92 + 93 + if (uses >= invite.available_uses) { 94 + throw new InvalidRequestError({ 95 + error: 'InvalidInviteCode', 96 + description: 'provided invite code not available', 97 + }); 98 + } 99 + } 100 + } 101 + 102 + /** 103 + * record an invite code use. 104 + * @param code invite code 105 + * @param usedBy DID of the account that used the code 106 + */ 107 + recordInviteUse(code: string, usedBy: Did): void { 108 + this.#db 109 + .insert(t.inviteCodeUse) 110 + .values({ 111 + code: code, 112 + used_by: usedBy, 113 + used_at: new Date(), 114 + }) 115 + .run(); 116 + } 117 + 118 + /** 119 + * get invite code details with usage. 120 + * @param code invite code 121 + * @returns invite code with uses or null 122 + */ 123 + getInviteCode(code: string): InviteCodeWithUses | null { 124 + const invite = this.#db.select().from(t.inviteCode).where(eq(t.inviteCode.code, code)).get(); 125 + 126 + if (!invite) { 127 + return null; 128 + } 129 + 130 + const uses = this.#db.select().from(t.inviteCodeUse).where(eq(t.inviteCodeUse.code, code)).all(); 131 + 132 + return { ...invite, uses }; 133 + } 134 + 135 + /** 136 + * list invite codes with pagination. 137 + * @param options list options 138 + * @returns invite codes and cursor 139 + */ 140 + listInviteCodes(options: ListInviteCodesOptions = {}): { 141 + codes: InviteCodeWithUses[]; 142 + cursor?: string; 143 + } { 144 + const { limit = 50, cursor, includeDisabled = false, includeUsed = true } = options; 145 + const parsed = inviteCodeKeyset.unpackOptional(cursor); 146 + 147 + // paginate codes first in a CTE, then LEFT JOIN with uses 148 + const paginatedCodes = this.#db.$with('paginated_codes').as( 149 + this.#db 150 + .select() 151 + .from(t.inviteCode) 152 + .where((f) => { 153 + return and( 154 + !includeDisabled ? eq(f.disabled, false) : undefined, 155 + parsed 156 + ? or(gt(f.created_at, parsed.time), and(eq(f.created_at, parsed.time), gt(f.code, parsed.key))) 157 + : undefined, 158 + ); 159 + }) 160 + .orderBy(asc(t.inviteCode.created_at), asc(t.inviteCode.code)) 161 + .limit(limit), 162 + ); 163 + 164 + const rows = this.#db 165 + .with(paginatedCodes) 166 + .select({ 167 + code: paginatedCodes.code, 168 + available_uses: paginatedCodes.available_uses, 169 + disabled: paginatedCodes.disabled, 170 + created_at: paginatedCodes.created_at, 171 + use: t.inviteCodeUse, 172 + }) 173 + .from(paginatedCodes) 174 + .leftJoin(t.inviteCodeUse, eq(paginatedCodes.code, t.inviteCodeUse.code)) 175 + .orderBy(asc(paginatedCodes.created_at), asc(paginatedCodes.code)) 176 + .all(); 177 + 178 + // group rows by invite code 179 + const codesMap = new Map<string, InviteCodeWithUses>(); 180 + for (const row of rows) { 181 + let entry = codesMap.get(row.code); 182 + if (!entry) { 183 + entry = { 184 + code: row.code, 185 + available_uses: row.available_uses, 186 + disabled: row.disabled, 187 + created_at: row.created_at, 188 + uses: [], 189 + }; 190 + codesMap.set(row.code, entry); 191 + } 192 + if (row.use) { 193 + entry.uses.push(row.use); 194 + } 195 + } 196 + 197 + const codes = [...codesMap.values()]; 198 + 199 + // filter out fully used codes if requested 200 + const filtered = includeUsed 201 + ? codes 202 + : codes.filter((c) => c.available_uses === -1 || c.uses.length < c.available_uses); 203 + 204 + const last = filtered.at(-1); 205 + const nextCursor = last ? inviteCodeKeyset.pack(last.created_at, last.code) : undefined; 206 + 207 + return { codes: filtered, cursor: nextCursor }; 208 + } 209 + 210 + /** 211 + * disable invite codes. 212 + * @param codes list of invite codes to disable 213 + */ 214 + disableInviteCodes(codes: string[]): void { 215 + if (codes.length === 0) { 216 + return; 217 + } 218 + 219 + this.#db.update(t.inviteCode).set({ disabled: true }).where(inArray(t.inviteCode.code, codes)).run(); 220 + } 221 + 222 + /** 223 + * get invite code statistics. 224 + * @returns invite code stats 225 + */ 226 + getInviteCodeStats(): { 227 + total: number; 228 + available: number; 229 + disabled: number; 230 + used: number; 231 + } { 232 + const total = 233 + this.#db 234 + .select({ count: sql<number>`count(*)` }) 235 + .from(t.inviteCode) 236 + .get()?.count ?? 0; 237 + 238 + const disabled = 239 + this.#db 240 + .select({ count: sql<number>`count(*)` }) 241 + .from(t.inviteCode) 242 + .where(eq(t.inviteCode.disabled, true)) 243 + .get()?.count ?? 0; 244 + 245 + const used = 246 + this.#db 247 + .select({ count: sql<number>`count(distinct ${t.inviteCodeUse.code})` }) 248 + .from(t.inviteCodeUse) 249 + .get()?.count ?? 0; 250 + 251 + return { 252 + total: total, 253 + available: total - disabled, 254 + disabled: disabled, 255 + used: used, 256 + }; 257 + } 258 + }
+374
packages/danaus/src/accounts/legacy-auth.ts
··· 1 + import type { KeyObject } from 'node:crypto'; 2 + 3 + import type { Did } from '@atcute/lexicons'; 4 + import { InvalidRequestError } from '@atcute/xrpc-server'; 5 + 6 + import { and, eq, isNull, lte, or } from 'drizzle-orm'; 7 + import { nanoid } from 'nanoid'; 8 + 9 + import { createLegacySessionTokens, type LegacySessionTokens } from '#app/auth/legacy-tokens.ts'; 10 + import { AuthScope } from '#app/auth/scopes.ts'; 11 + import { DAY, HOUR } from '#app/utils/times.ts'; 12 + import { generateAppPassword } from '#app/utils/token.ts'; 13 + 14 + import { t, type AccountDb } from './db'; 15 + import { AppPasswordPrivilege } from './db/schema'; 16 + import type { Account, AccountManager } from './manager'; 17 + import { hashPassword, verifyPassword } from './passwords'; 18 + 19 + const LEGACY_ACCESS_TTL_MS = 2 * HOUR; 20 + const LEGACY_REFRESH_TTL_MS = 90 * DAY; 21 + const LEGACY_REFRESH_GRACE_TTL_MS = 2 * HOUR; 22 + 23 + export const MAX_APP_PASSWORDS = 25; 24 + 25 + export type AppPassword = typeof t.appPassword.$inferSelect; 26 + export type LegacySession = typeof t.legacySession.$inferSelect; 27 + 28 + interface LegacyAuthManagerOptions { 29 + db: AccountDb; 30 + jwtKey: KeyObject; 31 + serviceDid: Did; 32 + accountManager: AccountManager; 33 + } 34 + 35 + interface CreateAppPasswordOptions { 36 + did: Did; 37 + name: string; 38 + privilege: AppPasswordPrivilege; 39 + } 40 + 41 + interface CreateLegacySessionOptions { 42 + did: Did; 43 + appPassword: AppPassword; 44 + sessionId?: string; 45 + now?: Date; 46 + } 47 + 48 + export class LegacyAuthManager { 49 + readonly #db: AccountDb; 50 + readonly #jwtKey: KeyObject; 51 + readonly #serviceDid: Did; 52 + readonly #accountManager: AccountManager; 53 + 54 + constructor(options: LegacyAuthManagerOptions) { 55 + this.#db = options.db; 56 + this.#jwtKey = options.jwtKey; 57 + this.#serviceDid = options.serviceDid; 58 + this.#accountManager = options.accountManager; 59 + } 60 + 61 + // #region app passwords 62 + 63 + /** 64 + * create a new app password for an account. 65 + * @param options app password options 66 + * @returns app password details and secret 67 + */ 68 + async createAppPassword( 69 + options: CreateAppPasswordOptions, 70 + ): Promise<{ appPassword: AppPassword; secret: string }> { 71 + const existing = this.#db.select().from(t.appPassword).where(eq(t.appPassword.did, options.did)).all(); 72 + 73 + if (existing.length >= MAX_APP_PASSWORDS) { 74 + throw new InvalidRequestError({ 75 + error: 'TooManyAppPasswords', 76 + description: `cannot have more than ${MAX_APP_PASSWORDS} app passwords`, 77 + }); 78 + } 79 + 80 + if (existing.some((row) => row.name === options.name)) { 81 + throw new InvalidRequestError({ 82 + error: 'DuplicateAppPassword', 83 + description: `app password already exists`, 84 + }); 85 + } 86 + 87 + const secret = generateAppPassword(); 88 + const passwordHash = await hashPassword(secret); 89 + const now = new Date(); 90 + 91 + const inserted = this.#db 92 + .insert(t.appPassword) 93 + .values({ 94 + did: options.did, 95 + name: options.name, 96 + privilege: options.privilege, 97 + password_hash: passwordHash, 98 + created_at: now, 99 + }) 100 + .returning() 101 + .get(); 102 + 103 + if (!inserted) { 104 + throw new Error(`failed to create app password`); 105 + } 106 + 107 + return { appPassword: inserted, secret: secret }; 108 + } 109 + 110 + /** 111 + * list app passwords for an account. 112 + * @param did account did 113 + * @returns app passwords 114 + */ 115 + listAppPasswords(did: Did): AppPassword[] { 116 + const rows = this.#db.select().from(t.appPassword).where(eq(t.appPassword.did, did)).all(); 117 + 118 + return rows; 119 + } 120 + 121 + /** 122 + * delete an app password by name. 123 + * @param did account did 124 + * @param name app password name 125 + */ 126 + deleteAppPassword(did: Did, name: string): void { 127 + this.#db 128 + .delete(t.appPassword) 129 + .where(and(eq(t.appPassword.did, did), eq(t.appPassword.name, name))) 130 + .run(); 131 + } 132 + 133 + /** 134 + * verify identifier/password for legacy auth (app password only). 135 + * @param identifier handle, did, or email 136 + * @param password app password 137 + * @returns legacy auth result or null 138 + */ 139 + async verifyLegacyCredentials( 140 + identifier: string, 141 + password: string, 142 + ): Promise<{ account: Account; appPassword: AppPassword } | null> { 143 + const account = this.#accountManager.resolveAccount(identifier, { 144 + includeDeactivated: true, 145 + includeTakenDown: true, 146 + }); 147 + if (!account) { 148 + return null; 149 + } 150 + 151 + const appPassword = await this.#findAppPasswordMatch(account.did, password); 152 + if (!appPassword) { 153 + return null; 154 + } 155 + 156 + return { account, appPassword }; 157 + } 158 + 159 + async #findAppPasswordMatch(did: Did, password: string): Promise<AppPassword | null> { 160 + const rows = this.#db.select().from(t.appPassword).where(eq(t.appPassword.did, did)).all(); 161 + 162 + for (const row of rows) { 163 + const valid = await verifyPassword(password, row.password_hash); 164 + if (valid) { 165 + return row; 166 + } 167 + } 168 + 169 + return null; 170 + } 171 + 172 + // #endregion 173 + 174 + // #region legacy sessions 175 + 176 + /** 177 + * create a legacy refresh session and jwt pair. 178 + * @param options legacy session options 179 + * @returns legacy session jwt pair 180 + */ 181 + async createLegacySession(options: CreateLegacySessionOptions): Promise<LegacySessionTokens> { 182 + const now = options.now ?? new Date(); 183 + const expiresAt = new Date(now.getTime() + LEGACY_REFRESH_TTL_MS); 184 + const sessionId = options.sessionId ?? nanoid(24); 185 + 186 + const inserted = this.#db 187 + .insert(t.legacySession) 188 + .values({ 189 + id: sessionId, 190 + did: options.did, 191 + appPasswordId: options.appPassword.id, 192 + created_at: now, 193 + expires_at: expiresAt, 194 + next_id: null, 195 + }) 196 + .returning() 197 + .get(); 198 + 199 + if (!inserted) { 200 + throw new Error(`failed to create legacy session`); 201 + } 202 + 203 + return await this.#issueLegacyTokens({ 204 + did: options.did, 205 + privilege: options.appPassword.privilege, 206 + sessionId: sessionId, 207 + now: now, 208 + }); 209 + } 210 + 211 + /** 212 + * fetch a legacy session by id. 213 + * @param sessionId legacy session id 214 + * @returns legacy session or null 215 + */ 216 + getLegacySession(sessionId: string): LegacySession | null { 217 + const session = this.#db.select().from(t.legacySession).where(eq(t.legacySession.id, sessionId)).get(); 218 + if (!session) { 219 + return null; 220 + } 221 + 222 + const now = new Date(); 223 + if (session.expires_at <= now) { 224 + this.#db.delete(t.legacySession).where(eq(t.legacySession.id, sessionId)).run(); 225 + return null; 226 + } 227 + 228 + return session; 229 + } 230 + 231 + /** 232 + * delete a legacy session by id. 233 + * @param sessionId legacy session id 234 + */ 235 + deleteLegacySession(sessionId: string): void { 236 + this.#db.delete(t.legacySession).where(eq(t.legacySession.id, sessionId)).run(); 237 + } 238 + 239 + /** 240 + * rotate a legacy refresh token and return new tokens. 241 + * @param sessionId legacy session id 242 + * @returns session jwt pair 243 + */ 244 + async rotateLegacyRefresh(sessionId: string): Promise<LegacySessionTokens> { 245 + const row = this.#db 246 + .select({ 247 + sessionId: t.legacySession.id, 248 + did: t.legacySession.did, 249 + expiresAt: t.legacySession.expires_at, 250 + appPasswordId: t.legacySession.appPasswordId, 251 + nextId: t.legacySession.next_id, 252 + privilege: t.appPassword.privilege, 253 + }) 254 + .from(t.legacySession) 255 + .innerJoin(t.appPassword, eq(t.legacySession.appPasswordId, t.appPassword.id)) 256 + .where(eq(t.legacySession.id, sessionId)) 257 + .get(); 258 + 259 + if (row === undefined) { 260 + throw new InvalidRequestError({ 261 + error: 'InvalidToken', 262 + description: `refresh token is invalid`, 263 + }); 264 + } 265 + 266 + const now = new Date(); 267 + 268 + // tidy up all of the user's expired sessions 269 + this.#db 270 + .delete(t.legacySession) 271 + .where(and(eq(t.legacySession.did, row.did), lte(t.legacySession.expires_at, now))) 272 + .run(); 273 + 274 + if (row.expiresAt <= now) { 275 + throw new InvalidRequestError({ 276 + error: 'ExpiredToken', 277 + description: `refresh token has expired`, 278 + }); 279 + } 280 + 281 + const graceExpiresAt = new Date(now.getTime() + LEGACY_REFRESH_GRACE_TTL_MS); 282 + const shortenedExpiresAt = graceExpiresAt < row.expiresAt ? graceExpiresAt : row.expiresAt; 283 + 284 + const nextId = row.nextId ?? nanoid(24); 285 + 286 + const success = this.#db.transaction((tx) => { 287 + const updateResult = tx 288 + .update(t.legacySession) 289 + .set({ next_id: nextId, expires_at: shortenedExpiresAt }) 290 + .where( 291 + and( 292 + eq(t.legacySession.id, sessionId), 293 + or(isNull(t.legacySession.next_id), eq(t.legacySession.next_id, nextId)), 294 + ), 295 + ) 296 + .returning({ id: t.legacySession.id }) 297 + .get(); 298 + 299 + if (!updateResult) { 300 + return false; 301 + } 302 + 303 + const nextExpiresAt = new Date(now.getTime() + LEGACY_REFRESH_TTL_MS); 304 + tx.insert(t.legacySession) 305 + .values({ 306 + id: nextId, 307 + did: row.did, 308 + appPasswordId: row.appPasswordId, 309 + created_at: now, 310 + expires_at: nextExpiresAt, 311 + next_id: null, 312 + }) 313 + .onConflictDoNothing({ target: t.legacySession.id }) 314 + .run(); 315 + 316 + return true; 317 + }); 318 + 319 + if (!success) { 320 + return await this.rotateLegacyRefresh(sessionId); 321 + } 322 + 323 + return await this.#issueLegacyTokens({ 324 + did: row.did, 325 + privilege: row.privilege, 326 + sessionId: nextId, 327 + now: now, 328 + }); 329 + } 330 + 331 + #scopeForPrivilege(privilege: AppPasswordPrivilege): AuthScope { 332 + switch (privilege) { 333 + case AppPasswordPrivilege.Full: 334 + return AuthScope.Access; 335 + case AppPasswordPrivilege.Privileged: 336 + return AuthScope.AppPassPrivileged; 337 + case AppPasswordPrivilege.Limited: 338 + return AuthScope.AppPass; 339 + } 340 + 341 + throw new InvalidRequestError({ 342 + error: 'InvalidAppPasswordPrivilege', 343 + description: `invalid app password privilege`, 344 + }); 345 + } 346 + 347 + async #issueLegacyTokens(options: { 348 + did: Did; 349 + privilege: AppPasswordPrivilege; 350 + sessionId: string; 351 + now: Date; 352 + }): Promise<LegacySessionTokens> { 353 + return await createLegacySessionTokens( 354 + { 355 + did: options.did, 356 + scope: this.#scopeForPrivilege(options.privilege), 357 + serviceDid: this.#serviceDid, 358 + jwtKey: this.#jwtKey, 359 + issuedAt: options.now.getTime(), 360 + expiresInMs: LEGACY_ACCESS_TTL_MS, 361 + }, 362 + { 363 + did: options.did, 364 + serviceDid: this.#serviceDid, 365 + jwtKey: this.#jwtKey, 366 + sessionId: options.sessionId, 367 + issuedAt: options.now.getTime(), 368 + expiresInMs: LEGACY_REFRESH_TTL_MS, 369 + }, 370 + ); 371 + } 372 + 373 + // #endregion 374 + }
+97 -1555
packages/danaus/src/accounts/manager.ts
··· 1 - import type { KeyObject } from 'node:crypto'; 2 - 3 1 import { DidNotFoundError, InvalidResolvedHandleError, type HandleResolver } from '@atcute/identity-resolver'; 4 2 import type { ActorIdentifier, Did, Handle } from '@atcute/lexicons'; 5 3 import { isDid, isHandle } from '@atcute/lexicons/syntax'; 6 4 import { InvalidRequestError, UpstreamFailureError } from '@atcute/xrpc-server'; 7 5 8 - import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'; 9 - import { and, asc, eq, gt, inArray, isNotNull, isNull, like, lte, or, sql } from 'drizzle-orm'; 10 - import { nanoid } from 'nanoid'; 6 + import { and, asc, eq, gt, inArray, isNotNull, isNull, like, or, sql } from 'drizzle-orm'; 11 7 12 - import { createLegacySessionTokens, type LegacySessionTokens } from '#app/auth/legacy-tokens.ts'; 13 - import { AuthScope } from '#app/auth/scopes.ts'; 14 - import { createWebSessionToken } from '#app/auth/web.ts'; 15 8 import { TimeKeyset } from '#app/utils/keyset.ts'; 16 - import { DAY, HOUR } from '#app/utils/times.ts'; 17 - import { generateAppPassword, generateInviteCode } from '#app/utils/token.ts'; 18 9 19 - import { getAccountDb, t, type AccountDb } from './db'; 20 - import { AppPasswordPrivilege, EmailTokenPurpose, PreferredMfa, WebAuthnCredentialType } from './db/schema'; 10 + import { t, type AccountDb } from './db'; 11 + import { EmailTokenPurpose } from './db/schema'; 21 12 import { isServiceDomain, isValidTld } from './handle'; 22 13 import { hashPassword, verifyPassword } from './passwords'; 23 - import { generateBackupCodes, MAX_TOTP_CREDENTIALS, verifyTotpCode } from './totp'; 24 14 import { AccountStatus, formatAccountStatus } from './types'; 25 - import { MAX_WEBAUTHN_CREDENTIALS, WEBAUTHN_CHALLENGE_TTL_MS } from './webauthn'; 26 - 27 - const WEB_SESSION_TTL_MS = 7 * DAY; 28 - const WEB_SESSION_LONG_TTL_MS = 365 * DAY; 29 - const LEGACY_ACCESS_TTL_MS = 2 * HOUR; 30 - const LEGACY_REFRESH_TTL_MS = 90 * DAY; 31 - const LEGACY_REFRESH_GRACE_TTL_MS = 2 * HOUR; 32 - const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes 33 - const SUDO_MODE_TTL_MS = 15 * 60 * 1000; // 15 minutes 34 - export const MAX_APP_PASSWORDS = 25; 35 15 36 16 export type Account = typeof t.account.$inferSelect; 37 - export type AppPassword = typeof t.appPassword.$inferSelect; 38 - export type LegacySession = typeof t.legacySession.$inferSelect; 39 - export type WebSession = typeof t.webSession.$inferSelect; 40 - export type InviteCode = typeof t.inviteCode.$inferSelect; 41 - export type InviteCodeUse = typeof t.inviteCodeUse.$inferSelect; 42 - export type TotpCredential = typeof t.totpCredential.$inferSelect; 43 - export type BackupCode = typeof t.recoveryCode.$inferSelect; 44 - export type VerifyChallenge = typeof t.verifyChallenge.$inferSelect; 45 - export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect; 46 - export type WebauthnRegistrationChallenge = typeof t.webauthnRegistrationChallenge.$inferSelect; 47 - 48 - /** MFA status for an account */ 49 - /** WebAuthn credential type for MFA status */ 50 - export type WebAuthnType = false | 'security-key' | 'passkey' | 'mixed'; 51 - 52 - export interface MfaStatus { 53 - /** preferred MFA method */ 54 - preferred: PreferredMfa; 55 - /** has TOTP credentials */ 56 - hasTotp: boolean; 57 - /** WebAuthn credential type(s) registered */ 58 - webAuthnType: WebAuthnType; 59 - /** has recovery codes */ 60 - hasRecoveryCodes: boolean; 61 - } 62 - 63 - export interface InviteCodeWithUses extends InviteCode { 64 - uses: InviteCodeUse[]; 65 - } 66 17 67 18 const accountKeyset = new TimeKeyset<Did>(isDid); 68 - const inviteCodeKeyset = new TimeKeyset(); 69 19 70 20 interface AccountManagerOptions { 71 - location: string; 72 - walAutocheckpointDisabled: boolean; 73 - 74 - serviceDid: Did; 21 + db: AccountDb; 75 22 serviceHandleDomains: string[]; 76 - 77 23 handleResolver: HandleResolver; 78 - 79 - jwtKey: KeyObject; 80 24 } 81 25 82 - export class AccountManager implements Disposable { 83 - private readonly db: AccountDb; 84 - 85 - private readonly serviceDid: Did; 86 - private readonly serviceHandleDomains: string[]; 87 - 88 - private readonly handleResolver: HandleResolver; 89 - 90 - private readonly jwtKey: KeyObject; 26 + export class AccountManager { 27 + readonly #db: AccountDb; 28 + readonly #serviceHandleDomains: string[]; 29 + readonly #handleResolver: HandleResolver; 91 30 92 31 constructor(options: AccountManagerOptions) { 93 - this.db = getAccountDb(options.location, options.walAutocheckpointDisabled); 94 - 95 - this.serviceDid = options.serviceDid; 96 - this.serviceHandleDomains = options.serviceHandleDomains; 97 - 98 - this.handleResolver = options.handleResolver; 99 - 100 - this.jwtKey = options.jwtKey; 101 - } 102 - 103 - dispose() { 104 - this.db.$client.close(); 105 - } 106 - [Symbol.dispose]() { 107 - this.dispose(); 32 + this.#db = options.db; 33 + this.#serviceHandleDomains = options.serviceHandleDomains; 34 + this.#handleResolver = options.handleResolver; 108 35 } 109 36 37 + /** 38 + * get an account by did or handle. 39 + * @param actor did or handle 40 + * @param options availability options 41 + * @returns account or null 42 + */ 110 43 getAccount(actor: ActorIdentifier, options: AccountAvailabilityOptions = {}): Account | null { 111 44 const { includeDeactivated = false, includeTakenDown = false } = options; 112 45 113 - const found = this.db 46 + const found = this.#db 114 47 .select() 115 48 .from(t.account) 116 49 .where((f) => { ··· 125 58 return found ?? null; 126 59 } 127 60 61 + /** 62 + * get multiple accounts by did. 63 + * @param actors array of dids 64 + * @param options availability options 65 + * @returns map of did to account 66 + */ 128 67 getAccounts(actors: Did[], options: AccountAvailabilityOptions = {}): Map<Did, Account> { 129 68 const { includeDeactivated = false, includeTakenDown = false } = options; 130 69 131 - const found = this.db 70 + const found = this.#db 132 71 .select() 133 72 .from(t.account) 134 73 .where((f) => { ··· 158 97 const normalizedQuery = query?.trim().toLowerCase(); 159 98 const searchPattern = normalizedQuery ? `%${normalizedQuery}%` : null; 160 99 161 - const rows = this.db 100 + const rows = this.#db 162 101 .select() 163 102 .from(t.account) 164 103 .where((f) => { ··· 197 136 deleteScheduled: number; 198 137 } { 199 138 const total = 200 - this.db 139 + this.#db 201 140 .select({ count: sql<number>`count(*)` }) 202 141 .from(t.account) 203 142 .get()?.count ?? 0; 204 143 const active = 205 - this.db 144 + this.#db 206 145 .select({ count: sql<number>`count(*)` }) 207 146 .from(t.account) 208 147 .where(and(isNull(t.account.takedown_ref), isNull(t.account.deactivated_at))) 209 148 .get()?.count ?? 0; 210 149 const deactivated = 211 - this.db 150 + this.#db 212 151 .select({ count: sql<number>`count(*)` }) 213 152 .from(t.account) 214 153 .where(isNotNull(t.account.deactivated_at)) 215 154 .get()?.count ?? 0; 216 155 const takendown = 217 - this.db 156 + this.#db 218 157 .select({ count: sql<number>`count(*)` }) 219 158 .from(t.account) 220 159 .where(isNotNull(t.account.takedown_ref)) 221 160 .get()?.count ?? 0; 222 161 const deleteScheduled = 223 - this.db 162 + this.#db 224 163 .select({ count: sql<number>`count(*)` }) 225 164 .from(t.account) 226 165 .where(isNotNull(t.account.delete_at)) ··· 235 174 }; 236 175 } 237 176 177 + /** 178 + * get an account by email address. 179 + * @param email email address 180 + * @param options availability options 181 + * @returns account or null 182 + */ 238 183 getAccountByEmail(email: string, options: AccountAvailabilityOptions = {}): Account | null { 239 184 const { includeDeactivated = false, includeTakenDown = false } = options; 240 185 241 - const found = this.db 186 + const found = this.#db 242 187 .select() 243 188 .from(t.account) 244 189 .where((f) => { ··· 253 198 return found ?? null; 254 199 } 255 200 256 - getWebSession(sessionId: string): WebSession | null { 257 - const session = this.db.select().from(t.webSession).where(eq(t.webSession.id, sessionId)).get(); 258 - if (!session) { 259 - return null; 260 - } 261 - 262 - const now = new Date(); 263 - if (session.expires_at <= now) { 264 - this.db.delete(t.webSession).where(eq(t.webSession.id, sessionId)).run(); 265 - return null; 266 - } 267 - 268 - return session; 269 - } 270 - 271 - isAccountActivated(did: Did): boolean { 272 - const account = this.getAccount(did, { includeDeactivated: true }); 273 - if (account === null) { 274 - return false; 275 - } 276 - 277 - return account.deactivated_at !== null; 278 - } 279 - 201 + /** 202 + * get the did for an actor. 203 + * @param actor did or handle 204 + * @param options availability options 205 + * @returns did or null 206 + */ 280 207 getAccountDid(actor: ActorIdentifier, options?: AccountAvailabilityOptions): Did | null { 281 208 const account = this.getAccount(actor, options); 282 209 if (account === null) { ··· 286 213 return account.did; 287 214 } 288 215 216 + /** 217 + * get the status of an account. 218 + * @param actor did or handle 219 + * @returns account status 220 + */ 289 221 getAccountStatus(actor: ActorIdentifier): AccountStatus { 290 222 const account = this.getAccount(actor, { 291 223 includeDeactivated: true, ··· 322 254 }; 323 255 } 324 256 325 - async validateHandle(handle: Handle, options: ValidateHandleOptions = {}) { 257 + /** 258 + * check if an account is activated (not deactivated). 259 + * @param did account did 260 + * @returns true if activated 261 + */ 262 + isAccountActivated(did: Did): boolean { 263 + const account = this.getAccount(did, { includeDeactivated: true }); 264 + if (account === null) { 265 + return false; 266 + } 267 + 268 + return account.deactivated_at === null; 269 + } 270 + 271 + /** 272 + * resolve an identifier (handle, did, or email) to an account. 273 + * @param identifier handle, did, or email 274 + * @param options availability options 275 + * @returns account or null 276 + */ 277 + resolveAccount(identifier: string, options: AccountAvailabilityOptions = {}): Account | null { 278 + if (isDid(identifier) || isHandle(identifier)) { 279 + return this.getAccount(identifier as ActorIdentifier, options); 280 + } 281 + 282 + return this.getAccountByEmail(identifier, options); 283 + } 284 + 285 + /** 286 + * validate a handle for use. 287 + * @param handle handle to validate 288 + * @param options validation options 289 + * @returns normalized handle 290 + */ 291 + async validateHandle(handle: Handle, options: ValidateHandleOptions = {}): Promise<Handle> { 326 292 const { did } = options; 327 293 328 - // Normalize to lowercase 294 + // normalize to lowercase 329 295 handle = handle.toLowerCase() as Handle; 330 296 331 297 if (!isValidTld(handle)) { ··· 335 301 }); 336 302 } 337 303 338 - if (isServiceDomain(handle, this.serviceHandleDomains)) { 339 - const suffix = this.serviceHandleDomains.find((s) => handle.endsWith(s))!; 304 + if (isServiceDomain(handle, this.#serviceHandleDomains)) { 305 + const suffix = this.#serviceHandleDomains.find((s) => handle.endsWith(s))!; 340 306 const front = handle.slice(0, handle.length - suffix.length); 341 307 342 308 if (front.includes('.')) { ··· 371 337 372 338 let resolvedDid: Did | undefined; 373 339 jmp: try { 374 - resolvedDid = await this.handleResolver.resolve(handle, { noCache: true }); 340 + resolvedDid = await this.#handleResolver.resolve(handle, { noCache: true }); 375 341 } catch (err) { 376 342 if (err instanceof DidNotFoundError) { 377 343 break jmp; ··· 406 372 * @returns account or null 407 373 */ 408 374 async verifyAccountPassword(identifier: string, password: string): Promise<Account | null> { 409 - const account = this.resolveIdentifier(identifier, { 375 + const account = this.resolveAccount(identifier, { 410 376 includeDeactivated: true, 411 377 includeTakenDown: true, 412 378 }); ··· 423 389 } 424 390 425 391 /** 426 - * verify identifier/password for legacy auth (app password only). 427 - * @param identifier handle, did, or email 428 - * @param password app password 429 - * @returns legacy auth result or null 430 - */ 431 - async verifyLegacyCredentials( 432 - identifier: string, 433 - password: string, 434 - ): Promise<{ account: Account; appPassword: AppPassword } | null> { 435 - const account = this.resolveIdentifier(identifier, { 436 - includeDeactivated: true, 437 - includeTakenDown: true, 438 - }); 439 - if (!account) { 440 - return null; 441 - } 442 - 443 - const appPassword = await this.findAppPasswordMatch(account.did, password); 444 - if (!appPassword) { 445 - return null; 446 - } 447 - 448 - return { account, appPassword }; 449 - } 450 - 451 - /** 452 392 * create a new account record. 453 393 * @param options account creation options 454 394 * @returns created account ··· 459 399 460 400 const passwordHash = await hashPassword(options.password); 461 401 462 - const inserted = this.db 402 + const inserted = this.#db 463 403 .insert(t.account) 464 404 .values({ 465 405 did: options.did, ··· 492 432 }); 493 433 } 494 434 495 - this.db.transaction((tx) => { 435 + this.#db.transaction((tx) => { 496 436 tx.update(t.account) 497 437 .set({ email: email, email_confirmed_at: null }) 498 438 .where(eq(t.account.did, options.did)) ··· 515 455 * @param handle new handle 516 456 */ 517 457 updateAccountHandle(did: Did, handle: Handle): void { 518 - this.db 458 + this.#db 519 459 .update(t.account) 520 460 .set({ handle: handle.toLowerCase() as Handle }) 521 461 .where(eq(t.account.did, did)) ··· 529 469 async updateAccountPassword(options: { did: Did; password: string }): Promise<void> { 530 470 const passwordHash = await hashPassword(options.password); 531 471 532 - this.db.transaction((tx) => { 472 + this.#db.transaction((tx) => { 533 473 tx.update(t.account).set({ password_hash: passwordHash }).where(eq(t.account.did, options.did)).run(); 534 474 tx.delete(t.legacySession).where(eq(t.legacySession.did, options.did)).run(); 535 475 tx.delete(t.webSession).where(eq(t.webSession.did, options.did)).run(); ··· 550 490 updateAccountTakedownStatus(did: Did, takedown: { applied: boolean; ref?: string }): void { 551 491 const ref = takedown.applied ? (takedown.ref ?? 'admin') : null; 552 492 553 - this.db.update(t.account).set({ takedown_ref: ref }).where(eq(t.account.did, did)).run(); 493 + this.#db.update(t.account).set({ takedown_ref: ref }).where(eq(t.account.did, did)).run(); 554 494 555 495 if (takedown.applied) { 556 - this.db.delete(t.legacySession).where(eq(t.legacySession.did, did)).run(); 557 - this.db.delete(t.webSession).where(eq(t.webSession.did, did)).run(); 496 + this.#db.delete(t.legacySession).where(eq(t.legacySession.did, did)).run(); 497 + this.#db.delete(t.webSession).where(eq(t.webSession.did, did)).run(); 558 498 } 559 499 } 560 500 ··· 564 504 * @param deactivated deactivated status 565 505 */ 566 506 updateAccountDeactivatedStatus(did: Did, deactivated: { applied: boolean }): void { 567 - this.db 507 + this.#db 568 508 .update(t.account) 569 509 .set({ deactivated_at: deactivated.applied ? new Date() : null }) 570 510 .where(eq(t.account.did, did)) 571 511 .run(); 572 512 } 573 513 574 - /** 575 - * create a web session record. 576 - * @param options web session options 577 - * @returns session and signed token 578 - */ 579 - async createWebSession(options: CreateWebSessionOptions): Promise<{ session: WebSession; token: string }> { 580 - const now = new Date(); 581 - const expiresAt = new Date( 582 - now.getTime() + (options.remember ? WEB_SESSION_LONG_TTL_MS : WEB_SESSION_TTL_MS), 583 - ); 584 - const id = nanoid(44); 585 - 586 - const inserted = this.db 587 - .insert(t.webSession) 588 - .values({ 589 - id: id, 590 - did: options.did, 591 - metadata: { 592 - userAgent: options.userAgent, 593 - ip: options.ip, 594 - }, 595 - created_at: now, 596 - expires_at: expiresAt, 597 - }) 598 - .returning() 599 - .get(); 600 - 601 - if (!inserted) { 602 - throw new Error(`failed to create web session`); 603 - } 604 - 605 - const token = createWebSessionToken(this.jwtKey, id); 606 - 607 - return { session: inserted, token: token }; 608 - } 609 - 610 - /** 611 - * delete a web session. 612 - * @param sessionId session id 613 - */ 614 - deleteWebSession(sessionId: string): void { 615 - this.db.delete(t.webSession).where(eq(t.webSession.id, sessionId)).run(); 616 - } 617 - 618 - /** 619 - * create a legacy refresh session and jwt pair. 620 - * @param options legacy session options 621 - * @returns legacy session jwt pair 622 - */ 623 - async createLegacySession(options: CreateLegacySessionOptions): Promise<LegacySessionTokens> { 624 - const now = options.now ?? new Date(); 625 - const expiresAt = new Date(now.getTime() + LEGACY_REFRESH_TTL_MS); 626 - const sessionId = options.sessionId ?? nanoid(24); 627 - 628 - const inserted = this.db 629 - .insert(t.legacySession) 630 - .values({ 631 - id: sessionId, 632 - did: options.did, 633 - appPasswordId: options.appPassword.id, 634 - created_at: now, 635 - expires_at: expiresAt, 636 - next_id: null, 637 - }) 638 - .returning() 639 - .get(); 640 - 641 - if (!inserted) { 642 - throw new Error(`failed to create legacy session`); 643 - } 644 - 645 - return await this.issueLegacyTokens({ 646 - did: options.did, 647 - privilege: options.appPassword.privilege, 648 - sessionId: sessionId, 649 - now: now, 650 - }); 651 - } 652 - 653 - /** 654 - * fetch a legacy session by id. 655 - * @param sessionId legacy session id 656 - * @returns legacy session or null 657 - */ 658 - getLegacySession(sessionId: string): LegacySession | null { 659 - const session = this.db.select().from(t.legacySession).where(eq(t.legacySession.id, sessionId)).get(); 660 - if (!session) { 661 - return null; 662 - } 663 - 664 - const now = new Date(); 665 - if (session.expires_at <= now) { 666 - this.db.delete(t.legacySession).where(eq(t.legacySession.id, sessionId)).run(); 667 - return null; 668 - } 669 - 670 - return session; 671 - } 672 - 673 - /** 674 - * delete a legacy session by id. 675 - * @param sessionId legacy session id 676 - */ 677 - deleteLegacySession(sessionId: string): void { 678 - this.db.delete(t.legacySession).where(eq(t.legacySession.id, sessionId)).run(); 679 - } 680 - 681 - /** 682 - * rotate a legacy refresh token and return new tokens. 683 - * @param sessionId legacy session id 684 - * @returns session jwt pair 685 - */ 686 - async rotateLegacyRefresh(sessionId: string): Promise<LegacySessionTokens> { 687 - const row = this.db 688 - .select({ 689 - sessionId: t.legacySession.id, 690 - did: t.legacySession.did, 691 - expiresAt: t.legacySession.expires_at, 692 - appPasswordId: t.legacySession.appPasswordId, 693 - nextId: t.legacySession.next_id, 694 - privilege: t.appPassword.privilege, 695 - }) 696 - .from(t.legacySession) 697 - .innerJoin(t.appPassword, eq(t.legacySession.appPasswordId, t.appPassword.id)) 698 - .where(eq(t.legacySession.id, sessionId)) 699 - .get(); 700 - 701 - if (row === undefined) { 702 - throw new InvalidRequestError({ 703 - error: 'InvalidToken', 704 - description: `refresh token is invalid`, 705 - }); 706 - } 707 - 708 - const now = new Date(); 709 - 710 - // tidy up all of the user's expired sessions 711 - this.db 712 - .delete(t.legacySession) 713 - .where(and(eq(t.legacySession.did, row.did), lte(t.legacySession.expires_at, now))) 714 - .run(); 715 - 716 - if (row.expiresAt <= now) { 717 - throw new InvalidRequestError({ 718 - error: 'ExpiredToken', 719 - description: `refresh token has expired`, 720 - }); 721 - } 722 - 723 - const graceExpiresAt = new Date(now.getTime() + LEGACY_REFRESH_GRACE_TTL_MS); 724 - const shortenedExpiresAt = graceExpiresAt < row.expiresAt ? graceExpiresAt : row.expiresAt; 725 - 726 - const nextId = row.nextId ?? nanoid(24); 727 - 728 - const success = this.db.transaction((tx) => { 729 - const updateResult = tx 730 - .update(t.legacySession) 731 - .set({ next_id: nextId, expires_at: shortenedExpiresAt }) 732 - .where( 733 - and( 734 - eq(t.legacySession.id, sessionId), 735 - or(isNull(t.legacySession.next_id), eq(t.legacySession.next_id, nextId)), 736 - ), 737 - ) 738 - .returning({ id: t.legacySession.id }) 739 - .get(); 740 - 741 - if (!updateResult) { 742 - return false; 743 - } 744 - 745 - const nextExpiresAt = new Date(now.getTime() + LEGACY_REFRESH_TTL_MS); 746 - tx.insert(t.legacySession) 747 - .values({ 748 - id: nextId, 749 - did: row.did, 750 - appPasswordId: row.appPasswordId, 751 - created_at: now, 752 - expires_at: nextExpiresAt, 753 - next_id: null, 754 - }) 755 - .onConflictDoNothing({ target: t.legacySession.id }) 756 - .run(); 757 - 758 - return true; 759 - }); 760 - 761 - if (!success) { 762 - return await this.rotateLegacyRefresh(sessionId); 763 - } 764 - 765 - return await this.issueLegacyTokens({ 766 - did: row.did, 767 - privilege: row.privilege, 768 - sessionId: nextId, 769 - now: now, 770 - }); 771 - } 772 - 773 - /** 774 - * create a new app password for an account. 775 - * @param options app password options 776 - * @returns app password details and secret 777 - */ 778 - async createAppPassword( 779 - options: CreateAppPasswordOptions, 780 - ): Promise<{ appPassword: AppPassword; secret: string }> { 781 - const existing = this.db.select().from(t.appPassword).where(eq(t.appPassword.did, options.did)).all(); 782 - 783 - if (existing.length >= MAX_APP_PASSWORDS) { 784 - throw new InvalidRequestError({ 785 - error: 'TooManyAppPasswords', 786 - description: `cannot have more than ${MAX_APP_PASSWORDS} app passwords`, 787 - }); 788 - } 789 - 790 - if (existing.some((row) => row.name === options.name)) { 791 - throw new InvalidRequestError({ 792 - error: 'DuplicateAppPassword', 793 - description: `app password already exists`, 794 - }); 795 - } 796 - 797 - const secret = generateAppPassword(); 798 - const passwordHash = await hashPassword(secret); 799 - const now = new Date(); 800 - 801 - const inserted = this.db 802 - .insert(t.appPassword) 803 - .values({ 804 - did: options.did, 805 - name: options.name, 806 - privilege: options.privilege, 807 - password_hash: passwordHash, 808 - created_at: now, 809 - }) 810 - .returning() 811 - .get(); 812 - 813 - if (!inserted) { 814 - throw new Error(`failed to create app password`); 815 - } 816 - 817 - return { appPassword: inserted, secret: secret }; 818 - } 819 - 820 - /** 821 - * list app passwords for an account. 822 - * @param did account did 823 - * @returns app passwords 824 - */ 825 - listAppPasswords(did: Did): AppPassword[] { 826 - const rows = this.db.select().from(t.appPassword).where(eq(t.appPassword.did, did)).all(); 827 - 828 - return rows; 829 - } 830 - 831 - /** 832 - * delete an app password by name. 833 - * @param did account did 834 - * @param name app password name 835 - */ 836 - deleteAppPassword(did: Did, name: string): void { 837 - this.db 838 - .delete(t.appPassword) 839 - .where(and(eq(t.appPassword.did, did), eq(t.appPassword.name, name))) 840 - .run(); 841 - } 842 - 843 - // #region invite codes 844 - 845 - /** 846 - * create invite codes. 847 - * @param count number of codes to create 848 - * @param availableUses uses per code (0 for unlimited) 849 - * @returns created invite codes 850 - */ 851 - createInviteCodes(count: number, availableUses: number): InviteCode[] { 852 - const now = new Date(); 853 - const codes: InviteCode[] = []; 854 - 855 - for (let i = 0; i < count; i++) { 856 - const code = generateInviteCode(); 857 - const inserted = this.db 858 - .insert(t.inviteCode) 859 - .values({ 860 - code: code, 861 - available_uses: availableUses === 0 ? -1 : availableUses, 862 - disabled: false, 863 - created_at: now, 864 - }) 865 - .returning() 866 - .get(); 867 - 868 - if (inserted) { 869 - codes.push(inserted); 870 - } 871 - } 872 - 873 - return codes; 874 - } 875 - 876 - /** 877 - * check if an invite code is available for use. 878 - * @param code invite code 879 - * @throws InvalidRequestError if code is not available 880 - */ 881 - ensureInviteIsAvailable(code: string): void { 882 - const invite = this.db.select().from(t.inviteCode).where(eq(t.inviteCode.code, code)).get(); 883 - 884 - if (!invite || invite.disabled) { 885 - throw new InvalidRequestError({ 886 - error: 'InvalidInviteCode', 887 - description: 'provided invite code not available', 888 - }); 889 - } 890 - 891 - // -1 means unlimited uses 892 - if (invite.available_uses !== -1) { 893 - const uses = 894 - this.db 895 - .select({ count: sql<number>`count(*)` }) 896 - .from(t.inviteCodeUse) 897 - .where(eq(t.inviteCodeUse.code, code)) 898 - .get()?.count ?? 0; 899 - 900 - if (uses >= invite.available_uses) { 901 - throw new InvalidRequestError({ 902 - error: 'InvalidInviteCode', 903 - description: 'provided invite code not available', 904 - }); 905 - } 906 - } 907 - } 908 - 909 - /** 910 - * record an invite code use. 911 - * @param code invite code 912 - * @param usedBy DID of the account that used the code 913 - */ 914 - recordInviteUse(code: string, usedBy: Did): void { 915 - this.db 916 - .insert(t.inviteCodeUse) 917 - .values({ 918 - code: code, 919 - used_by: usedBy, 920 - used_at: new Date(), 921 - }) 922 - .run(); 923 - } 924 - 925 - /** 926 - * get invite code details with usage. 927 - * @param code invite code 928 - * @returns invite code with uses or null 929 - */ 930 - getInviteCode(code: string): InviteCodeWithUses | null { 931 - const invite = this.db.select().from(t.inviteCode).where(eq(t.inviteCode.code, code)).get(); 932 - 933 - if (!invite) { 934 - return null; 935 - } 936 - 937 - const uses = this.db.select().from(t.inviteCodeUse).where(eq(t.inviteCodeUse.code, code)).all(); 938 - 939 - return { ...invite, uses }; 940 - } 941 - 942 - /** 943 - * list invite codes with pagination. 944 - * @param options list options 945 - * @returns invite codes and cursor 946 - */ 947 - listInviteCodes(options: ListInviteCodesOptions = {}): { 948 - codes: InviteCodeWithUses[]; 949 - cursor?: string; 950 - } { 951 - const { limit = 50, cursor, includeDisabled = false, includeUsed = true } = options; 952 - const parsed = inviteCodeKeyset.unpackOptional(cursor); 953 - 954 - // paginate codes first in a CTE, then LEFT JOIN with uses 955 - const paginatedCodes = this.db.$with('paginated_codes').as( 956 - this.db 957 - .select() 958 - .from(t.inviteCode) 959 - .where((f) => { 960 - return and( 961 - !includeDisabled ? eq(f.disabled, false) : undefined, 962 - parsed 963 - ? or(gt(f.created_at, parsed.time), and(eq(f.created_at, parsed.time), gt(f.code, parsed.key))) 964 - : undefined, 965 - ); 966 - }) 967 - .orderBy(asc(t.inviteCode.created_at), asc(t.inviteCode.code)) 968 - .limit(limit), 969 - ); 970 - 971 - const rows = this.db 972 - .with(paginatedCodes) 973 - .select({ 974 - code: paginatedCodes.code, 975 - available_uses: paginatedCodes.available_uses, 976 - disabled: paginatedCodes.disabled, 977 - created_at: paginatedCodes.created_at, 978 - use: t.inviteCodeUse, 979 - }) 980 - .from(paginatedCodes) 981 - .leftJoin(t.inviteCodeUse, eq(paginatedCodes.code, t.inviteCodeUse.code)) 982 - .orderBy(asc(paginatedCodes.created_at), asc(paginatedCodes.code)) 983 - .all(); 984 - 985 - // group rows by invite code 986 - const codesMap = new Map<string, InviteCodeWithUses>(); 987 - for (const row of rows) { 988 - let entry = codesMap.get(row.code); 989 - if (!entry) { 990 - entry = { 991 - code: row.code, 992 - available_uses: row.available_uses, 993 - disabled: row.disabled, 994 - created_at: row.created_at, 995 - uses: [], 996 - }; 997 - codesMap.set(row.code, entry); 998 - } 999 - if (row.use) { 1000 - entry.uses.push(row.use); 1001 - } 1002 - } 1003 - 1004 - const codes = [...codesMap.values()]; 1005 - 1006 - // filter out fully used codes if requested 1007 - const filtered = includeUsed 1008 - ? codes 1009 - : codes.filter((c) => c.available_uses === -1 || c.uses.length < c.available_uses); 1010 - 1011 - const last = filtered.at(-1); 1012 - const nextCursor = last ? inviteCodeKeyset.pack(last.created_at, last.code) : undefined; 1013 - 1014 - return { codes: filtered, cursor: nextCursor }; 1015 - } 1016 - 1017 - /** 1018 - * disable invite codes. 1019 - * @param codes list of invite codes to disable 1020 - */ 1021 - disableInviteCodes(codes: string[]): void { 1022 - if (codes.length === 0) { 1023 - return; 1024 - } 1025 - 1026 - this.db.update(t.inviteCode).set({ disabled: true }).where(inArray(t.inviteCode.code, codes)).run(); 1027 - } 1028 - 1029 - /** 1030 - * get invite code statistics. 1031 - * @returns invite code stats 1032 - */ 1033 - getInviteCodeStats(): { 1034 - total: number; 1035 - available: number; 1036 - disabled: number; 1037 - used: number; 1038 - } { 1039 - const total = 1040 - this.db 1041 - .select({ count: sql<number>`count(*)` }) 1042 - .from(t.inviteCode) 1043 - .get()?.count ?? 0; 1044 - 1045 - const disabled = 1046 - this.db 1047 - .select({ count: sql<number>`count(*)` }) 1048 - .from(t.inviteCode) 1049 - .where(eq(t.inviteCode.disabled, true)) 1050 - .get()?.count ?? 0; 1051 - 1052 - const used = 1053 - this.db 1054 - .select({ count: sql<number>`count(distinct ${t.inviteCodeUse.code})` }) 1055 - .from(t.inviteCodeUse) 1056 - .get()?.count ?? 0; 1057 - 1058 - return { 1059 - total: total, 1060 - available: total - disabled, 1061 - disabled: disabled, 1062 - used: used, 1063 - }; 1064 - } 1065 - 1066 - // #endregion 1067 - 1068 - // #region TOTP two-factor authentication 1069 - 1070 - /** 1071 - * create a TOTP credential for an account. 1072 - * @param options TOTP credential options 1073 - * @returns created credential 1074 - */ 1075 - createTotpCredential(options: CreateTotpCredentialOptions): TotpCredential { 1076 - const count = this.#countTotpCredentials(options.did); 1077 - if (count >= MAX_TOTP_CREDENTIALS) { 1078 - throw new InvalidRequestError({ 1079 - error: 'TooManyTotpCredentials', 1080 - description: `cannot have more than ${MAX_TOTP_CREDENTIALS} authenticators`, 1081 - }); 1082 - } 1083 - 1084 - const name = options.name?.trim() || this.generateTotpName(options.did); 1085 - 1086 - // check for duplicate name 1087 - const existing = this.db 1088 - .select() 1089 - .from(t.totpCredential) 1090 - .where(and(eq(t.totpCredential.did, options.did), eq(t.totpCredential.name, name))) 1091 - .get(); 1092 - 1093 - if (existing) { 1094 - throw new InvalidRequestError({ 1095 - error: 'DuplicateTotpName', 1096 - description: `an authenticator with this name already exists`, 1097 - }); 1098 - } 1099 - 1100 - const inserted = this.db 1101 - .insert(t.totpCredential) 1102 - .values({ 1103 - did: options.did, 1104 - name: name, 1105 - secret: Buffer.from(options.secret), 1106 - created_at: new Date(), 1107 - last_used_counter: options.lastUsedCounter, 1108 - }) 1109 - .returning() 1110 - .get(); 1111 - 1112 - if (!inserted) { 1113 - throw new Error(`failed to create TOTP credential`); 1114 - } 1115 - 1116 - this.#syncPreferredMfa(options.did); 1117 - 1118 - return inserted; 1119 - } 1120 - 1121 - /** 1122 - * list TOTP credentials for an account. 1123 - * @param did account did 1124 - * @returns TOTP credentials 1125 - */ 1126 - listTotpCredentials(did: Did): TotpCredential[] { 1127 - return this.db.select().from(t.totpCredential).where(eq(t.totpCredential.did, did)).all(); 1128 - } 1129 - 1130 - /** 1131 - * get a TOTP credential by id. 1132 - * @param did account did 1133 - * @param id credential id 1134 - * @returns TOTP credential or null 1135 - */ 1136 - getTotpCredential(did: Did, id: number): TotpCredential | null { 1137 - const credential = this.db 1138 - .select() 1139 - .from(t.totpCredential) 1140 - .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id))) 1141 - .get(); 1142 - 1143 - return credential ?? null; 1144 - } 1145 - 1146 - /** 1147 - * delete a TOTP credential. 1148 - * @param did account did 1149 - * @param id credential id 1150 - */ 1151 - deleteTotpCredential(did: Did, id: number): void { 1152 - this.db 1153 - .delete(t.totpCredential) 1154 - .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id))) 1155 - .run(); 1156 - 1157 - this.#syncPreferredMfa(did); 1158 - } 1159 - 1160 - /** 1161 - * sync preferred_mfa to reflect current MFA credentials. 1162 - * - if null and credentials exist → set to first available type 1163 - * - if set but that type has no credentials → switch to another type or clear 1164 - */ 1165 - #syncPreferredMfa(did: Did): void { 1166 - const account = this.db 1167 - .select({ preferred_mfa: t.account.preferred_mfa }) 1168 - .from(t.account) 1169 - .where(eq(t.account.did, did)) 1170 - .get(); 1171 - 1172 - if (!account) { 1173 - return; 1174 - } 1175 - 1176 - const hasTotp = this.#countTotpCredentials(did) > 0; 1177 - const hasWebAuthn = this.countWebAuthnCredentials(did) > 0; 1178 - 1179 - // check if current preference is still valid 1180 - if (account.preferred_mfa === PreferredMfa.Totp && hasTotp) { 1181 - return; 1182 - } 1183 - if (account.preferred_mfa === PreferredMfa.WebAuthn && hasWebAuthn) { 1184 - return; 1185 - } 1186 - 1187 - // need to set or switch: prefer the type that was just added (TOTP first for backwards compat) 1188 - let newPreferred: PreferredMfa | null = null; 1189 - if (hasTotp) { 1190 - newPreferred = PreferredMfa.Totp; 1191 - } else if (hasWebAuthn) { 1192 - newPreferred = PreferredMfa.WebAuthn; 1193 - } 1194 - 1195 - if (newPreferred !== account.preferred_mfa) { 1196 - this.db.update(t.account).set({ preferred_mfa: newPreferred }).where(eq(t.account.did, did)).run(); 1197 - } 1198 - } 1199 - 1200 - /** 1201 - * get MFA status for an account. 1202 - * @param did account did 1203 - * @returns MFA status with preferred method and available methods, or null if no MFA configured 1204 - */ 1205 - getMfaStatus(did: Did): MfaStatus | null { 1206 - const account = this.db 1207 - .select({ preferred_mfa: t.account.preferred_mfa }) 1208 - .from(t.account) 1209 - .where(eq(t.account.did, did)) 1210 - .get(); 1211 - 1212 - if (!account || account.preferred_mfa == null) { 1213 - return null; 1214 - } 1215 - 1216 - return { 1217 - preferred: account.preferred_mfa, 1218 - hasTotp: this.#countTotpCredentials(did) > 0, 1219 - webAuthnType: this.#getWebAuthnType(did), 1220 - hasRecoveryCodes: this.getRecoveryCodeCount(did) > 0, 1221 - }; 1222 - } 1223 - 1224 - /** 1225 - * count TOTP credentials for an account. 1226 - * @param did account did 1227 - * @returns number of credentials 1228 - */ 1229 - #countTotpCredentials(did: Did): number { 1230 - return ( 1231 - this.db 1232 - .select({ count: sql<number>`count(*)` }) 1233 - .from(t.totpCredential) 1234 - .where(eq(t.totpCredential.did, did)) 1235 - .get()?.count ?? 0 1236 - ); 1237 - } 1238 - 1239 - /** 1240 - * verify a TOTP code against any of the account's credentials. 1241 - * updates last_used_counter on successful verification to prevent replay attacks. 1242 - * @param did account did 1243 - * @param code the code to verify 1244 - * @returns true if the code is valid for any credential 1245 - */ 1246 - async verifyAccountTotpCode(did: Did, code: string): Promise<boolean> { 1247 - const credentials = this.listTotpCredentials(did); 1248 - 1249 - for (const credential of credentials) { 1250 - const counter = await verifyTotpCode(credential.secret, code, credential.last_used_counter); 1251 - 1252 - if (counter !== null) { 1253 - // update last_used_counter to prevent replay attacks 1254 - this.db 1255 - .update(t.totpCredential) 1256 - .set({ last_used_counter: counter }) 1257 - .where(eq(t.totpCredential.id, credential.id)) 1258 - .run(); 1259 - 1260 - return true; 1261 - } 1262 - } 1263 - 1264 - return false; 1265 - } 1266 - 1267 - /** 1268 - * generate a unique name for a new TOTP credential. 1269 - * @param did account did 1270 - * @returns generated name like "Authenticator" or "Authenticator 2" 1271 - */ 1272 - generateTotpName(did: Did): string { 1273 - const existing = this.listTotpCredentials(did); 1274 - const baseName = 'Authenticator'; 1275 - 1276 - if (existing.length === 0) { 1277 - return baseName; 1278 - } 1279 - 1280 - // find the next available number 1281 - const existingNames = new Set(existing.map((c) => c.name)); 1282 - let num = 2; 1283 - while (existingNames.has(`${baseName} ${num}`)) { 1284 - num++; 1285 - } 1286 - 1287 - return `${baseName} ${num}`; 1288 - } 1289 - 1290 - // #endregion 1291 - 1292 - // #region backup codes 1293 - 1294 - /** 1295 - * generate and store recovery codes for an account. 1296 - * deletes any existing codes first. 1297 - * @param did account did 1298 - */ 1299 - generateRecoveryCodes(did: Did): void { 1300 - const codes = generateBackupCodes(); 1301 - const now = new Date(); 1302 - 1303 - this.db.transaction((tx) => { 1304 - tx.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run(); 1305 - 1306 - for (const code of codes) { 1307 - tx.insert(t.recoveryCode) 1308 - .values({ 1309 - did: did, 1310 - code: code, 1311 - created_at: now, 1312 - }) 1313 - .run(); 1314 - } 1315 - }); 1316 - } 1317 - 1318 - /** 1319 - * get all unused recovery codes for an account. 1320 - * @param did account did 1321 - * @returns array of unused codes 1322 - */ 1323 - getRecoveryCodes(did: Did): string[] { 1324 - return this.db 1325 - .select({ code: t.recoveryCode.code }) 1326 - .from(t.recoveryCode) 1327 - .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at))) 1328 - .all() 1329 - .map((row) => row.code); 1330 - } 1331 - 1332 - /** 1333 - * get count of unused recovery codes. 1334 - * @param did account did 1335 - * @returns number of unused codes 1336 - */ 1337 - getRecoveryCodeCount(did: Did): number { 1338 - return ( 1339 - this.db 1340 - .select({ count: sql<number>`count(*)` }) 1341 - .from(t.recoveryCode) 1342 - .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at))) 1343 - .get()?.count ?? 0 1344 - ); 1345 - } 1346 - 1347 - /** 1348 - * verify and consume a recovery code. 1349 - * @param did account did 1350 - * @param code the code to verify 1351 - * @returns true if the code was valid and consumed 1352 - */ 1353 - consumeRecoveryCode(did: Did, code: string): boolean { 1354 - const result = this.db 1355 - .update(t.recoveryCode) 1356 - .set({ used_at: new Date() }) 1357 - .where(and(eq(t.recoveryCode.did, did), eq(t.recoveryCode.code, code), isNull(t.recoveryCode.used_at))) 1358 - .returning() 1359 - .get(); 1360 - 1361 - return result != null; 1362 - } 1363 - 1364 - /** 1365 - * delete all recovery codes for an account. 1366 - * @param did account did 1367 - */ 1368 - deleteRecoveryCodes(did: Did): void { 1369 - this.db.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run(); 1370 - } 1371 - 1372 - // #endregion 1373 - 1374 - // #region verification challenges 1375 - 1376 - /** 1377 - * create a verification challenge for MFA login (no session, creates one on success). 1378 - * @param did account did 1379 - * @param remember whether to create a long-lived session on success 1380 - * @returns token for the verify page 1381 - */ 1382 - createVerifyChallenge(did: Did, remember: boolean): string { 1383 - const token = nanoid(32); 1384 - const now = new Date(); 1385 - const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS); 1386 - 1387 - this.db 1388 - .insert(t.verifyChallenge) 1389 - .values({ 1390 - token: token, 1391 - did: did, 1392 - session_id: null, 1393 - remember: remember, 1394 - created_at: now, 1395 - expires_at: expiresAt, 1396 - }) 1397 - .run(); 1398 - 1399 - return token; 1400 - } 1401 - 1402 - /** 1403 - * create or get an existing sudo challenge for session elevation. 1404 - * @param sessionId the session to elevate 1405 - * @param did account did 1406 - * @returns the verify challenge row 1407 - */ 1408 - getOrCreateSudoChallenge(sessionId: string, did: Did): VerifyChallenge { 1409 - // check for existing sudo challenge for this session 1410 - const existing = this.db 1411 - .select() 1412 - .from(t.verifyChallenge) 1413 - .where(eq(t.verifyChallenge.session_id, sessionId)) 1414 - .get(); 1415 - 1416 - const now = new Date(); 1417 - 1418 - if (existing && existing.expires_at > now) { 1419 - return existing; 1420 - } 1421 - 1422 - // delete expired challenge if it exists 1423 - if (existing) { 1424 - this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, existing.token)).run(); 1425 - } 1426 - 1427 - const token = nanoid(32); 1428 - const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS); 1429 - 1430 - const inserted = this.db 1431 - .insert(t.verifyChallenge) 1432 - .values({ 1433 - token: token, 1434 - did: did, 1435 - session_id: sessionId, 1436 - created_at: now, 1437 - expires_at: expiresAt, 1438 - }) 1439 - .returning() 1440 - .get(); 1441 - 1442 - return inserted; 1443 - } 1444 - 1445 - /** 1446 - * get a verification challenge by token. 1447 - * @param token the token 1448 - * @returns verify challenge or null if expired/not found 1449 - */ 1450 - getVerifyChallenge(token: string): VerifyChallenge | null { 1451 - const challenge = this.db 1452 - .select() 1453 - .from(t.verifyChallenge) 1454 - .where(eq(t.verifyChallenge.token, token)) 1455 - .get(); 1456 - 1457 - if (!challenge) { 1458 - return null; 1459 - } 1460 - 1461 - const now = new Date(); 1462 - if (challenge.expires_at <= now) { 1463 - this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run(); 1464 - return null; 1465 - } 1466 - 1467 - return challenge; 1468 - } 1469 - 1470 - /** 1471 - * delete a verification challenge. 1472 - * @param token the token 1473 - */ 1474 - deleteVerifyChallenge(token: string): void { 1475 - this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run(); 1476 - } 1477 - 1478 - /** 1479 - * clean up expired verification challenges. 1480 - */ 1481 - cleanupExpiredVerifyChallenges(): void { 1482 - const now = new Date(); 1483 - this.db.delete(t.verifyChallenge).where(lte(t.verifyChallenge.expires_at, now)).run(); 1484 - } 1485 - 1486 - /** 1487 - * set the WebAuthn challenge on an existing verification challenge. 1488 - * @param token verify challenge token 1489 - * @param webauthnRegistrationChallenge base64url WebAuthn challenge 1490 - */ 1491 - setVerifyChallengeWebAuthn(token: string, webauthnRegistrationChallenge: string): void { 1492 - this.db 1493 - .update(t.verifyChallenge) 1494 - .set({ webauthn_challenge: webauthnRegistrationChallenge }) 1495 - .where(eq(t.verifyChallenge.token, token)) 1496 - .run(); 1497 - } 1498 - 1499 - // #endregion 1500 - 1501 - // #region sudo mode 1502 - 1503 - /** 1504 - * elevate a session to sudo mode. 1505 - * @param sessionId the session id 1506 - */ 1507 - elevateSession(sessionId: string): void { 1508 - this.db.update(t.webSession).set({ sudo_at: new Date() }).where(eq(t.webSession.id, sessionId)).run(); 1509 - } 1510 - 1511 - /** 1512 - * check if a session is in sudo mode. 1513 - * @param session the session 1514 - * @returns true if session is elevated 1515 - */ 1516 - isSessionElevated(session: WebSession): boolean { 1517 - if (session.sudo_at === null) { 1518 - return false; 1519 - } 1520 - const now = Date.now(); 1521 - const elevatedAt = session.sudo_at.getTime(); 1522 - return now - elevatedAt < SUDO_MODE_TTL_MS; 1523 - } 1524 - 1525 - // #endregion 1526 - 1527 - // #region WebAuthn credentials 1528 - 1529 - /** 1530 - * create a WebAuthn credential for an account. 1531 - * @param options credential options 1532 - * @returns created credential 1533 - */ 1534 - createWebAuthnCredential(options: CreateWebAuthnCredentialOptions): WebauthnCredential { 1535 - const count = this.countWebAuthnCredentials(options.did); 1536 - if (count >= MAX_WEBAUTHN_CREDENTIALS) { 1537 - throw new InvalidRequestError({ 1538 - error: 'TooManyWebAuthnCredentials', 1539 - description: `cannot have more than ${MAX_WEBAUTHN_CREDENTIALS} security keys`, 1540 - }); 1541 - } 1542 - 1543 - const name = options.name?.trim() || this.generateWebAuthnName(options.did, options.type); 1544 - 1545 - // check for duplicate name 1546 - const existing = this.db 1547 - .select() 1548 - .from(t.webauthnCredential) 1549 - .where(and(eq(t.webauthnCredential.did, options.did), eq(t.webauthnCredential.name, name))) 1550 - .get(); 1551 - 1552 - if (existing) { 1553 - throw new InvalidRequestError({ 1554 - error: 'DuplicateWebAuthnName', 1555 - description: `a credential with this name already exists`, 1556 - }); 1557 - } 1558 - 1559 - // check for duplicate credential ID 1560 - const existingCredId = this.db 1561 - .select() 1562 - .from(t.webauthnCredential) 1563 - .where(eq(t.webauthnCredential.credential_id, options.credentialId)) 1564 - .get(); 1565 - 1566 - if (existingCredId) { 1567 - throw new InvalidRequestError({ 1568 - error: 'DuplicateCredentialId', 1569 - description: `this security key is already registered`, 1570 - }); 1571 - } 1572 - 1573 - const inserted = this.db 1574 - .insert(t.webauthnCredential) 1575 - .values({ 1576 - did: options.did, 1577 - type: options.type, 1578 - name: name, 1579 - credential_id: options.credentialId, 1580 - public_key: Buffer.from(options.publicKey), 1581 - counter: options.counter, 1582 - transports: options.transports, 1583 - created_at: new Date(), 1584 - }) 1585 - .returning() 1586 - .get(); 1587 - 1588 - if (!inserted) { 1589 - throw new Error(`failed to create WebAuthn credential`); 1590 - } 1591 - 1592 - // sync preferred MFA (only for security keys, not passkeys) 1593 - if (options.type === WebAuthnCredentialType.SecurityKey) { 1594 - this.#syncPreferredMfa(options.did); 1595 - } 1596 - 1597 - return inserted; 1598 - } 1599 - 1600 - /** 1601 - * list WebAuthn credentials for an account. 1602 - * @param did account did 1603 - * @returns WebAuthn credentials 1604 - */ 1605 - listWebAuthnCredentials(did: Did): WebauthnCredential[] { 1606 - return this.db.select().from(t.webauthnCredential).where(eq(t.webauthnCredential.did, did)).all(); 1607 - } 1608 - 1609 - /** 1610 - * list WebAuthn credentials for an account filtered by type. 1611 - * @param did account did 1612 - * @param type credential type 1613 - * @returns WebAuthn credentials of the specified type 1614 - */ 1615 - listWebAuthnCredentialsByType(did: Did, type: WebAuthnCredentialType): WebauthnCredential[] { 1616 - return this.db 1617 - .select() 1618 - .from(t.webauthnCredential) 1619 - .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.type, type))) 1620 - .all(); 1621 - } 1622 - 1623 - /** 1624 - * get a WebAuthn credential by id. 1625 - * @param did account did 1626 - * @param id credential id 1627 - * @returns WebAuthn credential or null 1628 - */ 1629 - getWebAuthnCredential(did: Did, id: number): WebauthnCredential | null { 1630 - const credential = this.db 1631 - .select() 1632 - .from(t.webauthnCredential) 1633 - .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id))) 1634 - .get(); 1635 - 1636 - return credential ?? null; 1637 - } 1638 - 1639 - /** 1640 - * get a WebAuthn credential by credential ID. 1641 - * @param credentialId base64url credential ID 1642 - * @returns WebAuthn credential or null 1643 - */ 1644 - getWebAuthnCredentialByCredentialId(credentialId: string): WebauthnCredential | null { 1645 - const credential = this.db 1646 - .select() 1647 - .from(t.webauthnCredential) 1648 - .where(eq(t.webauthnCredential.credential_id, credentialId)) 1649 - .get(); 1650 - 1651 - return credential ?? null; 1652 - } 1653 - 1654 - /** 1655 - * delete a WebAuthn credential. 1656 - * @param did account did 1657 - * @param id credential id 1658 - */ 1659 - deleteWebAuthnCredential(did: Did, id: number): void { 1660 - this.db 1661 - .delete(t.webauthnCredential) 1662 - .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id))) 1663 - .run(); 1664 - 1665 - this.#syncPreferredMfa(did); 1666 - } 1667 - 1668 - /** 1669 - * count WebAuthn credentials for an account. 1670 - * @param did account did 1671 - * @returns number of credentials 1672 - */ 1673 - countWebAuthnCredentials(did: Did): number { 1674 - return ( 1675 - this.db 1676 - .select({ count: sql<number>`count(*)` }) 1677 - .from(t.webauthnCredential) 1678 - .where(eq(t.webauthnCredential.did, did)) 1679 - .get()?.count ?? 0 1680 - ); 1681 - } 1682 - 1683 - /** 1684 - * get the WebAuthn credential type(s) for an account. 1685 - * @param did account did 1686 - * @returns credential type: false if none, 'security-key', 'passkey', or 'mixed' 1687 - */ 1688 - #getWebAuthnType(did: Did): WebAuthnType { 1689 - const credentials = this.db 1690 - .select({ type: t.webauthnCredential.type }) 1691 - .from(t.webauthnCredential) 1692 - .where(eq(t.webauthnCredential.did, did)) 1693 - .all(); 1694 - 1695 - if (credentials.length === 0) { 1696 - return false; 1697 - } 1698 - 1699 - const hasSecurityKey = credentials.some((c) => c.type === WebAuthnCredentialType.SecurityKey); 1700 - const hasPasskey = credentials.some((c) => c.type === WebAuthnCredentialType.Passkey); 1701 - 1702 - if (hasSecurityKey && hasPasskey) { 1703 - return 'mixed'; 1704 - } 1705 - return hasPasskey ? 'passkey' : 'security-key'; 1706 - } 1707 - 1708 - /** 1709 - * update the counter for a WebAuthn credential. 1710 - * @param id credential id 1711 - * @param counter new counter value 1712 - */ 1713 - updateWebAuthnCredentialCounter(id: number, counter: number): void { 1714 - this.db.update(t.webauthnCredential).set({ counter }).where(eq(t.webauthnCredential.id, id)).run(); 1715 - } 1716 - 1717 - /** 1718 - * generate a unique name for a new WebAuthn credential. 1719 - * @param did account did 1720 - * @param type credential type 1721 - * @returns generated name like "Security Key" or "Security Key 2" 1722 - */ 1723 - generateWebAuthnName(did: Did, type: WebAuthnCredentialType): string { 1724 - const existing = this.listWebAuthnCredentialsByType(did, type); 1725 - const baseName = type === WebAuthnCredentialType.SecurityKey ? 'Security Key' : 'Passkey'; 1726 - 1727 - if (existing.length === 0) { 1728 - return baseName; 1729 - } 1730 - 1731 - // find the next available number 1732 - const existingNames = new Set(existing.map((c) => c.name)); 1733 - let num = 2; 1734 - while (existingNames.has(`${baseName} ${num}`)) { 1735 - num++; 1736 - } 1737 - 1738 - return `${baseName} ${num}`; 1739 - } 1740 - 1741 - // #endregion 1742 - 1743 - // #region WebAuthn registration challenges 1744 - 1745 - /** 1746 - * create a WebAuthn registration challenge. 1747 - * @param did account did 1748 - * @param challenge base64url challenge 1749 - * @returns token for retrieving the challenge 1750 - */ 1751 - createWebAuthnRegistrationChallenge(did: Did, challenge: string): string { 1752 - const token = nanoid(32); 1753 - const now = new Date(); 1754 - const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 1755 - 1756 - this.db 1757 - .insert(t.webauthnRegistrationChallenge) 1758 - .values({ 1759 - token: token, 1760 - did: did, 1761 - challenge: challenge, 1762 - created_at: now, 1763 - expires_at: expiresAt, 1764 - }) 1765 - .run(); 1766 - 1767 - return token; 1768 - } 1769 - 1770 - /** 1771 - * get a WebAuthn registration challenge by token. 1772 - * @param token the token 1773 - * @returns WebAuthn challenge or null if expired/not found 1774 - */ 1775 - getWebAuthnRegistrationChallenge(token: string): WebauthnRegistrationChallenge | null { 1776 - const challenge = this.db 1777 - .select() 1778 - .from(t.webauthnRegistrationChallenge) 1779 - .where(eq(t.webauthnRegistrationChallenge.token, token)) 1780 - .get(); 1781 - 1782 - if (!challenge) { 1783 - return null; 1784 - } 1785 - 1786 - const now = new Date(); 1787 - if (challenge.expires_at <= now) { 1788 - this.db 1789 - .delete(t.webauthnRegistrationChallenge) 1790 - .where(eq(t.webauthnRegistrationChallenge.token, token)) 1791 - .run(); 1792 - return null; 1793 - } 1794 - 1795 - return challenge; 1796 - } 1797 - 1798 - /** 1799 - * delete a WebAuthn registration challenge. 1800 - * @param token the token 1801 - */ 1802 - deleteWebAuthnRegistrationChallenge(token: string): void { 1803 - this.db 1804 - .delete(t.webauthnRegistrationChallenge) 1805 - .where(eq(t.webauthnRegistrationChallenge.token, token)) 1806 - .run(); 1807 - } 1808 - 1809 - /** 1810 - * clean up expired WebAuthn registration challenges. 1811 - */ 1812 - cleanupExpiredWebAuthnRegistrationChallenges(): void { 1813 - const now = new Date(); 1814 - this.db 1815 - .delete(t.webauthnRegistrationChallenge) 1816 - .where(lte(t.webauthnRegistrationChallenge.expires_at, now)) 1817 - .run(); 1818 - } 1819 - 1820 - // #endregion 1821 - 1822 - // #region passkey login challenges 1823 - 1824 - /** 1825 - * create a passkey login challenge for passwordless authentication. 1826 - * @param challenge base64url challenge string 1827 - */ 1828 - createPasskeyLoginChallenge(challenge: string): void { 1829 - const now = new Date(); 1830 - const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 1831 - 1832 - this.db 1833 - .insert(t.passkeyLoginChallenge) 1834 - .values({ 1835 - challenge: challenge, 1836 - created_at: now, 1837 - expires_at: expiresAt, 1838 - }) 1839 - .run(); 1840 - } 1841 - 1842 - /** 1843 - * consume a passkey login challenge (delete and return if valid). 1844 - * @param challenge base64url challenge string 1845 - * @returns true if challenge was valid and consumed 1846 - */ 1847 - consumePasskeyLoginChallenge(challenge: string): boolean { 1848 - const now = new Date(); 1849 - 1850 - // clean up expired challenges 1851 - this.db.delete(t.passkeyLoginChallenge).where(lte(t.passkeyLoginChallenge.expires_at, now)).run(); 1852 - 1853 - // try to delete the challenge (returns the deleted row if it existed) 1854 - const result = this.db 1855 - .delete(t.passkeyLoginChallenge) 1856 - .where(eq(t.passkeyLoginChallenge.challenge, challenge)) 1857 - .returning() 1858 - .get(); 1859 - 1860 - return result != null; 1861 - } 1862 - 1863 - // #endregion 1864 - 1865 514 async importAccount(_options: ImportAccountOptions) {} 1866 - 1867 - private resolveIdentifier(identifier: string, options: AccountAvailabilityOptions): Account | null { 1868 - if (isDid(identifier) || isHandle(identifier)) { 1869 - return this.getAccount(identifier as ActorIdentifier, options); 1870 - } 1871 - 1872 - return this.getAccountByEmail(identifier, options); 1873 - } 1874 - 1875 - private async findAppPasswordMatch(did: Did, password: string): Promise<AppPassword | null> { 1876 - const rows = this.db.select().from(t.appPassword).where(eq(t.appPassword.did, did)).all(); 1877 - 1878 - for (const row of rows) { 1879 - const valid = await verifyPassword(password, row.password_hash); 1880 - if (valid) { 1881 - return row; 1882 - } 1883 - } 1884 - 1885 - return null; 1886 - } 1887 - 1888 - private scopeForPrivilege(privilege: AppPasswordPrivilege): AuthScope { 1889 - switch (privilege) { 1890 - case AppPasswordPrivilege.Full: 1891 - return AuthScope.Access; 1892 - case AppPasswordPrivilege.Privileged: 1893 - return AuthScope.AppPassPrivileged; 1894 - case AppPasswordPrivilege.Limited: 1895 - return AuthScope.AppPass; 1896 - } 1897 - 1898 - throw new InvalidRequestError({ 1899 - error: 'InvalidAppPasswordPrivilege', 1900 - description: `invalid app password privilege`, 1901 - }); 1902 - } 1903 - 1904 - private async issueLegacyTokens(options: { 1905 - did: Did; 1906 - privilege: AppPasswordPrivilege; 1907 - sessionId: string; 1908 - now: Date; 1909 - }): Promise<LegacySessionTokens> { 1910 - return await createLegacySessionTokens( 1911 - { 1912 - did: options.did, 1913 - scope: this.scopeForPrivilege(options.privilege), 1914 - serviceDid: this.serviceDid, 1915 - jwtKey: this.jwtKey, 1916 - issuedAt: options.now.getTime(), 1917 - expiresInMs: LEGACY_ACCESS_TTL_MS, 1918 - }, 1919 - { 1920 - did: options.did, 1921 - serviceDid: this.serviceDid, 1922 - jwtKey: this.jwtKey, 1923 - sessionId: options.sessionId, 1924 - issuedAt: options.now.getTime(), 1925 - expiresInMs: LEGACY_REFRESH_TTL_MS, 1926 - }, 1927 - ); 1928 - } 1929 515 } 1930 516 1931 517 interface AccountAvailabilityOptions { ··· 1950 536 password: string; 1951 537 } 1952 538 1953 - interface CreateWebSessionOptions { 1954 - did: Did; 1955 - remember: boolean; 1956 - userAgent: string | undefined; 1957 - ip: string | undefined; 1958 - } 1959 - 1960 - interface CreateLegacySessionOptions { 1961 - did: Did; 1962 - appPassword: AppPassword; 1963 - sessionId?: string; 1964 - now?: Date; 1965 - } 1966 - 1967 - interface CreateAppPasswordOptions { 1968 - did: Did; 1969 - name: string; 1970 - privilege: AppPasswordPrivilege; 1971 - } 1972 - 1973 539 interface ImportAccountOptions {} 1974 - 1975 - interface ListInviteCodesOptions { 1976 - limit?: number; 1977 - cursor?: string; 1978 - includeDisabled?: boolean; 1979 - includeUsed?: boolean; 1980 - } 1981 - 1982 - interface CreateTotpCredentialOptions { 1983 - did: Did; 1984 - name?: string; 1985 - secret: Uint8Array; 1986 - lastUsedCounter: number; 1987 - } 1988 - 1989 - interface CreateWebAuthnCredentialOptions { 1990 - did: Did; 1991 - type: WebAuthnCredentialType; 1992 - name?: string; 1993 - credentialId: string; 1994 - publicKey: Uint8Array; 1995 - counter: number; 1996 - transports?: AuthenticatorTransportFuture[]; 1997 - }
+693
packages/danaus/src/accounts/mfa.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { InvalidRequestError } from '@atcute/xrpc-server'; 3 + 4 + import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'; 5 + import { and, eq, isNull, lte, sql } from 'drizzle-orm'; 6 + import { nanoid } from 'nanoid'; 7 + 8 + import { t, type AccountDb } from './db'; 9 + import { PreferredMfa, WebAuthnCredentialType } from './db/schema'; 10 + import { generateBackupCodes, MAX_TOTP_CREDENTIALS, verifyTotpCode } from './totp'; 11 + import { MAX_WEBAUTHN_CREDENTIALS, WEBAUTHN_CHALLENGE_TTL_MS } from './webauthn'; 12 + 13 + export type TotpCredential = typeof t.totpCredential.$inferSelect; 14 + export type BackupCode = typeof t.recoveryCode.$inferSelect; 15 + export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect; 16 + export type WebauthnRegistrationChallenge = typeof t.webauthnRegistrationChallenge.$inferSelect; 17 + 18 + /** WebAuthn credential type for MFA status */ 19 + export type WebAuthnType = false | 'security-key' | 'passkey' | 'mixed'; 20 + 21 + /** MFA status for an account */ 22 + export interface MfaStatus { 23 + /** preferred MFA method */ 24 + preferred: PreferredMfa; 25 + /** has TOTP credentials */ 26 + hasTotp: boolean; 27 + /** WebAuthn credential type(s) registered */ 28 + webAuthnType: WebAuthnType; 29 + /** has recovery codes */ 30 + hasRecoveryCodes: boolean; 31 + } 32 + 33 + interface MfaManagerOptions { 34 + db: AccountDb; 35 + } 36 + 37 + interface CreateTotpCredentialOptions { 38 + did: Did; 39 + name?: string; 40 + secret: Uint8Array; 41 + lastUsedCounter: number; 42 + } 43 + 44 + interface CreateWebAuthnCredentialOptions { 45 + did: Did; 46 + type: WebAuthnCredentialType; 47 + name?: string; 48 + credentialId: string; 49 + publicKey: Uint8Array; 50 + counter: number; 51 + transports?: AuthenticatorTransportFuture[]; 52 + } 53 + 54 + export class MfaManager { 55 + readonly #db: AccountDb; 56 + 57 + constructor(options: MfaManagerOptions) { 58 + this.#db = options.db; 59 + } 60 + 61 + // #region TOTP credentials 62 + 63 + /** 64 + * create a TOTP credential for an account. 65 + * @param options TOTP credential options 66 + * @returns created credential 67 + */ 68 + createTotpCredential(options: CreateTotpCredentialOptions): TotpCredential { 69 + const count = this.#countTotpCredentials(options.did); 70 + if (count >= MAX_TOTP_CREDENTIALS) { 71 + throw new InvalidRequestError({ 72 + error: 'TooManyTotpCredentials', 73 + description: `cannot have more than ${MAX_TOTP_CREDENTIALS} authenticators`, 74 + }); 75 + } 76 + 77 + const name = options.name?.trim() || this.generateTotpName(options.did); 78 + 79 + // check for duplicate name 80 + const existing = this.#db 81 + .select() 82 + .from(t.totpCredential) 83 + .where(and(eq(t.totpCredential.did, options.did), eq(t.totpCredential.name, name))) 84 + .get(); 85 + 86 + if (existing) { 87 + throw new InvalidRequestError({ 88 + error: 'DuplicateTotpName', 89 + description: `an authenticator with this name already exists`, 90 + }); 91 + } 92 + 93 + const inserted = this.#db 94 + .insert(t.totpCredential) 95 + .values({ 96 + did: options.did, 97 + name: name, 98 + secret: Buffer.from(options.secret), 99 + created_at: new Date(), 100 + last_used_counter: options.lastUsedCounter, 101 + }) 102 + .returning() 103 + .get(); 104 + 105 + if (!inserted) { 106 + throw new Error(`failed to create TOTP credential`); 107 + } 108 + 109 + this.#syncPreferredMfa(options.did); 110 + 111 + return inserted; 112 + } 113 + 114 + /** 115 + * list TOTP credentials for an account. 116 + * @param did account did 117 + * @returns TOTP credentials 118 + */ 119 + listTotpCredentials(did: Did): TotpCredential[] { 120 + return this.#db.select().from(t.totpCredential).where(eq(t.totpCredential.did, did)).all(); 121 + } 122 + 123 + /** 124 + * get a TOTP credential by id. 125 + * @param did account did 126 + * @param id credential id 127 + * @returns TOTP credential or null 128 + */ 129 + getTotpCredential(did: Did, id: number): TotpCredential | null { 130 + const credential = this.#db 131 + .select() 132 + .from(t.totpCredential) 133 + .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id))) 134 + .get(); 135 + 136 + return credential ?? null; 137 + } 138 + 139 + /** 140 + * delete a TOTP credential. 141 + * @param did account did 142 + * @param id credential id 143 + */ 144 + deleteTotpCredential(did: Did, id: number): void { 145 + this.#db 146 + .delete(t.totpCredential) 147 + .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id))) 148 + .run(); 149 + 150 + this.#syncPreferredMfa(did); 151 + } 152 + 153 + /** 154 + * verify a TOTP code against any of the account's credentials. 155 + * updates last_used_counter on successful verification to prevent replay attacks. 156 + * @param did account did 157 + * @param code the code to verify 158 + * @returns true if the code is valid for any credential 159 + */ 160 + async verifyAccountTotpCode(did: Did, code: string): Promise<boolean> { 161 + const credentials = this.listTotpCredentials(did); 162 + 163 + for (const credential of credentials) { 164 + const counter = await verifyTotpCode(credential.secret, code, credential.last_used_counter); 165 + 166 + if (counter !== null) { 167 + // update last_used_counter to prevent replay attacks 168 + this.#db 169 + .update(t.totpCredential) 170 + .set({ last_used_counter: counter }) 171 + .where(eq(t.totpCredential.id, credential.id)) 172 + .run(); 173 + 174 + return true; 175 + } 176 + } 177 + 178 + return false; 179 + } 180 + 181 + /** 182 + * generate a unique name for a new TOTP credential. 183 + * @param did account did 184 + * @returns generated name like "Authenticator" or "Authenticator 2" 185 + */ 186 + generateTotpName(did: Did): string { 187 + const existing = this.listTotpCredentials(did); 188 + const baseName = 'Authenticator'; 189 + 190 + if (existing.length === 0) { 191 + return baseName; 192 + } 193 + 194 + // find the next available number 195 + const existingNames = new Set(existing.map((c) => c.name)); 196 + let num = 2; 197 + while (existingNames.has(`${baseName} ${num}`)) { 198 + num++; 199 + } 200 + 201 + return `${baseName} ${num}`; 202 + } 203 + 204 + #countTotpCredentials(did: Did): number { 205 + return ( 206 + this.#db 207 + .select({ count: sql<number>`count(*)` }) 208 + .from(t.totpCredential) 209 + .where(eq(t.totpCredential.did, did)) 210 + .get()?.count ?? 0 211 + ); 212 + } 213 + 214 + // #endregion 215 + 216 + // #region recovery codes 217 + 218 + /** 219 + * generate and store recovery codes for an account. 220 + * deletes any existing codes first. 221 + * @param did account did 222 + */ 223 + generateRecoveryCodes(did: Did): void { 224 + const codes = generateBackupCodes(); 225 + const now = new Date(); 226 + 227 + this.#db.transaction((tx) => { 228 + tx.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run(); 229 + 230 + for (const code of codes) { 231 + tx.insert(t.recoveryCode) 232 + .values({ 233 + did: did, 234 + code: code, 235 + created_at: now, 236 + }) 237 + .run(); 238 + } 239 + }); 240 + } 241 + 242 + /** 243 + * get all unused recovery codes for an account. 244 + * @param did account did 245 + * @returns array of unused codes 246 + */ 247 + getRecoveryCodes(did: Did): string[] { 248 + return this.#db 249 + .select({ code: t.recoveryCode.code }) 250 + .from(t.recoveryCode) 251 + .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at))) 252 + .all() 253 + .map((row) => row.code); 254 + } 255 + 256 + /** 257 + * get count of unused recovery codes. 258 + * @param did account did 259 + * @returns number of unused codes 260 + */ 261 + getRecoveryCodeCount(did: Did): number { 262 + return ( 263 + this.#db 264 + .select({ count: sql<number>`count(*)` }) 265 + .from(t.recoveryCode) 266 + .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at))) 267 + .get()?.count ?? 0 268 + ); 269 + } 270 + 271 + /** 272 + * verify and consume a recovery code. 273 + * @param did account did 274 + * @param code the code to verify 275 + * @returns true if the code was valid and consumed 276 + */ 277 + consumeRecoveryCode(did: Did, code: string): boolean { 278 + const result = this.#db 279 + .update(t.recoveryCode) 280 + .set({ used_at: new Date() }) 281 + .where(and(eq(t.recoveryCode.did, did), eq(t.recoveryCode.code, code), isNull(t.recoveryCode.used_at))) 282 + .returning() 283 + .get(); 284 + 285 + return result != null; 286 + } 287 + 288 + /** 289 + * delete all recovery codes for an account. 290 + * @param did account did 291 + */ 292 + deleteRecoveryCodes(did: Did): void { 293 + this.#db.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run(); 294 + } 295 + 296 + // #endregion 297 + 298 + // #region WebAuthn credentials 299 + 300 + /** 301 + * create a WebAuthn credential for an account. 302 + * @param options credential options 303 + * @returns created credential 304 + */ 305 + createWebAuthnCredential(options: CreateWebAuthnCredentialOptions): WebauthnCredential { 306 + const count = this.#countWebAuthnCredentials(options.did); 307 + if (count >= MAX_WEBAUTHN_CREDENTIALS) { 308 + throw new InvalidRequestError({ 309 + error: 'TooManyWebAuthnCredentials', 310 + description: `cannot have more than ${MAX_WEBAUTHN_CREDENTIALS} security keys`, 311 + }); 312 + } 313 + 314 + const name = options.name?.trim() || this.generateWebAuthnName(options.did, options.type); 315 + 316 + // check for duplicate name 317 + const existing = this.#db 318 + .select() 319 + .from(t.webauthnCredential) 320 + .where(and(eq(t.webauthnCredential.did, options.did), eq(t.webauthnCredential.name, name))) 321 + .get(); 322 + 323 + if (existing) { 324 + throw new InvalidRequestError({ 325 + error: 'DuplicateWebAuthnName', 326 + description: `a credential with this name already exists`, 327 + }); 328 + } 329 + 330 + // check for duplicate credential ID 331 + const existingCredId = this.#db 332 + .select() 333 + .from(t.webauthnCredential) 334 + .where(eq(t.webauthnCredential.credential_id, options.credentialId)) 335 + .get(); 336 + 337 + if (existingCredId) { 338 + throw new InvalidRequestError({ 339 + error: 'DuplicateCredentialId', 340 + description: `this security key is already registered`, 341 + }); 342 + } 343 + 344 + const inserted = this.#db 345 + .insert(t.webauthnCredential) 346 + .values({ 347 + did: options.did, 348 + type: options.type, 349 + name: name, 350 + credential_id: options.credentialId, 351 + public_key: Buffer.from(options.publicKey), 352 + counter: options.counter, 353 + transports: options.transports, 354 + created_at: new Date(), 355 + }) 356 + .returning() 357 + .get(); 358 + 359 + if (!inserted) { 360 + throw new Error(`failed to create WebAuthn credential`); 361 + } 362 + 363 + // sync preferred MFA (only for security keys, not passkeys) 364 + if (options.type === WebAuthnCredentialType.SecurityKey) { 365 + this.#syncPreferredMfa(options.did); 366 + } 367 + 368 + return inserted; 369 + } 370 + 371 + /** 372 + * list WebAuthn credentials for an account. 373 + * @param did account did 374 + * @returns WebAuthn credentials 375 + */ 376 + listWebAuthnCredentials(did: Did): WebauthnCredential[] { 377 + return this.#db.select().from(t.webauthnCredential).where(eq(t.webauthnCredential.did, did)).all(); 378 + } 379 + 380 + /** 381 + * list WebAuthn credentials for an account filtered by type. 382 + * @param did account did 383 + * @param type credential type 384 + * @returns WebAuthn credentials of the specified type 385 + */ 386 + listWebAuthnCredentialsByType(did: Did, type: WebAuthnCredentialType): WebauthnCredential[] { 387 + return this.#db 388 + .select() 389 + .from(t.webauthnCredential) 390 + .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.type, type))) 391 + .all(); 392 + } 393 + 394 + /** 395 + * get a WebAuthn credential by id. 396 + * @param did account did 397 + * @param id credential id 398 + * @returns WebAuthn credential or null 399 + */ 400 + getWebAuthnCredential(did: Did, id: number): WebauthnCredential | null { 401 + const credential = this.#db 402 + .select() 403 + .from(t.webauthnCredential) 404 + .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id))) 405 + .get(); 406 + 407 + return credential ?? null; 408 + } 409 + 410 + /** 411 + * get a WebAuthn credential by credential ID. 412 + * @param credentialId base64url credential ID 413 + * @returns WebAuthn credential or null 414 + */ 415 + getWebAuthnCredentialByCredentialId(credentialId: string): WebauthnCredential | null { 416 + const credential = this.#db 417 + .select() 418 + .from(t.webauthnCredential) 419 + .where(eq(t.webauthnCredential.credential_id, credentialId)) 420 + .get(); 421 + 422 + return credential ?? null; 423 + } 424 + 425 + /** 426 + * delete a WebAuthn credential. 427 + * @param did account did 428 + * @param id credential id 429 + */ 430 + deleteWebAuthnCredential(did: Did, id: number): void { 431 + this.#db 432 + .delete(t.webauthnCredential) 433 + .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id))) 434 + .run(); 435 + 436 + this.#syncPreferredMfa(did); 437 + } 438 + 439 + /** 440 + * update the counter for a WebAuthn credential. 441 + * @param id credential id 442 + * @param counter new counter value 443 + */ 444 + updateWebAuthnCredentialCounter(id: number, counter: number): void { 445 + this.#db.update(t.webauthnCredential).set({ counter }).where(eq(t.webauthnCredential.id, id)).run(); 446 + } 447 + 448 + /** 449 + * generate a unique name for a new WebAuthn credential. 450 + * @param did account did 451 + * @param type credential type 452 + * @returns generated name like "Security Key" or "Security Key 2" 453 + */ 454 + generateWebAuthnName(did: Did, type: WebAuthnCredentialType): string { 455 + const existing = this.listWebAuthnCredentialsByType(did, type); 456 + const baseName = type === WebAuthnCredentialType.SecurityKey ? 'Security Key' : 'Passkey'; 457 + 458 + if (existing.length === 0) { 459 + return baseName; 460 + } 461 + 462 + // find the next available number 463 + const existingNames = new Set(existing.map((c) => c.name)); 464 + let num = 2; 465 + while (existingNames.has(`${baseName} ${num}`)) { 466 + num++; 467 + } 468 + 469 + return `${baseName} ${num}`; 470 + } 471 + 472 + #countWebAuthnCredentials(did: Did): number { 473 + return ( 474 + this.#db 475 + .select({ count: sql<number>`count(*)` }) 476 + .from(t.webauthnCredential) 477 + .where(eq(t.webauthnCredential.did, did)) 478 + .get()?.count ?? 0 479 + ); 480 + } 481 + 482 + #getWebAuthnType(did: Did): WebAuthnType { 483 + const credentials = this.#db 484 + .select({ type: t.webauthnCredential.type }) 485 + .from(t.webauthnCredential) 486 + .where(eq(t.webauthnCredential.did, did)) 487 + .all(); 488 + 489 + if (credentials.length === 0) { 490 + return false; 491 + } 492 + 493 + const hasSecurityKey = credentials.some((c) => c.type === WebAuthnCredentialType.SecurityKey); 494 + const hasPasskey = credentials.some((c) => c.type === WebAuthnCredentialType.Passkey); 495 + 496 + if (hasSecurityKey && hasPasskey) { 497 + return 'mixed'; 498 + } 499 + return hasPasskey ? 'passkey' : 'security-key'; 500 + } 501 + 502 + // #endregion 503 + 504 + // #region WebAuthn registration challenges 505 + 506 + /** 507 + * create a WebAuthn registration challenge. 508 + * @param did account did 509 + * @param challenge base64url challenge 510 + * @returns token for retrieving the challenge 511 + */ 512 + createWebAuthnRegistrationChallenge(did: Did, challenge: string): string { 513 + const token = nanoid(32); 514 + const now = new Date(); 515 + const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 516 + 517 + this.#db 518 + .insert(t.webauthnRegistrationChallenge) 519 + .values({ 520 + token: token, 521 + did: did, 522 + challenge: challenge, 523 + created_at: now, 524 + expires_at: expiresAt, 525 + }) 526 + .run(); 527 + 528 + return token; 529 + } 530 + 531 + /** 532 + * get a WebAuthn registration challenge by token. 533 + * @param token the token 534 + * @returns WebAuthn challenge or null if expired/not found 535 + */ 536 + getWebAuthnRegistrationChallenge(token: string): WebauthnRegistrationChallenge | null { 537 + const challenge = this.#db 538 + .select() 539 + .from(t.webauthnRegistrationChallenge) 540 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 541 + .get(); 542 + 543 + if (!challenge) { 544 + return null; 545 + } 546 + 547 + const now = new Date(); 548 + if (challenge.expires_at <= now) { 549 + this.#db 550 + .delete(t.webauthnRegistrationChallenge) 551 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 552 + .run(); 553 + return null; 554 + } 555 + 556 + return challenge; 557 + } 558 + 559 + /** 560 + * delete a WebAuthn registration challenge. 561 + * @param token the token 562 + */ 563 + deleteWebAuthnRegistrationChallenge(token: string): void { 564 + this.#db 565 + .delete(t.webauthnRegistrationChallenge) 566 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 567 + .run(); 568 + } 569 + 570 + /** 571 + * clean up expired WebAuthn registration challenges. 572 + */ 573 + cleanupExpiredWebAuthnRegistrationChallenges(): void { 574 + const now = new Date(); 575 + this.#db 576 + .delete(t.webauthnRegistrationChallenge) 577 + .where(lte(t.webauthnRegistrationChallenge.expires_at, now)) 578 + .run(); 579 + } 580 + 581 + // #endregion 582 + 583 + // #region passkey login challenges 584 + 585 + /** 586 + * create a passkey login challenge for passwordless authentication. 587 + * @param challenge base64url challenge string 588 + */ 589 + createPasskeyLoginChallenge(challenge: string): void { 590 + const now = new Date(); 591 + const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 592 + 593 + this.#db 594 + .insert(t.passkeyLoginChallenge) 595 + .values({ 596 + challenge: challenge, 597 + created_at: now, 598 + expires_at: expiresAt, 599 + }) 600 + .run(); 601 + } 602 + 603 + /** 604 + * consume a passkey login challenge (delete and return if valid). 605 + * @param challenge base64url challenge string 606 + * @returns true if challenge was valid and consumed 607 + */ 608 + consumePasskeyLoginChallenge(challenge: string): boolean { 609 + const now = new Date(); 610 + 611 + // clean up expired challenges 612 + this.#db.delete(t.passkeyLoginChallenge).where(lte(t.passkeyLoginChallenge.expires_at, now)).run(); 613 + 614 + // try to delete the challenge (returns the deleted row if it existed) 615 + const result = this.#db 616 + .delete(t.passkeyLoginChallenge) 617 + .where(eq(t.passkeyLoginChallenge.challenge, challenge)) 618 + .returning() 619 + .get(); 620 + 621 + return result != null; 622 + } 623 + 624 + // #endregion 625 + 626 + // #region MFA status 627 + 628 + /** 629 + * get MFA status for an account. 630 + * @param did account did 631 + * @returns MFA status with preferred method and available methods, or null if no MFA configured 632 + */ 633 + getMfaStatus(did: Did): MfaStatus | null { 634 + const account = this.#db 635 + .select({ preferred_mfa: t.account.preferred_mfa }) 636 + .from(t.account) 637 + .where(eq(t.account.did, did)) 638 + .get(); 639 + 640 + if (!account || account.preferred_mfa == null) { 641 + return null; 642 + } 643 + 644 + return { 645 + preferred: account.preferred_mfa, 646 + hasTotp: this.#countTotpCredentials(did) > 0, 647 + webAuthnType: this.#getWebAuthnType(did), 648 + hasRecoveryCodes: this.getRecoveryCodeCount(did) > 0, 649 + }; 650 + } 651 + 652 + /** 653 + * sync preferred_mfa to reflect current MFA credentials. 654 + * - if null and credentials exist → set to first available type 655 + * - if set but that type has no credentials → switch to another type or clear 656 + */ 657 + #syncPreferredMfa(did: Did): void { 658 + const account = this.#db 659 + .select({ preferred_mfa: t.account.preferred_mfa }) 660 + .from(t.account) 661 + .where(eq(t.account.did, did)) 662 + .get(); 663 + 664 + if (!account) { 665 + return; 666 + } 667 + 668 + const hasTotp = this.#countTotpCredentials(did) > 0; 669 + const hasWebAuthn = this.#countWebAuthnCredentials(did) > 0; 670 + 671 + // check if current preference is still valid 672 + if (account.preferred_mfa === PreferredMfa.Totp && hasTotp) { 673 + return; 674 + } 675 + if (account.preferred_mfa === PreferredMfa.WebAuthn && hasWebAuthn) { 676 + return; 677 + } 678 + 679 + // need to set or switch: prefer the type that was just added (TOTP first for backwards compat) 680 + let newPreferred: PreferredMfa | null = null; 681 + if (hasTotp) { 682 + newPreferred = PreferredMfa.Totp; 683 + } else if (hasWebAuthn) { 684 + newPreferred = PreferredMfa.WebAuthn; 685 + } 686 + 687 + if (newPreferred !== account.preferred_mfa) { 688 + this.#db.update(t.account).set({ preferred_mfa: newPreferred }).where(eq(t.account.did, did)).run(); 689 + } 690 + } 691 + 692 + // #endregion 693 + }
+262
packages/danaus/src/accounts/web-sessions.ts
··· 1 + import type { KeyObject } from 'node:crypto'; 2 + 3 + import type { Did } from '@atcute/lexicons'; 4 + 5 + import { eq, lte } from 'drizzle-orm'; 6 + import { nanoid } from 'nanoid'; 7 + 8 + import { createWebSessionToken } from '#app/auth/web.ts'; 9 + import { DAY } from '#app/utils/times.ts'; 10 + 11 + import { t, type AccountDb } from './db'; 12 + 13 + const WEB_SESSION_TTL_MS = 7 * DAY; 14 + const WEB_SESSION_LONG_TTL_MS = 365 * DAY; 15 + const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes 16 + const SUDO_MODE_TTL_MS = 15 * 60 * 1000; // 15 minutes 17 + 18 + export type WebSession = typeof t.webSession.$inferSelect; 19 + export type VerifyChallenge = typeof t.verifyChallenge.$inferSelect; 20 + 21 + interface WebSessionManagerOptions { 22 + db: AccountDb; 23 + jwtKey: KeyObject; 24 + } 25 + 26 + interface CreateWebSessionOptions { 27 + did: Did; 28 + remember: boolean; 29 + userAgent: string | undefined; 30 + ip: string | undefined; 31 + } 32 + 33 + export class WebSessionManager { 34 + readonly #db: AccountDb; 35 + readonly #jwtKey: KeyObject; 36 + 37 + constructor(options: WebSessionManagerOptions) { 38 + this.#db = options.db; 39 + this.#jwtKey = options.jwtKey; 40 + } 41 + 42 + // #region web sessions 43 + 44 + /** 45 + * create a web session record. 46 + * @param options web session options 47 + * @returns session and signed token 48 + */ 49 + async createWebSession(options: CreateWebSessionOptions): Promise<{ session: WebSession; token: string }> { 50 + const now = new Date(); 51 + const expiresAt = new Date( 52 + now.getTime() + (options.remember ? WEB_SESSION_LONG_TTL_MS : WEB_SESSION_TTL_MS), 53 + ); 54 + const id = nanoid(44); 55 + 56 + const inserted = this.#db 57 + .insert(t.webSession) 58 + .values({ 59 + id: id, 60 + did: options.did, 61 + metadata: { 62 + userAgent: options.userAgent, 63 + ip: options.ip, 64 + }, 65 + created_at: now, 66 + expires_at: expiresAt, 67 + }) 68 + .returning() 69 + .get(); 70 + 71 + if (!inserted) { 72 + throw new Error(`failed to create web session`); 73 + } 74 + 75 + const token = createWebSessionToken(this.#jwtKey, id); 76 + 77 + return { session: inserted, token: token }; 78 + } 79 + 80 + /** 81 + * get a web session by id. 82 + * @param sessionId session id 83 + * @returns web session or null if expired/not found 84 + */ 85 + getWebSession(sessionId: string): WebSession | null { 86 + const session = this.#db.select().from(t.webSession).where(eq(t.webSession.id, sessionId)).get(); 87 + if (!session) { 88 + return null; 89 + } 90 + 91 + const now = new Date(); 92 + if (session.expires_at <= now) { 93 + this.#db.delete(t.webSession).where(eq(t.webSession.id, sessionId)).run(); 94 + return null; 95 + } 96 + 97 + return session; 98 + } 99 + 100 + /** 101 + * delete a web session. 102 + * @param sessionId session id 103 + */ 104 + deleteWebSession(sessionId: string): void { 105 + this.#db.delete(t.webSession).where(eq(t.webSession.id, sessionId)).run(); 106 + } 107 + 108 + // #endregion 109 + 110 + // #region sudo mode 111 + 112 + /** 113 + * elevate a session to sudo mode. 114 + * @param sessionId the session id 115 + */ 116 + elevateSession(sessionId: string): void { 117 + this.#db.update(t.webSession).set({ sudo_at: new Date() }).where(eq(t.webSession.id, sessionId)).run(); 118 + } 119 + 120 + /** 121 + * check if a session is in sudo mode. 122 + * @param session the session 123 + * @returns true if session is elevated 124 + */ 125 + isSessionElevated(session: WebSession): boolean { 126 + if (session.sudo_at === null) { 127 + return false; 128 + } 129 + const now = Date.now(); 130 + const elevatedAt = session.sudo_at.getTime(); 131 + return now - elevatedAt < SUDO_MODE_TTL_MS; 132 + } 133 + 134 + // #endregion 135 + 136 + // #region verification challenges 137 + 138 + /** 139 + * create a verification challenge for MFA login (no session, creates one on success). 140 + * @param did account did 141 + * @param remember whether to create a long-lived session on success 142 + * @returns token for the verify page 143 + */ 144 + createVerifyChallenge(did: Did, remember: boolean): string { 145 + const token = nanoid(32); 146 + const now = new Date(); 147 + const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS); 148 + 149 + this.#db 150 + .insert(t.verifyChallenge) 151 + .values({ 152 + token: token, 153 + did: did, 154 + session_id: null, 155 + remember: remember, 156 + created_at: now, 157 + expires_at: expiresAt, 158 + }) 159 + .run(); 160 + 161 + return token; 162 + } 163 + 164 + /** 165 + * create or get an existing sudo challenge for session elevation. 166 + * @param sessionId the session to elevate 167 + * @param did account did 168 + * @returns the verify challenge row 169 + */ 170 + getOrCreateSudoChallenge(sessionId: string, did: Did): VerifyChallenge { 171 + // check for existing sudo challenge for this session 172 + const existing = this.#db 173 + .select() 174 + .from(t.verifyChallenge) 175 + .where(eq(t.verifyChallenge.session_id, sessionId)) 176 + .get(); 177 + 178 + const now = new Date(); 179 + 180 + if (existing && existing.expires_at > now) { 181 + return existing; 182 + } 183 + 184 + // delete expired challenge if it exists 185 + if (existing) { 186 + this.#db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, existing.token)).run(); 187 + } 188 + 189 + const token = nanoid(32); 190 + const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS); 191 + 192 + const inserted = this.#db 193 + .insert(t.verifyChallenge) 194 + .values({ 195 + token: token, 196 + did: did, 197 + session_id: sessionId, 198 + created_at: now, 199 + expires_at: expiresAt, 200 + }) 201 + .returning() 202 + .get(); 203 + 204 + return inserted; 205 + } 206 + 207 + /** 208 + * get a verification challenge by token. 209 + * @param token the token 210 + * @returns verify challenge or null if expired/not found 211 + */ 212 + getVerifyChallenge(token: string): VerifyChallenge | null { 213 + const challenge = this.#db 214 + .select() 215 + .from(t.verifyChallenge) 216 + .where(eq(t.verifyChallenge.token, token)) 217 + .get(); 218 + 219 + if (!challenge) { 220 + return null; 221 + } 222 + 223 + const now = new Date(); 224 + if (challenge.expires_at <= now) { 225 + this.#db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run(); 226 + return null; 227 + } 228 + 229 + return challenge; 230 + } 231 + 232 + /** 233 + * delete a verification challenge. 234 + * @param token the token 235 + */ 236 + deleteVerifyChallenge(token: string): void { 237 + this.#db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run(); 238 + } 239 + 240 + /** 241 + * clean up expired verification challenges. 242 + */ 243 + cleanupExpiredVerifyChallenges(): void { 244 + const now = new Date(); 245 + this.#db.delete(t.verifyChallenge).where(lte(t.verifyChallenge.expires_at, now)).run(); 246 + } 247 + 248 + /** 249 + * set the WebAuthn challenge on an existing verification challenge. 250 + * @param token verify challenge token 251 + * @param webauthnChallenge base64url WebAuthn challenge 252 + */ 253 + setVerifyChallengeWebAuthn(token: string, webauthnChallenge: string): void { 254 + this.#db 255 + .update(t.verifyChallenge) 256 + .set({ webauthn_challenge: webauthnChallenge }) 257 + .where(eq(t.verifyChallenge.token, token)) 258 + .run(); 259 + } 260 + 261 + // #endregion 262 + }
+1 -1
packages/danaus/src/accounts/webauthn.ts
··· 11 11 } from '@simplewebauthn/server'; 12 12 13 13 import { WebAuthnCredentialType } from './db/schema.ts'; 14 - import type { WebauthnCredential } from './manager'; 14 + import type { WebauthnCredential } from './mfa'; 15 15 16 16 // #region constants 17 17
+3 -3
packages/danaus/src/api/com.atproto/server.createSession.ts
··· 11 11 * @param context app context 12 12 */ 13 13 export const createSession = (router: XRPCRouter, context: AppContext) => { 14 - const { accountManager } = context; 14 + const { legacyAuthManager } = context; 15 15 16 16 router.addProcedure(ComAtprotoServerCreateSession, { 17 17 async handler({ input }) { 18 - const auth = await accountManager.verifyLegacyCredentials(input.identifier, input.password); 18 + const auth = await legacyAuthManager.verifyLegacyCredentials(input.identifier, input.password); 19 19 if (!auth) { 20 20 throw new AuthRequiredError({ 21 21 error: 'InvalidCredentials', ··· 38 38 throw new InvalidRequestError({ error: 'HandleNotFound', description: `handle not found` }); 39 39 } 40 40 41 - const { accessJwt, refreshJwt } = await accountManager.createLegacySession({ 41 + const { accessJwt, refreshJwt } = await legacyAuthManager.createLegacySession({ 42 42 did: account.did, 43 43 appPassword: appPassword, 44 44 });
+2 -2
packages/danaus/src/api/com.atproto/server.deleteSession.ts
··· 9 9 * @param context app context 10 10 */ 11 11 export const deleteSession = (router: XRPCRouter, context: AppContext) => { 12 - const { accountManager, authVerifier } = context; 12 + const { legacyAuthManager, authVerifier } = context; 13 13 14 14 router.addProcedure(ComAtprotoServerDeleteSession, { 15 15 async handler({ request }) { 16 16 const auth = await authVerifier.refresh(request); 17 17 18 - accountManager.deleteLegacySession(auth.tokenId); 18 + legacyAuthManager.deleteLegacySession(auth.tokenId); 19 19 }, 20 20 }); 21 21 };
+2 -2
packages/danaus/src/api/com.atproto/server.refreshSession.ts
··· 10 10 * @param context app context 11 11 */ 12 12 export const refreshSession = (router: XRPCRouter, context: AppContext) => { 13 - const { accountManager, authVerifier } = context; 13 + const { accountManager, legacyAuthManager, authVerifier } = context; 14 14 15 15 router.addProcedure(ComAtprotoServerRefreshSession, { 16 16 async handler({ request }) { ··· 40 40 throw new InvalidRequestError({ error: 'HandleNotFound', description: `handle not found` }); 41 41 } 42 42 43 - const { accessJwt, refreshJwt } = await accountManager.rotateLegacyRefresh(auth.tokenId); 43 + const { accessJwt, refreshJwt } = await legacyAuthManager.rotateLegacyRefresh(auth.tokenId); 44 44 45 45 return json({ 46 46 did: account.did,
+3 -3
packages/danaus/src/api/local.danaus/account.createAccount.ts
··· 22 22 23 23 // #region XRPC handler 24 24 export const createAccount = (router: XRPCRouter, context: AppContext) => { 25 - const { accountManager, authVerifier } = context; 25 + const { inviteCodeManager, authVerifier } = context; 26 26 27 27 router.addProcedure(LocalDanausAccountCreateAccount, { 28 28 async handler({ input, request }) { ··· 39 39 }); 40 40 } 41 41 42 - accountManager.ensureInviteIsAvailable(input.inviteCode); 42 + inviteCodeManager.ensureInviteIsAvailable(input.inviteCode); 43 43 } 44 44 } 45 45 ··· 52 52 53 53 // record invite code usage for public signups 54 54 if (!isAdmin && input.inviteCode) { 55 - accountManager.recordInviteUse(input.inviteCode, did); 55 + inviteCodeManager.recordInviteUse(input.inviteCode, did); 56 56 } 57 57 58 58 return json({ did });
+2 -2
packages/danaus/src/api/local.danaus/admin.getStats.ts
··· 9 9 * @param context app context 10 10 */ 11 11 export const getStats = (router: XRPCRouter, context: AppContext) => { 12 - const { accountManager, authVerifier, sequencer } = context; 12 + const { accountManager, inviteCodeManager, authVerifier, sequencer } = context; 13 13 14 14 router.addQuery(LocalDanausAdminGetStats, { 15 15 async handler({ request }) { ··· 18 18 return json({ 19 19 accounts: accountManager.getAccountStats(), 20 20 sequencer: sequencer.getStats(), 21 - inviteCodes: accountManager.getInviteCodeStats(), 21 + inviteCodes: inviteCodeManager.getInviteCodeStats(), 22 22 }); 23 23 }, 24 24 });
+38 -5
packages/danaus/src/context.ts
··· 10 10 } from '@atcute/identity-resolver'; 11 11 import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; 12 12 13 + import { getAccountDb, type AccountDb } from './accounts/db'; 14 + import { InviteCodeManager } from './accounts/invite-codes'; 15 + import { LegacyAuthManager } from './accounts/legacy-auth'; 13 16 import { AccountManager } from './accounts/manager'; 17 + import { MfaManager } from './accounts/mfa'; 18 + import { WebSessionManager } from './accounts/web-sessions'; 14 19 import { DiskBlobStore } from './actors/blob-store/disk'; 15 20 import { S3BlobStore } from './actors/blob-store/s3'; 16 21 import { ActorManager } from './actors/manager'; ··· 34 39 didDocumentResolver: DidDocumentResolver<'plc' | 'web'>; 35 40 plcClient: PlcClient; 36 41 42 + accountDb: AccountDb; 37 43 accountManager: AccountManager; 44 + inviteCodeManager: InviteCodeManager; 45 + mfaManager: MfaManager; 46 + legacyAuthManager: LegacyAuthManager; 47 + webSessionManager: WebSessionManager; 48 + 38 49 actorManager: ActorManager; 39 50 authVerifier: AuthVerifier; 40 51 ··· 82 93 serviceUrl: config.identity.plcDirectoryUrl, 83 94 }); 84 95 96 + const accountDb = getAccountDb(config.database.accountDbLocation, config.database.walAutoCheckpointDisabled); 97 + 85 98 const accountManager = new AccountManager({ 86 - location: config.database.accountDbLocation, 87 - walAutocheckpointDisabled: config.database.walAutoCheckpointDisabled, 99 + db: accountDb, 100 + serviceHandleDomains: config.identity.serviceHandleDomains, 101 + handleResolver: handleResolver, 102 + }); 103 + 104 + const inviteCodeManager = new InviteCodeManager({ 105 + db: accountDb, 106 + }); 107 + 108 + const mfaManager = new MfaManager({ 109 + db: accountDb, 110 + }); 88 111 112 + const legacyAuthManager = new LegacyAuthManager({ 113 + db: accountDb, 114 + jwtKey: config.secrets.jwtKey, 89 115 serviceDid: config.service.did, 90 - serviceHandleDomains: config.identity.serviceHandleDomains, 116 + accountManager: accountManager, 117 + }); 91 118 92 - handleResolver: handleResolver, 93 - 119 + const webSessionManager = new WebSessionManager({ 120 + db: accountDb, 94 121 jwtKey: config.secrets.jwtKey, 95 122 }); 96 123 ··· 137 164 didDocumentResolver: didDocumentResolver, 138 165 plcClient: plcClient, 139 166 167 + accountDb: accountDb, 140 168 accountManager: accountManager, 169 + inviteCodeManager: inviteCodeManager, 170 + mfaManager: mfaManager, 171 + legacyAuthManager: legacyAuthManager, 172 + webSessionManager: webSessionManager, 173 + 141 174 actorManager: actorManager, 142 175 authVerifier: authVerifier, 143 176
+1 -1
packages/danaus/src/pds-server.ts
··· 49 49 // doesn't accept Disposable like the spec allows, so we use defer() instead 50 50 disposables.defer(() => context.backgroundQueue.dispose()); 51 51 disposables.defer(() => context.identityCache.dispose()); 52 - disposables.defer(() => context.accountManager.dispose()); 52 + disposables.defer(() => context.accountDb.$client.close()); 53 53 54 54 const { wrap, adapter } = createBunWebSocket(); 55 55 const router = new XRPCRouter({
+1 -1
packages/danaus/src/test/seed-client.ts
··· 129 129 }), 130 130 ); 131 131 132 - const { secret: appPassword } = await this.network.pds.ctx.accountManager.createAppPassword({ 132 + const { secret: appPassword } = await this.network.pds.ctx.legacyAuthManager.createAppPassword({ 133 133 did: did, 134 134 name: params.appPasswordName ?? 'seed password', 135 135 privilege: params.appPasswordPrivilege ?? AppPasswordPrivilege.Full,
+10 -8
packages/danaus/src/web/account/forms.ts
··· 22 22 privilege: v.picklist(['limited', 'privileged', 'full'], `Invalid privilege`), 23 23 }), 24 24 async (data) => { 25 - const { accountManager } = getAppContext(); 25 + const { legacyAuthManager } = getAppContext(); 26 26 const session = getSession(); 27 27 28 28 const privilege = parseAppPasswordPrivilege(data.privilege); 29 29 30 30 try { 31 - const { appPassword, secret } = await accountManager.createAppPassword({ 31 + const { appPassword, secret } = await legacyAuthManager.createAppPassword({ 32 32 did: session.did, 33 33 name: data.name, 34 34 privilege, ··· 60 60 name: v.pipe(v.string(), v.minLength(1)), 61 61 }), 62 62 async (data) => { 63 - const { accountManager } = getAppContext(); 63 + const { legacyAuthManager } = getAppContext(); 64 64 const session = getSession(); 65 65 66 - accountManager.deleteAppPassword(session.did, data.name); 66 + legacyAuthManager.deleteAppPassword(session.did, data.name); 67 67 }, 68 68 ); 69 69 ··· 77 77 }), 78 78 async (data) => { 79 79 const ctx = getAppContext(); 80 + 81 + const { accountManager, sequencer } = ctx; 80 82 const { did } = getSession(); 81 83 82 84 let handle: Handle; ··· 97 99 98 100 // validate the handle (checks TLD, service domain constraints, external domain resolution) 99 101 try { 100 - handle = await ctx.accountManager.validateHandle(handle, { did }); 102 + handle = await accountManager.validateHandle(handle, { did }); 101 103 } catch (err) { 102 104 if (err instanceof XRPCError && err.status === 400) { 103 105 switch (err.error) { ··· 113 115 } 114 116 115 117 // check if handle is already taken by another account 116 - const existing = ctx.accountManager.getAccount(handle, { 118 + const existing = accountManager.getAccount(handle, { 117 119 includeDeactivated: true, 118 120 includeTakenDown: true, 119 121 }); ··· 140 142 } 141 143 142 144 // update local database and emit identity event 143 - ctx.accountManager.updateAccountHandle(did, handle); 144 - await ctx.sequencer.emitIdentity(did, handle); 145 + accountManager.updateAccountHandle(did, handle); 146 + await sequencer.emitIdentity(did, handle); 145 147 }, 146 148 ); 147 149
+3 -1
packages/danaus/src/web/admin/forms.ts
··· 30 30 async (data, issue) => { 31 31 const ctx = getAppContext(); 32 32 33 + const { config } = ctx; 34 + 33 35 // validate domain against config 34 - if (!ctx.config.identity.serviceHandleDomains.includes(data.domain)) { 36 + if (!config.identity.serviceHandleDomains.includes(data.domain)) { 35 37 invalid(issue.domain(`Invalid domain`)); 36 38 } 37 39
+10 -9
packages/danaus/src/web/controllers/account.tsx
··· 35 35 ], 36 36 actions: { 37 37 overview() { 38 - const ctx = getAppContext(); 38 + const { accountManager, config } = getAppContext(); 39 39 const session = getSession(); 40 - const account = ctx.accountManager.getAccount(session.did); 40 + 41 + const account = accountManager.getAccount(session.did)!; 41 42 42 43 // determine current handle parts for form prefill 43 - const currentHandle = account?.handle ?? ''; 44 - const isServiceHandle = ctx.config.identity.serviceHandleDomains.some((d) => currentHandle.endsWith(d)); 44 + const currentHandle = account.handle ?? ''; 45 + const isServiceHandle = config.identity.serviceHandleDomains.some((d) => currentHandle.endsWith(d)); 45 46 const currentDomain = isServiceHandle 46 - ? (ctx.config.identity.serviceHandleDomains.find((d) => currentHandle.endsWith(d)) ?? 'custom') 47 + ? (config.identity.serviceHandleDomains.find((d) => currentHandle.endsWith(d)) ?? 'custom') 47 48 : 'custom'; 48 49 const currentLocalPart = isServiceHandle 49 50 ? currentHandle.slice(0, -currentDomain.length) ··· 80 81 <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 81 82 <div class="flex items-center gap-4 px-4 py-3"> 82 83 <div class="min-w-0 grow"> 83 - <p class="text-base-300 font-medium wrap-break-word">@{account?.handle}</p> 84 + <p class="text-base-300 font-medium wrap-break-word">@{account.handle}</p> 84 85 <p class="text-base-300 text-neutral-foreground-3">Your username on the network</p> 85 86 </div> 86 87 ··· 168 169 <Select 169 170 {...updateHandleForm.fields.domain.as('select')} 170 171 value={updateHandleForm.fields.domain.value() || currentDomain} 171 - options={ctx.config.identity.serviceHandleDomains.map((d) => ({ 172 + options={config.identity.serviceHandleDomains.map((d) => ({ 172 173 value: d, 173 174 label: d, 174 175 }))} ··· 339 340 }, 340 341 341 342 appPasswords() { 342 - const ctx = getAppContext(); 343 + const { legacyAuthManager } = getAppContext(); 343 344 const session = getSession(); 344 345 const did = session.did as Did; 345 346 346 - const passwords = ctx.accountManager.listAppPasswords(did); 347 + const passwords = legacyAuthManager.listAppPasswords(did); 347 348 348 349 const newPasswordResult = createAppPasswordForm.result; 349 350 const newPasswordError = createAppPasswordForm.fields.allIssues()?.[0];
+8 -10
packages/danaus/src/web/controllers/account/security/overview.tsx
··· 2 2 import { render } from '@oomfware/jsx'; 3 3 4 4 import { WebAuthnCredentialType } from '#app/accounts/db/schema.ts'; 5 - import type { Account, TotpCredential, WebauthnCredential } from '#app/accounts/manager.ts'; 5 + import type { Account } from '#app/accounts/manager.ts'; 6 + import type { TotpCredential, WebauthnCredential } from '#app/accounts/mfa.ts'; 6 7 7 8 import DotGrid1x3HorizontalOutlined from '#web/icons/central/dot-grid-1x3-horizontal-outlined.tsx'; 8 9 import PasskeysOutlined from '#web/icons/central/passkeys-outlined.tsx'; ··· 19 20 export default { 20 21 middleware: [], 21 22 action() { 22 - const { accountManager } = getAppContext(); 23 + const { accountManager, mfaManager } = getAppContext(); 23 24 const { did } = getSession(); 24 25 const account = accountManager.getAccount(did)!; 25 26 26 - const totpCredentials = accountManager.listTotpCredentials(did); 27 - const securityKeys = accountManager.listWebAuthnCredentialsByType( 28 - did, 29 - WebAuthnCredentialType.SecurityKey, 30 - ); 31 - const passkeys = accountManager.listWebAuthnCredentialsByType(did, WebAuthnCredentialType.Passkey); 27 + const totpCredentials = mfaManager.listTotpCredentials(did); 28 + const securityKeys = mfaManager.listWebAuthnCredentialsByType(did, WebAuthnCredentialType.SecurityKey); 29 + const passkeys = mfaManager.listWebAuthnCredentialsByType(did, WebAuthnCredentialType.Passkey); 32 30 const hasMfa = totpCredentials.length > 0 || securityKeys.length > 0; 33 31 34 32 return render( ··· 301 299 }; 302 300 303 301 const RecoverySection = () => { 304 - const ctx = getAppContext(); 302 + const { mfaManager } = getAppContext(); 305 303 const session = getSession(); 306 304 307 - const backupCodeCount = ctx.accountManager.getRecoveryCodeCount(session.did); 305 + const backupCodeCount = mfaManager.getRecoveryCodeCount(session.did); 308 306 309 307 return ( 310 308 <div class="flex flex-col gap-2">
+12 -12
packages/danaus/src/web/controllers/account/security/recovery.tsx
··· 14 14 middleware: [], 15 15 actions: { 16 16 show({ url }) { 17 - const { accountManager } = getAppContext(); 17 + const { mfaManager, webSessionManager } = getAppContext(); 18 18 const session = getSession(); 19 19 20 - if (accountManager.getMfaStatus(session.did) === null) { 20 + if (mfaManager.getMfaStatus(session.did) === null) { 21 21 redirect(routes.account.security.overview.href()); 22 22 } 23 23 24 24 // require sudo mode 25 - if (!accountManager.isSessionElevated(session)) { 25 + if (!webSessionManager.isSessionElevated(session)) { 26 26 redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 27 27 } 28 28 29 29 // generate codes if none exist 30 - let codes = accountManager.getRecoveryCodes(session.did); 30 + let codes = mfaManager.getRecoveryCodes(session.did); 31 31 if (codes.length === 0) { 32 - accountManager.generateRecoveryCodes(session.did); 33 - codes = accountManager.getRecoveryCodes(session.did); 32 + mfaManager.generateRecoveryCodes(session.did); 33 + codes = mfaManager.getRecoveryCodes(session.did); 34 34 } 35 35 36 36 return render( ··· 68 68 regenerate: { 69 69 middleware: [forms({ generateBackupCodesForm })], 70 70 action({ url }) { 71 - const { accountManager } = getAppContext(); 71 + const { mfaManager, webSessionManager } = getAppContext(); 72 72 const session = getSession(); 73 73 74 - if (accountManager.getMfaStatus(session.did) === null) { 74 + if (mfaManager.getMfaStatus(session.did) === null) { 75 75 redirect(routes.account.security.overview.href()); 76 76 } 77 77 78 78 // require sudo mode 79 - if (!accountManager.isSessionElevated(session)) { 79 + if (!webSessionManager.isSessionElevated(session)) { 80 80 redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 81 81 } 82 82 ··· 124 124 remove: { 125 125 middleware: [forms({ deleteBackupCodesForm })], 126 126 action({ url }) { 127 - const { accountManager } = getAppContext(); 127 + const { mfaManager, webSessionManager } = getAppContext(); 128 128 const session = getSession(); 129 129 130 - if (accountManager.getMfaStatus(session.did) === null) { 130 + if (mfaManager.getMfaStatus(session.did) === null) { 131 131 redirect(routes.account.security.overview.href()); 132 132 } 133 133 134 134 // require sudo mode 135 - if (!accountManager.isSessionElevated(session)) { 135 + if (!webSessionManager.isSessionElevated(session)) { 136 136 redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 137 137 } 138 138
+4 -4
packages/danaus/src/web/controllers/account/security/recovery/lib/forms.ts
··· 10 10 import { getSession } from '#web/middlewares/session.ts'; 11 11 12 12 export const generateBackupCodesForm = form(v.object({}), async () => { 13 - const { accountManager } = getAppContext(); 13 + const { mfaManager } = getAppContext(); 14 14 const { did } = getSession(); 15 15 16 16 requireSudo(); 17 17 18 - accountManager.generateRecoveryCodes(did); 18 + mfaManager.generateRecoveryCodes(did); 19 19 20 20 redirect(routes.account.security.recovery.show.href()); 21 21 }); 22 22 23 23 export const deleteBackupCodesForm = form(v.object({}), async () => { 24 - const { accountManager } = getAppContext(); 24 + const { mfaManager } = getAppContext(); 25 25 const { did } = getSession(); 26 26 27 27 requireSudo(); 28 28 29 - accountManager.deleteRecoveryCodes(did); 29 + mfaManager.deleteRecoveryCodes(did); 30 30 31 31 redirect(routes.account.security.overview.href()); 32 32 });
+6 -6
packages/danaus/src/web/controllers/account/security/totp.tsx
··· 25 25 register: { 26 26 middleware: [forms({ setupTotpForm })], 27 27 async action({ url }) { 28 - const { accountManager, config } = getAppContext(); 28 + const { accountManager, mfaManager, webSessionManager, config } = getAppContext(); 29 29 const session = getSession(); 30 30 31 31 // require sudo mode 32 - if (!accountManager.isSessionElevated(session)) { 32 + if (!webSessionManager.isSessionElevated(session)) { 33 33 redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 34 34 } 35 35 ··· 104 104 > 105 105 <Input 106 106 {...fields.name.as('text')} 107 - placeholder={accountManager.generateTotpName(session.did)} 107 + placeholder={mfaManager.generateTotpName(session.did)} 108 108 /> 109 109 </Field> 110 110 ··· 150 150 remove: { 151 151 middleware: [forms({ removeTotpForm })], 152 152 action({ url, params }) { 153 - const { accountManager } = getAppContext(); 153 + const { mfaManager, webSessionManager } = getAppContext(); 154 154 const session = getSession(); 155 155 156 156 const id = coerceToInteger(params.id); ··· 158 158 redirect(routes.account.security.overview.href()); 159 159 } 160 160 161 - const totp = accountManager.getTotpCredential(session.did, id); 161 + const totp = mfaManager.getTotpCredential(session.did, id); 162 162 if (totp === null) { 163 163 redirect(routes.account.security.overview.href()); 164 164 } 165 165 166 166 // require sudo mode 167 - if (!accountManager.isSessionElevated(session)) { 167 + if (!webSessionManager.isSessionElevated(session)) { 168 168 redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 169 169 } 170 170
+4 -4
packages/danaus/src/web/controllers/account/security/totp/lib/forms.ts
··· 22 22 _code: v.pipe(v.string(), v.length(6, `Enter the 6-digit code`)), 23 23 }), 24 24 async (data, issue) => { 25 - const { accountManager } = getAppContext(); 25 + const { mfaManager } = getAppContext(); 26 26 const { did } = getSession(); 27 27 28 28 // verify the code against the provided secret ··· 42 42 43 43 // store the credential 44 44 try { 45 - accountManager.createTotpCredential({ 45 + mfaManager.createTotpCredential({ 46 46 did: did, 47 47 name: data.name, 48 48 secret: secretBytes, ··· 74 74 id: v.pipe(v.string(), v.toNumber(), v.safeInteger()), 75 75 }), 76 76 async (data) => { 77 - const { accountManager } = getAppContext(); 77 + const { mfaManager } = getAppContext(); 78 78 const { did } = getSession(); 79 79 80 80 requireSudo(); 81 - accountManager.deleteTotpCredential(did, data.id); 81 + mfaManager.deleteTotpCredential(did, data.id); 82 82 83 83 redirect(routes.account.security.overview.href()); 84 84 },
+8 -8
packages/danaus/src/web/controllers/account/security/webauthn.tsx
··· 19 19 register: { 20 20 middleware: [forms({ completeWebAuthnForm })], 21 21 async action({ url }) { 22 - const { accountManager } = getAppContext(); 22 + const { accountManager, mfaManager, webSessionManager } = getAppContext(); 23 23 const session = getSession(); 24 24 25 25 // require sudo mode 26 - if (!accountManager.isSessionElevated(session)) { 26 + if (!webSessionManager.isSessionElevated(session)) { 27 27 redirect(routes.verify.index.href(undefined, { redirect: url.pathname + url.search })); 28 28 } 29 29 ··· 43 43 44 44 if (token) { 45 45 // try to get existing challenge 46 - const existingChallenge = accountManager.getWebAuthnRegistrationChallenge(token); 46 + const existingChallenge = mfaManager.getWebAuthnRegistrationChallenge(token); 47 47 if (existingChallenge) { 48 48 // regenerate options with the same challenge 49 49 const state = await initiateWebAuthnRegistration( ··· 52 52 credentialType, 53 53 ); 54 54 // delete old challenge and use new one 55 - accountManager.deleteWebAuthnRegistrationChallenge(token); 55 + mfaManager.deleteWebAuthnRegistrationChallenge(token); 56 56 token = state.token; 57 57 options = state.options; 58 58 } ··· 116 116 > 117 117 <Input 118 118 {...fields.name.as('text')} 119 - placeholder={accountManager.generateWebAuthnName(session.did, credentialType)} 119 + placeholder={mfaManager.generateWebAuthnName(session.did, credentialType)} 120 120 /> 121 121 </Field> 122 122 ··· 151 151 remove: { 152 152 middleware: [forms({ removeWebAuthnForm })], 153 153 action({ url, params }) { 154 - const { accountManager } = getAppContext(); 154 + const { mfaManager, webSessionManager } = getAppContext(); 155 155 const session = getSession(); 156 156 157 157 const id = coerceToInteger(params.id); ··· 159 159 redirect(routes.account.security.overview.href()); 160 160 } 161 161 162 - const credential = accountManager.getWebAuthnCredential(session.did, id); 162 + const credential = mfaManager.getWebAuthnCredential(session.did, id); 163 163 if (credential === null) { 164 164 redirect(routes.account.security.overview.href()); 165 165 } 166 166 167 167 // require sudo mode 168 - if (!accountManager.isSessionElevated(session)) { 168 + if (!webSessionManager.isSessionElevated(session)) { 169 169 redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 170 170 } 171 171
+9 -9
packages/danaus/src/web/controllers/account/security/webauthn/lib/forms.ts
··· 31 31 userName: string, 32 32 credentialType: WebAuthnCredentialType, 33 33 ): Promise<WebAuthnRegistrationState> => { 34 - const { accountManager, config } = getAppContext(); 34 + const { mfaManager, config } = getAppContext(); 35 35 36 - const existingCredentials = accountManager.listWebAuthnCredentials(did); 36 + const existingCredentials = mfaManager.listWebAuthnCredentials(did); 37 37 38 38 const options = await generateWebAuthnRegistrationOptions({ 39 39 rpId: config.service.hostname, ··· 45 45 }); 46 46 47 47 // store the challenge 48 - const token = accountManager.createWebAuthnRegistrationChallenge(did, options.challenge); 48 + const token = mfaManager.createWebAuthnRegistrationChallenge(did, options.challenge); 49 49 50 50 return { token, options }; 51 51 }; ··· 78 78 ), 79 79 }), 80 80 async (data, issue) => { 81 - const { accountManager, config } = getAppContext(); 81 + const { mfaManager, config } = getAppContext(); 82 82 const { did } = getSession(); 83 83 84 84 // get the challenge 85 - const challenge = accountManager.getWebAuthnRegistrationChallenge(data.token); 85 + const challenge = mfaManager.getWebAuthnRegistrationChallenge(data.token); 86 86 if (!challenge) { 87 87 invalid(`Registration expired, please try again`); 88 88 } ··· 110 110 } 111 111 112 112 // delete the challenge 113 - accountManager.deleteWebAuthnRegistrationChallenge(data.token); 113 + mfaManager.deleteWebAuthnRegistrationChallenge(data.token); 114 114 115 115 requireSudo(); 116 116 ··· 120 120 data.credentialType === 'passkey' ? WebAuthnCredentialType.Passkey : WebAuthnCredentialType.SecurityKey; 121 121 122 122 try { 123 - accountManager.createWebAuthnCredential({ 123 + mfaManager.createWebAuthnCredential({ 124 124 did: did, 125 125 type: credentialType, 126 126 name: data.name, ··· 158 158 id: v.pipe(v.string(), v.toNumber(), v.safeInteger()), 159 159 }), 160 160 async (data) => { 161 - const { accountManager } = getAppContext(); 161 + const { mfaManager } = getAppContext(); 162 162 const { did } = getSession(); 163 163 164 164 requireSudo(); 165 - accountManager.deleteWebAuthnCredential(did, data.id); 165 + mfaManager.deleteWebAuthnCredential(did, data.id); 166 166 167 167 redirect(routes.account.security.overview.href()); 168 168 },
+10 -8
packages/danaus/src/web/controllers/admin.tsx
··· 16 16 middleware: [requireAdmin(), forms({ createAccountForm })], 17 17 actions: { 18 18 dashboard() { 19 - const ctx = getAppContext(); 20 - const accountStats = ctx.accountManager.getAccountStats(); 21 - const inviteCodeStats = ctx.accountManager.getInviteCodeStats(); 22 - const sequencerStats = ctx.sequencer.getStats(); 19 + const { accountManager, inviteCodeManager, sequencer } = getAppContext(); 20 + const accountStats = accountManager.getAccountStats(); 21 + const inviteCodeStats = inviteCodeManager.getInviteCodeStats(); 22 + const sequencerStats = sequencer.getStats(); 23 23 24 24 return render( 25 25 <AdminLayout> ··· 66 66 67 67 accounts: { 68 68 index({ url }) { 69 - const ctx = getAppContext(); 69 + const { accountManager } = getAppContext(); 70 + 70 71 const query = url.searchParams.get('q') ?? ''; 71 72 const cursor = url.searchParams.get('cursor') ?? undefined; 72 73 73 - const { accounts, cursor: nextCursor } = ctx.accountManager.listAccounts({ 74 + const { accounts, cursor: nextCursor } = accountManager.listAccounts({ 74 75 query: query || undefined, 75 76 cursor, 76 77 limit: 50, ··· 154 155 }, 155 156 156 157 create() { 157 - const ctx = getAppContext(); 158 - const domains = ctx.config.identity.serviceHandleDomains; 158 + const { config } = getAppContext(); 159 + 160 + const domains = config.identity.serviceHandleDomains; 159 161 const domainOptions = domains.map((d) => ({ value: d, label: d })); 160 162 161 163 const { fields } = createAccountForm;
+4 -4
packages/danaus/src/web/controllers/login.tsx
··· 108 108 }, 109 109 }, 110 110 logout() { 111 - const { accountManager, config } = getAppContext(); 111 + const { webSessionManager, config } = getAppContext(); 112 112 const { request } = getContext(); 113 113 114 114 // read and verify the session token ··· 116 116 if (token) { 117 117 const sessionId = verifyWebSessionToken(config.secrets.jwtKey, token); 118 118 if (sessionId) { 119 - accountManager.deleteWebSession(sessionId); 119 + webSessionManager.deleteWebSession(sessionId); 120 120 } 121 121 } 122 122 ··· 127 127 }, 128 128 passkey: { 129 129 async challenge() { 130 - const { accountManager, config } = getAppContext(); 130 + const { mfaManager, config } = getAppContext(); 131 131 132 132 // generate discoverable authentication options (no allowCredentials) 133 133 const options = await generateWebAuthnAuthenticationOptions({ ··· 137 137 138 138 // store challenge for verification (using null DID since we don't know the user yet) 139 139 // we'll use the challenge itself as a lookup key 140 - accountManager.createPasskeyLoginChallenge(options.challenge); 140 + mfaManager.createPasskeyLoginChallenge(options.challenge); 141 141 142 142 return Response.json(options); 143 143 },
+26 -26
packages/danaus/src/web/controllers/login/lib/forms.ts
··· 31 31 * @param options verification options 32 32 */ 33 33 const verifyFactor = async (options: VerifyFactorOptions): Promise<void> => { 34 - const { accountManager } = getAppContext(); 34 + const { accountManager, mfaManager } = getAppContext(); 35 35 const { did, factor, code, allowedFactors } = options; 36 36 37 37 if (!allowedFactors.includes(factor)) { ··· 44 44 invalid(`Invalid verification code`); 45 45 } 46 46 47 - const valid = await accountManager.verifyAccountTotpCode(did, code); 47 + const valid = await mfaManager.verifyAccountTotpCode(did, code); 48 48 if (!valid) { 49 49 invalid(`Invalid verification code`); 50 50 } ··· 56 56 invalid(`Invalid recovery code`); 57 57 } 58 58 59 - const valid = accountManager.consumeRecoveryCode(did, code); 59 + const valid = mfaManager.consumeRecoveryCode(did, code); 60 60 if (!valid) { 61 61 invalid(`Invalid recovery code`); 62 62 } ··· 101 101 redirect: v.pipe(v.string(), v.minLength(1)), 102 102 }), 103 103 async (data, issue) => { 104 - const { accountManager } = getAppContext(); 104 + const { accountManager, mfaManager, webSessionManager } = getAppContext(); 105 105 const { request } = getContext(); 106 106 107 107 if (data._password.length < MIN_PASSWORD_LENGTH || data._password.length > MAX_PASSWORD_LENGTH) { ··· 127 127 } 128 128 129 129 // clean up any expired verify challenges 130 - accountManager.cleanupExpiredVerifyChallenges(); 130 + webSessionManager.cleanupExpiredVerifyChallenges(); 131 131 132 132 // check if MFA is enabled 133 - if (accountManager.getMfaStatus(account.did) !== null) { 133 + if (mfaManager.getMfaStatus(account.did) !== null) { 134 134 // create verify challenge and redirect 135 - const token = accountManager.createVerifyChallenge(account.did, data.remember ?? false); 135 + const token = webSessionManager.createVerifyChallenge(account.did, data.remember ?? false); 136 136 137 137 redirect(routes.verify.index.href(undefined, { token, redirect: data.redirect })); 138 138 } 139 139 140 - const { session, token } = await accountManager.createWebSession({ 140 + const { session, token } = await webSessionManager.createWebSession({ 141 141 did: account.did, 142 142 remember: data.remember ?? false, 143 143 userAgent: request.headers.get('user-agent') ?? undefined, ··· 197 197 redirect: v.string(), 198 198 }), 199 199 async (data) => { 200 - const { accountManager } = getAppContext(); 200 + const { mfaManager, webSessionManager } = getAppContext(); 201 201 const { request } = getContext(); 202 202 203 - const challenge = accountManager.getVerifyChallenge(data.challenge); 203 + const challenge = webSessionManager.getVerifyChallenge(data.challenge); 204 204 if (challenge === null) { 205 205 redirect(routes.login.index.href(undefined, { redirect: data.redirect })); 206 206 } ··· 210 210 // determine allowed factors based on mode and MFA status 211 211 let allowedFactors: AuthFactor[]; 212 212 if (isSudo) { 213 - const hasMfa = accountManager.getMfaStatus(challenge.did) !== null; 213 + const hasMfa = mfaManager.getMfaStatus(challenge.did) !== null; 214 214 allowedFactors = hasMfa ? VERIFY_ALLOWED_SUDO_MFA_FACTORS : VERIFY_ALLOWED_SUDO_OFA_FACTORS; 215 215 } else { 216 216 allowedFactors = VERIFY_ALLOWED_MFA_FACTORS; ··· 224 224 }); 225 225 226 226 // delete challenge 227 - accountManager.deleteVerifyChallenge(data.challenge); 227 + webSessionManager.deleteVerifyChallenge(data.challenge); 228 228 229 229 if (isSudo) { 230 230 // elevate session and redirect 231 - accountManager.elevateSession(challenge.session_id!); 231 + webSessionManager.elevateSession(challenge.session_id!); 232 232 redirect(data.redirect); 233 233 } else { 234 234 // MFA login: create new session using remember preference from login 235 - const { session, token } = await accountManager.createWebSession({ 235 + const { session, token } = await webSessionManager.createWebSession({ 236 236 did: challenge.did, 237 237 remember: challenge.remember, 238 238 userAgent: request.headers.get('user-agent') ?? undefined, ··· 257 257 redirect: v.string(), 258 258 }), 259 259 async (data) => { 260 - const { accountManager, config } = getAppContext(); 260 + const { mfaManager, webSessionManager, config } = getAppContext(); 261 261 const { request } = getContext(); 262 262 263 263 // find the credential by ID (discoverable flow) 264 - const credential = accountManager.getWebAuthnCredentialByCredentialId(data.response.id); 264 + const credential = mfaManager.getWebAuthnCredentialByCredentialId(data.response.id); 265 265 if (credential === null) { 266 266 invalid(`Passkey not recognized`); 267 267 } ··· 272 272 ); 273 273 const challenge = clientDataJSON.challenge; 274 274 275 - if (!accountManager.consumePasskeyLoginChallenge(challenge)) { 275 + if (!mfaManager.consumePasskeyLoginChallenge(challenge)) { 276 276 invalid(`Invalid or expired challenge`); 277 277 } 278 278 ··· 290 290 } 291 291 292 292 // update counter 293 - accountManager.updateWebAuthnCredentialCounter( 293 + mfaManager.updateWebAuthnCredentialCounter( 294 294 credential.id, 295 295 verification.authenticationInfo.newCounter, 296 296 ); ··· 299 299 } 300 300 301 301 // create session 302 - const { session, token } = await accountManager.createWebSession({ 302 + const { session, token } = await webSessionManager.createWebSession({ 303 303 did: credential.did, 304 304 remember: true, // passkey login implies trusted device 305 305 userAgent: request.headers.get('user-agent') ?? undefined, ··· 324 324 redirect: v.string(), 325 325 }), 326 326 async (data) => { 327 - const { accountManager, config } = getAppContext(); 327 + const { mfaManager, webSessionManager, config } = getAppContext(); 328 328 const { request } = getContext(); 329 329 330 - const challenge = accountManager.getVerifyChallenge(data.challenge); 330 + const challenge = webSessionManager.getVerifyChallenge(data.challenge); 331 331 if (challenge === null) { 332 332 redirect(routes.login.index.href(undefined, { redirect: data.redirect })); 333 333 } ··· 337 337 } 338 338 339 339 // find the credential being used 340 - const credential = accountManager.getWebAuthnCredentialByCredentialId(data.response.id); 340 + const credential = mfaManager.getWebAuthnCredentialByCredentialId(data.response.id); 341 341 if (credential === null || credential.did !== challenge.did) { 342 342 invalid(`Invalid security key`); 343 343 } ··· 356 356 } 357 357 358 358 // update counter 359 - accountManager.updateWebAuthnCredentialCounter( 359 + mfaManager.updateWebAuthnCredentialCounter( 360 360 credential.id, 361 361 verification.authenticationInfo.newCounter, 362 362 ); ··· 367 367 const isSudo = challenge.session_id !== null; 368 368 369 369 // delete challenge 370 - accountManager.deleteVerifyChallenge(data.challenge); 370 + webSessionManager.deleteVerifyChallenge(data.challenge); 371 371 372 372 if (isSudo) { 373 373 // elevate session and redirect 374 - accountManager.elevateSession(challenge.session_id!); 374 + webSessionManager.elevateSession(challenge.session_id!); 375 375 redirect(data.redirect); 376 376 } else { 377 377 // MFA login: create new session using remember preference from login 378 - const { session, token } = await accountManager.createWebSession({ 378 + const { session, token } = await webSessionManager.createWebSession({ 379 379 did: challenge.did, 380 380 remember: challenge.remember, 381 381 userAgent: request.headers.get('user-agent') ?? undefined,
+11 -10
packages/danaus/src/web/controllers/verify.tsx
··· 3 3 import { render, type JSXNode } from '@oomfware/jsx'; 4 4 5 5 import { PreferredMfa } from '#app/accounts/db/schema.ts'; 6 - import type { MfaStatus, VerifyChallenge } from '#app/accounts/manager.ts'; 6 + import type { MfaStatus } from '#app/accounts/mfa.ts'; 7 + import type { VerifyChallenge } from '#app/accounts/web-sessions.ts'; 7 8 import { 8 9 RECOVERY_CODE_LENGTH, 9 10 RECOVERY_CODE_RE, ··· 38 39 * 3. neither → redirect to login 39 40 */ 40 41 const resolveVerifyContext = (url: URL): VerifyContext => { 41 - const { accountManager } = getAppContext(); 42 + const { mfaManager, webSessionManager } = getAppContext(); 42 43 43 44 const tokenParam = url.searchParams.get('token'); 44 45 const redirectUrl = url.searchParams.get('redirect') ?? routes.account.overview.href(); 45 46 46 47 // mode 1: MFA login - ?token is present 47 48 if (tokenParam !== null) { 48 - const challenge = accountManager.getVerifyChallenge(tokenParam); 49 + const challenge = webSessionManager.getVerifyChallenge(tokenParam); 49 50 if (challenge === null) { 50 51 // invalid or expired token → redirect to login (don't fall back to sudo) 51 52 redirect(routes.login.index.href(undefined, { redirect: redirectUrl })); 52 53 } 53 54 54 - const mfaStatus = accountManager.getMfaStatus(challenge.did); 55 + const mfaStatus = mfaManager.getMfaStatus(challenge.did); 55 56 if (mfaStatus === null) { 56 57 // no MFA configured (shouldn't happen, but handle it) 57 58 redirect(routes.login.index.href(undefined, { redirect: redirectUrl })); ··· 72 73 } 73 74 74 75 // already elevated? redirect directly to target 75 - if (accountManager.isSessionElevated(session)) { 76 + if (webSessionManager.isSessionElevated(session)) { 76 77 redirect(redirectUrl); 77 78 } 78 79 79 80 // create or reuse sudo challenge 80 - const challenge = accountManager.getOrCreateSudoChallenge(session.id, session.did); 81 - const mfaStatus = accountManager.getMfaStatus(session.did); 81 + const challenge = webSessionManager.getOrCreateSudoChallenge(session.id, session.did); 82 + const mfaStatus = mfaManager.getMfaStatus(session.did); 82 83 83 84 return { 84 85 challenge, ··· 164 165 ); 165 166 }, 166 167 async webauthn({ url }) { 167 - const { accountManager, config } = getAppContext(); 168 + const { mfaManager, webSessionManager, config } = getAppContext(); 168 169 169 170 const { fields } = verifyWebAuthnForm; 170 171 ··· 176 177 } 177 178 178 179 // get user's WebAuthn credentials 179 - const webauthnCredentials = accountManager.listWebAuthnCredentials(ctx.challenge.did); 180 + const webauthnCredentials = mfaManager.listWebAuthnCredentials(ctx.challenge.did); 180 181 if (webauthnCredentials.length === 0) { 181 182 // no WebAuthn credentials → redirect to TOTP 182 183 const tokenParam = ctx.isSudo ? undefined : ctx.challenge.token; ··· 190 191 }); 191 192 192 193 // store the challenge for verification 193 - accountManager.setVerifyChallengeWebAuthn(ctx.challenge.token, options.challenge); 194 + webSessionManager.setVerifyChallengeWebAuthn(ctx.challenge.token, options.challenge); 194 195 195 196 return render( 196 197 <BaseLayout>
+3 -3
packages/danaus/src/web/lib/forms.ts
··· 9 9 * calls invalid() if not elevated. 10 10 */ 11 11 export const requireSudo = (): void => { 12 - const { accountManager } = getAppContext(); 12 + const { webSessionManager } = getAppContext(); 13 13 const session = getSession(); 14 14 15 - if (!accountManager.isSessionElevated(session)) { 15 + if (!webSessionManager.isSessionElevated(session)) { 16 16 invalid(`Elevated permission has expired, reauthenticate again.`); 17 17 } 18 18 19 19 // refresh the sudo timeout 20 - accountManager.elevateSession(session.id); 20 + webSessionManager.elevateSession(session.id); 21 21 };
+2 -2
packages/danaus/src/web/middlewares/basic-auth.ts
··· 12 12 */ 13 13 export const requireAdmin = (): Middleware => { 14 14 return async ({ request }, next) => { 15 - const ctx = getAppContext(); 16 - const adminPassword = ctx.config.secrets.adminPassword; 15 + const { config } = getAppContext(); 16 + const adminPassword = config.secrets.adminPassword; 17 17 18 18 if (adminPassword === null) { 19 19 return new Response('Administration UI is disabled', { status: 403 });
+5 -5
packages/danaus/src/web/middlewares/session.ts
··· 1 1 import { createInjectionKey, redirect, type Middleware } from '@oomfware/fetch-router'; 2 2 import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 3 3 4 - import type { WebSession } from '#app/accounts/manager.ts'; 4 + import type { WebSession } from '#app/accounts/web-sessions.ts'; 5 5 import { readWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 6 6 7 7 import { routes } from '../routes.ts'; ··· 16 16 */ 17 17 export const requireSession = (): Middleware => { 18 18 return async ({ request, url, store }, next) => { 19 - const { accountManager, config } = getAppContext(); 19 + const { webSessionManager, config } = getAppContext(); 20 20 const path = url.pathname; 21 21 22 22 const redirectUrl = routes.login.index.href(undefined, { redirect: path }); ··· 31 31 redirect(redirectUrl); 32 32 } 33 33 34 - const session = accountManager.getWebSession(sessionId); 34 + const session = webSessionManager.getWebSession(sessionId); 35 35 if (!session) { 36 36 redirect(redirectUrl); 37 37 } ··· 61 61 * @returns the web session or null if not found/invalid 62 62 */ 63 63 export const tryGetSession = (): WebSession | null => { 64 - const { accountManager, config } = getAppContext(); 64 + const { webSessionManager, config } = getAppContext(); 65 65 const { request } = getContext(); 66 66 67 67 const token = readWebSessionToken(request); ··· 74 74 return null; 75 75 } 76 76 77 - const session = accountManager.getWebSession(sessionId); 77 + const session = webSessionManager.getWebSession(sessionId); 78 78 return session; 79 79 };