OAuth2 / OIDC Provider — User & Application Guide
Keystone can act as a standards-compliant OAuth2 Authorization Server / OpenID Connect Provider (OP). This means:
- Human users can log in through a browser (Authorization Code + PKCE, or the Device Authorization Grant for CLIs/headless machines) and get a short-lived, self-contained JWT instead of a Fernet token.
- Automated workloads (CI/CD pipelines, Kubernetes controllers, service
accounts) can authenticate with
client_credentialsand call OpenStack APIs directly with the resulting JWT — no Fernet exchange needed. - Third-party applications (Grafana, Harbor, internal portals) can use Keystone as a normal OIDC identity provider (“Login with OpenStack”).
See ADR 0026 for the full design. This page covers the flows you actually call. If you’re operating/deploying the provider rather than consuming it, see the administrator guide.
All endpoints below are under /v4/oauth2/{domain_id}/... — the OP is
per-domain, so domain_id is always part of the path, and each domain has its
own issuer and signing keys.
Discovery
GET /v4/oauth2/{domain_id}/.well-known/openid-configuration
GET /v4/oauth2/{domain_id}/jwks
Both are unauthenticated. Point any standard OIDC library at the discovery
document and it will find authorization_endpoint, token_endpoint,
jwks_uri, revocation_endpoint, introspection_endpoint,
userinfo_endpoint, supported grant types, and scopes.
Scopes
openid,profile,email— standard OIDC identity scopes.openstack:api— a distinct, explicit scope. Only when this is requested and granted does the returnedaccess_tokencarry OpenStack authorization data (openstack_context: scope + effective roles) and anaudthat OpenStack services will accept (openstack-apis). Without it, you get a minimal identity token good only for calling Keystone’s own/userinfo— not usable against Nova/Neutron/etc. Not yet supported on theauthorization_codeanddevice_codegrants: requesting it there is rejected withinvalid_scope. Useclient_credentialsor token exchange to obtain an OpenStack-capable token.- Omitting
scopeentirely defaults to the client’s fullallowed_scopes— exceptopenstack:apiis never implied by omission; you must request it explicitly every time.
Machine-to-machine: client_credentials
For CI/CD, Kubernetes operators, Terraform controllers, and any workload holding a registered client secret.
POST /v4/oauth2/{domain_id}/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=<id>&client_secret=<secret>&scope=openstack:api
Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "openstack:api"
}
Use access_token directly as Authorization: Bearer <token> against any
OpenStack service running the native JWT middleware. No id_token is issued for
this grant.
Human login: Authorization Code + PKCE
For browser-based apps and CLIs that can open a browser.
-
Redirect the user to:
GET /v4/oauth2/{domain_id}/authorize ?response_type=code &client_id=<id> &redirect_uri=<your callback> &scope=openid profile openstack:api &state=<random> &code_challenge=<S256 PKCE challenge> &code_challenge_method=S256PKCE (
S256only) is mandatory for public clients. Keystone serves its own login and consent pages. -
On success, your
redirect_urireceives?code=...&state=.... Exchange the code:POST /v4/oauth2/{domain_id}/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=<code>&redirect_uri=<same as above> &code_verifier=<PKCE verifier>&client_id=<id>[&client_secret=<secret>]Response includes
access_token,id_token,expires_in, and (if the client is registered forrefresh_token) arefresh_token. -
Refresh when the access token expires:
grant_type=refresh_token&refresh_token=<token>&client_id=<id>Refresh tokens rotate on every use (a new one is returned each time; the old one becomes invalid). Do not reuse an old refresh token — presenting an already-used one is treated as a possible theft and revokes the entire token family, forcing a fresh login.
A refresh token family also has an absolute lifetime (default 90 days from the original login,
[oauth2] refresh_token_absolute_lifetime_days). Rotating never extends it: once reached, the refresh fails withinvalid_grantand the user must log in again. On every refresh the server re-checks that the user still exists, is enabled and that the domain is enabled; otherwise the whole family is revoked andinvalid_grantis returned. Theexpires_inof the response is the plain access-token lifetime. Once roles are embedded in access tokens, a refreshed token carries the roles resolved at rotation time.
CLI / headless login: Device Authorization Grant (RFC 8628)
For openstack/osc CLI and other headless clients, the same flow every major
cloud CLI (aws sso, gcloud, az) uses.
-
Start the flow:
POST /v4/oauth2/{domain_id}/device_authorization Content-Type: application/x-www-form-urlencoded client_id=<id>&scope=openid profile openstack:apiResponse:
{ "device_code": "...", "user_code": "WDJB-MJHT", "verification_uri": "https://keystone.example.com/v4/oauth2/<domain_id>/device", "verification_uri_complete": "https://keystone.example.com/v4/oauth2/<domain_id>/device?user_code=WDJB-MJHT", "expires_in": 600, "interval": 5 } -
Show the user
verification_uri_complete(orverification_uri+user_code) and have them approve it in a browser. -
Poll for the token:
grant_type=urn:ietf:params:oauth:grant-type:device_code &device_code=<device_code>&client_id=<id>Poll no faster than
intervalseconds — polling too fast returnsslow_downper RFC 8628 §3.5, which means “back off further,” not a hard failure.user_codeuses an unambiguous character set ([A-Z0-9]minusO/0/I/l/1) so it’s easy to type by hand.
Managing connected applications
When you approve an application and tick “Remember my decision for this
application”, it is listed as a connected application and the consent page is
skipped next time it asks for the same or fewer scopes. Asking for an
additional scope shows the page again, as does prompt=consent.
List the applications you approved:
$ curl -H "X-Auth-Token: $TOKEN" \
$KEYSTONE/v4/users/$USER_ID/oauth2/consents
{"consents": [{"client_id": "...", "client_name": "My App",
"scopes": ["openid", "profile"], "authorization_target": null,
"granted_at": 1790000000, "updated_at": 1790000000}]}
Withdraw one:
$ curl -X DELETE -H "X-Auth-Token: $TOKEN" \
$KEYSTONE/v4/users/$USER_ID/oauth2/consents/$CLIENT_ID
This forgets the approval and revokes the application’s refresh tokens for
your account, so it cannot renew its access; access tokens it already holds
work until they expire. You can manage your own consents with an ordinary
token, but not with an application credential or a trust token; an
administrator can manage anyone’s. The endpoint answers 404 when there is
neither a remembered consent nor a live refresh token for that application.
Token types you’ll see
id_token— identity only,audis yourclient_id. Never carries roles or OpenStack scope; only your client’s configuredclaims_templateoutput is added.access_token(openstack:apigranted on a supported grant, or anyclient_credentialsgrant) — carriesopenstack_context(scope + effective roles at issuance time) andaud: "openstack-apis", valid against every OpenStack service that trusts the token’s issuer (the token’s domain). On the token-exchange grant you can narrow it with the RFC 8693audienceparameter (repeatable, one OpenStack service type per value, e.g.audience=compute), which yieldsaud: ["openstack-apis:compute", ...]accepted only by those service types. An unknown service type is rejected withinvalid_target. This is what OpenStack services accept.access_token(openstack:apinot granted) — minimal,audis your ownclient_id, not usable against any OpenStack service. Good only for/userinfo(and for revocation).
Access and ID tokens are short-lived (15 minutes by default) and stateless
bearer tokens. You can end a session early with the revocation endpoint below
(refresh tokens always; access tokens individually); otherwise wait out exp
(or an operator triggers emergency signing-key rotation, which is out of your
hands as a client). Treat them like any other bearer credential: don’t log them,
don’t put them in URLs.
UserInfo endpoint
GET or POST /v4/oauth2/{domain_id}/userinfo (OIDC Core §5.3), advertised as
userinfo_endpoint in the discovery document. Authenticate with the OIDC
access_token as an RFC 6750 bearer credential:
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://keystone.example.com/v4/oauth2/$DOMAIN_ID/userinfo
The token must carry the openid scope, be unexpired and not revoked, and its
user and client must still exist and be enabled. The response is plain JSON
(userinfo_signing_alg_values_supported is ["none"]) with
Cache-Control: no-store:
sub— always.name,preferred_username,updated_at— with theprofilescope.email,email_verified— with theemailscope, and only when the user has an email address. Keystone does not verify addresses, soemail_verifiedisfalseunless the user record explicitly says otherwise.- Your client’s
claims_templateoutput, identical to theid_token.
Failures follow RFC 6750 §3: 401 with WWW-Authenticate: Bearer (no token) or
Bearer error="invalid_token" (bad, expired, revoked or foreign token, disabled
user or client), and 403 with error="insufficient_scope" when the token
lacks openid. Access tokens issued with openstack:api (or by
client_credentials) are not accepted here. The endpoint is rate limited per
source IP.
Revoking tokens (RFC 7009)
POST /v4/oauth2/{domain_id}/revoke
Content-Type: application/x-www-form-urlencoded
token=<refresh or access token>&token_type_hint=refresh_token
Authenticate the client exactly as for /token (HTTP Basic, or client_id /
client_secret in the body). token_type_hint (refresh_token or
access_token) is optional and only affects lookup order.
- Refresh token — the whole refresh family (the token and everything rotated
from it) is revoked; any later
refresh_tokengrant fails withinvalid_grant. - Access token — its
jtiis added to the domain’s JTI revocation list (/jwks/revocation) until the token’s ownexp. Services that enforce the list (see the admin guide) reject it from then on, within the list’s cache TTL (60s). If the token carries asidclaim (the id of the refresh family that minted it), that refresh family is revoked too, ending the session. ID tokens carry nojtiand are ignored.
The endpoint always answers 200 with an empty body for an authenticated,
well-formed request — including unknown, expired, already-revoked tokens and
tokens that belong to another client — so it never reveals whether a token
exists. A token is only revoked if it belongs to the authenticated client. Bad
client credentials return 401 invalid_client, a missing token returns
400 invalid_request, and the endpoint is rate limited like /token.
Limitation: revocation is one-directional. Revoking an access token also ends
its refresh family (via sid), but revoking a refresh token does not revoke
access tokens already issued from it; they expire on their own (or revoke them
individually). Access tokens from client_credentials / token-exchange have no
refresh family and no sid.
Token introspection (RFC 7662)
POST /v4/oauth2/{domain_id}/introspect
Content-Type: application/x-www-form-urlencoded
token=<refresh or access token>&token_type_hint=access_token
Advertised as introspection_endpoint in the discovery document. Use it when a
service cannot accept the stateless revocation window of access tokens: unlike
offline verification it sees the domain’s JTI revocation list and the
refresh-token store as they are now.
Only an authenticated confidential client of the same domain may call it.
Authenticate exactly as for /token (HTTP Basic, or client_id /
client_secret in the body); public clients get 401 invalid_client.
token_type_hint (refresh_token or access_token) is optional and only
affects lookup order.
The answer is always 200 with a JSON body and Cache-Control: no-store. A
token that is unknown, malformed, expired, revoked, already used (refresh) or
issued for another domain yields exactly {"active": false}; the response never
says why. For an active token active is true and the body carries:
scope,client_id,sub,exp,iat,token_use. For a refresh tokenexpis bounded by the refresh family’s lifetime cap.- Access tokens additionally:
token_type(Bearer),nbf,aud,iss,jti. OpenStack access tokens (openstack:api,client_credentials) also includeopenstack_contextanddelegation_context, the scope and effective roles the token will act with.
A missing token returns 400 invalid_request, bad client credentials return
401 invalid_client, and the endpoint is rate limited per source IP and per
client.
Errors
Token endpoint errors follow RFC 6749 §5.2:
{ "error": "invalid_grant", "error_description": "..." }
Common ones: invalid_client (bad client_id/secret), invalid_grant
(expired/used code, revoked refresh token, wrong PKCE verifier), invalid_scope
(requested a scope outside allowed_scopes — the server never silently narrows
a request), slow_down / authorization_pending (device flow polling).