Published in openvibe-contracts v0.114.0 (docs/adr/ADR-003-service-principals.md), rendered as is.

ADR-003: Service/app principal authentication and capability grants

Status: Accepted, implemented 2026-09-22. Amended 2026-09-24: delegated authorization semantics and approval UX (roadmap §28.3).

Context and current evidence

First-party services authenticated to each other with one shared X-Internal-Key per deployment, with unrestricted reach.

Decision

Alternatives considered

Migration consequences

Guarded routes accept both during migration; callers fall back to the key only when no token can be had.

Rollback

Remove a grant (new tokens stop carrying it) or stop sending tokens (callers fall back to the key).

Acceptance tests

OpenVibe.Network/test/principals.test.js, modules.test.js, Contracts service-auth tests (forged, expired, wrong audience, alg none, tampered, ungranted, namespace, owner), Media service-token.test.js. Production smoke: tokens issued, cross-app and cross-namespace calls refused.

Amendment 2026-09-24: delegated authorization

Roadmap §28.3 carries "delegated authorization semantics and approval UX" into this ADR. Delegation already runs in production: Network issues app tokens with on_behalf_of (ADR-014, server/developer/tokens.js), and Wiki, Blog, News, Reviews and Trade honour it (server/auth/viewer.js in each, deployed 2026-09-23). This section records the rules they follow, so every other receiver implements the same ones.

Who can act for whom

| Principal | May name the acting person | How | |---|---|---| | First-party service (svc:<id>, actor_type: service) | any person | X-OV-Subject: usr_… on the request. The service vouches for its own signed-in user. | | Developer app (app:app_…) | only the person who authorized it | on_behalf_of in the token. An X-OV-Subject naming anyone else gets 403 subject.not_delegated. | | Mod (mod:mod_…, ADR-013) | only the person who installed or authorized it | the same rule as an app | | Any principal, client_credentials token | nobody | no on_behalf_of. The call is the principal's own, never a person's. |

What a delegated call may do

Approval UX (Network)

In force today (Network server/auth/oauth-routes.js, public/login.html):

  1. /oauth/authorize for an app requires an exact registered redirect_uri and PKCE S256. prompt=none is refused with interaction_required, so a third-party app never gets a code without the person choosing to continue.
  2. The account chooser shows the app's registered name, marked "(third-party app)", and the host the code will be sent to (/oauth/client-info). The name is looked up by client id, never taken from the URL.
  3. The code is single use, lasts 5 minutes, and records the approved scope. The token exchange may narrow that scope but never widen it (400 invalid_scope).

Required next, owned by Network (not built yet):

  1. The chooser lists the requested capabilities in plain words (each capability manifest's description), before the person continues. A capability the app was not granted is not shown and not issued.
  2. A stored authorization per (person, app), holding the approved scopes and a date. A repeated request within those scopes may skip the chooser. A request for more always shows it.
  3. "Connected apps" on my.openvibe.network, listing each authorization with its scopes and last use, and a revoke action. Revoking stops new tokens for that person and app at once.
  4. Revoking a person's authorization, and every Network ban, also ends on_behalf_of issuance for them (the ban check at exchange exists today).

Tests