release: WRNexusJS 0.5.0
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
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<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
export class MemoryAuthStore implements AuthStore {
|
||||
private readonly users = new Map<string, AuthUser>();
|
||||
private readonly identities = new Map<string, AuthIdentity>();
|
||||
private readonly passwords = new Map<string, PasswordCredential>();
|
||||
private readonly sessions = new Map<string, AuthSession>();
|
||||
private readonly trustedDevices = new Map<string, TrustedDevice>();
|
||||
private readonly tokens = new Map<string, OneTimeToken>();
|
||||
private readonly otps = new Map<string, OtpChallenge>();
|
||||
private readonly totp = new Map<string, TotpCredential>();
|
||||
private readonly recoveryCodes = new Map<string, RecoveryCodeRecord>();
|
||||
private readonly passkeys = new Map<string, PasskeyCredential>();
|
||||
private readonly oauthAccounts = new Map<string, OAuthAccount>();
|
||||
private readonly attempts: LoginAttempt[] = [];
|
||||
private readonly events: AuthSecurityEvent[] = [];
|
||||
|
||||
async createUser(user: AuthUser): Promise<void> {
|
||||
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<void> {
|
||||
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<AuthUser | undefined> {
|
||||
const value = this.users.get(id);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
async listUsers(): Promise<AuthUser[]> {
|
||||
return [...this.users.values()].map(clone);
|
||||
}
|
||||
|
||||
async createIdentity(identity: AuthIdentity): Promise<void> {
|
||||
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<void> {
|
||||
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<AuthIdentity | undefined> {
|
||||
const value = this.identities.get(`${type}:${normalizedValue}`);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
async listIdentities(userId: string): Promise<AuthIdentity[]> {
|
||||
return [...this.identities.values()].filter((item) => item.userId === userId).map(clone);
|
||||
}
|
||||
|
||||
async setPassword(credential: PasswordCredential): Promise<void> {
|
||||
this.passwords.set(credential.userId, clone(credential));
|
||||
}
|
||||
async getPassword(userId: string): Promise<PasswordCredential | undefined> {
|
||||
const value = this.passwords.get(userId);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
|
||||
async createSession(session: AuthSession): Promise<void> {
|
||||
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<void> {
|
||||
this.sessions.set(session.id, clone(session));
|
||||
}
|
||||
async findSession(id: string): Promise<AuthSession | undefined> {
|
||||
const value = this.sessions.get(id);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
async listSessions(userId: string): Promise<AuthSession[]> {
|
||||
return [...this.sessions.values()].filter((item) => item.userId === userId).map(clone);
|
||||
}
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
|
||||
async createTrustedDevice(device: TrustedDevice): Promise<void> {
|
||||
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<void> {
|
||||
this.trustedDevices.set(device.id, clone(device));
|
||||
}
|
||||
async findTrustedDeviceByFingerprint(
|
||||
userId: string,
|
||||
fingerprintHash: string,
|
||||
): Promise<TrustedDevice | undefined> {
|
||||
const value = [...this.trustedDevices.values()].find(
|
||||
(item) => item.userId === userId && item.fingerprintHash === fingerprintHash,
|
||||
);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
async listTrustedDevices(userId: string): Promise<TrustedDevice[]> {
|
||||
return [...this.trustedDevices.values()].filter((item) => item.userId === userId).map(clone);
|
||||
}
|
||||
|
||||
async createToken(token: OneTimeToken): Promise<void> {
|
||||
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<void> {
|
||||
this.tokens.set(token.tokenHash, clone(token));
|
||||
}
|
||||
async findTokenByHash(hash: string): Promise<OneTimeToken | undefined> {
|
||||
const value = this.tokens.get(hash);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
|
||||
async createOtp(challenge: OtpChallenge): Promise<void> {
|
||||
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<void> {
|
||||
this.otps.set(challenge.id, clone(challenge));
|
||||
}
|
||||
async findOtp(id: string): Promise<OtpChallenge | undefined> {
|
||||
const value = this.otps.get(id);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
|
||||
async createTotp(credential: TotpCredential): Promise<void> {
|
||||
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<void> {
|
||||
this.totp.set(credential.id, clone(credential));
|
||||
}
|
||||
async listTotp(userId: string): Promise<TotpCredential[]> {
|
||||
return [...this.totp.values()].filter((item) => item.userId === userId).map(clone);
|
||||
}
|
||||
async deleteTotp(id: string): Promise<void> {
|
||||
this.totp.delete(id);
|
||||
}
|
||||
|
||||
async createRecoveryCodes(codes: RecoveryCodeRecord[]): Promise<void> {
|
||||
const incoming = new Set<string>();
|
||||
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<void> {
|
||||
this.recoveryCodes.set(code.id, clone(code));
|
||||
}
|
||||
async listRecoveryCodes(userId: string): Promise<RecoveryCodeRecord[]> {
|
||||
return [...this.recoveryCodes.values()].filter((item) => item.userId === userId).map(clone);
|
||||
}
|
||||
async deleteRecoveryCodes(userId: string): Promise<void> {
|
||||
for (const [id, code] of this.recoveryCodes) {
|
||||
if (code.userId === userId) this.recoveryCodes.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async createPasskey(credential: PasskeyCredential): Promise<void> {
|
||||
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<void> {
|
||||
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<PasskeyCredential | undefined> {
|
||||
const value = [...this.passkeys.values()].find((item) => item.credentialId === credentialId);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
async listPasskeys(userId: string): Promise<PasskeyCredential[]> {
|
||||
return [...this.passkeys.values()].filter((item) => item.userId === userId).map(clone);
|
||||
}
|
||||
async deletePasskey(id: string): Promise<void> {
|
||||
this.passkeys.delete(id);
|
||||
}
|
||||
|
||||
async createOAuthAccount(account: OAuthAccount): Promise<void> {
|
||||
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<void> {
|
||||
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<OAuthAccount | undefined> {
|
||||
const value = [...this.oauthAccounts.values()].find(
|
||||
(item) => item.provider === provider && item.providerAccountId === providerAccountId,
|
||||
);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
async listOAuthAccounts(userId: string): Promise<OAuthAccount[]> {
|
||||
return [...this.oauthAccounts.values()].filter((item) => item.userId === userId).map(clone);
|
||||
}
|
||||
async deleteOAuthAccount(id: string): Promise<void> {
|
||||
this.oauthAccounts.delete(id);
|
||||
}
|
||||
|
||||
async createLoginAttempt(attempt: LoginAttempt): Promise<void> {
|
||||
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<LoginAttempt[]> {
|
||||
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<void> {
|
||||
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<AuthSecurityEvent[]> {
|
||||
return this.events
|
||||
.filter((item) => item.userId === userId)
|
||||
.sort((left, right) => right.createdAt - left.createdAt)
|
||||
.slice(0, limit)
|
||||
.map(clone);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import type { Db, Row } from "@wrnexus/db";
|
||||
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 bool(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
function json<T>(value: unknown, fallback: T): T {
|
||||
if (typeof value !== "string" || !value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
function placeholders(sql: string, dialect: string): string {
|
||||
if (dialect !== "postgres") return sql;
|
||||
let index = 0;
|
||||
return sql.replace(/\?/g, () => `$${++index}`);
|
||||
}
|
||||
|
||||
export class SqlAuthStore implements AuthStore {
|
||||
constructor(private readonly db: Db) {}
|
||||
private sql(value: string): string {
|
||||
return placeholders(value, this.db.driver.dialect);
|
||||
}
|
||||
private async one(sql: string, params: unknown[] = []): Promise<Row | undefined> {
|
||||
return (await this.db.one(this.sql(sql), params)) ?? undefined;
|
||||
}
|
||||
private async all(sql: string, params: unknown[] = []): Promise<Row[]> {
|
||||
return this.db.all(this.sql(sql), params);
|
||||
}
|
||||
private exec(sql: string, params: unknown[] = []) {
|
||||
return this.db.exec(this.sql(sql), params);
|
||||
}
|
||||
|
||||
async createUser(user: AuthUser): Promise<void> {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_users (id,username,display_name,avatar_url,status,roles_json,email_verified,phone_verified,mfa_enabled,locale,timezone,created_at,updated_at,last_login_at,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
user.id,
|
||||
user.username,
|
||||
user.displayName,
|
||||
user.avatarUrl,
|
||||
user.status,
|
||||
JSON.stringify(user.roles),
|
||||
Number(user.emailVerified),
|
||||
Number(user.phoneVerified),
|
||||
Number(user.mfaEnabled),
|
||||
user.locale,
|
||||
user.timezone,
|
||||
user.createdAt,
|
||||
user.updatedAt,
|
||||
user.lastLoginAt,
|
||||
JSON.stringify(user.metadata ?? {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
async updateUser(user: AuthUser): Promise<void> {
|
||||
await this.exec(
|
||||
"UPDATE wrn_auth_users SET username=?,display_name=?,avatar_url=?,status=?,roles_json=?,email_verified=?,phone_verified=?,mfa_enabled=?,locale=?,timezone=?,updated_at=?,last_login_at=?,metadata_json=? WHERE id=?",
|
||||
[
|
||||
user.username,
|
||||
user.displayName,
|
||||
user.avatarUrl,
|
||||
user.status,
|
||||
JSON.stringify(user.roles),
|
||||
Number(user.emailVerified),
|
||||
Number(user.phoneVerified),
|
||||
Number(user.mfaEnabled),
|
||||
user.locale,
|
||||
user.timezone,
|
||||
user.updatedAt,
|
||||
user.lastLoginAt,
|
||||
JSON.stringify(user.metadata ?? {}),
|
||||
user.id,
|
||||
],
|
||||
);
|
||||
}
|
||||
private user(row: Row | undefined): AuthUser | undefined {
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
id: String(row.id),
|
||||
username: row.username ? String(row.username) : undefined,
|
||||
displayName: row.display_name ? String(row.display_name) : undefined,
|
||||
avatarUrl: row.avatar_url ? String(row.avatar_url) : undefined,
|
||||
status: String(row.status) as AuthUser["status"],
|
||||
roles: json(row.roles_json, []),
|
||||
emailVerified: bool(row.email_verified),
|
||||
phoneVerified: bool(row.phone_verified),
|
||||
mfaEnabled: bool(row.mfa_enabled),
|
||||
locale: row.locale ? String(row.locale) : undefined,
|
||||
timezone: row.timezone ? String(row.timezone) : undefined,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
lastLoginAt: row.last_login_at == null ? undefined : Number(row.last_login_at),
|
||||
metadata: json(row.metadata_json, {}),
|
||||
};
|
||||
}
|
||||
async findUserById(id: string): Promise<AuthUser | undefined> {
|
||||
return this.user(await this.one("SELECT * FROM wrn_auth_users WHERE id=?", [id]));
|
||||
}
|
||||
async listUsers(): Promise<AuthUser[]> {
|
||||
return (await this.all("SELECT * FROM wrn_auth_users ORDER BY created_at")).map((row) =>
|
||||
this.user(row)!,
|
||||
);
|
||||
}
|
||||
|
||||
async createIdentity(x: AuthIdentity): Promise<void> {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_identities (id,user_id,type,value,normalized_value,is_primary,verified_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
x.userId,
|
||||
x.type,
|
||||
x.value,
|
||||
x.normalizedValue,
|
||||
Number(x.primary),
|
||||
x.verifiedAt,
|
||||
x.createdAt,
|
||||
x.updatedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
async updateIdentity(x: AuthIdentity): Promise<void> {
|
||||
await this.exec(
|
||||
"UPDATE wrn_auth_identities SET value=?,normalized_value=?,is_primary=?,verified_at=?,updated_at=? WHERE id=?",
|
||||
[x.value, x.normalizedValue, Number(x.primary), x.verifiedAt, x.updatedAt, x.id],
|
||||
);
|
||||
}
|
||||
private identity(row: Row | undefined): AuthIdentity | undefined {
|
||||
if (!row) return;
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.user_id),
|
||||
type: String(row.type) as AuthIdentity["type"],
|
||||
value: String(row.value),
|
||||
normalizedValue: String(row.normalized_value),
|
||||
primary: bool(row.is_primary),
|
||||
verifiedAt: row.verified_at == null ? undefined : Number(row.verified_at),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
};
|
||||
}
|
||||
async findIdentity(type: AuthIdentity["type"], value: string) {
|
||||
return this.identity(
|
||||
await this.one("SELECT * FROM wrn_auth_identities WHERE type=? AND normalized_value=?", [
|
||||
type,
|
||||
value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
async listIdentities(userId: string) {
|
||||
return (
|
||||
await this.all(
|
||||
"SELECT * FROM wrn_auth_identities WHERE user_id=? ORDER BY is_primary DESC, created_at",
|
||||
[userId],
|
||||
)
|
||||
).map((r) => this.identity(r)!);
|
||||
}
|
||||
|
||||
async setPassword(x: PasswordCredential) {
|
||||
const current = await this.getPassword(x.userId);
|
||||
if (current)
|
||||
await this.exec(
|
||||
"UPDATE wrn_auth_password_credentials SET password_hash=?,password_version=?,changed_at=?,must_change=? WHERE user_id=?",
|
||||
[x.passwordHash, x.passwordVersion, x.changedAt, Number(x.mustChange), x.userId],
|
||||
);
|
||||
else
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_password_credentials (user_id,password_hash,password_version,changed_at,must_change) VALUES (?,?,?,?,?)",
|
||||
[x.userId, x.passwordHash, x.passwordVersion, x.changedAt, Number(x.mustChange)],
|
||||
);
|
||||
}
|
||||
async getPassword(userId: string) {
|
||||
const r = await this.one("SELECT * FROM wrn_auth_password_credentials WHERE user_id=?", [
|
||||
userId,
|
||||
]);
|
||||
return r
|
||||
? {
|
||||
userId: String(r.user_id),
|
||||
passwordHash: String(r.password_hash),
|
||||
passwordVersion: Number(r.password_version),
|
||||
changedAt: Number(r.changed_at),
|
||||
mustChange: bool(r.must_change),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async createSession(x: AuthSession) {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_sessions (id,user_id,device_id,created_at,last_seen_at,expires_at,absolute_expires_at,ip,user_agent,trusted,revoked_at,revoke_reason,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
x.userId,
|
||||
x.deviceId,
|
||||
x.createdAt,
|
||||
x.lastSeenAt,
|
||||
x.expiresAt,
|
||||
x.absoluteExpiresAt,
|
||||
x.ip,
|
||||
x.userAgent,
|
||||
Number(x.trusted),
|
||||
x.revokedAt,
|
||||
x.revokeReason,
|
||||
JSON.stringify(x.metadata ?? {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
async updateSession(x: AuthSession) {
|
||||
await this.exec(
|
||||
"UPDATE wrn_auth_sessions SET last_seen_at=?,expires_at=?,trusted=?,revoked_at=?,revoke_reason=?,metadata_json=? WHERE id=?",
|
||||
[
|
||||
x.lastSeenAt,
|
||||
x.expiresAt,
|
||||
Number(x.trusted),
|
||||
x.revokedAt,
|
||||
x.revokeReason,
|
||||
JSON.stringify(x.metadata ?? {}),
|
||||
x.id,
|
||||
],
|
||||
);
|
||||
}
|
||||
private session(r: Row | undefined): AuthSession | undefined {
|
||||
if (!r) return;
|
||||
return {
|
||||
id: String(r.id),
|
||||
userId: String(r.user_id),
|
||||
deviceId: String(r.device_id),
|
||||
createdAt: Number(r.created_at),
|
||||
lastSeenAt: Number(r.last_seen_at),
|
||||
expiresAt: Number(r.expires_at),
|
||||
absoluteExpiresAt: Number(r.absolute_expires_at),
|
||||
ip: r.ip ? String(r.ip) : undefined,
|
||||
userAgent: r.user_agent ? String(r.user_agent) : undefined,
|
||||
trusted: bool(r.trusted),
|
||||
revokedAt: r.revoked_at == null ? undefined : Number(r.revoked_at),
|
||||
revokeReason: r.revoke_reason ? String(r.revoke_reason) : undefined,
|
||||
metadata: json(r.metadata_json, {}),
|
||||
};
|
||||
}
|
||||
async findSession(id: string) {
|
||||
return this.session(await this.one("SELECT * FROM wrn_auth_sessions WHERE id=?", [id]));
|
||||
}
|
||||
async listSessions(userId: string) {
|
||||
return (
|
||||
await this.all("SELECT * FROM wrn_auth_sessions WHERE user_id=? ORDER BY last_seen_at DESC", [
|
||||
userId,
|
||||
])
|
||||
).map((r) => this.session(r)!);
|
||||
}
|
||||
async deleteSession(id: string) {
|
||||
await this.exec("DELETE FROM wrn_auth_sessions WHERE id=?", [id]);
|
||||
}
|
||||
|
||||
async createTrustedDevice(x: TrustedDevice) {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_trusted_devices (id,user_id,name,fingerprint_hash,created_at,last_seen_at,expires_at,revoked_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
x.userId,
|
||||
x.name,
|
||||
x.fingerprintHash,
|
||||
x.createdAt,
|
||||
x.lastSeenAt,
|
||||
x.expiresAt,
|
||||
x.revokedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
async updateTrustedDevice(x: TrustedDevice) {
|
||||
await this.exec(
|
||||
"UPDATE wrn_auth_trusted_devices SET name=?,last_seen_at=?,expires_at=?,revoked_at=? WHERE id=?",
|
||||
[x.name, x.lastSeenAt, x.expiresAt, x.revokedAt, x.id],
|
||||
);
|
||||
}
|
||||
private device(r: Row | undefined): TrustedDevice | undefined {
|
||||
if (!r) return;
|
||||
return {
|
||||
id: String(r.id),
|
||||
userId: String(r.user_id),
|
||||
name: String(r.name),
|
||||
fingerprintHash: String(r.fingerprint_hash),
|
||||
createdAt: Number(r.created_at),
|
||||
lastSeenAt: Number(r.last_seen_at),
|
||||
expiresAt: Number(r.expires_at),
|
||||
revokedAt: r.revoked_at == null ? undefined : Number(r.revoked_at),
|
||||
};
|
||||
}
|
||||
async findTrustedDeviceByFingerprint(userId: string, hash: string) {
|
||||
return this.device(
|
||||
await this.one(
|
||||
"SELECT * FROM wrn_auth_trusted_devices WHERE user_id=? AND fingerprint_hash=?",
|
||||
[userId, hash],
|
||||
),
|
||||
);
|
||||
}
|
||||
async listTrustedDevices(userId: string) {
|
||||
return (
|
||||
await this.all(
|
||||
"SELECT * FROM wrn_auth_trusted_devices WHERE user_id=? ORDER BY last_seen_at DESC",
|
||||
[userId],
|
||||
)
|
||||
).map((r) => this.device(r)!);
|
||||
}
|
||||
|
||||
async createToken(x: OneTimeToken) {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_tokens (id,user_id,purpose,token_hash,target,created_at,expires_at,used_at,attempts,max_attempts,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
x.userId,
|
||||
x.purpose,
|
||||
x.tokenHash,
|
||||
x.target,
|
||||
x.createdAt,
|
||||
x.expiresAt,
|
||||
x.usedAt,
|
||||
x.attempts,
|
||||
x.maxAttempts,
|
||||
JSON.stringify(x.metadata ?? {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
async updateToken(x: OneTimeToken) {
|
||||
await this.exec("UPDATE wrn_auth_tokens SET used_at=?,attempts=?,metadata_json=? WHERE id=?", [
|
||||
x.usedAt,
|
||||
x.attempts,
|
||||
JSON.stringify(x.metadata ?? {}),
|
||||
x.id,
|
||||
]);
|
||||
}
|
||||
private token(r: Row | undefined): OneTimeToken | undefined {
|
||||
if (!r) return;
|
||||
return {
|
||||
id: String(r.id),
|
||||
userId: String(r.user_id),
|
||||
purpose: String(r.purpose) as OneTimeToken["purpose"],
|
||||
tokenHash: String(r.token_hash),
|
||||
target: r.target ? String(r.target) : undefined,
|
||||
createdAt: Number(r.created_at),
|
||||
expiresAt: Number(r.expires_at),
|
||||
usedAt: r.used_at == null ? undefined : Number(r.used_at),
|
||||
attempts: Number(r.attempts),
|
||||
maxAttempts: Number(r.max_attempts),
|
||||
metadata: json(r.metadata_json, {}),
|
||||
};
|
||||
}
|
||||
async findTokenByHash(hash: string) {
|
||||
return this.token(await this.one("SELECT * FROM wrn_auth_tokens WHERE token_hash=?", [hash]));
|
||||
}
|
||||
|
||||
async createOtp(x: OtpChallenge) {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_otp_challenges (id,user_id,method,purpose,destination,code_hash,created_at,expires_at,used_at,attempts,max_attempts) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
x.userId,
|
||||
x.method,
|
||||
x.purpose,
|
||||
x.destination,
|
||||
x.codeHash,
|
||||
x.createdAt,
|
||||
x.expiresAt,
|
||||
x.usedAt,
|
||||
x.attempts,
|
||||
x.maxAttempts,
|
||||
],
|
||||
);
|
||||
}
|
||||
async updateOtp(x: OtpChallenge) {
|
||||
await this.exec("UPDATE wrn_auth_otp_challenges SET used_at=?,attempts=? WHERE id=?", [
|
||||
x.usedAt,
|
||||
x.attempts,
|
||||
x.id,
|
||||
]);
|
||||
}
|
||||
async findOtp(id: string) {
|
||||
const r = await this.one("SELECT * FROM wrn_auth_otp_challenges WHERE id=?", [id]);
|
||||
return r
|
||||
? {
|
||||
id: String(r.id),
|
||||
userId: String(r.user_id),
|
||||
method: String(r.method) as OtpChallenge["method"],
|
||||
purpose: String(r.purpose ?? "verification") as OtpChallenge["purpose"],
|
||||
destination: String(r.destination),
|
||||
codeHash: String(r.code_hash),
|
||||
createdAt: Number(r.created_at),
|
||||
expiresAt: Number(r.expires_at),
|
||||
usedAt: r.used_at == null ? undefined : Number(r.used_at),
|
||||
attempts: Number(r.attempts),
|
||||
maxAttempts: Number(r.max_attempts),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async createTotp(x: TotpCredential) {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_totp_credentials (id,user_id,label,secret,created_at,verified_at,last_counter) VALUES (?,?,?,?,?,?,?)",
|
||||
[x.id, x.userId, x.label, x.secret, x.createdAt, x.verifiedAt, x.lastCounter],
|
||||
);
|
||||
}
|
||||
async updateTotp(x: TotpCredential) {
|
||||
await this.exec(
|
||||
"UPDATE wrn_auth_totp_credentials SET label=?,verified_at=?,last_counter=? WHERE id=?",
|
||||
[x.label, x.verifiedAt, x.lastCounter, x.id],
|
||||
);
|
||||
}
|
||||
private totpRow(r: Row): TotpCredential {
|
||||
return {
|
||||
id: String(r.id),
|
||||
userId: String(r.user_id),
|
||||
label: String(r.label),
|
||||
secret: String(r.secret),
|
||||
createdAt: Number(r.created_at),
|
||||
verifiedAt: r.verified_at == null ? undefined : Number(r.verified_at),
|
||||
lastCounter: r.last_counter == null ? undefined : Number(r.last_counter),
|
||||
};
|
||||
}
|
||||
async listTotp(userId: string) {
|
||||
return (
|
||||
await this.all("SELECT * FROM wrn_auth_totp_credentials WHERE user_id=?", [userId])
|
||||
).map((r) => this.totpRow(r));
|
||||
}
|
||||
async deleteTotp(id: string) {
|
||||
await this.exec("DELETE FROM wrn_auth_totp_credentials WHERE id=?", [id]);
|
||||
}
|
||||
|
||||
async createRecoveryCodes(codes: RecoveryCodeRecord[]) {
|
||||
for (const x of codes)
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_recovery_codes (id,user_id,code_hash,created_at,used_at) VALUES (?,?,?,?,?)",
|
||||
[x.id, x.userId, x.codeHash, x.createdAt, x.usedAt],
|
||||
);
|
||||
}
|
||||
async updateRecoveryCode(x: RecoveryCodeRecord) {
|
||||
await this.exec("UPDATE wrn_auth_recovery_codes SET used_at=? WHERE id=?", [x.usedAt, x.id]);
|
||||
}
|
||||
async listRecoveryCodes(userId: string) {
|
||||
return (await this.all("SELECT * FROM wrn_auth_recovery_codes WHERE user_id=?", [userId])).map(
|
||||
(r) => ({
|
||||
id: String(r.id),
|
||||
userId: String(r.user_id),
|
||||
codeHash: String(r.code_hash),
|
||||
createdAt: Number(r.created_at),
|
||||
usedAt: r.used_at == null ? undefined : Number(r.used_at),
|
||||
}),
|
||||
);
|
||||
}
|
||||
async deleteRecoveryCodes(userId: string) {
|
||||
await this.exec("DELETE FROM wrn_auth_recovery_codes WHERE user_id=?", [userId]);
|
||||
}
|
||||
|
||||
async createPasskey(x: PasskeyCredential) {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_passkeys (id,user_id,credential_id,public_key,counter,transports_json,name,created_at,last_used_at,backed_up,device_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
x.userId,
|
||||
x.credentialId,
|
||||
x.publicKey,
|
||||
x.counter,
|
||||
JSON.stringify(x.transports),
|
||||
x.name,
|
||||
x.createdAt,
|
||||
x.lastUsedAt,
|
||||
x.backedUp == null ? undefined : Number(x.backedUp),
|
||||
x.deviceType,
|
||||
],
|
||||
);
|
||||
}
|
||||
async updatePasskey(x: PasskeyCredential) {
|
||||
await this.exec(
|
||||
"UPDATE wrn_auth_passkeys SET counter=?,transports_json=?,name=?,last_used_at=?,backed_up=?,device_type=? WHERE id=?",
|
||||
[
|
||||
x.counter,
|
||||
JSON.stringify(x.transports),
|
||||
x.name,
|
||||
x.lastUsedAt,
|
||||
x.backedUp == null ? undefined : Number(x.backedUp),
|
||||
x.deviceType,
|
||||
x.id,
|
||||
],
|
||||
);
|
||||
}
|
||||
private passkey(r: Row | undefined): PasskeyCredential | undefined {
|
||||
if (!r) return;
|
||||
return {
|
||||
id: String(r.id),
|
||||
userId: String(r.user_id),
|
||||
credentialId: String(r.credential_id),
|
||||
publicKey: String(r.public_key),
|
||||
counter: Number(r.counter),
|
||||
transports: json(r.transports_json, []),
|
||||
name: String(r.name),
|
||||
createdAt: Number(r.created_at),
|
||||
lastUsedAt: r.last_used_at == null ? undefined : Number(r.last_used_at),
|
||||
backedUp: r.backed_up == null ? undefined : bool(r.backed_up),
|
||||
deviceType: r.device_type ? String(r.device_type) : undefined,
|
||||
};
|
||||
}
|
||||
async findPasskeyByCredentialId(id: string) {
|
||||
return this.passkey(
|
||||
await this.one("SELECT * FROM wrn_auth_passkeys WHERE credential_id=?", [id]),
|
||||
);
|
||||
}
|
||||
async listPasskeys(userId: string) {
|
||||
return (await this.all("SELECT * FROM wrn_auth_passkeys WHERE user_id=?", [userId])).map((r) =>
|
||||
this.passkey(r)!,
|
||||
);
|
||||
}
|
||||
async deletePasskey(id: string) {
|
||||
await this.exec("DELETE FROM wrn_auth_passkeys WHERE id=?", [id]);
|
||||
}
|
||||
|
||||
async createOAuthAccount(x: OAuthAccount) {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_oauth_accounts (id,user_id,provider,provider_account_id,email,access_token,refresh_token,token_expires_at,scope,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
x.userId,
|
||||
x.provider,
|
||||
x.providerAccountId,
|
||||
x.email,
|
||||
x.accessToken,
|
||||
x.refreshToken,
|
||||
x.tokenExpiresAt,
|
||||
x.scope,
|
||||
x.createdAt,
|
||||
x.updatedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
async updateOAuthAccount(x: OAuthAccount) {
|
||||
await this.exec(
|
||||
"UPDATE wrn_auth_oauth_accounts SET email=?,access_token=?,refresh_token=?,token_expires_at=?,scope=?,updated_at=? WHERE id=?",
|
||||
[x.email, x.accessToken, x.refreshToken, x.tokenExpiresAt, x.scope, x.updatedAt, x.id],
|
||||
);
|
||||
}
|
||||
private oauth(r: Row | undefined): OAuthAccount | undefined {
|
||||
if (!r) return;
|
||||
return {
|
||||
id: String(r.id),
|
||||
userId: String(r.user_id),
|
||||
provider: String(r.provider),
|
||||
providerAccountId: String(r.provider_account_id),
|
||||
email: r.email ? String(r.email) : undefined,
|
||||
accessToken: r.access_token ? String(r.access_token) : undefined,
|
||||
refreshToken: r.refresh_token ? String(r.refresh_token) : undefined,
|
||||
tokenExpiresAt: r.token_expires_at == null ? undefined : Number(r.token_expires_at),
|
||||
scope: r.scope ? String(r.scope) : undefined,
|
||||
createdAt: Number(r.created_at),
|
||||
updatedAt: Number(r.updated_at),
|
||||
};
|
||||
}
|
||||
async findOAuthAccount(provider: string, id: string) {
|
||||
return this.oauth(
|
||||
await this.one(
|
||||
"SELECT * FROM wrn_auth_oauth_accounts WHERE provider=? AND provider_account_id=?",
|
||||
[provider, id],
|
||||
),
|
||||
);
|
||||
}
|
||||
async listOAuthAccounts(userId: string) {
|
||||
return (await this.all("SELECT * FROM wrn_auth_oauth_accounts WHERE user_id=?", [userId])).map(
|
||||
(r) => this.oauth(r)!,
|
||||
);
|
||||
}
|
||||
async deleteOAuthAccount(id: string) {
|
||||
await this.exec("DELETE FROM wrn_auth_oauth_accounts WHERE id=?", [id]);
|
||||
}
|
||||
|
||||
async createLoginAttempt(x: LoginAttempt) {
|
||||
const identifier = x.identifier
|
||||
? normalizeIdentity(inferIdentityType(x.identifier), x.identifier)
|
||||
: undefined;
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_login_attempts (id,identifier,user_id,success,reason,ip,user_agent,created_at,risk_score,risk_level) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
identifier,
|
||||
x.userId,
|
||||
Number(x.success),
|
||||
x.reason,
|
||||
x.ip,
|
||||
x.userAgent,
|
||||
x.createdAt,
|
||||
x.riskScore,
|
||||
x.riskLevel,
|
||||
],
|
||||
);
|
||||
}
|
||||
async listRecentLoginAttempts(identifier: string, since: number) {
|
||||
const normalized = normalizeIdentity(inferIdentityType(identifier), identifier);
|
||||
const rows = await this.all(
|
||||
"SELECT * FROM wrn_auth_login_attempts WHERE identifier=? AND created_at>=? ORDER BY created_at DESC",
|
||||
[normalized, since],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
identifier: r.identifier ? String(r.identifier) : undefined,
|
||||
userId: r.user_id ? String(r.user_id) : undefined,
|
||||
success: bool(r.success),
|
||||
reason: r.reason ? String(r.reason) : undefined,
|
||||
ip: r.ip ? String(r.ip) : undefined,
|
||||
userAgent: r.user_agent ? String(r.user_agent) : undefined,
|
||||
createdAt: Number(r.created_at),
|
||||
riskScore: Number(r.risk_score),
|
||||
riskLevel: String(r.risk_level) as LoginAttempt["riskLevel"],
|
||||
}));
|
||||
}
|
||||
|
||||
async createSecurityEvent(x: AuthSecurityEvent) {
|
||||
await this.exec(
|
||||
"INSERT INTO wrn_auth_security_events (id,user_id,type,severity,actor_user_id,session_id,ip,user_agent,created_at,data_json) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
x.id,
|
||||
x.userId,
|
||||
x.type,
|
||||
x.severity,
|
||||
x.actorUserId,
|
||||
x.sessionId,
|
||||
x.ip,
|
||||
x.userAgent,
|
||||
x.createdAt,
|
||||
JSON.stringify(x.data ?? {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
async listSecurityEvents(userId: string, limit = 100) {
|
||||
const rows = await this.all(
|
||||
"SELECT * FROM wrn_auth_security_events WHERE user_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
[userId, limit],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
userId: r.user_id ? String(r.user_id) : undefined,
|
||||
type: String(r.type),
|
||||
severity: String(r.severity) as AuthSecurityEvent["severity"],
|
||||
actorUserId: r.actor_user_id ? String(r.actor_user_id) : undefined,
|
||||
sessionId: r.session_id ? String(r.session_id) : undefined,
|
||||
ip: r.ip ? String(r.ip) : undefined,
|
||||
userAgent: r.user_agent ? String(r.user_agent) : undefined,
|
||||
createdAt: Number(r.created_at),
|
||||
data: json(r.data_json, {}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user