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 e9381dc5be
89 changed files with 5237 additions and 9 deletions

View File

@@ -0,0 +1,32 @@
------------------------------------------------------------------
== A token for a DIFFERENT audience, signed by the SAME issuer
------------------------------------------------------------------
demo-service's tokens carry aud=[orders-api] thanks to the token customiser.
Here we ask for one and then present it to a resource server configured to
require a different audience - and to one that does not check at all.
aud claim in the token:
"aud": "orders-api",
"exp": 1787539386,
"iat": 1787538786,
"iss": "http://localhost:9000",
------------------------------------------------------------------
== Resource server running with demo.validate-audience=false
------------------------------------------------------------------
This is the Spring Boot default: issuer-uri alone validates signature, exp/nbf
and iss. Audience is not checked unless you add a validator.
GET /api/orders -> 200
{"orders":[{"total":"42.00","id":1}],"subject":"demo-service","clientId":null,"scopes":["orders.read"],"roles":null,"tenant":"acme","audience":["orders-api"]}
------------------------------------------------------------------
== The same token with a deliberately mangled signature
------------------------------------------------------------------
HTTP/1.1 200
------------------------------------------------------------------
== No token at all
------------------------------------------------------------------
HTTP/1.1 401
WWW-Authenticate: Bearer resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"

View File

