import type { AuthStore } from "../store.ts"; import { inferIdentityType, normalizeIdentity } from "../normalize.ts"; import type { AuthIdentity, AuthSecurityEvent, AuthSession, AuthUser, LoginAttempt, OAuthAccount, OneTimeToken, OtpChallenge, PasskeyCredential, PasswordCredential, RecoveryCodeRecord, TotpCredential, TrustedDevice, } from "../types.ts"; function clone(value: T): T { return structuredClone(value); } export class MemoryAuthStore implements AuthStore { private readonly users = new Map(); private readonly identities = new Map(); private readonly passwords = new Map(); private readonly sessions = new Map(); private readonly trustedDevices = new Map(); private readonly tokens = new Map(); private readonly otps = new Map(); private readonly totp = new Map(); private readonly recoveryCodes = new Map(); private readonly passkeys = new Map(); private readonly oauthAccounts = new Map(); private readonly attempts: LoginAttempt[] = []; private readonly events: AuthSecurityEvent[] = []; async createUser(user: AuthUser): Promise { if (this.users.has(user.id)) throw new Error(`WRN-AUTH-USER-EXISTS: ${user.id}`); this.users.set(user.id, clone(user)); } async updateUser(user: AuthUser): Promise { if (!this.users.has(user.id)) throw new Error(`WRN-AUTH-USER-MISSING: ${user.id}`); this.users.set(user.id, clone(user)); } async findUserById(id: string): Promise { const value = this.users.get(id); return value ? clone(value) : undefined; } async listUsers(): Promise { return [...this.users.values()].map(clone); } async createIdentity(identity: AuthIdentity): Promise { const key = `${identity.type}:${identity.normalizedValue}`; if ( this.identities.has(key) || [...this.identities.values()].some((item) => item.id === identity.id) ) { throw new Error("WRN-AUTH-IDENTITY-EXISTS"); } this.identities.set(key, clone(identity)); } async updateIdentity(identity: AuthIdentity): Promise { const currentEntry = [...this.identities.entries()].find( ([, existing]) => existing.id === identity.id, ); if (!currentEntry) throw new Error("WRN-AUTH-IDENTITY-MISSING"); const [currentKey, current] = currentEntry; if (identity.userId !== current.userId || identity.type !== current.type) { throw new Error("WRN-AUTH-IDENTITY-IMMUTABLE"); } const nextKey = `${identity.type}:${identity.normalizedValue}`; const collision = this.identities.get(nextKey); if (collision && collision.id !== identity.id) { throw new Error("WRN-AUTH-IDENTITY-EXISTS"); } if (currentKey !== nextKey) this.identities.delete(currentKey); this.identities.set(nextKey, clone(identity)); } async findIdentity( type: AuthIdentity["type"], normalizedValue: string, ): Promise { const value = this.identities.get(`${type}:${normalizedValue}`); return value ? clone(value) : undefined; } async listIdentities(userId: string): Promise { return [...this.identities.values()].filter((item) => item.userId === userId).map(clone); } async setPassword(credential: PasswordCredential): Promise { this.passwords.set(credential.userId, clone(credential)); } async getPassword(userId: string): Promise { const value = this.passwords.get(userId); return value ? clone(value) : undefined; } async createSession(session: AuthSession): Promise { if (this.sessions.has(session.id)) throw new Error("WRN-AUTH-SESSION-EXISTS"); this.sessions.set(session.id, clone(session)); } async updateSession(session: AuthSession): Promise { this.sessions.set(session.id, clone(session)); } async findSession(id: string): Promise { const value = this.sessions.get(id); return value ? clone(value) : undefined; } async listSessions(userId: string): Promise { return [...this.sessions.values()].filter((item) => item.userId === userId).map(clone); } async deleteSession(id: string): Promise { this.sessions.delete(id); } async createTrustedDevice(device: TrustedDevice): Promise { if ( this.trustedDevices.has(device.id) || [...this.trustedDevices.values()].some( (item) => item.userId === device.userId && item.fingerprintHash === device.fingerprintHash, ) ) { throw new Error("WRN-AUTH-TRUSTED-DEVICE-EXISTS"); } this.trustedDevices.set(device.id, clone(device)); } async updateTrustedDevice(device: TrustedDevice): Promise { this.trustedDevices.set(device.id, clone(device)); } async findTrustedDeviceByFingerprint( userId: string, fingerprintHash: string, ): Promise { const value = [...this.trustedDevices.values()].find( (item) => item.userId === userId && item.fingerprintHash === fingerprintHash, ); return value ? clone(value) : undefined; } async listTrustedDevices(userId: string): Promise { return [...this.trustedDevices.values()].filter((item) => item.userId === userId).map(clone); } async createToken(token: OneTimeToken): Promise { if ( this.tokens.has(token.tokenHash) || [...this.tokens.values()].some((item) => item.id === token.id) ) { throw new Error("WRN-AUTH-TOKEN-EXISTS"); } this.tokens.set(token.tokenHash, clone(token)); } async updateToken(token: OneTimeToken): Promise { this.tokens.set(token.tokenHash, clone(token)); } async findTokenByHash(hash: string): Promise { const value = this.tokens.get(hash); return value ? clone(value) : undefined; } async createOtp(challenge: OtpChallenge): Promise { if (this.otps.has(challenge.id)) throw new Error("WRN-AUTH-OTP-EXISTS"); this.otps.set(challenge.id, clone(challenge)); } async updateOtp(challenge: OtpChallenge): Promise { this.otps.set(challenge.id, clone(challenge)); } async findOtp(id: string): Promise { const value = this.otps.get(id); return value ? clone(value) : undefined; } async createTotp(credential: TotpCredential): Promise { if (this.totp.has(credential.id)) throw new Error("WRN-AUTH-TOTP-EXISTS"); this.totp.set(credential.id, clone(credential)); } async updateTotp(credential: TotpCredential): Promise { this.totp.set(credential.id, clone(credential)); } async listTotp(userId: string): Promise { return [...this.totp.values()].filter((item) => item.userId === userId).map(clone); } async deleteTotp(id: string): Promise { this.totp.delete(id); } async createRecoveryCodes(codes: RecoveryCodeRecord[]): Promise { const incoming = new Set(); for (const code of codes) { if (incoming.has(code.id) || this.recoveryCodes.has(code.id)) { throw new Error("WRN-AUTH-RECOVERY-CODE-EXISTS"); } incoming.add(code.id); } for (const code of codes) this.recoveryCodes.set(code.id, clone(code)); } async updateRecoveryCode(code: RecoveryCodeRecord): Promise { this.recoveryCodes.set(code.id, clone(code)); } async listRecoveryCodes(userId: string): Promise { return [...this.recoveryCodes.values()].filter((item) => item.userId === userId).map(clone); } async deleteRecoveryCodes(userId: string): Promise { for (const [id, code] of this.recoveryCodes) { if (code.userId === userId) this.recoveryCodes.delete(id); } } async createPasskey(credential: PasskeyCredential): Promise { if ( this.passkeys.has(credential.id) || [...this.passkeys.values()].some((item) => item.credentialId === credential.credentialId) ) { throw new Error("WRN-AUTH-PASSKEY-EXISTS"); } this.passkeys.set(credential.id, clone(credential)); } async updatePasskey(credential: PasskeyCredential): Promise { const current = this.passkeys.get(credential.id); if (!current) throw new Error("WRN-AUTH-PASSKEY-MISSING"); if ( credential.userId !== current.userId || credential.credentialId !== current.credentialId || credential.createdAt !== current.createdAt ) { throw new Error("WRN-AUTH-PASSKEY-IMMUTABLE"); } this.passkeys.set(credential.id, clone(credential)); } async findPasskeyByCredentialId(credentialId: string): Promise { const value = [...this.passkeys.values()].find((item) => item.credentialId === credentialId); return value ? clone(value) : undefined; } async listPasskeys(userId: string): Promise { return [...this.passkeys.values()].filter((item) => item.userId === userId).map(clone); } async deletePasskey(id: string): Promise { this.passkeys.delete(id); } async createOAuthAccount(account: OAuthAccount): Promise { if ( this.oauthAccounts.has(account.id) || [...this.oauthAccounts.values()].some( (item) => item.provider === account.provider && item.providerAccountId === account.providerAccountId, ) ) { throw new Error("WRN-AUTH-OAUTH-ACCOUNT-EXISTS"); } this.oauthAccounts.set(account.id, clone(account)); } async updateOAuthAccount(account: OAuthAccount): Promise { const current = this.oauthAccounts.get(account.id); if (!current) throw new Error("WRN-AUTH-OAUTH-ACCOUNT-MISSING"); if ( account.userId !== current.userId || account.provider !== current.provider || account.providerAccountId !== current.providerAccountId || account.createdAt !== current.createdAt ) { throw new Error("WRN-AUTH-OAUTH-ACCOUNT-IMMUTABLE"); } this.oauthAccounts.set(account.id, clone(account)); } async findOAuthAccount( provider: string, providerAccountId: string, ): Promise { const value = [...this.oauthAccounts.values()].find( (item) => item.provider === provider && item.providerAccountId === providerAccountId, ); return value ? clone(value) : undefined; } async listOAuthAccounts(userId: string): Promise { return [...this.oauthAccounts.values()].filter((item) => item.userId === userId).map(clone); } async deleteOAuthAccount(id: string): Promise { this.oauthAccounts.delete(id); } async createLoginAttempt(attempt: LoginAttempt): Promise { if (this.attempts.some((item) => item.id === attempt.id)) { throw new Error("WRN-AUTH-LOGIN-ATTEMPT-EXISTS"); } const identifier = attempt.identifier ? normalizeIdentity(inferIdentityType(attempt.identifier), attempt.identifier) : undefined; this.attempts.push(clone({ ...attempt, identifier })); } async listRecentLoginAttempts(identifier: string, since: number): Promise { const normalized = normalizeIdentity(inferIdentityType(identifier), identifier); return this.attempts .filter((item) => { if (item.createdAt < since || !item.identifier) return false; return ( normalizeIdentity(inferIdentityType(item.identifier), item.identifier) === normalized ); }) .map(clone); } async createSecurityEvent(event: AuthSecurityEvent): Promise { if (this.events.some((item) => item.id === event.id)) { throw new Error("WRN-AUTH-SECURITY-EVENT-EXISTS"); } this.events.push(clone(event)); } async listSecurityEvents(userId: string, limit = 100): Promise { return this.events .filter((item) => item.userId === userId) .sort((left, right) => right.createdAt - left.createdAt) .slice(0, limit) .map(clone); } }