Skip to content

Connect your identity provider

Connect an identity provider so people and workloads can call the admin API without a shared secret. Pistra verifies tokens. Your provider identifies the caller, and a Cedar policy authorizes its roles.

You will register one OAuth client at your provider, add an issuer to the top-level issuers list, bind it under admin.issuers, and map a group claim onto roles. Workloads need less than that. They already hold a token their own platform minted.

Have the local socket working first. Everything on this page depends on your provider being reachable, so it stops working on the day your provider is the problem. Turning the socket on afterwards is a config change, and somebody locked out of the admin API cannot make one.

admin:
local_socket: /run/pistra/admin.sock
policy_file: /etc/pistra/admin.cedar

policy_file becomes required the moment an issuer is configured. An admin API where every authenticated caller can mint keys and drop raft members is not a default worth offering, so the gateway refuses to start without one.

Register one client, with two redirect URIs

Section titled “Register one client, with two redirect URIs”

There are two ways a person signs in, and they redirect to different places. Register both on the same public client:

client redirect URI
the console, in a browser https://gw.example:8485/ui/callback
pistra login, on a terminal http://127.0.0.1:<any port>/callback

The console’s is fixed. It is the gateway’s own origin, wherever you serve the admin listener, with /ui/callback on the end. Add https://gw.example:8485/ui/ as the post-logout redirect while you are there.

The CLI’s is a loopback listener on an ephemeral port, as RFC 8252 prescribes for a program on somebody’s laptop. The authorization server must accept any port on 127.0.0.1. A provider that lets you pin one should be left unpinned, because another program on the machine can be sitting on a fixed port first. How you express “any port” differs by provider, and it is the step most likely to send you back to the provider’s console.

Everything else about the client is the same for both:

  • public client, no secret. A browser tab and a laptop binary can each keep zero secrets. PKCE replaces one. It binds the authorization code to the process that asked for it, so a code intercepted on the way back cannot be redeemed by anybody else.
  • PKCE required.
  • the device grant enabled, if anyone will ever sign in over SSH.
  • a mapper that puts group membership in a claim. Which claim and which scope carries it is your provider’s business. It has to exist, because roles come from it.

A Keycloak realm with the public client, the group mapper and the device grant is committed as ui/dev/idp/realm.json. Its redirect list covers the console only, because the repository’s browser tests sign in at it. Add the loopback URI yourself if you copy the client for CLI use.

Add this configuration:

issuers:
- name: corp
url: https://login.example.com
admin:
listen: 0.0.0.0:8485
policy_file: /etc/pistra/admin.cedar
local_socket: /run/pistra/admin.sock
issuers:
- issuer: corp
audience: pistra-admin
roles:
claim: groups
map:
"CN=pistra-admins,OU=Groups": [keyadmin]
"CN=platform-sre,OU=Groups": [viewer, clusteradmin]
login:
client_id: pistra-admin
scopes: [profile, groups]

There are two blocks, because they answer two questions. The entry under issuers says who signs and how this node reaches them. The entry under admin.issuers says what the admin API accepts from them. The same directory can also sign for the data plane, agents presenting their own tokens instead of a virtual key. That is a second binding of the same issuer, under auth.issuers, with an audience of its own. See Authenticate agents with your identity provider. The issuer is written once either way.

name is what the audit trail, the policy and the bindings call this issuer, and it is required because a subject is only unique within one. Two providers both calling somebody admin must not collapse into one principal.

url is matched against the token’s iss exactly, and is where discovery looks. Keys are fetched on the first token rather than at startup, so a provider that is unreachable when the config loads does not stop the gateway serving traffic.

audience is what the token must carry in aud, and it is required. Accepting a token minted for a different application is the confused deputy in its original form. For an ID token, which is the default and what works everywhere, the audience is the client id, so these two are normally the same string. Setting them differently without a reason produces a token the gateway then refuses. That is a confusing way to find out.

Make roles come from something the issuer assigns

Section titled “Make roles come from something the issuer assigns”

This is the one setting worth slowing down for.

Roles come from whatever claim roles.claim names, so a claim the caller can set is a claim the caller can use to choose their own roles. With no map it is worse. The claim’s value becomes the role name directly, so somebody who can edit preferred_username to keyadmin is a key administrator.

