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:
140
docs/output/as-authcode-pkce.txt
Normal file
140
docs/output/as-authcode-pkce.txt
Normal 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"
|
||||
Reference in New Issue
Block a user