Files
netris-nestri/apps/api/app/middleware/auth.ts
Wanjohi 40b4270161 refactor(api)!: remove the shared operator secret, and let hosts sync their own
A single secret that turned any request into an operator was the only
credential several routes accepted, and it had no caller left: the device
pairing it existed for is on hold, and nothing in this tree or any client
sent it. What remained was a key that bypassed authentication entirely,
required to boot, and checked by nobody.

Every route behind it had a better answer available:

- Library and game sync move to host credentials. Both took a `userId` in
  the body, which meant one secret could write into anybody's library. A
  host now says which of its enrolled users a batch is for, and that claim
  is checked against the Steam sign-ins it actually holds — one box carries
  several people's accounts, so the pair is the unit.
- Download-state reporting narrows to hosts alone, and the body that could
  name a different host is gone. Which host is reporting comes from its own
  credentials, and a body that still names one is refused rather than
  ignored.
- Linking a Steam account is always for the caller.
- Creating a game by hand is deleted; syncing already upserts the catalogue.
- Reading the waitlist is deleted. Every address on it belongs to someone
  who has not agreed to anything, and answering it over HTTP made that list
  something a leaked key could drain.
- The pairing-code routes are deleted with the flow they served. The domain
  module and its table stay, so returning to it is a route file rather than
  a migration.

Nothing in the API now accepts a credential that stands for more than one
caller: every request resolves to a specific user or a specific host, which
is what lets a route say "the caller's own library" and mean it.

BREAKING CHANGE: the `x-nestri-admin-token` header is no longer accepted and
`ADMIN_SHARED_SECRET` is no longer read. `POST /games`, `GET /waitlist` and
the `/pairing-code` routes are gone; `POST /games/sync` and `POST /library/sync`
now require host credentials and take `userId` in the body; `POST /steam/link`
no longer accepts `userId`; `POST /games/download-state` no longer accepts
`hostId`.
2026-09-18 22:59:06 +03:00

261 lines
8.8 KiB
TypeScript

