Configuring OpenID Connect flows
This page shows how to set up iya-sts, as an OpenID Provider, for each OpenID Connect flow and each related specification it supports. For each one it covers the relying party (RP) to register, the settings to change, and the request the RP then sends. Each recipe is given twice: once as steps in the admin console and once as Management API calls.
The values come from this repository’s test jobs in tests/vendored/, and
each recipe names the job it is taken from.
Configuring OAuth 2.0 grants
explains the shared mechanics: the API token, the api shell helper used
below, the console’s Set / Add to / Remove from forms, and what
development and product mode change. Read that section first. The OAuth 2.0
grants themselves (code, refresh, client credentials, device, CIBA, token
exchange and the assertion grants) are on that page too.
- Configuring OpenID Connect flows
- Two things to know first
- The rules that hold for every flow
- Authorization Code flow
- Implicit flow
- Hybrid flow
- Response modes: query, fragment, form_post, JARM
- UserInfo and the claims parameter
- Pushed Authorization Requests
- Request objects (JAR)
- CIBA
- Native SSO
- RP-Initiated Logout
- Front-Channel Logout
- Back-Channel Logout
- Session Management
- Step-up authentication (acr_values, max_age)
- Subject types: public, pairwise, ephemeral
- Dynamic Client Registration
- Related
Two things to know first
A client registered in the console or through /admin-api may use every
response type the server advertises. oauthResponseType and
oauthGrantType record what the client is for, but they are not enforced. A
client that registers itself with POST /oauth2/register (RFC 7591) is
held to the response_types and grant_types it registered, and is refused
anything else with unauthorized_client. To restrict a client, register it
dynamically.
Some client metadata exists only as registration members. That includes
the JARM algorithms, the signing and encryption algorithms for UserInfo and
the ID Token, and default_acr_values / default_max_age. No console or API
attribute holds them, so a client that needs them must use dynamic
registration.
Consent is on by default. Unless the person has already approved the
client, the flow stops at /oauth2/consent. For a first-party client, grant
consent for everybody in advance. In the console that is Consent
(/admin/consent) → Consent a scope for everybody → pick the
Application, type the Scope, then Consent it for everybody. The
API call is:
api consent/grant-global-consent '{"client":"parmon-a","scope":"openid"}'
The rules that hold for every flow
Every authorization request, in every mode, must follow these rules:
redirect_uriis required, and must match a registered URI exactly when the client has any.- A response type that returns an
id_tokenneeds theopenidscope and anonce. - The implicit flow refuses an
httpredirect URI that is not a loopback address.
Product mode adds more: it refuses an unregistered client, it requires PKCE
S256 from a public client, and it refuses any response type that contains
token. See
OAuth 2.0 and OpenID Connect → development and product mode.
Authorization Code flow
response_type=code. The RP exchanges the code at /oauth2/token. PKCE
accepts S256 and, in development only, plain.
Taken from sts_oauth2_monitor.js and sts_oidc_core.js.
Console:
- Go to Directory → Applications → New application ›. Set Identifier to
parmon-a. - Tick OAuth 2.0 and OpenID Connect.
- Fill in the fields that appear:
oauthClientId:parmon-a.- Where responses go back to:
https://rp.parmon.example.test/cb. - The client secret: click Generate Secret.
- Click Create the application.
- On the application’s page, Tick
client_secret_basicunderoauthTokenEndpointAuthMethod, and nothing else. - Grant consent for
openidon Consent, as described above.
API:
api applications/create '{"identifier":"parmon-a","name":"parmon-a",
"protocols":["oauth2","oidc"],
"fields":{"oauthClientId":"parmon-a",
"oauthRedirectUri":["https://rp.parmon.example.test/cb"],
"oauthClientSecret":"'"$SECRET"'",
"oauthTokenEndpointAuthMethod":"client_secret_basic"}}'
api consent/grant-global-consent '{"client":"parmon-a","scope":"openid"}'
Requests:
GET /oauth2/authorize?response_type=code&client_id=parmon-a
&redirect_uri=https://rp.parmon.example.test/cb&scope=openid
&state=<random>&nonce=<random>
&code_challenge=<S256 challenge>&code_challenge_method=S256
POST /oauth2/token (HTTP Basic: parmon-a / secret)
grant_type=authorization_code&code=<code>
&redirect_uri=https://rp.parmon.example.test/cb&code_verifier=<verifier>
The authorization request can also be sent as a form POST. For a public
client, see the public-client recipe.
Implicit flow
response_type=id_token or id_token token. The response is always
returned in the fragment, and an explicit response_mode=query is refused.
id_tokenworks in every mode and needs no configuration beyond a registered redirect URI. The jobsts_oidc_core.jssendsresponse_type=id_token&scope=openid email&nonce=<random>, and withid_tokenalone the scope’s claims go into the ID Token.id_token token, and any other type containingtoken, is refused in RFC 9700 mode and in product mode. In those modes it is also removed fromresponse_types_supported. To use it you need development mode withoauth2.rfc9700off.
A client that registers itself must list implicit in grant_types
whenever one of its response_types contains token.
Hybrid flow
code id_token, code token and code id_token token. The ID Token carries
c_hash and at_hash, plus s_hash when a state was sent. The response
goes in the fragment. code id_token and code id_token token require a
nonce.
code id_tokenworks in every mode. The jobsts_oidc_core.jsruns it acrossid_token_signed_response_algvaluesRS256,RS384,PS512,ES512,EdDSAandML-DSA-44. That algorithm is a registration member, so those clients are registered dynamically.code tokenandcode id_token tokenare refused in RFC 9700 mode and product mode.
The OpenID conformance job (sts_oidcc_conformance.js) registers its hybrid
client like this, after setting oauth2.openRegistration to true in the
realm:
POST /realm/<id>/oauth2/register
{"redirect_uris":["https://rp.example.com/callback"],
"token_endpoint_auth_method":"client_secret_basic",
"grant_types":["authorization_code","refresh_token","implicit"],
"response_types":["code id_token token"],
"scope":"openid profile email address phone offline_access"}
Response modes: query, fragment, form_post, JARM
response_mode |
Configuration | Notes |
|---|---|---|
query |
none | The default for code and none. |
fragment |
none | The default for everything else. |
form_post |
none | Answers with a self-submitting form (/oauth2/autopost.js) that also has a real submit button. Refused to a private-use (native) redirect URI. |
jwt, query.jwt, fragment.jwt, form_post.jwt (JARM) |
optional registration members authorization_signed_response_alg (default RS256), authorization_encrypted_response_alg and _enc |
Works in every mode. query.jwt carrying a token is refused unless the response is encrypted. oauth2.jarmResponseLifetimeS defaults to 600. |
Taken from sts_form_post.js, which sets oauth2.openRegistration and
oauth2.rfc9700 to true in its realm and registers:
POST /realm/<id>/oauth2/register
{"redirect_uris":["https://rp.formpost.example/cb"],"grant_types":["authorization_code"],
"response_types":["code"],"token_endpoint_auth_method":"client_secret_basic"}
UserInfo and the claims parameter
GET or POST /oauth2/userinfo takes the access token from an
Authorization: Bearer or DPoP header, or as access_token in a form body.
The token must carry the openid scope, or the answer is 403
insufficient_scope.
- Which claims are released is set per scope on Protocols → OAuth2 /
OIDC → UserInfo claims (
/admin/userinfo-claims) and Claims (/admin/claims). Product mode takes every value from the person’s directory entry, so fill the entry in:api users/create '{"username":"alice","invent":false,"credential":"password", "password":"<password>","attributes":{"cn":"Alice Example","givenName":"Alice", "sn":"Example","mail":"alice@example.com","telephoneNumber":"+1 555 0100", "street":"1 Main St","l":"Springfield","postalCode":"12345","c":"US"}}'(These attributes are the ones
sts_oidc_core.jscreates.) - Signed or encrypted UserInfo is chosen by the registration members
userinfo_signed_response_alganduserinfo_encrypted_response_alg. The job for this issts_userinfo_protected.js. claims=(OIDC Core 5.5) needs no configuration.essential,valueandvaluesare carried through but not enforced, except that an essentialacris enforced, and so is anything insideverified_claims.oauth2.maxRequestedClaims(default 64) caps the size of the request.
Pushed Authorization Requests
The RP POSTs its authorization request to /oauth2/par, authenticating as it
would at the token endpoint. The answer is a request_uri, which the RP then
uses at /oauth2/authorize. PAR is on by default.
Settings (on /admin/oauth2):
| Setting | Default | What it does |
|---|---|---|
oauth2.pushedAuthorizationRequests |
true |
Serves the endpoint; false makes it answer 404. |
oauth2.requirePushedAuthorizationRequests |
false |
Refuses a plain authorization request from every client. |
oauth2.parRequestUriLifetimeS |
60 |
Sets how long a request_uri is valid. |
To require PAR from one client only:
api applications/set '{"application":"parmon-a",
"attribute":"oauthRequirePushedAuthorizationRequests","value":"TRUE"}'
Requests (from sts_oauth2_monitor.js, with the parmon-a client above):
POST /oauth2/par (HTTP Basic)
response_type=code&redirect_uri=https://rp.parmon.example.test/cb&scope=openid
&state=…&nonce=…&code_challenge=…&code_challenge_method=S256
→ 201 {"request_uri":"urn:ietf:params:oauth:request_uri:…","expires_in":60}
GET /oauth2/authorize?client_id=parmon-a&request_uri=urn:ietf:params:oauth:request_uri:…
Development mode only logs a failed client authentication at PAR. RFC 9700, OAuth 2.1 and product mode refuse it.
Request objects (JAR)
request=<JWT> or request_uri=<URL>. A request_uri is fetched only if it
is registered on the client, so add each one:
api applications/add '{"application":"parmon-a","attribute":"oauthRequestUri",
"value":"https://rp.parmon.example.test/request.jwt"}'
api applications/set '{"application":"parmon-a","attribute":"oauthJwksUri",
"value":"https://rp.parmon.example.test/jwks.json"}'
These attributes also apply:
oauthRequestObjectSigningAlg, andoauthRequestObjectEncryptionAlg/…Encfor an encrypted object.oauthRequireSignedRequestObjectset toTRUE, which requires a signed object from this client.- The client’s keys, in
oauthJwksoroauthJwksUri.
The realm settings oauth2.requireSignedRequestObject,
oauth2.requireRequestObjectType and
oauth2.requireRequestObjectIssuerAudience (all false by default) apply the
same checks to every client. Development mode accepts an http request URI
and an alg: none object. Product mode refuses both. The job that covers this
is tests/rfc9101_request_objects.js, which signs with ES256 and
typ: oauth-authz-req+jwt.
CIBA
The whole recipe is on Configuring OAuth 2.0 grants → CIBA.
Here is what differs between the three delivery modes (from sts_ciba.js):
| Delivery mode | Client attributes |
|---|---|
poll |
oauthBackchannelTokenDeliveryMode: poll |
ping |
…DeliveryMode: ping, and oauthBackchannelClientNotificationEndpoint: https://client.example.com/ping |
push |
…DeliveryMode: push, and the notification endpoint (refused under any FAPI profile) |
These attributes can be set only on a client declared for OpenID Connect.
Setting oauthBackchannelAuthenticationRequestSigningAlg requires the client
to send a signed request. Ping and push deliveries are listed on
Outbound deliveries (/admin/deliveries).
Native SSO
Native SSO lets apps from one vendor on one device share a sign-in. The first
app gets a device_secret, and the others exchange it together with the ID
Token. There is no setting to turn it on or off. A client takes part when it
has the flag and a group.
Taken from sts_native_sso.js.
Console: create a public client (see the public-client recipe) declared for OpenID Connect. Then, on its page:
- Set
oauthNativeSsotoTRUE. - Set
oauthNativeSsoGrouptovendor.one.
API:
api applications/create '{"identifier":"nsso-first","name":"nsso-first",
"protocols":["oauth2","oidc"],
"fields":{"oauthClientId":"nsso-first","oauthTokenEndpointAuthMethod":"none",
"oauthConfidential":"FALSE",
"oauthRedirectUri":["https://nsso-first.example.test/cb"],
"oauthResponseType":["code"],
"oauthGrantType":["authorization_code","refresh_token",
"urn:ietf:params:oauth:grant-type:token-exchange"],
"oauthScope":["openid","device_sso"],
"oauthNativeSso":"TRUE","oauthNativeSsoGroup":"vendor.one"}}'
Create the second app, nsso-second, the same way with the same group. Only
apps in the same group share sessions.
Requests:
1. First app: /oauth2/authorize?response_type=code&scope=openid device_sso&…(PKCE, nonce)
→ token response includes device_secret; ID Token carries ds_hash and sid
2. Second app: POST /oauth2/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&client_id=nsso-second
&subject_token=<id_token>&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&actor_token=<device_secret>&actor_token_type=urn:openid:params:token-type:device-secret
&audience=<issuer>&scope=openid
If a client without the flag asks for device_sso, it gets invalid_scope.
An exchange across groups gets unauthorized_client. A dynamic registration
may set native_sso only inside a trusted software statement.
RP-Initiated Logout
The RP sends the person to GET or POST /oauth2/logout with
post_logout_redirect_uri, id_token_hint or client_id, and state.
The return address must be registered. In the console, use Where a
sign-out goes on the new-application form, or Add to
oauthPostLogoutRedirectUri on an existing client. Through the API:
api applications/add '{"application":"parmon-a",
"attribute":"oauthPostLogoutRedirectUri","value":"https://rp.rplogout.example/signed-out"}'
Request (from sts_rp_initiated_logout.js):
GET /oauth2/logout?client_id=parmon-a
&post_logout_redirect_uri=https://rp.rplogout.example/signed-out&state=st-1
→ 302 https://rp.rplogout.example/signed-out?state=st-1
If the client has registered return URIs, the request must match one exactly,
in every mode. If it has registered none, development mode follows the
return anyway, and product and OAuth 2.1 mode never do. A request that names
no client is never redirected. If there is no id_token_hint whose sid
matches the current session, the person sees a confirmation page first. A
return that is refused still signs the person out.
Front-Channel Logout
When a session ends, the logout page loads each RP’s front-channel URI in a
hidden iframe. It then moves on to the return address after
oauth2.frontchannelLogoutWaitS seconds.
Settings: oauth2.frontchannelLogout (default true) and
oauth2.frontchannelLogoutWaitS (default 3, range 0–60).
Client (on its page, or through the API; the values are from
sts_frontchannel_logout.js):
api applications/set '{"application":"parmon-a","attribute":"oauthFrontchannelLogoutUri",
"value":"https://rp.parmon.example.test/fc"}'
api applications/set '{"application":"parmon-a",
"attribute":"oauthFrontchannelLogoutSessionRequired","value":"TRUE"}'
The URI must be on the origin of one of the client’s redirect URIs. A URI
on any other origin is refused with 400. With …SessionRequired set to
TRUE, the service appends iss and sid to the URI.
Back-Channel Logout
When a session ends or expires, the service POSTs a signed Logout Token
(logout_token=…) to each RP’s back-channel URI. Each delivery is stored,
retried and, if it keeps failing, dead-lettered.
Settings: oauth2.backchannelLogout (default true) and
oauth2.backchannelLogoutOnExpiry (default true). There are also tuning
settings such as …TokenTtlS (120), …Attempts (3) and …TimeoutMs (5000).
Client (from the in-process job tests/backchannel_logout.js):
api applications/create '{"identifier":"bcl-ok","name":"bcl-ok","protocols":["oauth2","oidc"],
"fields":{"oauthClientId":"bcl-ok","oauthClientSecret":"'"$SECRET"'",
"oauthRedirectUri":["https://rp.backchannel.example/cb"],
"oauthGrantType":["authorization_code"],
"oauthTokenEndpointAuthMethod":"client_secret_basic",
"oauthBackchannelLogoutUri":"https://rp.backchannel.example/bcl",
"oauthBackchannelLogoutSessionRequired":"TRUE"}}'
A public client cannot have an http back-channel URI, and product mode never
sends to http. Failed deliveries are listed on Sign-out
(/admin/logout) → Back-channel Logout Tokens, which has a State filter
and a Retry button on each dead row, and on Outbound deliveries
(/admin/deliveries). The API equivalents are
GET /admin-api/logout?deliveryState=dead and
POST /admin-api/logout/retry-backchannel {"delivery":"<id>"}.
Session Management
The OP iframe (/oauth2/check_session) and session_state on each
authentication response. It is off by default and needs only a setting;
no client attributes are involved.
api config/set '{"key":"oauth2.sessionManagement","value":true}'
Once it is on, check_session_iframe appears in discovery. Only the
redirect-URI origins registered in the realm may frame the iframe. Browsers
that block third-party cookies never send the cookie the iframe reads.
sts_session_management.js registers its RP with
redirect_uris: ["https://rp.sessmgmt.example/cb"].
Step-up authentication (acr_values, max_age)
A client asks for a stronger or more recent sign-in with acr_values and
max_age. A resource server can require one by naming it on its own
application. The levels are 0 < 1 < mfa, plus
urn:sts:acr:compliant-device. If the person cannot meet the requirement,
the answer is unmet_authentication_requirements in every mode.
Taken from sts_step_up.js.
Set the requirement on the resource, not on the client:
api applications/create '{"identifier":"su-api","name":"su-api","protocols":["oauth2"],
"fields":{"oauthAudience":["https://api.stepup.example.test/"],
"oauthStepUpAcrValues":"mfa","oauthStepUpMaxAge":"600"}}'
Request:
GET /oauth2/authorize?response_type=code&client_id=su-client&scope=openid
&redirect_uri=https://rp.stepup.example.test/cb&nonce=…&code_challenge=…&code_challenge_method=S256
&acr_values=mfa&max_age=600&resource=https://api.stepup.example.test/
For this service’s own resource servers, use the settings
oauth2.stepUpAcrValues (for example mfa) and oauth2.stepUpMaxAgeS
(-1 turns it off). The person needs a second factor enrolled to reach
mfa; see Authentication.
Subject types: public, pairwise, ephemeral
oauthSubjectType |
sub in the ID Token and UserInfo |
|---|---|
public (the default) |
urn:uuid:<entryUUID> |
pairwise |
a different value per sector (the host of oauthSectorIdentifierUri, or the one host all redirect URIs share) |
ephemeral |
a new random value per session and client |
api applications/set '{"application":"parmon-a","attribute":"oauthSubjectType","value":"pairwise"}'
A pairwise client whose redirect URIs span several hosts must also set
oauthSectorIdentifierUri, pointing to an https JSON array that lists every
redirect URI. Both attributes need the client to be declared for OpenID
Connect. The jobs sts_ephemeral_subjects.js and sts_oidc_core.js register
with subject_type instead.
Dynamic Client Registration
This is the alternative to the console. The RP registers itself at
POST /oauth2/register and manages its registration at
/oauth2/register/<client_id>, using the registration_access_token it was
given.
- Development mode: registration is always open.
- Product mode: it is closed (403) unless
oauth2.openRegistrationistrue, or the request carries a software statement from a trusted issuer (oauth2.softwareStatementOpensRegistration, on by default). An administrator issues software statements from the application page’s Software statements section (APIapplications/issue-software-statement).
api config/set '{"key":"oauth2.openRegistration","value":true}'
POST /oauth2/register
{"client_name":"Endpoint Test Client","redirect_uris":["https://rp.example.com/callback"],
"grant_types":["authorization_code","refresh_token"],
"response_types":["code"],"token_endpoint_auth_method":"client_secret_basic"}
→ 201 client_id, client_secret, registration_access_token, registration_client_uri
A registered client appears in the Applications list like any other, marked
as registered through RFC 7591. The application page’s Revoke the RFC 7591
registration button (API applications/revoke-registration) ends the
registration.
Related
- OAuth 2.0 and OpenID Connect: each feature in depth.
- Signing out and Sessions.
- Configuring OAuth 2.0 grants and Configuring SAML profiles.