318 lines
10 KiB
TypeScript
318 lines
10 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>();
|
|
readonly apiKeysByTokenHash = new Map<string, ApiKeyRecord>();
|
|
|
|
private storeApiKey(key: ApiKeyRecord): void {
|
|
this.apiKeys.set(key.id, key);
|
|
this.apiKeysByTokenHash.set(key.tokenHash, key);
|
|
}
|
|
|
|
private deleteApiKey(id: string): void {
|
|
const key = this.apiKeys.get(id);
|
|
if (!key) return;
|
|
this.apiKeys.delete(id);
|
|
if (this.apiKeysByTokenHash.get(key.tokenHash) === key)
|
|
this.apiKeysByTokenHash.delete(key.tokenHash);
|
|
}
|
|
|
|
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.deleteApiKey(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.apiKeysByTokenHash.get(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.storeApiKey(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.deleteApiKey(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.deleteApiKey(key.id);
|
|
return expired.length;
|
|
}
|
|
}
|