A plan view in the corner, a night you can actually see, and three kinds of visitor
The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.
Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.
Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".
Three things this run found and fixed rather than shipped:
- entryUrl came off the wire and went straight into an href with no scheme
check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
javascript: URL from navigating. One rejection point in access.ts now.
- A 5xx from /health was the same null as "no API at all" and therefore the
opposite conclusion. Eight seconds of tera-api restarting would have told
every anonymous visitor they were a member. A 5xx is an answer; it fails
closed.
- decodeURIComponent in cookieToken was the one path in auth/index.ts that
threw rather than returning ANONYMOUS, so one malformed cookie header from
an unauthenticated caller turned /api/v1/session into a 500.
Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -171,6 +171,51 @@ a JWKS-only implementation would reject every real token. (CONTRACT.md §6.)
|
||||
never created — so the endpoint cannot be used to enumerate what exists. A pack
|
||||
that does not declare its visibility is treated as private.
|
||||
|
||||
### The god tier
|
||||
|
||||
| variable | default | what it does |
|
||||
| --- | --- | --- |
|
||||
| `TERA_ADMIN_SUBJECTS` | *(empty)* | Comma-separated subject ids that get the admin tier. Empty means **no admins**. |
|
||||
|
||||
There are three tiers on the wire and the server decides all three.
|
||||
`GET /api/v1/session` answers `{ authenticated, subject, admin, passwordLogin }`:
|
||||
anonymous is `authenticated: false`, a member is `authenticated: true`, and a god
|
||||
is `admin: true`. The client reads `admin` to decide what to *draw* — the
|
||||
time/date scrubber, the debug panel — and nothing is authorised by it. A boolean
|
||||
that arrived over the wire is a rendering hint; anything that actually matters is
|
||||
checked again where it is enforced.
|
||||
|
||||
The list holds **subject ids**, meaning the `sub` claim this box verifies — not
|
||||
an email and not a display name. Under `TERA_AUTH_MODE=password` the subject is
|
||||
`TERA_AUTH_PASSWORD_USER`, so the single self-hosted account becomes an admin by
|
||||
naming it here:
|
||||
|
||||
```ini
|
||||
TERA_AUTH_MODE=password
|
||||
TERA_AUTH_PASSWORD_USER=karti
|
||||
TERA_ADMIN_SUBJECTS=karti
|
||||
```
|
||||
|
||||
Password mode is deliberately **not** auto-admin. One grant path, written down
|
||||
in the environment, is worth more than a convenience that makes "who is a god on
|
||||
this box" a question you answer by reading code.
|
||||
|
||||
Matching is exact after trimming and **case-sensitive**: `karti` and `KARTI` are
|
||||
two ids as far as an issuer is concerned, and folding case here would widen a
|
||||
grant to something nobody configured.
|
||||
|
||||
`TERA_ADMIN_SUBJECTS=*` grants the tier to **every authenticated subject**. It is
|
||||
a development escape hatch for a self-hoster who does not want to go find their
|
||||
own subject id first, it must never reach a deployment env file, and it pushes a
|
||||
line into `degraded` so `/api/v1/health` announces it. lumbridge-v4 is why:
|
||||
`ADMIN_EMAILS` shipped with `admin@lumbridgecorp.com` as a committed default
|
||||
while nobody had registered that address — a standing offer of admin to whoever
|
||||
claimed it first, invisible because nothing said it was on. A grant nobody can
|
||||
see is a grant nobody revokes.
|
||||
|
||||
Health never serves the list or its length. The `degraded` lines name the
|
||||
variable; they never name a subject.
|
||||
|
||||
## Deploying
|
||||
|
||||
Three files in `../deploy`, and exactly one of each:
|
||||
|
||||
@@ -35,23 +35,69 @@ export interface Viewer {
|
||||
authenticated: boolean;
|
||||
/** Stable subject id where one is known. Never a token, never an email. */
|
||||
subject: string | null;
|
||||
/**
|
||||
* The god tier. Decided here, from `TERA_ADMIN_SUBJECTS` and nothing else:
|
||||
* the client is *told* whether it is an admin, it never asserts it, and no
|
||||
* request header, query parameter or claim in the token can turn this on.
|
||||
* Anonymous is never an admin, and neither is an authenticated subject the
|
||||
* operator did not list — including the local `password` account, which gets
|
||||
* no automatic grant precisely so that there is one path to godmode and it
|
||||
* is legible in the env file.
|
||||
*/
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
resolve(req: FastifyRequest): Promise<Viewer>;
|
||||
}
|
||||
|
||||
const ANONYMOUS: Viewer = { authenticated: false, subject: null };
|
||||
const ANONYMOUS: Viewer = { authenticated: false, subject: null, admin: false };
|
||||
|
||||
/** Positive revalidations are held briefly; negative ones are not held at all. */
|
||||
const SESSION_TTL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Whether a subject holds the god tier, by the one rule there is.
|
||||
*
|
||||
* Exported because `routes/session.ts` has to answer the POST that signs
|
||||
* somebody in *before* any token has been round-tripped through `resolve()`,
|
||||
* and its answer must be the value the next `GET /api/v1/session` produces for
|
||||
* the same account. Two expressions of the same rule is exactly how a UI ends
|
||||
* up drawing controls the server will refuse to honour, so there is one
|
||||
* function and both callers go through it.
|
||||
*
|
||||
* The grant comes in on `AuthConfig` rather than as a second argument to
|
||||
* `createAuth` so that the list arrives by the same route as every other thing
|
||||
* an operator configured, read once in `config.ts` and never from `process.env`
|
||||
* down here.
|
||||
*/
|
||||
export function grantsAdmin(config: AuthConfig, subject: string | null): boolean {
|
||||
// `*` means "everyone who is authenticated", so it grants even where the
|
||||
// issuer handed us no `sub` to match against. It is a development switch and
|
||||
// it announces itself in `degraded`; see `loadAdmins` in config.ts.
|
||||
if (config.admins.everyone) return true;
|
||||
if (subject === null) return false;
|
||||
// Exact match. The trimming happened once, at load.
|
||||
return config.admins.subjects.includes(subject);
|
||||
}
|
||||
|
||||
export function createAuth(config: AuthConfig): AuthService {
|
||||
const sessions = new Map<string, { viewer: Viewer; checkedAt: number }>();
|
||||
|
||||
async function revalidate(token: string): Promise<Viewer> {
|
||||
// The cache is keyed on a hash so that a heap dump, a debugger or a stray
|
||||
// log line never contains a usable session token.
|
||||
//
|
||||
// What it holds is the whole viewer, `admin` included, for up to
|
||||
// SESSION_TTL_MS. That is safe to hold because the grant is a pure function
|
||||
// of the subject and of `TERA_ADMIN_SUBJECTS`, and the environment is read
|
||||
// exactly once, at boot: the only way to change who is an admin is to edit
|
||||
// the env file and restart, and a restart is a new process with an empty
|
||||
// map. There is no sequence of operator actions that leaves a stale
|
||||
// `admin: true` being served. What the sixty seconds does cost is the other
|
||||
// direction — a session revoked upstream keeps working for up to a minute,
|
||||
// admin sessions along with everything else — and a minute of staleness on
|
||||
// a positive revalidation is the trade this cache exists to make.
|
||||
const key = createHash("sha256").update(token).digest("hex");
|
||||
const hit = sessions.get(key);
|
||||
if (hit !== undefined && Date.now() - hit.checkedAt < SESSION_TTL_MS) return hit.viewer;
|
||||
@@ -65,7 +111,7 @@ export function createAuth(config: AuthConfig): AuthService {
|
||||
if (res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { sub?: unknown } | null;
|
||||
const sub = typeof body?.sub === "string" ? body.sub : null;
|
||||
viewer = { authenticated: true, subject: sub };
|
||||
viewer = { authenticated: true, subject: sub, admin: grantsAdmin(config, sub) };
|
||||
}
|
||||
} catch {
|
||||
// An unreachable identity service means nobody is authenticated. That is
|
||||
@@ -89,7 +135,8 @@ export function createAuth(config: AuthConfig): AuthService {
|
||||
|
||||
const claims = await verifyJwt(token, config);
|
||||
if (claims === null) return ANONYMOUS;
|
||||
return { authenticated: true, subject: typeof claims.sub === "string" ? claims.sub : null };
|
||||
const subject = typeof claims.sub === "string" ? claims.sub : null;
|
||||
return { authenticated: true, subject, admin: grantsAdmin(config, subject) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -165,6 +212,15 @@ function bearerToken(req: FastifyRequest): string | null {
|
||||
/**
|
||||
* Cookies are parsed by hand rather than with a plugin. One header, one split,
|
||||
* and the alternative is a dependency whose entire job is this function.
|
||||
*
|
||||
* The decode is guarded because `decodeURIComponent` throws on malformed
|
||||
* percent-encoding, and that throw was the one error path in this file that
|
||||
* escaped: everything else here is written to hand back `ANONYMOUS` rather than
|
||||
* raise, on the reasoning at the top of `resolve()`. A single request carrying
|
||||
* `Cookie: tera_session=%zz` turned `GET /api/v1/session` — and the private
|
||||
* office check that shares this code path — into a 500, from an unauthenticated
|
||||
* caller, with one header. A cookie that is not valid percent-encoding is not a
|
||||
* token this box issued, so the honest answer is "no token".
|
||||
*/
|
||||
function cookieToken(req: FastifyRequest, name: string): string | null {
|
||||
const header = req.headers.cookie;
|
||||
@@ -174,7 +230,12 @@ function cookieToken(req: FastifyRequest, name: string): string | null {
|
||||
if (eq === -1) continue;
|
||||
if (pair.slice(0, eq).trim() !== name) continue;
|
||||
const value = pair.slice(eq + 1).trim();
|
||||
return value === "" ? null : decodeURIComponent(value);
|
||||
if (value === "") return null;
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,18 @@ export interface PasswordLogin {
|
||||
rateWindowSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds the god tier, resolved from `TERA_ADMIN_SUBJECTS`. See `loadAdmins`
|
||||
* for the rules; the shape exists so that "everyone" is a state the type system
|
||||
* knows about rather than a magic string left sitting in `subjects`.
|
||||
*/
|
||||
export interface AdminGrant {
|
||||
/** `TERA_ADMIN_SUBJECTS=*`. Development only; see `loadAdmins`. */
|
||||
everyone: boolean;
|
||||
/** Exact subject ids, trimmed. Empty with `everyone: false` means no admins. */
|
||||
subjects: string[];
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
mode: AuthMode;
|
||||
/** Where a browser sends someone to sign in. `sso` mode only. */
|
||||
@@ -93,6 +105,8 @@ export interface AuthConfig {
|
||||
audience: string;
|
||||
/** Set only by `TERA_AUTH_MODE=password`; see `PasswordLogin` and `loadPasswordLogin`. */
|
||||
passwordLogin: PasswordLogin | null;
|
||||
/** Subjects the server will call admins. Unset means nobody; see `loadAdmins`. */
|
||||
admins: AdminGrant;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
@@ -352,9 +366,79 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
|
||||
issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""),
|
||||
audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""),
|
||||
passwordLogin,
|
||||
// Last, because it wants the mode *after* every demotion above has run: a
|
||||
// list of admins on a box that just demoted to mode=none is worth a
|
||||
// sentence, and the sentence is only true once `mode` has settled.
|
||||
admins: loadAdmins(env, mode, degraded),
|
||||
};
|
||||
}
|
||||
|
||||
/** `TERA_ADMIN_SUBJECTS=*`; see `loadAdmins` for why this is a dev switch. */
|
||||
const ADMIN_WILDCARD = "*";
|
||||
|
||||
/**
|
||||
* Who gets the god tier — the time controls, the debug panel, and whatever else
|
||||
* ends up behind `Viewer.admin`.
|
||||
*
|
||||
* `TERA_ADMIN_SUBJECTS` is a comma-separated list of **subject ids**: the `sub`
|
||||
* claim this box verifies, not an email and not a display name. Under
|
||||
* `TERA_AUTH_MODE=password` the subject is `TERA_AUTH_PASSWORD_USER`, so the
|
||||
* single self-hosted account becomes an admin by naming it here — and only by
|
||||
* naming it here. Password mode is deliberately not auto-admin: one grant path,
|
||||
* written down in the environment, is the property worth having.
|
||||
*
|
||||
* **Unset means no admins, and that is the safe default.** A box handed nothing
|
||||
* serves the public map to everyone and the god tier to nobody. Matching is
|
||||
* exact after trimming, and case-sensitive: subject ids are opaque strings an
|
||||
* issuer minted, `user_01H…` and `USER_01H…` can be two different accounts, and
|
||||
* folding case here would silently widen a grant to an id nobody configured.
|
||||
*
|
||||
* The one wildcard is `*`, which makes **every authenticated subject an admin**.
|
||||
* It is here so a self-hoster poking at this on a laptop does not have to go
|
||||
* find their own subject id first. It is a development switch, it must never
|
||||
* reach a deployment env file, and it pushes a line into `degraded` so that
|
||||
* `/api/v1/health` says out loud that the box is handing out godmode.
|
||||
*
|
||||
* That loudness is the whole point, and it is paid for. lumbridge-v4 shipped
|
||||
* `ADMIN_EMAILS` with `admin@lumbridgecorp.com` as a committed default while
|
||||
* nobody had ever registered that address — a standing offer of admin to
|
||||
* whoever signed up for it first, invisible because nothing anywhere announced
|
||||
* it. A grant nobody can see is a grant nobody revokes. Hence: no committed
|
||||
* defaults, no implicit grants, and the one blanket switch reports itself.
|
||||
*
|
||||
* Note what is *not* here: no count and no list ever reaches the wire.
|
||||
* `routes/health.ts` serves `degraded`, and these sentences name the variable,
|
||||
* never its contents.
|
||||
*/
|
||||
function loadAdmins(env: Env, mode: AuthMode, degraded: string[]): AdminGrant {
|
||||
const configured = list(env, "TERA_ADMIN_SUBJECTS");
|
||||
const everyone = configured.includes(ADMIN_WILDCARD);
|
||||
const subjects = configured.filter((subject) => subject !== ADMIN_WILDCARD);
|
||||
|
||||
if (everyone) {
|
||||
degraded.push(
|
||||
"TERA_ADMIN_SUBJECTS=* grants the admin tier to every authenticated " +
|
||||
"subject on this box, time controls and debug included. That is a " +
|
||||
"development switch; anywhere reachable from outside, list the subject " +
|
||||
"ids instead.",
|
||||
);
|
||||
}
|
||||
|
||||
// Not a demotion of this setting — nothing falls back — but a line worth
|
||||
// printing, because admin is a property of an *authenticated* viewer and
|
||||
// mode=none never produces one. An operator who listed admins here and reads
|
||||
// `degraded` learns in one sentence why nobody is getting them.
|
||||
if ((everyone || subjects.length > 0) && mode === "none") {
|
||||
degraded.push(
|
||||
"TERA_ADMIN_SUBJECTS is set, but authentication resolved to mode=none: " +
|
||||
"nobody can sign in, so nobody is an admin. Set TERA_AUTH_MODE, and " +
|
||||
"check the lines above for an auth demotion that got you here.",
|
||||
);
|
||||
}
|
||||
|
||||
return { everyone, subjects };
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential for `TERA_AUTH_MODE=password`, or `null` with a loud line if
|
||||
* the environment did not supply a usable one.
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
clearedSessionCookie,
|
||||
grantsAdmin,
|
||||
issueSessionToken,
|
||||
sessionCookie,
|
||||
type Viewer,
|
||||
} from "../auth/index.ts";
|
||||
import { MAX_PASSWORD_LENGTH, credentialsMatch } from "../auth/password.ts";
|
||||
import type { ErrorBody } from "../../../src/server/wire.ts";
|
||||
@@ -42,6 +44,14 @@ export interface SessionBody {
|
||||
authenticated: boolean;
|
||||
/** The signed-in subject, or `null`. Never a token. */
|
||||
subject: string | null;
|
||||
/**
|
||||
* The god tier, decided by `TERA_ADMIN_SUBJECTS` on the server. The client
|
||||
* reads it to decide what to draw — the time scrubber, the debug panel — and
|
||||
* that is all it is for. It is not a capability: everything gated on it is
|
||||
* gated again where it is enforced, because a boolean that arrived over the
|
||||
* wire is a rendering hint and nothing more.
|
||||
*/
|
||||
admin: boolean;
|
||||
/** Whether `POST` to this endpoint can sign somebody in on this deployment. */
|
||||
passwordLogin: boolean;
|
||||
}
|
||||
@@ -71,6 +81,14 @@ const RATE_LIMITED: ErrorBody = {
|
||||
|
||||
const MAX_USERNAME_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* What `DELETE` reports. Signing out drops the tier with the session, and it
|
||||
* has to be said explicitly rather than left to the client: a page that cached
|
||||
* `admin: true` and only ever hears "authenticated: false" would keep drawing
|
||||
* the god-only controls until the next reload.
|
||||
*/
|
||||
const SIGNED_OUT: Viewer = { authenticated: false, subject: null, admin: false };
|
||||
|
||||
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 —
|
||||
@@ -82,7 +100,7 @@ export function registerSession(app: FastifyInstance, services: Services): void
|
||||
|
||||
app.get("/api/v1/session", async (req) => {
|
||||
const viewer = await services.auth.resolve(req);
|
||||
return body(viewer.authenticated, viewer.subject, auth.passwordLogin !== null);
|
||||
return body(viewer, auth.passwordLogin !== null);
|
||||
});
|
||||
|
||||
app.post("/api/v1/session", async (req, reply) => {
|
||||
@@ -118,7 +136,16 @@ export function registerSession(app: FastifyInstance, services: Services): void
|
||||
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);
|
||||
// The real grant for the account that just signed in, not `false` and not a
|
||||
// guess. `issueSessionToken` put `login.username` in the `sub` claim, so
|
||||
// `grantsAdmin` is being asked the same question about the same string that
|
||||
// `resolve()` will ask on the very next request with this cookie — a login
|
||||
// that answered differently from the GET a moment later would be a flicker
|
||||
// nobody could reproduce.
|
||||
return body(
|
||||
{ authenticated: true, subject: login.username, admin: grantsAdmin(auth, login.username) },
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Signing out is available in every mode, including the ones where this box
|
||||
@@ -126,12 +153,22 @@ export function registerSession(app: FastifyInstance, services: Services): void
|
||||
// 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);
|
||||
return body(SIGNED_OUT, auth.passwordLogin !== null);
|
||||
});
|
||||
}
|
||||
|
||||
function body(authenticated: boolean, subject: string | null, passwordLogin: boolean): SessionBody {
|
||||
return { authenticated, subject, passwordLogin };
|
||||
/**
|
||||
* The one place the session shape is written. All three handlers go through it,
|
||||
* so `admin` cannot be present on one response and missing from another — which
|
||||
* is the bug a client's `s.admin === true` would read as "demoted" and act on.
|
||||
*/
|
||||
function body(viewer: Viewer, passwordLogin: boolean): SessionBody {
|
||||
return {
|
||||
authenticated: viewer.authenticated,
|
||||
subject: viewer.subject,
|
||||
admin: viewer.admin,
|
||||
passwordLogin,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,9 @@ describe("a box handed nothing", () => {
|
||||
assert.equal(config.flights.source, "sim");
|
||||
assert.equal(config.markers.source, "none");
|
||||
assert.equal(config.auth.mode, "none");
|
||||
// Nobody configured an admin, so there is no admin. The god tier has to be
|
||||
// the thing an empty environment does *not* hand out.
|
||||
assert.deepEqual(config.auth.admins, { everyone: false, subjects: [] });
|
||||
assert.equal(config.host, "127.0.0.1");
|
||||
assert.equal(config.port, 8431);
|
||||
assert.deepEqual(config.degraded, []);
|
||||
|
||||
@@ -91,4 +91,24 @@ describe("other misconfigurations", () => {
|
||||
const config = loadConfig({ TERA_MARKERS_SOURCE: "file" });
|
||||
assert.equal(config.markers.source, "none");
|
||||
});
|
||||
|
||||
it("says so when admins are listed on a box where nobody can sign in", () => {
|
||||
// Nothing falls back here — there is nothing to fall back to — but the
|
||||
// operator who wrote a name into TERA_ADMIN_SUBJECTS is owed the sentence
|
||||
// explaining why that name is not getting the time controls.
|
||||
const config = loadConfig({ TERA_ADMIN_SUBJECTS: "karti" });
|
||||
assert.equal(config.auth.mode, "none");
|
||||
assert.deepEqual(config.auth.admins, { everyone: false, subjects: ["karti"] });
|
||||
assert.match(config.degraded[0] ?? "", /TERA_ADMIN_SUBJECTS/);
|
||||
assert.match(config.degraded[0] ?? "", /mode=none/);
|
||||
});
|
||||
|
||||
it("records the wildcard as its own line, on top of any other demotion", () => {
|
||||
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_ADMIN_SUBJECTS: "*" });
|
||||
// The jwt demotion, the wildcard, and the fact that the wildcard cannot
|
||||
// reach anybody on a box that just closed itself: three separate facts, and
|
||||
// health prints all three rather than the first one that happened.
|
||||
assert.equal(config.degraded.length, 3);
|
||||
assert.ok(config.degraded.some((line) => line.includes("TERA_ADMIN_SUBJECTS=*")));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
import type { HealthBody } from "../../../src/server/wire.ts";
|
||||
|
||||
const SECRET = "not-a-real-secret-and-never-was";
|
||||
const USER = "karti";
|
||||
@@ -89,6 +90,7 @@ describe("the login endpoint", () => {
|
||||
assert.deepEqual(res.json<SessionBody>(), {
|
||||
authenticated: true,
|
||||
subject: USER,
|
||||
admin: false,
|
||||
passwordLogin: true,
|
||||
});
|
||||
|
||||
@@ -188,6 +190,7 @@ describe("a session cookie and a private office", () => {
|
||||
assert.deepEqual(anonymous.json<SessionBody>(), {
|
||||
authenticated: false,
|
||||
subject: null,
|
||||
admin: false,
|
||||
passwordLogin: true,
|
||||
});
|
||||
|
||||
@@ -196,6 +199,7 @@ describe("a session cookie and a private office", () => {
|
||||
assert.deepEqual(signedIn.json<SessionBody>(), {
|
||||
authenticated: true,
|
||||
subject: USER,
|
||||
admin: false,
|
||||
passwordLogin: true,
|
||||
});
|
||||
});
|
||||
@@ -212,6 +216,131 @@ describe("a session cookie and a private office", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The god tier is a fact the server states and the client repeats. Everything
|
||||
* below is one shape of the same question — *can the environment, and only the
|
||||
* environment, decide this?* — so the negatives outnumber the positive again:
|
||||
* an unlisted account, an anonymous caller under the blanket switch, a name
|
||||
* that differs only in case. The `*` case is tested for its `degraded` line as
|
||||
* much as for the grant, because a silent grant-everyone switch is the failure
|
||||
* this whole setting is shaped around.
|
||||
*/
|
||||
describe("the admin tier", () => {
|
||||
/** Sign in as USER on a box configured this way, and report what it says. */
|
||||
async function signedIn(env: Record<string, string>): Promise<SessionBody> {
|
||||
const app = appWith({ ...passwordEnv, ...env });
|
||||
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 } });
|
||||
return state.json<SessionBody>();
|
||||
}
|
||||
|
||||
it("grants nobody when TERA_ADMIN_SUBJECTS is unset", async () => {
|
||||
assert.equal((await signedIn({})).admin, false);
|
||||
// And the safe default is silent: an unset variable is not a demotion.
|
||||
assert.deepEqual(loadConfig(passwordEnv).degraded, []);
|
||||
});
|
||||
|
||||
it("grants a listed subject, on the login itself and on the session after it", async () => {
|
||||
const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: USER });
|
||||
after(() => app.close());
|
||||
|
||||
const res = await login(app, USER, PASSWORD);
|
||||
// The POST answers with the real grant for the account that just signed in,
|
||||
// not a placeholder the following GET would contradict.
|
||||
assert.deepEqual(res.json<SessionBody>(), {
|
||||
authenticated: true,
|
||||
subject: USER,
|
||||
admin: true,
|
||||
passwordLogin: true,
|
||||
});
|
||||
|
||||
const cookie = cookiePair(res.headers["set-cookie"]);
|
||||
const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
|
||||
assert.deepEqual(state.json<SessionBody>(), res.json<SessionBody>());
|
||||
});
|
||||
|
||||
it("does not grant an authenticated subject nobody listed", async () => {
|
||||
const state = await signedIn({ TERA_ADMIN_SUBJECTS: "someone-else,third-party" });
|
||||
assert.equal(state.authenticated, true);
|
||||
assert.equal(state.admin, false);
|
||||
});
|
||||
|
||||
it("matches exactly after trimming, and is case-sensitive", async () => {
|
||||
// Surrounding whitespace is an artefact of writing a list in an env file and
|
||||
// is dropped; the id itself must be the id.
|
||||
assert.equal((await signedIn({ TERA_ADMIN_SUBJECTS: " karti , other " })).admin, true);
|
||||
// Case is not. A subject id is an opaque string an issuer minted, and two
|
||||
// ids that differ only in case can be two accounts.
|
||||
assert.equal((await signedIn({ TERA_ADMIN_SUBJECTS: "KARTI" })).admin, false);
|
||||
});
|
||||
|
||||
it("grants everyone under the wildcard, and announces it on health", async () => {
|
||||
const config = loadConfig({ TERA_OFFICES_DIR: dir, ...passwordEnv, TERA_ADMIN_SUBJECTS: "*" });
|
||||
config.logLevel = "silent";
|
||||
const app = buildApp(config);
|
||||
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 } });
|
||||
assert.equal(state.json<SessionBody>().admin, true);
|
||||
|
||||
// The whole point of the wildcard being allowed at all: it cannot be on
|
||||
// without `/api/v1/health` saying so.
|
||||
const health = (await app.inject({ method: "GET", url: "/api/v1/health" })).json<HealthBody>();
|
||||
assert.ok(health.degraded.some((line) => line.includes("TERA_ADMIN_SUBJECTS=*")));
|
||||
});
|
||||
|
||||
it("still refuses the anonymous caller under the wildcard", async () => {
|
||||
const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: "*" });
|
||||
after(() => app.close());
|
||||
|
||||
// "Everyone" means everyone *authenticated*. A browser with no cookie is
|
||||
// not a member, let alone a god.
|
||||
const state = await app.inject({ method: "GET", url: "/api/v1/session" });
|
||||
assert.deepEqual(state.json<SessionBody>(), {
|
||||
authenticated: false,
|
||||
subject: null,
|
||||
admin: false,
|
||||
passwordLogin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the tier away with the session", async () => {
|
||||
const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: USER });
|
||||
after(() => app.close());
|
||||
|
||||
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
|
||||
const out = await app.inject({ method: "DELETE", url: "/api/v1/session", headers: { cookie } });
|
||||
assert.equal(out.statusCode, 200);
|
||||
assert.deepEqual(out.json<SessionBody>(), {
|
||||
authenticated: false,
|
||||
subject: null,
|
||||
// Said out loud rather than implied by `authenticated: false`, so a page
|
||||
// holding the old value has something to overwrite it with.
|
||||
admin: false,
|
||||
passwordLogin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the list and its size off the health body", async () => {
|
||||
const app = appWith({
|
||||
...passwordEnv,
|
||||
TERA_ADMIN_SUBJECTS: `${USER},someone-else,third-party`,
|
||||
});
|
||||
after(() => app.close());
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/api/v1/health" });
|
||||
const health = res.json<HealthBody>();
|
||||
// Who the admins are is not a public question, and neither is how many
|
||||
// there are — a count is an invitation to go looking for the one account.
|
||||
assert.equal(res.body.includes(USER), false);
|
||||
assert.equal(res.body.includes("someone-else"), false);
|
||||
assert.equal(res.body.includes("admin"), false);
|
||||
assert.deepEqual(health.degraded, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deployments that cannot sign anyone in", () => {
|
||||
it("leaves mode=none open and offers no login", async () => {
|
||||
const app = appWith({});
|
||||
@@ -223,6 +352,7 @@ describe("deployments that cannot sign anyone in", () => {
|
||||
assert.deepEqual(state.json<SessionBody>(), {
|
||||
authenticated: false,
|
||||
subject: null,
|
||||
admin: false,
|
||||
passwordLogin: false,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user