Skip to content

Inter-service Authorization

A Granit API often needs to call another API — an invoicing backend, a document generator, a third-party partner. That downstream service enforces its own authorization, and the question becomes: whose identity does the outbound call carry?

Three answers live in Granit, in decreasing order of subtlety:

  1. Client Credentials + DPoP — the caller service authenticates as itself, with no user context. Right for machine-to-machine work.
  2. On-Behalf-Of (RFC 8693 Token Exchange) + DPoP — the caller service acts on behalf of an inbound user, with a token whose audience and scope are narrowed to the downstream service. Right for user-initiated chains.
  3. Naive bearer-token propagation — forward the caller’s inbound Authorization: Bearer … as-is. Granit does not offer this: it is a confused-deputy vulnerability (OAuth 2.0 Security BCP §4.8 — RFC 8725). AddAuthTokenPropagation was removed.
ScenarioPatternAPI
Background job, scheduled task, webhook processor calling another Granit serviceClient Credentials + DPoPAddClientCredentialsHttpClient
User-initiated HTTP handler that fans out to a downstream APIOn-Behalf-Of (RFC 8693) + DPoPAddOnBehalfOfHttpClient
SPA → BFF → backend API (user tokens never leave the server)BFF token injectionGranit.Bff.Yarp transform
”Just forward the token I received”No — rewrite using one of the three above

Use when the calling service has no ambient user — a scheduled job, a background worker, an event consumer, a webhook receiver.

builder.Services
.AddGranitHttpClient("PaymentsGateway")
.AddClientCredentialsHttpClient("PaymentsGateway", opts =>
{
opts.Authority = "https://idp.example.com";
opts.ClientId = "billing-worker";
opts.ClientSecret = builder.Configuration["Billing:Secret"]; // Vault
opts.Scope = "payments:write";
opts.UseDPoP = true;
});

The handler acquires an access token via grant_type=client_credentials, caches it for expires_in − DefaultCacheMargin, and attaches it to every outbound request. DPoP proofs are generated per request from a per-process EC P-256 key pair. Full reference: Token Management.


On-Behalf-Of (RFC 8693 Token Exchange) + DPoP

Section titled “On-Behalf-Of (RFC 8693 Token Exchange) + DPoP”

Use when an HTTP handler receives a user’s access token and must call a downstream API as that user. The handler exchanges the inbound token at the identity provider for a new token whose aud claim is bound to the downstream service. This is the only safe way to propagate user identity across a service boundary.

sequenceDiagram
    participant Client as SPA / Client
    participant Upstream as Upstream API<br/>(e.g. payments-api)
    participant IdP as Identity Provider<br/>(OpenIddict / Keycloak)
    participant Downstream as Downstream API<br/>(e.g. invoicing-api)

    Client->>Upstream: POST /payments<br/>Authorization: DPoP <u_token><br/>DPoP: <proof>
    Note over Upstream: Inbound token:<br/>aud=payments-api<br/>scope=payments:write

    Note over Upstream: OnBehalfOfTokenHandler:<br/>1. validate target host + https<br/>2. cache lookup (tenant, sub, aud, scopes)

    alt cache miss
        Upstream->>IdP: POST /connect/token<br/>grant_type=urn:ietf:params:oauth:<br/>grant-type:token-exchange<br/>subject_token=<u_token><br/>audience=invoicing-api<br/>scope=invoicing:write<br/>DPoP: <proof>
        IdP-->>Upstream: 200 { access_token: <d_token>, expires_in: 3600 }
        Note over Upstream: Downstream token:<br/>aud=invoicing-api<br/>scope=invoicing:write<br/>sub=<user> (preserved)
        Upstream->>Upstream: Cache <d_token><br/>key: obo:{tenant}:{sha256(sub|aud|scopes)}
    end

    Upstream->>Downstream: POST /invoices<br/>Authorization: DPoP <d_token><br/>DPoP: <proof>
    Downstream-->>Upstream: 201 { ... }
    Upstream-->>Client: 201 { ... }
builder.Services
.AddGranitHttpClient("InvoicingApi")
.AddOnBehalfOfHttpClient("InvoicingApi", opts =>
{
opts.Authority = "https://idp.example.com";
opts.ClientId = "payments-service";
opts.ClientSecret = builder.Configuration["Obo:Secret"]; // Vault
opts.Audience = "invoicing-api"; // hard-narrows `aud`
opts.Scopes = ["invoicing:write"]; // least-privilege
opts.AllowedHosts = ["invoicing.internal"]; // SSRF guard
// opts.RequireDPoP and opts.RequireHttps are true by default.
});
OptionDefaultPurpose
Authority(required)Base URL of the identity provider
ClientId(required)OAuth client id of THIS service (not the caller)
ClientSecret / ClientSigningKeyJwk(one required)Auth at the token endpoint
Audience(required)Downstream API’s aud value — mandatory
Scopes[]Downstream scopes — narrow to least-privilege
AllowedHosts(required, min 1)Exact-match host allow-list for outbound targets
RequireDPoPtrueBinds the exchanged token to a per-process proof key
RequireHttpstrueRejects http:// targets even if allow-listed
TokenLifetimeSafetyMargin30 sSubtracted from expires_in in the cache TTL

The handler’s failure modes are asymmetric on purpose:

