This commit is contained in:
2026-09-03 11:28:30 +07:00 Unverified
parent 26c2d0f015
commit 82d0fe3e0d
93 changed files with 19237 additions and 1285 deletions
+204
View File
@@ -0,0 +1,204 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { isRole, type Role } from "./authorization";
export type KuberUser = {
username: string;
passwordHash: string;
roles: Role[];
authVersion: number;
disabled?: boolean;
};
export type SessionRecord = {
tokenHash: string;
username: string;
authVersion: number;
expiresAt: string;
};
export type SessionInput =
| SessionRecord
| {
tokenHash: string;
username: string;
roles: string[];
expiresAt: string;
};
export type NewKuberUser = Omit<KuberUser, "authVersion"> & {
authVersion?: number;
};
export type UserUpdate = Partial<
Pick<KuberUser, "passwordHash" | "roles" | "disabled">
>;
export interface AuthStore {
getUser(username: string): Promise<KuberUser | undefined>;
listUsers(): Promise<KuberUser[]>;
putUser(user: NewKuberUser | KuberUser): Promise<void>;
createUser(user: NewKuberUser): Promise<KuberUser>;
updateUser(
username: string,
update: UserUpdate,
): Promise<KuberUser | undefined>;
deleteUser(username: string): Promise<boolean>;
getSession(tokenHash: string): Promise<SessionRecord | undefined>;
putSession(session: SessionInput): Promise<void>;
deleteSession(tokenHash: string): Promise<void>;
revokeUserSessions(username: string): Promise<number>;
listExpiredSessions(now?: number): Promise<SessionRecord[]>;
deleteExpiredSessions(now?: number): Promise<number>;
}
export function normalizeUser(user: NewKuberUser | KuberUser): KuberUser {
if (!user.username || user.username !== user.username.trim())
throw new Error("Username must be a non-empty trimmed string");
if (!user.passwordHash) throw new Error("Password hash is required");
if (
!Array.isArray(user.roles) ||
user.roles.length === 0 ||
new Set(user.roles).size !== user.roles.length ||
!user.roles.every(isRole)
) {
throw new Error("At least one unique valid role is required");
}
const authVersion = user.authVersion ?? 1;
if (!Number.isSafeInteger(authVersion) || authVersion < 1)
throw new Error("Auth version must be a positive integer");
return { ...user, roles: [...user.roles], authVersion };
}
export function normalizeSession(session: SessionRecord): SessionRecord {
if (!/^[a-f0-9]{64}$/.test(session.tokenHash))
throw new Error("Session token hash must be a SHA-256 hex digest");
if (!session.username) throw new Error("Session username is required");
if (!Number.isSafeInteger(session.authVersion) || session.authVersion < 1)
throw new Error("Session auth version must be a positive integer");
const expiresAt = new Date(session.expiresAt);
if (
!Number.isFinite(expiresAt.getTime()) ||
expiresAt.toISOString() !== session.expiresAt
) {
throw new Error("Session expiration must be an ISO timestamp");
}
return { ...session };
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function createToken(): string {
return randomBytes(32).toString("base64url");
}
export function tokenHashesEqual(left: string, right: string): boolean {
if (!/^[a-f0-9]{64}$/.test(left) || !/^[a-f0-9]{64}$/.test(right))
return false;
const leftBuffer = Buffer.from(left, "hex");
const rightBuffer = Buffer.from(right, "hex");
return (
leftBuffer.length === rightBuffer.length &&
timingSafeEqual(leftBuffer, rightBuffer)
);
}
export class MemoryAuthStore implements AuthStore {
readonly users = new Map<string, KuberUser>();
readonly sessions = new Map<string, SessionRecord>();
async getUser(username: string): Promise<KuberUser | undefined> {
return this.users.get(username);
}
async listUsers(): Promise<KuberUser[]> {
return [...this.users.values()].sort((a, b) =>
a.username.localeCompare(b.username),
);
}
async putUser(user: NewKuberUser | KuberUser): Promise<void> {
const normalized = normalizeUser(user);
this.users.set(normalized.username, normalized);
}
async createUser(user: NewKuberUser): Promise<KuberUser> {
if (this.users.has(user.username)) throw new Error("User already exists");
const normalized = normalizeUser(user);
this.users.set(normalized.username, normalized);
return normalized;
}
async updateUser(
username: string,
update: UserUpdate,
): Promise<KuberUser | undefined> {
const existing = this.users.get(username);
if (!existing) return;
const updated = normalizeUser({
...existing,
...update,
username,
authVersion: existing.authVersion + 1,
});
this.users.set(username, updated);
return updated;
}
async deleteUser(username: string): Promise<boolean> {
await this.revokeUserSessions(username);
return this.users.delete(username);
}
async getSession(tokenHash: string): Promise<SessionRecord | undefined> {
const session = this.sessions.get(tokenHash);
if (!session) return;
const user = this.users.get(session.username);
if (!user || user.disabled || user.authVersion !== session.authVersion)
return;
return session;
}
async putSession(session: SessionInput): Promise<void> {
const user = this.users.get(session.username);
if (!user || user.disabled) throw new Error("Session user is not active");
const authVersion =
"authVersion" in session ? session.authVersion : user.authVersion;
if (authVersion !== user.authVersion)
throw new Error("Session auth version is stale");
const normalized = normalizeSession({
tokenHash: session.tokenHash,
username: session.username,
authVersion,
expiresAt: session.expiresAt,
});
this.sessions.set(normalized.tokenHash, normalized);
}
async deleteSession(tokenHash: string): Promise<void> {
this.sessions.delete(tokenHash);
}
async revokeUserSessions(username: string): Promise<number> {
let deleted = 0;
for (const [tokenHash, session] of this.sessions) {
if (session.username !== username) continue;
this.sessions.delete(tokenHash);
deleted += 1;
}
return deleted;
}
async listExpiredSessions(now = Date.now()): Promise<SessionRecord[]> {
return [...this.sessions.values()].filter(
(session) => Date.parse(session.expiresAt) <= now,
);
}
async deleteExpiredSessions(now = Date.now()): Promise<number> {
const expired = await this.listExpiredSessions(now);
for (const session of expired) this.sessions.delete(session.tokenHash);
return expired.length;
}
}