@@ -0,0 +1,67 @@
------------------------------------------------------------------
== PKCE parameters (RFC 7636)
------------------------------------------------------------------
code_verifier RnM5322FJWXI9zUZDUkmRmK-x_rjKT6HHFQyIhSEqjtVkb527uqjZmfQCwrNmavk (64 chars)
code_challenge rzSywj6JKmWWVVBuZsD26hYR4_3PK0zZRCXrn_4sU9M
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)
------------------------------------------------------------------
NO_CHALLENGE=1: the authorization request carries no code_challenge.
$ 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'
-> 302 http://127.0.0.1:8080/authorized?code=ydxIVNbSbrjWRlj1YxX6LS4cnXgDdzxTIyZgqoqlmX6cD8q-AYnL_Sdy6m5gqheZ-yd8qKUgSrrgMtg331sYHjblO3IYqGb1i94OxMypdyLr6RMqm_O_XuVZX2g1FSeD&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 = ydxIVNbSbrjWRlj1YxX6LS4cnXgDdzxTIyZgqoqlmX6cD8q-AYnL_Sdy6m5gqheZ-yd8qKUgSrrgMtg331sYHjblO3IYqGb1i94OxMypdyLr6RMqm_O_XuVZX2g1FSeD
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 = uvr0H49ByWte7NfWDoDnZ61-11rMP0wxYFJMY8eM73Lm2dcc2Edl-oFdW3DwJNSqUBMyIRX8rY9JqPDZIvwnEo4grUU-D5Z-GTwYYV1B8Xvosdbg-vAjiss1wbRsvD03
$ curl -d grant_type=authorization_code -d code=... http://localhost:9000/oauth2/token
(no code_verifier - there was no challenge to verify against)
HTTP 401
(empty response body)
>>> No token, even though the client is registered with requireProofKey(false)
>>> and the authorization request carried no challenge. The reason is that a
>>> public client has no other way to authenticate at the token endpoint:
>>> PublicClientAuthenticationProvider delegates entirely to
>>> CodeVerifierAuthenticator, and raises invalid_client when there is nothing
>>> to verify. requireProofKey(false) relaxes the AUTHORIZATION endpoint only.

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"

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
------------------------------------------------------------------
== PKCE parameters (RFC 7636)
------------------------------------------------------------------
code_verifier BTbNq-KzR5R5zjPoT5s8JVMvvweKRQq_dMcGF-2P7Ea8-lku7Jgv0Na6RZkxBjlY (64 chars)
code_challenge B_BI3Dk68d1epKthalJJxOKFAN0mf3FkUOoJRuV26kM
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)
------------------------------------------------------------------
NO_CHALLENGE=1: the authorization request carries no code_challenge.
$ 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'
-> 302 http://127.0.0.1:8080/authorized?error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge&error_uri=https%3A%2F%2Fdatatracker.ietf.org%2Fdoc%2Fhtml%2Frfc7636%23section-4.4.1&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 =
state = xyz123 (the client's own value, returned untouched - compare it)
no code in the redirect. The error was:
http://127.0.0.1:8080/authorized?error=invalid_request
error_description=OAuth%202.0%20Parameter%3A%20code_challenge
error_uri=https%3A%2F%2Fdatatracker.ietf.org%2Fdoc%2Fhtml%2Frfc7636%23section-4.4.1
state=xyz123

View File

@@ -0,0 +1,140 @@
------------------------------------------------------------------
== PKCE parameters (RFC 7636)
------------------------------------------------------------------
code_verifier C9PyP-Bxq-_AwoSFt1hAHGO6klWLddojkvIaIR1-vTzzTkjtc6o9c71G-yVYQwKf (64 chars)
code_challenge FlJK8n-vjKPJ9-ZQPGrBm4JqIvhUotk67SUZRkXXJ3o
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=FlJK8n-vjKPJ9-ZQPGrBm4JqIvhUotk67SUZRkXXJ3o&code_challenge_method=S256'
-> 302 http://localhost:9000/oauth2/consent?scope=openid%20orders.read&client_id=demo-spa&state=s4VpkAnwpXo3Q9sekOo3MJVSaZgwc_AJnZMNIqcHvpE%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=s4VpkAnwpXo3Q9sekOo3MJVSaZgwc_AJnZMNIqcHvpE%3D
Scopes rendered as checkboxes (openid deliberately not among them):
orders.read
The hidden state the form must echo back: s4VpkAnwpXo3Q9sekOo3MJVSaZgwc_AJnZMNIqcHvpE=
(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=s4VpkAnwpXo3Q9sekOo3MJVSaZgwc_AJnZMNIqcHvpE= -d _csrf=pQeZvH9HfbMeKSIJ5Tgv-82ImtETjf8f3pu1C0Ukgwnkyy9_lDX9iE12HNUzGRA_3RUbmqy9t-glv50y7qKGOiRCujHU_Eod -d scope=orders.read http://localhost:9000/oauth2/authorize
-> 302 http://127.0.0.1:8080/authorized?code=f5H5d-BObbl65Mzu2LE_Lg6QmVgG31gd_1__sJ28KSIjHqbr9yp0o4sq-Q9NqADqXc2Bemfdiod46rduBfDmvygYYvvhadjjn5ZQcYHGNfipjZoTQ4oxjDFQJYldbS4H&state=xyz123
------------------------------------------------------------------
== 5. The authorization code
------------------------------------------------------------------
code = f5H5d-BObbl65Mzu2LE_Lg6QmVgG31gd_1__sJ28KSIjHqbr9yp0o4sq-Q9NqADqXc2Bemfdiod46rduBfDmvygYYvvhadjjn5ZQcYHGNfipjZoTQ4oxjDFQJYldbS4H
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 = 5eNIeklP92R9ihz7RAuR_3cwTdlRKhqMXLSVVyNGei_jHwwsTiFIpHe_4bDLIdO0GbrLkkQ4NWAOD5XE1u1gsNB7xtfNEf9t6zTEyeu0EQ4tEtklkKpGtnfGMsKU1XZR
$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
HTTP 200
{
"access_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3MTQsInNjb3BlIjpbIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwMTQsImlhdCI6MTc4NzUzODcxNCwianRpIjoiMWQ5NGFjYmMtZTI0NS00MGZiLWE3NWQtMDVhYjNiYzI5NGI2IiwidGVuYW50IjoiYWNtZSJ9.f0zh8PyK_6luy-npcr4zZj-ZHeqbrCtGI-b3SBlyE36RAu1PDXv6WP7ZQjV-9DWn7fSmD7mClOVSOdCZDXx9Y9jsCAvGwyV3DHeFutJHcM5pqrdX7n31TmPgFZgaXko8bK34qs62ic8pwNKKEL2R0jAYeVLqlGtPYVo1a5hMvXNxYARC519wKzfIJMMYtEiecOlk5n9m41lXk3WT4EvqN72zeQBgOnJBqd75vwTyr27UwLlXoGfeuu1cUBXzEg0COw4Eirv-P7zhvTpGSc8oich_At_TYip9GyOLnfNK60p_QJEPMnhCvye6ooXi6tiQ2kbHxdodTlTWDYId0wA6nQ",
"scope": "openid orders.read",
"id_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwiYXpwIjoiZGVtby1zcGEiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3MTMsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTE0LCJpYXQiOjE3ODc1Mzg3MTQsImp0aSI6ImRjM2JiZTMyLTBlMmEtNDUwYi1hNjVjLTU0YzQ0M2E4NTcwZCIsInNpZCI6ImNIb29WN2Nqa0ctV0t4ZC1NWTJyNDM0SXRJYXhwcXJZR1VPbENCVjY3OHcifQ.fCsxjhoqx7WQLXoN5eV6e1zrPB1MgHPZVbmW5HixQPcmvqqu2Zk--4sPsngPJBrLXKTcKEJeUM4jGvugkfQMnAIYu4stafM5_lJXwgA-Rvd7DgDzmzpXtfWpBDPsuuoiHyG00Hp9evbru0qHbfKFA4d4KarJTw9F1OHx5b_3H2Z3CGLh33ZZZG6zC2ki4wOg__GKmfw00p6OeRRfNIL_1zr4ZFv6xF0VynZdSOA1_0XtRBv1J-kp4G1YQcn2KwU1i1j2_5CU_dN8_kTC-T2HdF_-ANV37PoNYyZ9TdRuKwArQfBlbHsyD5WC6KDLMGf7MxK-DTMuoPlZh3SP12POTw",
"token_type": "Bearer",
"expires_in": 299
}
------------------------------------------------------------------
== 7. The access token
------------------------------------------------------------------
{
"alg": "RS256",
"kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb"
}
{
"aud": "orders-api",
"exp": 1787539014,
"iat": 1787538714,
"iss": "http://localhost:9000",
"jti": "1d94acbc-e245-40fb-a75d-05ab3bc294b6",
"nbf": 1787538714,
"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": 1787538713,
"azp": "demo-spa",
"exp": 1787540514,
"iat": 1787538714,
"iss": "http://localhost:9000",
"jti": "dc3bbe32-0e2a-450b-a65c-54c443a8570d",
"preferred_username": "alice",
"sid": "cHooV7cjkG-WKxd-MY2r434ItIaxpqrYGUOlCBV678w",
"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:31:54.540547140Z]","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"

View File

@@ -0,0 +1,155 @@
------------------------------------------------------------------
== PKCE parameters (RFC 7636)
------------------------------------------------------------------
code_verifier D6kPMLmsNKTof_0_UEga6cyBnpBSX5UkfU6eUgWFZW3BJ4iU9OVB5xvNk0hPQsW3 (64 chars)
code_challenge 0E5PYxr7XERt0s3OvrJY-HsaIhqh7JcFqJIjb8KnZjg
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-web)
------------------------------------------------------------------
$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Flogin%2Foauth2%2Fcode%2Fdemo-web&scope=openid%20orders.read%20orders.write&state=xyz123&code_challenge=0E5PYxr7XERt0s3OvrJY-HsaIhqh7JcFqJIjb8KnZjg&code_challenge_method=S256'
-> 302 http://localhost:9000/oauth2/consent?scope=orders.write%20openid%20orders.read&client_id=demo-web&state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24%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=orders.write%20openid%20orders.read
client_id=demo-web
state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24%3D
Scopes rendered as checkboxes (openid deliberately not among them):
orders.write
orders.read
The hidden state the form must echo back: FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24=
(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-web -d state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24= -d _csrf=P_NC_egjNFLN-1IOgLWK3N9qIgIvnhma_LGeHCLVKyZKXF1SB5J7zdgXUGHgzmU7tZi-7epcDztK-y-3ndStLBSwSRR5Pm5j -d scope=orders.write -d scope=orders.read http://localhost:9000/oauth2/authorize
-> 302 http://127.0.0.1:8080/login/oauth2/code/demo-web?code=7K02csgk4cAepvRDnCiDqNA9gOVLCSGnjU-ByFlWBaHdFxe1byEXN14iQ3UOAMn_rWnY_jUz3xWAeeYke2UA8G74BcAhgzlmaRkxIAs6e6MywPQz-6eJ6H5XB6okDZVT&state=xyz123
------------------------------------------------------------------
== 5. The authorization code
------------------------------------------------------------------
code = 7K02csgk4cAepvRDnCiDqNA9gOVLCSGnjU-ByFlWBaHdFxe1byEXN14iQ3UOAMn_rWnY_jUz3xWAeeYke2UA8G74BcAhgzlmaRkxIAs6e6MywPQz-6eJ6H5XB6okDZVT
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 400
{
"error": "invalid_grant"
}
>>> 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 = dR8FdRofdCXW63nskaWklVZAALCEFP4eG8kNmo6KctU7_KUBzxyPenduDcuFwP3ek7ohYsf5Mff7zVbfkyzPaYhC-k-x9hAlteWsQNFkmZO6xZQyaCMaUp-H3DLPn4M3
$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
HTTP 200
{
"access_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3MTUsInNjb3BlIjpbIm9yZGVycy53cml0ZSIsIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwMTUsImlhdCI6MTc4NzUzODcxNSwianRpIjoiYjQxNzljNDYtZTA1NS00YjY0LWJhNTktZjE1ZjkyYTkyY2QxIiwidGVuYW50IjoiYWNtZSJ9.LdqBUotxR3briYnuL56ZuFHKmKCr1hehZtzO-7sMa9sclA30jTsvn2sadm-kj9vnk0HRJP0WyR2UX7NGl1quIGlUlSN_0YqV4gTYbfKSIhAV3JilNeXXgSIe2X04UjjnesOxp2Ui-Umk5v3zpZqeSM0ZJtTafDyBXMNA_I3n5CzJ1_AiYFQ9DxvfN0pQCN-hik2gP1a_u9l1sSD0r_Su8YCtReYue37wA2tGgviA1mRMM4xaDOCSyGmie41kq4Bj0K9a8bOCSHEZ9CGnjmVTQENF3ZhkPjX_EnkDvzLcBZh84DtEue6ddp5jdWH5DSJTk8YKipvNqL6eK3CGjX4Byw",
"refresh_token": "8BsQgRSdo4MCHf2sZb9-paqL0tOhYGYOfpYxdJ659LUefpK3csiabvBV5JyNaE9PZuweNEADbNuTtbfHIVtinxyaoH871wqd3YXTVHOQCMZ46_FUM9CkicHeRU17TL_E",
"scope": "orders.write openid orders.read",
"id_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8td2ViIiwiYXpwIjoiZGVtby13ZWIiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3MTQsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTE1LCJpYXQiOjE3ODc1Mzg3MTUsImp0aSI6IjQwZmJjMGEwLTMyNTQtNDFkMC04M2Q1LTJlOTBhZjcxZmY2MyIsInNpZCI6Ilk0Zk5vZjQ3bldodlFMSjB4Z3lRX3BPbTdfVERxTXAyMm1tamg0Mjl2Sm8ifQ.BmuLT6VfjSP_EUPSLKwnkGVAThNHDv-9Z0uRnlGNC39nJL2P8SbgDYB7pWxy0eLQ8mqi4iwyoL9faFLFGaqAvndaPqLSSiWmJ5L4PfCGEW7PTa0vaRjgO-MHtFdAvlUfltWzm39nskyj0Q94QNNv5p7ZW7NAwBFwRL9IFzPzxi80IivvdLwgPZEHOKc6DLAmtrpTOgTkucOIwW_FF3FWbOJ4XgqT5dApqdon74ikq_8ZcwotqhVlkv2Z1VfiSLj4OBh9t35McLah4UszvAl8aYQlEkY6xrzoal8bWqpaJOMJs709-cd13NCv58WV45BbVMakphc_jk0XM7rXIhC45w",
"token_type": "Bearer",
"expires_in": 299
}
------------------------------------------------------------------
== 7. The access token
------------------------------------------------------------------
{
"alg": "RS256",
"kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb"
}
{
"aud": "orders-api",
"exp": 1787539015,
"iat": 1787538715,
"iss": "http://localhost:9000",
"jti": "b4179c46-e055-4b64-ba59-f15f92a92cd1",
"nbf": 1787538715,
"roles": [
"ADMIN",
"USER"
],
"scope": [
"orders.write",
"openid",
"orders.read"
],
"sub": "alice",
"tenant": "acme"
}
------------------------------------------------------------------
== 8. The id_token - a different token, for a different audience
------------------------------------------------------------------
{
"aud": "demo-web",
"auth_time": 1787538714,
"azp": "demo-web",
"exp": 1787540515,
"iat": 1787538715,
"iss": "http://localhost:9000",
"jti": "40fbc0a0-3254-41d0-83d5-2e90af71ff63",
"preferred_username": "alice",
"sid": "Y4fNof47nWhvQLJ0xgyQ_pOm7_TDqMp22mmjh429vJo",
"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":["orders.write","openid","orders.read"],"roles":["ADMIN","USER"],"tenant":"acme","audience":["orders-api"]}
GET /api/admin -> 200
{"authorities":["FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T02:31:55.262560965Z]","SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN","SCOPE_orders.write"],"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"
------------------------------------------------------------------
== 11. Refresh, with rotation
------------------------------------------------------------------
old refresh token: 8BsQgRSdo4MCHf2sZb9-paqL...
new refresh token: aPYpv9v2EcgMOzCvQdhMLycP...
DIFFERENT - reuseRefreshTokens(false), the old one is now dead
Replaying the old one:
{"error":"invalid_grant"}

View File

@@ -0,0 +1,67 @@
------------------------------------------------------------------
== POST /oauth2/token grant_type=client_credentials
------------------------------------------------------------------
$ curl -su demo-service:service-secret -d grant_type=client_credentials \
-d scope=orders.read http://localhost:9000/oauth2/token
{
"access_token": "eyJraWQiOiIyNDgwNWM5Ni02MGY1LTQ5MDItYTczYi03ODgxYmFkNWY5ZGMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkZW1vLXNlcnZpY2UiLCJhdWQiOiJkZW1vLXNlcnZpY2UiLCJuYmYiOjE3ODc1Mzg3MzksInNjb3BlIjpbIm9yZGVycy5yZWFkIl0sImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsImV4cCI6MTc4NzUzOTMzOSwiaWF0IjoxNzg3NTM4NzM5LCJqdGkiOiIzN2E0NTlmMS0xZjdlLTQ5ZTktOWQyNi01YmY0MjM5YmM1ZTkifQ.UxWq_Cs1bBhqQicgcLU7Z-vYW-jWxuVbrmco1gmXM8cxRvRjXoNpaDkYCiTI51gu21K9mXsOxf45l5dg7Rh9Gku7fNWJeKcWll5Ekcjpgq9msCwjLNPVxbuDVV8K-2f8OcPJ1Y6ojDtqxQq9RCBKEMxuBhl1Plz8nMjUYUm1A-njz43wL9SDJslz2xiIgoEkLkiRyVdk4ArzWGOQ4WLKR_y-bIn0dIhyTh4bVDy4rf2LTFqyPl_ZTCAH_ZUqXZuJhFn73MpxEaavwIHl-b8EDCpyk2vCUQnSykIMEaAbEPs0JBNaBpah-lPR0FFZIr2vZQxI4xpxButI98W2d9CtBQ",
"scope": "orders.read",
"token_type": "Bearer",
"expires_in": 599
}
------------------------------------------------------------------
== JOSE header
------------------------------------------------------------------
{
"alg": "RS256",
"kid": "24805c96-60f5-4902-a73b-7881bad5f9dc"
}
------------------------------------------------------------------
== Claims
------------------------------------------------------------------
{
"aud": "demo-service",
"exp": 1787539339,
"iat": 1787538739,
"iss": "http://localhost:9000",
"jti": "37a459f1-1f7e-49e9-9d26-5bf4239bc5e9",
"nbf": 1787538739,
"scope": [
"orders.read"
],
"sub": "demo-service"
}
------------------------------------------------------------------
== Wrong secret
------------------------------------------------------------------
$ curl -si -u demo-service:WRONG -d grant_type=client_credentials http://localhost:9000/oauth2/token
HTTP/1.1 401
{"error":"invalid_client"}
------------------------------------------------------------------
== A grant the client is not registered for
------------------------------------------------------------------
$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x http://localhost:9000/oauth2/token
HTTP/1.1 400
{"error":"invalid_grant"}
------------------------------------------------------------------
== A scope the client is not registered for
------------------------------------------------------------------
$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write http://localhost:9000/oauth2/token
{"error":"invalid_scope"}
------------------------------------------------------------------
== Calling the resource server with the token
------------------------------------------------------------------
GET /public -> 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"
GET /api/orders -> 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"
GET /api/admin -> 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"

View File

@@ -0,0 +1,68 @@
------------------------------------------------------------------
== POST /oauth2/token grant_type=client_credentials
------------------------------------------------------------------
$ curl -su demo-service:service-secret -d grant_type=client_credentials \
-d scope=orders.read http://localhost:9000/oauth2/token
{
"access_token": "DE0Ps3Jo6f6IdZ1Jd6bqBg_HnYTatIanVFutxdNihyAkHjlgMHWElxo4TGiWI9oNkxgdOAZZu6vgW9BXZrd_KkYVkKrV238fmHUCD9Xpz0_U3k55Brs9fXcMwxxTKGWa",
"scope": "orders.read",
"token_type": "Bearer",
"expires_in": 599
}
------------------------------------------------------------------
== Not a JWT
------------------------------------------------------------------
The access token is an opaque reference: DE0Ps3Jo6f6IdZ1Jd6bqBg_HnYTatIanVFutxdNihyAkHjlgMHWElxo4TGiWI9oNkxgdOAZZu6vgW9BXZrd_KkYVkKrV238fmHUCD9Xpz0_U3k55Brs9fXcMwxxTKGWa
Length 128. It carries no claims; the resource server must introspect it.
------------------------------------------------------------------
== POST /oauth2/introspect
------------------------------------------------------------------
{
"active": true,
"sub": "demo-service",
"aud": [
"demo-service"
],
"nbf": 1787538776,
"scope": "orders.read",
"iss": "http://localhost:9000",
"exp": 1787539376,
"iat": 1787538776,
"jti": "9d96a819-4f1e-4efb-8816-4523bb6def61",
"client_id": "demo-service",
"token_type": "Bearer"
}
------------------------------------------------------------------
== Wrong secret
------------------------------------------------------------------
$ curl -si -u demo-service:WRONG -d grant_type=client_credentials http://localhost:9000/oauth2/token
HTTP/1.1 401
{"error":"invalid_client"}
------------------------------------------------------------------
== A grant the client is not registered for
------------------------------------------------------------------
$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x http://localhost:9000/oauth2/token
HTTP/1.1 400
{"error":"invalid_grant"}
------------------------------------------------------------------
== A scope the client is not registered for
------------------------------------------------------------------
$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write http://localhost:9000/oauth2/token
{"error":"invalid_scope"}
------------------------------------------------------------------
== Calling the resource server with the token
------------------------------------------------------------------
GET /public -> 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
GET /api/orders -> 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
GET /api/admin -> 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"

View File

@@ -0,0 +1,66 @@
------------------------------------------------------------------
== POST /oauth2/token grant_type=client_credentials
------------------------------------------------------------------
$ curl -su demo-service:service-secret -d grant_type=client_credentials \
-d scope=orders.read http://localhost:9000/oauth2/token
{
"access_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkZW1vLXNlcnZpY2UiLCJhdWQiOiJvcmRlcnMtYXBpIiwibmJmIjoxNzg3NTM4NzEzLCJzY29wZSI6WyJvcmRlcnMucmVhZCJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkzMTMsImlhdCI6MTc4NzUzODcxMywianRpIjoiYWZkMDFkNTYtMTlhYS00MDk1LWJjYmYtZGQxMjlmNGNlMTRkIiwidGVuYW50IjoiYWNtZSJ9.P63TpKkWrcqxnCTVtTw3XRlmWRromcCmrynK1k1vWM9SoXzAvT7Pu03-JtwrH8b3-Js2QylXGiP2cah2HZHwNNNlft0zpIwosNtIWxSEVI4K5_M5IgAALgCqlwXs3rIFRvXGY5IyPXDJNkUslHO2OMQqdonHO8JL1cSLLe6MQKcKPS9jc-byqaLHgyYtAhO7acCmKSvzmP1kTN6cE33FtEOlCg_9HHB6hwphl5e2Sbacc8wPZU8pyGBD02QymvlH0LUMC-b2F0pnGka0os1pYL6cVI48irvKK6hhty-l7CNOfhjKJRaGwMHf4SAoFT2TBmzAKArNrJY2o_zganeZEg",
"scope": "orders.read",
"token_type": "Bearer",
"expires_in": 599
}
------------------------------------------------------------------
== JOSE header
------------------------------------------------------------------
{
"alg": "RS256",
"kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb"
}
------------------------------------------------------------------
== Claims
------------------------------------------------------------------
{
"aud": "orders-api",
"exp": 1787539313,
"iat": 1787538713,
"iss": "http://localhost:9000",
"jti": "afd01d56-19aa-4095-bcbf-dd129f4ce14d",
"nbf": 1787538713,
"scope": [
"orders.read"
],
"sub": "demo-service",
"tenant": "acme"
}
------------------------------------------------------------------
== Wrong secret
------------------------------------------------------------------
$ curl -si -u demo-service:WRONG -d grant_type=client_credentials http://localhost:9000/oauth2/token
HTTP/1.1 401
{"error":"invalid_client"}
------------------------------------------------------------------
== A grant the client is not registered for
------------------------------------------------------------------
$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x http://localhost:9000/oauth2/token
HTTP/1.1 400
{"error":"invalid_grant"}
------------------------------------------------------------------
== A scope the client is not registered for
------------------------------------------------------------------
$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write http://localhost:9000/oauth2/token
{"error":"invalid_scope"}
------------------------------------------------------------------
== Calling the resource server with the token
------------------------------------------------------------------
GET /public -> 200
{"message":"no token required"}
GET /api/orders -> 200
{"orders":[{"total":"42.00","id":1}],"subject":"demo-service","clientId":null,"scopes":["orders.read"],"roles":null,"tenant":"acme","audience":["orders-api"]}
GET /api/admin -> 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

View File

@@ -0,0 +1,35 @@
------------------------------------------------------------------
== The relying party drives the flow [confidential client, no PKCE - the Boot default]
------------------------------------------------------------------
GET http://127.0.0.1:8080/orders while unauthenticated. Every hop below is a real redirect.
302 http://127.0.0.1:8080/orders
302 http://127.0.0.1:8080/oauth2/authorization/demo-web
302 http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&scope=orders.write%20openid%20profile%20orders.read&state=ONw1L2XOM3BG7j5kgeISWex_263H9-sYOJeG5lcrGv8%3D&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web&nonce=VSauV4DIo1T-zZKYq8as0fEwQjqmhx462L3sCCBnAHk
302 http://127.0.0.1:8080/login/oauth2/code/demo-web?error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge&error_uri=https%3A%2F%2Fdatatracker.ietf.org%2Fdoc%2Fhtml%2Frfc7636%23section-4.4.1&state=ONw1L2XOM3BG7j5kgeISWex_263H9-sYOJeG5lcrGv8%3D
200 http://127.0.0.1:8080/login?error
The authorization request the client built:
http://localhost:9000/oauth2/authorize
response_type=code
client_id=demo-web
scope=orders.write%20openid%20profile%20orders.read
state=ONw1L2XOM3BG7j5kgeISWex_263H9-sYOJeG5lcrGv8%3D
redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web
nonce=VSauV4DIo1T-zZKYq8as0fEwQjqmhx462L3sCCBnAHk
>>> NO code_challenge - a client registered with
>>> requireProofKey(true) will reject this outright
The flow ended at the CLIENT's error page, not the provider's. The provider
rejected the authorization request and redirected the failure back to the
registered redirect_uri, so nothing in the client's logs names the provider
as the cause. The reason is only in the query string above.
------------------------------------------------------------------
== What the client rendered
------------------------------------------------------------------
Please sign in
Login with OAuth 2.0
Invalid credentials
http://localhost:9000

View File

@@ -0,0 +1,50 @@
------------------------------------------------------------------
== The relying party drives the flow [client sends PKCE]
------------------------------------------------------------------
GET http://127.0.0.1:8080/orders while unauthenticated. Every hop below is a real redirect.
302 http://127.0.0.1:8080/orders
302 http://127.0.0.1:8080/oauth2/authorization/demo-web
302 http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&scope=openid%20profile%20orders.read%20orders.write&state=Fp-5wpXOPVoiWsvQQ3CiBOPxfQW759f5XzpAjesCqzw%3D&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web&nonce=gTVVonl0f_lFOa4XBskyk0Rn2RwgiDLPbkni3UhL5Js&code_challenge=PkejOsdpC8l7T_ZRKp7LyJOmKrbAvZy8bw2OGAwZIlI&code_challenge_method=S256
200 http://localhost:9000/login
The authorization request the client built:
http://localhost:9000/oauth2/authorize
response_type=code
client_id=demo-web
scope=openid%20profile%20orders.read%20orders.write
state=Fp-5wpXOPVoiWsvQQ3CiBOPxfQW759f5XzpAjesCqzw%3D
redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web
nonce=gTVVonl0f_lFOa4XBskyk0Rn2RwgiDLPbkni3UhL5Js
code_challenge=PkejOsdpC8l7T_ZRKp7LyJOmKrbAvZy8bw2OGAwZIlI
code_challenge_method=S256
>>> code_challenge IS present
Landed on the authorization server's login page. Submitting credentials:
302 POST http://localhost:9000/login
Resuming the authorization request:
302 http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&scope=openid%20profile%20orders.read%20orders.write&state=Fp-5wpXOPVoiWsvQQ3CiBOPxfQW759f5XzpAjesCqzw%3D&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web&nonce=gTVVonl0f_lFOa4XBskyk0Rn2RwgiDLPbkni3UhL5Js&code_challenge=PkejOsdpC8l7T_ZRKp7LyJOmKrbAvZy8bw2OGAwZIlI&code_challenge_method=S256&continue
200 http://localhost:9000/oauth2/consent?scope=orders.write%20openid%20profile%20orders.read&client_id=demo-web&state=es7hi90inWtTT6_LtEb7zFjBGcmoAvOXR9mR4SW1Atw%3D
Consent page reached. Approving:
302 POST http://localhost:9000/oauth2/authorize
Back to the client with the code:
302 http://127.0.0.1:8080/login/oauth2/code/demo-web?code=u_QjA35AcYq3OlGDu6skLcZndAM8JAKlMxDiyMrCw-AFr0an52L6PNGVyfI6eYeM8gZogMNRnG0DeZGFKyeeVF74F82yeY47qI5GRmX1BYNnPYt7PiEl8MorixXZI89q&state=Fp-5wpXOPVoiWsvQQ3CiBOPxfQW759f5XzpAjesCqzw%3D
200 http://127.0.0.1:8080/orders?continue
------------------------------------------------------------------
== What the client rendered
------------------------------------------------------------------
Orders
body{font-family:system-ui,sans-serif;max-width:44rem;margin:3rem auto;color:#222}
pre{background:#f4f5f7;padding:1rem;border-radius:6px;overflow:auto;word-break:break-all;white-space:pre-wrap}
Resource server response
{orders=[{total=42.00, id=1}], subject=alice, clientId=null, scopes=[orders.write, openid, profile, orders.read], roles=[ADMIN, USER], tenant=acme, audience=[orders-api]}
Granted scopes
[orders.write, openid, profile, orders.read]
Access token (raw)
eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3MjMsInNjb3BlIjpbIm9yZGVycy53cml0ZSIsIm9wZW5pZCIsInByb2ZpbGUiLCJvcmRlcnMucmVhZCJdLCJyb2xlcyI6WyJBRE1JTiIsIlVTRVIiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDAwIiwiZXhwIjoxNzg3NTM5MDIzLCJpYXQiOjE3ODc1Mzg3MjMsImp0aSI6ImU2MGJkZWVhLTJhYmUtNDdhNC04YjI5LWQxYjhmYjA0MzBhMyIsInRlbmFudCI6ImFjbWUifQ.bR3y7idlv63l4zIyXQKu9pjfaHXlFWaaIVupi4eITuWaMFJMk4S-V96CyajRWzDEur7K0wBxvlRNieBWoLetZ2_sfKexbfYfrJjjSzcZvtvtWygA7prqBspDzIEKOlHfSckh0Y1d7c6hQhyCTU3Z5SuCi8_ZiFPxT1bHBxSnqtr4j3K5a4yc_6E93e3M5-VCkuMpKJiiDVxw4bAeqbegp8LOSxChLmmoz9sGJ9HeMLUrjjHvtQZ1ZFM8q9eAhA0KvDXukWFPxfq7UUfbDMqLNcQdHGcwUTAgwIklO6MsCfvtLQRUaN2KsX-FWjHnG49C_jLT1h5CRcSKdCnqKuxrAw
back

View File

@@ -0,0 +1,240 @@
------------------------------------------------------------------
== OpenID Connect discovery: GET /.well-known/openid-configuration
------------------------------------------------------------------
$ curl -s http://localhost:9000/.well-known/openid-configuration
{
"issuer": "http://localhost:9000",
"authorization_endpoint": "http://localhost:9000/oauth2/authorize",
"token_endpoint": "http://localhost:9000/oauth2/token",
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth"
],
"jwks_uri": "http://localhost:9000/oauth2/jwks",
"userinfo_endpoint": "http://localhost:9000/userinfo",
"end_session_endpoint": "http://localhost:9000/connect/logout",
"response_types_supported": [
"code"
],
"grant_types_supported": [
"authorization_code",
"client_credentials",
"refresh_token",
"urn:ietf:params:oauth:grant-type:token-exchange"
],
"revocation_endpoint": "http://localhost:9000/oauth2/revoke",
"revocation_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth"
],
"introspection_endpoint": "http://localhost:9000/oauth2/introspect",
"introspection_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth"
],
"code_challenge_methods_supported": [
"S256"
],
"tls_client_certificate_bound_access_tokens": true,
"dpop_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid"
]
}
------------------------------------------------------------------
== OAuth2 metadata: GET /.well-known/oauth-authorization-server
------------------------------------------------------------------
Present even with .oidc(...) switched off. The OIDC document above is the one
that additionally advertises userinfo_endpoint and id_token signing algorithms.
$ curl -s http://localhost:9000/.well-known/oauth-authorization-server
{
"issuer": "http://localhost:9000",
"authorization_endpoint": "http://localhost:9000/oauth2/authorize",
"token_endpoint": "http://localhost:9000/oauth2/token",
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth"
],
"jwks_uri": "http://localhost:9000/oauth2/jwks",
"response_types_supported": [
"code"
],
"grant_types_supported": [
"authorization_code",
"client_credentials",
"refresh_token",
"urn:ietf:params:oauth:grant-type:token-exchange"
],
"revocation_endpoint": "http://localhost:9000/oauth2/revoke",
"revocation_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth"
],
"introspection_endpoint": "http://localhost:9000/oauth2/introspect",
"introspection_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth"
],
"code_challenge_methods_supported": [
"S256"
],
"tls_client_certificate_bound_access_tokens": true,
"dpop_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512"
]
}
------------------------------------------------------------------
== JWK Set: GET /oauth2/jwks
------------------------------------------------------------------
Public keys only. No 'd' member - if you ever see one here, stop the server.
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb",
"n": "pFCIstpVnGQm7Mp8bskE_-_Rz-oI6mPaiyQLiWMBuVip4fkKYwapZVbsZf9fmy1w1KXWIdtXOhe-fqa3-KqYzsrje-o2u6_D27rqR1Z0I9ezbDfw2A4Gsj5AlmnXzWMLnNMzSueSf8luRk04MHO4bGzXLqQ1gOltnqMkMAQzhCqWFZmKxNJeaB4FlXOtxqzcso0eeKsPzRjZvTamgU5TvGUZmQ4LTKTkoYzB3kjvCspVpZAbdVR01RlmzfTJB0tVIc0ioMk1YZHUx27TPN1W8Nw1AaAYmV9URaf2fgz2Ja3y_Lj8hmXuQAcPOGBmoVWX0QkW4DTSAPW0xiPHqpjw9Q"
}
]
}
------------------------------------------------------------------
== Resolved endpoint settings, read back from AuthorizationServerSettings
------------------------------------------------------------------
{
"settings.authorization-server.client-registration-endpoint": "/oauth2/register",
"settings.authorization-server.authorization-endpoint": "/oauth2/authorize",
"settings.authorization-server.token-endpoint": "/oauth2/token",
"settings.authorization-server.device-verification-endpoint": "/oauth2/device_verification",
"settings.authorization-server.oidc-user-info-endpoint": "/userinfo",
"settings.authorization-server.pushed-authorization-request-endpoint": "/oauth2/par",
"settings.authorization-server.oidc-client-registration-endpoint": "/connect/register",
"settings.authorization-server.oidc-logout-endpoint": "/connect/logout",
"settings.authorization-server.issuer": "http://localhost:9000",
"settings.authorization-server.multiple-issuers-allowed": false,
"settings.authorization-server.device-authorization-endpoint": "/oauth2/device_authorization",
"settings.authorization-server.jwk-set-endpoint": "/oauth2/jwks",
"settings.authorization-server.token-revocation-endpoint": "/oauth2/revoke",
"settings.authorization-server.token-introspection-endpoint": "/oauth2/introspect"
}
------------------------------------------------------------------
== Registered clients, as the server actually holds them
------------------------------------------------------------------
[
{
"clientId": "demo-web",
"authenticationMethods": [
"client_secret_basic"
],
"grantTypes": [
"refresh_token",
"authorization_code"
],
"redirectUris": [
"http://127.0.0.1:8080/login/oauth2/code/demo-web"
],
"scopes": [
"orders.write",
"openid",
"profile",
"orders.read"
],
"requireProofKey": true,
"requireAuthorizationConsent": true,
"accessTokenFormat": "self-contained",
"accessTokenTtlSeconds": 300,
"reuseRefreshTokens": false
},
{
"clientId": "demo-spa",
"authenticationMethods": [
"none"
],
"grantTypes": [
"refresh_token",
"authorization_code"
],
"redirectUris": [
"http://127.0.0.1:8080/authorized"
],
"scopes": [
"openid",
"orders.read"
],
"requireProofKey": true,
"requireAuthorizationConsent": true,
"accessTokenFormat": "self-contained",
"accessTokenTtlSeconds": 300,
"reuseRefreshTokens": true
},
{
"clientId": "demo-service",
"authenticationMethods": [
"client_secret_basic"
],
"grantTypes": [
"client_credentials"
],
"redirectUris": [],
"scopes": [
"orders.read"
],
"requireProofKey": true,
"requireAuthorizationConsent": false,
"accessTokenFormat": "self-contained",
"accessTokenTtlSeconds": 600,
"reuseRefreshTokens": true
}
]