FailureBehaviorReasoning
No ambient HttpContext (background job)Request sent unauthenticated + Warning logThere is no user to act on behalf of; silently forwarding nothing is safer than silently forwarding client-credentials
Outbound host not in AllowedHostsRequest sent unauthenticated + Warning logDefense in depth against SSRF / attacker-influenced URLs
Outbound target is http:// and RequireHttps = trueRequest sent unauthenticated + Warning logTokens must never cross a plaintext channel
IdP refuses the exchange (invalid_grant, access_denied, etc.)Request sent unauthenticatedNever fall back to naive bearer propagation — the downstream returns a clean 401 instead
Distributed cache (Redis) unavailableCall the IdP, continue without cachingFail-open on cache outage — availability beats a cache hit
Token endpoint returns 401 use_dpop_nonce with a fresh nonceRe-sign the request with the new nonce and retry oncePer RFC 9449 §8 nonce handshake

Both handlers emit metrics through TokenManagementMetrics:

MetricTagsMeaning
granit.oidc.token.requesttenant_id, grant_type, client_idToken endpoint calls (includes token-exchange grants)
granit.oidc.token.cache.hittenant_id, client_nameCached-token reuse
granit.oidc.token.cache.misstenant_id, client_nameForced IdP round-trip
granit.oidc.token.errortenant_id, errorAny token endpoint refusal

Warning-level log lines to alert on:

  • OBO[{ClientName}]: rejected token attachment — {Reason} — a request went out unauthenticated. Frequent occurrences indicate misconfiguration or an attack probe.
  • OBO[{ClientName}]: cache operation GetAsync failed — distributed cache degraded; user traffic is still served but with higher IdP load.

The identity provider must support RFC 8693 and recognise the calling service as a client permitted to request token-exchange grants for the target audience.

options.RegisterScopes("invoicing:write");
options.SetTokenEndpointUris("/connect/token");
options.UseReferenceAccessTokens(); // or signed JWTs
// Grant the calling service permission to exchange tokens for this audience.
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = "payments-service",
ClientSecret = secret,
Permissions =
{
Permissions.Endpoints.Token,
Permissions.GrantTypes.TokenExchange, // urn:ietf:params:oauth:grant-type:token-exchange
Permissions.Scopes.Prefixes.Scope + "invoicing:write",
},
});

Before the VULN-100 fix, Granit.Http.Resilience shipped AddAuthTokenPropagation() — a DelegatingHandler that copied the inbound Authorization header verbatim onto every outbound request. This extension has been removed.

The inbound bearer token is issued with a specific aud claim (the upstream API) and a specific scope set (what the user agreed to). Forwarding it to a downstream service creates multiple failure modes:

  • Confused deputy. If any downstream URL is derived from user input (avatar proxy, webhook dispatcher, partner hop), the handler forwards the caller’s token to an attacker-chosen host. The attacker captures a full upstream-valid token and replays it against the real upstream API.
  • Over-privileged downstream. The downstream service sees a token whose aud is the upstream service, not itself. Proper validation would reject it; weak validation (audience check missing) would mistakenly accept it, and the token’s full scope set is honoured regardless of what the downstream actually needed.
  • No revocation horizon. A stolen bearer token is valid until expiry and can be replayed from any network location. DPoP plus a narrowed audience (the On-Behalf-Of pattern) both close this gap.

Every AddGranitHttpClient(...).AddAuthTokenPropagation() call site must become AddOnBehalfOfHttpClient(...) with an explicit audience, allow-list, and client authentication. The resulting token is narrowed to the downstream service and bound to a per-process DPoP key. See Minimum viable configuration.

If the calling context has no ambient user (a background job), the correct replacement is AddClientCredentialsHttpClient(...) instead — the service acts as itself, not on behalf of a user.

PropertyClient Credentials + DPoPOn-Behalf-Of + DPoPNaive propagation (removed)
Downstream aud scoped to target✗ — carries upstream aud
Scope narrowed to downstream needs✗ — full upstream scopes
Sender-constrained (DPoP)
Downstream sees user identity(service identity only)sub preserved(but at high cost)
Host allow-list enforcedN/A (client-level policy)
Fail-closed on IdP refusalN/A — always sent

Both ClientCredentialsTokenHandler and OnBehalfOfTokenHandler are exercised with NSubstitute + xUnit v3 + Shouldly. Minimal fixture:

public sealed class MyHandlerTests
{
private readonly ITokenEndpointService _endpoint = Substitute.For<ITokenEndpointService>();
private readonly IConditionalCache _cache = Substitute.For<IConditionalCache>();
[Fact]
public async Task NoHttpContext_SendsUnauthenticated()
{
// ambient HttpContextAccessor returns null → fail-closed, no Authorization header
}
}

Look at OnBehalfOfTokenHandlerTests for the full set — 9 scenarios covering fail-closed paths, cache hit/miss, and the tenant-key partitioning invariant.

StandardScope
RFC 8693OAuth 2.0 Token Exchange
RFC 9449OAuth 2.0 Demonstrating Proof-of-Possession (DPoP)
RFC 8725 §4.8JWT Best Current Practices — audience constraints
RFC 8707Resource Indicators (optional resource parameter)
OWASP ASVS V13.2OAuth and OIDC controls
OWASP API Security Top 10 (2023) — API2Broken Authentication