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.
124 lines
7.0 KiB
Plaintext
124 lines
7.0 KiB
Plaintext
|
|
------------------------------------------------------------------
|
|
== PKCE parameters (RFC 7636)
|
|
------------------------------------------------------------------
|
|
code_verifier 1cYLvhzx4gH7jAoNyYB34Nv0jcuHMVqDPC8fFzZBuyiaYeArg2yU_LJIC2BBR8x1 (64 chars)
|
|
code_challenge vocXBOiUBp_mSdSMvkfZUMxcKHxo021R2rZ46NndJ7c
|
|
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=vocXBOiUBp_mSdSMvkfZUMxcKHxo021R2rZ46NndJ7c&code_challenge_method=S256'
|
|
-> 302 http://127.0.0.1:8080/authorized?code=SEUTqNBFCouk0Ip0zD1IGctT21q8_hboy-3B5ofrsKPWUhZe43_dQ-mIhmSwgtHeHka6lyeow-SMgcVb4kLGCl0czBIt1tnbiMsJbNu3HLdW7M06FVrYILKrmJqGFsoE&state=xyz123
|
|
|
|
------------------------------------------------------------------
|
|
== 3. No consent page
|
|
------------------------------------------------------------------
|
|
The authorization endpoint went straight back to the client. Either consent is
|
|
off for this client, or every requested scope was already approved.
|
|
|
|
------------------------------------------------------------------
|
|
== 5. The authorization code
|
|
------------------------------------------------------------------
|
|
code = SEUTqNBFCouk0Ip0zD1IGctT21q8_hboy-3B5ofrsKPWUhZe43_dQ-mIhmSwgtHeHka6lyeow-SMgcVb4kLGCl0czBIt1tnbiMsJbNu3HLdW7M06FVrYILKrmJqGFsoE
|
|
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 = _TiGoBrAj3MPpOg3uoh76reyCg-YfoYTvHSraf-ljGyPjrPF6C0ccK_C4IG4oXjavEbTym0HttIURO7Mrt2U-YgBQ8Q_TSrgLgwpU357VYRtDm5rFdu4WVtET2Lt0YjB
|
|
|
|
$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
|
|
HTTP 200
|
|
{
|
|
"access_token": "eyJraWQiOiJmMjgxYTNkYy0zZjlmLTRkMGItOTk1YS1iZmUwYzcwYTEwODEiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3NTksInNjb3BlIjpbIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwNTksImlhdCI6MTc4NzUzODc1OSwianRpIjoiZGYxNzJlNTgtOGMxZi00NWVlLTlkZWEtMGE3OWQ2Y2NlMDU1IiwidGVuYW50IjoiYWNtZSJ9.TGm_0gOhoVk1mSX4YVVLA6iQp_bDFTvFAjE_1DFltFyg33FLdl7tV0Z97587SB40SgL53Vx5AUan0egPVzxZscsYwUHVxQgnXHsm0FFfIGywqccXNn2IDleUAoKtMF4Lz6oMwNc9lC6XU32UgeKGOweM_IamKcHox9GnY7q9M57nG6boOp89FZzGcYsgQ9zkbG0XvzfX3WY-FS7O5cFm8oF0b3duJ3Hb3nP8WN8VrUJdOTzuGRJC9dwxZV0Ss6sI5Z-tGr0uz_Kf0tYmJ-zlx21zh6pVKHwCEnaN6T9crl4qa784DFJlW9MA4NI_E6vXOfpHbAMyRHONSrdUAcTCtA",
|
|
"scope": "openid orders.read",
|
|
"id_token": "eyJraWQiOiJmMjgxYTNkYy0zZjlmLTRkMGItOTk1YS1iZmUwYzcwYTEwODEiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwiYXpwIjoiZGVtby1zcGEiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3NTgsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTU5LCJpYXQiOjE3ODc1Mzg3NTksImp0aSI6ImE5NjFkNGJkLWIwNGUtNDc1Ni04N2JkLWE5ZjI4ZjkxMWM3NCIsInNpZCI6IkNlZlExc2ZTblJkRzBMZnY0SGdhX25iZ1pPckJiWTV6dzRwN2F4U1V1ZU0ifQ.o3-P70sm-3n2GST9kQdc0TMTazGy5vjbtO6RLdkrkbqpdz5bFmEwirdZ7ajymyutPHaGF1vGpq76fnSoBCADpNtfqNJsir8aUyKoqPxGw9HLRN_Ocky3rP-XuKAVSuaplrlEiIC6CKvwoX1oH7CnJqrI1362oLaza7ThriIyzJhArrmCIZsn7AR5h0gqFp-ivMnVgPSiRI9Gg_IpD8Jr1ZREPHo6z304vpwfTU9CJeHQb2k8wWueuJJVcjnd2hfVvqqkXX9nyIqiwy3TGG_3bbEtQ4yA4uhLACcH0E1KCtFIOFPcLujcwsNa0EnsO4OLB3w1teppZyK4vpIfGUSXcA",
|
|
"token_type": "Bearer",
|
|
"expires_in": 299
|
|
}
|
|
|
|
------------------------------------------------------------------
|
|
== 7. The access token
|
|
------------------------------------------------------------------
|
|
{
|
|
"alg": "RS256",
|
|
"kid": "f281a3dc-3f9f-4d0b-995a-bfe0c70a1081"
|
|
}
|
|
{
|
|
"aud": "orders-api",
|
|
"exp": 1787539059,
|
|
"iat": 1787538759,
|
|
"iss": "http://localhost:9000",
|
|
"jti": "df172e58-8c1f-45ee-9dea-0a79d6cce055",
|
|
"nbf": 1787538759,
|
|
"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": 1787538758,
|
|
"azp": "demo-spa",
|
|
"exp": 1787540559,
|
|
"iat": 1787538759,
|
|
"iss": "http://localhost:9000",
|
|
"jti": "a961d4bd-b04e-4756-87bd-a9f28f911c74",
|
|
"preferred_username": "alice",
|
|
"sid": "CefQ1sfSnRdG0Lfv4Hga_nbgZOrBbY5zw4p7axSUueM",
|
|
"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":["FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T02:32:39.220990600Z]","SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN"],"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"
|