View File

@@ -0,0 +1,65 @@
------------------------------------------------------------------
== Public client, failed authentication at the token endpoint [acceptall profile: setIgnoredMediaTypes NOT called]
------------------------------------------------------------------
A public client authenticates at /oauth2/token by presenting a code_verifier.
With no verifier there is nothing to authenticate with, so the request falls
through to the AuthenticationEntryPoint - and which entry point runs depends on
the Accept header.
--- Accept: */* (curl's default, and most HTTP clients')
$ curl -H 'Accept: */*' -d grant_type=authorization_code -d code=bogus \
-d client_id=demo-spa http://localhost:9000/oauth2/token
HTTP/1.1 302
Location: http://localhost:9000/login
--- Accept: application/json
$ curl -H 'Accept: application/json' -d grant_type=authorization_code -d code=bogus \
-d client_id=demo-spa http://localhost:9000/oauth2/token
HTTP/1.1 401
--- Accept: text/html (a browser)
$ curl -H 'Accept: text/html' -d grant_type=authorization_code -d code=bogus \
-d client_id=demo-spa http://localhost:9000/oauth2/token
HTTP/1.1 302
Location: http://localhost:9000/login
------------------------------------------------------------------
== Confidential client with a wrong secret, for contrast
------------------------------------------------------------------
This never reaches the entry point: OAuth2ClientAuthenticationFilter writes the
error itself, so the Accept header makes no difference.
HTTP/1.1 401
------------------------------------------------------------------
== Public client, failed authentication at the token endpoint [default profile: setIgnoredMediaTypes(ALL) called]
------------------------------------------------------------------
A public client authenticates at /oauth2/token by presenting a code_verifier.
With no verifier there is nothing to authenticate with, so the request falls
through to the AuthenticationEntryPoint - and which entry point runs depends on
the Accept header.
--- Accept: */* (curl's default, and most HTTP clients')
$ curl -H 'Accept: */*' -d grant_type=authorization_code -d code=bogus \
-d client_id=demo-spa http://localhost:9000/oauth2/token
HTTP/1.1 401
--- Accept: application/json
$ curl -H 'Accept: application/json' -d grant_type=authorization_code -d code=bogus \
-d client_id=demo-spa http://localhost:9000/oauth2/token
HTTP/1.1 401
--- Accept: text/html (a browser)
$ curl -H 'Accept: text/html' -d grant_type=authorization_code -d code=bogus \
-d client_id=demo-spa http://localhost:9000/oauth2/token
HTTP/1.1 302
Location: http://localhost:9000/login
------------------------------------------------------------------
== Confidential client with a wrong secret, for contrast
------------------------------------------------------------------
This never reaches the entry point: OAuth2ClientAuthenticationFilter writes the
error itself, so the Accept header makes no difference.
HTTP/1.1 401

