Arc.Identity.SignedForwarding (cratis_arc v0.1.0)

Copy Markdown View Source

Signed forwarded identity: an ingress attests the identity headers it sends with an HMAC-SHA256 over the request, so the headers are no longer trusted merely because they are present.

It is opt-in. Set trust_forwarded_identity_headers: true and forwarded_identity: [enabled: true, …]:

config :my_app, MyApp.Arc,
  trust_forwarded_identity_headers: true,
  forwarded_identity: [
    enabled: true,
    audience: "my-app",
    keys: [[key_id: "k1", secret_base64: System.fetch_env!("ARC_FORWARDED_IDENTITY_KEY")]]
  ]

Each secret is exactly 32 random bytes, standard-base64 encoded; configure 1 to 8 keys with distinct, case-sensitive ids, from your secret provider, never from a committed file. With signing enabled there is no unsigned fallback: a missing, malformed, expired, replayed or incorrectly signed credential establishes no identity, name, roles or actor.

The wire contract

The ingress sends exactly one x-cratis-forwarded-identity-signature value, v1.<kid>.<ts>.<nonce>.<mac>, at most 256 ASCII bytes: kid matches [A-Za-z0-9_-]{1,64}, ts is decimal Unix seconds, nonce is unpadded base64url of 32 fresh random bytes, and mac is unpadded base64url of the 32-byte HMAC-SHA256 over the canonical bytes. The canonical bytes start with Cratis.AuthProxy.ForwardedIdentity.v1\n followed by frames F(s) (the UTF-8 byte count, :, the string, a line feed) for the audience, key id, timestamp, nonce, method, escaped path, the covered header count, and each covered header's lowercase name and value in ordinal order. Covered headers are every x-ms-client-principal* and x-cratis-on-behalf* header, x-cratis-token-client-id, x-cratis-token-scope, x-cratis-tenant-id, tenant-id, x-cratis-microservice, service-id and the configured tenant header. Every covered header must have exactly one value. The method and the path (with percent-escape hex digits uppercased) are bound; the query and body are not.

A timestamp is accepted from 60 seconds in the past to 30 seconds in the future. The nonce is reserved only after the MAC and the identity bindings succeed, so an invalid attempt cannot poison it. Replay memory is per application process, like Arc on .NET: it is not shared across nodes or restarts. The application must be started (MyApp.Arc in the supervision tree); without it replays cannot be detected and every signed request is rejected.

This attests identity and bindings, not tenant existence, tenant membership, request-body integrity or authorization. Use TLS and restrict backend ingress; a shared-key verifier can also forge identities, so provision service-specific secrets.

Summary

Functions

Authenticates a connection. Returns {:authenticated, principal}, :anonymous (no credential), or {:failed, reason} with a fixed reason code.

Whether any header carries a forwarded-identity credential.

Functions

authenticate(conn, app, config, now \\ nil)

@spec authenticate(Plug.Conn.t(), module(), keyword(), integer() | nil) ::
  {:authenticated, Arc.Principal.t()} | :anonymous | {:failed, String.t()}

Authenticates a connection. Returns {:authenticated, principal}, :anonymous (no credential), or {:failed, reason} with a fixed reason code.

credential?(names)

@spec credential?([String.t()]) :: boolean()

Whether any header carries a forwarded-identity credential.

header()