Emilio Cignetti

Senior Full-Stack Engineer

Italian and Argentine citizen

May 2025 to nowPersonal project

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

All work

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.