Compare commits
4 Commits
e7a2aa8df3
...
9c98e2f2fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c98e2f2fb | |||
| 7f467f86d9 | |||
|
|
752bfcf03e | ||
|
|
589e146ecc |
@ -1,303 +1,15 @@
|
||||
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
|
||||
import { Input, Secret, Select } from "jsr:@cliffy/prompt@1.0.0-rc.7";
|
||||
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||
import { runSetupWizard } from "./prompts/wizard.ts";
|
||||
import { authCommand } from "./commands/auth.ts";
|
||||
import { testCommand } from "./commands/test.ts";
|
||||
import {
|
||||
generateDockerCompose,
|
||||
generateSpireDockerCompose,
|
||||
} from "./compose.ts";
|
||||
import {
|
||||
AuthSetupConfig,
|
||||
COMPOSE_PATH,
|
||||
DEFAULT_AUTH_CONFIG,
|
||||
ENV_PATH,
|
||||
generateEnv,
|
||||
generateSpireEnv,
|
||||
readEnv,
|
||||
SPIRE_COMPOSE_PATH,
|
||||
SPIRE_ENV_PATH,
|
||||
} from "./env.ts";
|
||||
import {
|
||||
downloadWorkloadProto,
|
||||
executeBuildImage,
|
||||
executeProtobufCompilation,
|
||||
generateBuildCommands,
|
||||
} from "./build.ts";
|
||||
|
||||
export async function generateAuthSetupFiles(
|
||||
config: AuthSetupConfig,
|
||||
): Promise<void> {
|
||||
const envContent = generateEnv(config);
|
||||
await Deno.writeTextFile(ENV_PATH, envContent);
|
||||
|
||||
const spireEnvContent = generateSpireEnv(config);
|
||||
await Deno.writeTextFile(SPIRE_ENV_PATH, spireEnvContent);
|
||||
|
||||
const composeContent = generateDockerCompose();
|
||||
await Deno.writeTextFile(COMPOSE_PATH, composeContent);
|
||||
|
||||
const spireComposeContent = generateSpireDockerCompose();
|
||||
await Deno.writeTextFile(SPIRE_COMPOSE_PATH, spireComposeContent);
|
||||
|
||||
console.log(
|
||||
colors.green(
|
||||
`\n✓ Successfully generated ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, and ${SPIRE_COMPOSE_PATH}!`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.green("Setup complete. You may deploy your stacks by running:\n"),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"1. Deploy SPIRE Stack:\n" +
|
||||
" podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n\n" +
|
||||
"2. Deploy Auth-Yes Stack:\n" +
|
||||
" podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleAuthSetup(
|
||||
currentConfig: AuthSetupConfig,
|
||||
): Promise<AuthSetupConfig> {
|
||||
console.log(
|
||||
colors.gray(
|
||||
"Please provide the following Auth Yes configuration details.\n",
|
||||
),
|
||||
);
|
||||
|
||||
const reg = await Input.prompt({
|
||||
message: "Enter the Primary Container Registry URL:",
|
||||
default: currentConfig.reg,
|
||||
});
|
||||
|
||||
const ghcrReg = await Input.prompt({
|
||||
message: "Enter the GHCR Mirror Registry URL (for SPIRE):",
|
||||
default: currentConfig.ghcrReg || "ghcr.atyg.org",
|
||||
});
|
||||
|
||||
const domainName = await Input.prompt({
|
||||
message: "Enter the Auth Domain Name:",
|
||||
hint: "E.g., auth.system.local",
|
||||
default: currentConfig.domainName,
|
||||
});
|
||||
|
||||
const dbDataPath = await Input.prompt({
|
||||
message: "Enter the Database Path on the Host:",
|
||||
default: currentConfig.dbDataPath,
|
||||
});
|
||||
|
||||
const spireDataPath = await Input.prompt({
|
||||
message: "Enter the SPIRE Path on the Host:",
|
||||
default: currentConfig.spireDataPath,
|
||||
});
|
||||
|
||||
const appSecret = await Secret.prompt({
|
||||
message: "Enter the App Secret for the IDP:",
|
||||
default: currentConfig.appSecret,
|
||||
minLength: 16,
|
||||
});
|
||||
|
||||
const pwdMessage = currentConfig.dbPassword
|
||||
? "Enter the PostgreSQL database password: (Leave blank to keep existing password)"
|
||||
: "Enter the PostgreSQL database password:";
|
||||
|
||||
const pwdInput = await Secret.prompt({
|
||||
message: pwdMessage,
|
||||
minLength: currentConfig.dbPassword ? 0 : 1,
|
||||
});
|
||||
|
||||
const dbPassword = pwdInput === "" && currentConfig.dbPassword !== ""
|
||||
? currentConfig.dbPassword
|
||||
: pwdInput;
|
||||
|
||||
const newConfig: AuthSetupConfig = {
|
||||
reg,
|
||||
ghcrReg,
|
||||
domainName,
|
||||
dbPassword,
|
||||
dbDataPath,
|
||||
spireDataPath,
|
||||
appSecret,
|
||||
};
|
||||
|
||||
await generateAuthSetupFiles(newConfig);
|
||||
|
||||
return newConfig;
|
||||
}
|
||||
|
||||
export async function handleTestConnection(domainName: string): Promise<void> {
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(`\n=== Testing Auth Connection (https://${domainName}) ===`),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const res1 = await fetch(`https://${domainName}/`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (res1.status === 502 || res1.status === 503 || res1.status === 504) {
|
||||
throw new Error(`Gateway error: ${res1.status}`);
|
||||
}
|
||||
|
||||
if (res1.body) {
|
||||
await res1.body.cancel();
|
||||
}
|
||||
|
||||
console.log(
|
||||
colors.green(
|
||||
`✓ Auth service is up and reachable at https://${domainName}/`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
colors.red(
|
||||
`✗ Error: Could not reach the auth service at https://${domainName}/`,
|
||||
),
|
||||
);
|
||||
if (error instanceof Error) {
|
||||
console.log(colors.red(` Reason: ${error.message}`));
|
||||
} else {
|
||||
console.log(colors.red(` Reason: ${error}`));
|
||||
}
|
||||
console.log(
|
||||
colors.red(
|
||||
" Please verify the container is running and DNS/Traefik is resolving.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReviewConfigs(): Promise<void> {
|
||||
const envContent = await Deno.readTextFile(ENV_PATH).catch(() => null);
|
||||
const composeContent = await Deno.readTextFile(COMPOSE_PATH).catch(() =>
|
||||
null
|
||||
);
|
||||
const spireComposeContent = await Deno.readTextFile(SPIRE_COMPOSE_PATH).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!envContent && !composeContent && !spireComposeContent) {
|
||||
console.log(
|
||||
colors.red("\n✗ No generated configs found. Run the setup first.\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const action = await Select.prompt({
|
||||
message: "Review Generated Configs",
|
||||
options: [
|
||||
{ name: "[Show .env]", value: "env" },
|
||||
{ name: "[Show compose.yml]", value: "compose" },
|
||||
{ name: "[Show compose.spire.yml]", value: "spire_compose" },
|
||||
{ name: "[Back to Main Menu]", value: "back" },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === "env") {
|
||||
console.log(colors.bold(colors.blue(`\n=== ${ENV_PATH} ===\n`)));
|
||||
console.log(envContent || colors.yellow("File not found."));
|
||||
console.log();
|
||||
} else if (action === "compose") {
|
||||
console.log(colors.bold(colors.blue(`\n=== ${COMPOSE_PATH} ===\n`)));
|
||||
console.log(composeContent || colors.yellow("File not found."));
|
||||
console.log();
|
||||
} else if (action === "spire_compose") {
|
||||
console.log(
|
||||
colors.bold(colors.blue(`\n=== ${SPIRE_COMPOSE_PATH} ===\n`)),
|
||||
);
|
||||
console.log(spireComposeContent || colors.yellow("File not found."));
|
||||
console.log();
|
||||
} else if (action === "back") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SIDE EFFECT: Runs the interactive CLI wizard.
|
||||
export async function runSetupWizard(): Promise<void> {
|
||||
console.log(colors.bold(colors.blue("=== Auth Setup Wizard ===\n")));
|
||||
|
||||
const loadedEnv = await readEnv();
|
||||
let currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
|
||||
if (Object.keys(loadedEnv).length > 0 && currentConfig.domainName) {
|
||||
await handleTestConnection(currentConfig.domainName);
|
||||
console.log();
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const action = await Select.prompt({
|
||||
message: "Main Menu",
|
||||
options: [
|
||||
{ name: "[Test Auth Connection]", value: "test" },
|
||||
{ name: "[Configure Auth Yes API]", value: "auth" },
|
||||
{ name: "[Compile Protobuf Definitions]", value: "compile_proto" },
|
||||
{ name: "[Review Generated Configs]", value: "review" },
|
||||
{ name: "[Build and Push Auth Image]", value: "build" },
|
||||
{ name: "[Exit]", value: "exit" },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === "test") {
|
||||
await handleTestConnection(currentConfig.domainName);
|
||||
console.log();
|
||||
} else if (action === "auth") {
|
||||
currentConfig = await handleAuthSetup(currentConfig);
|
||||
} else if (action === "compile_proto") {
|
||||
try {
|
||||
await downloadWorkloadProto();
|
||||
await executeProtobufCompilation();
|
||||
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (action === "review") {
|
||||
await handleReviewConfigs();
|
||||
} else if (action === "build") {
|
||||
try {
|
||||
const commands = generateBuildCommands(currentConfig.reg);
|
||||
await executeBuildImage(commands);
|
||||
console.log(colors.green("\n✓ Successfully built and pushed image!"));
|
||||
console.log(
|
||||
colors.green("You may now deploy your stack by running:\n"),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
colors.red(`\n✗ Build process failed: ${error.message}\n`),
|
||||
);
|
||||
} else {
|
||||
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
|
||||
}
|
||||
}
|
||||
} else if (action === "exit") {
|
||||
console.log(colors.gray("Exiting...\n"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
buildCommand,
|
||||
compileProtoCommand,
|
||||
releaseCommand,
|
||||
} from "./commands/build.ts";
|
||||
import { dumpComposeCommand, dumpEnvCommand } from "./commands/compose.ts";
|
||||
import { secretsCommand } from "./commands/secrets.ts";
|
||||
|
||||
export async function runCli(args = Deno.args): Promise<void> {
|
||||
if (import.meta.main || args.length === 0) {
|
||||
@ -314,458 +26,18 @@ export async function runCli(args = Deno.args): Promise<void> {
|
||||
const cmd = new Command()
|
||||
.name("auth-setup")
|
||||
.description("Auth setup wizard and CLI")
|
||||
.version("1.0.0")
|
||||
.action(() => {
|
||||
runSetupWizard();
|
||||
})
|
||||
.command("auth", "Configure Auth Yes API")
|
||||
.option("--auto, --headless", "Run in headless mode")
|
||||
.option("--registry <reg:string>", "Container Registry URL")
|
||||
.option("--ghcr-registry <ghcrReg:string>", "GHCR Mirror Registry URL")
|
||||
.option("--domain <domain:string>", "Auth Domain Name")
|
||||
.option("--db-path <path:string>", "Database Path on the Host")
|
||||
.option("--spire-path <path:string>", "SPIRE Path on the Host")
|
||||
.action(async (options) => {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
|
||||
if (options.auto) {
|
||||
if (!options.registry || !options.domain || !options.dbPath) {
|
||||
console.error(
|
||||
colors.red(
|
||||
"Error: --registry, --domain, and --db-path are required in headless mode.",
|
||||
),
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
const dbPassword = Deno.env.get("POSTGRES_PASSWORD") ||
|
||||
currentConfig.dbPassword;
|
||||
const appSecret = Deno.env.get("APP_SECRET") ||
|
||||
currentConfig.appSecret;
|
||||
if (!dbPassword) {
|
||||
console.error(
|
||||
colors.red(
|
||||
"Error: POSTGRES_PASSWORD environment variable is required in headless mode.",
|
||||
),
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
if (!appSecret) {
|
||||
console.error(
|
||||
colors.red(
|
||||
"Error: APP_SECRET environment variable is required in headless mode.",
|
||||
),
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
const newConfig: AuthSetupConfig = {
|
||||
reg: options.registry,
|
||||
ghcrReg: options.ghcrRegistry || currentConfig.ghcrReg ||
|
||||
"ghcr.atyg.org",
|
||||
domainName: options.domain,
|
||||
dbPassword,
|
||||
dbDataPath: options.dbPath || currentConfig.dbDataPath ||
|
||||
"/volume1/docker/auth-yes/data",
|
||||
spireDataPath: options.spirePath || currentConfig.spireDataPath ||
|
||||
"/volume1/docker/spire",
|
||||
appSecret,
|
||||
};
|
||||
await generateAuthSetupFiles(newConfig);
|
||||
} else {
|
||||
if (options.registry) currentConfig.reg = options.registry;
|
||||
if (options.ghcrRegistry) currentConfig.ghcrReg = options.ghcrRegistry;
|
||||
if (options.domain) currentConfig.domainName = options.domain;
|
||||
if (options.dbPath) currentConfig.dbDataPath = options.dbPath;
|
||||
if (options.spirePath) currentConfig.spireDataPath = options.spirePath;
|
||||
await handleAuthSetup(currentConfig);
|
||||
}
|
||||
})
|
||||
.command("test", "Test Auth Connection")
|
||||
.action(async () => {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
await handleTestConnection(currentConfig.domainName);
|
||||
})
|
||||
.command("compile_proto", "Compile Protobuf Definitions")
|
||||
.action(async () => {
|
||||
try {
|
||||
await downloadWorkloadProto();
|
||||
await executeProtobufCompilation();
|
||||
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
|
||||
);
|
||||
}
|
||||
Deno.exit(1);
|
||||
}
|
||||
})
|
||||
.command("build", "Build and Push Auth Image")
|
||||
.action(async () => {
|
||||
try {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
const commands = generateBuildCommands(currentConfig.reg);
|
||||
await executeBuildImage(commands);
|
||||
console.log(
|
||||
colors.green("\n✓ Successfully built and pushed auth image!"),
|
||||
);
|
||||
console.log(
|
||||
colors.green("You may now deploy your stack by running:\n"),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
colors.red(`\n✗ Build process failed: ${error.message}\n`),
|
||||
);
|
||||
} else {
|
||||
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
|
||||
}
|
||||
Deno.exit(1);
|
||||
}
|
||||
})
|
||||
.command("dump_compose", "Output all generated Compose YAML configurations")
|
||||
.action(() => {
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"\n================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(" STACK 1: Auth-Yes Stack (infra/compose.yml)"),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(generateDockerCompose());
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(
|
||||
" STACK 2: Standalone SPIRE Stack (infra/compose.spire.yml)",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(generateSpireDockerCompose());
|
||||
})
|
||||
.command(
|
||||
"dump_env",
|
||||
"Output current environment configuration (.env and .env.spire)",
|
||||
)
|
||||
.action(async () => {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"\n================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(" STACK 1: Auth-Yes Environment (infra/stack.env)"),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(generateEnv(currentConfig));
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(
|
||||
" STACK 2: SPIRE Stack Environment (infra/.env.spire)",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(generateSpireEnv(currentConfig));
|
||||
})
|
||||
.command("secrets", "Inspect secrets status and persistence paths")
|
||||
.action(async () => {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"\n=== Auth-Yes Secrets & Persistence Topology ===\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
`${
|
||||
colors.cyan("Database Persistence Path:")
|
||||
} ${currentConfig.dbDataPath}`,
|
||||
);
|
||||
console.log(
|
||||
`${
|
||||
colors.cyan("SPIRE Persistence Path:")
|
||||
} ${currentConfig.spireDataPath}`,
|
||||
);
|
||||
console.log(
|
||||
`${
|
||||
colors.cyan("Local Artifact Files:")
|
||||
} ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, ${SPIRE_COMPOSE_PATH}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.cyan("PostgreSQL Password:")} ${
|
||||
currentConfig.dbPassword
|
||||
? colors.green(
|
||||
"✓ Configured (len: " + currentConfig.dbPassword.length + ")",
|
||||
)
|
||||
: colors.red("✗ Missing")
|
||||
}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.cyan("Application Secret:")} ${
|
||||
currentConfig.appSecret
|
||||
? colors.green(
|
||||
"✓ Configured (len: " + currentConfig.appSecret.length + ")",
|
||||
)
|
||||
: colors.red("✗ Missing")
|
||||
}`,
|
||||
);
|
||||
console.log(
|
||||
colors.gray(
|
||||
"\nTip: To dump raw environment variables, run: deno task setup dump_env\n",
|
||||
),
|
||||
);
|
||||
})
|
||||
.command(
|
||||
"release",
|
||||
"Run complete build & release pipeline with copy-paste deployment instructions",
|
||||
)
|
||||
.action(async () => {
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"\n================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(
|
||||
" Auth-Yes Infrastructure Build & Release Pipeline",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 1. Quality Gates
|
||||
console.log(colors.cyan("[1/4] Running Quality Gates & Test Suite..."));
|
||||
const testCmd = new Deno.Command("deno", {
|
||||
args: ["task", "test"],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const testRes = await testCmd.output();
|
||||
if (testRes.code !== 0) {
|
||||
console.error(
|
||||
colors.red("\n✗ Quality gates failed. Aborting release."),
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
// 2. Compile Protobufs
|
||||
console.log(
|
||||
colors.cyan("\n[2/4] Downloading & Compiling Protobufs..."),
|
||||
);
|
||||
await downloadWorkloadProto();
|
||||
await executeProtobufCompilation();
|
||||
|
||||
// 3. Build & Push Images
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"\n[3/4] Building and Pushing Custom Container Images...",
|
||||
),
|
||||
);
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
const commands = generateBuildCommands(currentConfig.reg);
|
||||
await executeBuildImage(commands);
|
||||
console.log(
|
||||
colors.green(
|
||||
"\n✓ Successfully built and pushed all stack images to " +
|
||||
currentConfig.reg + "!",
|
||||
),
|
||||
);
|
||||
|
||||
// 4. Output Copy-Paste Guides
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"\n[4/4] Release Complete. Deployment & Configuration Guides:\n",
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"┌──────────────────────────────────────────────────────────────┐",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"│ A. FOR NEW SYSTEM INSTALLATIONS │",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"└──────────────────────────────────────────────────────────────┘",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.gray("# 1. Inspect environment variables and secrets:"),
|
||||
);
|
||||
console.log(colors.cyan("deno task setup dump_env"));
|
||||
console.log(
|
||||
colors.gray("\n# 2. Inspect generated Docker Compose files:"),
|
||||
);
|
||||
console.log(colors.cyan("deno task setup dump_compose"));
|
||||
console.log(
|
||||
colors.gray("\n# 3. Create persistent storage paths on host:"),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
`mkdir -p ${currentConfig.spireDataPath} ${currentConfig.dbDataPath}`,
|
||||
),
|
||||
);
|
||||
console.log(colors.gray("\n# 4. Deploy fresh stacks:"));
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d",
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"\n┌──────────────────────────────────────────────────────────────┐",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"│ B. FOR EXISTING SYSTEM UPDATES (ROLLING RESTART) │",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"└──────────────────────────────────────────────────────────────┘",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.gray(
|
||||
"# Pull newly pushed custom images and restart containers:",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml pull",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml pull",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||
),
|
||||
);
|
||||
});
|
||||
.command("auth", authCommand)
|
||||
.command("test", testCommand)
|
||||
.command("compile_proto", compileProtoCommand)
|
||||
.command("build", buildCommand)
|
||||
.command("release", releaseCommand)
|
||||
.command("dump_compose", dumpComposeCommand)
|
||||
.command("dump_env", dumpEnvCommand)
|
||||
.command("secrets", secretsCommand);
|
||||
|
||||
await cmd.parse(args);
|
||||
}
|
||||
|
||||
71
infra/setup/commands/auth.ts
Normal file
71
infra/setup/commands/auth.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
|
||||
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||
import { AuthSetupConfig, DEFAULT_AUTH_CONFIG, readEnv } from "../env.ts";
|
||||
import { generateAuthSetupFiles, handleAuthSetup } from "../prompts/auth.ts";
|
||||
|
||||
export const authCommand = new Command()
|
||||
.description("Configure Auth Yes API")
|
||||
.option("--auto, --headless", "Run in headless mode")
|
||||
.option("--registry <reg:string>", "Container Registry URL")
|
||||
.option("--ghcr-registry <ghcrReg:string>", "GHCR Mirror Registry URL")
|
||||
.option("--domain <domain:string>", "Auth Domain Name")
|
||||
.option("--db-path <path:string>", "Database Path on the Host")
|
||||
.option("--spire-path <path:string>", "SPIRE Path on the Host")
|
||||
.action(async (options) => {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
|
||||
if (options.auto) {
|
||||
if (!options.registry || !options.domain || !options.dbPath) {
|
||||
console.error(
|
||||
colors.red(
|
||||
"Error: --registry, --domain, and --db-path are required in headless mode.",
|
||||
),
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
const dbPassword = Deno.env.get("POSTGRES_PASSWORD") ||
|
||||
currentConfig.dbPassword;
|
||||
const appSecret = Deno.env.get("APP_SECRET") || currentConfig.appSecret;
|
||||
if (!dbPassword) {
|
||||
console.error(
|
||||
colors.red(
|
||||
"Error: POSTGRES_PASSWORD environment variable is required in headless mode.",
|
||||
),
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
if (!appSecret) {
|
||||
console.error(
|
||||
colors.red(
|
||||
"Error: APP_SECRET environment variable is required in headless mode.",
|
||||
),
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
const newConfig: AuthSetupConfig = {
|
||||
reg: options.registry,
|
||||
ghcrReg: options.ghcrRegistry || currentConfig.ghcrReg ||
|
||||
"ghcr.atyg.org",
|
||||
domainName: options.domain,
|
||||
dbPassword,
|
||||
dbDataPath: options.dbPath || currentConfig.dbDataPath ||
|
||||
"/volume1/docker/auth-yes/data",
|
||||
spireDataPath: options.spirePath || currentConfig.spireDataPath ||
|
||||
"/volume1/docker/spire",
|
||||
appSecret,
|
||||
};
|
||||
await generateAuthSetupFiles(newConfig);
|
||||
} else {
|
||||
if (options.registry) currentConfig.reg = options.registry;
|
||||
if (options.ghcrRegistry) currentConfig.ghcrReg = options.ghcrRegistry;
|
||||
if (options.domain) currentConfig.domainName = options.domain;
|
||||
if (options.dbPath) currentConfig.dbDataPath = options.dbPath;
|
||||
if (options.spirePath) currentConfig.spireDataPath = options.spirePath;
|
||||
await handleAuthSetup(currentConfig);
|
||||
}
|
||||
});
|
||||
235
infra/setup/commands/build.ts
Normal file
235
infra/setup/commands/build.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
|
||||
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||
import { AuthSetupConfig, DEFAULT_AUTH_CONFIG, readEnv } from "../env.ts";
|
||||
import {
|
||||
downloadWorkloadProto,
|
||||
executeBuildImage,
|
||||
executeProtobufCompilation,
|
||||
generateBuildCommands,
|
||||
} from "../build.ts";
|
||||
|
||||
export const compileProtoCommand = new Command()
|
||||
.description("Compile Protobuf Definitions")
|
||||
.action(async () => {
|
||||
try {
|
||||
await downloadWorkloadProto();
|
||||
await executeProtobufCompilation();
|
||||
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
|
||||
);
|
||||
} else {
|
||||
console.log(colors.red(`\n✗ Protobuf compilation failed: ${error}\n`));
|
||||
}
|
||||
Deno.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
export const buildCommand = new Command()
|
||||
.description("Build and Push Auth Image")
|
||||
.action(async () => {
|
||||
try {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
const commands = generateBuildCommands(currentConfig.reg);
|
||||
await executeBuildImage(commands);
|
||||
console.log(
|
||||
colors.green("\n✓ Successfully built and pushed auth image!"),
|
||||
);
|
||||
console.log(
|
||||
colors.green("You may now deploy your stack by running:\n"),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
colors.red(`\n✗ Build process failed: ${error.message}\n`),
|
||||
);
|
||||
} else {
|
||||
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
|
||||
}
|
||||
Deno.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
export const releaseCommand = new Command()
|
||||
.description(
|
||||
"Run complete build & release pipeline with copy-paste deployment instructions",
|
||||
)
|
||||
.action(async () => {
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"\n================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(
|
||||
" Auth-Yes Infrastructure Build & Release Pipeline",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 1. Quality Gates
|
||||
console.log(colors.cyan("[1/4] Running Quality Gates & Test Suite..."));
|
||||
const testCmd = new Deno.Command("deno", {
|
||||
args: ["task", "test"],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const testRes = await testCmd.output();
|
||||
if (testRes.code !== 0) {
|
||||
console.error(
|
||||
colors.red("\n✗ Quality gates failed. Aborting release."),
|
||||
);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
// 2. Compile Protobufs
|
||||
console.log(
|
||||
colors.cyan("\n[2/4] Downloading & Compiling Protobufs..."),
|
||||
);
|
||||
await downloadWorkloadProto();
|
||||
await executeProtobufCompilation();
|
||||
|
||||
// 3. Build & Push Images
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"\n[3/4] Building and Pushing Custom Container Images...",
|
||||
),
|
||||
);
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
const commands = generateBuildCommands(currentConfig.reg);
|
||||
await executeBuildImage(commands);
|
||||
console.log(
|
||||
colors.green(
|
||||
"\n✓ Successfully built and pushed all stack images to " +
|
||||
currentConfig.reg + "!",
|
||||
),
|
||||
);
|
||||
|
||||
// 4. Output Copy-Paste Guides
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"\n[4/4] Release Complete. Deployment & Configuration Guides:\n",
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"┌──────────────────────────────────────────────────────────────┐",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"│ A. FOR NEW SYSTEM INSTALLATIONS │",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"└──────────────────────────────────────────────────────────────┘",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.gray("# 1. Inspect environment variables and secrets:"),
|
||||
);
|
||||
console.log(colors.cyan("deno task setup dump_env"));
|
||||
console.log(
|
||||
colors.gray("\n# 2. Inspect generated Docker Compose files:"),
|
||||
);
|
||||
console.log(colors.cyan("deno task setup dump_compose"));
|
||||
console.log(
|
||||
colors.gray("\n# 3. Create persistent storage paths on host:"),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
`mkdir -p ${currentConfig.spireDataPath} ${currentConfig.dbDataPath}`,
|
||||
),
|
||||
);
|
||||
console.log(colors.gray("\n# 4. Deploy fresh stacks:"));
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d",
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"\n┌──────────────────────────────────────────────────────────────┐",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"│ B. FOR EXISTING SYSTEM UPDATES (ROLLING RESTART) │",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.yellow(
|
||||
"└──────────────────────────────────────────────────────────────┘",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.gray(
|
||||
"# Pull newly pushed custom images and restart containers:",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml pull",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml pull",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||
),
|
||||
);
|
||||
});
|
||||
112
infra/setup/commands/compose.ts
Normal file
112
infra/setup/commands/compose.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
|
||||
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||
import {
|
||||
generateDockerCompose,
|
||||
generateSpireDockerCompose,
|
||||
} from "../compose.ts";
|
||||
import {
|
||||
AuthSetupConfig,
|
||||
DEFAULT_AUTH_CONFIG,
|
||||
generateEnv,
|
||||
generateSpireEnv,
|
||||
readEnv,
|
||||
} from "../env.ts";
|
||||
|
||||
export const dumpComposeCommand = new Command()
|
||||
.description("Output all generated Compose YAML configurations")
|
||||
.action(() => {
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"\n================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(" STACK 1: Auth-Yes Stack (infra/compose.yml)"),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(generateDockerCompose());
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(
|
||||
" STACK 2: Standalone SPIRE Stack (infra/compose.spire.yml)",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(generateSpireDockerCompose());
|
||||
});
|
||||
|
||||
export const dumpEnvCommand = new Command()
|
||||
.description("Output current environment configuration (.env and .env.spire)")
|
||||
.action(async () => {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"\n================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(" STACK 1: Auth-Yes Environment (infra/stack.env)"),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(generateEnv(currentConfig));
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.green(
|
||||
" STACK 2: SPIRE Stack Environment (infra/.env.spire)",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"================================================================\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(generateSpireEnv(currentConfig));
|
||||
});
|
||||
66
infra/setup/commands/secrets.ts
Normal file
66
infra/setup/commands/secrets.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
|
||||
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||
import {
|
||||
AuthSetupConfig,
|
||||
COMPOSE_PATH,
|
||||
DEFAULT_AUTH_CONFIG,
|
||||
ENV_PATH,
|
||||
readEnv,
|
||||
SPIRE_COMPOSE_PATH,
|
||||
SPIRE_ENV_PATH,
|
||||
} from "../env.ts";
|
||||
|
||||
export const secretsCommand = new Command()
|
||||
.description("Inspect secrets status and persistence paths")
|
||||
.action(async () => {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(
|
||||
"\n=== Auth-Yes Secrets & Persistence Topology ===\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
`${
|
||||
colors.cyan("Database Persistence Path:")
|
||||
} ${currentConfig.dbDataPath}`,
|
||||
);
|
||||
console.log(
|
||||
`${
|
||||
colors.cyan("SPIRE Persistence Path:")
|
||||
} ${currentConfig.spireDataPath}`,
|
||||
);
|
||||
console.log(
|
||||
`${
|
||||
colors.cyan("Local Artifact Files:")
|
||||
} ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, ${SPIRE_COMPOSE_PATH}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.cyan("PostgreSQL Password:")} ${
|
||||
currentConfig.dbPassword
|
||||
? colors.green(
|
||||
"✓ Configured (len: " + currentConfig.dbPassword.length + ")",
|
||||
)
|
||||
: colors.red("✗ Missing")
|
||||
}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.cyan("Application Secret:")} ${
|
||||
currentConfig.appSecret
|
||||
? colors.green(
|
||||
"✓ Configured (len: " + currentConfig.appSecret.length + ")",
|
||||
)
|
||||
: colors.red("✗ Missing")
|
||||
}`,
|
||||
);
|
||||
console.log(
|
||||
colors.gray(
|
||||
"\nTip: To dump raw environment variables, run: deno task setup dump_env\n",
|
||||
),
|
||||
);
|
||||
});
|
||||
61
infra/setup/commands/test.ts
Normal file
61
infra/setup/commands/test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
|
||||
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||
import { AuthSetupConfig, DEFAULT_AUTH_CONFIG, readEnv } from "../env.ts";
|
||||
|
||||
export async function handleTestConnection(domainName: string): Promise<void> {
|
||||
console.log(
|
||||
colors.bold(
|
||||
colors.blue(`\n=== Testing Auth Connection (https://${domainName}) ===`),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const res1 = await fetch(`https://${domainName}/`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (res1.status === 502 || res1.status === 503 || res1.status === 504) {
|
||||
throw new Error(`Gateway error: ${res1.status}`);
|
||||
}
|
||||
|
||||
if (res1.body) {
|
||||
await res1.body.cancel();
|
||||
}
|
||||
|
||||
console.log(
|
||||
colors.green(
|
||||
`✓ Auth service is up and reachable at https://${domainName}/`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
colors.red(
|
||||
`✗ Error: Could not reach the auth service at https://${domainName}/`,
|
||||
),
|
||||
);
|
||||
if (error instanceof Error) {
|
||||
console.log(colors.red(` Reason: ${error.message}`));
|
||||
} else {
|
||||
console.log(colors.red(` Reason: ${error}`));
|
||||
}
|
||||
console.log(
|
||||
colors.red(
|
||||
" Please verify the container is running and DNS/Traefik is resolving.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const testCommand = new Command()
|
||||
.description("Test Auth Connection")
|
||||
.action(async () => {
|
||||
const loadedEnv = await readEnv();
|
||||
const currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
await handleTestConnection(currentConfig.domainName);
|
||||
});
|
||||
134
infra/setup/prompts/auth.ts
Normal file
134
infra/setup/prompts/auth.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { Input, Secret } from "jsr:@cliffy/prompt@1.0.0-rc.7";
|
||||
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||
import {
|
||||
generateDockerCompose,
|
||||
generateSpireDockerCompose,
|
||||
} from "../compose.ts";
|
||||
import {
|
||||
AuthSetupConfig,
|
||||
COMPOSE_PATH,
|
||||
ENV_PATH,
|
||||
generateEnv,
|
||||
generateSpireEnv,
|
||||
SPIRE_COMPOSE_PATH,
|
||||
SPIRE_ENV_PATH,
|
||||
} from "../env.ts";
|
||||
|
||||
export async function promptAuthConfig(
|
||||
currentConfig: AuthSetupConfig,
|
||||
): Promise<Partial<AuthSetupConfig>> {
|
||||
console.log(
|
||||
colors.gray(
|
||||
"Please provide the following Auth Yes configuration details.\n",
|
||||
),
|
||||
);
|
||||
|
||||
const reg = await Input.prompt({
|
||||
message: "Enter the Primary Container Registry URL:",
|
||||
default: currentConfig.reg,
|
||||
});
|
||||
|
||||
const ghcrReg = await Input.prompt({
|
||||
message: "Enter the GHCR Mirror Registry URL (for SPIRE):",
|
||||
default: currentConfig.ghcrReg || "ghcr.atyg.org",
|
||||
});
|
||||
|
||||
const domainName = await Input.prompt({
|
||||
message: "Enter the Auth Domain Name:",
|
||||
hint: "E.g., auth.system.local",
|
||||
default: currentConfig.domainName,
|
||||
});
|
||||
|
||||
const appSecret = await Secret.prompt({
|
||||
message: "Enter the App Secret for the IDP:",
|
||||
default: currentConfig.appSecret,
|
||||
minLength: 16,
|
||||
});
|
||||
|
||||
const pwdMessage = currentConfig.dbPassword
|
||||
? "Enter the PostgreSQL database password: (Leave blank to keep existing password)"
|
||||
: "Enter the PostgreSQL database password:";
|
||||
|
||||
const pwdInput = await Secret.prompt({
|
||||
message: pwdMessage,
|
||||
minLength: currentConfig.dbPassword ? 0 : 1,
|
||||
});
|
||||
|
||||
const dbPassword = pwdInput === "" && currentConfig.dbPassword !== ""
|
||||
? currentConfig.dbPassword
|
||||
: pwdInput;
|
||||
|
||||
return { reg, ghcrReg, domainName, appSecret, dbPassword };
|
||||
}
|
||||
|
||||
export async function promptSpireConfig(
|
||||
currentConfig: AuthSetupConfig,
|
||||
): Promise<Partial<AuthSetupConfig>> {
|
||||
const dbDataPath = await Input.prompt({
|
||||
message: "Enter the Database Path on the Host:",
|
||||
default: currentConfig.dbDataPath,
|
||||
});
|
||||
|
||||
const spireDataPath = await Input.prompt({
|
||||
message: "Enter the SPIRE Path on the Host:",
|
||||
default: currentConfig.spireDataPath,
|
||||
});
|
||||
|
||||
return { dbDataPath, spireDataPath };
|
||||
}
|
||||
|
||||
export async function promptConfirmation(): Promise<void> {
|
||||
// Empty implementation as there was no explicit confirmation in cli.ts before
|
||||
// But we add it to cleanly delineate the flow as requested.
|
||||
}
|
||||
|
||||
export async function generateAuthSetupFiles(
|
||||
config: AuthSetupConfig,
|
||||
): Promise<void> {
|
||||
const envContent = generateEnv(config);
|
||||
await Deno.writeTextFile(ENV_PATH, envContent);
|
||||
|
||||
const spireEnvContent = generateSpireEnv(config);
|
||||
await Deno.writeTextFile(SPIRE_ENV_PATH, spireEnvContent);
|
||||
|
||||
const composeContent = generateDockerCompose();
|
||||
await Deno.writeTextFile(COMPOSE_PATH, composeContent);
|
||||
|
||||
const spireComposeContent = generateSpireDockerCompose();
|
||||
await Deno.writeTextFile(SPIRE_COMPOSE_PATH, spireComposeContent);
|
||||
|
||||
console.log(
|
||||
colors.green(
|
||||
`\n✓ Successfully generated ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, and ${SPIRE_COMPOSE_PATH}!`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
colors.green("Setup complete. You may deploy your stacks by running:\n"),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"1. Deploy SPIRE Stack:\n" +
|
||||
" podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n\n" +
|
||||
"2. Deploy Auth-Yes Stack:\n" +
|
||||
" podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleAuthSetup(
|
||||
currentConfig: AuthSetupConfig,
|
||||
): Promise<AuthSetupConfig> {
|
||||
const authConfig = await promptAuthConfig(currentConfig);
|
||||
const spireConfig = await promptSpireConfig(currentConfig);
|
||||
|
||||
const newConfig: AuthSetupConfig = {
|
||||
...currentConfig,
|
||||
...authConfig,
|
||||
...spireConfig,
|
||||
};
|
||||
|
||||
await promptConfirmation();
|
||||
await generateAuthSetupFiles(newConfig);
|
||||
|
||||
return newConfig;
|
||||
}
|
||||
145
infra/setup/prompts/wizard.ts
Normal file
145
infra/setup/prompts/wizard.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { Select } from "jsr:@cliffy/prompt@1.0.0-rc.7";
|
||||
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
||||
import {
|
||||
AuthSetupConfig,
|
||||
COMPOSE_PATH,
|
||||
DEFAULT_AUTH_CONFIG,
|
||||
ENV_PATH,
|
||||
readEnv,
|
||||
SPIRE_COMPOSE_PATH,
|
||||
} from "../env.ts";
|
||||
import { handleAuthSetup } from "./auth.ts";
|
||||
import { handleTestConnection } from "../commands/test.ts";
|
||||
import {
|
||||
downloadWorkloadProto,
|
||||
executeBuildImage,
|
||||
executeProtobufCompilation,
|
||||
generateBuildCommands,
|
||||
} from "../build.ts";
|
||||
|
||||
export async function handleReviewConfigs(): Promise<void> {
|
||||
const envContent = await Deno.readTextFile(ENV_PATH).catch(() => null);
|
||||
const composeContent = await Deno.readTextFile(COMPOSE_PATH).catch(() =>
|
||||
null
|
||||
);
|
||||
const spireComposeContent = await Deno.readTextFile(SPIRE_COMPOSE_PATH).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!envContent && !composeContent && !spireComposeContent) {
|
||||
console.log(
|
||||
colors.red("\n✗ No generated configs found. Run the setup first.\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const action = await Select.prompt({
|
||||
message: "Review Generated Configs",
|
||||
options: [
|
||||
{ name: "[Show .env]", value: "env" },
|
||||
{ name: "[Show compose.yml]", value: "compose" },
|
||||
{ name: "[Show compose.spire.yml]", value: "spire_compose" },
|
||||
{ name: "[Back to Main Menu]", value: "back" },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === "env") {
|
||||
console.log(colors.bold(colors.blue(`\n=== ${ENV_PATH} ===\n`)));
|
||||
console.log(envContent || colors.yellow("File not found."));
|
||||
console.log();
|
||||
} else if (action === "compose") {
|
||||
console.log(colors.bold(colors.blue(`\n=== ${COMPOSE_PATH} ===\n`)));
|
||||
console.log(composeContent || colors.yellow("File not found."));
|
||||
console.log();
|
||||
} else if (action === "spire_compose") {
|
||||
console.log(
|
||||
colors.bold(colors.blue(`\n=== ${SPIRE_COMPOSE_PATH} ===\n`)),
|
||||
);
|
||||
console.log(spireComposeContent || colors.yellow("File not found."));
|
||||
console.log();
|
||||
} else if (action === "back") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SIDE EFFECT: Runs the interactive CLI wizard.
|
||||
export async function runSetupWizard(): Promise<void> {
|
||||
console.log(colors.bold(colors.blue("=== Auth Setup Wizard ===\n")));
|
||||
|
||||
const loadedEnv = await readEnv();
|
||||
let currentConfig: AuthSetupConfig = {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...loadedEnv,
|
||||
};
|
||||
|
||||
if (Object.keys(loadedEnv).length > 0 && currentConfig.domainName) {
|
||||
await handleTestConnection(currentConfig.domainName);
|
||||
console.log();
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const action = await Select.prompt({
|
||||
message: "Main Menu",
|
||||
options: [
|
||||
{ name: "[Test Auth Connection]", value: "test" },
|
||||
{ name: "[Configure Auth Yes API]", value: "auth" },
|
||||
{ name: "[Compile Protobuf Definitions]", value: "compile_proto" },
|
||||
{ name: "[Review Generated Configs]", value: "review" },
|
||||
{ name: "[Build and Push Auth Image]", value: "build" },
|
||||
{ name: "[Exit]", value: "exit" },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === "test") {
|
||||
await handleTestConnection(currentConfig.domainName);
|
||||
console.log();
|
||||
} else if (action === "auth") {
|
||||
currentConfig = await handleAuthSetup(currentConfig);
|
||||
} else if (action === "compile_proto") {
|
||||
try {
|
||||
await downloadWorkloadProto();
|
||||
await executeProtobufCompilation();
|
||||
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (action === "review") {
|
||||
await handleReviewConfigs();
|
||||
} else if (action === "build") {
|
||||
try {
|
||||
const commands = generateBuildCommands(currentConfig.reg);
|
||||
await executeBuildImage(commands);
|
||||
console.log(colors.green("\n✓ Successfully built and pushed image!"));
|
||||
console.log(
|
||||
colors.green("You may now deploy your stack by running:\n"),
|
||||
);
|
||||
console.log(
|
||||
colors.cyan(
|
||||
"podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
colors.red(`\n✗ Build process failed: ${error.message}\n`),
|
||||
);
|
||||
} else {
|
||||
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
|
||||
}
|
||||
}
|
||||
} else if (action === "exit") {
|
||||
console.log(colors.gray("Exiting...\n"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,865 +1,22 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||
import { sqlWrapper } from "../db.ts";
|
||||
import { valkey } from "../valkey.ts";
|
||||
import { auditWrapper } from "../audit.ts";
|
||||
import {
|
||||
getAuthenticatedUser,
|
||||
isGlobalAdmin,
|
||||
requireAdmin,
|
||||
} from "../auth-session.ts";
|
||||
import { adminRateLimiter, getClientIp } from "../middleware.ts";
|
||||
import { computeJwkThumbprint } from "../http_signatures.ts";
|
||||
import { requireAdmin } from "../auth-session.ts";
|
||||
import { adminRateLimiter } from "../middleware.ts";
|
||||
|
||||
import { usersAdminRoutes } from "./admin/users.ts";
|
||||
import { appsAdminRoutes } from "./admin/apps.ts";
|
||||
import { rolesAdminRoutes } from "./admin/roles.ts";
|
||||
import { invitesAdminRoutes } from "./admin/invites.ts";
|
||||
import { hardwareKeysAdminRoutes } from "./admin/hardware_keys.ts";
|
||||
import { auditAdminRoutes } from "./admin/audit.ts";
|
||||
|
||||
export const adminRoutes = new Hono();
|
||||
|
||||
adminRoutes.use("*", requireAdmin);
|
||||
adminRoutes.use("*", adminRateLimiter);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Global Admin APIs (For the Management Console)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.get("/audit-logs", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const logs = await sqlWrapper.sql`
|
||||
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
||||
FROM audit_records a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
return c.json({ logs });
|
||||
});
|
||||
|
||||
adminRoutes.get("/users", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const users = await sqlWrapper.sql`
|
||||
SELECT id, username, display_name, account_status
|
||||
FROM users
|
||||
ORDER BY username ASC
|
||||
`;
|
||||
|
||||
return c.json({ users });
|
||||
});
|
||||
|
||||
adminRoutes.post("/users/:id/status", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const { status } = await c.req.json();
|
||||
|
||||
if (!["active", "pending", "suspended"].includes(status)) {
|
||||
return c.json({ error: "Invalid status" }, 400);
|
||||
}
|
||||
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`UPDATE users SET account_status = ${status} WHERE id = ${targetUserId} RETURNING id`
|
||||
.then((res: any) => res[0]);
|
||||
|
||||
if (!targetUser) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "user_status_changed", targetUserId, {
|
||||
newStatus: status,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
adminRoutes.post("/users/:id/profile", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const { displayName } = await c.req.json();
|
||||
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`UPDATE users SET display_name = ${
|
||||
displayName?.trim() || null
|
||||
} WHERE id = ${targetUserId} RETURNING id, username, display_name`
|
||||
.then((res: any) => res[0]);
|
||||
|
||||
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "user_profile_updated", targetUserId, {
|
||||
display_name: targetUser.display_name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, user: targetUser });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Admin Application Registry
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.get("/apps", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const apps = await sqlWrapper.sql`
|
||||
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
|
||||
COUNT(g.id) AS active_grants_count
|
||||
FROM apps a
|
||||
LEFT JOIN grants g ON a.id = g.app_id
|
||||
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
|
||||
ORDER BY a.created_at ASC
|
||||
`;
|
||||
return c.json({ apps });
|
||||
});
|
||||
|
||||
adminRoutes.post("/apps", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const {
|
||||
name,
|
||||
spiffeId,
|
||||
description,
|
||||
domain,
|
||||
is_public,
|
||||
bypass_paths,
|
||||
allowed_cidrs,
|
||||
} = await c.req.json();
|
||||
if (!name || !spiffeId) {
|
||||
return c.json({ error: "Name and SPIFFE ID are required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const newApp = await sqlWrapper.sql`
|
||||
INSERT INTO apps (name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs)
|
||||
VALUES (${name.trim()}, ${spiffeId.trim()}, ${
|
||||
description?.trim() || null
|
||||
}, ${domain?.trim() || null}, ${is_public || false}, ${
|
||||
bypass_paths || []
|
||||
}, ${allowed_cidrs || []})
|
||||
RETURNING id, name, spiffe_id, description, created_at
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "app_registered", newApp.id, {
|
||||
name: newApp.name,
|
||||
spiffe_id: newApp.spiffe_id,
|
||||
}, getClientIp(c));
|
||||
return c.json({ success: true, app: newApp });
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({
|
||||
error: "An application with this SPIFFE ID already exists",
|
||||
}, 409);
|
||||
}
|
||||
return c.json({ error: "Failed to register application" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
adminRoutes.put("/apps/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const appId = c.req.param("id");
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
domain,
|
||||
is_public,
|
||||
bypass_paths,
|
||||
allowed_cidrs,
|
||||
} = await c.req.json();
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: "Application name is required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedApp = await sqlWrapper.sql`
|
||||
UPDATE apps
|
||||
SET name = ${name.trim()},
|
||||
description = ${description?.trim() || null},
|
||||
domain = ${domain?.trim() || null},
|
||||
is_public = ${is_public || false},
|
||||
bypass_paths = ${bypass_paths || []},
|
||||
allowed_cidrs = ${allowed_cidrs || []}
|
||||
WHERE id = ${appId}
|
||||
RETURNING id, name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!updatedApp) return c.json({ error: "Application not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "app_updated", updatedApp.id, {
|
||||
name: updatedApp.name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, app: updatedApp });
|
||||
} catch (_err: any) {
|
||||
return c.json({ error: "Failed to update application" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
adminRoutes.delete("/apps/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const appId = c.req.param("id");
|
||||
const app = await sqlWrapper
|
||||
.sql`DELETE FROM apps WHERE id = ${appId} RETURNING id, name`
|
||||
.then((res: any) => res[0]);
|
||||
if (app) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"app_deleted",
|
||||
appId,
|
||||
{ name: app.name },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
return c.json({ error: "Application not found" }, 404);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Admin Role Catalog Management
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.get("/roles", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const appId = c.req.query("appId");
|
||||
let roles;
|
||||
if (appId) {
|
||||
roles = await sqlWrapper.sql`
|
||||
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
||||
a.name AS app_name
|
||||
FROM roles r
|
||||
LEFT JOIN apps a ON r.app_id = a.id
|
||||
WHERE r.app_id IS NULL OR r.app_id = ${appId}
|
||||
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
||||
`;
|
||||
} else {
|
||||
roles = await sqlWrapper.sql`
|
||||
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
||||
a.name AS app_name
|
||||
FROM roles r
|
||||
LEFT JOIN apps a ON r.app_id = a.id
|
||||
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
return c.json({ roles });
|
||||
});
|
||||
|
||||
adminRoutes.post("/roles", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const { name, description, appId } = await c.req.json();
|
||||
if (
|
||||
!name || typeof name !== "string" || name.trim().length < 2 ||
|
||||
name.trim().length > 32
|
||||
) {
|
||||
return c.json(
|
||||
{ error: "Role name must be between 2 and 32 characters" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
||||
|
||||
let validatedAppId = null;
|
||||
if (appId && typeof appId === "string" && appId.trim()) {
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
if (!uuidRegex.test(appId)) {
|
||||
return c.json({ error: "Invalid App UUID" }, 400);
|
||||
}
|
||||
const appExists = await sqlWrapper
|
||||
.sql`SELECT id, name FROM apps WHERE id = ${appId}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!appExists) {
|
||||
return c.json({ error: "Selected application does not exist" }, 404);
|
||||
}
|
||||
validatedAppId = appId;
|
||||
}
|
||||
|
||||
try {
|
||||
const newRole = await sqlWrapper.sql`
|
||||
INSERT INTO roles (name, description, app_id)
|
||||
VALUES (${normalizedName}, ${
|
||||
description?.trim() || null
|
||||
}, ${validatedAppId})
|
||||
RETURNING id, name, description, app_id, created_at
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "role_created", validatedAppId, {
|
||||
role_name: newRole.name,
|
||||
scope: validatedAppId ? "app-specific" : "global",
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, role: newRole });
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({
|
||||
error: "This role already exists for the selected scope",
|
||||
}, 409);
|
||||
}
|
||||
return c.json({ error: "Failed to create role" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
adminRoutes.put("/roles/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const roleId = c.req.param("id");
|
||||
const { name, description } = await c.req.json();
|
||||
|
||||
if (!name || typeof name !== "string" || name.trim().length < 2) {
|
||||
return c.json({ error: "Role name must be at least 2 characters" }, 400);
|
||||
}
|
||||
|
||||
const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
||||
|
||||
try {
|
||||
const updatedRole = await sqlWrapper.sql`
|
||||
UPDATE roles
|
||||
SET name = ${normalizedName},
|
||||
description = ${description?.trim() || null}
|
||||
WHERE id = ${roleId}
|
||||
RETURNING id, name, description, app_id, created_at
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!updatedRole) return c.json({ error: "Role not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "role_updated", updatedRole.app_id, {
|
||||
role_name: updatedRole.name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, role: updatedRole });
|
||||
} catch (_err: any) {
|
||||
return c.json({ error: "Failed to update role" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
adminRoutes.delete("/roles/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const roleId = c.req.param("id");
|
||||
const role = await sqlWrapper
|
||||
.sql`SELECT id, name, app_id FROM roles WHERE id = ${roleId}`.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (!role) {
|
||||
return c.json({ error: "Role not found" }, 404);
|
||||
}
|
||||
|
||||
if (role.name === "admin" && role.app_id === null) {
|
||||
return c.json({ error: "The global 'admin' role cannot be deleted" }, 400);
|
||||
}
|
||||
|
||||
await sqlWrapper.sql`DELETE FROM roles WHERE id = ${roleId}`;
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"role_deleted",
|
||||
role.app_id,
|
||||
{ role_name: role.name },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Admin Invites / Registration Tokens
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.get("/invites", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const invites = await sqlWrapper.sql`
|
||||
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
|
||||
a.name AS app_name, a.id AS app_id,
|
||||
u.username AS used_by_username
|
||||
FROM invites i
|
||||
LEFT JOIN apps a ON i.app_id = a.id
|
||||
LEFT JOIN users u ON i.used_by = u.id
|
||||
ORDER BY i.created_at DESC
|
||||
`;
|
||||
return c.json({ invites });
|
||||
});
|
||||
|
||||
adminRoutes.post("/invites/create", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const {
|
||||
appId,
|
||||
role,
|
||||
expiresInDays,
|
||||
customCode,
|
||||
usageLimitType,
|
||||
maxUses,
|
||||
autoActivate,
|
||||
} = await c.req.json();
|
||||
const assignedRole = (typeof role === "string" && role.trim())
|
||||
? role.trim()
|
||||
: "user";
|
||||
|
||||
let validatedAppId = null;
|
||||
if (appId) {
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
if (typeof appId !== "string" || !uuidRegex.test(appId)) {
|
||||
return c.json({ error: "appId must be a valid UUID string" }, 400);
|
||||
}
|
||||
const appExists = await sqlWrapper
|
||||
.sql`SELECT id FROM apps WHERE id = ${appId}`.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (!appExists) {
|
||||
return c.json({ error: "Target application does not exist" }, 404);
|
||||
}
|
||||
validatedAppId = appId;
|
||||
}
|
||||
|
||||
let parsedMaxUses: number | null = 1;
|
||||
if (usageLimitType === "unlimited") {
|
||||
parsedMaxUses = null;
|
||||
} else if (usageLimitType === "limited") {
|
||||
const n = parseInt(maxUses);
|
||||
parsedMaxUses = (!isNaN(n) && n > 0) ? n : 5;
|
||||
} else {
|
||||
parsedMaxUses = 1; // single-use default
|
||||
}
|
||||
|
||||
const shouldAutoActivate = autoActivate !== false;
|
||||
|
||||
const inviteCode =
|
||||
(customCode && typeof customCode === "string" && customCode.trim())
|
||||
? customCode.trim()
|
||||
: encodeBase64Url(crypto.getRandomValues(new Uint8Array(24)));
|
||||
|
||||
const days = Number(expiresInDays) || 7;
|
||||
if (!Number.isInteger(days) || days < 1 || days > 30) {
|
||||
return c.json({
|
||||
error: "expiresInDays must be an integer between 1 and 30",
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + days);
|
||||
|
||||
try {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO invites (code, app_id, role, created_by, max_uses, uses_count, auto_activate, expires_at)
|
||||
VALUES (${inviteCode}, ${validatedAppId}, ${assignedRole}, ${auth.userId}, ${parsedMaxUses}, 0, ${shouldAutoActivate}, ${expiresAt})
|
||||
`;
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({ error: "An invite with this code already exists" }, 409);
|
||||
}
|
||||
return c.json({ error: "Failed to generate invite" }, 500);
|
||||
}
|
||||
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"invite_created",
|
||||
validatedAppId,
|
||||
{
|
||||
code: inviteCode,
|
||||
role: assignedRole,
|
||||
max_uses: parsedMaxUses,
|
||||
auto_activate: shouldAutoActivate,
|
||||
expiresInDays: days,
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
inviteCode,
|
||||
expiresAt,
|
||||
maxUses: parsedMaxUses,
|
||||
autoActivate: shouldAutoActivate,
|
||||
});
|
||||
});
|
||||
|
||||
adminRoutes.get("/invites/:id/redemptions", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const inviteId = c.req.param("id");
|
||||
const redemptions = await sqlWrapper.sql`
|
||||
SELECT ir.id, ir.redeemed_at, u.id AS user_id, u.username, u.display_name, u.account_status
|
||||
FROM invite_redemptions ir
|
||||
JOIN users u ON ir.user_id = u.id
|
||||
WHERE ir.invite_id = ${inviteId}
|
||||
ORDER BY ir.redeemed_at DESC
|
||||
`;
|
||||
|
||||
return c.json({ redemptions });
|
||||
});
|
||||
|
||||
adminRoutes.delete("/invites/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const inviteId = c.req.param("id");
|
||||
const invite = await sqlWrapper
|
||||
.sql`DELETE FROM invites WHERE id = ${inviteId} RETURNING id, code`
|
||||
.then((res: any) => res[0]);
|
||||
if (invite) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"invite_revoked",
|
||||
inviteId,
|
||||
{ code: invite.code },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
return c.json({ error: "Invite not found" }, 404);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Admin User RBAC Grants Management
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.get("/users/:id/grants", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const grants = await sqlWrapper.sql`
|
||||
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
|
||||
FROM grants g
|
||||
JOIN apps a ON g.app_id = a.id
|
||||
WHERE g.user_id = ${targetUserId}
|
||||
ORDER BY a.name ASC
|
||||
`;
|
||||
return c.json({ grants });
|
||||
});
|
||||
|
||||
adminRoutes.post("/users/:id/grants", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const { appId, role } = await c.req.json();
|
||||
|
||||
if (!appId || !role) {
|
||||
return c.json({ error: "appId and role are required" }, 400);
|
||||
}
|
||||
|
||||
const app = await sqlWrapper
|
||||
.sql`SELECT id, name FROM apps WHERE id = ${appId}`.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (!app) return c.json({ error: "Application not found" }, 404);
|
||||
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`SELECT id, username FROM users WHERE id = ${targetUserId}`.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO grants (user_id, app_id, role)
|
||||
VALUES (${targetUserId}, ${appId}, ${role})
|
||||
ON CONFLICT (user_id, app_id) DO UPDATE SET role = ${role}
|
||||
`;
|
||||
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"user_grant_assigned",
|
||||
targetUserId,
|
||||
{ app_id: appId, app_name: app.name, role },
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
adminRoutes.delete("/users/:id/grants/:appId", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const { id: targetUserId, appId } = c.req.param();
|
||||
|
||||
const grant = await sqlWrapper.sql`
|
||||
DELETE FROM grants
|
||||
WHERE user_id = ${targetUserId} AND app_id = ${appId}
|
||||
RETURNING id
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (grant) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"user_grant_revoked",
|
||||
targetUserId,
|
||||
{ app_id: appId },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
|
||||
return c.json({ error: "Grant not found" }, 404);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Admin AAGUID Management
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.get("/aaguid", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const allowlist = await sqlWrapper
|
||||
.sql`SELECT id, aaguid, description, created_at FROM aaguid_allowlist ORDER BY created_at DESC`;
|
||||
return c.json({ allowlist });
|
||||
});
|
||||
|
||||
adminRoutes.post("/aaguid", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const { aaguid, description } = await c.req.json();
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/i;
|
||||
if (!aaguid || !uuidRegex.test(aaguid)) {
|
||||
return c.json({ error: "Valid AAGUID (UUID) is required" }, 400);
|
||||
}
|
||||
try {
|
||||
await sqlWrapper
|
||||
.sql`INSERT INTO aaguid_allowlist (aaguid, description) VALUES (${aaguid.toLowerCase()}, ${
|
||||
description || null
|
||||
})`;
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"aaguid_added",
|
||||
null,
|
||||
{ aaguid },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({ error: "AAGUID already exists" }, 409);
|
||||
}
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
adminRoutes.delete("/aaguid/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const id = c.req.param("id");
|
||||
const record = await sqlWrapper
|
||||
.sql`DELETE FROM aaguid_allowlist WHERE id = ${id} RETURNING aaguid`
|
||||
.then((res: any) => res[0]);
|
||||
if (record) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"aaguid_removed",
|
||||
null,
|
||||
{ aaguid: record.aaguid },
|
||||
getClientIp(c),
|
||||
);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Admin HWK (Header Web Key) Management
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.post("/hwk", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const { jwk, name } = await c.req.json();
|
||||
if (!jwk || !name || typeof name !== "string") {
|
||||
return c.json({ error: "Missing required fields: jwk, name" }, 400);
|
||||
}
|
||||
|
||||
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) {
|
||||
return c.json({ error: "Invalid JWK: Must be an Ed25519 OKP key" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const fingerprint = await computeJwkThumbprint(jwk);
|
||||
|
||||
// 1. Dual Storage: PostgreSQL (Durability)
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO hwk_keys (fingerprint, public_key, name)
|
||||
VALUES (${fingerprint}, ${JSON.stringify(jwk)}, ${name})
|
||||
`;
|
||||
|
||||
// 2. Dual Storage: Valkey (O(1) Verification)
|
||||
await valkey.sadd("auth:hwk:fingerprints", fingerprint);
|
||||
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"hwk_added",
|
||||
null,
|
||||
{ fingerprint, name },
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
return c.json({ success: true, fingerprint }, 201);
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({ error: "This key has already been registered" }, 409);
|
||||
}
|
||||
console.error("Failed to add HWK:", err);
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
adminRoutes.delete("/hwk/:fingerprint", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const fingerprint = c.req.param("fingerprint");
|
||||
|
||||
// 1. Remove from PostgreSQL
|
||||
const record = await sqlWrapper.sql`
|
||||
DELETE FROM hwk_keys WHERE fingerprint = ${fingerprint} RETURNING id, name
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (record) {
|
||||
// 2. Remove from Valkey
|
||||
try {
|
||||
await valkey.srem("auth:hwk:fingerprints", fingerprint);
|
||||
} catch (_err) {}
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "hwk_removed", null, {
|
||||
fingerprint,
|
||||
name: record.name,
|
||||
}, getClientIp(c));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
|
||||
return c.json({ error: "Key not found" }, 404);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Global Session and Device Revocation (Admin)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.get("/users/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const targetUserId = c.req.param("id");
|
||||
const user = await sqlWrapper
|
||||
.sql`SELECT id, username, display_name, account_status FROM users WHERE id = ${targetUserId}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!user) return c.json({ error: "User not found" }, 404);
|
||||
const sessions = await sqlWrapper
|
||||
.sql`SELECT id, created_at, expires_at FROM sessions WHERE user_id = ${targetUserId} ORDER BY created_at DESC`;
|
||||
const passkeys = await sqlWrapper
|
||||
.sql`SELECT id, credential_id, counter FROM passkeys WHERE user_id = ${targetUserId}`;
|
||||
return c.json({ user, sessions, passkeys });
|
||||
});
|
||||
|
||||
adminRoutes.delete("/sessions/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const sessionId = c.req.param("id");
|
||||
const session = await sqlWrapper
|
||||
.sql`DELETE FROM sessions WHERE id = ${sessionId} RETURNING user_id`
|
||||
.then((res: any) => res[0]);
|
||||
if (session) {
|
||||
try {
|
||||
await valkey.del(sessionId);
|
||||
} catch (_err) {}
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"admin_session_revoked",
|
||||
session.user_id,
|
||||
{
|
||||
revoked_session_id: sessionId,
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
adminRoutes.delete("/users/:id/sessions", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const targetUserId = c.req.param("id");
|
||||
const sessions = await sqlWrapper
|
||||
.sql`DELETE FROM sessions WHERE user_id = ${targetUserId} RETURNING id`;
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
await valkey.del(session.id);
|
||||
} catch (_err) {}
|
||||
}
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"admin_all_sessions_revoked",
|
||||
targetUserId,
|
||||
null,
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
adminRoutes.delete("/users/:userId/passkeys/:passkeyId", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const { userId, passkeyId } = c.req.param();
|
||||
const passkey = await sqlWrapper
|
||||
.sql`DELETE FROM passkeys WHERE id = ${passkeyId} AND user_id = ${userId} RETURNING id`
|
||||
.then((res: any) => res[0]);
|
||||
if (passkey) {
|
||||
auditWrapper.auditLog(auth.userId, "admin_passkey_revoked", userId, {
|
||||
passkey_id: passkey.id,
|
||||
}, getClientIp(c));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
return c.json({ error: "Passkey not found" }, 404);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Out-of-Band Account Recovery (Use Case 12)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
adminRoutes.post("/users/:id/recovery", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const targetUserId = c.req.param("id");
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`SELECT id FROM users WHERE id = ${targetUserId}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
||||
const recoveryCode = encodeBase64Url(
|
||||
crypto.getRandomValues(new Uint8Array(24)),
|
||||
);
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 1);
|
||||
await sqlWrapper
|
||||
.sql`INSERT INTO recovery_links (code, user_id, created_by, expires_at) VALUES (${recoveryCode}, ${targetUserId}, ${auth.userId}, ${expiresAt})`;
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"recovery_link_created",
|
||||
targetUserId,
|
||||
null,
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true, recoveryCode, expiresAt });
|
||||
});
|
||||
|
||||
adminRoutes.get("/check", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
return c.json({ isAdmin });
|
||||
});
|
||||
adminRoutes.route("/users", usersAdminRoutes);
|
||||
adminRoutes.route("/apps", appsAdminRoutes);
|
||||
adminRoutes.route("/roles", rolesAdminRoutes);
|
||||
adminRoutes.route("/invites", invitesAdminRoutes);
|
||||
adminRoutes.route("/", hardwareKeysAdminRoutes);
|
||||
adminRoutes.route("/", auditAdminRoutes);
|
||||
|
||||
129
server/routes/admin/apps.ts
Normal file
129
server/routes/admin/apps.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import { getAuthenticatedUser } from "../../auth-session.ts";
|
||||
import { getClientIp } from "../../middleware.ts";
|
||||
|
||||
export const appsAdminRoutes = new Hono();
|
||||
|
||||
appsAdminRoutes.get("/", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const apps = await sqlWrapper.sql`
|
||||
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
|
||||
COUNT(g.id) AS active_grants_count
|
||||
FROM apps a
|
||||
LEFT JOIN grants g ON a.id = g.app_id
|
||||
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
|
||||
ORDER BY a.created_at ASC
|
||||
`;
|
||||
return c.json({ apps });
|
||||
});
|
||||
|
||||
appsAdminRoutes.post("/", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const {
|
||||
name,
|
||||
spiffeId,
|
||||
description,
|
||||
domain,
|
||||
is_public,
|
||||
bypass_paths,
|
||||
allowed_cidrs,
|
||||
} = await c.req.json();
|
||||
if (!name || !spiffeId) {
|
||||
return c.json({ error: "Name and SPIFFE ID are required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const newApp = await sqlWrapper.sql`
|
||||
INSERT INTO apps (name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs)
|
||||
VALUES (${name.trim()}, ${spiffeId.trim()}, ${
|
||||
description?.trim() || null
|
||||
}, ${domain?.trim() || null}, ${is_public || false}, ${
|
||||
bypass_paths || []
|
||||
}, ${allowed_cidrs || []})
|
||||
RETURNING id, name, spiffe_id, description, created_at
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "app_registered", newApp.id, {
|
||||
name: newApp.name,
|
||||
spiffe_id: newApp.spiffe_id,
|
||||
}, getClientIp(c));
|
||||
return c.json({ success: true, app: newApp });
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({
|
||||
error: "An application with this SPIFFE ID already exists",
|
||||
}, 409);
|
||||
}
|
||||
return c.json({ error: "Failed to register application" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
appsAdminRoutes.put("/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const appId = c.req.param("id");
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
domain,
|
||||
is_public,
|
||||
bypass_paths,
|
||||
allowed_cidrs,
|
||||
} = await c.req.json();
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: "Application name is required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedApp = await sqlWrapper.sql`
|
||||
UPDATE apps
|
||||
SET name = ${name.trim()},
|
||||
description = ${description?.trim() || null},
|
||||
domain = ${domain?.trim() || null},
|
||||
is_public = ${is_public || false},
|
||||
bypass_paths = ${bypass_paths || []},
|
||||
allowed_cidrs = ${allowed_cidrs || []}
|
||||
WHERE id = ${appId}
|
||||
RETURNING id, name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!updatedApp) return c.json({ error: "Application not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "app_updated", updatedApp.id, {
|
||||
name: updatedApp.name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, app: updatedApp });
|
||||
} catch (_err: any) {
|
||||
return c.json({ error: "Failed to update application" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
appsAdminRoutes.delete("/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const appId = c.req.param("id");
|
||||
const app = await sqlWrapper
|
||||
.sql`DELETE FROM apps WHERE id = ${appId} RETURNING id, name`
|
||||
.then((res: any) => res[0]);
|
||||
if (app) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"app_deleted",
|
||||
appId,
|
||||
{ name: app.name },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
return c.json({ error: "Application not found" }, 404);
|
||||
});
|
||||
55
server/routes/admin/audit.ts
Normal file
55
server/routes/admin/audit.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { valkey } from "../../valkey.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import { getAuthenticatedUser, isGlobalAdmin } from "../../auth-session.ts";
|
||||
import { getClientIp } from "../../middleware.ts";
|
||||
|
||||
export const auditAdminRoutes = new Hono();
|
||||
|
||||
auditAdminRoutes.get("/audit-logs", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const logs = await sqlWrapper.sql`
|
||||
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
||||
FROM audit_records a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
return c.json({ logs });
|
||||
});
|
||||
|
||||
auditAdminRoutes.get("/check", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
return c.json({ isAdmin });
|
||||
});
|
||||
|
||||
auditAdminRoutes.delete("/sessions/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const sessionId = c.req.param("id");
|
||||
const session = await sqlWrapper
|
||||
.sql`DELETE FROM sessions WHERE id = ${sessionId} RETURNING user_id`
|
||||
.then((res: any) => res[0]);
|
||||
if (session) {
|
||||
try {
|
||||
await valkey.del(sessionId);
|
||||
} catch (_err) {}
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"admin_session_revoked",
|
||||
session.user_id,
|
||||
{
|
||||
revoked_session_id: sessionId,
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
136
server/routes/admin/hardware_keys.ts
Normal file
136
server/routes/admin/hardware_keys.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { valkey } from "../../valkey.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import { getAuthenticatedUser } from "../../auth-session.ts";
|
||||
import { getClientIp } from "../../middleware.ts";
|
||||
import { computeJwkThumbprint } from "../../http_signatures.ts";
|
||||
|
||||
export const hardwareKeysAdminRoutes = new Hono();
|
||||
|
||||
hardwareKeysAdminRoutes.get("/aaguid", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const allowlist = await sqlWrapper
|
||||
.sql`SELECT id, aaguid, description, created_at FROM aaguid_allowlist ORDER BY created_at DESC`;
|
||||
return c.json({ allowlist });
|
||||
});
|
||||
|
||||
hardwareKeysAdminRoutes.post("/aaguid", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const { aaguid, description } = await c.req.json();
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/i;
|
||||
if (!aaguid || !uuidRegex.test(aaguid)) {
|
||||
return c.json({ error: "Valid AAGUID (UUID) is required" }, 400);
|
||||
}
|
||||
try {
|
||||
await sqlWrapper
|
||||
.sql`INSERT INTO aaguid_allowlist (aaguid, description) VALUES (${aaguid.toLowerCase()}, ${
|
||||
description || null
|
||||
})`;
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"aaguid_added",
|
||||
null,
|
||||
{ aaguid },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({ error: "AAGUID already exists" }, 409);
|
||||
}
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
hardwareKeysAdminRoutes.delete("/aaguid/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const id = c.req.param("id");
|
||||
const record = await sqlWrapper
|
||||
.sql`DELETE FROM aaguid_allowlist WHERE id = ${id} RETURNING aaguid`
|
||||
.then((res: any) => res[0]);
|
||||
if (record) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"aaguid_removed",
|
||||
null,
|
||||
{ aaguid: record.aaguid },
|
||||
getClientIp(c),
|
||||
);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
hardwareKeysAdminRoutes.post("/hwk", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const { jwk, name } = await c.req.json();
|
||||
if (!jwk || !name || typeof name !== "string") {
|
||||
return c.json({ error: "Missing required fields: jwk, name" }, 400);
|
||||
}
|
||||
|
||||
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) {
|
||||
return c.json({ error: "Invalid JWK: Must be an Ed25519 OKP key" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const fingerprint = await computeJwkThumbprint(jwk);
|
||||
|
||||
// 1. Dual Storage: PostgreSQL (Durability)
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO hwk_keys (fingerprint, public_key, name)
|
||||
VALUES (${fingerprint}, ${JSON.stringify(jwk)}, ${name})
|
||||
`;
|
||||
|
||||
// 2. Dual Storage: Valkey (O(1) Verification)
|
||||
await valkey.sadd("auth:hwk:fingerprints", fingerprint);
|
||||
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"hwk_added",
|
||||
null,
|
||||
{ fingerprint, name },
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
return c.json({ success: true, fingerprint }, 201);
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({ error: "This key has already been registered" }, 409);
|
||||
}
|
||||
console.error("Failed to add HWK:", err);
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
hardwareKeysAdminRoutes.delete("/hwk/:fingerprint", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const fingerprint = c.req.param("fingerprint");
|
||||
|
||||
// 1. Remove from PostgreSQL
|
||||
const record = await sqlWrapper.sql`
|
||||
DELETE FROM hwk_keys WHERE fingerprint = ${fingerprint} RETURNING id, name
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (record) {
|
||||
// 2. Remove from Valkey
|
||||
try {
|
||||
await valkey.srem("auth:hwk:fingerprints", fingerprint);
|
||||
} catch (_err) {}
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "hwk_removed", null, {
|
||||
fingerprint,
|
||||
name: record.name,
|
||||
}, getClientIp(c));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
|
||||
return c.json({ error: "Key not found" }, 404);
|
||||
});
|
||||
157
server/routes/admin/invites.ts
Normal file
157
server/routes/admin/invites.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import { getAuthenticatedUser } from "../../auth-session.ts";
|
||||
import { getClientIp } from "../../middleware.ts";
|
||||
|
||||
export const invitesAdminRoutes = new Hono();
|
||||
|
||||
invitesAdminRoutes.get("/", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const invites = await sqlWrapper.sql`
|
||||
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
|
||||
a.name AS app_name, a.id AS app_id,
|
||||
u.username AS used_by_username
|
||||
FROM invites i
|
||||
LEFT JOIN apps a ON i.app_id = a.id
|
||||
LEFT JOIN users u ON i.used_by = u.id
|
||||
ORDER BY i.created_at DESC
|
||||
`;
|
||||
return c.json({ invites });
|
||||
});
|
||||
|
||||
invitesAdminRoutes.post("/create", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const {
|
||||
appId,
|
||||
role,
|
||||
expiresInDays,
|
||||
customCode,
|
||||
usageLimitType,
|
||||
maxUses,
|
||||
autoActivate,
|
||||
} = await c.req.json();
|
||||
const assignedRole = (typeof role === "string" && role.trim())
|
||||
? role.trim()
|
||||
: "user";
|
||||
|
||||
let validatedAppId = null;
|
||||
if (appId) {
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
if (typeof appId !== "string" || !uuidRegex.test(appId)) {
|
||||
return c.json({ error: "appId must be a valid UUID string" }, 400);
|
||||
}
|
||||
const appExists = await sqlWrapper
|
||||
.sql`SELECT id FROM apps WHERE id = ${appId}`.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (!appExists) {
|
||||
return c.json({ error: "Target application does not exist" }, 404);
|
||||
}
|
||||
validatedAppId = appId;
|
||||
}
|
||||
|
||||
let parsedMaxUses: number | null = 1;
|
||||
if (usageLimitType === "unlimited") {
|
||||
parsedMaxUses = null;
|
||||
} else if (usageLimitType === "limited") {
|
||||
const n = parseInt(maxUses);
|
||||
parsedMaxUses = (!isNaN(n) && n > 0) ? n : 5;
|
||||
} else {
|
||||
parsedMaxUses = 1; // single-use default
|
||||
}
|
||||
|
||||
const shouldAutoActivate = autoActivate !== false;
|
||||
|
||||
const inviteCode =
|
||||
(customCode && typeof customCode === "string" && customCode.trim())
|
||||
? customCode.trim()
|
||||
: encodeBase64Url(crypto.getRandomValues(new Uint8Array(24)));
|
||||
|
||||
const days = Number(expiresInDays) || 7;
|
||||
if (!Number.isInteger(days) || days < 1 || days > 30) {
|
||||
return c.json({
|
||||
error: "expiresInDays must be an integer between 1 and 30",
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + days);
|
||||
|
||||
try {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO invites (code, app_id, role, created_by, max_uses, uses_count, auto_activate, expires_at)
|
||||
VALUES (${inviteCode}, ${validatedAppId}, ${assignedRole}, ${auth.userId}, ${parsedMaxUses}, 0, ${shouldAutoActivate}, ${expiresAt})
|
||||
`;
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({ error: "An invite with this code already exists" }, 409);
|
||||
}
|
||||
return c.json({ error: "Failed to generate invite" }, 500);
|
||||
}
|
||||
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"invite_created",
|
||||
validatedAppId,
|
||||
{
|
||||
code: inviteCode,
|
||||
role: assignedRole,
|
||||
max_uses: parsedMaxUses,
|
||||
auto_activate: shouldAutoActivate,
|
||||
expiresInDays: days,
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
inviteCode,
|
||||
expiresAt,
|
||||
maxUses: parsedMaxUses,
|
||||
autoActivate: shouldAutoActivate,
|
||||
});
|
||||
});
|
||||
|
||||
invitesAdminRoutes.get("/:id/redemptions", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const inviteId = c.req.param("id");
|
||||
const redemptions = await sqlWrapper.sql`
|
||||
SELECT ir.id, ir.redeemed_at, u.id AS user_id, u.username, u.display_name, u.account_status
|
||||
FROM invite_redemptions ir
|
||||
JOIN users u ON ir.user_id = u.id
|
||||
WHERE ir.invite_id = ${inviteId}
|
||||
ORDER BY ir.redeemed_at DESC
|
||||
`;
|
||||
|
||||
return c.json({ redemptions });
|
||||
});
|
||||
|
||||
invitesAdminRoutes.delete("/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const inviteId = c.req.param("id");
|
||||
const invite = await sqlWrapper
|
||||
.sql`DELETE FROM invites WHERE id = ${inviteId} RETURNING id, code`
|
||||
.then((res: any) => res[0]);
|
||||
if (invite) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"invite_revoked",
|
||||
inviteId,
|
||||
{ code: invite.code },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
return c.json({ error: "Invite not found" }, 404);
|
||||
});
|
||||
155
server/routes/admin/roles.ts
Normal file
155
server/routes/admin/roles.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import { getAuthenticatedUser } from "../../auth-session.ts";
|
||||
import { getClientIp } from "../../middleware.ts";
|
||||
|
||||
export const rolesAdminRoutes = new Hono();
|
||||
|
||||
rolesAdminRoutes.get("/", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const appId = c.req.query("appId");
|
||||
let roles;
|
||||
if (appId) {
|
||||
roles = await sqlWrapper.sql`
|
||||
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
||||
a.name AS app_name
|
||||
FROM roles r
|
||||
LEFT JOIN apps a ON r.app_id = a.id
|
||||
WHERE r.app_id IS NULL OR r.app_id = ${appId}
|
||||
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
||||
`;
|
||||
} else {
|
||||
roles = await sqlWrapper.sql`
|
||||
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
||||
a.name AS app_name
|
||||
FROM roles r
|
||||
LEFT JOIN apps a ON r.app_id = a.id
|
||||
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
return c.json({ roles });
|
||||
});
|
||||
|
||||
rolesAdminRoutes.post("/", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const { name, description, appId } = await c.req.json();
|
||||
if (
|
||||
!name || typeof name !== "string" || name.trim().length < 2 ||
|
||||
name.trim().length > 32
|
||||
) {
|
||||
return c.json(
|
||||
{ error: "Role name must be between 2 and 32 characters" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
||||
|
||||
let validatedAppId = null;
|
||||
if (appId && typeof appId === "string" && appId.trim()) {
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
if (!uuidRegex.test(appId)) {
|
||||
return c.json({ error: "Invalid App UUID" }, 400);
|
||||
}
|
||||
const appExists = await sqlWrapper
|
||||
.sql`SELECT id, name FROM apps WHERE id = ${appId}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!appExists) {
|
||||
return c.json({ error: "Selected application does not exist" }, 404);
|
||||
}
|
||||
validatedAppId = appId;
|
||||
}
|
||||
|
||||
try {
|
||||
const newRole = await sqlWrapper.sql`
|
||||
INSERT INTO roles (name, description, app_id)
|
||||
VALUES (${normalizedName}, ${
|
||||
description?.trim() || null
|
||||
}, ${validatedAppId})
|
||||
RETURNING id, name, description, app_id, created_at
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "role_created", validatedAppId, {
|
||||
role_name: newRole.name,
|
||||
scope: validatedAppId ? "app-specific" : "global",
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, role: newRole });
|
||||
} catch (err: any) {
|
||||
if (err.code === "23505") {
|
||||
return c.json({
|
||||
error: "This role already exists for the selected scope",
|
||||
}, 409);
|
||||
}
|
||||
return c.json({ error: "Failed to create role" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
rolesAdminRoutes.put("/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const roleId = c.req.param("id");
|
||||
const { name, description } = await c.req.json();
|
||||
|
||||
if (!name || typeof name !== "string" || name.trim().length < 2) {
|
||||
return c.json({ error: "Role name must be at least 2 characters" }, 400);
|
||||
}
|
||||
|
||||
const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
||||
|
||||
try {
|
||||
const updatedRole = await sqlWrapper.sql`
|
||||
UPDATE roles
|
||||
SET name = ${normalizedName},
|
||||
description = ${description?.trim() || null}
|
||||
WHERE id = ${roleId}
|
||||
RETURNING id, name, description, app_id, created_at
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!updatedRole) return c.json({ error: "Role not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "role_updated", updatedRole.app_id, {
|
||||
role_name: updatedRole.name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, role: updatedRole });
|
||||
} catch (_err: any) {
|
||||
return c.json({ error: "Failed to update role" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
rolesAdminRoutes.delete("/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const roleId = c.req.param("id");
|
||||
const role = await sqlWrapper
|
||||
.sql`SELECT id, name, app_id FROM roles WHERE id = ${roleId}`.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (!role) {
|
||||
return c.json({ error: "Role not found" }, 404);
|
||||
}
|
||||
|
||||
if (role.name === "admin" && role.app_id === null) {
|
||||
return c.json({ error: "The global 'admin' role cannot be deleted" }, 400);
|
||||
}
|
||||
|
||||
await sqlWrapper.sql`DELETE FROM roles WHERE id = ${roleId}`;
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"role_deleted",
|
||||
role.app_id,
|
||||
{ role_name: role.name },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
228
server/routes/admin/users.ts
Normal file
228
server/routes/admin/users.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { valkey } from "../../valkey.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import { getAuthenticatedUser } from "../../auth-session.ts";
|
||||
import { getClientIp } from "../../middleware.ts";
|
||||
|
||||
export const usersAdminRoutes = new Hono();
|
||||
|
||||
usersAdminRoutes.get("/", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const users = await sqlWrapper.sql`
|
||||
SELECT id, username, display_name, account_status
|
||||
FROM users
|
||||
ORDER BY username ASC
|
||||
`;
|
||||
|
||||
return c.json({ users });
|
||||
});
|
||||
|
||||
usersAdminRoutes.post("/:id/status", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const { status } = await c.req.json();
|
||||
|
||||
if (!["active", "pending", "suspended"].includes(status)) {
|
||||
return c.json({ error: "Invalid status" }, 400);
|
||||
}
|
||||
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`UPDATE users SET account_status = ${status} WHERE id = ${targetUserId} RETURNING id`
|
||||
.then((res: any) => res[0]);
|
||||
|
||||
if (!targetUser) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "user_status_changed", targetUserId, {
|
||||
newStatus: status,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
usersAdminRoutes.post("/:id/profile", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const { displayName } = await c.req.json();
|
||||
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`UPDATE users SET display_name = ${
|
||||
displayName?.trim() || null
|
||||
} WHERE id = ${targetUserId} RETURNING id, username, display_name`
|
||||
.then((res: any) => res[0]);
|
||||
|
||||
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "user_profile_updated", targetUserId, {
|
||||
display_name: targetUser.display_name,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true, user: targetUser });
|
||||
});
|
||||
|
||||
usersAdminRoutes.get("/:id/grants", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const grants = await sqlWrapper.sql`
|
||||
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
|
||||
FROM grants g
|
||||
JOIN apps a ON g.app_id = a.id
|
||||
WHERE g.user_id = ${targetUserId}
|
||||
ORDER BY a.name ASC
|
||||
`;
|
||||
return c.json({ grants });
|
||||
});
|
||||
|
||||
usersAdminRoutes.post("/:id/grants", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const { appId, role } = await c.req.json();
|
||||
|
||||
if (!appId || !role) {
|
||||
return c.json({ error: "appId and role are required" }, 400);
|
||||
}
|
||||
|
||||
const app = await sqlWrapper
|
||||
.sql`SELECT id, name FROM apps WHERE id = ${appId}`.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (!app) return c.json({ error: "Application not found" }, 404);
|
||||
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`SELECT id, username FROM users WHERE id = ${targetUserId}`.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO grants (user_id, app_id, role)
|
||||
VALUES (${targetUserId}, ${appId}, ${role})
|
||||
ON CONFLICT (user_id, app_id) DO UPDATE SET role = ${role}
|
||||
`;
|
||||
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"user_grant_assigned",
|
||||
targetUserId,
|
||||
{ app_id: appId, app_name: app.name, role },
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
usersAdminRoutes.delete("/:id/grants/:appId", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const { id: targetUserId, appId } = c.req.param();
|
||||
|
||||
const grant = await sqlWrapper.sql`
|
||||
DELETE FROM grants
|
||||
WHERE user_id = ${targetUserId} AND app_id = ${appId}
|
||||
RETURNING id
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (grant) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"user_grant_revoked",
|
||||
targetUserId,
|
||||
{ app_id: appId },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
|
||||
return c.json({ error: "Grant not found" }, 404);
|
||||
});
|
||||
|
||||
usersAdminRoutes.get("/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const targetUserId = c.req.param("id");
|
||||
const user = await sqlWrapper
|
||||
.sql`SELECT id, username, display_name, account_status FROM users WHERE id = ${targetUserId}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!user) return c.json({ error: "User not found" }, 404);
|
||||
const sessions = await sqlWrapper
|
||||
.sql`SELECT id, created_at, expires_at FROM sessions WHERE user_id = ${targetUserId} ORDER BY created_at DESC`;
|
||||
const passkeys = await sqlWrapper
|
||||
.sql`SELECT id, credential_id, counter FROM passkeys WHERE user_id = ${targetUserId}`;
|
||||
return c.json({ user, sessions, passkeys });
|
||||
});
|
||||
|
||||
usersAdminRoutes.delete("/:id/sessions", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const targetUserId = c.req.param("id");
|
||||
const sessions = await sqlWrapper
|
||||
.sql`DELETE FROM sessions WHERE user_id = ${targetUserId} RETURNING id`;
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
await valkey.del(session.id);
|
||||
} catch (_err) {}
|
||||
}
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"admin_all_sessions_revoked",
|
||||
targetUserId,
|
||||
null,
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
usersAdminRoutes.delete("/:userId/passkeys/:passkeyId", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const { userId, passkeyId } = c.req.param();
|
||||
const passkey = await sqlWrapper
|
||||
.sql`DELETE FROM passkeys WHERE id = ${passkeyId} AND user_id = ${userId} RETURNING id`
|
||||
.then((res: any) => res[0]);
|
||||
if (passkey) {
|
||||
auditWrapper.auditLog(auth.userId, "admin_passkey_revoked", userId, {
|
||||
passkey_id: passkey.id,
|
||||
}, getClientIp(c));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
return c.json({ error: "Passkey not found" }, 404);
|
||||
});
|
||||
|
||||
usersAdminRoutes.post("/:id/recovery", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
const targetUserId = c.req.param("id");
|
||||
const targetUser = await sqlWrapper
|
||||
.sql`SELECT id FROM users WHERE id = ${targetUserId}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
||||
const recoveryCode = encodeBase64Url(
|
||||
crypto.getRandomValues(new Uint8Array(24)),
|
||||
);
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 1);
|
||||
await sqlWrapper
|
||||
.sql`INSERT INTO recovery_links (code, user_id, created_by, expires_at) VALUES (${recoveryCode}, ${targetUserId}, ${auth.userId}, ${expiresAt})`;
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"recovery_link_created",
|
||||
targetUserId,
|
||||
null,
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ success: true, recoveryCode, expiresAt });
|
||||
});
|
||||
@ -1,928 +1,13 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import {
|
||||
decodeBase64Url,
|
||||
encodeBase64Url,
|
||||
} from "jsr:@std/encoding@1/base64url";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
MetadataService,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "jsr:@simplewebauthn/server@13";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "jsr:@simplewebauthn/server@13";
|
||||
|
||||
import { sqlWrapper } from "../db.ts";
|
||||
import { valkey } from "../valkey.ts";
|
||||
import { auditWrapper } from "../audit.ts";
|
||||
import {
|
||||
extractAllSessionIds,
|
||||
getAuthenticatedUser,
|
||||
requirePrimarySession,
|
||||
} from "../auth-session.ts";
|
||||
import { getClientIp, publicRateLimiter } from "../middleware.ts";
|
||||
import { registerAuthRoutes } from "./auth/register.ts";
|
||||
import { loginAuthRoutes } from "./auth/login.ts";
|
||||
import { passkeysAuthRoutes } from "./auth/passkeys.ts";
|
||||
import { guestAuthRoutes } from "./auth/guest.ts";
|
||||
|
||||
export const authRoutes = new Hono();
|
||||
|
||||
const rpName = "Auth-Yes Identity Provider";
|
||||
const rpID = Deno.env.get("RP_ID") ||
|
||||
(import.meta.main ? undefined : "localhost");
|
||||
const origin = Deno.env.get("ORIGIN") ||
|
||||
(import.meta.main ? undefined : "http://localhost");
|
||||
const requireHardwareToken = Deno.env.get("REQUIRE_HARDWARE_TOKEN") === "true";
|
||||
|
||||
export function getCookieDomain(customRpId?: string): string | undefined {
|
||||
const envDomain = Deno.env.get("COOKIE_DOMAIN");
|
||||
if (envDomain) {
|
||||
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
|
||||
}
|
||||
const targetId = customRpId || Deno.env.get("RP_ID") || "";
|
||||
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
|
||||
return undefined;
|
||||
}
|
||||
const parts = targetId.split(".").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `.${parts.slice(-2).join(".")}`;
|
||||
}
|
||||
return `.${targetId}`;
|
||||
}
|
||||
|
||||
function generateSessionId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
authRoutes.use("/api/register/*", publicRateLimiter);
|
||||
authRoutes.use("/api/login/*", publicRateLimiter);
|
||||
authRoutes.use("/api/passkeys/*", requirePrimarySession);
|
||||
|
||||
authRoutes.get("/.well-known/webauthn", (c) => {
|
||||
if (!origin) {
|
||||
return c.json({ origins: [] });
|
||||
}
|
||||
return c.json({ origins: [origin] });
|
||||
});
|
||||
|
||||
// Start a WebAuthn registration ceremony
|
||||
authRoutes.post("/api/register/challenge", async (c) => {
|
||||
const { username, inviteCode } = await c.req.json();
|
||||
|
||||
if (!username || !inviteCode) {
|
||||
return c.json({ error: "Username and inviteCode required" }, 400);
|
||||
}
|
||||
|
||||
// Validate invite code early (checks expiration and max_uses bounds)
|
||||
const invite = await sqlWrapper
|
||||
.sql`SELECT id, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
||||
.then((res: any) => res[0]);
|
||||
if (!invite) {
|
||||
return c.json(
|
||||
{ error: "Invalid, expired, or fully claimed invite code" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent hijacking an existing user's account if they already exist
|
||||
const existingUser = await sqlWrapper
|
||||
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
||||
res[0]
|
||||
);
|
||||
if (existingUser) {
|
||||
return c.json({ error: "Username already exists" }, 409);
|
||||
}
|
||||
|
||||
const newUserId = crypto.randomUUID();
|
||||
const userIdBytes = new TextEncoder().encode(newUserId);
|
||||
|
||||
if (!rpID) throw new Error("rpID is missing");
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: username,
|
||||
userID: userIdBytes,
|
||||
attestationType: "direct",
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
requireResidentKey: true,
|
||||
userVerification: "preferred",
|
||||
},
|
||||
timeout: 60000,
|
||||
extensions: {
|
||||
["prf" as string]: {},
|
||||
} as any,
|
||||
});
|
||||
|
||||
setCookie(c, "expected_registration_challenge", options.challenge, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
setCookie(c, "registration_user_id", newUserId, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
return c.json({ options, username });
|
||||
});
|
||||
|
||||
// Verify registration and create UUID/session
|
||||
authRoutes.post("/api/register/verify", async (c) => {
|
||||
try {
|
||||
const { response, username, inviteCode, upgrade_session } = await c.req
|
||||
.json();
|
||||
|
||||
if (!inviteCode && !upgrade_session) {
|
||||
return c.json({ error: "inviteCode or upgrade_session required" }, 400);
|
||||
}
|
||||
|
||||
const expectedChallenge = getCookie(c, "expected_registration_challenge");
|
||||
const registrationUserId = getCookie(c, "registration_user_id");
|
||||
if (!expectedChallenge || !registrationUserId) {
|
||||
return c.json({
|
||||
error: "Missing or expired registration challenge/user ID",
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Prevent race condition account hijacking
|
||||
let user = await sqlWrapper
|
||||
.sql`SELECT id FROM users WHERE username = ${username}`
|
||||
.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (user) {
|
||||
return c.json({ error: "Username already exists" }, 409);
|
||||
}
|
||||
|
||||
if (!origin || !rpID) throw new Error("Missing origin or rpID");
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: response as RegistrationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
if (!verified || !registrationInfo) {
|
||||
return c.json({ error: "Verification failed" }, 400);
|
||||
}
|
||||
|
||||
// Enterprise Allow-List Verification
|
||||
const allowlistCount = await sqlWrapper
|
||||
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
||||
Number(res[0].count)
|
||||
);
|
||||
if (allowlistCount > 0 && registrationInfo.aaguid) {
|
||||
const isAllowed = await sqlWrapper
|
||||
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!isAllowed) {
|
||||
auditWrapper.auditLog(null, "failed_attestation_allowlist", null, {
|
||||
aaguid: registrationInfo.aaguid,
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error: "Authenticator AAGUID is not in the enterprise allow-list.",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional Strict Hardware Attestation (e.g. YubiKey-only)
|
||||
if (requireHardwareToken) {
|
||||
if (
|
||||
!registrationInfo.aaguid ||
|
||||
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
||||
) {
|
||||
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
||||
username,
|
||||
reason: "No AAGUID provided",
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error:
|
||||
"Hardware attestation failed: No AAGUID provided. Only certified hardware security keys are permitted.",
|
||||
}, 403);
|
||||
}
|
||||
|
||||
let mdsStatement;
|
||||
try {
|
||||
mdsStatement = await MetadataService.getStatement(
|
||||
registrationInfo.aaguid,
|
||||
);
|
||||
} catch (mdsError) {
|
||||
console.warn("[Auth API] MetadataService lookup error:", mdsError);
|
||||
}
|
||||
|
||||
if (!mdsStatement) {
|
||||
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
||||
username,
|
||||
aaguid: registrationInfo.aaguid,
|
||||
reason: "AAGUID not found in MDS3",
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error:
|
||||
`Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob. Only certified hardware security keys are permitted.`,
|
||||
}, 403);
|
||||
}
|
||||
|
||||
// @ts-ignore: TypeScript definition might be out of date for FIDO MDS3 (1)
|
||||
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
||||
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
||||
username,
|
||||
aaguid: registrationInfo.aaguid,
|
||||
reason: "Software passkey detected",
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error:
|
||||
"Hardware attestation failed: Authenticator is flagged as a software-based passkey. Only certified hardware security keys are permitted.",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
const credentialID = registrationInfo.credential.id;
|
||||
const credentialPublicKey = registrationInfo.credential.publicKey;
|
||||
const counter = registrationInfo.credential.counter;
|
||||
|
||||
const base64CredentialID = typeof credentialID === "string"
|
||||
? credentialID
|
||||
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
|
||||
const base64PublicKey = encodeBase64Url(
|
||||
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
||||
);
|
||||
|
||||
const prfEnabled =
|
||||
(response.clientExtensionResults as any)?.prf?.enabled === true;
|
||||
let prfSalt = null;
|
||||
if (prfEnabled) {
|
||||
const saltBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
prfSalt = encodeBase64Url(saltBytes);
|
||||
}
|
||||
|
||||
if (upgrade_session) {
|
||||
// Ephemeral Guest Sandbox in-flight promotion
|
||||
const sessionDataStr = await valkey.get(upgrade_session);
|
||||
if (!sessionDataStr) {
|
||||
return c.json({ error: "Invalid or expired guest session" }, 400);
|
||||
}
|
||||
const sessionData = JSON.parse(sessionDataStr);
|
||||
if (
|
||||
!sessionData || !sessionData.uuid ||
|
||||
sessionData.account_status !== "guest"
|
||||
) {
|
||||
return c.json({ error: "Invalid guest session state" }, 400);
|
||||
}
|
||||
|
||||
const guestUuid = sessionData.uuid;
|
||||
|
||||
const insertRes = await sqlWrapper
|
||||
.sql`INSERT INTO users (id, username, account_status) VALUES (${guestUuid}, ${username}, 'active') RETURNING id`;
|
||||
user = insertRes[0];
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
||||
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
||||
`;
|
||||
|
||||
// Promote Valkey session
|
||||
await valkey.setex(
|
||||
upgrade_session,
|
||||
28800, // Upgrade TTL to 8 hours
|
||||
JSON.stringify({ uuid: guestUuid, username, account_status: "active" }),
|
||||
);
|
||||
|
||||
// Register session in PostgreSQL
|
||||
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO sessions (id, user_id, expires_at)
|
||||
VALUES (${upgrade_session}, ${user.id}, ${expiresAt})
|
||||
`;
|
||||
} else {
|
||||
// Standard Registration Flow
|
||||
const invite = await sqlWrapper
|
||||
.sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
||||
.then((res: any) => res[0]);
|
||||
if (!invite) {
|
||||
return c.json(
|
||||
{ error: "Invalid, expired, or fully claimed invite code" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const initialStatus = invite.auto_activate === false
|
||||
? "pending"
|
||||
: "active";
|
||||
const insertRes = await sqlWrapper
|
||||
.sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) RETURNING id`;
|
||||
user = insertRes[0];
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
||||
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
||||
`;
|
||||
|
||||
await sqlWrapper.sql`
|
||||
UPDATE invites
|
||||
SET uses_count = uses_count + 1,
|
||||
used_at = NOW(),
|
||||
used_by = ${user.id}
|
||||
WHERE id = ${invite.id}
|
||||
`;
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO invite_redemptions (invite_id, user_id)
|
||||
VALUES (${invite.id}, ${user.id})
|
||||
`;
|
||||
|
||||
if (invite.app_id) {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO grants (user_id, app_id, role)
|
||||
VALUES (${user.id}, ${invite.app_id}, ${invite.role})
|
||||
`;
|
||||
} else if (invite.role === "admin") {
|
||||
const adminApp = await sqlWrapper
|
||||
.sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'`
|
||||
.then((res: any) => res[0]);
|
||||
if (adminApp) {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO grants (user_id, app_id, role)
|
||||
VALUES (${user.id}, ${adminApp.id}, 'admin')
|
||||
ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin'
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditWrapper.auditLog(
|
||||
user.id,
|
||||
"user_registered",
|
||||
null,
|
||||
{ username, inviteCode },
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
// Set response cookie to clear out the challenge
|
||||
setCookie(c, "expected_registration_challenge", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
setCookie(c, "registration_user_id", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
if (cookieDomain) {
|
||||
deleteCookie(c, "session_id", { domain: cookieDomain, path: "/" });
|
||||
}
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"[Auth API] Uncaught Exception in /api/register/verify:",
|
||||
error,
|
||||
);
|
||||
return c.json({ error: error.message || "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Start a WebAuthn authentication ceremony
|
||||
authRoutes.post("/api/login/challenge", async (c) => {
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch (_err) {
|
||||
body = {};
|
||||
}
|
||||
const username = body.username;
|
||||
let extensions: any = undefined;
|
||||
let allowCredentials: any[] | undefined = undefined;
|
||||
|
||||
if (username) {
|
||||
const user = await sqlWrapper
|
||||
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
||||
res[0]
|
||||
);
|
||||
if (user) {
|
||||
const passkeys = await sqlWrapper
|
||||
.sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id}`;
|
||||
|
||||
if (passkeys.length > 0) {
|
||||
allowCredentials = passkeys.map((pk: any) => ({
|
||||
id: pk.credential_id,
|
||||
type: "public-key",
|
||||
}));
|
||||
|
||||
const prfPasskeys = passkeys.filter((pk: any) =>
|
||||
pk.prf_enabled && pk.prf_salt
|
||||
);
|
||||
if (prfPasskeys.length > 0) {
|
||||
extensions = {
|
||||
["prf" as string]: { evalByCredential: {} },
|
||||
};
|
||||
for (const pk of prfPasskeys) {
|
||||
const saltBytes = decodeBase64Url(pk.prf_salt);
|
||||
extensions["prf"]["evalByCredential"][pk.credential_id] = {
|
||||
first: saltBytes,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rpID) throw new Error("rpID is missing");
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: "preferred",
|
||||
timeout: 60000,
|
||||
allowCredentials,
|
||||
extensions,
|
||||
});
|
||||
|
||||
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
return c.json({ options });
|
||||
});
|
||||
|
||||
// Verify login and issue session
|
||||
authRoutes.post("/api/login/verify", async (c) => {
|
||||
const { response } = await c.req.json();
|
||||
|
||||
const expectedChallenge = getCookie(c, "expected_authentication_challenge");
|
||||
if (!expectedChallenge) {
|
||||
return c.json(
|
||||
{ error: "Missing or expired authentication challenge" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const base64CredentialID = response.id;
|
||||
|
||||
const passkey = await sqlWrapper
|
||||
.sql`SELECT * FROM passkeys WHERE credential_id = ${base64CredentialID}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!passkey) {
|
||||
return c.json({
|
||||
error: "Passkey not found. Please register your passkey first.",
|
||||
}, 404);
|
||||
}
|
||||
|
||||
const user = await sqlWrapper
|
||||
.sql`SELECT id, username, account_status FROM users WHERE id = ${passkey.user_id}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!user) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
if (user.account_status !== "active") {
|
||||
auditWrapper.auditLog(
|
||||
userId,
|
||||
"login_failed",
|
||||
null,
|
||||
{ reason: `Account status is ${user.account_status}` },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({
|
||||
error: "Account is not active. Please contact an administrator.",
|
||||
}, 403);
|
||||
}
|
||||
|
||||
const publicKeyBytes = decodeBase64Url(passkey.public_key);
|
||||
|
||||
if (!origin || !rpID) throw new Error("Missing origin or rpID");
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: response as AuthenticationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
credential: {
|
||||
id: passkey.credential_id,
|
||||
publicKey: publicKeyBytes,
|
||||
counter: Number(passkey.counter),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
if (!verified || !authenticationInfo) {
|
||||
auditWrapper.auditLog(
|
||||
userId,
|
||||
"login_failed",
|
||||
null,
|
||||
{ reason: "verification failed" },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ error: "Verification failed" }, 400);
|
||||
}
|
||||
|
||||
await sqlWrapper
|
||||
.sql`UPDATE passkeys SET counter = ${authenticationInfo.newCounter} WHERE id = ${passkey.id}`;
|
||||
|
||||
const sessionId = generateSessionId();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
// Persistence in PostgreSQL
|
||||
await sqlWrapper
|
||||
.sql`INSERT INTO sessions (id, user_id, expires_at) VALUES (${sessionId}, ${user.id}, ${expiresAt})`;
|
||||
|
||||
// Write session to Valkey with TTL matching expiresAt
|
||||
const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
||||
try {
|
||||
const sessionData = JSON.stringify({
|
||||
uuid: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
await valkey.setex(sessionId, ttlSeconds, sessionData);
|
||||
} catch (_err: unknown) {
|
||||
// If Valkey fails, log and fail closed for security
|
||||
auditWrapper.auditLog(
|
||||
user.id,
|
||||
"login_failed",
|
||||
null,
|
||||
{ reason: "Cache write failure" },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
|
||||
const oldSessionIds = extractAllSessionIds(c);
|
||||
if (oldSessionIds.length > 0) {
|
||||
for (const old of oldSessionIds) {
|
||||
try {
|
||||
await valkey.del(old);
|
||||
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`;
|
||||
} catch (_e) {}
|
||||
}
|
||||
}
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
setCookie(c, "session_id", sessionId, {
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
setCookie(c, "expected_authentication_challenge", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Generate Ephemeral Guest Sandbox
|
||||
authRoutes.post("/api/guests/sandbox", async (c) => {
|
||||
const guestUuid = crypto.randomUUID();
|
||||
const sessionId = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
||||
const username = `guest-${guestUuid.substring(0, 8)}`;
|
||||
|
||||
await valkey.setex(
|
||||
sessionId,
|
||||
7200, // 2-hour TTL
|
||||
JSON.stringify({ uuid: guestUuid, username, account_status: "guest" }),
|
||||
);
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
setCookie(c, "session_id", sessionId, {
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 7200,
|
||||
});
|
||||
|
||||
return c.json({ success: true, sessionId, guestUuid });
|
||||
});
|
||||
|
||||
// Revoke a session manually (used by layout logout)
|
||||
authRoutes.post("/api/revoke", async (c) => {
|
||||
// Try Authorization header first (SDK)
|
||||
let token = "";
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.split(" ")[1];
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// Fallback to session cookie (Web UI)
|
||||
const tokens = extractAllSessionIds(c);
|
||||
if (tokens.length === 0) {
|
||||
return c.json({ error: "Missing or invalid token" }, 401);
|
||||
}
|
||||
|
||||
for (const t of tokens) {
|
||||
try {
|
||||
await valkey.del(t);
|
||||
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${t}`;
|
||||
} catch (_e) {}
|
||||
}
|
||||
} else {
|
||||
// SDK Token Path
|
||||
try {
|
||||
await valkey.del(token);
|
||||
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${token}`;
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
if (cookieDomain) {
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
}
|
||||
deleteCookie(c, "session_id", {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Authenticated Passkey Registration (Adding a new device)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
authRoutes.post("/api/passkeys/register/challenge", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const user = await sqlWrapper
|
||||
.sql`SELECT username FROM users WHERE id = ${auth.userId}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!user) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
const userIdBytes = new TextEncoder().encode(auth.userId);
|
||||
|
||||
if (!rpID) throw new Error("rpID is missing");
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: user.username,
|
||||
userID: userIdBytes,
|
||||
attestationType: "direct",
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
requireResidentKey: true,
|
||||
userVerification: "preferred",
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
setCookie(c, "expected_add_passkey_challenge", options.challenge, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
return c.json({ options });
|
||||
});
|
||||
|
||||
authRoutes.post("/api/passkeys/register/verify", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const { response } = await c.req.json();
|
||||
const expectedChallenge = getCookie(c, "expected_add_passkey_challenge");
|
||||
|
||||
if (!expectedChallenge) {
|
||||
return c.json({ error: "Missing or expired registration challenge" }, 400);
|
||||
}
|
||||
|
||||
if (!origin || !rpID) throw new Error("Missing origin or rpID");
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: response as RegistrationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
if (!verified || !registrationInfo) {
|
||||
return c.json({ error: "Verification failed" }, 400);
|
||||
}
|
||||
|
||||
// Enterprise Allow-List Verification
|
||||
const allowlistCount = await sqlWrapper
|
||||
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
||||
Number(res[0].count)
|
||||
);
|
||||
if (allowlistCount > 0 && registrationInfo.aaguid) {
|
||||
const isAllowed = await sqlWrapper
|
||||
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!isAllowed) {
|
||||
auditWrapper.auditLog(auth.userId, "failed_attestation_allowlist", null, {
|
||||
aaguid: registrationInfo.aaguid,
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error: "Authenticator AAGUID is not in the enterprise allow-list.",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional Strict Hardware Attestation
|
||||
if (requireHardwareToken) {
|
||||
if (
|
||||
!registrationInfo.aaguid ||
|
||||
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
||||
) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"add_passkey_failed_attestation",
|
||||
null,
|
||||
{
|
||||
reason: "No AAGUID provided",
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json(
|
||||
{ error: "Hardware attestation failed: No AAGUID provided." },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
let mdsStatement;
|
||||
try {
|
||||
mdsStatement = await MetadataService.getStatement(
|
||||
registrationInfo.aaguid,
|
||||
);
|
||||
} catch (mdsError) {
|
||||
console.warn("[Auth API] MetadataService lookup error:", mdsError);
|
||||
}
|
||||
|
||||
if (!mdsStatement) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"add_passkey_failed_attestation",
|
||||
null,
|
||||
{
|
||||
aaguid: registrationInfo.aaguid,
|
||||
reason: "AAGUID not found in MDS3",
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({
|
||||
error:
|
||||
`Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob.`,
|
||||
}, 403);
|
||||
}
|
||||
|
||||
// @ts-ignore: FIDO MDS3 missing type
|
||||
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"add_passkey_failed_attestation",
|
||||
null,
|
||||
{
|
||||
aaguid: registrationInfo.aaguid,
|
||||
reason: "Software passkey detected",
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({
|
||||
error:
|
||||
"Hardware attestation failed: Authenticator is flagged as a software-based passkey.",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
const credentialID = registrationInfo.credential.id;
|
||||
const credentialPublicKey = registrationInfo.credential.publicKey;
|
||||
const counter = registrationInfo.credential.counter;
|
||||
|
||||
const base64CredentialID = typeof credentialID === "string"
|
||||
? credentialID
|
||||
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
|
||||
const base64PublicKey = encodeBase64Url(
|
||||
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
||||
);
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter)
|
||||
VALUES (${auth.userId}, ${base64CredentialID}, ${base64PublicKey}, ${counter})
|
||||
`;
|
||||
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"passkey_added",
|
||||
null,
|
||||
null,
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
setCookie(c, "expected_add_passkey_challenge", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Get user's registered passkeys
|
||||
authRoutes.get("/api/passkeys", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const passkeys = await sqlWrapper.sql`
|
||||
SELECT id, counter
|
||||
FROM passkeys
|
||||
WHERE user_id = ${auth.userId}
|
||||
`;
|
||||
|
||||
return c.json({ passkeys });
|
||||
});
|
||||
|
||||
// Revoke a specific passkey
|
||||
authRoutes.delete("/api/passkeys/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetPasskeyId = c.req.param("id");
|
||||
|
||||
// Verify the passkey belongs to the user
|
||||
const passkey = await sqlWrapper.sql`
|
||||
SELECT id FROM passkeys WHERE id = ${targetPasskeyId} AND user_id = ${auth.userId}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!passkey) {
|
||||
return c.json({ error: "Passkey not found or access denied" }, 404);
|
||||
}
|
||||
|
||||
// Prevent deleting the very last passkey to avoid locking out the user
|
||||
const passkeyCount = await sqlWrapper.sql`
|
||||
SELECT count(*) as count FROM passkeys WHERE user_id = ${auth.userId}
|
||||
`.then((res: any) => Number(res[0].count));
|
||||
|
||||
if (passkeyCount <= 1) {
|
||||
return c.json({
|
||||
error: "Cannot delete your last passkey. Register another one first.",
|
||||
}, 400);
|
||||
}
|
||||
|
||||
await sqlWrapper.sql`DELETE FROM passkeys WHERE id = ${targetPasskeyId}`;
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "passkey_revoked", null, {
|
||||
revoked_passkey_id: targetPasskeyId,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
authRoutes.route("/", registerAuthRoutes);
|
||||
authRoutes.route("/", loginAuthRoutes);
|
||||
authRoutes.route("/", passkeysAuthRoutes);
|
||||
authRoutes.route("/", guestAuthRoutes);
|
||||
|
||||
90
server/routes/auth/guest.ts
Normal file
90
server/routes/auth/guest.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { valkey } from "../../valkey.ts";
|
||||
import { extractAllSessionIds } from "../../auth-session.ts";
|
||||
import { getCookieDomain } from "./utils.ts";
|
||||
|
||||
export const guestAuthRoutes = new Hono();
|
||||
|
||||
const rpID = Deno.env.get("RP_ID") ||
|
||||
(import.meta.main ? undefined : "localhost");
|
||||
|
||||
// Generate Ephemeral Guest Sandbox
|
||||
guestAuthRoutes.post("/api/guests/sandbox", async (c) => {
|
||||
const guestUuid = crypto.randomUUID();
|
||||
const sessionId = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
||||
const username = `guest-${guestUuid.substring(0, 8)}`;
|
||||
|
||||
await valkey.setex(
|
||||
sessionId,
|
||||
7200, // 2-hour TTL
|
||||
JSON.stringify({ uuid: guestUuid, username, account_status: "guest" }),
|
||||
);
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
setCookie(c, "session_id", sessionId, {
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 7200,
|
||||
});
|
||||
|
||||
return c.json({ success: true, sessionId, guestUuid });
|
||||
});
|
||||
|
||||
// Revoke a session manually (used by layout logout)
|
||||
guestAuthRoutes.post("/api/revoke", async (c) => {
|
||||
// Try Authorization header first (SDK)
|
||||
let token = "";
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.split(" ")[1];
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// Fallback to session cookie (Web UI)
|
||||
const tokens = extractAllSessionIds(c);
|
||||
if (tokens.length === 0) {
|
||||
return c.json({ error: "Missing or invalid token" }, 401);
|
||||
}
|
||||
|
||||
for (const t of tokens) {
|
||||
try {
|
||||
await valkey.del(t);
|
||||
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${t}`;
|
||||
} catch (_e) {}
|
||||
}
|
||||
} else {
|
||||
// SDK Token Path
|
||||
try {
|
||||
await valkey.del(token);
|
||||
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${token}`;
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
if (cookieDomain) {
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
}
|
||||
deleteCookie(c, "session_id", {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
236
server/routes/auth/login.ts
Normal file
236
server/routes/auth/login.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { decodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from "jsr:@simplewebauthn/server@13";
|
||||
import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { valkey } from "../../valkey.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import { extractAllSessionIds } from "../../auth-session.ts";
|
||||
import { getClientIp, publicRateLimiter } from "../../middleware.ts";
|
||||
import { getCookieDomain } from "./utils.ts";
|
||||
|
||||
export const loginAuthRoutes = new Hono();
|
||||
|
||||
const rpID = Deno.env.get("RP_ID") ||
|
||||
(import.meta.main ? undefined : "localhost");
|
||||
const origin = Deno.env.get("ORIGIN") ||
|
||||
(import.meta.main ? undefined : "http://localhost");
|
||||
|
||||
function generateSessionId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
loginAuthRoutes.use("/api/login/*", publicRateLimiter);
|
||||
|
||||
// Start a WebAuthn authentication ceremony
|
||||
loginAuthRoutes.post("/api/login/challenge", async (c) => {
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch (_err) {
|
||||
body = {};
|
||||
}
|
||||
const username = body.username;
|
||||
let extensions: any = undefined;
|
||||
let allowCredentials: any[] | undefined = undefined;
|
||||
|
||||
if (username) {
|
||||
const user = await sqlWrapper
|
||||
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
||||
res[0]
|
||||
);
|
||||
if (user) {
|
||||
const passkeys = await sqlWrapper
|
||||
.sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id}`;
|
||||
|
||||
if (passkeys.length > 0) {
|
||||
allowCredentials = passkeys.map((pk: any) => ({
|
||||
id: pk.credential_id,
|
||||
type: "public-key",
|
||||
}));
|
||||
|
||||
const prfPasskeys = passkeys.filter((pk: any) =>
|
||||
pk.prf_enabled && pk.prf_salt
|
||||
);
|
||||
if (prfPasskeys.length > 0) {
|
||||
extensions = {
|
||||
["prf" as string]: { evalByCredential: {} },
|
||||
};
|
||||
for (const pk of prfPasskeys) {
|
||||
const saltBytes = decodeBase64Url(pk.prf_salt);
|
||||
extensions["prf"]["evalByCredential"][pk.credential_id] = {
|
||||
first: saltBytes,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rpID) throw new Error("rpID is missing");
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: "preferred",
|
||||
timeout: 60000,
|
||||
allowCredentials,
|
||||
extensions,
|
||||
});
|
||||
|
||||
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
return c.json({ options });
|
||||
});
|
||||
|
||||
// Verify login and issue session
|
||||
loginAuthRoutes.post("/api/login/verify", async (c) => {
|
||||
const { response } = await c.req.json();
|
||||
|
||||
const expectedChallenge = getCookie(c, "expected_authentication_challenge");
|
||||
if (!expectedChallenge) {
|
||||
return c.json(
|
||||
{ error: "Missing or expired authentication challenge" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const base64CredentialID = response.id;
|
||||
|
||||
const passkey = await sqlWrapper
|
||||
.sql`SELECT * FROM passkeys WHERE credential_id = ${base64CredentialID}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!passkey) {
|
||||
return c.json({
|
||||
error: "Passkey not found. Please register your passkey first.",
|
||||
}, 404);
|
||||
}
|
||||
|
||||
const user = await sqlWrapper
|
||||
.sql`SELECT id, username, account_status FROM users WHERE id = ${passkey.user_id}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!user) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
if (user.account_status !== "active") {
|
||||
auditWrapper.auditLog(
|
||||
userId,
|
||||
"login_failed",
|
||||
null,
|
||||
{ reason: `Account status is ${user.account_status}` },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({
|
||||
error: "Account is not active. Please contact an administrator.",
|
||||
}, 403);
|
||||
}
|
||||
|
||||
const publicKeyBytes = decodeBase64Url(passkey.public_key);
|
||||
|
||||
if (!origin || !rpID) throw new Error("Missing origin or rpID");
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: response as AuthenticationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
credential: {
|
||||
id: passkey.credential_id,
|
||||
publicKey: publicKeyBytes,
|
||||
counter: Number(passkey.counter),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
if (!verified || !authenticationInfo) {
|
||||
auditWrapper.auditLog(
|
||||
userId,
|
||||
"login_failed",
|
||||
null,
|
||||
{ reason: "verification failed" },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ error: "Verification failed" }, 400);
|
||||
}
|
||||
|
||||
await sqlWrapper
|
||||
.sql`UPDATE passkeys SET counter = ${authenticationInfo.newCounter} WHERE id = ${passkey.id}`;
|
||||
|
||||
const sessionId = generateSessionId();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
// Persistence in PostgreSQL
|
||||
await sqlWrapper
|
||||
.sql`INSERT INTO sessions (id, user_id, expires_at) VALUES (${sessionId}, ${user.id}, ${expiresAt})`;
|
||||
|
||||
// Write session to Valkey with TTL matching expiresAt
|
||||
const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
||||
try {
|
||||
const sessionData = JSON.stringify({
|
||||
uuid: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
await valkey.setex(sessionId, ttlSeconds, sessionData);
|
||||
} catch (_err: unknown) {
|
||||
// If Valkey fails, log and fail closed for security
|
||||
auditWrapper.auditLog(
|
||||
user.id,
|
||||
"login_failed",
|
||||
null,
|
||||
{ reason: "Cache write failure" },
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
|
||||
const oldSessionIds = extractAllSessionIds(c);
|
||||
if (oldSessionIds.length > 0) {
|
||||
for (const old of oldSessionIds) {
|
||||
try {
|
||||
await valkey.del(old);
|
||||
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`;
|
||||
} catch (_e) {}
|
||||
}
|
||||
}
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
setCookie(c, "session_id", sessionId, {
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
setCookie(c, "expected_authentication_challenge", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
268
server/routes/auth/passkeys.ts
Normal file
268
server/routes/auth/passkeys.ts
Normal file
@ -0,0 +1,268 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
MetadataService,
|
||||
verifyRegistrationResponse,
|
||||
} from "jsr:@simplewebauthn/server@13";
|
||||
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import {
|
||||
getAuthenticatedUser,
|
||||
requirePrimarySession,
|
||||
} from "../../auth-session.ts";
|
||||
import { getClientIp } from "../../middleware.ts";
|
||||
|
||||
export const passkeysAuthRoutes = new Hono();
|
||||
|
||||
const rpName = "Auth-Yes Identity Provider";
|
||||
const rpID = Deno.env.get("RP_ID") ||
|
||||
(import.meta.main ? undefined : "localhost");
|
||||
const origin = Deno.env.get("ORIGIN") ||
|
||||
(import.meta.main ? undefined : "http://localhost");
|
||||
const requireHardwareToken = Deno.env.get("REQUIRE_HARDWARE_TOKEN") === "true";
|
||||
|
||||
passkeysAuthRoutes.use("/api/passkeys/*", requirePrimarySession);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Authenticated Passkey Registration (Adding a new device)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
passkeysAuthRoutes.post("/api/passkeys/register/challenge", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const user = await sqlWrapper
|
||||
.sql`SELECT username FROM users WHERE id = ${auth.userId}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!user) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
const userIdBytes = new TextEncoder().encode(auth.userId);
|
||||
|
||||
if (!rpID) throw new Error("rpID is missing");
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: user.username,
|
||||
userID: userIdBytes,
|
||||
attestationType: "direct",
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
requireResidentKey: true,
|
||||
userVerification: "preferred",
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
setCookie(c, "expected_add_passkey_challenge", options.challenge, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
return c.json({ options });
|
||||
});
|
||||
|
||||
passkeysAuthRoutes.post("/api/passkeys/register/verify", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const { response } = await c.req.json();
|
||||
const expectedChallenge = getCookie(c, "expected_add_passkey_challenge");
|
||||
|
||||
if (!expectedChallenge) {
|
||||
return c.json({ error: "Missing or expired registration challenge" }, 400);
|
||||
}
|
||||
|
||||
if (!origin || !rpID) throw new Error("Missing origin or rpID");
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: response as RegistrationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
if (!verified || !registrationInfo) {
|
||||
return c.json({ error: "Verification failed" }, 400);
|
||||
}
|
||||
|
||||
// Enterprise Allow-List Verification
|
||||
const allowlistCount = await sqlWrapper
|
||||
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
||||
Number(res[0].count)
|
||||
);
|
||||
if (allowlistCount > 0 && registrationInfo.aaguid) {
|
||||
const isAllowed = await sqlWrapper
|
||||
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!isAllowed) {
|
||||
auditWrapper.auditLog(auth.userId, "failed_attestation_allowlist", null, {
|
||||
aaguid: registrationInfo.aaguid,
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error: "Authenticator AAGUID is not in the enterprise allow-list.",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional Strict Hardware Attestation
|
||||
if (requireHardwareToken) {
|
||||
if (
|
||||
!registrationInfo.aaguid ||
|
||||
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
||||
) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"add_passkey_failed_attestation",
|
||||
null,
|
||||
{
|
||||
reason: "No AAGUID provided",
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json(
|
||||
{ error: "Hardware attestation failed: No AAGUID provided." },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
let mdsStatement;
|
||||
try {
|
||||
mdsStatement = await MetadataService.getStatement(
|
||||
registrationInfo.aaguid,
|
||||
);
|
||||
} catch (mdsError) {
|
||||
console.warn("[Auth API] MetadataService lookup error:", mdsError);
|
||||
}
|
||||
|
||||
if (!mdsStatement) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"add_passkey_failed_attestation",
|
||||
null,
|
||||
{
|
||||
aaguid: registrationInfo.aaguid,
|
||||
reason: "AAGUID not found in MDS3",
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({
|
||||
error:
|
||||
`Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob.`,
|
||||
}, 403);
|
||||
}
|
||||
|
||||
// @ts-ignore: FIDO MDS3 missing type
|
||||
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"add_passkey_failed_attestation",
|
||||
null,
|
||||
{
|
||||
aaguid: registrationInfo.aaguid,
|
||||
reason: "Software passkey detected",
|
||||
},
|
||||
getClientIp(c),
|
||||
);
|
||||
return c.json({
|
||||
error:
|
||||
"Hardware attestation failed: Authenticator is flagged as a software-based passkey.",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
const credentialID = registrationInfo.credential.id;
|
||||
const credentialPublicKey = registrationInfo.credential.publicKey;
|
||||
const counter = registrationInfo.credential.counter;
|
||||
|
||||
const base64CredentialID = typeof credentialID === "string"
|
||||
? credentialID
|
||||
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
|
||||
const base64PublicKey = encodeBase64Url(
|
||||
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
||||
);
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter)
|
||||
VALUES (${auth.userId}, ${base64CredentialID}, ${base64PublicKey}, ${counter})
|
||||
`;
|
||||
|
||||
auditWrapper.auditLog(
|
||||
auth.userId,
|
||||
"passkey_added",
|
||||
null,
|
||||
null,
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
setCookie(c, "expected_add_passkey_challenge", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Get user's registered passkeys
|
||||
passkeysAuthRoutes.get("/api/passkeys", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const passkeys = await sqlWrapper.sql`
|
||||
SELECT id, counter
|
||||
FROM passkeys
|
||||
WHERE user_id = ${auth.userId}
|
||||
`;
|
||||
|
||||
return c.json({ passkeys });
|
||||
});
|
||||
|
||||
// Revoke a specific passkey
|
||||
passkeysAuthRoutes.delete("/api/passkeys/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const targetPasskeyId = c.req.param("id");
|
||||
|
||||
// Verify the passkey belongs to the user
|
||||
const passkey = await sqlWrapper.sql`
|
||||
SELECT id FROM passkeys WHERE id = ${targetPasskeyId} AND user_id = ${auth.userId}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!passkey) {
|
||||
return c.json({ error: "Passkey not found or access denied" }, 404);
|
||||
}
|
||||
|
||||
// Prevent deleting the very last passkey to avoid locking out the user
|
||||
const passkeyCount = await sqlWrapper.sql`
|
||||
SELECT count(*) as count FROM passkeys WHERE user_id = ${auth.userId}
|
||||
`.then((res: any) => Number(res[0].count));
|
||||
|
||||
if (passkeyCount <= 1) {
|
||||
return c.json({
|
||||
error: "Cannot delete your last passkey. Register another one first.",
|
||||
}, 400);
|
||||
}
|
||||
|
||||
await sqlWrapper.sql`DELETE FROM passkeys WHERE id = ${targetPasskeyId}`;
|
||||
|
||||
auditWrapper.auditLog(auth.userId, "passkey_revoked", null, {
|
||||
revoked_passkey_id: targetPasskeyId,
|
||||
}, getClientIp(c));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
369
server/routes/auth/register.ts
Normal file
369
server/routes/auth/register.ts
Normal file
@ -0,0 +1,369 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
MetadataService,
|
||||
verifyRegistrationResponse,
|
||||
} from "jsr:@simplewebauthn/server@13";
|
||||
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
||||
|
||||
import { sqlWrapper } from "../../db.ts";
|
||||
import { valkey } from "../../valkey.ts";
|
||||
import { auditWrapper } from "../../audit.ts";
|
||||
import { getClientIp, publicRateLimiter } from "../../middleware.ts";
|
||||
import { getCookieDomain } from "./utils.ts";
|
||||
|
||||
export const registerAuthRoutes = new Hono();
|
||||
|
||||
const rpName = "Auth-Yes Identity Provider";
|
||||
const rpID = Deno.env.get("RP_ID") ||
|
||||
(import.meta.main ? undefined : "localhost");
|
||||
const origin = Deno.env.get("ORIGIN") ||
|
||||
(import.meta.main ? undefined : "http://localhost");
|
||||
const requireHardwareToken = Deno.env.get("REQUIRE_HARDWARE_TOKEN") === "true";
|
||||
|
||||
registerAuthRoutes.use("/api/register/*", publicRateLimiter);
|
||||
|
||||
registerAuthRoutes.get("/.well-known/webauthn", (c) => {
|
||||
if (!origin) {
|
||||
return c.json({ origins: [] });
|
||||
}
|
||||
return c.json({ origins: [origin] });
|
||||
});
|
||||
|
||||
// Start a WebAuthn registration ceremony
|
||||
registerAuthRoutes.post("/api/register/challenge", async (c) => {
|
||||
const { username, inviteCode } = await c.req.json();
|
||||
|
||||
if (!username || !inviteCode) {
|
||||
return c.json({ error: "Username and inviteCode required" }, 400);
|
||||
}
|
||||
|
||||
// Validate invite code early (checks expiration and max_uses bounds)
|
||||
const invite = await sqlWrapper
|
||||
.sql`SELECT id, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
||||
.then((res: any) => res[0]);
|
||||
if (!invite) {
|
||||
return c.json(
|
||||
{ error: "Invalid, expired, or fully claimed invite code" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent hijacking an existing user's account if they already exist
|
||||
const existingUser = await sqlWrapper
|
||||
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
||||
res[0]
|
||||
);
|
||||
if (existingUser) {
|
||||
return c.json({ error: "Username already exists" }, 409);
|
||||
}
|
||||
|
||||
const newUserId = crypto.randomUUID();
|
||||
const userIdBytes = new TextEncoder().encode(newUserId);
|
||||
|
||||
if (!rpID) throw new Error("rpID is missing");
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: username,
|
||||
userID: userIdBytes,
|
||||
attestationType: "direct",
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
requireResidentKey: true,
|
||||
userVerification: "preferred",
|
||||
},
|
||||
timeout: 60000,
|
||||
extensions: {
|
||||
["prf" as string]: {},
|
||||
} as any,
|
||||
});
|
||||
|
||||
setCookie(c, "expected_registration_challenge", options.challenge, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
setCookie(c, "registration_user_id", newUserId, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 300,
|
||||
});
|
||||
|
||||
return c.json({ options, username });
|
||||
});
|
||||
|
||||
// Verify registration and create UUID/session
|
||||
registerAuthRoutes.post("/api/register/verify", async (c) => {
|
||||
try {
|
||||
const { response, username, inviteCode, upgrade_session } = await c.req
|
||||
.json();
|
||||
|
||||
if (!inviteCode && !upgrade_session) {
|
||||
return c.json({ error: "inviteCode or upgrade_session required" }, 400);
|
||||
}
|
||||
|
||||
const expectedChallenge = getCookie(c, "expected_registration_challenge");
|
||||
const registrationUserId = getCookie(c, "registration_user_id");
|
||||
if (!expectedChallenge || !registrationUserId) {
|
||||
return c.json({
|
||||
error: "Missing or expired registration challenge/user ID",
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Prevent race condition account hijacking
|
||||
let user = await sqlWrapper
|
||||
.sql`SELECT id FROM users WHERE username = ${username}`
|
||||
.then(
|
||||
(res: any) => res[0],
|
||||
);
|
||||
if (user) {
|
||||
return c.json({ error: "Username already exists" }, 409);
|
||||
}
|
||||
|
||||
if (!origin || !rpID) throw new Error("Missing origin or rpID");
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: response as RegistrationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
if (!verified || !registrationInfo) {
|
||||
return c.json({ error: "Verification failed" }, 400);
|
||||
}
|
||||
|
||||
// Enterprise Allow-List Verification
|
||||
const allowlistCount = await sqlWrapper
|
||||
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
||||
Number(res[0].count)
|
||||
);
|
||||
if (allowlistCount > 0 && registrationInfo.aaguid) {
|
||||
const isAllowed = await sqlWrapper
|
||||
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
||||
.then((res: any) => res[0]);
|
||||
if (!isAllowed) {
|
||||
auditWrapper.auditLog(null, "failed_attestation_allowlist", null, {
|
||||
aaguid: registrationInfo.aaguid,
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error: "Authenticator AAGUID is not in the enterprise allow-list.",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional Strict Hardware Attestation (e.g. YubiKey-only)
|
||||
if (requireHardwareToken) {
|
||||
if (
|
||||
!registrationInfo.aaguid ||
|
||||
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
||||
) {
|
||||
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
||||
username,
|
||||
reason: "No AAGUID provided",
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error:
|
||||
"Hardware attestation failed: No AAGUID provided. Only certified hardware security keys are permitted.",
|
||||
}, 403);
|
||||
}
|
||||
|
||||
let mdsStatement;
|
||||
try {
|
||||
mdsStatement = await MetadataService.getStatement(
|
||||
registrationInfo.aaguid,
|
||||
);
|
||||
} catch (mdsError) {
|
||||
console.warn("[Auth API] MetadataService lookup error:", mdsError);
|
||||
}
|
||||
|
||||
if (!mdsStatement) {
|
||||
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
||||
username,
|
||||
aaguid: registrationInfo.aaguid,
|
||||
reason: "AAGUID not found in MDS3",
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error:
|
||||
`Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob. Only certified hardware security keys are permitted.`,
|
||||
}, 403);
|
||||
}
|
||||
|
||||
// @ts-ignore: TypeScript definition might be out of date for FIDO MDS3 (1)
|
||||
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
||||
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
||||
username,
|
||||
aaguid: registrationInfo.aaguid,
|
||||
reason: "Software passkey detected",
|
||||
}, getClientIp(c));
|
||||
return c.json({
|
||||
error:
|
||||
"Hardware attestation failed: Authenticator is flagged as a software-based passkey. Only certified hardware security keys are permitted.",
|
||||
}, 403);
|
||||
}
|
||||
}
|
||||
const credentialID = registrationInfo.credential.id;
|
||||
const credentialPublicKey = registrationInfo.credential.publicKey;
|
||||
const counter = registrationInfo.credential.counter;
|
||||
|
||||
const base64CredentialID = typeof credentialID === "string"
|
||||
? credentialID
|
||||
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
|
||||
const base64PublicKey = encodeBase64Url(
|
||||
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
||||
);
|
||||
|
||||
const prfEnabled =
|
||||
(response.clientExtensionResults as any)?.prf?.enabled === true;
|
||||
let prfSalt = null;
|
||||
if (prfEnabled) {
|
||||
const saltBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
prfSalt = encodeBase64Url(saltBytes);
|
||||
}
|
||||
|
||||
if (upgrade_session) {
|
||||
// Ephemeral Guest Sandbox in-flight promotion
|
||||
const sessionDataStr = await valkey.get(upgrade_session);
|
||||
if (!sessionDataStr) {
|
||||
return c.json({ error: "Invalid or expired guest session" }, 400);
|
||||
}
|
||||
const sessionData = JSON.parse(sessionDataStr);
|
||||
if (
|
||||
!sessionData || !sessionData.uuid ||
|
||||
sessionData.account_status !== "guest"
|
||||
) {
|
||||
return c.json({ error: "Invalid guest session state" }, 400);
|
||||
}
|
||||
|
||||
const guestUuid = sessionData.uuid;
|
||||
|
||||
const insertRes = await sqlWrapper
|
||||
.sql`INSERT INTO users (id, username, account_status) VALUES (${guestUuid}, ${username}, 'active') RETURNING id`;
|
||||
user = insertRes[0];
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
||||
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
||||
`;
|
||||
|
||||
// Promote Valkey session
|
||||
await valkey.setex(
|
||||
upgrade_session,
|
||||
28800, // Upgrade TTL to 8 hours
|
||||
JSON.stringify({ uuid: guestUuid, username, account_status: "active" }),
|
||||
);
|
||||
|
||||
// Register session in PostgreSQL
|
||||
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO sessions (id, user_id, expires_at)
|
||||
VALUES (${upgrade_session}, ${user.id}, ${expiresAt})
|
||||
`;
|
||||
} else {
|
||||
// Standard Registration Flow
|
||||
const invite = await sqlWrapper
|
||||
.sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
||||
.then((res: any) => res[0]);
|
||||
if (!invite) {
|
||||
return c.json(
|
||||
{ error: "Invalid, expired, or fully claimed invite code" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const initialStatus = invite.auto_activate === false
|
||||
? "pending"
|
||||
: "active";
|
||||
const insertRes = await sqlWrapper
|
||||
.sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) RETURNING id`;
|
||||
user = insertRes[0];
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
||||
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
||||
`;
|
||||
|
||||
await sqlWrapper.sql`
|
||||
UPDATE invites
|
||||
SET uses_count = uses_count + 1,
|
||||
used_at = NOW(),
|
||||
used_by = ${user.id}
|
||||
WHERE id = ${invite.id}
|
||||
`;
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO invite_redemptions (invite_id, user_id)
|
||||
VALUES (${invite.id}, ${user.id})
|
||||
`;
|
||||
|
||||
if (invite.app_id) {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO grants (user_id, app_id, role)
|
||||
VALUES (${user.id}, ${invite.app_id}, ${invite.role})
|
||||
`;
|
||||
} else if (invite.role === "admin") {
|
||||
const adminApp = await sqlWrapper
|
||||
.sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'`
|
||||
.then((res: any) => res[0]);
|
||||
if (adminApp) {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO grants (user_id, app_id, role)
|
||||
VALUES (${user.id}, ${adminApp.id}, 'admin')
|
||||
ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin'
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditWrapper.auditLog(
|
||||
user.id,
|
||||
"user_registered",
|
||||
null,
|
||||
{ username, inviteCode },
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
// Set response cookie to clear out the challenge
|
||||
setCookie(c, "expected_registration_challenge", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
setCookie(c, "registration_user_id", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
if (cookieDomain) {
|
||||
deleteCookie(c, "session_id", { domain: cookieDomain, path: "/" });
|
||||
}
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"[Auth API] Uncaught Exception in /api/register/verify:",
|
||||
error,
|
||||
);
|
||||
return c.json({ error: error.message || "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
15
server/routes/auth/utils.ts
Normal file
15
server/routes/auth/utils.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export function getCookieDomain(customRpId?: string): string | undefined {
|
||||
const envDomain = Deno.env.get("COOKIE_DOMAIN");
|
||||
if (envDomain) {
|
||||
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
|
||||
}
|
||||
const targetId = customRpId || Deno.env.get("RP_ID") || "";
|
||||
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
|
||||
return undefined;
|
||||
}
|
||||
const parts = targetId.split(".").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `.${parts.slice(-2).join(".")}`;
|
||||
}
|
||||
return `.${targetId}`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user