SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same blocker: `focusRegions` and `coarseFactor` existed on the `City` type and nothing implemented them. Uniform lattices would have been 2.9M points for Southern California and 3.7M for the expanded bay. Both packs were unloadable as written. `buildAxis` is the answer, and it is honest about its limits: refinement is per axis, not per rectangle, so a focus region sharpens its whole row *and* its whole column. Two regions at opposite corners refine nearly everything between them. Measured, not guessed — the bay went 0.53M points with one region and 1.64M with three, for detail nobody is looking at from a board this wide. One region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s. Then three things that were only ever right because San Francisco was the only city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a 230-unit board; the bay is 1003 units across and the camera physically could not retreat far enough to frame it. Fog distances were scene units pinned to the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest weather, which over ninety-four kilometres of bay correctly hides three quarters of it — the night view was a black rectangle for a completely reasonable reason. All three now derive from the board. The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a physical ratio of one to four hundred thousand. What is being reproduced is what a moonlit night looks like on a screen in a lit room. The CI gate caught itself, which is the part worth keeping. Port 8431 was already held by a server from an earlier session, so the boot check polled a healthy stranger while the process it started died on EADDRINUSE. It now refuses to run rather than pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import { registerFlights } from "./routes/flights.ts";
|
||||
import { registerHealth } from "./routes/health.ts";
|
||||
import { registerMarkers } from "./routes/markers.ts";
|
||||
import { registerOffices } from "./routes/offices.ts";
|
||||
import { registerSession } from "./routes/session.ts";
|
||||
import { registerWeather } from "./routes/weather.ts";
|
||||
import { createServices } from "./services.ts";
|
||||
import type { ErrorBody } from "../../src/server/wire.ts";
|
||||
@@ -46,6 +47,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
|
||||
registerWeather(app, services);
|
||||
registerMarkers(app, services);
|
||||
registerOffices(app, services);
|
||||
registerSession(app, services);
|
||||
|
||||
app.setNotFoundHandler(async (_req, reply) => {
|
||||
const body: ErrorBody = { error: "not_found", message: "No such route." };
|
||||
|
||||
@@ -13,13 +13,20 @@
|
||||
* - **`jwt`** verifies a token here, for a deployment that would rather not make
|
||||
* an outbound call per request. See `jwt.ts` for why HS256 is the primary path.
|
||||
*
|
||||
* Enforcement is on the server in all three. A viewer object that says
|
||||
* `TERA_AUTH_MODE=password` is a fourth thing an operator can write and
|
||||
* deliberately **not** a fourth branch below: it resolves to `jwt` in
|
||||
* `config.ts`, and the only difference is that this box also signs the token
|
||||
* itself, in `issueSessionToken`. A self-hoster with nowhere to get a token from
|
||||
* needed a way to sign in; giving them a second authorisation path to audit was
|
||||
* not worth it, so they get the same one with a local issuer attached.
|
||||
*
|
||||
* Enforcement is on the server in all of them. A viewer object that says
|
||||
* `authenticated: false` is the only thing a route ever sees, and the route
|
||||
* answers 404 — never 403 — so the endpoint cannot be used to enumerate what
|
||||
* exists.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import type { AuthConfig } from "../config.ts";
|
||||
import { verifyJwt } from "./jwt.ts";
|
||||
@@ -87,6 +94,67 @@ export function createAuth(config: AuthConfig): AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Issuing a session ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sign the session token `resolve()` will later verify.
|
||||
*
|
||||
* Signing and verification live in the same directory on purpose: the claims
|
||||
* this writes are exactly the claims `jwt.ts` checks, including the `iss` and
|
||||
* `aud` an operator configured, and a token that this server issues but cannot
|
||||
* accept is the failure mode worth designing against. `alg` is pinned to HS256
|
||||
* to match `verifyHs256`, which will not accept anything else.
|
||||
*/
|
||||
export function issueSessionToken(
|
||||
config: AuthConfig,
|
||||
subject: string,
|
||||
ttlSeconds: number,
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims: Record<string, unknown> = { sub: subject, iat: now, exp: now + ttlSeconds };
|
||||
if (config.issuer !== "") claims.iss = config.issuer;
|
||||
if (config.audience !== "") claims.aud = config.audience;
|
||||
|
||||
const signed = `${base64url({ alg: "HS256", typ: "JWT" })}.${base64url(claims)}`;
|
||||
const signature = createHmac("sha256", config.jwtSecret).update(signed).digest("base64url");
|
||||
return `${signed}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Set-Cookie` value for a signed-in browser.
|
||||
*
|
||||
* `HttpOnly` because no script has any business reading a bearer token — it is
|
||||
* the difference between an XSS bug that defaces a page and one that walks off
|
||||
* with a session. `Secure` unconditionally, including in development: browsers
|
||||
* treat `localhost` and `127.0.0.1` as trustworthy origins and will store a
|
||||
* Secure cookie there, so there is no dev exemption to add and therefore no dev
|
||||
* exemption to accidentally ship. `SameSite=Lax` because nothing here is a
|
||||
* cross-site POST, and `Lax` is what stops another origin's form from acting as
|
||||
* the signed-in user.
|
||||
*/
|
||||
export function sessionCookie(config: AuthConfig, token: string, ttlSeconds: number): string {
|
||||
return cookie(config.cookieName, encodeURIComponent(token), Math.max(0, Math.floor(ttlSeconds)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clearing has to repeat every attribute that was set. A browser matches a
|
||||
* replacement cookie on name, domain and path, so dropping `Path=/` here would
|
||||
* leave the original in place and sign nobody out.
|
||||
*/
|
||||
export function clearedSessionCookie(config: AuthConfig): string {
|
||||
return cookie(config.cookieName, "", 0);
|
||||
}
|
||||
|
||||
function cookie(name: string, value: string, maxAge: number): string {
|
||||
return `${name}=${value}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function base64url(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
// ---- Reading a session ----------------------------------------------------
|
||||
|
||||
function bearerToken(req: FastifyRequest): string | null {
|
||||
const header = req.headers.authorization;
|
||||
if (typeof header !== "string") return null;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Credential verification for `TERA_AUTH_MODE=password`.
|
||||
*
|
||||
* The rule this file exists to enforce is that **no plaintext password ever
|
||||
* comes out of the environment**. `TERA_AUTH_PASSWORD_HASH` carries a scrypt
|
||||
* digest and the parameters it was derived under, so an operator who leaks their
|
||||
* unit file, their `docker inspect` output or their shell history has leaked a
|
||||
* hash and not an account. A `TERA_AUTH_PASSWORD` that this module would happily
|
||||
* compare against is the version of this feature that must never exist.
|
||||
*
|
||||
* scrypt is `node:crypto`'s, which is why there is no new dependency: it is
|
||||
* memory-hard, it ships in the runtime, and the alternative is pulling argon2 or
|
||||
* bcrypt — a native build — into a repo whose acceptance test is `npm ci` on a
|
||||
* stranger's laptop.
|
||||
*
|
||||
* ## Generating a hash
|
||||
*
|
||||
* There is no script to run and nothing to install. Paste this, type the
|
||||
* password, press ctrl-D, and put the line it prints in
|
||||
* `TERA_AUTH_PASSWORD_HASH`:
|
||||
*
|
||||
* ```sh
|
||||
* node -e 'const c=require("node:crypto");let p="";process.stdin.setEncoding("utf8").on("data",d=>p+=d).on("end",()=>{const s=c.randomBytes(16),k=c.scryptSync(p.replace(/\n$/,""),s,32,{N:16384,r:8,p:1,maxmem:67108864});console.log(`scrypt$16384$8$1$${s.toString("base64url")}$${k.toString("base64url")}`)})'
|
||||
* ```
|
||||
*
|
||||
* It reads stdin rather than taking the password as an argument because an
|
||||
* argument lands in shell history and in anybody's `ps` output. A trailing
|
||||
* newline is stripped so that typing the password and pressing return produces
|
||||
* the same hash as piping it in.
|
||||
*
|
||||
* `session.test.ts` pins the output of exactly that command against the parser
|
||||
* below, so the two cannot drift apart without a test failing.
|
||||
*/
|
||||
|
||||
import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto";
|
||||
|
||||
/** A parsed `TERA_AUTH_PASSWORD_HASH`, with the parameters it was made under. */
|
||||
export interface ScryptHash {
|
||||
n: number;
|
||||
r: number;
|
||||
p: number;
|
||||
salt: Buffer;
|
||||
key: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive-login parameters as of 2026: ~16 MiB and a few tens of
|
||||
* milliseconds. High enough that a leaked hash is not a wordlist away from a
|
||||
* password, low enough that the login endpoint is not itself a way to burn the
|
||||
* box's CPU. The chosen values are recorded *in* the hash, so raising them later
|
||||
* does not invalidate the hashes already in operators' unit files.
|
||||
*/
|
||||
const DEFAULT_PARAMS = { n: 16384, r: 8, p: 1 } as const;
|
||||
const SALT_BYTES = 16;
|
||||
const KEY_BYTES = 32;
|
||||
|
||||
/**
|
||||
* An upper bound on what a hash string is allowed to ask scrypt to allocate.
|
||||
* `TERA_AUTH_PASSWORD_HASH` is operator input rather than attacker input, but a
|
||||
* fat-fingered `N` should be a boot-time demotion and not a box that allocates
|
||||
* sixteen gigabytes on the first login attempt.
|
||||
*/
|
||||
const MAX_MEMORY_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Longer than this and we do not even hash it. scrypt's cost is independent of
|
||||
* the password's length, so this is not about work — it is about not copying a
|
||||
* megabyte of request body into a key-derivation function on an unauthenticated
|
||||
* endpoint.
|
||||
*/
|
||||
export const MAX_PASSWORD_LENGTH = 256;
|
||||
|
||||
export function parseScryptHash(encoded: string): ScryptHash | null {
|
||||
const parts = encoded.split("$");
|
||||
if (parts.length !== 6) return null;
|
||||
const [scheme, nRaw, rRaw, pRaw, saltRaw, keyRaw] = parts;
|
||||
if (scheme !== "scrypt") return null;
|
||||
if (nRaw === undefined || rRaw === undefined || pRaw === undefined) return null;
|
||||
if (saltRaw === undefined || keyRaw === undefined) return null;
|
||||
|
||||
const n = positiveInt(nRaw);
|
||||
const r = positiveInt(rRaw);
|
||||
const p = positiveInt(pRaw);
|
||||
if (n === null || r === null || p === null) return null;
|
||||
// scrypt requires N to be a power of two greater than one, and rejects
|
||||
// anything else at call time. Catching it here turns a runtime throw on the
|
||||
// login path into one line in `/api/v1/health`.
|
||||
if (n < 2 || (n & (n - 1)) !== 0) return null;
|
||||
if (memoryFor(n, r, p) > MAX_MEMORY_BYTES) return null;
|
||||
|
||||
const salt = fromBase64Url(saltRaw);
|
||||
const key = fromBase64Url(keyRaw);
|
||||
if (salt === null || key === null) return null;
|
||||
if (salt.length < 8 || key.length < 16) return null;
|
||||
|
||||
return { n, r, p, salt, key };
|
||||
}
|
||||
|
||||
export function formatScryptHash(hash: ScryptHash): string {
|
||||
const salt = hash.salt.toString("base64url");
|
||||
const key = hash.key.toString("base64url");
|
||||
return `scrypt$${hash.n}$${hash.r}$${hash.p}$${salt}$${key}`;
|
||||
}
|
||||
|
||||
/** Used by the tests and available to anyone who would rather not paste shell. */
|
||||
export async function hashPassword(plaintext: string): Promise<string> {
|
||||
const { n, r, p } = DEFAULT_PARAMS;
|
||||
const salt = randomBytes(SALT_BYTES);
|
||||
const key = await derive(plaintext, salt, KEY_BYTES, { n, r, p });
|
||||
if (key === null) throw new Error("scrypt failed");
|
||||
return formatScryptHash({ n, r, p, salt, key });
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive and compare in constant time.
|
||||
*
|
||||
* The comparison is `timingSafeEqual` over two buffers that are the same length
|
||||
* by construction — the derived key's length is taken from the stored key — so
|
||||
* there is no early exit for an attacker to measure. The caller must still call
|
||||
* this even when it already knows the username is wrong; see `credentialsMatch`.
|
||||
*/
|
||||
export async function verifyPassword(hash: ScryptHash, supplied: string): Promise<boolean> {
|
||||
if (supplied.length > MAX_PASSWORD_LENGTH) return false;
|
||||
const derived = await derive(supplied, hash.salt, hash.key.length, hash);
|
||||
if (derived === null) return false;
|
||||
return timingSafeEqual(derived, hash.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole credential check, and the reason it is one function rather than two
|
||||
* calls at the route: **a wrong username and a wrong password must be the same
|
||||
* event**. Both branches derive a key, both take the same path, and the two
|
||||
* answers are combined only at the end. An implementation that returns early on
|
||||
* an unknown username answers in a millisecond instead of fifty and hands an
|
||||
* attacker a user-enumeration oracle for free.
|
||||
*/
|
||||
export async function credentialsMatch(
|
||||
expectedUsername: string,
|
||||
hash: ScryptHash,
|
||||
suppliedUsername: string,
|
||||
suppliedPassword: string,
|
||||
): Promise<boolean> {
|
||||
const usernameOk = timingSafeStringEqual(expectedUsername, suppliedUsername);
|
||||
const passwordOk = await verifyPassword(hash, suppliedPassword);
|
||||
return usernameOk && passwordOk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two strings of unequal length without leaking the length.
|
||||
*
|
||||
* `timingSafeEqual` throws when the buffers differ in size, which would make the
|
||||
* *shape* of the failure depend on the input. Hashing both first gives two
|
||||
* 32-byte buffers whatever arrived, and a username is not secret enough for the
|
||||
* extra digest to be worth avoiding.
|
||||
*/
|
||||
export function timingSafeStringEqual(a: string, b: string): boolean {
|
||||
const left = createHash("sha256").update(a, "utf8").digest();
|
||||
const right = createHash("sha256").update(b, "utf8").digest();
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
// ---- Internals ------------------------------------------------------------
|
||||
|
||||
function derive(
|
||||
plaintext: string,
|
||||
salt: Buffer,
|
||||
length: number,
|
||||
params: { n: number; r: number; p: number },
|
||||
): Promise<Buffer | null> {
|
||||
const { n, r, p } = params;
|
||||
return new Promise((resolve) => {
|
||||
// The callback form, not `scryptSync`: this runs on a login request, and
|
||||
// fifty milliseconds of synchronous key derivation is fifty milliseconds in
|
||||
// which the one server answers nobody else.
|
||||
scrypt(
|
||||
plaintext,
|
||||
salt,
|
||||
length,
|
||||
{ N: n, r, p, maxmem: Math.max(32 * 1024 * 1024, memoryFor(n, r, p) * 2) },
|
||||
(err, derived) => resolve(err === null ? derived : null),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** scrypt's working set, which is what `maxmem` is checked against. */
|
||||
function memoryFor(n: number, r: number, p: number): number {
|
||||
return 128 * n * r * p;
|
||||
}
|
||||
|
||||
function positiveInt(raw: string): number | null {
|
||||
if (!/^[0-9]{1,10}$/.test(raw)) return null;
|
||||
const value = Number(raw);
|
||||
return value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Buffer.from(s, "base64url")` never fails — it stops at the first character it
|
||||
* cannot use and returns what it had. Round-tripping is the only way to know the
|
||||
* string was really base64url, and a hash with a typo in it must be a boot-time
|
||||
* demotion rather than a password that can never be right.
|
||||
*/
|
||||
function fromBase64Url(raw: string): Buffer | null {
|
||||
if (raw === "") return null;
|
||||
const decoded = Buffer.from(raw, "base64url");
|
||||
return decoded.toString("base64url") === raw ? decoded : null;
|
||||
}
|
||||
+114
-3
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
|
||||
import type {
|
||||
AuthMode,
|
||||
FlightsSourceId,
|
||||
@@ -58,6 +59,23 @@ export interface MarkersConfig {
|
||||
ttlSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one account `TERA_AUTH_MODE=password` will sign in, once the environment
|
||||
* has been read and found to contain a usable credential. `null` everywhere
|
||||
* else, and `routes/session.ts` reads that null as "this box cannot sign anyone
|
||||
* in" and answers 404 on the login endpoint.
|
||||
*/
|
||||
export interface PasswordLogin {
|
||||
username: string;
|
||||
/** Parsed at boot so a typo in the hash is a health line, not a login that never works. */
|
||||
hash: ScryptHash;
|
||||
/** Lifetime of the token this box issues for itself, in seconds. */
|
||||
sessionTtlSeconds: number;
|
||||
/** Failed attempts one IP may make inside `rateWindowSeconds` before 429s start. */
|
||||
rateAttempts: number;
|
||||
rateWindowSeconds: number;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
mode: AuthMode;
|
||||
/** Where a browser sends someone to sign in. `sso` mode only. */
|
||||
@@ -73,6 +91,8 @@ export interface AuthConfig {
|
||||
jwksUrl: string;
|
||||
issuer: string;
|
||||
audience: string;
|
||||
/** Set only by `TERA_AUTH_MODE=password`; see `PasswordLogin` and `loadPasswordLogin`. */
|
||||
passwordLogin: PasswordLogin | null;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
@@ -243,11 +263,17 @@ function loadMarkers(env: Env, degraded: string[]): MarkersConfig {
|
||||
};
|
||||
}
|
||||
|
||||
const AUTH_MODES: AuthMode[] = ["none", "sso", "jwt"];
|
||||
/**
|
||||
* `password` is a mode an operator asks for, not a mode the rest of the server
|
||||
* ever sees. It resolves to `jwt` below; see `loadPasswordLogin` for why.
|
||||
*/
|
||||
type ConfiguredAuthMode = AuthMode | "password";
|
||||
|
||||
const AUTH_MODES: ConfiguredAuthMode[] = ["none", "sso", "jwt", "password"];
|
||||
|
||||
function loadAuth(env: Env, degraded: string[]): AuthConfig {
|
||||
const asked = str(env, "TERA_AUTH_MODE", "none");
|
||||
let mode = oneOf(asked, AUTH_MODES);
|
||||
let mode: ConfiguredAuthMode | null = oneOf(asked, AUTH_MODES);
|
||||
if (mode === null) {
|
||||
degraded.push(
|
||||
`TERA_AUTH_MODE="${asked}" is not one of ${AUTH_MODES.join(", ")}; ` +
|
||||
@@ -260,7 +286,8 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
|
||||
const revalidateUrl = str(env, "TERA_AUTH_REVALIDATE_URL", "");
|
||||
const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", "");
|
||||
const jwksUrl = str(env, "TERA_AUTH_JWKS_URL", "");
|
||||
const jwtVerify = str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256";
|
||||
let jwtVerify: "hs256" | "jwks" =
|
||||
str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256";
|
||||
|
||||
// A demotion here has teeth: it takes private offices with it, which is the
|
||||
// safe direction. Unverifiable credentials must never mean "let them in".
|
||||
@@ -287,6 +314,33 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
|
||||
mode = "none";
|
||||
}
|
||||
|
||||
// `password` collapses into `jwt` here, and that is the whole design rather
|
||||
// than an implementation detail. `routes/session.ts` signs exactly the token
|
||||
// `jwt` mode already verifies, so a browser that signed in on this box and a
|
||||
// browser carrying a token from the fleet's issuer arrive at `offices.ts`
|
||||
// through the same `resolve()` — one authorisation path that can be reasoned
|
||||
// about, rather than two that have to be kept in agreement. What `password`
|
||||
// adds is an issuer, not a verifier.
|
||||
let passwordLogin: PasswordLogin | null = null;
|
||||
if (mode === "password") {
|
||||
passwordLogin = loadPasswordLogin(env, jwtSecret, degraded);
|
||||
if (passwordLogin === null) {
|
||||
mode = "none";
|
||||
} else {
|
||||
mode = "jwt";
|
||||
if (jwtVerify === "jwks") {
|
||||
// Verifying against someone else's public keys while signing with a
|
||||
// local secret means this box would reject its own sessions.
|
||||
degraded.push(
|
||||
"TERA_AUTH_MODE=password issues its own HS256 tokens, so " +
|
||||
"TERA_AUTH_JWT_VERIFY=jwks was ignored; this box verifies the " +
|
||||
"sessions it signs.",
|
||||
);
|
||||
jwtVerify = "hs256";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
entryUrl,
|
||||
@@ -297,6 +351,63 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
|
||||
jwksUrl,
|
||||
issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""),
|
||||
audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""),
|
||||
passwordLogin,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential for `TERA_AUTH_MODE=password`, or `null` with a loud line if
|
||||
* the environment did not supply a usable one.
|
||||
*
|
||||
* Every failure here is a demotion to `mode=none` rather than a refusal to boot,
|
||||
* which is the rule the whole file follows — but note which direction the
|
||||
* demotion runs: mode=none makes every private office answer 404 to everybody,
|
||||
* including the operator. Nobody is let in by a misconfiguration.
|
||||
*
|
||||
* `TERA_AUTH_PASSWORD_HASH` is a hash and there is deliberately no plaintext
|
||||
* equivalent to set. `auth/password.ts` carries the command that produces one.
|
||||
*/
|
||||
function loadPasswordLogin(
|
||||
env: Env,
|
||||
jwtSecret: string,
|
||||
degraded: string[],
|
||||
): PasswordLogin | null {
|
||||
const username = str(env, "TERA_AUTH_PASSWORD_USER", "");
|
||||
const encoded = str(env, "TERA_AUTH_PASSWORD_HASH", "");
|
||||
|
||||
if (username === "" || encoded === "") {
|
||||
degraded.push(
|
||||
"TERA_AUTH_MODE=password needs TERA_AUTH_PASSWORD_USER and " +
|
||||
"TERA_AUTH_PASSWORD_HASH (a scrypt hash — see server/src/auth/password.ts " +
|
||||
"for the one-liner that prints one). Demoted to mode=none; private " +
|
||||
"offices will answer 404 to everyone.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (jwtSecret === "") {
|
||||
degraded.push(
|
||||
"TERA_AUTH_MODE=password needs TERA_AUTH_JWT_SECRET to sign the session " +
|
||||
"cookie it issues — try `openssl rand -base64 48`. Demoted to mode=none.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = parseScryptHash(encoded);
|
||||
if (hash === null) {
|
||||
degraded.push(
|
||||
"TERA_AUTH_PASSWORD_HASH is not a hash this server can read; it should " +
|
||||
"look like `scrypt$16384$8$1$<salt>$<key>`. Demoted to mode=none.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
hash,
|
||||
sessionTtlSeconds: num(env, "TERA_AUTH_SESSION_TTL", 43_200, degraded),
|
||||
rateAttempts: num(env, "TERA_AUTH_LOGIN_ATTEMPTS", 8, degraded),
|
||||
rateWindowSeconds: num(env, "TERA_AUTH_LOGIN_WINDOW", 300, degraded),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* `/api/v1/session` — the only place this box issues a credential rather than
|
||||
* checking one.
|
||||
*
|
||||
* It exists because neither committed mode lets a human sign in: `none` is open
|
||||
* and `jwt` verifies a token somebody else already issued. `TERA_AUTH_MODE=password`
|
||||
* fills that gap for a self-hoster with no identity provider, and it fills it by
|
||||
* signing the same HS256 token `jwt` mode verifies and putting it in a cookie.
|
||||
* `offices.ts` is unchanged and unaware; there is still exactly one code path
|
||||
* that decides whether a private office exists for you.
|
||||
*
|
||||
* Three deliberate refusals to be helpful, all of them about not answering
|
||||
* questions an anonymous caller should not get answers to:
|
||||
*
|
||||
* - A wrong username and a wrong password produce the same status, the same body
|
||||
* and the same amount of work. `credentialsMatch` in `auth/password.ts` is
|
||||
* where that is enforced.
|
||||
* - When this box cannot sign anyone in, `POST` answers **404 with the body an
|
||||
* unrouted path gets**, not "password login is disabled". Whether an operator
|
||||
* configured a local account is not public information.
|
||||
* - A private office still answers 404 rather than 403, before and after signing
|
||||
* in. That rule lives in `offices.ts` and nothing here weakens it.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
clearedSessionCookie,
|
||||
issueSessionToken,
|
||||
sessionCookie,
|
||||
} from "../auth/index.ts";
|
||||
import { MAX_PASSWORD_LENGTH, credentialsMatch } from "../auth/password.ts";
|
||||
import type { ErrorBody } from "../../../src/server/wire.ts";
|
||||
import type { Services } from "../services.ts";
|
||||
|
||||
/**
|
||||
* What the client gets to know. `passwordLogin` is the field the UI gates on:
|
||||
* it says a login form would do something here, which is not a secret — the
|
||||
* login page is served to anyone — while `authenticated` says whether this
|
||||
* particular caller needs one.
|
||||
*/
|
||||
export interface SessionBody {
|
||||
authenticated: boolean;
|
||||
/** The signed-in subject, or `null`. Never a token. */
|
||||
subject: string | null;
|
||||
/** Whether `POST` to this endpoint can sign somebody in on this deployment. */
|
||||
passwordLogin: boolean;
|
||||
}
|
||||
|
||||
/** Byte-identical to what the not-found handler serves for a path with no route. */
|
||||
const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such route." };
|
||||
|
||||
/**
|
||||
* One message for every rejected credential. It says nothing about which half
|
||||
* was wrong, and the same object is sent for a username that does not exist.
|
||||
*/
|
||||
const REJECTED: ErrorBody = {
|
||||
error: "unauthorized",
|
||||
message: "Those credentials were not accepted.",
|
||||
};
|
||||
|
||||
/**
|
||||
* `unauthorized` rather than a token of its own because the error union in
|
||||
* `wire.ts` is closed and deliberately short. The 429 and `Retry-After` carry
|
||||
* the distinction, and a client that only reads `error` treats a rate-limited
|
||||
* attempt as a failed one, which is the right thing for it to do.
|
||||
*/
|
||||
const RATE_LIMITED: ErrorBody = {
|
||||
error: "unauthorized",
|
||||
message: "Too many sign-in attempts. Try again shortly.",
|
||||
};
|
||||
|
||||
const MAX_USERNAME_LENGTH = 256;
|
||||
|
||||
export function registerSession(app: FastifyInstance, services: Services): void {
|
||||
const { auth } = services.config;
|
||||
// Per app instance rather than per module, so two servers in one process —
|
||||
// which is what the tests are — cannot share a limiter.
|
||||
const limiter = createLimiter(
|
||||
auth.passwordLogin?.rateAttempts ?? 8,
|
||||
auth.passwordLogin?.rateWindowSeconds ?? 300,
|
||||
);
|
||||
|
||||
app.get("/api/v1/session", async (req) => {
|
||||
const viewer = await services.auth.resolve(req);
|
||||
return body(viewer.authenticated, viewer.subject, auth.passwordLogin !== null);
|
||||
});
|
||||
|
||||
app.post("/api/v1/session", async (req, reply) => {
|
||||
const login = auth.passwordLogin;
|
||||
if (login === null) return reply.code(404).send(NOT_FOUND);
|
||||
|
||||
const retryAfter = limiter.check(req.ip);
|
||||
if (retryAfter !== null) {
|
||||
// Before reading the body and before hashing anything: the point of the
|
||||
// limit is that a flood of attempts costs the box nothing.
|
||||
return reply.code(429).header("retry-after", String(retryAfter)).send(RATE_LIMITED);
|
||||
}
|
||||
|
||||
const supplied = credentials(req.body);
|
||||
if (supplied === null) {
|
||||
// A malformed body is a failed attempt, not a 400. Answering differently
|
||||
// would let an attacker probe the endpoint without spending attempts.
|
||||
limiter.fail(req.ip);
|
||||
return reply.code(401).send(REJECTED);
|
||||
}
|
||||
|
||||
const ok = await credentialsMatch(
|
||||
login.username,
|
||||
login.hash,
|
||||
supplied.username,
|
||||
supplied.password,
|
||||
);
|
||||
if (!ok) {
|
||||
limiter.fail(req.ip);
|
||||
return reply.code(401).send(REJECTED);
|
||||
}
|
||||
|
||||
limiter.succeed(req.ip);
|
||||
const token = issueSessionToken(auth, login.username, login.sessionTtlSeconds);
|
||||
reply.header("set-cookie", sessionCookie(auth, token, login.sessionTtlSeconds));
|
||||
return body(true, login.username, true);
|
||||
});
|
||||
|
||||
// Signing out is available in every mode, including the ones where this box
|
||||
// never issued the cookie. Refusing to clear a cookie tells the caller
|
||||
// something about how the deployment is configured and helps nobody.
|
||||
app.delete("/api/v1/session", async (_req, reply) => {
|
||||
reply.header("set-cookie", clearedSessionCookie(auth));
|
||||
return body(false, null, auth.passwordLogin !== null);
|
||||
});
|
||||
}
|
||||
|
||||
function body(authenticated: boolean, subject: string | null, passwordLogin: boolean): SessionBody {
|
||||
return { authenticated, subject, passwordLogin };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the two fields, or decide there was nothing to read.
|
||||
*
|
||||
* Length caps are here rather than at the hash: a request body is up to a
|
||||
* megabyte and there is no reason to hand any of it to a key-derivation
|
||||
* function on an unauthenticated route.
|
||||
*/
|
||||
function credentials(raw: unknown): { username: string; password: string } | null {
|
||||
if (raw === null || typeof raw !== "object") return null;
|
||||
const { username, password } = raw as { username?: unknown; password?: unknown };
|
||||
if (typeof username !== "string" || typeof password !== "string") return null;
|
||||
if (username === "" || password === "") return null;
|
||||
if (username.length > MAX_USERNAME_LENGTH || password.length > MAX_PASSWORD_LENGTH) return null;
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
// ---- Rate limiting --------------------------------------------------------
|
||||
|
||||
interface Bucket {
|
||||
failures: number;
|
||||
windowEndsAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed window of failures per client address, held in memory.
|
||||
*
|
||||
* In memory because there is one process and one box (CONTRACT.md §5) and a
|
||||
* Redis to lose is worse than a limiter that resets on deploy. Keyed on
|
||||
* `req.ip`, which is the real client because Caddy is the only thing that can
|
||||
* reach the socket and `trustProxy` is on — nothing else can forge the header.
|
||||
*
|
||||
* Only failures count. A working session should not be able to lock its own
|
||||
* owner out by reloading, and an attacker who is already succeeding is not
|
||||
* someone a rate limit is going to help with.
|
||||
*/
|
||||
function createLimiter(attempts: number, windowSeconds: number) {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const limit = Math.max(1, Math.floor(attempts));
|
||||
const windowMs = Math.max(1, Math.floor(windowSeconds)) * 1000;
|
||||
|
||||
/** Bounded so a flood from many addresses cannot grow the map without limit. */
|
||||
const MAX_TRACKED = 4096;
|
||||
|
||||
function sweep(now: number): void {
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.windowEndsAt <= now) buckets.delete(key);
|
||||
}
|
||||
// Still full after dropping the expired ones: that is a flood rather than
|
||||
// accumulation, and clearing is the right trade. The worst case is that some
|
||||
// attackers get their attempts back; the alternative is unbounded memory.
|
||||
if (buckets.size >= MAX_TRACKED) buckets.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
/** Seconds the caller should wait, or `null` when it may try now. */
|
||||
check(ip: string): number | null {
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(ip);
|
||||
if (bucket === undefined || bucket.windowEndsAt <= now) return null;
|
||||
if (bucket.failures < limit) return null;
|
||||
return Math.max(1, Math.ceil((bucket.windowEndsAt - now) / 1000));
|
||||
},
|
||||
|
||||
fail(ip: string): void {
|
||||
const now = Date.now();
|
||||
if (buckets.size >= MAX_TRACKED) sweep(now);
|
||||
const bucket = buckets.get(ip);
|
||||
if (bucket === undefined || bucket.windowEndsAt <= now) {
|
||||
buckets.set(ip, { failures: 1, windowEndsAt: now + windowMs });
|
||||
return;
|
||||
}
|
||||
bucket.failures += 1;
|
||||
},
|
||||
|
||||
succeed(ip: string): void {
|
||||
buckets.delete(ip);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Signing in, and the things signing in must not reveal.
|
||||
*
|
||||
* The positive case is one test; the rest of this file is about the negatives,
|
||||
* because those are the ones that fail quietly in production. A wrong username
|
||||
* and a wrong password have to be the same event byte for byte. A box with no
|
||||
* local account has to look like a box with no such route. And a private office
|
||||
* has to go back to not existing the moment the cookie is cleared — a logout
|
||||
* that only hides the UI is not a logout.
|
||||
*
|
||||
* Follows `offices.test.ts`: a temp directory of office packs, `buildApp` with a
|
||||
* config built from a fake environment, and `inject()` rather than a socket.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { buildApp } from "../app.ts";
|
||||
import { loadConfig } from "../config.ts";
|
||||
import { hashPassword, parseScryptHash, verifyPassword } from "../auth/password.ts";
|
||||
import type { SessionBody } from "../routes/session.ts";
|
||||
|
||||
const SECRET = "not-a-real-secret-and-never-was";
|
||||
const USER = "karti";
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
|
||||
/**
|
||||
* Produced by the exact command documented in `auth/password.ts`, pasted
|
||||
* verbatim, for the password above. It is a fixture rather than a call to
|
||||
* `hashPassword` so that the shell an operator is told to run and the parser
|
||||
* this server ships cannot drift apart without this test going red.
|
||||
*/
|
||||
const HASH_FROM_THE_DOCUMENTED_COMMAND =
|
||||
"scrypt$16384$8$1$Ns_--HDodVjObBpWzIp5TQ$_2BLRqJI14VRzr2iA-3OqR-Z1X-bVrezZYqmaU477xc";
|
||||
|
||||
/** A minimal floor. `Plan` is what makes sense of it; the API only carries it. */
|
||||
const floor = { id: "hq", name: "HQ", levels: [], viewpoints: [] };
|
||||
|
||||
let dir = "";
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "tera-session-"));
|
||||
await writeFile(
|
||||
join(dir, "open.json"),
|
||||
JSON.stringify({ id: "open", name: "Open office", visibility: "public", floor }),
|
||||
);
|
||||
await writeFile(
|
||||
join(dir, "closed.json"),
|
||||
JSON.stringify({ id: "closed", name: "Closed office", visibility: "private", floor }),
|
||||
);
|
||||
});
|
||||
|
||||
const passwordEnv = {
|
||||
TERA_AUTH_MODE: "password",
|
||||
TERA_AUTH_PASSWORD_USER: USER,
|
||||
TERA_AUTH_PASSWORD_HASH: HASH_FROM_THE_DOCUMENTED_COMMAND,
|
||||
TERA_AUTH_JWT_SECRET: SECRET,
|
||||
};
|
||||
|
||||
function appWith(env: Record<string, string>) {
|
||||
const config = loadConfig({ TERA_OFFICES_DIR: dir, ...env });
|
||||
config.logLevel = "silent";
|
||||
return buildApp(config);
|
||||
}
|
||||
|
||||
function login(app: ReturnType<typeof buildApp>, username: string, password: string) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/session",
|
||||
payload: { username, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** The `Set-Cookie` value, reduced to what a browser would send back. */
|
||||
function cookiePair(setCookie: unknown): string {
|
||||
const header = Array.isArray(setCookie) ? String(setCookie[0]) : String(setCookie);
|
||||
return header.split(";")[0] ?? "";
|
||||
}
|
||||
|
||||
describe("the login endpoint", () => {
|
||||
it("accepts the right credentials and sets a locked-down cookie", async () => {
|
||||
const app = appWith(passwordEnv);
|
||||
after(() => app.close());
|
||||
|
||||
const res = await login(app, USER, PASSWORD);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.deepEqual(res.json<SessionBody>(), {
|
||||
authenticated: true,
|
||||
subject: USER,
|
||||
passwordLogin: true,
|
||||
});
|
||||
|
||||
const header = String(res.headers["set-cookie"]);
|
||||
assert.match(header, /^tera_session=[^;]+;/);
|
||||
assert.match(header, /HttpOnly/);
|
||||
assert.match(header, /Secure/);
|
||||
assert.match(header, /SameSite=Lax/);
|
||||
assert.match(header, /Path=\//);
|
||||
// Whatever else it is, it must never be a body a shared cache would keep.
|
||||
assert.equal(res.headers["cache-control"], "private, no-store");
|
||||
});
|
||||
|
||||
it("rejects a wrong password and an unknown user identically", async () => {
|
||||
const app = appWith(passwordEnv);
|
||||
after(() => app.close());
|
||||
|
||||
const wrongPassword = await login(app, USER, "not the password");
|
||||
const unknownUser = await login(app, "someone-else", PASSWORD);
|
||||
|
||||
assert.equal(wrongPassword.statusCode, 401);
|
||||
assert.equal(unknownUser.statusCode, 401);
|
||||
assert.deepEqual(wrongPassword.json(), unknownUser.json());
|
||||
// Nothing that could be used to tell the two apart, including a cookie that
|
||||
// was set and immediately cleared.
|
||||
assert.equal(wrongPassword.headers["set-cookie"], undefined);
|
||||
assert.equal(unknownUser.headers["set-cookie"], undefined);
|
||||
});
|
||||
|
||||
it("treats a malformed body as a failed attempt rather than a hint", async () => {
|
||||
const app = appWith(passwordEnv);
|
||||
after(() => app.close());
|
||||
|
||||
for (const payload of [{}, { username: USER }, { username: 1, password: 2 }]) {
|
||||
const res = await app.inject({ method: "POST", url: "/api/v1/session", payload });
|
||||
assert.equal(res.statusCode, 401);
|
||||
}
|
||||
});
|
||||
|
||||
it("stops answering after too many failures from one address", async () => {
|
||||
const app = appWith({ ...passwordEnv, TERA_AUTH_LOGIN_ATTEMPTS: "3" });
|
||||
after(() => app.close());
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
assert.equal((await login(app, USER, "wrong")).statusCode, 401);
|
||||
}
|
||||
|
||||
const limited = await login(app, USER, "wrong");
|
||||
assert.equal(limited.statusCode, 429);
|
||||
assert.ok(Number(limited.headers["retry-after"]) > 0);
|
||||
|
||||
// And the limit is not a way past the password: the right credentials are
|
||||
// refused too while the window is open.
|
||||
assert.equal((await login(app, USER, PASSWORD)).statusCode, 429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a session cookie and a private office", () => {
|
||||
it("opens the private office, then closes again once the session is cleared", async () => {
|
||||
const app = appWith(passwordEnv);
|
||||
after(() => app.close());
|
||||
|
||||
const before = await app.inject({ method: "GET", url: "/api/v1/offices/closed" });
|
||||
assert.equal(before.statusCode, 404);
|
||||
|
||||
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
|
||||
const opened = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/offices/closed",
|
||||
headers: { cookie },
|
||||
});
|
||||
assert.equal(opened.statusCode, 200);
|
||||
assert.equal(opened.headers["cache-control"], "private, no-store");
|
||||
|
||||
const out = await app.inject({ method: "DELETE", url: "/api/v1/session", headers: { cookie } });
|
||||
assert.equal(out.statusCode, 200);
|
||||
assert.equal(out.json<SessionBody>().authenticated, false);
|
||||
// The browser is told to drop it, and the same attributes are repeated so
|
||||
// that the replacement actually matches the cookie it is replacing.
|
||||
const cleared = String(out.headers["set-cookie"]);
|
||||
assert.match(cleared, /^tera_session=;/);
|
||||
assert.match(cleared, /Max-Age=0/);
|
||||
assert.match(cleared, /HttpOnly/);
|
||||
assert.match(cleared, /Secure/);
|
||||
|
||||
// A browser that dropped the cookie is a browser with no session, and the
|
||||
// office goes back to not existing.
|
||||
const after_ = await app.inject({ method: "GET", url: "/api/v1/offices/closed" });
|
||||
assert.equal(after_.statusCode, 404);
|
||||
});
|
||||
|
||||
it("reports the session state for the client to gate its UI", async () => {
|
||||
const app = appWith(passwordEnv);
|
||||
after(() => app.close());
|
||||
|
||||
const anonymous = await app.inject({ method: "GET", url: "/api/v1/session" });
|
||||
assert.deepEqual(anonymous.json<SessionBody>(), {
|
||||
authenticated: false,
|
||||
subject: null,
|
||||
passwordLogin: true,
|
||||
});
|
||||
|
||||
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
|
||||
const signedIn = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
|
||||
assert.deepEqual(signedIn.json<SessionBody>(), {
|
||||
authenticated: true,
|
||||
subject: USER,
|
||||
passwordLogin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("still keeps the token out of reach of a script", async () => {
|
||||
const app = appWith(passwordEnv);
|
||||
after(() => app.close());
|
||||
|
||||
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
|
||||
const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
|
||||
// The session body is what a page can read. It must not contain the bearer
|
||||
// token the cookie is carrying.
|
||||
assert.equal(state.body.includes(cookie.split("=")[1] ?? "never"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deployments that cannot sign anyone in", () => {
|
||||
it("leaves mode=none open and offers no login", async () => {
|
||||
const app = appWith({});
|
||||
after(() => app.close());
|
||||
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/open" })).statusCode, 200);
|
||||
|
||||
const state = await app.inject({ method: "GET", url: "/api/v1/session" });
|
||||
assert.deepEqual(state.json<SessionBody>(), {
|
||||
authenticated: false,
|
||||
subject: null,
|
||||
passwordLogin: false,
|
||||
});
|
||||
|
||||
// Not "login is disabled" — the same 404 an unrouted path gets.
|
||||
const attempt = await login(app, USER, PASSWORD);
|
||||
const nowhere = await app.inject({ method: "POST", url: "/api/v1/no-such-thing" });
|
||||
assert.equal(attempt.statusCode, 404);
|
||||
assert.deepEqual(attempt.json(), nowhere.json());
|
||||
});
|
||||
|
||||
it("demotes to mode=none when the hash is unreadable, and says so", async () => {
|
||||
const config = loadConfig({
|
||||
TERA_OFFICES_DIR: dir,
|
||||
...passwordEnv,
|
||||
TERA_AUTH_PASSWORD_HASH: "scrypt$notanumber$8$1$aaaa$bbbb",
|
||||
});
|
||||
config.logLevel = "silent";
|
||||
const app = buildApp(config);
|
||||
after(() => app.close());
|
||||
|
||||
assert.equal(config.auth.mode, "none");
|
||||
assert.equal(config.auth.passwordLogin, null);
|
||||
assert.ok(config.degraded.some((line) => line.includes("TERA_AUTH_PASSWORD_HASH")));
|
||||
// The demotion runs towards closed: nobody gets the private office.
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/closed" })).statusCode, 404);
|
||||
assert.equal((await login(app, USER, PASSWORD)).statusCode, 404);
|
||||
});
|
||||
|
||||
it("demotes when there is no secret to sign a session with", async () => {
|
||||
const config = loadConfig({
|
||||
TERA_AUTH_MODE: "password",
|
||||
TERA_AUTH_PASSWORD_USER: USER,
|
||||
TERA_AUTH_PASSWORD_HASH: HASH_FROM_THE_DOCUMENTED_COMMAND,
|
||||
});
|
||||
assert.equal(config.auth.mode, "none");
|
||||
assert.ok(config.degraded.some((line) => line.includes("TERA_AUTH_JWT_SECRET")));
|
||||
});
|
||||
|
||||
it("resolves password mode to jwt so there is one authorisation path", async () => {
|
||||
const config = loadConfig(passwordEnv);
|
||||
assert.equal(config.auth.mode, "jwt");
|
||||
assert.equal(config.auth.jwtVerify, "hs256");
|
||||
assert.equal(config.auth.passwordLogin?.username, USER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hash format", () => {
|
||||
it("reads what the documented one-liner writes", async () => {
|
||||
const parsed = parseScryptHash(HASH_FROM_THE_DOCUMENTED_COMMAND);
|
||||
assert.notEqual(parsed, null);
|
||||
assert.equal(parsed?.n, 16384);
|
||||
assert.equal(parsed?.r, 8);
|
||||
assert.equal(parsed?.p, 1);
|
||||
assert.equal(await verifyPassword(parsed!, PASSWORD), true);
|
||||
assert.equal(await verifyPassword(parsed!, "close but no"), false);
|
||||
});
|
||||
|
||||
it("round-trips a freshly generated hash, with a different salt each time", async () => {
|
||||
const first = await hashPassword(PASSWORD);
|
||||
const second = await hashPassword(PASSWORD);
|
||||
assert.notEqual(first, second);
|
||||
|
||||
const parsed = parseScryptHash(first);
|
||||
assert.notEqual(parsed, null);
|
||||
assert.equal(await verifyPassword(parsed!, PASSWORD), true);
|
||||
});
|
||||
|
||||
it("refuses anything it cannot verify against", () => {
|
||||
for (const bad of [
|
||||
"",
|
||||
"hunter2",
|
||||
"bcrypt$16384$8$1$aaaa$bbbb",
|
||||
"scrypt$16384$8$1$aaaa",
|
||||
"scrypt$16383$8$1$c21d5m2wtoFIu4-rXO6mXA$aaaaaaaaaaaaaaaaaaaaaaaa", // N not a power of two
|
||||
"scrypt$1073741824$8$1$c21d5m2wtoFIu4-rXO6mXA$aaaaaaaaaaaaaaaaaaaaaaaa", // absurd memory
|
||||
"scrypt$16384$8$1$not base64!$aaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"scrypt$16384$8$1$c21d5m2wtoFIu4-rXO6mXA$aa", // key too short to be a key
|
||||
]) {
|
||||
assert.equal(parseScryptHash(bad), null, `${bad} must not parse`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user