1
0

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.
This commit is contained in:
2026-08-24 08:12:36 +05:30
parent 4dc45d5e00
commit 38c0a5f358
89 changed files with 5234 additions and 9 deletions

View File

@@ -0,0 +1,134 @@
------------------------------------------------------------------
== 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"