|
|
@@ -0,0 +1,104 @@
|
|
|
+/**
|
|
|
+ * File-based credential validation.
|
|
|
+ *
|
|
|
+ * Credentials are read from a local JSON file mounted by Kubernetes from a ConfigMap:
|
|
|
+ *
|
|
|
+ * apiVersion: v1
|
|
|
+ * kind: ConfigMap
|
|
|
+ * metadata:
|
|
|
+ * name: agent-paas-users
|
|
|
+ * data:
|
|
|
+ * users.json: |
|
|
|
+ * [
|
|
|
+ * { "username": "admin", "passwordHash": "<bcrypt hash>" }
|
|
|
+ * ]
|
|
|
+ *
|
|
|
+ * Mount it as a volume at the path supplied via AuthConfig.usersFile.
|
|
|
+ *
|
|
|
+ * Generate a bcrypt hash: `htpasswd -nbB admin mypassword | cut -d: -f2`
|
|
|
+ *
|
|
|
+ * Future: replace `Auth` with an OIDC/SSO-backed implementation without changing callers.
|
|
|
+ */
|
|
|
+import { readFile } from 'node:fs/promises';
|
|
|
+
|
|
|
+import bcrypt from 'bcryptjs';
|
|
|
+import { jwtVerify, SignJWT } from 'jose';
|
|
|
+
|
|
|
+import { authLogger as log } from '$lib/server';
|
|
|
+import type { User } from '$lib/types/user';
|
|
|
+
|
|
|
+type AuthConfig = {
|
|
|
+ jwtSecret: string;
|
|
|
+ /** Path to the JSON users file. Default: /etc/agent-paas/users.json */
|
|
|
+ usersFile: string;
|
|
|
+};
|
|
|
+
|
|
|
+type UserEntry = {
|
|
|
+ id: string;
|
|
|
+ passwordHash: string;
|
|
|
+ name?: string;
|
|
|
+ email?: string;
|
|
|
+ workspaces?: string[];
|
|
|
+};
|
|
|
+
|
|
|
+class Auth {
|
|
|
+ #ALG = 'HS256';
|
|
|
+ #TTL = '8h';
|
|
|
+ #users: UserEntry[] = [];
|
|
|
+ #cfg: AuthConfig;
|
|
|
+
|
|
|
+ constructor(cfg: AuthConfig) {
|
|
|
+ this.#cfg = cfg;
|
|
|
+ }
|
|
|
+
|
|
|
+ async init(): Promise<void> {
|
|
|
+ log.info('Loading users file', { path: this.#cfg.usersFile });
|
|
|
+ const raw = await readFile(this.#cfg.usersFile, 'utf-8');
|
|
|
+ this.#users = JSON.parse(raw) as UserEntry[];
|
|
|
+ log.info('Users loaded', { count: this.#users.length });
|
|
|
+ }
|
|
|
+
|
|
|
+ async validateCredentials(userId: string, password: string): Promise<boolean> {
|
|
|
+ const user = this.#users.find((u) => u.id === userId);
|
|
|
+ if (!user) {
|
|
|
+ log.warn('Login attempt for unknown user', { username: userId });
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ const valid = await bcrypt.compare(password, user.passwordHash);
|
|
|
+ if (valid) {
|
|
|
+ log.info('Login successful', { username: userId });
|
|
|
+ } else {
|
|
|
+ log.warn('Login failed: wrong password', { username: userId });
|
|
|
+ }
|
|
|
+ return valid;
|
|
|
+ }
|
|
|
+
|
|
|
+ #secret(): Uint8Array {
|
|
|
+ if (!this.#cfg.jwtSecret) throw new Error('jwtSecret is not set');
|
|
|
+ return new TextEncoder().encode(this.#cfg.jwtSecret);
|
|
|
+ }
|
|
|
+
|
|
|
+ async signToken(userId: string): Promise<string> {
|
|
|
+ return new SignJWT({ username: userId })
|
|
|
+ .setProtectedHeader({ alg: this.#ALG })
|
|
|
+ .setIssuedAt()
|
|
|
+ .setExpirationTime(this.#TTL)
|
|
|
+ .sign(this.#secret());
|
|
|
+ }
|
|
|
+
|
|
|
+ async verifyToken(token: string): Promise<User | undefined> {
|
|
|
+ try {
|
|
|
+ const { payload } = await jwtVerify(token, this.#secret(), { algorithms: [this.#ALG] });
|
|
|
+ const userId = payload['username'];
|
|
|
+ if (typeof userId !== 'string') return undefined;
|
|
|
+ const user = this.#users.find((u) => u.id === userId);
|
|
|
+ if (!user) return undefined;
|
|
|
+ return { id: user.id, name: user.name, email: user.email, workspaces: user.workspaces || [] };
|
|
|
+ } catch {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export type { AuthConfig };
|
|
|
+export { Auth };
|