ComplicatedAuth
Menu

Guides / Delegated authorization

Delegated authorization.

Register one exact API audience, define immutable capability scopes, grant them to OAuth clients, request a resource-bound token, and evaluate scope-v1 authorization decisions without caller-supplied Tenant context.

Baseline policy: scope-v1 is capability authorization, not a general ACL language. It answers whether this active pairwise Tenant Member token currently carries a registered operation for one Resource Server. The resource identifier and bounded context are stable decision inputs, but this policy does not use them to invent customer-data ownership rules.

Three independent grants

  1. A Tenant administrator registers a Resource Server with one immutable exact audience identifier.
  2. The administrator creates immutable delegated scope tokens, then assigns a non-empty subset to an OAuth Application grant. This is the administrative upper bound.
  3. A Tenant Member explicitly consents to a requested subset during authorization. Consent cannot expand the administrative grant.

Keeping these resources separate prevents a client registration from silently becoming authorization, and prevents user consent from overriding an administrator’s policy.

Register the audience and vocabulary

Use Resource servers in the console or the corresponding management resources. Production audience identifiers require HTTPS; localhost and literal-IP HTTP are allowed only for development. Query strings, fragments, wildcards, aliases, and host-derived defaults are rejected.

Use capability-shaped scope names such as documents.read or invoices:approve. A name is immutable and remains reserved after deletion because changing its meaning would silently change every existing integration. Human labels and descriptions are mutable versioned metadata and appear on the consent screen.

Resource Servers, scopes, and client grants use strong ETags for updates and deletion. Creation requires Idempotency-Key. Disabling or deleting a Resource Server, scope, or client grant revokes affected server-tracked tokens immediately; an offline validator remains bounded by the token’s short expiry.

Request one Resource Server

const authorize = new URL(discovery.authorization_endpoint);
authorize.search = new URLSearchParams({
  response_type: "code",
  client_id: CLIENT_ID,
  redirect_uri: "https://app.example.com/oauth/callback",
  resource: "https://api.example.com",
  scope: "openid profile documents.read",
  state,
  nonce,
  code_challenge: challenge,
  code_challenge_method: "S256",
}).toString();

One request names at most one exact Resource Server. Delegated scopes require resource; a resource indicator requires at least one delegated scope. ComplicatedAuth validates the active audience, client grant, and every requested scope when the request starts, when consent is approved, and again when the code is exchanged. A configuration change between stages fails closed.

Validate the access token

  • Allow only RS256, select the public key by kid, and refresh the configured issuer JWKS once for an unknown key.
  • Verify the exact configured issuer, expiry, issued-at sanity, and exact Resource Server identifier in aud.
  • Use the pairwise sub as the principal and token-derived tenant_uid as the Tenant boundary.
  • Require the operation’s exact scope token. Do not accept prefix, wildcard, substring, case-folded, or implied scopes.
  • Never accept a Tenant ID, subject, audience, or capability from an unsigned header, request parameter, or customer-data record as a replacement for verified claims.

A resource-bound token is rejected by UserInfo. A UserInfo-bound token is rejected by the authorization decision endpoint. This audience separation is intentional.

Ask for a decision

const response = await fetch("https://issuer.example/v1/authorization/decisions", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + accessToken,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    resource: { type: "document", id: "doc_123" },
    operation: "documents.read",
    context: { request_ip_class: "trusted-network" },
  }),
});

const decision = await response.json();
if (!decision.allowed) throw new Error(decision.denial_reason);

The caller cannot submit Tenant, principal, audience, capabilities, policy version, or validity. The service derives them from the active token and current grant catalog. A well-formed denial returns 200 with allowed: false and either missing_capability or unknown_operation. Invalid, expired, revoked, wrong-audience, disabled-member, disabled-client, or disabled-grant tokens return 401 invalid_token.

Decision fields

  • principal is the pairwise Tenant Member subject, not an email address.
  • tenant_uid and the Resource Server come only from the token record.
  • resource is the caller’s opaque customer-resource identity. The Resource Server remains responsible for mapping it to its own data.
  • capabilities contains only current active delegated scopes carried by the token; OpenID scopes are excluded.
  • policy_version is scope-v1:N and changes when the Resource Server policy catalog changes.
  • valid_until never exceeds token expiry. Do not cache an allow result past it; highly sensitive operations should avoid caching decisions.

Failure and evolution rules

Configuration mutations revoke matching server-tracked tokens so online decisions fail immediately. A separately hosted Resource Server performing offline JWT validation cannot observe database revocation until token expiry; keep access tokens short-lived and choose online decisions for operations that require immediate revocation.

Future attribute or relationship policies must be introduced as a new named policy version with explicit semantics. They must not silently reinterpret scope-v1, make context fields authoritative without versioning, or broaden an already issued token.

Search guides, architecture, SDKs, and the REST API.