OAUTH 2.0 · LAB
Run a FAPI 2.0 payment flow, and gap-check your tenant today
Plan the lesson's Pay by bank flow end to end in FAPI mode with private_key_jwt and DPoP. Today, compare your tenant's discovery document with each FAPI 2.0 requirement and fix what configuration can fix.
PlannedUses your lab tenant
The lesson
Builds on: The purpose of a security profile.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Planned. The core of this lab waits on platform features that are not built yet. The planned walkthrough shows exactly how it will run; Do today is a real exercise you can do now.
- G11 PAR
- G14 DPoP
- G8 Client authentication beyond `client_secret_basic` and `none`
- G58 FAPI profile mode (incl. PS256/EdDSA keys)
- G56 Client JWKS (`jwks` / `jwks_uri` per client)
- G3 Sample protected resource API; no RFC 9728 protected resource metadata
- G15 RAR (`authorization_details`)
Request console
Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.
Setup
Use the shell variables from the earlier labs, and write down the current Flow policy, Token Decoder state and ID token manager key so you can restore them.
Planned (G58): turn on FAPI 2.0 profile mode for the tenant (Security Profile, Message Signing off).
Planned (G8, G14, G56): create a temporary client
lab-tmp-pay-printer: confidential,private_key_jwtwith the registered keyprinter-2026-10from the JAR labs, DPoP-bound access tokens required, redirect URIhttp://127.0.0.1:8765/callback, scopeprints.create.Planned (G3): the sample photo API offers a DPoP-protected payments resource at
$ISSUER/resource/payments.
Note: the lab toolkit has no command yet that signs client assertions or DPoP proofs with your own key. The planned steps say exactly what each one contains; they need that command before they can run.
Planned walkthrough
Configuration. Fetch the metadata and check that
issuerequals$ISSUERexactly before using any endpoint in it.
GET$ISSUER/.well-known/oauth-authorization-server
Open in console
GET $ISSUER/.well-known/oauth-authorization-serverWhy it matters: every endpoint below comes from a document whose issuer you checked against your own configuration.
Push. Build a client assertion with
audset to$ISSUERas a single string, and a DPoP proof forPOST $ISSUER/oauth/par. Pushresponse_type=code,client_id,redirect_uri,scope=prints.create(or, with G15,authorization_detailsdescribing a 42.50 EURpayment_initiation),code_challenge,code_challenge_method=S256anddpop_jkt. Expect201withexpires_inunder 600.
Why it matters: PAR, client authentication, PKCE and code binding happen in one request, before the person sees anything.
Visit. Open
$ISSUER/oauth/authorize?client_id=<id>&request_uri=<encoded reference>and approve as Ava.
Why it matters: the browser carries a reference and nothing anyone could edit.
Return. Compare
isswith$ISSUERbefore anything else.
Why it matters: the mix-up defense is mandatory, and it comes first.
Exchange. Within 60 seconds, redeem the code with a new assertion, the
code_verifierand a new DPoP proof from the same key. Expecttoken_type: DPoPand no rotated refresh token.
Why it matters: "Where FAPI departs from habit". The code is short-lived and single use, the token is bound, and refresh tokens are not rotated.
Pay. Call the payments resource with
Authorization: DPoPand a proof that includesath. The resource checks the binding before acting.For each step, write which row of the lesson's "Where each requirement comes from" table it satisfied.
Do today
Pull the fields that matter for FAPI 2.0 from your tenant's discovery document.
curl -s "$ISSUER/.well-known/oauth-authorization-server" | jq '{
issuer, par_endpoint: (.pushed_authorization_request_endpoint // "MISSING"),
par_required: (.require_pushed_authorization_requests // false),
response_types_supported, code_challenge_methods_supported,
iss_param: .authorization_response_iss_parameter_supported,
client_auth: .token_endpoint_auth_methods_supported,
dpop_algs: (.dpop_signing_alg_values_supported // "MISSING"),
mtls_bound: (.tls_client_certificate_bound_access_tokens // false),
at_algs: .access_token_signing_alg_values_supported,
par_status: .btl_endpoint_status["/oauth/par"]}'
curl -s "$ISSUER/.well-known/openid-configuration" | jq .id_token_signing_alg_values_supported
Fill in this table. The middle column shows what a new tenant reports.
| FAPI 2.0 requirement | Your tenant | Fixable by configuration? |
|---|---|---|
| PAR for every request | MISSING, not_implemented | No (G11) |
| PKCE with S256 | ["S256"] | Already met; keep PKCE required on every client |
response_type=code only | Depends on Flow policy | Yes, in Flow policy |
iss in every response | true | Already met |
| Codes single use, at most 60 seconds | Single use always; lifetime set in Flow policy | Yes, set 60 seconds |
Client authentication by mutual TLS or private_key_jwt | ["client_secret_basic","none"] | No (G8) |
| Sender-constrained tokens | MISSING, false | No (G14 or G8) |
| Confidential clients only | The Token Decoder is public | Yes, disable it and create no public clients |
| PS256, ES256 or EdDSA | Access tokens ES256; ID tokens RS256 by default | Partly: point the ID token manager at an ES256 key |
| No refresh token rotation | Rotation always on | No (G58) |
| Tokens never in a URL query | UserInfo refuses query tokens | Already met |
| TLS 1.2 or later | Recorded below | Platform setting, not a tenant one |
Confirm two rows by request. Tokens in a query string are refused:
GET$ISSUER/oidc/userinfo?access_token=x
Open in console
GET $ISSUER/oidc/userinfo?access_token=xThe answer is 400. Then record the TLS row: curl --tls-max 1.1 -sv "$ISSUER/.well-known/openid-configuration" -o /dev/null fails to connect.
Apply every "Yes" row, as in Narrow your tenant to a written security profile, run step 1 again and mark what changed. The "No" rows are this lab's missing infrastructure.
Restore: put back the Flow policy, Token Decoder and ID token manager values you wrote down in Setup.
Break it
These run once the gaps close. Each is a request an honest FAPI client never sends.
Open an ordinary authorization URL for
lab-tmp-pay-printer: refused withinvalid_request, because PAR is required.Wait 70 seconds before the exchange:
invalid_grant, the code expired.Send a client assertion whose
audis the token endpoint address instead of the issuer:401 invalid_client.Exchange the code with a proof from a different key of yours:
invalid_grant, because the code is bound to the original key.
Check your work
Today, your table is complete, Audit shows tenant.oauth.policy.update and tenant.oauth.clients.update for the "Yes" rows and their restore, and Logs show oidc.userinfo rejected invalid_request for the query token. Once the gaps close, Audit shows oauth.par succeeded, oauth.authorize code_issued, oauth.token succeeded with private_key_jwt and DPoP, the resource call, and each Break it refusal with its own reason.
Cleanup
Confirm the Flow policy, Token Decoder and ID token manager are restored.
Once the gaps close, turn FAPI profile mode off and delete
lab-tmp-pay-printerand its key.
Missing infrastructure
G11 (PAR), G14 (DPoP with
dpop_jkt), G8 (private_key_jwt) and G56 (client keys): the mechanisms the flow combines.G58: FAPI profile mode that enforces the whole profile and stops refresh token rotation.
G3: a sample payments resource that checks DPoP binding.
G15 (optional):
authorization_detailsfor thepayment_initiationrequest.Once these exist and the toolkit can sign assertions and proofs, the Planned walkthrough runs as written.