feat: v2
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user