Files
kuber/server/auth.ts
T

306 lines
9.7 KiB
TypeScript

import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import {
isCapability,
isRole,
type Capability,
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 ApiKeyRecord = {
id: string;
tokenHash: string;
username: string;
capabilities: Capability[];
workspace?: string;
expiresAt: string;
disabled?: boolean;
};
export type NewApiKey = ApiKeyRecord;
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>;
getApiKey(tokenHash: string): Promise<ApiKeyRecord | undefined>;
createApiKey(key: NewApiKey): Promise<void>;
listApiKeys(username: string): Promise<ApiKeyRecord[]>;
revokeApiKey(username: string, id: string): Promise<boolean>;
deleteExpiredApiKeys(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 normalizeApiKey(key: NewApiKey): ApiKeyRecord {
if (!/^[a-zA-Z0-9_-]{16,128}$/.test(key.id))
throw new Error("API key ID is invalid");
if (!/^[a-f0-9]{64}$/.test(key.tokenHash))
throw new Error("API key token hash must be a SHA-256 hex digest");
if (!key.username || key.username !== key.username.trim())
throw new Error("API key username is required");
if (
!Array.isArray(key.capabilities) ||
key.capabilities.length === 0 ||
new Set(key.capabilities).size !== key.capabilities.length ||
!key.capabilities.every(isCapability)
) {
throw new Error("API key requires unique valid capabilities");
}
if (
key.workspace !== undefined &&
(!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(key.workspace) ||
key.workspace.length > 63)
) {
throw new Error("API key workspace scope is invalid");
}
const expiresAt = new Date(key.expiresAt);
if (
!Number.isFinite(expiresAt.getTime()) ||
expiresAt.toISOString() !== key.expiresAt
) {
throw new Error("API key expiration must be an ISO timestamp");
}
return {
...key,
capabilities: [...key.capabilities],
disabled: Boolean(key.disabled),
};
}
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>();
readonly apiKeys = new Map<string, ApiKeyRecord>();
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);
for (const [id, key] of this.apiKeys)
if (key.username === username) this.apiKeys.delete(id);
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;
}
async getApiKey(tokenHash: string): Promise<ApiKeyRecord | undefined> {
const key = [...this.apiKeys.values()].find(
(item) => item.tokenHash === tokenHash,
);
if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return;
const user = this.users.get(key.username);
if (!user || user.disabled) return;
return key;
}
async createApiKey(key: NewApiKey): Promise<void> {
const normalized = normalizeApiKey(key);
const user = this.users.get(normalized.username);
if (!user || user.disabled) throw new Error("API key user is not active");
if (this.apiKeys.has(normalized.id))
throw new Error("API key already exists");
this.apiKeys.set(normalized.id, normalized);
}
async listApiKeys(username: string): Promise<ApiKeyRecord[]> {
return [...this.apiKeys.values()]
.filter((key) => key.username === username)
.sort((left, right) => left.id.localeCompare(right.id));
}
async revokeApiKey(username: string, id: string): Promise<boolean> {
const key = this.apiKeys.get(id);
if (!key || key.username !== username) return false;
this.apiKeys.delete(id);
return true;
}
async deleteExpiredApiKeys(now = Date.now()): Promise<number> {
const expired = [...this.apiKeys.values()].filter(
(key) => Date.parse(key.expiresAt) <= now,
);
for (const key of expired) this.apiKeys.delete(key.id);
return expired.length;
}
}