···11-import type { KeyObject } from 'node:crypto';
22-31import { DidNotFoundError, InvalidResolvedHandleError, type HandleResolver } from '@atcute/identity-resolver';
42import type { ActorIdentifier, Did, Handle } from '@atcute/lexicons';
53import { isDid, isHandle } from '@atcute/lexicons/syntax';
64import { InvalidRequestError, UpstreamFailureError } from '@atcute/xrpc-server';
7588-import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
99-import { and, asc, eq, gt, inArray, isNotNull, isNull, like, lte, or, sql } from 'drizzle-orm';
1010-import { nanoid } from 'nanoid';
66+import { and, asc, eq, gt, inArray, isNotNull, isNull, like, or, sql } from 'drizzle-orm';
1171212-import { createLegacySessionTokens, type LegacySessionTokens } from '#app/auth/legacy-tokens.ts';
1313-import { AuthScope } from '#app/auth/scopes.ts';
1414-import { createWebSessionToken } from '#app/auth/web.ts';
158import { TimeKeyset } from '#app/utils/keyset.ts';
1616-import { DAY, HOUR } from '#app/utils/times.ts';
1717-import { generateAppPassword, generateInviteCode } from '#app/utils/token.ts';
1891919-import { getAccountDb, t, type AccountDb } from './db';
2020-import { AppPasswordPrivilege, EmailTokenPurpose, PreferredMfa, WebAuthnCredentialType } from './db/schema';
1010+import { t, type AccountDb } from './db';
1111+import { EmailTokenPurpose } from './db/schema';
2112import { isServiceDomain, isValidTld } from './handle';
2213import { hashPassword, verifyPassword } from './passwords';
2323-import { generateBackupCodes, MAX_TOTP_CREDENTIALS, verifyTotpCode } from './totp';
2414import { AccountStatus, formatAccountStatus } from './types';
2525-import { MAX_WEBAUTHN_CREDENTIALS, WEBAUTHN_CHALLENGE_TTL_MS } from './webauthn';
2626-2727-const WEB_SESSION_TTL_MS = 7 * DAY;
2828-const WEB_SESSION_LONG_TTL_MS = 365 * DAY;
2929-const LEGACY_ACCESS_TTL_MS = 2 * HOUR;
3030-const LEGACY_REFRESH_TTL_MS = 90 * DAY;
3131-const LEGACY_REFRESH_GRACE_TTL_MS = 2 * HOUR;
3232-const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
3333-const SUDO_MODE_TTL_MS = 15 * 60 * 1000; // 15 minutes
3434-export const MAX_APP_PASSWORDS = 25;
35153616export type Account = typeof t.account.$inferSelect;
3737-export type AppPassword = typeof t.appPassword.$inferSelect;
3838-export type LegacySession = typeof t.legacySession.$inferSelect;
3939-export type WebSession = typeof t.webSession.$inferSelect;
4040-export type InviteCode = typeof t.inviteCode.$inferSelect;
4141-export type InviteCodeUse = typeof t.inviteCodeUse.$inferSelect;
4242-export type TotpCredential = typeof t.totpCredential.$inferSelect;
4343-export type BackupCode = typeof t.recoveryCode.$inferSelect;
4444-export type VerifyChallenge = typeof t.verifyChallenge.$inferSelect;
4545-export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect;
4646-export type WebauthnRegistrationChallenge = typeof t.webauthnRegistrationChallenge.$inferSelect;
4747-4848-/** MFA status for an account */
4949-/** WebAuthn credential type for MFA status */
5050-export type WebAuthnType = false | 'security-key' | 'passkey' | 'mixed';
5151-5252-export interface MfaStatus {
5353- /** preferred MFA method */
5454- preferred: PreferredMfa;
5555- /** has TOTP credentials */
5656- hasTotp: boolean;
5757- /** WebAuthn credential type(s) registered */
5858- webAuthnType: WebAuthnType;
5959- /** has recovery codes */
6060- hasRecoveryCodes: boolean;
6161-}
6262-6363-export interface InviteCodeWithUses extends InviteCode {
6464- uses: InviteCodeUse[];
6565-}
66176718const accountKeyset = new TimeKeyset<Did>(isDid);
6868-const inviteCodeKeyset = new TimeKeyset();
69197020interface AccountManagerOptions {
7171- location: string;
7272- walAutocheckpointDisabled: boolean;
7373-7474- serviceDid: Did;
2121+ db: AccountDb;
7522 serviceHandleDomains: string[];
7676-7723 handleResolver: HandleResolver;
7878-7979- jwtKey: KeyObject;
8024}
81258282-export class AccountManager implements Disposable {
8383- private readonly db: AccountDb;
8484-8585- private readonly serviceDid: Did;
8686- private readonly serviceHandleDomains: string[];
8787-8888- private readonly handleResolver: HandleResolver;
8989-9090- private readonly jwtKey: KeyObject;
2626+export class AccountManager {
2727+ readonly #db: AccountDb;
2828+ readonly #serviceHandleDomains: string[];
2929+ readonly #handleResolver: HandleResolver;
91309231 constructor(options: AccountManagerOptions) {
9393- this.db = getAccountDb(options.location, options.walAutocheckpointDisabled);
9494-9595- this.serviceDid = options.serviceDid;
9696- this.serviceHandleDomains = options.serviceHandleDomains;
9797-9898- this.handleResolver = options.handleResolver;
9999-100100- this.jwtKey = options.jwtKey;
101101- }
102102-103103- dispose() {
104104- this.db.$client.close();
105105- }
106106- [Symbol.dispose]() {
107107- this.dispose();
3232+ this.#db = options.db;
3333+ this.#serviceHandleDomains = options.serviceHandleDomains;
3434+ this.#handleResolver = options.handleResolver;
10835 }
109363737+ /**
3838+ * get an account by did or handle.
3939+ * @param actor did or handle
4040+ * @param options availability options
4141+ * @returns account or null
4242+ */
11043 getAccount(actor: ActorIdentifier, options: AccountAvailabilityOptions = {}): Account | null {
11144 const { includeDeactivated = false, includeTakenDown = false } = options;
11245113113- const found = this.db
4646+ const found = this.#db
11447 .select()
11548 .from(t.account)
11649 .where((f) => {
···12558 return found ?? null;
12659 }
127606161+ /**
6262+ * get multiple accounts by did.
6363+ * @param actors array of dids
6464+ * @param options availability options
6565+ * @returns map of did to account
6666+ */
12867 getAccounts(actors: Did[], options: AccountAvailabilityOptions = {}): Map<Did, Account> {
12968 const { includeDeactivated = false, includeTakenDown = false } = options;
13069131131- const found = this.db
7070+ const found = this.#db
13271 .select()
13372 .from(t.account)
13473 .where((f) => {
···15897 const normalizedQuery = query?.trim().toLowerCase();
15998 const searchPattern = normalizedQuery ? `%${normalizedQuery}%` : null;
16099161161- const rows = this.db
100100+ const rows = this.#db
162101 .select()
163102 .from(t.account)
164103 .where((f) => {
···197136 deleteScheduled: number;
198137 } {
199138 const total =
200200- this.db
139139+ this.#db
201140 .select({ count: sql<number>`count(*)` })
202141 .from(t.account)
203142 .get()?.count ?? 0;
204143 const active =
205205- this.db
144144+ this.#db
206145 .select({ count: sql<number>`count(*)` })
207146 .from(t.account)
208147 .where(and(isNull(t.account.takedown_ref), isNull(t.account.deactivated_at)))
209148 .get()?.count ?? 0;
210149 const deactivated =
211211- this.db
150150+ this.#db
212151 .select({ count: sql<number>`count(*)` })
213152 .from(t.account)
214153 .where(isNotNull(t.account.deactivated_at))
215154 .get()?.count ?? 0;
216155 const takendown =
217217- this.db
156156+ this.#db
218157 .select({ count: sql<number>`count(*)` })
219158 .from(t.account)
220159 .where(isNotNull(t.account.takedown_ref))
221160 .get()?.count ?? 0;
222161 const deleteScheduled =
223223- this.db
162162+ this.#db
224163 .select({ count: sql<number>`count(*)` })
225164 .from(t.account)
226165 .where(isNotNull(t.account.delete_at))
···235174 };
236175 }
237176177177+ /**
178178+ * get an account by email address.
179179+ * @param email email address
180180+ * @param options availability options
181181+ * @returns account or null
182182+ */
238183 getAccountByEmail(email: string, options: AccountAvailabilityOptions = {}): Account | null {
239184 const { includeDeactivated = false, includeTakenDown = false } = options;
240185241241- const found = this.db
186186+ const found = this.#db
242187 .select()
243188 .from(t.account)
244189 .where((f) => {
···253198 return found ?? null;
254199 }
255200256256- getWebSession(sessionId: string): WebSession | null {
257257- const session = this.db.select().from(t.webSession).where(eq(t.webSession.id, sessionId)).get();
258258- if (!session) {
259259- return null;
260260- }
261261-262262- const now = new Date();
263263- if (session.expires_at <= now) {
264264- this.db.delete(t.webSession).where(eq(t.webSession.id, sessionId)).run();
265265- return null;
266266- }
267267-268268- return session;
269269- }
270270-271271- isAccountActivated(did: Did): boolean {
272272- const account = this.getAccount(did, { includeDeactivated: true });
273273- if (account === null) {
274274- return false;
275275- }
276276-277277- return account.deactivated_at !== null;
278278- }
279279-201201+ /**
202202+ * get the did for an actor.
203203+ * @param actor did or handle
204204+ * @param options availability options
205205+ * @returns did or null
206206+ */
280207 getAccountDid(actor: ActorIdentifier, options?: AccountAvailabilityOptions): Did | null {
281208 const account = this.getAccount(actor, options);
282209 if (account === null) {
···286213 return account.did;
287214 }
288215216216+ /**
217217+ * get the status of an account.
218218+ * @param actor did or handle
219219+ * @returns account status
220220+ */
289221 getAccountStatus(actor: ActorIdentifier): AccountStatus {
290222 const account = this.getAccount(actor, {
291223 includeDeactivated: true,
···322254 };
323255 }
324256325325- async validateHandle(handle: Handle, options: ValidateHandleOptions = {}) {
257257+ /**
258258+ * check if an account is activated (not deactivated).
259259+ * @param did account did
260260+ * @returns true if activated
261261+ */
262262+ isAccountActivated(did: Did): boolean {
263263+ const account = this.getAccount(did, { includeDeactivated: true });
264264+ if (account === null) {
265265+ return false;
266266+ }
267267+268268+ return account.deactivated_at === null;
269269+ }
270270+271271+ /**
272272+ * resolve an identifier (handle, did, or email) to an account.
273273+ * @param identifier handle, did, or email
274274+ * @param options availability options
275275+ * @returns account or null
276276+ */
277277+ resolveAccount(identifier: string, options: AccountAvailabilityOptions = {}): Account | null {
278278+ if (isDid(identifier) || isHandle(identifier)) {
279279+ return this.getAccount(identifier as ActorIdentifier, options);
280280+ }
281281+282282+ return this.getAccountByEmail(identifier, options);
283283+ }
284284+285285+ /**
286286+ * validate a handle for use.
287287+ * @param handle handle to validate
288288+ * @param options validation options
289289+ * @returns normalized handle
290290+ */
291291+ async validateHandle(handle: Handle, options: ValidateHandleOptions = {}): Promise<Handle> {
326292 const { did } = options;
327293328328- // Normalize to lowercase
294294+ // normalize to lowercase
329295 handle = handle.toLowerCase() as Handle;
330296331297 if (!isValidTld(handle)) {
···335301 });
336302 }
337303338338- if (isServiceDomain(handle, this.serviceHandleDomains)) {
339339- const suffix = this.serviceHandleDomains.find((s) => handle.endsWith(s))!;
304304+ if (isServiceDomain(handle, this.#serviceHandleDomains)) {
305305+ const suffix = this.#serviceHandleDomains.find((s) => handle.endsWith(s))!;
340306 const front = handle.slice(0, handle.length - suffix.length);
341307342308 if (front.includes('.')) {
···371337372338 let resolvedDid: Did | undefined;
373339 jmp: try {
374374- resolvedDid = await this.handleResolver.resolve(handle, { noCache: true });
340340+ resolvedDid = await this.#handleResolver.resolve(handle, { noCache: true });
375341 } catch (err) {
376342 if (err instanceof DidNotFoundError) {
377343 break jmp;
···406372 * @returns account or null
407373 */
408374 async verifyAccountPassword(identifier: string, password: string): Promise<Account | null> {
409409- const account = this.resolveIdentifier(identifier, {
375375+ const account = this.resolveAccount(identifier, {
410376 includeDeactivated: true,
411377 includeTakenDown: true,
412378 });
···423389 }
424390425391 /**
426426- * verify identifier/password for legacy auth (app password only).
427427- * @param identifier handle, did, or email
428428- * @param password app password
429429- * @returns legacy auth result or null
430430- */
431431- async verifyLegacyCredentials(
432432- identifier: string,
433433- password: string,
434434- ): Promise<{ account: Account; appPassword: AppPassword } | null> {
435435- const account = this.resolveIdentifier(identifier, {
436436- includeDeactivated: true,
437437- includeTakenDown: true,
438438- });
439439- if (!account) {
440440- return null;
441441- }
442442-443443- const appPassword = await this.findAppPasswordMatch(account.did, password);
444444- if (!appPassword) {
445445- return null;
446446- }
447447-448448- return { account, appPassword };
449449- }
450450-451451- /**
452392 * create a new account record.
453393 * @param options account creation options
454394 * @returns created account
···459399460400 const passwordHash = await hashPassword(options.password);
461401462462- const inserted = this.db
402402+ const inserted = this.#db
463403 .insert(t.account)
464404 .values({
465405 did: options.did,
···492432 });
493433 }
494434495495- this.db.transaction((tx) => {
435435+ this.#db.transaction((tx) => {
496436 tx.update(t.account)
497437 .set({ email: email, email_confirmed_at: null })
498438 .where(eq(t.account.did, options.did))
···515455 * @param handle new handle
516456 */
517457 updateAccountHandle(did: Did, handle: Handle): void {
518518- this.db
458458+ this.#db
519459 .update(t.account)
520460 .set({ handle: handle.toLowerCase() as Handle })
521461 .where(eq(t.account.did, did))
···529469 async updateAccountPassword(options: { did: Did; password: string }): Promise<void> {
530470 const passwordHash = await hashPassword(options.password);
531471532532- this.db.transaction((tx) => {
472472+ this.#db.transaction((tx) => {
533473 tx.update(t.account).set({ password_hash: passwordHash }).where(eq(t.account.did, options.did)).run();
534474 tx.delete(t.legacySession).where(eq(t.legacySession.did, options.did)).run();
535475 tx.delete(t.webSession).where(eq(t.webSession.did, options.did)).run();
···550490 updateAccountTakedownStatus(did: Did, takedown: { applied: boolean; ref?: string }): void {
551491 const ref = takedown.applied ? (takedown.ref ?? 'admin') : null;
552492553553- this.db.update(t.account).set({ takedown_ref: ref }).where(eq(t.account.did, did)).run();
493493+ this.#db.update(t.account).set({ takedown_ref: ref }).where(eq(t.account.did, did)).run();
554494555495 if (takedown.applied) {
556556- this.db.delete(t.legacySession).where(eq(t.legacySession.did, did)).run();
557557- this.db.delete(t.webSession).where(eq(t.webSession.did, did)).run();
496496+ this.#db.delete(t.legacySession).where(eq(t.legacySession.did, did)).run();
497497+ this.#db.delete(t.webSession).where(eq(t.webSession.did, did)).run();
558498 }
559499 }
560500···564504 * @param deactivated deactivated status
565505 */
566506 updateAccountDeactivatedStatus(did: Did, deactivated: { applied: boolean }): void {
567567- this.db
507507+ this.#db
568508 .update(t.account)
569509 .set({ deactivated_at: deactivated.applied ? new Date() : null })
570510 .where(eq(t.account.did, did))
571511 .run();
572512 }
573513574574- /**
575575- * create a web session record.
576576- * @param options web session options
577577- * @returns session and signed token
578578- */
579579- async createWebSession(options: CreateWebSessionOptions): Promise<{ session: WebSession; token: string }> {
580580- const now = new Date();
581581- const expiresAt = new Date(
582582- now.getTime() + (options.remember ? WEB_SESSION_LONG_TTL_MS : WEB_SESSION_TTL_MS),
583583- );
584584- const id = nanoid(44);
585585-586586- const inserted = this.db
587587- .insert(t.webSession)
588588- .values({
589589- id: id,
590590- did: options.did,
591591- metadata: {
592592- userAgent: options.userAgent,
593593- ip: options.ip,
594594- },
595595- created_at: now,
596596- expires_at: expiresAt,
597597- })
598598- .returning()
599599- .get();
600600-601601- if (!inserted) {
602602- throw new Error(`failed to create web session`);
603603- }
604604-605605- const token = createWebSessionToken(this.jwtKey, id);
606606-607607- return { session: inserted, token: token };
608608- }
609609-610610- /**
611611- * delete a web session.
612612- * @param sessionId session id
613613- */
614614- deleteWebSession(sessionId: string): void {
615615- this.db.delete(t.webSession).where(eq(t.webSession.id, sessionId)).run();
616616- }
617617-618618- /**
619619- * create a legacy refresh session and jwt pair.
620620- * @param options legacy session options
621621- * @returns legacy session jwt pair
622622- */
623623- async createLegacySession(options: CreateLegacySessionOptions): Promise<LegacySessionTokens> {
624624- const now = options.now ?? new Date();
625625- const expiresAt = new Date(now.getTime() + LEGACY_REFRESH_TTL_MS);
626626- const sessionId = options.sessionId ?? nanoid(24);
627627-628628- const inserted = this.db
629629- .insert(t.legacySession)
630630- .values({
631631- id: sessionId,
632632- did: options.did,
633633- appPasswordId: options.appPassword.id,
634634- created_at: now,
635635- expires_at: expiresAt,
636636- next_id: null,
637637- })
638638- .returning()
639639- .get();
640640-641641- if (!inserted) {
642642- throw new Error(`failed to create legacy session`);
643643- }
644644-645645- return await this.issueLegacyTokens({
646646- did: options.did,
647647- privilege: options.appPassword.privilege,
648648- sessionId: sessionId,
649649- now: now,
650650- });
651651- }
652652-653653- /**
654654- * fetch a legacy session by id.
655655- * @param sessionId legacy session id
656656- * @returns legacy session or null
657657- */
658658- getLegacySession(sessionId: string): LegacySession | null {
659659- const session = this.db.select().from(t.legacySession).where(eq(t.legacySession.id, sessionId)).get();
660660- if (!session) {
661661- return null;
662662- }
663663-664664- const now = new Date();
665665- if (session.expires_at <= now) {
666666- this.db.delete(t.legacySession).where(eq(t.legacySession.id, sessionId)).run();
667667- return null;
668668- }
669669-670670- return session;
671671- }
672672-673673- /**
674674- * delete a legacy session by id.
675675- * @param sessionId legacy session id
676676- */
677677- deleteLegacySession(sessionId: string): void {
678678- this.db.delete(t.legacySession).where(eq(t.legacySession.id, sessionId)).run();
679679- }
680680-681681- /**
682682- * rotate a legacy refresh token and return new tokens.
683683- * @param sessionId legacy session id
684684- * @returns session jwt pair
685685- */
686686- async rotateLegacyRefresh(sessionId: string): Promise<LegacySessionTokens> {
687687- const row = this.db
688688- .select({
689689- sessionId: t.legacySession.id,
690690- did: t.legacySession.did,
691691- expiresAt: t.legacySession.expires_at,
692692- appPasswordId: t.legacySession.appPasswordId,
693693- nextId: t.legacySession.next_id,
694694- privilege: t.appPassword.privilege,
695695- })
696696- .from(t.legacySession)
697697- .innerJoin(t.appPassword, eq(t.legacySession.appPasswordId, t.appPassword.id))
698698- .where(eq(t.legacySession.id, sessionId))
699699- .get();
700700-701701- if (row === undefined) {
702702- throw new InvalidRequestError({
703703- error: 'InvalidToken',
704704- description: `refresh token is invalid`,
705705- });
706706- }
707707-708708- const now = new Date();
709709-710710- // tidy up all of the user's expired sessions
711711- this.db
712712- .delete(t.legacySession)
713713- .where(and(eq(t.legacySession.did, row.did), lte(t.legacySession.expires_at, now)))
714714- .run();
715715-716716- if (row.expiresAt <= now) {
717717- throw new InvalidRequestError({
718718- error: 'ExpiredToken',
719719- description: `refresh token has expired`,
720720- });
721721- }
722722-723723- const graceExpiresAt = new Date(now.getTime() + LEGACY_REFRESH_GRACE_TTL_MS);
724724- const shortenedExpiresAt = graceExpiresAt < row.expiresAt ? graceExpiresAt : row.expiresAt;
725725-726726- const nextId = row.nextId ?? nanoid(24);
727727-728728- const success = this.db.transaction((tx) => {
729729- const updateResult = tx
730730- .update(t.legacySession)
731731- .set({ next_id: nextId, expires_at: shortenedExpiresAt })
732732- .where(
733733- and(
734734- eq(t.legacySession.id, sessionId),
735735- or(isNull(t.legacySession.next_id), eq(t.legacySession.next_id, nextId)),
736736- ),
737737- )
738738- .returning({ id: t.legacySession.id })
739739- .get();
740740-741741- if (!updateResult) {
742742- return false;
743743- }
744744-745745- const nextExpiresAt = new Date(now.getTime() + LEGACY_REFRESH_TTL_MS);
746746- tx.insert(t.legacySession)
747747- .values({
748748- id: nextId,
749749- did: row.did,
750750- appPasswordId: row.appPasswordId,
751751- created_at: now,
752752- expires_at: nextExpiresAt,
753753- next_id: null,
754754- })
755755- .onConflictDoNothing({ target: t.legacySession.id })
756756- .run();
757757-758758- return true;
759759- });
760760-761761- if (!success) {
762762- return await this.rotateLegacyRefresh(sessionId);
763763- }
764764-765765- return await this.issueLegacyTokens({
766766- did: row.did,
767767- privilege: row.privilege,
768768- sessionId: nextId,
769769- now: now,
770770- });
771771- }
772772-773773- /**
774774- * create a new app password for an account.
775775- * @param options app password options
776776- * @returns app password details and secret
777777- */
778778- async createAppPassword(
779779- options: CreateAppPasswordOptions,
780780- ): Promise<{ appPassword: AppPassword; secret: string }> {
781781- const existing = this.db.select().from(t.appPassword).where(eq(t.appPassword.did, options.did)).all();
782782-783783- if (existing.length >= MAX_APP_PASSWORDS) {
784784- throw new InvalidRequestError({
785785- error: 'TooManyAppPasswords',
786786- description: `cannot have more than ${MAX_APP_PASSWORDS} app passwords`,
787787- });
788788- }
789789-790790- if (existing.some((row) => row.name === options.name)) {
791791- throw new InvalidRequestError({
792792- error: 'DuplicateAppPassword',
793793- description: `app password already exists`,
794794- });
795795- }
796796-797797- const secret = generateAppPassword();
798798- const passwordHash = await hashPassword(secret);
799799- const now = new Date();
800800-801801- const inserted = this.db
802802- .insert(t.appPassword)
803803- .values({
804804- did: options.did,
805805- name: options.name,
806806- privilege: options.privilege,
807807- password_hash: passwordHash,
808808- created_at: now,
809809- })
810810- .returning()
811811- .get();
812812-813813- if (!inserted) {
814814- throw new Error(`failed to create app password`);
815815- }
816816-817817- return { appPassword: inserted, secret: secret };
818818- }
819819-820820- /**
821821- * list app passwords for an account.
822822- * @param did account did
823823- * @returns app passwords
824824- */
825825- listAppPasswords(did: Did): AppPassword[] {
826826- const rows = this.db.select().from(t.appPassword).where(eq(t.appPassword.did, did)).all();
827827-828828- return rows;
829829- }
830830-831831- /**
832832- * delete an app password by name.
833833- * @param did account did
834834- * @param name app password name
835835- */
836836- deleteAppPassword(did: Did, name: string): void {
837837- this.db
838838- .delete(t.appPassword)
839839- .where(and(eq(t.appPassword.did, did), eq(t.appPassword.name, name)))
840840- .run();
841841- }
842842-843843- // #region invite codes
844844-845845- /**
846846- * create invite codes.
847847- * @param count number of codes to create
848848- * @param availableUses uses per code (0 for unlimited)
849849- * @returns created invite codes
850850- */
851851- createInviteCodes(count: number, availableUses: number): InviteCode[] {
852852- const now = new Date();
853853- const codes: InviteCode[] = [];
854854-855855- for (let i = 0; i < count; i++) {
856856- const code = generateInviteCode();
857857- const inserted = this.db
858858- .insert(t.inviteCode)
859859- .values({
860860- code: code,
861861- available_uses: availableUses === 0 ? -1 : availableUses,
862862- disabled: false,
863863- created_at: now,
864864- })
865865- .returning()
866866- .get();
867867-868868- if (inserted) {
869869- codes.push(inserted);
870870- }
871871- }
872872-873873- return codes;
874874- }
875875-876876- /**
877877- * check if an invite code is available for use.
878878- * @param code invite code
879879- * @throws InvalidRequestError if code is not available
880880- */
881881- ensureInviteIsAvailable(code: string): void {
882882- const invite = this.db.select().from(t.inviteCode).where(eq(t.inviteCode.code, code)).get();
883883-884884- if (!invite || invite.disabled) {
885885- throw new InvalidRequestError({
886886- error: 'InvalidInviteCode',
887887- description: 'provided invite code not available',
888888- });
889889- }
890890-891891- // -1 means unlimited uses
892892- if (invite.available_uses !== -1) {
893893- const uses =
894894- this.db
895895- .select({ count: sql<number>`count(*)` })
896896- .from(t.inviteCodeUse)
897897- .where(eq(t.inviteCodeUse.code, code))
898898- .get()?.count ?? 0;
899899-900900- if (uses >= invite.available_uses) {
901901- throw new InvalidRequestError({
902902- error: 'InvalidInviteCode',
903903- description: 'provided invite code not available',
904904- });
905905- }
906906- }
907907- }
908908-909909- /**
910910- * record an invite code use.
911911- * @param code invite code
912912- * @param usedBy DID of the account that used the code
913913- */
914914- recordInviteUse(code: string, usedBy: Did): void {
915915- this.db
916916- .insert(t.inviteCodeUse)
917917- .values({
918918- code: code,
919919- used_by: usedBy,
920920- used_at: new Date(),
921921- })
922922- .run();
923923- }
924924-925925- /**
926926- * get invite code details with usage.
927927- * @param code invite code
928928- * @returns invite code with uses or null
929929- */
930930- getInviteCode(code: string): InviteCodeWithUses | null {
931931- const invite = this.db.select().from(t.inviteCode).where(eq(t.inviteCode.code, code)).get();
932932-933933- if (!invite) {
934934- return null;
935935- }
936936-937937- const uses = this.db.select().from(t.inviteCodeUse).where(eq(t.inviteCodeUse.code, code)).all();
938938-939939- return { ...invite, uses };
940940- }
941941-942942- /**
943943- * list invite codes with pagination.
944944- * @param options list options
945945- * @returns invite codes and cursor
946946- */
947947- listInviteCodes(options: ListInviteCodesOptions = {}): {
948948- codes: InviteCodeWithUses[];
949949- cursor?: string;
950950- } {
951951- const { limit = 50, cursor, includeDisabled = false, includeUsed = true } = options;
952952- const parsed = inviteCodeKeyset.unpackOptional(cursor);
953953-954954- // paginate codes first in a CTE, then LEFT JOIN with uses
955955- const paginatedCodes = this.db.$with('paginated_codes').as(
956956- this.db
957957- .select()
958958- .from(t.inviteCode)
959959- .where((f) => {
960960- return and(
961961- !includeDisabled ? eq(f.disabled, false) : undefined,
962962- parsed
963963- ? or(gt(f.created_at, parsed.time), and(eq(f.created_at, parsed.time), gt(f.code, parsed.key)))
964964- : undefined,
965965- );
966966- })
967967- .orderBy(asc(t.inviteCode.created_at), asc(t.inviteCode.code))
968968- .limit(limit),
969969- );
970970-971971- const rows = this.db
972972- .with(paginatedCodes)
973973- .select({
974974- code: paginatedCodes.code,
975975- available_uses: paginatedCodes.available_uses,
976976- disabled: paginatedCodes.disabled,
977977- created_at: paginatedCodes.created_at,
978978- use: t.inviteCodeUse,
979979- })
980980- .from(paginatedCodes)
981981- .leftJoin(t.inviteCodeUse, eq(paginatedCodes.code, t.inviteCodeUse.code))
982982- .orderBy(asc(paginatedCodes.created_at), asc(paginatedCodes.code))
983983- .all();
984984-985985- // group rows by invite code
986986- const codesMap = new Map<string, InviteCodeWithUses>();
987987- for (const row of rows) {
988988- let entry = codesMap.get(row.code);
989989- if (!entry) {
990990- entry = {
991991- code: row.code,
992992- available_uses: row.available_uses,
993993- disabled: row.disabled,
994994- created_at: row.created_at,
995995- uses: [],
996996- };
997997- codesMap.set(row.code, entry);
998998- }
999999- if (row.use) {
10001000- entry.uses.push(row.use);
10011001- }
10021002- }
10031003-10041004- const codes = [...codesMap.values()];
10051005-10061006- // filter out fully used codes if requested
10071007- const filtered = includeUsed
10081008- ? codes
10091009- : codes.filter((c) => c.available_uses === -1 || c.uses.length < c.available_uses);
10101010-10111011- const last = filtered.at(-1);
10121012- const nextCursor = last ? inviteCodeKeyset.pack(last.created_at, last.code) : undefined;
10131013-10141014- return { codes: filtered, cursor: nextCursor };
10151015- }
10161016-10171017- /**
10181018- * disable invite codes.
10191019- * @param codes list of invite codes to disable
10201020- */
10211021- disableInviteCodes(codes: string[]): void {
10221022- if (codes.length === 0) {
10231023- return;
10241024- }
10251025-10261026- this.db.update(t.inviteCode).set({ disabled: true }).where(inArray(t.inviteCode.code, codes)).run();
10271027- }
10281028-10291029- /**
10301030- * get invite code statistics.
10311031- * @returns invite code stats
10321032- */
10331033- getInviteCodeStats(): {
10341034- total: number;
10351035- available: number;
10361036- disabled: number;
10371037- used: number;
10381038- } {
10391039- const total =
10401040- this.db
10411041- .select({ count: sql<number>`count(*)` })
10421042- .from(t.inviteCode)
10431043- .get()?.count ?? 0;
10441044-10451045- const disabled =
10461046- this.db
10471047- .select({ count: sql<number>`count(*)` })
10481048- .from(t.inviteCode)
10491049- .where(eq(t.inviteCode.disabled, true))
10501050- .get()?.count ?? 0;
10511051-10521052- const used =
10531053- this.db
10541054- .select({ count: sql<number>`count(distinct ${t.inviteCodeUse.code})` })
10551055- .from(t.inviteCodeUse)
10561056- .get()?.count ?? 0;
10571057-10581058- return {
10591059- total: total,
10601060- available: total - disabled,
10611061- disabled: disabled,
10621062- used: used,
10631063- };
10641064- }
10651065-10661066- // #endregion
10671067-10681068- // #region TOTP two-factor authentication
10691069-10701070- /**
10711071- * create a TOTP credential for an account.
10721072- * @param options TOTP credential options
10731073- * @returns created credential
10741074- */
10751075- createTotpCredential(options: CreateTotpCredentialOptions): TotpCredential {
10761076- const count = this.#countTotpCredentials(options.did);
10771077- if (count >= MAX_TOTP_CREDENTIALS) {
10781078- throw new InvalidRequestError({
10791079- error: 'TooManyTotpCredentials',
10801080- description: `cannot have more than ${MAX_TOTP_CREDENTIALS} authenticators`,
10811081- });
10821082- }
10831083-10841084- const name = options.name?.trim() || this.generateTotpName(options.did);
10851085-10861086- // check for duplicate name
10871087- const existing = this.db
10881088- .select()
10891089- .from(t.totpCredential)
10901090- .where(and(eq(t.totpCredential.did, options.did), eq(t.totpCredential.name, name)))
10911091- .get();
10921092-10931093- if (existing) {
10941094- throw new InvalidRequestError({
10951095- error: 'DuplicateTotpName',
10961096- description: `an authenticator with this name already exists`,
10971097- });
10981098- }
10991099-11001100- const inserted = this.db
11011101- .insert(t.totpCredential)
11021102- .values({
11031103- did: options.did,
11041104- name: name,
11051105- secret: Buffer.from(options.secret),
11061106- created_at: new Date(),
11071107- last_used_counter: options.lastUsedCounter,
11081108- })
11091109- .returning()
11101110- .get();
11111111-11121112- if (!inserted) {
11131113- throw new Error(`failed to create TOTP credential`);
11141114- }
11151115-11161116- this.#syncPreferredMfa(options.did);
11171117-11181118- return inserted;
11191119- }
11201120-11211121- /**
11221122- * list TOTP credentials for an account.
11231123- * @param did account did
11241124- * @returns TOTP credentials
11251125- */
11261126- listTotpCredentials(did: Did): TotpCredential[] {
11271127- return this.db.select().from(t.totpCredential).where(eq(t.totpCredential.did, did)).all();
11281128- }
11291129-11301130- /**
11311131- * get a TOTP credential by id.
11321132- * @param did account did
11331133- * @param id credential id
11341134- * @returns TOTP credential or null
11351135- */
11361136- getTotpCredential(did: Did, id: number): TotpCredential | null {
11371137- const credential = this.db
11381138- .select()
11391139- .from(t.totpCredential)
11401140- .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id)))
11411141- .get();
11421142-11431143- return credential ?? null;
11441144- }
11451145-11461146- /**
11471147- * delete a TOTP credential.
11481148- * @param did account did
11491149- * @param id credential id
11501150- */
11511151- deleteTotpCredential(did: Did, id: number): void {
11521152- this.db
11531153- .delete(t.totpCredential)
11541154- .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id)))
11551155- .run();
11561156-11571157- this.#syncPreferredMfa(did);
11581158- }
11591159-11601160- /**
11611161- * sync preferred_mfa to reflect current MFA credentials.
11621162- * - if null and credentials exist → set to first available type
11631163- * - if set but that type has no credentials → switch to another type or clear
11641164- */
11651165- #syncPreferredMfa(did: Did): void {
11661166- const account = this.db
11671167- .select({ preferred_mfa: t.account.preferred_mfa })
11681168- .from(t.account)
11691169- .where(eq(t.account.did, did))
11701170- .get();
11711171-11721172- if (!account) {
11731173- return;
11741174- }
11751175-11761176- const hasTotp = this.#countTotpCredentials(did) > 0;
11771177- const hasWebAuthn = this.countWebAuthnCredentials(did) > 0;
11781178-11791179- // check if current preference is still valid
11801180- if (account.preferred_mfa === PreferredMfa.Totp && hasTotp) {
11811181- return;
11821182- }
11831183- if (account.preferred_mfa === PreferredMfa.WebAuthn && hasWebAuthn) {
11841184- return;
11851185- }
11861186-11871187- // need to set or switch: prefer the type that was just added (TOTP first for backwards compat)
11881188- let newPreferred: PreferredMfa | null = null;
11891189- if (hasTotp) {
11901190- newPreferred = PreferredMfa.Totp;
11911191- } else if (hasWebAuthn) {
11921192- newPreferred = PreferredMfa.WebAuthn;
11931193- }
11941194-11951195- if (newPreferred !== account.preferred_mfa) {
11961196- this.db.update(t.account).set({ preferred_mfa: newPreferred }).where(eq(t.account.did, did)).run();
11971197- }
11981198- }
11991199-12001200- /**
12011201- * get MFA status for an account.
12021202- * @param did account did
12031203- * @returns MFA status with preferred method and available methods, or null if no MFA configured
12041204- */
12051205- getMfaStatus(did: Did): MfaStatus | null {
12061206- const account = this.db
12071207- .select({ preferred_mfa: t.account.preferred_mfa })
12081208- .from(t.account)
12091209- .where(eq(t.account.did, did))
12101210- .get();
12111211-12121212- if (!account || account.preferred_mfa == null) {
12131213- return null;
12141214- }
12151215-12161216- return {
12171217- preferred: account.preferred_mfa,
12181218- hasTotp: this.#countTotpCredentials(did) > 0,
12191219- webAuthnType: this.#getWebAuthnType(did),
12201220- hasRecoveryCodes: this.getRecoveryCodeCount(did) > 0,
12211221- };
12221222- }
12231223-12241224- /**
12251225- * count TOTP credentials for an account.
12261226- * @param did account did
12271227- * @returns number of credentials
12281228- */
12291229- #countTotpCredentials(did: Did): number {
12301230- return (
12311231- this.db
12321232- .select({ count: sql<number>`count(*)` })
12331233- .from(t.totpCredential)
12341234- .where(eq(t.totpCredential.did, did))
12351235- .get()?.count ?? 0
12361236- );
12371237- }
12381238-12391239- /**
12401240- * verify a TOTP code against any of the account's credentials.
12411241- * updates last_used_counter on successful verification to prevent replay attacks.
12421242- * @param did account did
12431243- * @param code the code to verify
12441244- * @returns true if the code is valid for any credential
12451245- */
12461246- async verifyAccountTotpCode(did: Did, code: string): Promise<boolean> {
12471247- const credentials = this.listTotpCredentials(did);
12481248-12491249- for (const credential of credentials) {
12501250- const counter = await verifyTotpCode(credential.secret, code, credential.last_used_counter);
12511251-12521252- if (counter !== null) {
12531253- // update last_used_counter to prevent replay attacks
12541254- this.db
12551255- .update(t.totpCredential)
12561256- .set({ last_used_counter: counter })
12571257- .where(eq(t.totpCredential.id, credential.id))
12581258- .run();
12591259-12601260- return true;
12611261- }
12621262- }
12631263-12641264- return false;
12651265- }
12661266-12671267- /**
12681268- * generate a unique name for a new TOTP credential.
12691269- * @param did account did
12701270- * @returns generated name like "Authenticator" or "Authenticator 2"
12711271- */
12721272- generateTotpName(did: Did): string {
12731273- const existing = this.listTotpCredentials(did);
12741274- const baseName = 'Authenticator';
12751275-12761276- if (existing.length === 0) {
12771277- return baseName;
12781278- }
12791279-12801280- // find the next available number
12811281- const existingNames = new Set(existing.map((c) => c.name));
12821282- let num = 2;
12831283- while (existingNames.has(`${baseName} ${num}`)) {
12841284- num++;
12851285- }
12861286-12871287- return `${baseName} ${num}`;
12881288- }
12891289-12901290- // #endregion
12911291-12921292- // #region backup codes
12931293-12941294- /**
12951295- * generate and store recovery codes for an account.
12961296- * deletes any existing codes first.
12971297- * @param did account did
12981298- */
12991299- generateRecoveryCodes(did: Did): void {
13001300- const codes = generateBackupCodes();
13011301- const now = new Date();
13021302-13031303- this.db.transaction((tx) => {
13041304- tx.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run();
13051305-13061306- for (const code of codes) {
13071307- tx.insert(t.recoveryCode)
13081308- .values({
13091309- did: did,
13101310- code: code,
13111311- created_at: now,
13121312- })
13131313- .run();
13141314- }
13151315- });
13161316- }
13171317-13181318- /**
13191319- * get all unused recovery codes for an account.
13201320- * @param did account did
13211321- * @returns array of unused codes
13221322- */
13231323- getRecoveryCodes(did: Did): string[] {
13241324- return this.db
13251325- .select({ code: t.recoveryCode.code })
13261326- .from(t.recoveryCode)
13271327- .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at)))
13281328- .all()
13291329- .map((row) => row.code);
13301330- }
13311331-13321332- /**
13331333- * get count of unused recovery codes.
13341334- * @param did account did
13351335- * @returns number of unused codes
13361336- */
13371337- getRecoveryCodeCount(did: Did): number {
13381338- return (
13391339- this.db
13401340- .select({ count: sql<number>`count(*)` })
13411341- .from(t.recoveryCode)
13421342- .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at)))
13431343- .get()?.count ?? 0
13441344- );
13451345- }
13461346-13471347- /**
13481348- * verify and consume a recovery code.
13491349- * @param did account did
13501350- * @param code the code to verify
13511351- * @returns true if the code was valid and consumed
13521352- */
13531353- consumeRecoveryCode(did: Did, code: string): boolean {
13541354- const result = this.db
13551355- .update(t.recoveryCode)
13561356- .set({ used_at: new Date() })
13571357- .where(and(eq(t.recoveryCode.did, did), eq(t.recoveryCode.code, code), isNull(t.recoveryCode.used_at)))
13581358- .returning()
13591359- .get();
13601360-13611361- return result != null;
13621362- }
13631363-13641364- /**
13651365- * delete all recovery codes for an account.
13661366- * @param did account did
13671367- */
13681368- deleteRecoveryCodes(did: Did): void {
13691369- this.db.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run();
13701370- }
13711371-13721372- // #endregion
13731373-13741374- // #region verification challenges
13751375-13761376- /**
13771377- * create a verification challenge for MFA login (no session, creates one on success).
13781378- * @param did account did
13791379- * @param remember whether to create a long-lived session on success
13801380- * @returns token for the verify page
13811381- */
13821382- createVerifyChallenge(did: Did, remember: boolean): string {
13831383- const token = nanoid(32);
13841384- const now = new Date();
13851385- const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS);
13861386-13871387- this.db
13881388- .insert(t.verifyChallenge)
13891389- .values({
13901390- token: token,
13911391- did: did,
13921392- session_id: null,
13931393- remember: remember,
13941394- created_at: now,
13951395- expires_at: expiresAt,
13961396- })
13971397- .run();
13981398-13991399- return token;
14001400- }
14011401-14021402- /**
14031403- * create or get an existing sudo challenge for session elevation.
14041404- * @param sessionId the session to elevate
14051405- * @param did account did
14061406- * @returns the verify challenge row
14071407- */
14081408- getOrCreateSudoChallenge(sessionId: string, did: Did): VerifyChallenge {
14091409- // check for existing sudo challenge for this session
14101410- const existing = this.db
14111411- .select()
14121412- .from(t.verifyChallenge)
14131413- .where(eq(t.verifyChallenge.session_id, sessionId))
14141414- .get();
14151415-14161416- const now = new Date();
14171417-14181418- if (existing && existing.expires_at > now) {
14191419- return existing;
14201420- }
14211421-14221422- // delete expired challenge if it exists
14231423- if (existing) {
14241424- this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, existing.token)).run();
14251425- }
14261426-14271427- const token = nanoid(32);
14281428- const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS);
14291429-14301430- const inserted = this.db
14311431- .insert(t.verifyChallenge)
14321432- .values({
14331433- token: token,
14341434- did: did,
14351435- session_id: sessionId,
14361436- created_at: now,
14371437- expires_at: expiresAt,
14381438- })
14391439- .returning()
14401440- .get();
14411441-14421442- return inserted;
14431443- }
14441444-14451445- /**
14461446- * get a verification challenge by token.
14471447- * @param token the token
14481448- * @returns verify challenge or null if expired/not found
14491449- */
14501450- getVerifyChallenge(token: string): VerifyChallenge | null {
14511451- const challenge = this.db
14521452- .select()
14531453- .from(t.verifyChallenge)
14541454- .where(eq(t.verifyChallenge.token, token))
14551455- .get();
14561456-14571457- if (!challenge) {
14581458- return null;
14591459- }
14601460-14611461- const now = new Date();
14621462- if (challenge.expires_at <= now) {
14631463- this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run();
14641464- return null;
14651465- }
14661466-14671467- return challenge;
14681468- }
14691469-14701470- /**
14711471- * delete a verification challenge.
14721472- * @param token the token
14731473- */
14741474- deleteVerifyChallenge(token: string): void {
14751475- this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run();
14761476- }
14771477-14781478- /**
14791479- * clean up expired verification challenges.
14801480- */
14811481- cleanupExpiredVerifyChallenges(): void {
14821482- const now = new Date();
14831483- this.db.delete(t.verifyChallenge).where(lte(t.verifyChallenge.expires_at, now)).run();
14841484- }
14851485-14861486- /**
14871487- * set the WebAuthn challenge on an existing verification challenge.
14881488- * @param token verify challenge token
14891489- * @param webauthnRegistrationChallenge base64url WebAuthn challenge
14901490- */
14911491- setVerifyChallengeWebAuthn(token: string, webauthnRegistrationChallenge: string): void {
14921492- this.db
14931493- .update(t.verifyChallenge)
14941494- .set({ webauthn_challenge: webauthnRegistrationChallenge })
14951495- .where(eq(t.verifyChallenge.token, token))
14961496- .run();
14971497- }
14981498-14991499- // #endregion
15001500-15011501- // #region sudo mode
15021502-15031503- /**
15041504- * elevate a session to sudo mode.
15051505- * @param sessionId the session id
15061506- */
15071507- elevateSession(sessionId: string): void {
15081508- this.db.update(t.webSession).set({ sudo_at: new Date() }).where(eq(t.webSession.id, sessionId)).run();
15091509- }
15101510-15111511- /**
15121512- * check if a session is in sudo mode.
15131513- * @param session the session
15141514- * @returns true if session is elevated
15151515- */
15161516- isSessionElevated(session: WebSession): boolean {
15171517- if (session.sudo_at === null) {
15181518- return false;
15191519- }
15201520- const now = Date.now();
15211521- const elevatedAt = session.sudo_at.getTime();
15221522- return now - elevatedAt < SUDO_MODE_TTL_MS;
15231523- }
15241524-15251525- // #endregion
15261526-15271527- // #region WebAuthn credentials
15281528-15291529- /**
15301530- * create a WebAuthn credential for an account.
15311531- * @param options credential options
15321532- * @returns created credential
15331533- */
15341534- createWebAuthnCredential(options: CreateWebAuthnCredentialOptions): WebauthnCredential {
15351535- const count = this.countWebAuthnCredentials(options.did);
15361536- if (count >= MAX_WEBAUTHN_CREDENTIALS) {
15371537- throw new InvalidRequestError({
15381538- error: 'TooManyWebAuthnCredentials',
15391539- description: `cannot have more than ${MAX_WEBAUTHN_CREDENTIALS} security keys`,
15401540- });
15411541- }
15421542-15431543- const name = options.name?.trim() || this.generateWebAuthnName(options.did, options.type);
15441544-15451545- // check for duplicate name
15461546- const existing = this.db
15471547- .select()
15481548- .from(t.webauthnCredential)
15491549- .where(and(eq(t.webauthnCredential.did, options.did), eq(t.webauthnCredential.name, name)))
15501550- .get();
15511551-15521552- if (existing) {
15531553- throw new InvalidRequestError({
15541554- error: 'DuplicateWebAuthnName',
15551555- description: `a credential with this name already exists`,
15561556- });
15571557- }
15581558-15591559- // check for duplicate credential ID
15601560- const existingCredId = this.db
15611561- .select()
15621562- .from(t.webauthnCredential)
15631563- .where(eq(t.webauthnCredential.credential_id, options.credentialId))
15641564- .get();
15651565-15661566- if (existingCredId) {
15671567- throw new InvalidRequestError({
15681568- error: 'DuplicateCredentialId',
15691569- description: `this security key is already registered`,
15701570- });
15711571- }
15721572-15731573- const inserted = this.db
15741574- .insert(t.webauthnCredential)
15751575- .values({
15761576- did: options.did,
15771577- type: options.type,
15781578- name: name,
15791579- credential_id: options.credentialId,
15801580- public_key: Buffer.from(options.publicKey),
15811581- counter: options.counter,
15821582- transports: options.transports,
15831583- created_at: new Date(),
15841584- })
15851585- .returning()
15861586- .get();
15871587-15881588- if (!inserted) {
15891589- throw new Error(`failed to create WebAuthn credential`);
15901590- }
15911591-15921592- // sync preferred MFA (only for security keys, not passkeys)
15931593- if (options.type === WebAuthnCredentialType.SecurityKey) {
15941594- this.#syncPreferredMfa(options.did);
15951595- }
15961596-15971597- return inserted;
15981598- }
15991599-16001600- /**
16011601- * list WebAuthn credentials for an account.
16021602- * @param did account did
16031603- * @returns WebAuthn credentials
16041604- */
16051605- listWebAuthnCredentials(did: Did): WebauthnCredential[] {
16061606- return this.db.select().from(t.webauthnCredential).where(eq(t.webauthnCredential.did, did)).all();
16071607- }
16081608-16091609- /**
16101610- * list WebAuthn credentials for an account filtered by type.
16111611- * @param did account did
16121612- * @param type credential type
16131613- * @returns WebAuthn credentials of the specified type
16141614- */
16151615- listWebAuthnCredentialsByType(did: Did, type: WebAuthnCredentialType): WebauthnCredential[] {
16161616- return this.db
16171617- .select()
16181618- .from(t.webauthnCredential)
16191619- .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.type, type)))
16201620- .all();
16211621- }
16221622-16231623- /**
16241624- * get a WebAuthn credential by id.
16251625- * @param did account did
16261626- * @param id credential id
16271627- * @returns WebAuthn credential or null
16281628- */
16291629- getWebAuthnCredential(did: Did, id: number): WebauthnCredential | null {
16301630- const credential = this.db
16311631- .select()
16321632- .from(t.webauthnCredential)
16331633- .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id)))
16341634- .get();
16351635-16361636- return credential ?? null;
16371637- }
16381638-16391639- /**
16401640- * get a WebAuthn credential by credential ID.
16411641- * @param credentialId base64url credential ID
16421642- * @returns WebAuthn credential or null
16431643- */
16441644- getWebAuthnCredentialByCredentialId(credentialId: string): WebauthnCredential | null {
16451645- const credential = this.db
16461646- .select()
16471647- .from(t.webauthnCredential)
16481648- .where(eq(t.webauthnCredential.credential_id, credentialId))
16491649- .get();
16501650-16511651- return credential ?? null;
16521652- }
16531653-16541654- /**
16551655- * delete a WebAuthn credential.
16561656- * @param did account did
16571657- * @param id credential id
16581658- */
16591659- deleteWebAuthnCredential(did: Did, id: number): void {
16601660- this.db
16611661- .delete(t.webauthnCredential)
16621662- .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id)))
16631663- .run();
16641664-16651665- this.#syncPreferredMfa(did);
16661666- }
16671667-16681668- /**
16691669- * count WebAuthn credentials for an account.
16701670- * @param did account did
16711671- * @returns number of credentials
16721672- */
16731673- countWebAuthnCredentials(did: Did): number {
16741674- return (
16751675- this.db
16761676- .select({ count: sql<number>`count(*)` })
16771677- .from(t.webauthnCredential)
16781678- .where(eq(t.webauthnCredential.did, did))
16791679- .get()?.count ?? 0
16801680- );
16811681- }
16821682-16831683- /**
16841684- * get the WebAuthn credential type(s) for an account.
16851685- * @param did account did
16861686- * @returns credential type: false if none, 'security-key', 'passkey', or 'mixed'
16871687- */
16881688- #getWebAuthnType(did: Did): WebAuthnType {
16891689- const credentials = this.db
16901690- .select({ type: t.webauthnCredential.type })
16911691- .from(t.webauthnCredential)
16921692- .where(eq(t.webauthnCredential.did, did))
16931693- .all();
16941694-16951695- if (credentials.length === 0) {
16961696- return false;
16971697- }
16981698-16991699- const hasSecurityKey = credentials.some((c) => c.type === WebAuthnCredentialType.SecurityKey);
17001700- const hasPasskey = credentials.some((c) => c.type === WebAuthnCredentialType.Passkey);
17011701-17021702- if (hasSecurityKey && hasPasskey) {
17031703- return 'mixed';
17041704- }
17051705- return hasPasskey ? 'passkey' : 'security-key';
17061706- }
17071707-17081708- /**
17091709- * update the counter for a WebAuthn credential.
17101710- * @param id credential id
17111711- * @param counter new counter value
17121712- */
17131713- updateWebAuthnCredentialCounter(id: number, counter: number): void {
17141714- this.db.update(t.webauthnCredential).set({ counter }).where(eq(t.webauthnCredential.id, id)).run();
17151715- }
17161716-17171717- /**
17181718- * generate a unique name for a new WebAuthn credential.
17191719- * @param did account did
17201720- * @param type credential type
17211721- * @returns generated name like "Security Key" or "Security Key 2"
17221722- */
17231723- generateWebAuthnName(did: Did, type: WebAuthnCredentialType): string {
17241724- const existing = this.listWebAuthnCredentialsByType(did, type);
17251725- const baseName = type === WebAuthnCredentialType.SecurityKey ? 'Security Key' : 'Passkey';
17261726-17271727- if (existing.length === 0) {
17281728- return baseName;
17291729- }
17301730-17311731- // find the next available number
17321732- const existingNames = new Set(existing.map((c) => c.name));
17331733- let num = 2;
17341734- while (existingNames.has(`${baseName} ${num}`)) {
17351735- num++;
17361736- }
17371737-17381738- return `${baseName} ${num}`;
17391739- }
17401740-17411741- // #endregion
17421742-17431743- // #region WebAuthn registration challenges
17441744-17451745- /**
17461746- * create a WebAuthn registration challenge.
17471747- * @param did account did
17481748- * @param challenge base64url challenge
17491749- * @returns token for retrieving the challenge
17501750- */
17511751- createWebAuthnRegistrationChallenge(did: Did, challenge: string): string {
17521752- const token = nanoid(32);
17531753- const now = new Date();
17541754- const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS);
17551755-17561756- this.db
17571757- .insert(t.webauthnRegistrationChallenge)
17581758- .values({
17591759- token: token,
17601760- did: did,
17611761- challenge: challenge,
17621762- created_at: now,
17631763- expires_at: expiresAt,
17641764- })
17651765- .run();
17661766-17671767- return token;
17681768- }
17691769-17701770- /**
17711771- * get a WebAuthn registration challenge by token.
17721772- * @param token the token
17731773- * @returns WebAuthn challenge or null if expired/not found
17741774- */
17751775- getWebAuthnRegistrationChallenge(token: string): WebauthnRegistrationChallenge | null {
17761776- const challenge = this.db
17771777- .select()
17781778- .from(t.webauthnRegistrationChallenge)
17791779- .where(eq(t.webauthnRegistrationChallenge.token, token))
17801780- .get();
17811781-17821782- if (!challenge) {
17831783- return null;
17841784- }
17851785-17861786- const now = new Date();
17871787- if (challenge.expires_at <= now) {
17881788- this.db
17891789- .delete(t.webauthnRegistrationChallenge)
17901790- .where(eq(t.webauthnRegistrationChallenge.token, token))
17911791- .run();
17921792- return null;
17931793- }
17941794-17951795- return challenge;
17961796- }
17971797-17981798- /**
17991799- * delete a WebAuthn registration challenge.
18001800- * @param token the token
18011801- */
18021802- deleteWebAuthnRegistrationChallenge(token: string): void {
18031803- this.db
18041804- .delete(t.webauthnRegistrationChallenge)
18051805- .where(eq(t.webauthnRegistrationChallenge.token, token))
18061806- .run();
18071807- }
18081808-18091809- /**
18101810- * clean up expired WebAuthn registration challenges.
18111811- */
18121812- cleanupExpiredWebAuthnRegistrationChallenges(): void {
18131813- const now = new Date();
18141814- this.db
18151815- .delete(t.webauthnRegistrationChallenge)
18161816- .where(lte(t.webauthnRegistrationChallenge.expires_at, now))
18171817- .run();
18181818- }
18191819-18201820- // #endregion
18211821-18221822- // #region passkey login challenges
18231823-18241824- /**
18251825- * create a passkey login challenge for passwordless authentication.
18261826- * @param challenge base64url challenge string
18271827- */
18281828- createPasskeyLoginChallenge(challenge: string): void {
18291829- const now = new Date();
18301830- const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS);
18311831-18321832- this.db
18331833- .insert(t.passkeyLoginChallenge)
18341834- .values({
18351835- challenge: challenge,
18361836- created_at: now,
18371837- expires_at: expiresAt,
18381838- })
18391839- .run();
18401840- }
18411841-18421842- /**
18431843- * consume a passkey login challenge (delete and return if valid).
18441844- * @param challenge base64url challenge string
18451845- * @returns true if challenge was valid and consumed
18461846- */
18471847- consumePasskeyLoginChallenge(challenge: string): boolean {
18481848- const now = new Date();
18491849-18501850- // clean up expired challenges
18511851- this.db.delete(t.passkeyLoginChallenge).where(lte(t.passkeyLoginChallenge.expires_at, now)).run();
18521852-18531853- // try to delete the challenge (returns the deleted row if it existed)
18541854- const result = this.db
18551855- .delete(t.passkeyLoginChallenge)
18561856- .where(eq(t.passkeyLoginChallenge.challenge, challenge))
18571857- .returning()
18581858- .get();
18591859-18601860- return result != null;
18611861- }
18621862-18631863- // #endregion
18641864-1865514 async importAccount(_options: ImportAccountOptions) {}
18661866-18671867- private resolveIdentifier(identifier: string, options: AccountAvailabilityOptions): Account | null {
18681868- if (isDid(identifier) || isHandle(identifier)) {
18691869- return this.getAccount(identifier as ActorIdentifier, options);
18701870- }
18711871-18721872- return this.getAccountByEmail(identifier, options);
18731873- }
18741874-18751875- private async findAppPasswordMatch(did: Did, password: string): Promise<AppPassword | null> {
18761876- const rows = this.db.select().from(t.appPassword).where(eq(t.appPassword.did, did)).all();
18771877-18781878- for (const row of rows) {
18791879- const valid = await verifyPassword(password, row.password_hash);
18801880- if (valid) {
18811881- return row;
18821882- }
18831883- }
18841884-18851885- return null;
18861886- }
18871887-18881888- private scopeForPrivilege(privilege: AppPasswordPrivilege): AuthScope {
18891889- switch (privilege) {
18901890- case AppPasswordPrivilege.Full:
18911891- return AuthScope.Access;
18921892- case AppPasswordPrivilege.Privileged:
18931893- return AuthScope.AppPassPrivileged;
18941894- case AppPasswordPrivilege.Limited:
18951895- return AuthScope.AppPass;
18961896- }
18971897-18981898- throw new InvalidRequestError({
18991899- error: 'InvalidAppPasswordPrivilege',
19001900- description: `invalid app password privilege`,
19011901- });
19021902- }
19031903-19041904- private async issueLegacyTokens(options: {
19051905- did: Did;
19061906- privilege: AppPasswordPrivilege;
19071907- sessionId: string;
19081908- now: Date;
19091909- }): Promise<LegacySessionTokens> {
19101910- return await createLegacySessionTokens(
19111911- {
19121912- did: options.did,
19131913- scope: this.scopeForPrivilege(options.privilege),
19141914- serviceDid: this.serviceDid,
19151915- jwtKey: this.jwtKey,
19161916- issuedAt: options.now.getTime(),
19171917- expiresInMs: LEGACY_ACCESS_TTL_MS,
19181918- },
19191919- {
19201920- did: options.did,
19211921- serviceDid: this.serviceDid,
19221922- jwtKey: this.jwtKey,
19231923- sessionId: options.sessionId,
19241924- issuedAt: options.now.getTime(),
19251925- expiresInMs: LEGACY_REFRESH_TTL_MS,
19261926- },
19271927- );
19281928- }
1929515}
19305161931517interface AccountAvailabilityOptions {
···1950536 password: string;
1951537}
195253819531953-interface CreateWebSessionOptions {
19541954- did: Did;
19551955- remember: boolean;
19561956- userAgent: string | undefined;
19571957- ip: string | undefined;
19581958-}
19591959-19601960-interface CreateLegacySessionOptions {
19611961- did: Did;
19621962- appPassword: AppPassword;
19631963- sessionId?: string;
19641964- now?: Date;
19651965-}
19661966-19671967-interface CreateAppPasswordOptions {
19681968- did: Did;
19691969- name: string;
19701970- privilege: AppPasswordPrivilege;
19711971-}
19721972-1973539interface ImportAccountOptions {}
19741974-19751975-interface ListInviteCodesOptions {
19761976- limit?: number;
19771977- cursor?: string;
19781978- includeDisabled?: boolean;
19791979- includeUsed?: boolean;
19801980-}
19811981-19821982-interface CreateTotpCredentialOptions {
19831983- did: Did;
19841984- name?: string;
19851985- secret: Uint8Array;
19861986- lastUsedCounter: number;
19871987-}
19881988-19891989-interface CreateWebAuthnCredentialOptions {
19901990- did: Did;
19911991- type: WebAuthnCredentialType;
19921992- name?: string;
19931993- credentialId: string;
19941994- publicKey: Uint8Array;
19951995- counter: number;
19961996- transports?: AuthenticatorTransportFuture[];
19971997-}
+693
packages/danaus/src/accounts/mfa.ts
···11+import type { Did } from '@atcute/lexicons';
22+import { InvalidRequestError } from '@atcute/xrpc-server';
33+44+import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
55+import { and, eq, isNull, lte, sql } from 'drizzle-orm';
66+import { nanoid } from 'nanoid';
77+88+import { t, type AccountDb } from './db';
99+import { PreferredMfa, WebAuthnCredentialType } from './db/schema';
1010+import { generateBackupCodes, MAX_TOTP_CREDENTIALS, verifyTotpCode } from './totp';
1111+import { MAX_WEBAUTHN_CREDENTIALS, WEBAUTHN_CHALLENGE_TTL_MS } from './webauthn';
1212+1313+export type TotpCredential = typeof t.totpCredential.$inferSelect;
1414+export type BackupCode = typeof t.recoveryCode.$inferSelect;
1515+export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect;
1616+export type WebauthnRegistrationChallenge = typeof t.webauthnRegistrationChallenge.$inferSelect;
1717+1818+/** WebAuthn credential type for MFA status */
1919+export type WebAuthnType = false | 'security-key' | 'passkey' | 'mixed';
2020+2121+/** MFA status for an account */
2222+export interface MfaStatus {
2323+ /** preferred MFA method */
2424+ preferred: PreferredMfa;
2525+ /** has TOTP credentials */
2626+ hasTotp: boolean;
2727+ /** WebAuthn credential type(s) registered */
2828+ webAuthnType: WebAuthnType;
2929+ /** has recovery codes */
3030+ hasRecoveryCodes: boolean;
3131+}
3232+3333+interface MfaManagerOptions {
3434+ db: AccountDb;
3535+}
3636+3737+interface CreateTotpCredentialOptions {
3838+ did: Did;
3939+ name?: string;
4040+ secret: Uint8Array;
4141+ lastUsedCounter: number;
4242+}
4343+4444+interface CreateWebAuthnCredentialOptions {
4545+ did: Did;
4646+ type: WebAuthnCredentialType;
4747+ name?: string;
4848+ credentialId: string;
4949+ publicKey: Uint8Array;
5050+ counter: number;
5151+ transports?: AuthenticatorTransportFuture[];
5252+}
5353+5454+export class MfaManager {
5555+ readonly #db: AccountDb;
5656+5757+ constructor(options: MfaManagerOptions) {
5858+ this.#db = options.db;
5959+ }
6060+6161+ // #region TOTP credentials
6262+6363+ /**
6464+ * create a TOTP credential for an account.
6565+ * @param options TOTP credential options
6666+ * @returns created credential
6767+ */
6868+ createTotpCredential(options: CreateTotpCredentialOptions): TotpCredential {
6969+ const count = this.#countTotpCredentials(options.did);
7070+ if (count >= MAX_TOTP_CREDENTIALS) {
7171+ throw new InvalidRequestError({
7272+ error: 'TooManyTotpCredentials',
7373+ description: `cannot have more than ${MAX_TOTP_CREDENTIALS} authenticators`,
7474+ });
7575+ }
7676+7777+ const name = options.name?.trim() || this.generateTotpName(options.did);
7878+7979+ // check for duplicate name
8080+ const existing = this.#db
8181+ .select()
8282+ .from(t.totpCredential)
8383+ .where(and(eq(t.totpCredential.did, options.did), eq(t.totpCredential.name, name)))
8484+ .get();
8585+8686+ if (existing) {
8787+ throw new InvalidRequestError({
8888+ error: 'DuplicateTotpName',
8989+ description: `an authenticator with this name already exists`,
9090+ });
9191+ }
9292+9393+ const inserted = this.#db
9494+ .insert(t.totpCredential)
9595+ .values({
9696+ did: options.did,
9797+ name: name,
9898+ secret: Buffer.from(options.secret),
9999+ created_at: new Date(),
100100+ last_used_counter: options.lastUsedCounter,
101101+ })
102102+ .returning()
103103+ .get();
104104+105105+ if (!inserted) {
106106+ throw new Error(`failed to create TOTP credential`);
107107+ }
108108+109109+ this.#syncPreferredMfa(options.did);
110110+111111+ return inserted;
112112+ }
113113+114114+ /**
115115+ * list TOTP credentials for an account.
116116+ * @param did account did
117117+ * @returns TOTP credentials
118118+ */
119119+ listTotpCredentials(did: Did): TotpCredential[] {
120120+ return this.#db.select().from(t.totpCredential).where(eq(t.totpCredential.did, did)).all();
121121+ }
122122+123123+ /**
124124+ * get a TOTP credential by id.
125125+ * @param did account did
126126+ * @param id credential id
127127+ * @returns TOTP credential or null
128128+ */
129129+ getTotpCredential(did: Did, id: number): TotpCredential | null {
130130+ const credential = this.#db
131131+ .select()
132132+ .from(t.totpCredential)
133133+ .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id)))
134134+ .get();
135135+136136+ return credential ?? null;
137137+ }
138138+139139+ /**
140140+ * delete a TOTP credential.
141141+ * @param did account did
142142+ * @param id credential id
143143+ */
144144+ deleteTotpCredential(did: Did, id: number): void {
145145+ this.#db
146146+ .delete(t.totpCredential)
147147+ .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id)))
148148+ .run();
149149+150150+ this.#syncPreferredMfa(did);
151151+ }
152152+153153+ /**
154154+ * verify a TOTP code against any of the account's credentials.
155155+ * updates last_used_counter on successful verification to prevent replay attacks.
156156+ * @param did account did
157157+ * @param code the code to verify
158158+ * @returns true if the code is valid for any credential
159159+ */
160160+ async verifyAccountTotpCode(did: Did, code: string): Promise<boolean> {
161161+ const credentials = this.listTotpCredentials(did);
162162+163163+ for (const credential of credentials) {
164164+ const counter = await verifyTotpCode(credential.secret, code, credential.last_used_counter);
165165+166166+ if (counter !== null) {
167167+ // update last_used_counter to prevent replay attacks
168168+ this.#db
169169+ .update(t.totpCredential)
170170+ .set({ last_used_counter: counter })
171171+ .where(eq(t.totpCredential.id, credential.id))
172172+ .run();
173173+174174+ return true;
175175+ }
176176+ }
177177+178178+ return false;
179179+ }
180180+181181+ /**
182182+ * generate a unique name for a new TOTP credential.
183183+ * @param did account did
184184+ * @returns generated name like "Authenticator" or "Authenticator 2"
185185+ */
186186+ generateTotpName(did: Did): string {
187187+ const existing = this.listTotpCredentials(did);
188188+ const baseName = 'Authenticator';
189189+190190+ if (existing.length === 0) {
191191+ return baseName;
192192+ }
193193+194194+ // find the next available number
195195+ const existingNames = new Set(existing.map((c) => c.name));
196196+ let num = 2;
197197+ while (existingNames.has(`${baseName} ${num}`)) {
198198+ num++;
199199+ }
200200+201201+ return `${baseName} ${num}`;
202202+ }
203203+204204+ #countTotpCredentials(did: Did): number {
205205+ return (
206206+ this.#db
207207+ .select({ count: sql<number>`count(*)` })
208208+ .from(t.totpCredential)
209209+ .where(eq(t.totpCredential.did, did))
210210+ .get()?.count ?? 0
211211+ );
212212+ }
213213+214214+ // #endregion
215215+216216+ // #region recovery codes
217217+218218+ /**
219219+ * generate and store recovery codes for an account.
220220+ * deletes any existing codes first.
221221+ * @param did account did
222222+ */
223223+ generateRecoveryCodes(did: Did): void {
224224+ const codes = generateBackupCodes();
225225+ const now = new Date();
226226+227227+ this.#db.transaction((tx) => {
228228+ tx.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run();
229229+230230+ for (const code of codes) {
231231+ tx.insert(t.recoveryCode)
232232+ .values({
233233+ did: did,
234234+ code: code,
235235+ created_at: now,
236236+ })
237237+ .run();
238238+ }
239239+ });
240240+ }
241241+242242+ /**
243243+ * get all unused recovery codes for an account.
244244+ * @param did account did
245245+ * @returns array of unused codes
246246+ */
247247+ getRecoveryCodes(did: Did): string[] {
248248+ return this.#db
249249+ .select({ code: t.recoveryCode.code })
250250+ .from(t.recoveryCode)
251251+ .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at)))
252252+ .all()
253253+ .map((row) => row.code);
254254+ }
255255+256256+ /**
257257+ * get count of unused recovery codes.
258258+ * @param did account did
259259+ * @returns number of unused codes
260260+ */
261261+ getRecoveryCodeCount(did: Did): number {
262262+ return (
263263+ this.#db
264264+ .select({ count: sql<number>`count(*)` })
265265+ .from(t.recoveryCode)
266266+ .where(and(eq(t.recoveryCode.did, did), isNull(t.recoveryCode.used_at)))
267267+ .get()?.count ?? 0
268268+ );
269269+ }
270270+271271+ /**
272272+ * verify and consume a recovery code.
273273+ * @param did account did
274274+ * @param code the code to verify
275275+ * @returns true if the code was valid and consumed
276276+ */
277277+ consumeRecoveryCode(did: Did, code: string): boolean {
278278+ const result = this.#db
279279+ .update(t.recoveryCode)
280280+ .set({ used_at: new Date() })
281281+ .where(and(eq(t.recoveryCode.did, did), eq(t.recoveryCode.code, code), isNull(t.recoveryCode.used_at)))
282282+ .returning()
283283+ .get();
284284+285285+ return result != null;
286286+ }
287287+288288+ /**
289289+ * delete all recovery codes for an account.
290290+ * @param did account did
291291+ */
292292+ deleteRecoveryCodes(did: Did): void {
293293+ this.#db.delete(t.recoveryCode).where(eq(t.recoveryCode.did, did)).run();
294294+ }
295295+296296+ // #endregion
297297+298298+ // #region WebAuthn credentials
299299+300300+ /**
301301+ * create a WebAuthn credential for an account.
302302+ * @param options credential options
303303+ * @returns created credential
304304+ */
305305+ createWebAuthnCredential(options: CreateWebAuthnCredentialOptions): WebauthnCredential {
306306+ const count = this.#countWebAuthnCredentials(options.did);
307307+ if (count >= MAX_WEBAUTHN_CREDENTIALS) {
308308+ throw new InvalidRequestError({
309309+ error: 'TooManyWebAuthnCredentials',
310310+ description: `cannot have more than ${MAX_WEBAUTHN_CREDENTIALS} security keys`,
311311+ });
312312+ }
313313+314314+ const name = options.name?.trim() || this.generateWebAuthnName(options.did, options.type);
315315+316316+ // check for duplicate name
317317+ const existing = this.#db
318318+ .select()
319319+ .from(t.webauthnCredential)
320320+ .where(and(eq(t.webauthnCredential.did, options.did), eq(t.webauthnCredential.name, name)))
321321+ .get();
322322+323323+ if (existing) {
324324+ throw new InvalidRequestError({
325325+ error: 'DuplicateWebAuthnName',
326326+ description: `a credential with this name already exists`,
327327+ });
328328+ }
329329+330330+ // check for duplicate credential ID
331331+ const existingCredId = this.#db
332332+ .select()
333333+ .from(t.webauthnCredential)
334334+ .where(eq(t.webauthnCredential.credential_id, options.credentialId))
335335+ .get();
336336+337337+ if (existingCredId) {
338338+ throw new InvalidRequestError({
339339+ error: 'DuplicateCredentialId',
340340+ description: `this security key is already registered`,
341341+ });
342342+ }
343343+344344+ const inserted = this.#db
345345+ .insert(t.webauthnCredential)
346346+ .values({
347347+ did: options.did,
348348+ type: options.type,
349349+ name: name,
350350+ credential_id: options.credentialId,
351351+ public_key: Buffer.from(options.publicKey),
352352+ counter: options.counter,
353353+ transports: options.transports,
354354+ created_at: new Date(),
355355+ })
356356+ .returning()
357357+ .get();
358358+359359+ if (!inserted) {
360360+ throw new Error(`failed to create WebAuthn credential`);
361361+ }
362362+363363+ // sync preferred MFA (only for security keys, not passkeys)
364364+ if (options.type === WebAuthnCredentialType.SecurityKey) {
365365+ this.#syncPreferredMfa(options.did);
366366+ }
367367+368368+ return inserted;
369369+ }
370370+371371+ /**
372372+ * list WebAuthn credentials for an account.
373373+ * @param did account did
374374+ * @returns WebAuthn credentials
375375+ */
376376+ listWebAuthnCredentials(did: Did): WebauthnCredential[] {
377377+ return this.#db.select().from(t.webauthnCredential).where(eq(t.webauthnCredential.did, did)).all();
378378+ }
379379+380380+ /**
381381+ * list WebAuthn credentials for an account filtered by type.
382382+ * @param did account did
383383+ * @param type credential type
384384+ * @returns WebAuthn credentials of the specified type
385385+ */
386386+ listWebAuthnCredentialsByType(did: Did, type: WebAuthnCredentialType): WebauthnCredential[] {
387387+ return this.#db
388388+ .select()
389389+ .from(t.webauthnCredential)
390390+ .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.type, type)))
391391+ .all();
392392+ }
393393+394394+ /**
395395+ * get a WebAuthn credential by id.
396396+ * @param did account did
397397+ * @param id credential id
398398+ * @returns WebAuthn credential or null
399399+ */
400400+ getWebAuthnCredential(did: Did, id: number): WebauthnCredential | null {
401401+ const credential = this.#db
402402+ .select()
403403+ .from(t.webauthnCredential)
404404+ .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id)))
405405+ .get();
406406+407407+ return credential ?? null;
408408+ }
409409+410410+ /**
411411+ * get a WebAuthn credential by credential ID.
412412+ * @param credentialId base64url credential ID
413413+ * @returns WebAuthn credential or null
414414+ */
415415+ getWebAuthnCredentialByCredentialId(credentialId: string): WebauthnCredential | null {
416416+ const credential = this.#db
417417+ .select()
418418+ .from(t.webauthnCredential)
419419+ .where(eq(t.webauthnCredential.credential_id, credentialId))
420420+ .get();
421421+422422+ return credential ?? null;
423423+ }
424424+425425+ /**
426426+ * delete a WebAuthn credential.
427427+ * @param did account did
428428+ * @param id credential id
429429+ */
430430+ deleteWebAuthnCredential(did: Did, id: number): void {
431431+ this.#db
432432+ .delete(t.webauthnCredential)
433433+ .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id)))
434434+ .run();
435435+436436+ this.#syncPreferredMfa(did);
437437+ }
438438+439439+ /**
440440+ * update the counter for a WebAuthn credential.
441441+ * @param id credential id
442442+ * @param counter new counter value
443443+ */
444444+ updateWebAuthnCredentialCounter(id: number, counter: number): void {
445445+ this.#db.update(t.webauthnCredential).set({ counter }).where(eq(t.webauthnCredential.id, id)).run();
446446+ }
447447+448448+ /**
449449+ * generate a unique name for a new WebAuthn credential.
450450+ * @param did account did
451451+ * @param type credential type
452452+ * @returns generated name like "Security Key" or "Security Key 2"
453453+ */
454454+ generateWebAuthnName(did: Did, type: WebAuthnCredentialType): string {
455455+ const existing = this.listWebAuthnCredentialsByType(did, type);
456456+ const baseName = type === WebAuthnCredentialType.SecurityKey ? 'Security Key' : 'Passkey';
457457+458458+ if (existing.length === 0) {
459459+ return baseName;
460460+ }
461461+462462+ // find the next available number
463463+ const existingNames = new Set(existing.map((c) => c.name));
464464+ let num = 2;
465465+ while (existingNames.has(`${baseName} ${num}`)) {
466466+ num++;
467467+ }
468468+469469+ return `${baseName} ${num}`;
470470+ }
471471+472472+ #countWebAuthnCredentials(did: Did): number {
473473+ return (
474474+ this.#db
475475+ .select({ count: sql<number>`count(*)` })
476476+ .from(t.webauthnCredential)
477477+ .where(eq(t.webauthnCredential.did, did))
478478+ .get()?.count ?? 0
479479+ );
480480+ }
481481+482482+ #getWebAuthnType(did: Did): WebAuthnType {
483483+ const credentials = this.#db
484484+ .select({ type: t.webauthnCredential.type })
485485+ .from(t.webauthnCredential)
486486+ .where(eq(t.webauthnCredential.did, did))
487487+ .all();
488488+489489+ if (credentials.length === 0) {
490490+ return false;
491491+ }
492492+493493+ const hasSecurityKey = credentials.some((c) => c.type === WebAuthnCredentialType.SecurityKey);
494494+ const hasPasskey = credentials.some((c) => c.type === WebAuthnCredentialType.Passkey);
495495+496496+ if (hasSecurityKey && hasPasskey) {
497497+ return 'mixed';
498498+ }
499499+ return hasPasskey ? 'passkey' : 'security-key';
500500+ }
501501+502502+ // #endregion
503503+504504+ // #region WebAuthn registration challenges
505505+506506+ /**
507507+ * create a WebAuthn registration challenge.
508508+ * @param did account did
509509+ * @param challenge base64url challenge
510510+ * @returns token for retrieving the challenge
511511+ */
512512+ createWebAuthnRegistrationChallenge(did: Did, challenge: string): string {
513513+ const token = nanoid(32);
514514+ const now = new Date();
515515+ const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS);
516516+517517+ this.#db
518518+ .insert(t.webauthnRegistrationChallenge)
519519+ .values({
520520+ token: token,
521521+ did: did,
522522+ challenge: challenge,
523523+ created_at: now,
524524+ expires_at: expiresAt,
525525+ })
526526+ .run();
527527+528528+ return token;
529529+ }
530530+531531+ /**
532532+ * get a WebAuthn registration challenge by token.
533533+ * @param token the token
534534+ * @returns WebAuthn challenge or null if expired/not found
535535+ */
536536+ getWebAuthnRegistrationChallenge(token: string): WebauthnRegistrationChallenge | null {
537537+ const challenge = this.#db
538538+ .select()
539539+ .from(t.webauthnRegistrationChallenge)
540540+ .where(eq(t.webauthnRegistrationChallenge.token, token))
541541+ .get();
542542+543543+ if (!challenge) {
544544+ return null;
545545+ }
546546+547547+ const now = new Date();
548548+ if (challenge.expires_at <= now) {
549549+ this.#db
550550+ .delete(t.webauthnRegistrationChallenge)
551551+ .where(eq(t.webauthnRegistrationChallenge.token, token))
552552+ .run();
553553+ return null;
554554+ }
555555+556556+ return challenge;
557557+ }
558558+559559+ /**
560560+ * delete a WebAuthn registration challenge.
561561+ * @param token the token
562562+ */
563563+ deleteWebAuthnRegistrationChallenge(token: string): void {
564564+ this.#db
565565+ .delete(t.webauthnRegistrationChallenge)
566566+ .where(eq(t.webauthnRegistrationChallenge.token, token))
567567+ .run();
568568+ }
569569+570570+ /**
571571+ * clean up expired WebAuthn registration challenges.
572572+ */
573573+ cleanupExpiredWebAuthnRegistrationChallenges(): void {
574574+ const now = new Date();
575575+ this.#db
576576+ .delete(t.webauthnRegistrationChallenge)
577577+ .where(lte(t.webauthnRegistrationChallenge.expires_at, now))
578578+ .run();
579579+ }
580580+581581+ // #endregion
582582+583583+ // #region passkey login challenges
584584+585585+ /**
586586+ * create a passkey login challenge for passwordless authentication.
587587+ * @param challenge base64url challenge string
588588+ */
589589+ createPasskeyLoginChallenge(challenge: string): void {
590590+ const now = new Date();
591591+ const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS);
592592+593593+ this.#db
594594+ .insert(t.passkeyLoginChallenge)
595595+ .values({
596596+ challenge: challenge,
597597+ created_at: now,
598598+ expires_at: expiresAt,
599599+ })
600600+ .run();
601601+ }
602602+603603+ /**
604604+ * consume a passkey login challenge (delete and return if valid).
605605+ * @param challenge base64url challenge string
606606+ * @returns true if challenge was valid and consumed
607607+ */
608608+ consumePasskeyLoginChallenge(challenge: string): boolean {
609609+ const now = new Date();
610610+611611+ // clean up expired challenges
612612+ this.#db.delete(t.passkeyLoginChallenge).where(lte(t.passkeyLoginChallenge.expires_at, now)).run();
613613+614614+ // try to delete the challenge (returns the deleted row if it existed)
615615+ const result = this.#db
616616+ .delete(t.passkeyLoginChallenge)
617617+ .where(eq(t.passkeyLoginChallenge.challenge, challenge))
618618+ .returning()
619619+ .get();
620620+621621+ return result != null;
622622+ }
623623+624624+ // #endregion
625625+626626+ // #region MFA status
627627+628628+ /**
629629+ * get MFA status for an account.
630630+ * @param did account did
631631+ * @returns MFA status with preferred method and available methods, or null if no MFA configured
632632+ */
633633+ getMfaStatus(did: Did): MfaStatus | null {
634634+ const account = this.#db
635635+ .select({ preferred_mfa: t.account.preferred_mfa })
636636+ .from(t.account)
637637+ .where(eq(t.account.did, did))
638638+ .get();
639639+640640+ if (!account || account.preferred_mfa == null) {
641641+ return null;
642642+ }
643643+644644+ return {
645645+ preferred: account.preferred_mfa,
646646+ hasTotp: this.#countTotpCredentials(did) > 0,
647647+ webAuthnType: this.#getWebAuthnType(did),
648648+ hasRecoveryCodes: this.getRecoveryCodeCount(did) > 0,
649649+ };
650650+ }
651651+652652+ /**
653653+ * sync preferred_mfa to reflect current MFA credentials.
654654+ * - if null and credentials exist → set to first available type
655655+ * - if set but that type has no credentials → switch to another type or clear
656656+ */
657657+ #syncPreferredMfa(did: Did): void {
658658+ const account = this.#db
659659+ .select({ preferred_mfa: t.account.preferred_mfa })
660660+ .from(t.account)
661661+ .where(eq(t.account.did, did))
662662+ .get();
663663+664664+ if (!account) {
665665+ return;
666666+ }
667667+668668+ const hasTotp = this.#countTotpCredentials(did) > 0;
669669+ const hasWebAuthn = this.#countWebAuthnCredentials(did) > 0;
670670+671671+ // check if current preference is still valid
672672+ if (account.preferred_mfa === PreferredMfa.Totp && hasTotp) {
673673+ return;
674674+ }
675675+ if (account.preferred_mfa === PreferredMfa.WebAuthn && hasWebAuthn) {
676676+ return;
677677+ }
678678+679679+ // need to set or switch: prefer the type that was just added (TOTP first for backwards compat)
680680+ let newPreferred: PreferredMfa | null = null;
681681+ if (hasTotp) {
682682+ newPreferred = PreferredMfa.Totp;
683683+ } else if (hasWebAuthn) {
684684+ newPreferred = PreferredMfa.WebAuthn;
685685+ }
686686+687687+ if (newPreferred !== account.preferred_mfa) {
688688+ this.#db.update(t.account).set({ preferred_mfa: newPreferred }).where(eq(t.account.did, did)).run();
689689+ }
690690+ }
691691+692692+ // #endregion
693693+}
+262
packages/danaus/src/accounts/web-sessions.ts
···11+import type { KeyObject } from 'node:crypto';
22+33+import type { Did } from '@atcute/lexicons';
44+55+import { eq, lte } from 'drizzle-orm';
66+import { nanoid } from 'nanoid';
77+88+import { createWebSessionToken } from '#app/auth/web.ts';
99+import { DAY } from '#app/utils/times.ts';
1010+1111+import { t, type AccountDb } from './db';
1212+1313+const WEB_SESSION_TTL_MS = 7 * DAY;
1414+const WEB_SESSION_LONG_TTL_MS = 365 * DAY;
1515+const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
1616+const SUDO_MODE_TTL_MS = 15 * 60 * 1000; // 15 minutes
1717+1818+export type WebSession = typeof t.webSession.$inferSelect;
1919+export type VerifyChallenge = typeof t.verifyChallenge.$inferSelect;
2020+2121+interface WebSessionManagerOptions {
2222+ db: AccountDb;
2323+ jwtKey: KeyObject;
2424+}
2525+2626+interface CreateWebSessionOptions {
2727+ did: Did;
2828+ remember: boolean;
2929+ userAgent: string | undefined;
3030+ ip: string | undefined;
3131+}
3232+3333+export class WebSessionManager {
3434+ readonly #db: AccountDb;
3535+ readonly #jwtKey: KeyObject;
3636+3737+ constructor(options: WebSessionManagerOptions) {
3838+ this.#db = options.db;
3939+ this.#jwtKey = options.jwtKey;
4040+ }
4141+4242+ // #region web sessions
4343+4444+ /**
4545+ * create a web session record.
4646+ * @param options web session options
4747+ * @returns session and signed token
4848+ */
4949+ async createWebSession(options: CreateWebSessionOptions): Promise<{ session: WebSession; token: string }> {
5050+ const now = new Date();
5151+ const expiresAt = new Date(
5252+ now.getTime() + (options.remember ? WEB_SESSION_LONG_TTL_MS : WEB_SESSION_TTL_MS),
5353+ );
5454+ const id = nanoid(44);
5555+5656+ const inserted = this.#db
5757+ .insert(t.webSession)
5858+ .values({
5959+ id: id,
6060+ did: options.did,
6161+ metadata: {
6262+ userAgent: options.userAgent,
6363+ ip: options.ip,
6464+ },
6565+ created_at: now,
6666+ expires_at: expiresAt,
6767+ })
6868+ .returning()
6969+ .get();
7070+7171+ if (!inserted) {
7272+ throw new Error(`failed to create web session`);
7373+ }
7474+7575+ const token = createWebSessionToken(this.#jwtKey, id);
7676+7777+ return { session: inserted, token: token };
7878+ }
7979+8080+ /**
8181+ * get a web session by id.
8282+ * @param sessionId session id
8383+ * @returns web session or null if expired/not found
8484+ */
8585+ getWebSession(sessionId: string): WebSession | null {
8686+ const session = this.#db.select().from(t.webSession).where(eq(t.webSession.id, sessionId)).get();
8787+ if (!session) {
8888+ return null;
8989+ }
9090+9191+ const now = new Date();
9292+ if (session.expires_at <= now) {
9393+ this.#db.delete(t.webSession).where(eq(t.webSession.id, sessionId)).run();
9494+ return null;
9595+ }
9696+9797+ return session;
9898+ }
9999+100100+ /**
101101+ * delete a web session.
102102+ * @param sessionId session id
103103+ */
104104+ deleteWebSession(sessionId: string): void {
105105+ this.#db.delete(t.webSession).where(eq(t.webSession.id, sessionId)).run();
106106+ }
107107+108108+ // #endregion
109109+110110+ // #region sudo mode
111111+112112+ /**
113113+ * elevate a session to sudo mode.
114114+ * @param sessionId the session id
115115+ */
116116+ elevateSession(sessionId: string): void {
117117+ this.#db.update(t.webSession).set({ sudo_at: new Date() }).where(eq(t.webSession.id, sessionId)).run();
118118+ }
119119+120120+ /**
121121+ * check if a session is in sudo mode.
122122+ * @param session the session
123123+ * @returns true if session is elevated
124124+ */
125125+ isSessionElevated(session: WebSession): boolean {
126126+ if (session.sudo_at === null) {
127127+ return false;
128128+ }
129129+ const now = Date.now();
130130+ const elevatedAt = session.sudo_at.getTime();
131131+ return now - elevatedAt < SUDO_MODE_TTL_MS;
132132+ }
133133+134134+ // #endregion
135135+136136+ // #region verification challenges
137137+138138+ /**
139139+ * create a verification challenge for MFA login (no session, creates one on success).
140140+ * @param did account did
141141+ * @param remember whether to create a long-lived session on success
142142+ * @returns token for the verify page
143143+ */
144144+ createVerifyChallenge(did: Did, remember: boolean): string {
145145+ const token = nanoid(32);
146146+ const now = new Date();
147147+ const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS);
148148+149149+ this.#db
150150+ .insert(t.verifyChallenge)
151151+ .values({
152152+ token: token,
153153+ did: did,
154154+ session_id: null,
155155+ remember: remember,
156156+ created_at: now,
157157+ expires_at: expiresAt,
158158+ })
159159+ .run();
160160+161161+ return token;
162162+ }
163163+164164+ /**
165165+ * create or get an existing sudo challenge for session elevation.
166166+ * @param sessionId the session to elevate
167167+ * @param did account did
168168+ * @returns the verify challenge row
169169+ */
170170+ getOrCreateSudoChallenge(sessionId: string, did: Did): VerifyChallenge {
171171+ // check for existing sudo challenge for this session
172172+ const existing = this.#db
173173+ .select()
174174+ .from(t.verifyChallenge)
175175+ .where(eq(t.verifyChallenge.session_id, sessionId))
176176+ .get();
177177+178178+ const now = new Date();
179179+180180+ if (existing && existing.expires_at > now) {
181181+ return existing;
182182+ }
183183+184184+ // delete expired challenge if it exists
185185+ if (existing) {
186186+ this.#db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, existing.token)).run();
187187+ }
188188+189189+ const token = nanoid(32);
190190+ const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS);
191191+192192+ const inserted = this.#db
193193+ .insert(t.verifyChallenge)
194194+ .values({
195195+ token: token,
196196+ did: did,
197197+ session_id: sessionId,
198198+ created_at: now,
199199+ expires_at: expiresAt,
200200+ })
201201+ .returning()
202202+ .get();
203203+204204+ return inserted;
205205+ }
206206+207207+ /**
208208+ * get a verification challenge by token.
209209+ * @param token the token
210210+ * @returns verify challenge or null if expired/not found
211211+ */
212212+ getVerifyChallenge(token: string): VerifyChallenge | null {
213213+ const challenge = this.#db
214214+ .select()
215215+ .from(t.verifyChallenge)
216216+ .where(eq(t.verifyChallenge.token, token))
217217+ .get();
218218+219219+ if (!challenge) {
220220+ return null;
221221+ }
222222+223223+ const now = new Date();
224224+ if (challenge.expires_at <= now) {
225225+ this.#db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run();
226226+ return null;
227227+ }
228228+229229+ return challenge;
230230+ }
231231+232232+ /**
233233+ * delete a verification challenge.
234234+ * @param token the token
235235+ */
236236+ deleteVerifyChallenge(token: string): void {
237237+ this.#db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run();
238238+ }
239239+240240+ /**
241241+ * clean up expired verification challenges.
242242+ */
243243+ cleanupExpiredVerifyChallenges(): void {
244244+ const now = new Date();
245245+ this.#db.delete(t.verifyChallenge).where(lte(t.verifyChallenge.expires_at, now)).run();
246246+ }
247247+248248+ /**
249249+ * set the WebAuthn challenge on an existing verification challenge.
250250+ * @param token verify challenge token
251251+ * @param webauthnChallenge base64url WebAuthn challenge
252252+ */
253253+ setVerifyChallengeWebAuthn(token: string, webauthnChallenge: string): void {
254254+ this.#db
255255+ .update(t.verifyChallenge)
256256+ .set({ webauthn_challenge: webauthnChallenge })
257257+ .where(eq(t.verifyChallenge.token, token))
258258+ .run();
259259+ }
260260+261261+ // #endregion
262262+}
+1-1
packages/danaus/src/accounts/webauthn.ts
···1111} from '@simplewebauthn/server';
12121313import { WebAuthnCredentialType } from './db/schema.ts';
1414-import type { WebauthnCredential } from './manager';
1414+import type { WebauthnCredential } from './mfa';
15151616// #region constants
1717