Threat model for OAuth-server.ts
An authorization server is the front door of every application behind it, so the question “what is this defended against, and how do you know” deserves a written answer rather than a list of RFC numbers. This page is that answer. It follows the structure of the OAuth 2.0 Security Best Current Practice (RFC 9700) and the older threat catalogue (RFC 6819): name the attackers, name what they want, and for each threat point at the control and at the test that would fail if the control were removed.
Every control below links to its source in the repository. Where a test holds the control, the test is named. Where a control does not exist, the section titled Known limitations says so, because a threat model that lists only the good news is not one you can plan around.
In scope is everything in the repository: the protocol endpoints, the end-user screens, the administration console and its API, the MCP control plane, the storage adapters and the container image. Out of scope is what the security policy excludes: denial of service by volume, findings that presuppose a compromised administrator account or a compromised database, and reports against dependencies with no demonstrated effect on this server.
Assets
Section titled “Assets”What an attacker would want, roughly in order of blast radius.
| Asset | Where it lives | Why it matters |
|---|---|---|
| Signing keys | the jwksStore collection |
Whoever holds a private key mints tokens every relying party trusts |
| Client secrets, registration access tokens | the Client collection |
A stolen confidential-client secret impersonates that client to the token endpoint |
| Refresh tokens, authorization codes, access tokens | token collections, all with TTL indexes | Direct access to a user’s resources; a refresh token for as long as the grant lives |
| End-user and administrator credentials | argon2id hashes on the user record; TOTP secrets | Account takeover, and for administrators, control of the instance |
| Sessions and interaction state | the _session, _interaction and admin cookies, opaque ids into the store |
Riding an authenticated session without the credential |
| DPoP nonce secret, pairwise subject salt | singleton secret documents | Forging nonces; correlating users across relying parties |
| Configuration and the audit trail | configStore, adminAudit |
Turning off a control silently; rewriting the record of who did what |
| SMTP credentials, upstream provider client secrets and signing keys | settings and federation documents | Sending mail as the instance; impersonating it to an upstream identity provider |
Trust boundaries
Section titled “Trust boundaries”Internet --TLS--> reverse proxy --> OAuth-server.ts --> MongoDB (terminates TLS, | sets X-Forwarded-For) +--> upstream identity providers (OIDC) +--> SMTP relay +--> Sentry (optional, allow-listed event shape)AI agent --bearer token, aud = MCP -----> |The server assumes TLS is terminated in front of it and that the issuer is an https:// URL. It
trusts X-Forwarded-For and its relatives only when rateLimit.trustedProxy says so
(rateLimit.ts);
otherwise every request shares one origin bucket, which fails safe rather than letting a client
choose its own address. The database is inside the boundary: a party who can read it can read
secrets, which is why a compromised database is out of scope and why the operator section below
is not optional.
Attackers
Section titled “Attackers”Following RFC 9700 §3, the server is built against:
- A web attacker who controls their own domain and can get a user’s browser to visit it. They can register a client, run a relying party, and send any request a browser can be made to send.
- A network attacker between the user and the proxy, or between the proxy and an upstream provider. TLS is assumed to hold; what the model covers is what remains when it does.
- A malicious or compromised client, including one legitimately registered by an attacker, trying to obtain tokens for another client or another user.
- A malicious or compromised resource server replaying tokens it was shown.
- An AI agent acting on the MCP control plane with a token meant for something else, or a prompt-injected agent asked to do something destructive with the permissions it legitimately has.
Not modelled: malware in the user’s own browser or device, a compromised administrator account, a compromised database, and attackers with volume alone.
Threats and controls
Section titled “Threats and controls”Each row is a threat, the control, and the evidence. Tests live under
test/ and run on every push.
Authorization endpoint
Section titled “Authorization endpoint”| Threat | Control | Evidence |
|---|---|---|
| Authorization code interception (RFC 9700 §4.5) | PKCE is required of every client, public or confidential, and only S256 is accepted; plain is refused |
pkce.ts, test/pkce/pkce.spec.ts |
| Redirect to an attacker-controlled URI (§4.1) | Redirect URIs match by exact string; the only exception is the loopback port of a native client per RFC 8252 §7.3. No wildcards exist. Fragments, non-loopback http for native clients and non-reverse-DNS custom schemes are refused at registration |
checks.ts, validateRedirectUri.ts, test/helpers/redirect_uri.spec.ts |
| Mix-up attacks (§4.4) | Every authorization response carries iss (RFC 9207); the OIDC callback of the admin console checks it |
authorization/, login.ts |
| Code replay | A code is consumed on first use; a second presentation revokes the whole grant, not only the code | authorization_code.ts, test/authorization_code/code.grant.spec.ts |
| Code bound to the wrong party | The exchange checks client, redirect URI, account and grant expiry; codes live sixty seconds | liveTime.ts |
| Cross-site request forgery on the interaction screens | Interaction and session cookies are HttpOnly; Secure; SameSite=Lax — Lax withholds them on exactly the cross-site POSTs and subresource requests a forgery is made of, while still permitting the top-level navigation every sign-in that begins at a relying party depends on; the device-code and end-session confirmations also carry a per-session token |
param_list.ts, test/sessions/sessions.spec.ts, test/end_session/end_session.spec.ts |
| Open redirect after logout | post_logout_redirect_uri must be registered on the client or the parameter is refused |
end_session.ts |
| Clickjacking of the consent screen | Every HTML page ships frame-ancestors 'none' and X-Frame-Options: DENY; inline scripts are allowed by hash only, never by 'unsafe-inline' |
csp.ts, test/csp/csp.spec.ts |
Token endpoint and tokens
Section titled “Token endpoint and tokens”| Threat | Control | Evidence |
|---|---|---|
| Refresh token theft and replay (§4.14) | Public clients and any token past seventy percent of its life are rotated; presenting a consumed refresh token destroys it and revokes the grant family; total lifetime is capped | refresh_token.ts, tokens.ts, test/refresh/ |
| Access token replay by a resource server (§4.10) | DPoP (RFC 9449): htm, htu, ath, jti replay detection and server nonces derived from a rolling secret; a code exchanged with dpop_jkt yields tokens bound to that key, and the binding is re-checked on refresh |
validate_dpop.ts, dpop_nonces.ts, test/dpop/dpop.spec.ts |
| Token presented to the wrong audience (§4.9) | Resource indicators (RFC 8707) restrict the audience; the MCP plane refuses any token whose aud is not itself, and UserInfo refuses audience-carrying tokens |
check_resource.ts, principal.ts, test/resource_indicators/ |
Algorithm confusion, alg: none |
JWT access tokens refuse none; symmetric algorithms require a secret key object; the allowed lists are fixed per use |
jwt.ts, jwaAlgorithms.ts, test/formats/jwt.spec.ts |
Client assertion replay or misuse (private_key_jwt) |
exp, jti, iss and aud are mandatory; iss must equal the client id; aud must be this issuer or the endpoint called; jti is replay-checked |
token_jwt_auth.ts, test/client_auth/client_auth.spec.ts |
| Timing side channel on secret comparison | Client secrets, PKCE verifiers, device user codes, registration tokens, TOTP codes and DPoP nonces are compared in constant time; the client secret comparison pads to a floor of a thousand characters | constant_equals.ts, test/helpers/constant_equals.spec.ts |
| Unregistered or over-broad request parameters | Routes validate against TypeBox schemas with normalisation off, so an undeclared property is refused rather than silently dropped | index.ts |
Accounts and sign-in
Section titled “Accounts and sign-in”| Threat | Control | Evidence |
|---|---|---|
| Password brute force and credential stuffing | A per-bucket, per-identity failure counter with a doubling lockout window, checked before the account is even looked up, and indistinguishable from a wrong password. It has no kill switch, deliberately | throttle.ts, test/login_throttle/ |
| Offline cracking of a leaked user table | Passwords are hashed with argon2id via Bun.password; federated accounts get an unusable random hash |
setup.ts, resolve.ts |
| Phished or reused password | Per-bucket TOTP (RFC 6238) with a ten-failure window per account and a per-interaction attempt cap | totp/, test/totp/ |
| Guessing a verification code or reset link | Codes are six digits, hashed at rest, capped at five attempts and then refused even when correct; links are 32 random bytes, single use, and the reset challenge is destroyed before the password changes so a replay cannot land | verification/challenge.ts, password_reset/challenge.ts, test/email_verification/, test/password_reset/ |
| Replay or CSRF against an upstream sign-in | State is single use; the nonce must round-trip in the upstream ID token where the provider issues one; and the authorization code is bound to its request whenever the upstream is known to support it — stated as data for a recognised provider rather than inferred from its metadata, because three of the four recognised providers support that binding without advertising a method for it | federation/state.ts, verifyIdToken.ts, test/federation/ |
| Correlating one user across relying parties | Pairwise subject identifiers, salted from a stored secret | pairwiseSalt.ts |
Administration and the MCP control plane
Section titled “Administration and the MCP control plane”| Threat | Control | Evidence |
|---|---|---|
| An administrator reaching another group’s containers | Ownership is by group membership, resolved live on every request; a removal takes effect on the next call. Instance-wide surfaces are super-admin only | rbac.ts, test/admin/rbac.spec.ts |
| Confused deputy: an agent presenting a token issued for another resource | The MCP plane accepts only tokens whose audience is itself, and the agent gets exactly its authorising administrator’s permissions, never more | principal.ts, test/mcp/roles.spec.ts |
| A prompt-injected agent deleting something | High-consequence tools need a second call carrying a confirmation token bound to the tool, target, argument hash, principal and agent; a mismatch spends the token so it cannot be probed. The tool list is an allow-list checked against the admin routes by a test, not the whole surface | confirm.ts, catalogue.ts, test/mcp/confirmation_matrix.spec.ts, test/mcp/catalogue_drift.spec.ts |
| Covering tracks | Every state-changing admin route writes an append-only audit entry before the mutation; the store exposes no update or delete, and a test fails when a mutating route is not classified | record.ts, test/admin/audit_route_classification.spec.ts, test/admin/audit_secrecy.spec.ts |
| Secrets leaking through telemetry | The error store keeps an allow-list of fields, submitted field names and never values, and the caller address is keyed-hashed and truncated. Sentry receives an allow-listed event shape; an event with an unexpected key is refused, not scrubbed. Nothing in lib/ logs a token, secret or password |
redact.ts, sentry/dispatch.ts, test/error_store/redaction.spec.ts, test/sentry/redaction.spec.ts, test/mcp/secrecy.spec.ts |
| CSRF against the admin API from a web attacker’s page | The admin session cookie is SameSite=Strict, scoped to /admin, and no response ever carries Access-Control-Allow-Credentials |
session.ts, cors.ts |
Transport and browser surface
Section titled “Transport and browser surface”| Threat | Control | Evidence |
|---|---|---|
| Cross-origin reads of token responses | CORS is closed by data: an origin may read a client-based endpoint only if it is listed on the project that owns the calling client; an unidentifiable client gets no header at all | cors.ts, test/cors/ |
| Downgrade, sniffing, referrer leaks | Strict-Transport-Security for two years with subdomains, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, a denying Permissions-Policy, and default-src 'none' on every non-HTML response |
securityHeaders.ts, test/security_headers/ |
| Exhausting the sign-in, token or registration endpoints | Per-origin rate limiting tiered by route class, mounted before routing so a refused request costs nothing | rateLimit.ts, test/rate_limit/ |
Known limitations
Section titled “Known limitations”These are true today. Each is either a deliberate trade-off with its reasoning, or a gap with its compensating control. A fix that closes one is welcome; a report that one of them is exploitable in a way the compensating control does not cover is a vulnerability report.
- Client secrets, signing keys and the SMTP password are stored unencrypted in the database. The database is inside the trust boundary and a compromised database is out of scope; the compensating control is the operator’s: network isolation and access control on MongoDB, and encryption at rest where the platform offers it. Encrypting these fields with an operator-held key remains an open design question; nothing has been decided against it.
- The rate limiter is a resource protection. It is not a security boundary: counters are in-process, multiply per machine, reset on restart and fail open under an address-rotation flood. The boundaries that must hold absolutely, the sign-in throttle, verification attempt caps and TOTP failure windows, are persisted per identity and do not depend on it.
- CSRF protection on the admin API and on the sign-in, registration and TOTP screens rests on
SameSitecookies alone, with no per-request token. The admin console’s cookie isStrict, since nothing legitimately navigates there from another site; the end-user cookies areLax, because a relying party’s sign-in is a cross-site top-level navigation andStrictrefused it.Laxstill withholds the cookie on every cross-site POST and subresource request, which is the shape each of these submissions takes. What it permits thatStrictdid not is a cross-site top-levelGET; no operation reachable that way changes state for anyone but the signed-in user whose own pending interaction it belongs to. Every current browser honours the attribute, and the credentialed CORS path that would bypass it does not exist; a browser that ignoresSameSiteis the assumption this rests on. - Cookies are opaque random identifiers and carry no signature. Forging one means guessing a session id in the store; nothing on the cookie itself is trusted.
- Email-verification link tokens are looked up directly rather than hashed at rest. A reader of the database could redeem a pending link within its twenty-four-hour life; the six-digit codes are hashed.
- Device-flow user codes have no per-code attempt counter. Entropy is set by the mask, the comparison is constant-time, and the verification screen is in the strict rate class; a dedicated counter would make the code cheaper to give and is the natural next step.
- The container image runs as root. The process writes no files and binds one port; the compensating control is the platform’s sandbox. Adding a non-root user is straightforward and tracked.
- TOTP is not enforced for administrators by default. It is a per-bucket setting, and the first administrator guide says to turn it on before anything else; the default is a choice about first-run friction rather than a limitation of the control.
dpop.allowReplayexists for deployments that cannot run replay detection. It is absent from the settings console on purpose and defaults to off.
What the operator owns
Section titled “What the operator owns”The controls above assume a deployment that: terminates TLS in front of the server with an
https:// issuer; sets rateLimit.trustedProxy only when a proxy it controls sets the forwarding
headers; keeps MongoDB off the public network with authentication on and encryption at rest where
available; turns on the TOTP requirement for the administrator bucket; and backs up the database,
because the signing keys are in it. Behind a reverse proxy covers
the first two.
How this page stays true
Section titled “How this page stays true”The evidence column is the maintenance rule: a control listed here without a test is a control that can disappear in a refactor with nothing to say so. When a control changes, this page changes in the same pull request, and the assurance page describes the scanning that runs regardless of anyone remembering to.