Phase1
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 21s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 11:31:54 +02:00
parent 4fe25e6ec3
commit 58961000eb
123 changed files with 4231 additions and 136 deletions

View File

@@ -6,10 +6,17 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"otplib": "^12.0.1"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"typescript": "^5.7.3"
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}

View File

@@ -10,6 +10,8 @@ export interface AuthenticatedUser {
id: string;
email: string;
username: string;
displayName: string | null;
emailVerified: boolean;
roles: UserRole[];
}
@@ -27,3 +29,35 @@ export interface AuthTokenPayload {
iat?: number;
exp?: number;
}
export { hashPassword, verifyPassword } from './password';
export {
generateSecureToken,
hashToken,
verifyTokenHash,
} from './tokens';
export {
generateUsernameFromEmail,
sanitizeUsername,
validateUsername,
MIN_USERNAME_LENGTH,
MAX_USERNAME_LENGTH,
type UsernameValidationResult,
} from './username';
export {
generateTotpSecret,
verifyTotpCode,
buildOtpAuthUrl,
} from './totp';
export {
generateRecoveryCodes,
hashRecoveryCode,
verifyRecoveryCode,
} from './recovery-codes';
export {
SESSION_COOKIE_NAME,
SESSION_DURATION_MS,
SESSION_ABSOLUTE_MAX_DURATION_MS,
SESSION_REMEMBER_ME_DURATION_MS,
createSessionExpiry,
} from './session';

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "./password";
describe("password", () => {
it("hashes and verifies a password roundtrip", async () => {
const plain = "super-secure-password-123!";
const hashed = await hashPassword(plain);
expect(hashed).not.toBe(plain);
expect(await verifyPassword(plain, hashed)).toBe(true);
});
it("rejects an incorrect password", async () => {
const hashed = await hashPassword("correct-password");
expect(await verifyPassword("wrong-password", hashed)).toBe(false);
});
});

View File

@@ -0,0 +1,16 @@
import { hash, verify } from '@node-rs/argon2';
export async function hashPassword(password: string): Promise<string> {
return hash(password);
}
export async function verifyPassword(
plain: string,
hashed: string,
): Promise<boolean> {
try {
return await verify(hashed, plain);
} catch {
return false;
}
}

View File

@@ -0,0 +1,28 @@
import { randomBytes } from "node:crypto";
import { hashToken, verifyTokenHash } from "./tokens";
const RECOVERY_CODE_SEGMENT_LENGTH = 4;
function formatRecoveryCode(bytes: Buffer): string {
const hex = bytes.toString("hex").toUpperCase();
return `${hex.slice(0, RECOVERY_CODE_SEGMENT_LENGTH)}-${hex.slice(RECOVERY_CODE_SEGMENT_LENGTH)}`;
}
export function generateRecoveryCodes(count = 10): string[] {
return Array.from({ length: count }, () =>
formatRecoveryCode(randomBytes(RECOVERY_CODE_SEGMENT_LENGTH)),
);
}
export function hashRecoveryCode(code: string): string {
return hashToken(normalizeRecoveryCode(code));
}
export function verifyRecoveryCode(code: string, hashed: string): boolean {
return verifyTokenHash(normalizeRecoveryCode(code), hashed);
}
function normalizeRecoveryCode(code: string): string {
return code.trim().toUpperCase().replace(/\s+/g, "");
}

View File

