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
+381
View File
@@ -0,0 +1,381 @@
import {
KubeConfig,
KubernetesObjectApi,
PatchStrategy,
type KubernetesObject,
} from "@kubernetes/client-node";
import { createHash } from "node:crypto";
import { createKubernetesHttpLibrary } from "../lib/k8s-http";
import {
normalizeSession,
normalizeUser,
type AuthStore,
type KuberUser,
type NewKuberUser,
type SessionInput,
type SessionRecord,
type UserUpdate,
} from "./auth";
import { isRole } from "./authorization";
const FIELD_MANAGER = "kuber-server";
export const KUBER_SYSTEM_NAMESPACE = "kuber-system";
type SecretObject = KubernetesObject & {
data?: Record<string, string>;
type?: string;
};
function objectName(prefix: string, value: string): string {
const digest = createHash("sha256").update(value).digest("hex").slice(0, 48);
return `${prefix}-${digest}`;
}
function decode(value: unknown): string | undefined {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
value,
)
) {
return;
}
const decoded = Buffer.from(value, "base64");
if (decoded.toString("base64") !== value) return;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(decoded);
} catch {
return;
}
}
function hasOnlyKeys(
data: Record<string, string>,
keys: readonly string[],
): boolean {
const actual = Object.keys(data).sort();
const expected = [...keys].sort();
return (
actual.length === expected.length &&
actual.every((key, index) => key === expected[index])
);
}
function parseRoles(value: unknown): KuberUser["roles"] | undefined {
const decoded = decode(value);
if (!decoded) return;
try {
const roles: unknown = JSON.parse(decoded);
if (
!Array.isArray(roles) ||
roles.length === 0 ||
new Set(roles).size !== roles.length ||
!roles.every(isRole)
)
return;
return roles;
} catch {
return;
}
}
function isSecret(secret: SecretObject, type: "user" | "session"): boolean {
return (
secret.apiVersion === "v1" &&
secret.kind === "Secret" &&
secret.type === "Opaque" &&
secret.metadata?.namespace === KUBER_SYSTEM_NAMESPACE &&
secret.metadata.labels?.["kuber.astrxl.dev/type"] === type &&
Boolean(secret.data) &&
typeof secret.data === "object" &&
!Array.isArray(secret.data)
);
}
function parseUser(
secret: SecretObject,
expectedUsername?: string,
): KuberUser | undefined {
if (!isSecret(secret, "user")) return;
const userKeys =
secret.data?.authVersion === undefined
? ["username", "passwordHash", "roles", "disabled"]
: ["username", "passwordHash", "roles", "authVersion", "disabled"];
if (!secret.data || !hasOnlyKeys(secret.data, userKeys)) return;
const username = decode(secret.data?.username);
const passwordHash = decode(secret.data?.passwordHash);
const roles = parseRoles(secret.data?.roles);
const disabled = decode(secret.data?.disabled);
const encodedAuthVersion = secret.data?.authVersion;
const authVersion =
encodedAuthVersion === undefined ? 1 : Number(decode(encodedAuthVersion));
if (
!username ||
username !== username.trim() ||
(expectedUsername !== undefined && username !== expectedUsername) ||
secret.metadata?.name !== objectName("user", username) ||
!passwordHash ||
!roles ||
(disabled !== "true" && disabled !== "false") ||
!Number.isSafeInteger(authVersion) ||
authVersion < 1
)
return;
return {
username,
passwordHash,
roles,
disabled: disabled === "true",
authVersion,
};
}
function parseSession(secret: SecretObject): SessionRecord | undefined {
if (!isSecret(secret, "session")) return;
const sessionKeys =
secret.data?.authVersion === undefined
? ["tokenHash", "username", "roles", "expiresAt"]
: ["tokenHash", "username", "authVersion", "expiresAt"];
if (!secret.data || !hasOnlyKeys(secret.data, sessionKeys)) return;
const tokenHash = decode(secret.data?.tokenHash);
const username = decode(secret.data?.username);
const expiresAt = decode(secret.data?.expiresAt);
const encodedAuthVersion = secret.data?.authVersion;
const authVersion =
encodedAuthVersion === undefined ? 1 : Number(decode(encodedAuthVersion));
if (
!tokenHash ||
!username ||
!expiresAt ||
secret.metadata?.name !== objectName("session", tokenHash) ||
(encodedAuthVersion === undefined && !parseRoles(secret.data?.roles))
)
return;
try {
return normalizeSession({ tokenHash, username, authVersion, expiresAt });
} catch {
return;
}
}
function isNotFound(error: unknown): boolean {
return Boolean(
error && typeof error === "object" && "code" in error && error.code === 404,
);
}
function createObjectApi(): KubernetesObjectApi {
const config = new KubeConfig();
if (process.env.KUBERNETES_SERVICE_HOST) config.loadFromCluster();
else config.loadFromDefault();
const makeApiClient = config.makeApiClient.bind(config);
const httpLibrary = createKubernetesHttpLibrary();
config.makeApiClient = ((apiClientType) => {
const client = makeApiClient(apiClientType) as unknown as {
api?: { configuration?: { httpApi?: typeof httpLibrary } };
configuration?: { httpApi?: typeof httpLibrary };
};
if (client.api?.configuration)
client.api.configuration.httpApi = httpLibrary;
if (client.configuration) client.configuration.httpApi = httpLibrary;
return client;
}) as typeof config.makeApiClient;
return KubernetesObjectApi.makeApiClient(config);
}
export class KubernetesAuthStore implements AuthStore {
constructor(private readonly objects = createObjectApi()) {}
private async readSecret(name: string): Promise<SecretObject | undefined> {
try {
return (await this.objects.read({
apiVersion: "v1",
kind: "Secret",
metadata: { name, namespace: KUBER_SYSTEM_NAMESPACE },
})) as SecretObject;
} catch (error) {
if (isNotFound(error)) return;
throw error;
}
}
private async applySecret(
name: string,
type: "user" | "session",
stringData: Record<string, string>,
): Promise<void> {
await this.objects.patch(
{
apiVersion: "v1",
kind: "Secret",
metadata: {
name,
namespace: KUBER_SYSTEM_NAMESPACE,
labels: { "kuber.astrxl.dev/type": type },
},
type: "Opaque",
stringData,
} as KubernetesObject,
undefined,
undefined,
FIELD_MANAGER,
true,
PatchStrategy.ServerSideApply,
);
}
private async listSecrets(type: "user" | "session"): Promise<SecretObject[]> {
const result = await this.objects.list(
"v1",
"Secret",
KUBER_SYSTEM_NAMESPACE,
undefined,
undefined,
undefined,
undefined,
`kuber.astrxl.dev/type=${type}`,
);
return result.items.map((item) => ({
...item,
apiVersion: item.apiVersion ?? "v1",
kind: item.kind ?? "Secret",
})) as SecretObject[];
}
private async deleteSecret(name: string): Promise<boolean> {
try {
await this.objects.delete({
apiVersion: "v1",
kind: "Secret",
metadata: { name, namespace: KUBER_SYSTEM_NAMESPACE },
});
return true;
} catch (error) {
if (isNotFound(error)) return false;
throw error;
}
}
async getUser(username: string): Promise<KuberUser | undefined> {
const secret = await this.readSecret(objectName("user", username));
return secret ? parseUser(secret, username) : undefined;
}
async listUsers(): Promise<KuberUser[]> {
return (await this.listSecrets("user"))
.map((secret) => parseUser(secret))
.filter((user): user is KuberUser => Boolean(user))
.sort((a, b) => a.username.localeCompare(b.username));
}
async putUser(user: NewKuberUser | KuberUser): Promise<void> {
const normalized = normalizeUser(user);
await this.applySecret(objectName("user", normalized.username), "user", {
username: normalized.username,
passwordHash: normalized.passwordHash,
roles: JSON.stringify(normalized.roles),
authVersion: String(normalized.authVersion),
disabled: String(Boolean(normalized.disabled)),
});
}
async createUser(user: NewKuberUser): Promise<KuberUser> {
if (await this.getUser(user.username))
throw new Error("User already exists");
const normalized = normalizeUser(user);
await this.putUser(normalized);
return normalized;
}
async updateUser(
username: string,
update: UserUpdate,
): Promise<KuberUser | undefined> {
const existing = await this.getUser(username);
if (!existing) return;
const updated = normalizeUser({
...existing,
...update,
username,
authVersion: existing.authVersion + 1,
});
await this.putUser(updated);
return updated;
}
async deleteUser(username: string): Promise<boolean> {
await this.revokeUserSessions(username);
return this.deleteSecret(objectName("user", username));
}
async getSession(tokenHash: string): Promise<SessionRecord | undefined> {
const secret = await this.readSecret(objectName("session", tokenHash));
if (!secret) return;
const session = parseSession(secret);
if (!session || session.tokenHash !== tokenHash) return;
const user = await this.getUser(session.username);
if (!user || user.disabled || user.authVersion !== session.authVersion)
return;
return session;
}
async putSession(session: SessionInput): Promise<void> {
const user = await this.getUser(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,
});
await this.applySecret(
objectName("session", normalized.tokenHash),
"session",
{
tokenHash: normalized.tokenHash,
username: normalized.username,
authVersion: String(normalized.authVersion),
expiresAt: normalized.expiresAt,
},
);
}
async deleteSession(tokenHash: string): Promise<void> {
await this.deleteSecret(objectName("session", tokenHash));
}
async revokeUserSessions(username: string): Promise<number> {
const sessions = (await this.listSecrets("session"))
.map((secret) => parseSession(secret))
.filter(
(session): session is SessionRecord => session?.username === username,
);
for (const session of sessions) await this.deleteSession(session.tokenHash);
return sessions.length;
}
async listExpiredSessions(now = Date.now()): Promise<SessionRecord[]> {
return (await this.listSecrets("session"))
.map((secret) => parseSession(secret))
.filter(
(session): session is SessionRecord =>
session !== undefined && Date.parse(session.expiresAt) <= now,
);
}
async deleteExpiredSessions(now = Date.now()): Promise<number> {
const expired = await this.listExpiredSessions(now);
for (const session of expired) {
await this.deleteSession(session.tokenHash);
}
return expired.length;
}
}