View File

@@ -0,0 +1,24 @@
# The SAS 1.x configuration, compiled against Spring Boot 4.1.1 / Spring Security 7.1.1.
# Source: src-broken/LegacySasConfig.java.txt
$ javac -cp <spring-boot-4.1.1 classpath> LegacySasConfig.java
./com/ankurm/authserver/legacy/LegacySasConfig.java:13: error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration does not exist
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
^
./com/ankurm/authserver/legacy/LegacySasConfig.java:14: error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers does not exist
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
^
./com/ankurm/authserver/legacy/LegacySasConfig.java:33: error: cannot find symbol
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
^
symbol: variable OAuth2AuthorizationServerConfiguration
location: class LegacySasConfig
./com/ankurm/authserver/legacy/LegacySasConfig.java:35: error: cannot find symbol
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
^
symbol: class OAuth2AuthorizationServerConfigurer
location: class LegacySasConfig
4 errors
javac exit status: 1

View File

@@ -0,0 +1,5 @@
# Starting the authorization server with a ConsentController that constructor-injects
# OAuth2AuthorizationConsentService, without declaring that bean.
# Spring Authorization Server 7.1.1 / Spring Boot 4.1.1.
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'consentController' defined in file [authorization-server/auth-server/target/classes/com/ankurm/authserver/web/ConsentController.class]: Unsatisfied dependency expressed through constructor parameter 1: No qualifying bean of type 'org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

