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,5 @@
# The entry-point matcher is left as MediaTypeRequestMatcher(TEXT_HTML) with no ignored
# types. `Accept: */*` then matches text/html and the token endpoint redirects API callers
# to the HTML login page. See docs/authorization-server/09-entry-point.md.
demo:
ignore-accept-all: false

View File

@@ -0,0 +1,3 @@
# The OAuth2TokenCustomizer bean is not registered (see TokenClaimsCustomizer's @Profile).
# Diff a token from this profile against a default one to see exactly what the default
# access token does and does not carry.

View File

@@ -0,0 +1,4 @@
# Consent off. The authorization endpoint issues the code immediately after login.
# Correct for a first-party client you own; wrong the moment a third party registers.
demo:
require-consent: false

View File

@@ -0,0 +1,4 @@
# The public client no longer requires a code_verifier. The code exchange then succeeds
# with nothing but the authorization code - which is the whole attack PKCE prevents.
demo:
require-pkce: false

View File

@@ -0,0 +1,4 @@
# demo-service gets REFERENCE (opaque) access tokens instead of self-contained JWTs.
# The string is meaningless to a resource server without a call to /oauth2/introspect.
demo:
service-token-format: reference

View File

@@ -0,0 +1,5 @@
logging:
level:
org.springframework.security: TRACE
org.springframework.security.oauth2.server.authorization: TRACE
org.springframework.security.web.FilterChainProxy: DEBUG

View File

@@ -0,0 +1,19 @@
server:
port: 9000
spring:
application:
name: auth-server
thymeleaf:
cache: false
# Flags the RegisteredClientRepository reads. Every variant profile below flips exactly
# one of them, so the resulting failure is attributable to one line.
demo:
require-consent: true
require-pkce: true
service-token-format: jwt
logging:
level:
org.springframework.security: INFO

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<title>Approve access</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 4rem auto; color: #222; }
.card { border: 1px solid #e2e5ea; border-radius: 8px; padding: 1.5rem 1.75rem; }
.scope { display: block; margin: .4rem 0; }
code { background: #f4f5f7; padding: .1rem .3rem; border-radius: 3px; }
button { padding: .5rem 1rem; border-radius: 6px; border: 1px solid #b7bec9; cursor: pointer; }
.primary { background: #2f6fdb; color: #fff; border-color: #2f6fdb; }
.muted { color: #667; font-size: .9rem; }
</style>
</head>
<body>
<div class="card">
<h2>Approve access</h2>
<p>
Signed in as <b th:text="${principalName}">user</b>.
The application <code th:text="${clientId}">client</code> wants to act on your behalf.
</p>
<!--
The form POSTs to /oauth2/authorize, NOT to /oauth2/consent. The authorization
endpoint owns both halves of the exchange; the consent page is only a renderer.
-->
<form method="post" th:action="@{${requestURI}}">
<!-- Echoed back verbatim. Drop `state` and the endpoint cannot correlate this
approval with the pending authorization request, and you get a redirect loop. -->
<input type="hidden" name="client_id" th:value="${clientId}">
<input type="hidden" name="state" th:value="${state}">
<input type="hidden" name="user_code" th:value="${userCode}" th:if="${userCode}">
<p><b>This application will be able to:</b></p>
<label class="scope" th:each="s : ${scopes}">
<!-- One `scope` parameter per approved scope. A single space-joined value is
accepted by the parameter binder and then silently treated as one unknown
scope name, so consent appears to succeed and the token comes back short. -->
<input type="checkbox" name="scope" th:value="${s}" checked>
<code th:text="${s}">scope</code>
</label>
<div th:if="${!previouslyApprovedScopes.isEmpty()}">
<p class="muted">Already approved previously:</p>
<label class="scope" th:each="s : ${previouslyApprovedScopes}">
<input type="checkbox" disabled checked>
<code th:text="${s}">scope</code>
</label>
</div>
<p class="muted">
<code>openid</code> is requested implicitly and is not listed &mdash; the
authorization server never asks for consent on it.
</p>
<p>
<button class="primary" type="submit" id="approve">Approve</button>
</p>
</form>
<form method="post" th:action="@{${requestURI}}">
<input type="hidden" name="client_id" th:value="${clientId}">
<input type="hidden" name="state" th:value="${state}">
<!-- No `scope` parameters at all is how you say "denied". The endpoint then redirects
back to the client with error=access_denied. -->
<button type="submit" id="deny">Deny</button>
</form>
</div>
</body>
</html>