import { createClient } from '@nestri/auth/client';
import { AccessToken } from '@nestri/core/access-token/index';
import { Actor } from '@nestri/core/actor';
import { subjects } from '@nestri/core/auth/subjects';
import { Env } from '@nestri/core/env';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Machine } from '@nestri/core/machine/index';
import { Member } from '@nestri/core/team/member';
import type { MiddlewareHandler } from 'hono';
/**
* Reaches the issuer, over a binding where the platform offers one.
*
* A binding routes by binding rather than by hostname, which saves a trip out
* to the internet and back for a call this middleware makes on nearly every
* request. The host in the URL is then arbitrary — but `new Request` still
* demands an absolute URL, and stripping down to a bare path threw
* `Invalid URL` before the token was even looked at.
*
* Where there is no such binding the issuer is an ordinary HTTP origin at
* `AUTH_ISSUER_URL` and plain `fetch` is the whole of it. Nothing else here
* changes, because the URL being fetched is the same one either way.
*/
function issuerFetch(env: Record<string, unknown>, issuer: string) {
const binding = env?.AUTH as { fetch: typeof fetch } | undefined;
if (typeof binding?.fetch === 'function') {
return (input: RequestInfo | URL, init?: RequestInit) => {
const url = asUrl(input);
return binding.fetch(new Request(url, init));
};
}
// The same split the binding makes, spelled out: `AUTH_INTERNAL_URL` is
// the route and `issuer` stays the name. The client builds its URLs from
// the name, so the swap happens here, on the way out, and every claim
// checked afterwards is still checked against the name.
const internal = Env.get().AUTH_INTERNAL_URL?.replace(/\/+$/, '');
if (!internal || internal === issuer) {
return (input: RequestInfo | URL, init?: RequestInit) => fetch(asUrl(input), init);
}
return (input: RequestInfo | URL, init?: RequestInit) => {
const url = asUrl(input);
return fetch(url.startsWith(issuer) ? internal + url.slice(issuer.length) : url, init);
};
}
function asUrl(input: RequestInfo | URL): string {
return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
}
/**
* The issuer must be its **public** URL.
*
* `verify` checks a token's `iss` claim against the issuer the client was
* built with, and the issuer derives what it advertises from the URL it was
* reached on. Tokens are minted through the public URL, so they carry it. A
* placeholder like `https://auth.internal` addresses a binding perfectly well
* — the hostname is ignored there — and then disagrees with every real token.
* Discovery through the binding does not help: it answers with the placeholder
* too, because that is the host it was asked on.
*
* The failure is silent by nature. A rejected claim is reported as `err`,
* which is indistinguishable from an expired or forged token, so the whole
* bearer path returns 401 and looks like ordinary auth working correctly.
* Hence the explicit throw rather than a fallback: a misconfiguration here
* takes down every user session, and it should say so.
*/
function getClient(env: Record<string, unknown>) {
const configured = Env.get().AUTH_ISSUER_URL;
if (!configured) {
throw new Error(
'AUTH_ISSUER_URL is not configured; every bearer token would be rejected as unsigned'
);
}
// The trailing slash matters twice, and both failures are quiet. It is
// appended to build the discovery URL, where `…:1337//.well-known/…` is a
// 404; and it is compared literally against the `iss` claim, which carries
// no trailing slash. A worker URL from the platform arrives with one.
const issuer = configured.replace(/\/+$/, '');
return createClient({
issuer,
clientID: 'api',
fetch: issuerFetch(env, issuer)
});
}
export const auth: MiddlewareHandler = async (c, next) => {
// A registered nessh host proves it is itself, rather than asserting an id
// nobody checks. Wrong credentials fall through to public rather than
// erroring, so probing tells an attacker nothing about which ids exist.
const machineId = c.req.header('x-nestri-machine-id');
const machineSecret = c.req.header('x-nestri-machine-secret');
if (machineId && machineSecret) {
const machine = await Machine.authenticate({ id: machineId, secret: machineSecret });
if (machine) {
await Machine.touchLastSeen({ id: machine.id });
return Actor.with(
{
type: 'machine',
properties: {
machineID: machine.id,
ownerUserID: machine.ownerUserId,
teamID: machine.teamId
}
},
next
);
}
return Actor.with({ type: 'public', properties: {} }, next);
}
const authHeader = c.req.header('authorization');
if (!authHeader) {
return Actor.with({ type: 'public', properties: {} }, next);
}
const match = authHeader.match(/^Bearer (.+)$/);
if (!match) {
return Actor.with({ type: 'public', properties: {} }, next);
}
const token = match[1];
// A personal access token is resolved from the database, never through JWT
// verification. The prefix decides which, so a PAT does not pay for a
// well-known lookup and a JWT does not pay for a query.
if (AccessToken.looksLikeToken(token)) {
const pat = await AccessToken.authenticate(token);
if (!pat) {
return Actor.with({ type: 'public', properties: {} }, next);
}
await AccessToken.touchLastUsed(pat.id);
if (pat.teamId) {
// The team grant is re-checked against live membership rather than
// trusted from the row, so someone removed from a team loses what
// their old token carried without anyone remembering to revoke it.
const membership = await Member.findByTeamAndUser({
teamId: pat.teamId,
userId: pat.ownerUserId
});
if (!membership) {
return Actor.with({ type: 'public', properties: {} }, next);
}
return Actor.with(
{
type: 'member',
properties: {
userID: pat.ownerUserId,
role: membership.role,
teamID: pat.teamId
}
},
next
);
}
return Actor.with(
{
type: 'user',
properties: {
userID: pat.ownerUserId,
// A PAT is tied to neither a Steam account nor a device, so
// it carries neither. A route needing those must read them
// from the user rather than assume the caller came by SSH.
linkedAccountID: '',
fingerprint: undefined
}
},
next
);
}
// A token that cannot be verified — malformed, expired, or because the
// auth service is unreachable — makes the caller unauthenticated, not the
// request a server fault. `verify` reports the first two in `err` and
// *throws* the third, and an uncaught throw turned a bad token into a 500.
let verified;
try {
verified = await getClient(c.env).verify(subjects, token);
} catch (error) {
// eslint-disable-next-line no-console
console.error('token verification failed:', error);
return Actor.with({ type: 'public', properties: {} }, next);
}
if (verified.err) {
return Actor.with({ type: 'public', properties: {} }, next);
}
const { subject } = verified;
if (subject.type === 'user') {
const teamID = c.req.header('x-nestri-team');
if (teamID) {
const membership = await Member.findByTeamAndUser({
teamId: teamID,
userId: subject.properties.userID
});
if (membership) {
return Actor.with(
{
type: 'member',
properties: {
userID: subject.properties.userID,
role: membership.role,
teamID
}
},
next
);
}
}
return Actor.with(
{
type: 'user',
properties: {
userID: subject.properties.userID,
linkedAccountID: subject.properties.linkedAccountID,
fingerprint: subject.properties.fingerprint
}
},
next
);
}
return Actor.with({ type: 'public', properties: {} }, next);
};
/**
* Requires an authenticated caller of any kind, machines included.
*
* It deliberately does *not* single machines out: `/games` applies this to the
* whole group, and download-state — the one route a box exists to call — sits
* inside it. What stops a box from acting as its owner is `Actor.userID`,
* which refuses a machine outright, so a route written for a human cannot
* silently accept a box no matter which guard it sits behind.
*/
export const notPublic: MiddlewareHandler = async (_, next) => {
const actor = Actor.use();
if (actor.type === 'public') {
throw new VisibleError(
'authentication',
ErrorCodes.Authentication.UNAUTHORIZED,
'Missing authorization header'
);
}
return next();
};
/** Requires credentials belonging to a registered nessh host. */
export const machineOnly: MiddlewareHandler = async (_, next) => {
const actor = Actor.use();
if (actor.type !== 'machine') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Machine credentials required'
);
}
return next();
};