An OpenID Connect provider and a narrowing token exchange
An OpenID Connect server with an RFC 8693 token exchange, three resource servers, a client behind a backend-for-frontend, and an MCP server for agents.
A person consents once, broadly. Every token after that has to be narrowed by intersection before it touches an API, and no exchange may ever widen one.
- NestJS
- TypeScript
- PostgreSQL
- TypeORM
- Drizzle
- OAuth 2.1
- OIDC
- RFC 8693
- Angular
- Vue
Six services, built to the standards rather than to a tutorial: an OpenID Connect authorization server implementing RFC 8693 token exchange, three resource servers behind it, a single-page client behind a backend-for-frontend, and a Model Context Protocol server so an agent can hold delegated authority.
| Service | Role |
|---|---|
id |
Authorization server, and an RFC 8693 token exchange |
inventory |
Resource server. Items, trades, ownership |
social |
Resource server. The friend graph and privacy decisions |
ratatoskr |
Resource server. Notifications and the realtime stream |
vitrina |
Single-page client behind a backend-for-frontend |
antares-mcp |
Model Context Protocol server, for agents |
The one idea
A person consents once, broadly. After that, the application trades that broad token for a narrow, single-audience one before it touches any API at all. What comes back is the intersection of three sets:
granted = subject scopes
∩ target resource server's scopes
∩ exchanging client's allowed scopes
Every exchange narrows. None of them can widen. Delegated tokens carry a nested
act chain naming each hop, so a resource server can see who acted on whose
behalf to get there.
The consequence that makes it worth doing: the audience is a single string, and the intersection strips everything else. A token that reaches one API cannot reach another. A client that needs three APIs performs three exchanges from its own root token and holds three tokens with nothing in common. It also means there is no second hop — a token audienced at the social service carries only social scopes, so that service cannot exchange it onward even if it wanted to.
Two ways for services to talk: Basic auth vs client credentials
Cross-service calls here are machine-to-machine, never delegated. The codebase has both patterns, and only one of them is worth copying.
The older is a shared symmetric key over HTTP Basic. The username half carries no authority — it names the caller for the logs, and the secret is the whole credential. It is defensible exactly where it sits: one caller, one endpoint that answers a question, TLS end to end, and a machine-generated per-caller secret rather than anything a person could have chosen.
It should still move to client_credentials, and the reason is the
credential’s shape rather than its cryptography. Over TLS the secret is not in
the clear — but it is replayed on every request, it has no expiry, no scope
and no audience, and every hop that terminates TLS can log it. Rotating it is a
coordinated change across every caller at once, which is the kind of task that
gets postponed until it becomes an incident.
Under client_credentials the secret goes to one endpoint, only when a
token is minted, and what reaches the resource server is a short-lived, scoped,
audience-bound token it verifies against JWKS, having never seen the credential
behind it.
The newer pattern already runs on the notification path, where each producing service holds client credentials of its own.
Who may see whose inventory
Originally nobody but the owner, enforced by two inline if statements. Now the
social service decides and the inventory service asks it.
inventory ──▶ GET /internal/inventory-visibility?viewer=&owners=
Authorization: Basic inventory:<key>
◀── { "user-…": { "allowed": true, "reason": "friend" } }
Two independent gates, both required. The token has to carry
inventory:read.others, meaning the client was consented to ask at all; and the
social service has to say yes, meaning the owner permits this viewer. The scope
is named others rather than friends because an owner set to public is
readable by people who are not friends.
The social service returns a decision and never a relationship, so the inventory service never learns what a privacy setting is.
Deliberately deferred
Consent persistence, and rate limiting at a reverse proxy rather than in six separate codebases, are both deferred to a later round rather than forgotten.
The documents are the point
Each repository carries its own architecture document, and two of them record the significant choices I made, the alternatives I rejected, and why.