The gateway refuses to start on the claims it knows are usually self-asserted, email, preferred_username, name, nickname, upn, unique_name, phone_number, and names the alternative in the error. That is a list of known-soft claims rather than known-safe ones, so a provider-specific claim nobody anticipated is allowed. Keep to the rule it teaches. Read roles from what the issuer assigns: groups, roles, wids, sub, oid.

map keeps the directory’s naming out of the policy file, so renaming a group is not a policy change. When a map is present, a value it does not mention grants nothing. A map is a statement about which groups matter, and passing the rest through would let a group named keyadmin somewhere unrelated in the directory grant by accident.

Where your directory owns its mailboxes and forbids profile edits, email is as good as a group and roles.trust_claim: true says so deliberately. See the how-to for what that does and does not override.

Workloads: no client, no login block, no secret

Section titled “Workloads: no client, no login block, no secret”

A pipeline or a controller already holds a token its own platform minted. There is nothing to register and nothing to hand out:

issuers:
- name: cluster
url: https://kubernetes.default.svc.cluster.local
ca_file: /etc/pistra/kube-ca/ca.crt
token_file: /etc/pistra/kube-sa/token
admin:
issuers:
- issuer: cluster
audience: pistra-admin
roles:
claim: sub
map:
"system:serviceaccount:platform:ci": [keyadmin]

There is no login block, because a service account has no browser to open, and a binding without one is not advertised at /admin/v1/login, so neither the console nor the CLI offers it.

A Kubernetes API server needs ca_file and token_file specifically. Its TLS certificate is not in the system pool, and it publishes its signing keys to system:serviceaccounts rather than to anonymous callers. Reading them takes a service account token of the gateway’s own, which cannot be the token being verified, because the API server refuses one minted for another audience. token_file is re-read on each fetch, so a projected token that rotates keeps working.

On Kubernetes the chart writes both entries for you. Set admin.workloadIdentity.enabled and give it the map. The two file paths are neither optional nor obvious.

The subject carries identity, and it is more precise than a static credential can be. A GitHub Actions token names a repository and a branch, so a pull request from a fork is a different principal and gets nothing:

roles:
claim: sub
map:
"repo:acme/infra:ref:refs/heads/main": [keyadmin]
"spiffe://acme.example/ns/platform/sa/ci": [keyadmin]

Nothing in the policy file changes for any of this. Role::"keyadmin" is the same role whether a person or a pod is holding it.

The two populations do not share a provider, and each brings its own keys, its own audience and its own idea of what a subject looks like. A token is routed to one issuer by its iss claim and that issuer alone checks the signature, so a forged iss reaches keys that did not sign it, and finds nothing.

List the login flows the gateway advertises:

Terminal window
$ curl -s https://gw.example:8485/admin/v1/login | jq
{
"issuers": [
{ "name": "corp", "issuer": "https://login.example.com",
"client_id": "pistra-admin", "scopes": ["profile","groups"],
"token": "id" }
]
}

Nothing there is a secret. A public client’s id is public by design and the issuer URL is on the front of every token it mints. An issuer you expected and do not see has no login block.

Sign in and inspect the resolved principal and roles:

Terminal window
$ pistra login https://gw.example:8485
$ pistra admin -url https://gw.example:8485 /admin/v1/whoami

whoami reports the principal and the roles it resolved. A caller whose group you forgot to map authenticates fine and is refused everything, so check this after a rollout rather than waiting for the first complaint.

The console is at /ui/ on the same listener, and the listener’s root redirects there. It runs the same authorization-code flow in the browser, keeps the token in that tab’s sessionStorage, and sends it as a bearer on every request, no cookie, and no session on the server.

There are two different events, kept apart on purpose:

  • admin.auth, we do not know who you are. A bad signature, a wrong aud, an expired token, an iss naming no configured issuer.
  • admin.authz, we know exactly who you are, and you may not. See what a refusal looks like.

Filing them together hides the more urgent one. A burst of admin.authz records with no rule is a credential missing a role, not a policy catching somebody.

What changes without a restart, and what does not

Section titled “What changes without a restart, and what does not”

Issuers, the policy file and the credentials it decides over are read once at startup, so changing any of them needs a restart. Group membership is not. It comes from the token on every request, so moving somebody between groups in the directory takes effect with the gateway untouched.

That split is the design. The policy is static operator intent and belongs in the review that changes it. Who is in which role is your directory’s business and changes all day.