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.
135 lines
7.6 KiB
Plaintext
135 lines
7.6 KiB
Plaintext
|
|
------------------------------------------------------------------
|
|
== PKCE parameters (RFC 7636)
|
|
------------------------------------------------------------------
|
|
code_verifier Iw8HeF3yGXVXGbb7-1--z99v8prKuEaJsvcbuUCsIyPLPjoLmFL3ugA7jKMKXrs- (64 chars)
|
|
code_challenge HmY3EXZTXZ3o7cMa9zsushNacFOu76m71sP160ZUvmQ
|
|
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=HmY3EXZTXZ3o7cMa9zsushNacFOu76m71sP160ZUvmQ&code_challenge_method=S256'
|
|
-> 302 http://localhost:9000/oauth2/consent?scope=openid%20orders.read&client_id=demo-spa&state=m02fs6cglXagctSASVX0sCCxeeDSeu72XdFVtdTcFKI%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=m02fs6cglXagctSASVX0sCCxeeDSeu72XdFVtdTcFKI%3D
|
|
|
|
Scopes rendered as checkboxes (openid deliberately not among them):
|
|
orders.read
|
|
|
|
The hidden state the form must echo back: m02fs6cglXagctSASVX0sCCxeeDSeu72XdFVtdTcFKI=
|
|
(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=m02fs6cglXagctSASVX0sCCxeeDSeu72XdFVtdTcFKI= -d _csrf=MAR-q4pdtKeD5di-IHeoaLRqgfgFYFA6F0y8f8RdqDhoLtygB2JLnrI50JGu1O_cRVqcDNUJrME8VzQXIyiESvM7kF0LHeuU -d scope=orders.read http://localhost:9000/oauth2/authorize
|
|
-> 302 http://127.0.0.1:8080/authorized?code=SZzaL3XfpYK5WflGaIQRq9NdFO_kPBK0jX5T19PfPZ9QZO6hyB8vQuID5L9uRizF0qd_Vu4XjNm4oe04nRKn1EsHwvmBndHMJ_C2I5b3wROlLL_tI-gAjinXV72LBKtl&state=xyz123
|
|
|
|
------------------------------------------------------------------
|
|
== 5. The authorization code
|
|
------------------------------------------------------------------
|
|
code = SZzaL3XfpYK5WflGaIQRq9NdFO_kPBK0jX5T19PfPZ9QZO6hyB8vQuID5L9uRizF0qd_Vu4XjNm4oe04nRKn1EsHwvmBndHMJ_C2I5b3wROlLL_tI-gAjinXV72LBKtl
|
|
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 = L0f5Q2ZTW6TLHG9tOCddSEnQRM7U-C1ikbB7QU2glxHSoKs75R54iAYiPJmlpDMNDabWWEP17ZaZ-6jFlmvwlSnTgqxwJDUljiEQHhuZdr8rJXAjy4wPjpgn-hsje6pz
|
|
|
|
$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
|
|
HTTP 200
|
|
{
|
|
"access_token": "eyJraWQiOiIyNDgwNWM5Ni02MGY1LTQ5MDItYTczYi03ODgxYmFkNWY5ZGMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwibmJmIjoxNzg3NTM4NzQwLCJzY29wZSI6WyJvcGVuaWQiLCJvcmRlcnMucmVhZCJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwNDAsImlhdCI6MTc4NzUzODc0MCwianRpIjoiYTg0YjUyMzYtZWE3ZC00ZjZjLWE2NzItNDkyNTAwNDJjODU3In0.Gu0qaHsAyLMCOp6PfufdSgex6k67zg2SL9i1NKzWKfyHV5Es1QSVyiy5CyjmCW2NaOrM-N18VybrA36f2WVG466dHWLZ17vDgE9BX1slFRQTMnVBzGi7kB6S06PwI_4l8MPe24XNUTSOT9L0OKWyRE5zVA4jw0p14Yn4qmCkG-ur3lqEPlPPEc0nnoALnazv_kQrpm-4xw42E5j0SBlOWXEwv7SwMw59VyamXQonY_LaflwNmUeevUtic-PeGBN9JJafsx31we63Vjcfri7d1dW0YtHQVAYzzJGiXA15pRChIZvbvY7miLiGryRQ8iPghHflALGpfITO2OmZHihoJQ",
|
|
"scope": "openid orders.read",
|
|
"id_token": "eyJraWQiOiIyNDgwNWM5Ni02MGY1LTQ5MDItYTczYi03ODgxYmFkNWY5ZGMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwiYXpwIjoiZGVtby1zcGEiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3NDAsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsImV4cCI6MTc4NzU0MDU0MCwiaWF0IjoxNzg3NTM4NzQwLCJqdGkiOiI3YzUzMDc5OS05ZGZiLTQxMDYtOWViOS02ZGU4NDFiN2FjYmIiLCJzaWQiOiJCcC02UWNZZW9tclJ1VGFlcUZQbWRscF9nZmNLVVJfSS1wakRuTGFaYXpnIn0.ELFbbP-luao7YrJlEOr2RL86dBlw1M-ultJk2LnstkE996a2MvghwTe4N0r_qJBSvkdvUmO0oaxdafDjbKMmJHFCtTHAJDBxguD35vGTpxXL_nLEMvJZy86Nu-joUoJ30Dy_4tkNMQDlBWapomLHAxmyRi6Nmv2yNujcejp9auHJjb_qeEuFbri_tV_znYBVJd0tg4BXibV_nYZ4vmkUZR_FpHIdXHGX4xMan478BsZSXmt0QNpWXrPiewrVYZqGPXAvp1_Uu-pzxZ3XZthitP1SKGl6HCUFIeWr8izcmKeErcCxzRZsz7Ymq0qjUPMnOY98VJof_0jp8tt5XKSqkw",
|
|
"token_type": "Bearer",
|
|
"expires_in": 299
|
|
}
|
|
|
|
------------------------------------------------------------------
|
|
== 7. The access token
|
|
------------------------------------------------------------------
|
|
{
|
|
"alg": "RS256",
|
|
"kid": "24805c96-60f5-4902-a73b-7881bad5f9dc"
|
|
}
|
|
{
|
|
"aud": "demo-spa",
|
|
"exp": 1787539040,
|
|
"iat": 1787538740,
|
|
"iss": "http://localhost:9000",
|
|
"jti": "a84b5236-ea7d-4f6c-a672-49250042c857",
|
|
"nbf": 1787538740,
|
|
"scope": [
|
|
"openid",
|
|
"orders.read"
|
|
],
|
|
"sub": "alice"
|
|
}
|
|
|
|
------------------------------------------------------------------
|
|
== 8. The id_token - a different token, for a different audience
|
|
------------------------------------------------------------------
|
|
{
|
|
"aud": "demo-spa",
|
|
"auth_time": 1787538740,
|
|
"azp": "demo-spa",
|
|
"exp": 1787540540,
|
|
"iat": 1787538740,
|
|
"iss": "http://localhost:9000",
|
|
"jti": "7c530799-9dfb-4106-9eb9-6de841b7acbb",
|
|
"sid": "Bp-6QcYeomrRuTaeqFPmdlp_gfcKUR_I-pjDnLaZazg",
|
|
"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 -> 401
|
|
|
|
GET /api/admin -> 401
|
|
|
|
|
|
------------------------------------------------------------------
|
|
== 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"
|