Agent-native authentication
fremforge accepts three OAuth2 / OIDC authentication patterns for non-human callers (agents, scripts, AI assistants, CI runners, vendor integrations). All three sit on the same per-tenant scope-allowlist and audit-chain machinery as user sign-in — no shadow path, no separate trust store.
The shared pre-condition for any flow that involves the customer’s IdP: register your IdP as an OIDC auth source at Org admin → SSO → Auth sources, verify the domain, and configure per-app scope allowlists at Org admin → SSO → Edit auth source → Exchangeable scopes. The same registration powers human sign-in federation, so a customer using OIDC SSO already has the foundation in place.
The three flows at a glance
| Flow | Spec | Customer IdP role | Best for |
|---|---|---|---|
| 1. Direct JWT bearer | — (informal, “Bearer JWT”) | Issues the JWT; fremforge verifies | Workload identities (Entra workload identity, Okta service account, Google service account) that already mint tenant-bound JWTs |
| 2. Token exchange (RFC 8693) | RFC 8693 | Issues subject_token (+ optional actor_token for OBO) | CI workflows, scheduled agents, on-behalf-of (OBO) flows where audit must record both the agent and the user |
3. client_credentials (RFC 6749 §4.4) | RFC 6749 | None — fremforge issues both client_id + client_secret | Apps that don’t have an IdP at all, or where the customer prefers fremforge-as-IdP |
All three flows produce a short-lived (≤ 1 hour) ffp_ access token bound to one tenant and one scope set. RBAC, rate-limiting, audit emission, and tenant-isolation gates are identical to a human-minted PAT.
Flow 1 — Direct customer-IdP JWT as bearer
The agent presents its IdP-issued JWT directly as Authorization: Bearer <jwt> on any /api/v1/* endpoint. fremforge verifies the JWT against your registered auth source, applies scope intersection from the source’s app-grant, and returns the response — no extra round-trip, no stored credential.
Endpoint: any frem.sh/api/v1/* route.
Setup (one-time, per auth source):
- Org admin → SSO → register an OIDC source for your IdP (or reuse an existing one used for human sign-in).
- Configure per-app scope allowlists at Edit auth source → App grants. The allowlist binds an IdP application (matched on the JWT’s
azporaud[0]claim) to a maximum scope set. - Org admin → Agent authentication → enable Allow direct JWT bearer on the source.
- (Multi-tenant IdPs only) Enter a claim assertion that binds the source to your tenant —
{"tid": "<your-Entra-tenant-guid>"}for Entra/common/,{"hd": "yourdomain.com"}for Google Workspace, etc. The admin form refuses to enable direct bearer on a multi-tenant issuer URL without claim assertions.
Token requirements:
issmatches the registered source URL (normalised case-insensitive, trailing-slash-tolerant).audmatches either the source’s configured Audience override OR the canonical per-tenant audiencehttps://frem.sh/<your-tenant-slug>.expin the future,iatnot unreasonably in the past (jose enforces 30-second clock skew).- All
claim_assertionsconfigured on the source match the verified payload. - Signing key resolves via OIDC discovery + JWKS (cached 10 minutes, customer-controllable through standard IdP key-rotation).
Example (Entra workload identity):
# 1. Acquire a JWT from your IdP (managed identity, federated identity,
# or workload-identity SDK) with aud=https://frem.sh/<your-slug>
ACCESS_TOKEN=$(az account get-access-token \
--resource https://frem.sh/acme \
--query accessToken -o tsv)
# 2. Call any fremforge API directly
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://frem.sh/api/v1/orgs/acme/reposSecurity properties:
- The JWT is the credential. No PAT row created in fremforge’s DB. Revocation = let
expelapse, OR remove the IdP source / disable direct-bearer (next request is rejected). - Cross-tenant binding is impossible by construction: a per-tenant audience binds the JWT to one slug, and the auth_sources table enforces a database-level UNIQUE constraint preventing two tenants from registering the same issuer for direct bearer.
- Scope-cap from the source’s per-app grant. Money-moving scopes (
billing:write,sso:write,policy:write,orgs:write) require explicit per-app allowlist entries — they are NEVER granted by a NULL allowlist.
Flow 2 — RFC 8693 token exchange (with OBO)
The agent presents an IdP-issued JWT as the subject_token and exchanges it for a short-lived fremforge PAT (ffp_). The actor_token parameter enables on-behalf-of delegation — the agent acts for a named user, and the audit chain records both principals.
Endpoint: POST frem.sh/api/v1/auth/token-exchange.
Example (basic exchange):
curl -X POST https://frem.sh/api/v1/auth/token-exchange \
-H "Content-Type: application/json" \
-d '{
"subject_token": "<customer-idp-jwt>",
"tenant_slug": "acme",
"scopes": ["orgs:read", "findings:write"],
"name": "ci-build-bot"
}'Example (OBO — agent acts on behalf of Alice):
curl -X POST https://frem.sh/api/v1/auth/token-exchange \
-H "Content-Type: application/json" \
-d '{
"subject_token": "<alice-idp-jwt>",
"actor_token": "<agent-idp-jwt>",
"tenant_slug": "acme",
"scopes": ["repos:read", "issues:write"]
}'The minted PAT’s audit events record actor=oidc:agent:<agent-sub> and on_behalf_of=<alice-sub>. Compliance queries like “every action my Anthropic agent took on behalf of Alice last quarter” run against the indexed on_behalf_of column.
Difference from Flow 1: token exchange creates a stored PAT (so it can carry an explicit scope subset and a custom name). Flow 1 is ephemeral. Pick exchange when the agent will do many calls over a single workflow run; pick direct bearer when each call is independent.
Flow 3 — client_credentials (fremforge as IdP)
For apps without an IdP, or where the customer prefers a single credential set managed inside fremforge. Tenant admin mints a (client_id, client_secret) pair, the app authenticates with Authorization: Basic (or POST form), receives a short-lived ffp_ PAT.
Setup:
- Org admin → Agent authentication → Create new client.
- Pick a name, scope-cap, optional audience allowlist.
- Copy the
client_secretimmediately — it is shown once and never displayed again.
Endpoint: POST frem.sh/api/v1/oauth/token.
Example:
curl -X POST https://frem.sh/api/v1/oauth/token \
-u "ffc_<client-id>:ffs_<client-secret>" \
-d "grant_type=client_credentials" \
-d "scope=findings:write runners:read"Response:
{
"access_token": "ffp_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "findings:write runners:read",
"audience": "https://frem.sh/acme"
}Rotation: at any time, hit the Rotate button in the admin UI. The current secret remains valid alongside the new one for 24 hours so you can roll your config without downtime. Past the 24-hour window, only the new secret authenticates.
Revocation: clicking Revoke invalidates the client AND cascade-revokes every ffp_ token it ever minted in a single transaction — fail-safe even if the operator pod is killed mid-revocation.
What the three flows share
| Property | How it’s enforced |
|---|---|
| Tenant isolation | Every minted token has tenant_id set; requireTenantBinding rejects 404 on cross-tenant calls. Database-level UNIQUE prevents one issuer from being claimed by two tenants for direct bearer. |
| Scope ceiling | All three intersect against EXCHANGEABLE_SCOPES (operator-controlled) AND the customer’s per-IdP / per-client allowlist. EXPLICIT_OPT_IN_SCOPES (billing:write, sso:write, policy:write, orgs:write) require explicit allowlist entries — never granted by default. |
| TTL | All minted tokens cap at 1 hour. Customers wanting longer-lived credentials use the admin-UI minting path (Personal Access Tokens). |
| Audit chain | Every mint emits an audit_event with actor, action, scopes, on_behalf_of (for OBO). The chain hash covers fields_json including on_behalf_of. |
| Rate limiting | Bunny edge limits per IP. /auth/token-exchange and /oauth/token share a 30/min cap to bound the JWT-verify CPU cost from bogus inputs. |
| JWKS reachability | All outbound JWKS + discovery fetches go through outbound-proxy-strict — SSRF + private-IP + cloud-metadata blocked at the network hop. JWKS cached 10 min. |
| EU residency | The pipeline never reaches a US-hosted Sigstore / Auth0 / Okta endpoint — verification is entirely inside fremforge’s T Cloud eu-de footprint. Customer-IdP egress goes through the SSRF-proxied fetch only. |
Threat-model summary
| Threat | Mitigation |
|---|---|
Customer registers a multi-tenant Entra /common/ URL without tid claim_assertion | Admin form refuses; helper isMultiTenantIssuerUrl() flags Entra (v1 + v2), Google Workspace, Microsoft Gov / China clouds |
Two tenants share an IdP iss URL | Partial UNIQUE index on (LOWER(RTRIM(iss,'/'))) WHERE direct-bearer enabled — second registration is rejected at the DB layer |
| Customer-IdP JWT replayed against the wrong tenant | Per-tenant audience https://frem.sh/<slug> — a token minted for aud=https://frem.sh/acme literally cannot satisfy tenant initech’s audience check |
Leaked client_secret | Tenant admin runs Revoke → cascade transaction kills the client AND every PAT it minted; rotation supports a 24h dual-secret window for zero-downtime swap |
| Spoofed claims in OBO actor_token | jose verifies signature + iss + aud against the same source as subject; sub immutable, email/name never trusted |
| JWKS-cache stale after IdP key rotation | 10-min hard TTL — operationally bounded |
| Network-internal hostname in IdP discovery doc (DNS rebind / SSRF) | outbound-proxy-strict allowlist blocks the fetch; code-level https:// guard layer adds defence-in-depth |
Quick reference
| Path | Method | Auth | TTL | Body |
|---|---|---|---|---|
/api/v1/auth/token-exchange | POST | The JWT IS the auth | 1h | subject_token (+ optional actor_token), tenant_slug, scopes, name |
/api/v1/oauth/token | POST | Basic OR form-body | 1h | grant_type=client_credentials, client_id, client_secret, scope, audience |
/api/v1/* (any) | any | Authorization: Bearer <jwt or ffp_*> | per exp / per PAT row | — |
Related
- SSO setup (OIDC + SAML) — register your IdP for both human sign-in and agent auth
- Workflow OIDC for cloud auth — the outbound direction (runner pod → AWS/GCP/Azure trust policies)
- Audit log + chain — verify what every agent did, including OBO
on_behalf_offiltering - API tokens (PATs) — human-minted long-lived tokens for the same
/api/v1/*surface; mint at User settings → API tokens, see Authentication policy for max-lifetime + rotation policy