View File

@@ -0,0 +1,15 @@
# From spring-security-oauth2-client-7.1.1.jar
#
# The resolver applies its default PKCE customizer only when the registration's
# client authentication method is NONE - that is, only for public clients.
# A registration that has a client secret gets no code_challenge.
private static final java.util.function.Consumer<org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest$Builder> DEFAULT_PKCE_APPLIER;
57: getstatic #209 // Field org/springframework/security/oauth2/core/ClientAuthenticationMethod.NONE:Lorg/springframework/security/oauth2/core/ClientAuthenticationMethod;
80: getstatic #230 // Field DEFAULT_PKCE_APPLIER:Ljava/util/function/Consumer;
31: invokestatic #427 // Method org/springframework/security/oauth2/client/web/OAuth2AuthorizationRequestCustomizers.withPkce:()Ljava/util/function/Consumer;
34: putstatic #230 // Field DEFAULT_PKCE_APPLIER:Ljava/util/function/Consumer;
# The fields and the opt-in setter:
private static final java.util.function.Consumer<org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest$Builder> DEFAULT_PKCE_APPLIER;
public void setAuthorizationRequestCustomizer(java.util.function.Consumer<org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest$Builder>);

View File

@@ -0,0 +1,11 @@
# resource-server started with spring.security.oauth2.resourceserver.jwt.issuer-uri
# pointing at an authorization server that is not running.
2026-08-24T08:03:18.544+05:30 WARN 3087 --- [resource-server] [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'api' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'api' threw exception with message: Error creating bean with name 'jwtDecoder' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'api' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'api' threw exception with message: Error creating bean with name 'jwtDecoder' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'api' threw exception with message: Error creating bean with name 'jwtDecoder' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtDecoder' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
java.lang.IllegalArgumentException: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:9000/.well-known/openid-configuration": Connection refused (connect failed)
java.net.ConnectException: Connection refused (connect failed)

View File

@@ -0,0 +1,32 @@
# Defaults of ClientSettings.builder().build() and TokenSettings.builder().build(),
# read out of the jars themselves rather than from documentation.
# Source: tools/SettingsDefaults.java
=== Spring Authorization Server 1.5.8 (last release of the standalone project) ===
requireProofKey = false
requireAuthorizationConsent= false
accessTokenTimeToLive = PT5M
accessTokenFormat = self-contained
refreshTokenTimeToLive = PT1H
reuseRefreshTokens = true
authorizationCodeTTL = PT5M
=== Spring Authorization Server 7.1.1 (inside Spring Security, Boot 4.1.1 BOM) ===
requireProofKey = true
requireAuthorizationConsent= false
accessTokenTimeToLive = PT5M
accessTokenFormat = self-contained
refreshTokenTimeToLive = PT1H
reuseRefreshTokens = true
authorizationCodeTTL = PT5M
# The same question on the CLIENT side. Source: tools/ClientPkceDefault.java
=== spring-security-oauth2-client 6.5.1 ===
ClientRegistration.ClientSettings.requireProofKey = false
=== spring-security-oauth2-client 7.1.1 (Boot 4.1.1 BOM) ===
ClientRegistration.ClientSettings.requireProofKey = true
# Both sides flipped in the 7.x line. Spring-to-Spring therefore still works;
# a 7.1 authorization server in front of a 6.x or hand-rolled client does not.

View File

@@ -0,0 +1,3 @@
[INFO] Running com.ankurm.authserver.ProviderContractTests
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.631 s -- in com.ankurm.authserver.ProviderContractTests
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0