@@ -0,0 +1,9 @@
export const SESSION_COOKIE_NAME = 'hgc_session';
export const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
export const SESSION_ABSOLUTE_MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
export const SESSION_REMEMBER_ME_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
export function createSessionExpiry(from: Date = new Date()): Date {
return new Date(from.getTime() + SESSION_DURATION_MS);
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
generateSecureToken,
hashToken,
verifyTokenHash,
} from "./tokens";
describe("tokens", () => {
it("produces consistent hashes for the same token", () => {
const token = generateSecureToken();
const firstHash = hashToken(token);
const secondHash = hashToken(token);
expect(firstHash).toBe(secondHash);
expect(verifyTokenHash(token, firstHash)).toBe(true);
});
it("rejects tokens that do not match the hash", () => {
const token = generateSecureToken();
const hashed = hashToken(token);
expect(verifyTokenHash("different-token", hashed)).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
export function generateSecureToken(bytes = 32): string {
return randomBytes(bytes).toString('base64url');
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function verifyTokenHash(token: string, hashed: string): boolean {
const computed = hashToken(token);
const computedBuffer = Buffer.from(computed, 'hex');
const hashedBuffer = Buffer.from(hashed, 'hex');
if (computedBuffer.length !== hashedBuffer.length) {
return false;
}
return timingSafeEqual(computedBuffer, hashedBuffer);
}

View File

@@ -0,0 +1,27 @@
import { authenticator } from "otplib";
import { describe, expect, it } from "vitest";
import {
buildOtpAuthUrl,
generateTotpSecret,
verifyTotpCode,
} from "./totp";
describe("totp", () => {
it("generates a secret and verifies a valid code", () => {
const secret = generateTotpSecret();
const code = authenticator.generate(secret);
expect(secret.length).toBeGreaterThan(0);
expect(verifyTotpCode(secret, code)).toBe(true);
});
it("builds an otpauth URL", () => {
const secret = generateTotpSecret();
const url = buildOtpAuthUrl("HexaHost", "user@example.com", secret);
expect(url).toContain("otpauth://totp/");
expect(url).toContain("issuer=HexaHost");
expect(url).toContain(encodeURIComponent("user@example.com"));
});
});

21
packages/auth/src/totp.ts Normal file
View File

@@ -0,0 +1,21 @@
import { authenticator } from 'otplib';
export function generateTotpSecret(): string {
return authenticator.generateSecret();
}
export function verifyTotpCode(secret: string, code: string): boolean {
try {
return authenticator.verify({ token: code.replace(/\s+/gu, ''), secret });
} catch {
return false;
}
}
export function buildOtpAuthUrl(
issuer: string,
accountName: string,
secret: string,
): string {
return authenticator.keyuri(accountName, issuer, secret);
}

View File

@@ -0,0 +1,109 @@
export const MIN_USERNAME_LENGTH = 3;
export const MAX_USERNAME_LENGTH = 32;
const USERNAME_PATTERN = /^[a-z][a-z0-9_]*[a-z0-9]$|^[a-z]$/;
const RESERVED_USERNAMES = new Set([
"admin",
"administrator",
"api",
"auth",
"billing",
"dashboard",
"help",
"hexahost",
"login",
"logout",
"moderator",
"null",
"register",
"root",
"settings",
"support",
"system",
"undefined",
"user",
"www",
]);
export interface UsernameValidationResult {
valid: boolean;
error?: string;
}
export function sanitizeUsername(input: string): string {
const normalized = input
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!normalized) {
return "";
}
if (!/^[a-z]/.test(normalized)) {
return `u_${normalized}`.slice(0, MAX_USERNAME_LENGTH);
}
return normalized.slice(0, MAX_USERNAME_LENGTH);
}
export function generateUsernameFromEmail(email: string): string {
const localPart = email.trim().toLowerCase().split("@")[0] ?? "";
let username = sanitizeUsername(localPart);
if (!username) {
username = "user";
}
if (username.length < MIN_USERNAME_LENGTH) {
username = username.padEnd(MIN_USERNAME_LENGTH, "0");
}
if (username.length > MAX_USERNAME_LENGTH) {
username = username.slice(0, MAX_USERNAME_LENGTH);
}
if (!/^[a-z]/.test(username)) {
username = `u${username}`.slice(0, MAX_USERNAME_LENGTH);
}
return username;
}
export function validateUsername(username: string): UsernameValidationResult {
const normalized = username.trim().toLowerCase();
if (normalized.length < MIN_USERNAME_LENGTH) {
return {
valid: false,
error: `Username must be at least ${MIN_USERNAME_LENGTH} characters.`,
};
}
if (normalized.length > MAX_USERNAME_LENGTH) {
return {
valid: false,
error: `Username must be at most ${MAX_USERNAME_LENGTH} characters.`,
};
}
if (!USERNAME_PATTERN.test(normalized)) {
return {
valid: false,
error:
"Username must start with a letter, contain only lowercase letters, numbers, and underscores, and not end with an underscore.",
};
}
if (RESERVED_USERNAMES.has(normalized)) {
return {
valid: false,
error: "This username is reserved.",
};
}
return { valid: true };
}

View File

@@ -5,5 +5,5 @@
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(dirname, "./src"),
},
},
});