OPENID CONNECT · LAB
Review how your tenant protects each OIDC message
Do the lesson's security review on your own tenant. Follow each message, record how it is protected today, ask for more protection, and read exactly what the tenant answers.
Partly readyUses your lab tenant
The lesson
Builds on: Connecting a sign-in to an account.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.
- G12 JAR
- G13 JARM
- G8 Client authentication beyond `client_secret_basic` and `none`
- G64 Encrypted ID tokens and signed or encrypted UserInfo
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Ask for a request object by reference
Recorded as
oauth.authorizerejected (request_uri_not_supported) forlab-collage.Ask for a signed authorization response
Recorded as
oauth.authorizerejected (unsupported_response_mode) forlab-collage.Read the plain UserInfo response
Recorded as
oidc.userinfosucceeded (userinfo_served).Send the client secret in the body
Recorded as
oauth.tokenrejected (unsupported_auth_method) forlab-collage.
Setup
The review runs against real messages. Request objects (G12), signed authorization responses (G13), JWT client assertions (G8), and signed or encrypted UserInfo and ID tokens (G64) are not available yet, so decisions that need them stay on paper.
Start a review table with the columns Message, Created by, Protection today, Can it be more, and What the tenant answered.
source ~/btl-oidc.sh, runbtl-lab callbackbefore each request, and press Start.
Walkthrough
The authentication request. Run
signin, open the URL, and look at the address bar and browser history: every parameter is there in clear. Then ask for a request object by reference:
signin request_uri=urn%3Aexample%3Aplaceholder
The listener prints error=request_uri_not_supported, and discovery says request_parameter_supported: false.
Why it matters: anything you put in the request, such as a claims request about a student discount, is visible wherever URLs are recorded.
The authorization response. The listener shows
code,stateandissin clear. Ask for a signed response anyway:
signin response_mode=query.jwt
The listener prints error=invalid_request, with a description saying the response mode must be query, fragment or form_post.
Why it matters: the response carries nothing worth hiding, and PKCE protects the code. That is the printer's reasoning too.
The ID token. Complete a sign-in, redeem, and read the header:
part "$ID_TOKEN" 1
{"alg":"RS256","kid":"...","typ":"JWT"}, three parts: signed, not encrypted. It came from the token endpoint over your own TLS connection.
Why it matters: in the code flow, with no profile claims in the token, signing alone is enough.
The UserInfo response.
curl -si -H "Authorization: Bearer $TOKEN" "$ISSUER/oidc/userinfo" | grep -i '^content-type'
curl -s "$ISSUER/.well-known/openid-configuration" | jq '{userinfo_signing_alg_values_supported, userinfo_encryption_alg_values_supported}'
application/json, and neither metadata field exists.
Why it matters: a second service you pass this JSON to cannot tell where it came from. Only a signed response could travel with its proof, and only an encrypted one stays unreadable past your load balancer.
Client authentication. Send the secret in the body instead of the
Authorizationheader:
curl -s "$ISSUER/oauth/token" -d grant_type=authorization_code -d code=unused -d "client_id=$CLIENT_ID" --data-urlencode "client_secret=$CLIENT_SECRET" | jq .
curl -s "$ISSUER/.well-known/openid-configuration" | jq .token_endpoint_auth_methods_supported
invalid_client, with a description naming HTTP Basic. Client authentication is checked before the code, so the code never mattered. The tenant lists only client_secret_basic and none.
Why it matters: a signed client assertion is not available, so the shared secret stays in the Authorization header and nowhere else.
Write your decision for each message given what the tenant can do today, and mark the ones that would change once the gaps close: a request object signed and then encrypted, and UserInfo signed and then encrypted.
Why it matters: each protection should answer a question someone actually needs answered, and a provider that does not list an algorithm cannot be asked to use it.
Break it
Put a
claimsrequest in the URL anyway:
signin "claims=$(enc '{"userinfo":{"email":null}}')"
There is no error and the parameter is ignored (claims_parameter_supported: false). Check your browser history: the request was recorded in clear regardless. That is the reviewer's first finding, and nothing was weakened to see it.
Check your work
Press Check my progress. The checks look for the refused request object, the refused signed response mode, one plain UserInfo response, and the refused client secret in the body. Your completed table has one row per message.
Cleanup
Nothing changed in the tenant.
Missing infrastructure
G12 (JAR). Signed and encrypted request objects, by value and by reference.
G13 (JARM). Signed authorization responses (
query.jwt,form_post.jwt).G8 (client authentication beyond
client_secret_basic).private_key_jwtclient assertions.G64 (encrypted ID tokens, signed or encrypted UserInfo). Per-client
id_token_encrypted_response_algand_enc,userinfo_signed_response_alg,userinfo_encrypted_response_algand_enc, a client JWKS for encryption keys, the matching*_values_supportedmetadata, andapplication/jwtUserInfo responses. The full lab would register UserInfo as signed then encrypted, and pass the signed inner JWT to a second local service that verifies it.