1
0
Files
spring-auth-demo/docs/output/as-authcode-nopkce.txt
Ankur Mhatre e9381dc5be Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server
Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.

Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:

  - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
    in 7.0, and both configuration classes moved into spring-security-config
  - ClientSettings.requireProofKey flipped from false to true, on the authorization server
    (1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
  - requireProofKey(false) does not make PKCE optional for a public client; the code
    verifier is that client's only authentication at the token endpoint
  - MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
    API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called

Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
2026-08-24 08:20:38 +05:30

141 lines
8.1 KiB
Plaintext

------------------------------------------------------------------
== PKCE parameters (RFC 7636)
------------------------------------------------------------------
code_verifier s3e6dmr3wSf4_lvze8F93m7moAckkHVcu6hKAerx3Ug77jqz38_iNhnOtio2dNj6 (64 chars)
code_challenge fIGXtoxshuoWNmv8gk1b7wWtLzxOzB0fSenfe02UwAs
code_challenge_method S256
The verifier never leaves the client until the token request. The challenge is
all the authorization request carries, and it is a one-way hash of the verifier.
------------------------------------------------------------------
== 1. Log in to the authorization server (browser session)
------------------------------------------------------------------
$ curl -c jar -d username=alice -d password=password -d _csrf=<token> http://localhost:9000/login
HTTP/1.1 302
Location: http://localhost:9000/
------------------------------------------------------------------
== 2. GET /oauth2/authorize (client=demo-spa)
------------------------------------------------------------------
$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-spa&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=openid%20orders.read&state=xyz123&code_challenge=fIGXtoxshuoWNmv8gk1b7wWtLzxOzB0fSenfe02UwAs&code_challenge_method=S256'
-> 302 http://localhost:9000/oauth2/consent?scope=openid%20orders.read&client_id=demo-spa&state=zjgza5hwc4CbJeJepOgTFZXTDk1IBgjWjsGo_mupymU%3D
------------------------------------------------------------------
== 3. The consent page
------------------------------------------------------------------
The authorization endpoint redirected to OUR page, at the path given to
.consentPage("/oauth2/consent"). Note the query string it hands over:
http://localhost:9000/oauth2/consent?scope=openid%20orders.read
client_id=demo-spa
state=zjgza5hwc4CbJeJepOgTFZXTDk1IBgjWjsGo_mupymU%3D
Scopes rendered as checkboxes (openid deliberately not among them):
orders.read
The hidden state the form must echo back: zjgza5hwc4CbJeJepOgTFZXTDk1IBgjWjsGo_mupymU=
(this is NOT the client's state=xyz123 - it is the server's own correlation
handle for the pending authorization request, and sending the client's value
instead is what produces the consent redirect loop)
------------------------------------------------------------------
== 4. POST the approval to /oauth2/authorize
------------------------------------------------------------------
$ curl -b jar -X POST -d client_id=demo-spa -d state=zjgza5hwc4CbJeJepOgTFZXTDk1IBgjWjsGo_mupymU= -d _csrf=f7zUD7ezBE6G0H5DQuPikT9CkEEKR9JuA2Y8njWwhSkoewGDG4ziN46BZXerthpycc7Wp1x6vSBrJetDOwMLr1SI4EpOGDG1 -d scope=orders.read http://localhost:9000/oauth2/authorize
-> 302 http://127.0.0.1:8080/authorized?code=whzdCa9Z_53d6sCkZYkNNswxQLQ46kDsMy9ynR0rOvy6KqP741fpHOAC5nF4PsN6O0Sqvi3R9kksVer6L6V3Zk9Ii_LcHOYdHqWGEiE9O8jDDRJjqr-LiTnpEDI12St1&state=xyz123
------------------------------------------------------------------
== 5. The authorization code
------------------------------------------------------------------
code = whzdCa9Z_53d6sCkZYkNNswxQLQ46kDsMy9ynR0rOvy6KqP741fpHOAC5nF4PsN6O0Sqvi3R9kksVer6L6V3Zk9Ii_LcHOYdHqWGEiE9O8jDDRJjqr-LiTnpEDI12St1
state = xyz123 (the client's own value, returned untouched - compare it)
------------------------------------------------------------------
== 6a. Exchange the code WITHOUT the verifier
------------------------------------------------------------------
This is the request an attacker who stole the code can make.
HTTP 401
(empty response body)
>>> Rejected. invalid_grant is deliberately vague: the server will not tell
>>> a caller whether the code was wrong, expired, already used, or missing a
>>> verifier, because each of those is information an attacker can use.
Note: this consumed the code. Authorization codes are single-use, so the
successful exchange below needs a fresh one.
------------------------------------------------------------------
== 6b. A fresh code, exchanged properly
------------------------------------------------------------------
fresh code = B3GUYBE-2tE9C3eSx8_m8ejjXhPwFYEHuTrMwvmNpj-xZuDsPhutSgDt3pY88aE4bSmclgB8mDWFb_SiCODkeheeyCkogZkPwSLFFY4MqbZ1WlUipMynXR1jn9L0UIav
$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
HTTP 200
{
"access_token": "eyJraWQiOiIyYjNjZGFhMy1kMDE1LTQ3NTItOWZiYS0xNmM3YjI0NTJiNTciLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3NDYsInNjb3BlIjpbIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwNDYsImlhdCI6MTc4NzUzODc0NiwianRpIjoiZmI3ZTUyOTEtMTc2OC00NjI2LWJhODUtNWQ3NWI2OGZmNjcyIiwidGVuYW50IjoiYWNtZSJ9.e048ANvC61A6JPe7rE5k_aQDFcQTTCWNRg11j6Nj3MoUsqPvRIPFtYWvK0_l20HnUWZn0X84hy-ifgi4f_nBYDXDXFI-Ya2bRjRnnhtincjZsLNB8RUUDK5tqAPMSdfcceM48cTLLDrvwrxIp0ASG697aBuRVmndcUWyMVfZPzSidR3h0ydeWUVaY7NmL8d6pOzgLLNlDTIjyipURUboda7Mcw7KTHxodM_saz1xwQTzozRtcybmreUw44O6b07wjATcIzAwAJzASaiX2ElUyYs9lWAtxQ-JSiGX4htmfLJiONVnuEZNSy3uvLQnTfnl4G4OY2mjK1zJzIT-wBfX6w",
"scope": "openid orders.read",
"id_token": "eyJraWQiOiIyYjNjZGFhMy1kMDE1LTQ3NTItOWZiYS0xNmM3YjI0NTJiNTciLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwiYXpwIjoiZGVtby1zcGEiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3NDYsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTQ2LCJpYXQiOjE3ODc1Mzg3NDYsImp0aSI6IjBjY2IzZjI4LTk1MWYtNGU5ZS1iNzkzLTNjNWExNjdmNDg1MSIsInNpZCI6InQ1dUpXUDM2V1dYWTNXX2hkaURueTR1OGlEZlVyZHlLd1EyRE9XWUdoSzgifQ.B6kGKui5mOyakllBv7xmmlVcsy6BYUN-JNALZLKMPz-O0WZO6WigSYfHKXdHtYVGsNdxVpFgeyWYPyi6JOTX9ALaDeJcEAiUwZFEN4PJa-lSzZvdT6m5atD1OCQqXOd7c_4bqD596I94Gu22arBij6UYaNBagLdBmtbAhJYMhBT5Q7Ygzo5m0w7Ru-oxU_cK06R4ZggNGo9B0NWJfqXroTOdFzfJmptl3CN9Ddh14pj5pz4w0hAMkxSMsJ-GAgm85ldD9aGVzFfjZ4-lzI7gveUVn_IzWZQbCY3_SYUuoGQFe60I0L6cDyot88qfuXn3m1udEaQVvrJlE7SBxMIFmQ",
"token_type": "Bearer",
"expires_in": 299
}
------------------------------------------------------------------
== 7. The access token
------------------------------------------------------------------
{
"alg": "RS256",
"kid": "2b3cdaa3-d015-4752-9fba-16c7b2452b57"
}
{
"aud": "orders-api",
"exp": 1787539046,
"iat": 1787538746,
"iss": "http://localhost:9000",
"jti": "fb7e5291-1768-4626-ba85-5d75b68ff672",
"nbf": 1787538746,
"roles": [
"ADMIN",
"USER"
],
"scope": [
"openid",
"orders.read"
],
"sub": "alice",
"tenant": "acme"
}
------------------------------------------------------------------
== 8. The id_token - a different token, for a different audience
------------------------------------------------------------------
{
"aud": "demo-spa",
"auth_time": 1787538746,
"azp": "demo-spa",
"exp": 1787540546,
"iat": 1787538746,
"iss": "http://localhost:9000",
"jti": "0ccb3f28-951f-4e9e-b793-3c5a167f4851",
"preferred_username": "alice",
"sid": "t5uJWP36WWXY3W_hdiDny4u8iDfUrdyKwQ2DOWYGhK8",
"sub": "alice"
}
aud is the CLIENT here, not the API. Sending this to a resource server is the
classic mix-up: it verifies (same issuer, same key) and then fails the audience
check, or worse, passes it if nobody checks audience.
------------------------------------------------------------------
== 9. Calling the resource server
------------------------------------------------------------------
GET /api/orders -> 200
{"orders":[{"total":"42.00","id":1}],"subject":"alice","clientId":null,"scopes":["openid","orders.read"],"roles":["ADMIN","USER"],"tenant":"acme","audience":["orders-api"]}
GET /api/admin -> 200
{"authorities":["SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN","FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T02:32:26.714311634Z]"],"message":"admin only"}
------------------------------------------------------------------
== 10. Sending the id_token instead
------------------------------------------------------------------
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"