205 lines
6.4 KiB
TypeScript
205 lines
6.4 KiB
TypeScript
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;
|
|
}
|
|
}
|