Files
minecraft-account-manager/packages/database/src/auth-repository.ts
T
dmg b88097c15a
CI / validate (push) Successful in 5m13s
Release / release (push) Successful in 7m8s
feat(groups): add fail-closed admission management
2026-08-01 18:36:45 -04:00

134 lines
4.2 KiB
TypeScript

import { LoginRateLimitedError, type AuthRepository } from "@minecraft-account-manager/auth";
import { and, desc, eq, gt, isNull, lt, sql } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import * as schema from "./schema";
import { loginCodes, sessions, users } from "./schema";
type Database = PostgresJsDatabase<typeof schema>;
export function createAuthRepository(db: Database): AuthRepository {
return {
async saveLoginCode(code) {
await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${code.discordUserId}))`);
const [latestCode] = await tx
.select({ createdAt: loginCodes.createdAt })
.from(loginCodes)
.where(eq(loginCodes.discordUserId, code.discordUserId))
.orderBy(desc(loginCodes.createdAt))
.limit(1);
if (latestCode && latestCode.createdAt > new Date(code.createdAt.getTime() - 30_000)) {
throw new LoginRateLimitedError();
}
await tx.delete(loginCodes).where(lt(loginCodes.expiresAt, code.createdAt));
await tx
.update(loginCodes)
.set({ consumedAt: code.createdAt })
.where(
and(
eq(loginCodes.discordUserId, code.discordUserId),
isNull(loginCodes.consumedAt),
),
);
await tx.insert(loginCodes).values({
tokenHash: code.tokenHash,
discordUserId: code.discordUserId,
discordUsername: code.discordUsername,
discordGlobalName: code.discordGlobalName,
expiresAt: code.expiresAt,
createdAt: code.createdAt,
});
});
},
async exchangeLoginCode(input) {
return db.transaction(async (tx) => {
const [loginCode] = await tx
.update(loginCodes)
.set({ consumedAt: input.now })
.where(
and(
eq(loginCodes.tokenHash, input.loginCodeHash),
isNull(loginCodes.consumedAt),
gt(loginCodes.expiresAt, input.now),
),
)
.returning();
if (!loginCode) {
return null;
}
const [existingUser] = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.discordUserId, loginCode.discordUserId))
.limit(1);
const [user] = await tx
.insert(users)
.values({
discordUserId: loginCode.discordUserId,
discordUsername: loginCode.discordUsername,
discordGlobalName: loginCode.discordGlobalName,
})
.onConflictDoUpdate({
target: users.discordUserId,
set: {
discordUsername: loginCode.discordUsername,
discordGlobalName: loginCode.discordGlobalName,
updatedAt: input.now,
},
})
.returning({
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
firstName: users.firstName,
});
if (!user) {
throw new Error("Failed to create or update the Discord user");
}
await tx.insert(sessions).values({
userId: user.id,
tokenHash: input.sessionTokenHash,
expiresAt: input.sessionExpiresAt,
lastSeenAt: input.now,
createdAt: input.now,
});
return { user, isNewUser: !existingUser };
});
},
};
}
export async function findUserBySessionToken(db: Database, tokenHash: string, now = new Date()) {
const [user] = await db
.select({
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
discordGlobalName: users.discordGlobalName,
firstName: users.firstName,
onboardingCompletedAt: users.onboardingCompletedAt,
sessionExpiresAt: sessions.expiresAt,
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(
and(
eq(sessions.tokenHash, tokenHash),
isNull(sessions.revokedAt),
gt(sessions.expiresAt, now),
),
)
.limit(1);
return user ?? null;
}