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,81 @@
[← index](README.md) · next: [02 — The minimum working provider](02-minimum-provider.md)
# Versions, artifacts and the 7.0 move
## There is no Spring Authorization Server version to pin
The brief for this project was “pin the SAS version from the Boot 4.1 BOM”. There
is nothing to pin. `spring-boot-dependencies:4.1.1` has no
`<spring-authorization-server.version>` property, because Spring Authorization Server is no
longer a separate project.
```
$ grep -oP '<spring-security\.version>[^<]+' spring-boot-dependencies-4.1.1.pom
<spring-security.version>7.1.1
$ curl -s .../spring-security-bom/7.1.1/spring-security-bom-7.1.1.pom | grep -A1 authorization-server
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>7.1.1</version>
```
The Maven coordinates are unchanged &mdash;
`org.springframework.security:spring-security-oauth2-authorization-server` &mdash; and the
version now tracks Spring Security. Spring Boot 4.1.1 therefore gives you **7.1.1**.
## The version numbers skipped
The published version list on Maven Central tells the story on its own:
```
… 1.5.6 1.5.7 1.5.8 2.0.0-M1 2.0.0-M2 7.0.0-M3 7.0.0-RC1 … 7.0.0 7.0.1 … 7.1.1 7.2.0-M1
```
`2.0.0` was started and abandoned. There is **no 2.x GA**, and anything that tells you to
upgrade to Spring Authorization Server 2 is describing a milestone that was renumbered.
The line jumps from 1.5.8 to 7.0.0 to align with Spring Security 7.0.
[Joe Grandja's announcement](https://spring.io/blog/2025/09/11/spring-authorization-server-moving-to-spring-security-7-0/)
(11 September 2025) says the migration impact is &ldquo;quite minimal&rdquo; with &ldquo;a
couple of minor package relocation changes&rdquo;. That is true in the sense that the
relocations are mechanical. It is optimistic in the sense that one of them is the class
every tutorial calls &mdash; see [02](02-minimum-provider.md).
## Which starter
Boot 4.1 publishes both of these, and they resolve the same four dependencies:
| artifact | status |
|---|---|
| `spring-boot-starter-oauth2-authorization-server` | deprecated |
| `spring-boot-starter-security-oauth2-authorization-server` | current |
That is not inference. It is in the deprecated starter's own published POM:
```xml
<description>Starter for using Spring Authorization Server features (deprecated in favor
of spring-boot-starter-security-oauth2-authorization-server)</description>
```
The same rename happened to the client and resource-server starters
(`spring-boot-starter-security-oauth2-client`,
`spring-boot-starter-security-oauth2-resource-server`), and there is a new
`spring-boot-starter-security-oauth2-authorization-server-test`. Boot 4 also renamed
`spring-boot-starter-web` to `spring-boot-starter-webmvc`; the authorization server starter
pulls the latter in transitively, so you do not need to declare a web starter at all.
## Exact versions this project was built and run against
| | |
|---|---|
| JDK | Temurin 25.0.4.1+1 (current LTS) |
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| Spring Security / Authorization Server | 7.1.1 |
| Maven | 3.9.11 |
## Related
- [Spring Security 7.1 JWT Authentication: The Complete Guide](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) and [`docs/11-spring-security-7-changes.md`](../11-spring-security-7-changes.md) &mdash; the rest of what moved in Spring Security 7
- [`docs/output/as-settings-defaults.txt`](../output/as-settings-defaults.txt) &mdash; defaults read out of the 1.5.8 and 7.1.1 jars side by side
Next: [02 &mdash; The minimum working provider](02-minimum-provider.md)

View File

@@ -0,0 +1,101 @@
[&larr; 01 Versions](01-versions.md) &middot; [index](README.md) &middot; next: [03 &mdash; Clients, PKCE and the defaults that moved](03-clients-and-pkce.md)
# The minimum working provider
## The two imports that break every tutorial
Two classes moved out of the Spring Authorization Server jar and into
`spring-security-config`:
| | 1.5.8 | 7.1.1 |
|---|---|---|
| `OAuth2AuthorizationServerConfiguration` | `o.s.s.oauth2.server.authorization.config.annotation.web.configuration` | `o.s.s.config.annotation.web.configuration` |
| `OAuth2AuthorizationServerConfigurer` | `o.s.s.oauth2.server.authorization.config.annotation.web.configurers` | `o.s.s.config.annotation.web.configurers.oauth2.server.authorization` |
And one method was deleted. `javap` on both jars:
```
# 1.5.8
public static void applyDefaultSecurity(HttpSecurity) throws Exception;
# 7.1.1
(absent)
```
`OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)` is the one-liner in
essentially every article and sample written before September 2025. It is gone.
[`src-broken/LegacySasConfig.java.txt`](../../authorization-server/src-broken/LegacySasConfig.java.txt)
is that configuration, kept out of the build.
[`scripts/compile-legacy.sh`](../../authorization-server/scripts/compile-legacy.sh) compiles
it against the real 7.1.1 classpath and commits the compiler's own words to
[`docs/output/as-legacy-compile-failure.txt`](../output/as-legacy-compile-failure.txt):
```
error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration does not exist
error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers does not exist
error: cannot find symbol
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
symbol: variable OAuth2AuthorizationServerConfiguration
4 errors
```
Four errors from nine lines of copied configuration.
## What replaces it
```java
OAuth2AuthorizationServerConfigurer authorizationServer =
new OAuth2AuthorizationServerConfigurer();
http
.securityMatcher(authorizationServer.getEndpointsMatcher())
.with(authorizationServer, server -> server
.oidc(Customizer.withDefaults())
.authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent")))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.exceptionHandling(...)
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
```
Source:
[`AuthorizationServerConfig.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java).
## Why two filter chains
The protocol chain carries `securityMatcher(getEndpointsMatcher())`, so it declines every
request that is not an OAuth2 or OIDC endpoint. Something has to serve the login form and
the consent page, and it needs a completely different authentication mechanism &mdash; a
browser session rather than a bearer token. That is
[`DefaultSecurityConfig`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java).
**Order is load-bearing.** The protocol chain must be `@Order(HIGHEST_PRECEDENCE)`. Swap
them and the catch-all form-login chain matches `/oauth2/token` first: a token request 302s
to `/login` and the token endpoint is never reached. That redirect is the fingerprint.
[`/diag/chains`](07-diagnostics.md) prints the live ordering.
## OIDC is not on by default
`.oidc(Customizer.withDefaults())` is one line and omitting it costs you `/userinfo`, the
`id_token`, and `/.well-known/openid-configuration`. You still get the OAuth2 metadata
document at `/.well-known/oauth-authorization-server` &mdash; the two are different
documents, and [`as-discovery.txt`](../output/as-discovery.txt) prints both.
## The bean that is not a bean
A custom consent page needs to read `OAuth2AuthorizationConsentService`. It is not exposed
as an injectable bean. The configurer creates one for its own use; a controller that
constructor-injects it fails the context at startup, and the real message is kept in
[`as-missing-consent-service.txt`](../output/as-missing-consent-service.txt):
```
No qualifying bean of type 'org.springframework.security.oauth2.server.authorization
.OAuth2AuthorizationConsentService' available: expected at least 1 bean which qualifies
as autowire candidate.
```
Declare `OAuth2AuthorizationService` and `OAuth2AuthorizationConsentService` yourself. That
also forces the storage decision into the open: the in-memory implementations mean a second
replica of the authorization server cannot complete a code exchange started on the first.
Next: [03 &mdash; Clients, PKCE and the defaults that moved](03-clients-and-pkce.md)

View File

@@ -0,0 +1,126 @@
[&larr; 02 Minimum provider](02-minimum-provider.md) &middot; [index](README.md) &middot; next: [04 &mdash; The consent page](04-consent-page.md)
# Clients, PKCE and the defaults that moved
A `RegisteredClient` is a policy, not a credential. It states which grants a caller may
use, which redirect URIs are acceptable, which scopes it may request, whether consent is
required, whether PKCE is mandatory, and how long the tokens live. Most &ldquo;works in
Postman, not in the browser&rdquo; reports are one of those fields.
Source:
[`RegisteredClientConfig.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java).
Three clients:
| client | authentication | grants | demonstrates |
|---|---|---|---|
| `demo-web` | `client_secret_basic` | code + refresh | consent, refresh rotation |
| `demo-spa` | `none` (public) | code + refresh | PKCE, and no refresh token |
| `demo-service` | `client_secret_basic` | client credentials | opaque vs JWT tokens |
## The default that flipped
`ClientSettings.builder().build()` run against both jars
([`tools/SettingsDefaults.java`](../../authorization-server/tools/SettingsDefaults.java),
output in [`as-settings-defaults.txt`](../output/as-settings-defaults.txt)):
```
=== Spring Authorization Server 1.5.8 ===
requireProofKey = false
=== Spring Authorization Server 7.1.1 ===
requireProofKey = true
```
**PKCE is now mandatory for every client you did not think about.** `demo-service` in this
project never touches `ClientSettings`, and `/diag/clients` reports
`"requireProofKey": true` for it. An authorization request with no `code_challenge` is
rejected at the authorization endpoint, before login:
```
302 http://127.0.0.1:8080/authorized
?error=invalid_request
&error_description=OAuth%202.0%20Parameter%3A%20code_challenge
&error_uri=…rfc7636%23section-4.4.1
```
That is [`as-authcode-pkce-enforced.txt`](../output/as-authcode-pkce-enforced.txt).
The client side moved in the same release. `ClientRegistration.ClientSettings.Builder`
initialises `requireProofKey` to `false` in Spring Security 6.5.1 and to `true` in 7.1.1
(same output file). So Spring-client-to-Spring-server keeps working; what breaks is a 7.1
server in front of a 6.x client, a non-Spring client, or a saved Postman collection. See
[08](08-client.md) for that failure end to end.
## `requireProofKey(false)` does not make PKCE optional for a public client
Two separate experiments, both in `docs/output`:
1. [`as-authcode-nopkce.txt`](../output/as-authcode-nopkce.txt) &mdash; `requireProofKey(false)`,
but the authorization request still carries a challenge. The token endpoint still demands
the verifier. Sending a challenge and then omitting the verifier is never accepted.
2. [`as-authcode-nochallenge.txt`](../output/as-authcode-nochallenge.txt) &mdash;
`requireProofKey(false)` and no challenge at all. The authorization endpoint issues a
code, and the token exchange then fails with **401 and an empty body**.
The second is the interesting one. `PublicClientAuthenticationProvider` delegates entirely
to `CodeVerifierAuthenticator` and raises `invalid_client` when there is nothing to verify:
```
private final CodeVerifierAuthenticator codeVerifierAuthenticator;
// String invalid_client
// String https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1
```
For a client registered with `ClientAuthenticationMethod.NONE`, the code verifier *is* the
client authentication. Turning `requireProofKey` off does not make PKCE optional; it makes
the client unable to authenticate. The setting relaxes the authorization endpoint only.
## Client secrets are hashed
```java
.clientSecret(encoder.encode("web-secret"))
```
Registering the bare string and then sending it produces `invalid_client` with no further
detail, because the server bcrypt-compares the presented secret against what it believes is
a hash. This is the most common first-hour failure and the error message is deliberately
unhelpful.
## Redirect URIs are exact
Scheme, host, port and path, byte for byte. No wildcards. A mismatch is rejected *before*
login and rendered by the authorization server rather than sent to the client &mdash; by
design, since redirecting to an unvalidated URI is the vulnerability.
## Public clients get no refresh token
`demo-spa` is registered with `AuthorizationGrantType.REFRESH_TOKEN` and the token response
contains no `refresh_token`. The gate is in `OAuth2RefreshTokenGenerator`, which returns
`null` when the authenticated client's method is `ClientAuthenticationMethod.NONE` &mdash;
not in `OAuth2AuthorizationCodeAuthenticationProvider`, which only checks that the client is
registered for the grant
([`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt)). `demo-web`, identically
registered but confidential, does get one
([`as-authcode-web.txt`](../output/as-authcode-web.txt)).
## Refresh rotation
`reuseRefreshTokens` defaults to `true` in both 1.5.8 and 7.1.1. `demo-web` sets it to
`false`, and the transcript shows the old token dying on first use:
```
old refresh token: 1BYxixPcmy4PLVmNvkTIo-00...
new refresh token: P-7KeaSx9alEBp5CFpDBt-c0...
DIFFERENT - reuseRefreshTokens(false), the old one is now dead
Replaying the old one:
{"error":"invalid_grant"}
```
## Related
- [`docs/12-issuer-and-audience.md`](../12-issuer-and-audience.md) &mdash; the same `iss`/`aud` questions from the resource server's side
- [`docs/05-hs256-vs-rs256.md`](../05-hs256-vs-rs256.md) &mdash; why the provider signs with RS256 here
Next: [04 &mdash; The consent page](04-consent-page.md)

View File

@@ -0,0 +1,77 @@
[&larr; 03 Clients and PKCE](03-clients-and-pkce.md) &middot; [index](README.md) &middot; next: [05 &mdash; Token customisation](05-token-customisation.md)
# The consent page
Wiring a custom consent page is one line:
```java
.authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent"))
```
The path is your own MVC controller, served by the *browser* chain, not the protocol chain.
Source:
[`ConsentController.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java)
and [`consent.html`](../../authorization-server/auth-server/src/main/resources/templates/consent.html).
## The form contract
The undocumented part is what the form has to send back. Getting any of it wrong produces a
redirect loop rather than an error.
| requirement | consequence of getting it wrong |
|---|---|
| POST to `/oauth2/authorize`, not to the consent path | 404 or a fresh authorization request |
| echo `state` **as the consent page received it** | redirect loop |
| echo `client_id` | `invalid_request` |
| one `scope` parameter per approved scope | consent appears to succeed, token comes back short |
| include the CSRF token | 403 |
| omit `openid` from the checkboxes | harmless, but unticking it does nothing |
## The `state` is not the client's `state`
This is the one that costs an afternoon. From
[`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt):
```
GET /oauth2/authorize?…&state=xyz123
-> 302 /oauth2/consent?scope=openid%20orders.read&client_id=demo-spa
&state=RXHrz8avEvUmNxYMLZoT0CyJS2E0t99pJtMJ5fyJBVM%3D
```
The client sent `state=xyz123`. The consent page is handed
`RXHrz8avEvUmNxYMLZoT0CyJS2E0t99pJtMJ5fyJBVM=` &mdash; the authorization server's own
correlation handle for the pending request. Echo the client's value instead and the endpoint
cannot find the pending authorization, so it starts a new one, which redirects to the
consent page again. The loop looks like a session problem and is not.
The client's `state` comes back at the end, untouched, in the redirect to the client:
```
-> 302 http://127.0.0.1:8080/authorized?code=B6iUSZ…&state=xyz123
```
## Approving and denying
Approve: POST with one `scope` parameter per approved scope.
Deny: POST with **no** `scope` parameters at all. The endpoint then redirects to the client
with `error=access_denied`.
## Consent is remembered
`OAuth2AuthorizationConsentService` stores what the user approved, keyed by client and
principal. A second authorization for scopes already approved skips the page entirely.
That is why `run-all.sh` restarts the authorization server between the two client-flow
runs &mdash; otherwise the second one silently takes the no-consent path and proves nothing.
The in-memory implementation loses all of it on restart, and is per-instance. Two replicas
of your authorization server will ask the same user twice.
## Turning consent off
The `noconsent` profile sets `requireAuthorizationConsent(false)`
([`as-authcode-noconsent.txt`](../output/as-authcode-noconsent.txt)). Correct for a
first-party client you own and ship together with the provider. Wrong the moment a third
party registers, because consent is the only point at which the user is told what they are
agreeing to.
Next: [05 &mdash; Token customisation](05-token-customisation.md)

View File

@@ -0,0 +1,106 @@
[&larr; 04 Consent page](04-consent-page.md) &middot; [index](README.md) &middot; next: [06 &mdash; The resource server side](06-resource-server.md)
# Token customisation
Source:
[`TokenClaimsCustomizer.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java).
## The bean is found by generic type, and nothing logs if it is not
One bean of type `OAuth2TokenCustomizer<JwtEncodingContext>` is picked up automatically by
the JWT generator. No annotation, no registration step.
Declare it as `OAuth2TokenCustomizer<OAuth2TokenClaimsContext>` &mdash; the type used for
*opaque* tokens &mdash; and it is silently ignored. The generator resolves the bean by
generic type and simply does not find it. Your claims are just absent, and nothing in the
logs says why.
## What the default access token actually contains
Diff two runs of the same flow, with and without the customiser
([`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt) vs
[`as-authcode-noclaims.txt`](../output/as-authcode-noclaims.txt)):
```
default (noclaims) with the customiser
{ {
"aud": "demo-spa", <--> "aud": "orders-api",
"roles": ["ADMIN", "USER"],
"tenant": "acme",
"exp": …, "exp": …,
"iat": …, "iat": …,
"iss": "http://localhost:9000", "iss": "http://localhost:9000",
"jti": …, "jti": …,
"nbf": …, "nbf": …,
"scope": ["openid","orders.read"], "scope": ["openid","orders.read"],
"sub": "alice" "sub": "alice"
} }
```
Two things worth noticing.
**`aud` defaults to the client id.** Not the API. There is no per-client audience setting on
`RegisteredClient`, so if your resource servers validate audience &mdash; and they should
&mdash; the token customiser is where you set it. A resource server that naively checks
`aud == "orders-api"` will reject every default-issued token.
**Roles are not there by default.** `scope` is, as `SCOPE_*` authorities. Anything else
about the user &mdash; roles, tenant, entitlements &mdash; you put there or you make a
network call per request.
## Guard on the grant type
`client_credentials` has no user. `context.getPrincipal()` is the client's own
authentication, and copying its authorities into a `roles` claim gives a machine token
whatever the client authentication happened to carry. The customiser here excludes that
grant explicitly.
## The id_token is a different token
```java
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) { }
```
The `id_token`'s audience is the **client**; the access token's is the **API**. From the
transcript:
```
access token "aud": "orders-api"
id_token "aud": "demo-spa", "azp": "demo-spa", "sid": "1ZK2c__DhcDY…"
```
Sending the `id_token` to a resource server is the classic mix-up. It verifies &mdash; same
issuer, same signing key &mdash; and then fails the audience check:
```
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", …
```
If nobody checks audience, it *passes*, and a token the client was allowed to read becomes
a token the API accepts. That is the argument for [06](06-resource-server.md).
Put authorisation data in the access token. Put profile data in the `id_token`. The
`id_token` is for the client to render a username; it is not a credential for your APIs.
## Self-contained versus reference tokens
`TokenSettings.accessTokenFormat` takes `SELF_CONTAINED` (a signed JWT, verified offline)
or `REFERENCE` (an opaque string). The `opaque` profile flips `demo-service` to the latter
([`as-client-credentials-opaque.txt`](../output/as-client-credentials-opaque.txt)):
```
The access token is an opaque reference: unf4kl7MSFlYyNpNqcVFcIT4Hbny…
Length 128. It carries no claims; the resource server must introspect it.
POST /oauth2/introspect
{ "active": true, "sub": "demo-service", "scope": "orders.read", … }
```
The trade is instant revocation for a network round trip on every API call. Note that the
introspection response reports `"aud": ["demo-service"]` &mdash; the customiser did not run,
because opaque tokens go through `OAuth2TokenClaimsContext`, not `JwtEncodingContext`.
Next: [06 &mdash; The resource server side](06-resource-server.md)

View File

@@ -0,0 +1,96 @@
[&larr; 05 Token customisation](05-token-customisation.md) &middot; [index](README.md) &middot; next: [07 &mdash; Diagnostics](07-diagnostics.md)
# The resource server side
Source:
[`SecurityConfig.java`](../../authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java).
The deeper treatment of this half lives in [`docs/12`&ndash;`18`](../12-issuer-and-audience.md);
this chapter is only what changes when the issuer is *yours*.
## One property, and what it buys
```yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9000
```
At startup, Spring fetches `/.well-known/openid-configuration`, reads `jwks_uri` from it,
and builds a decoder. You get signature verification, `exp`/`nbf`, and an `iss` check.
You do **not** get an audience check. See [05](05-token-customisation.md) for why that
matters when the default `aud` is the client id.
## The startup coupling nobody mentions
If the authorization server is not reachable, the resource server does not start
([`as-rs-startup-failure.txt`](../output/as-rs-startup-failure.txt)):
```
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
```
This is deliberate &mdash; fail fast rather than serve unauthenticated traffic &mdash; but
it means a provider outage during a rolling deploy takes every API with it. If that is not
acceptable, configure `jwk-set-uri` directly and validate `iss` yourself, which removes the
discovery call at the cost of pinning the endpoint.
## The issuer string must match exactly
`http://localhost:9000` and `http://localhost:9000/` are different values. A mismatch fails
at *validation* time with `The iss claim is not valid`, not at startup, so it looks like a
token problem rather than a configuration one.
## Mapping custom claims without deleting the scopes
The provider writes a `roles` claim. Mapping it is easy to get wrong in one specific way:
```java
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
var authorities = new ArrayList<GrantedAuthority>(scopes.convert(jwt)); // keep these
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
}
return authorities;
});
```
Returning a converter that only handles `roles` silently deletes every `SCOPE_*` authority,
which turns `hasAuthority("SCOPE_orders.read")` into a 403 on a perfectly valid token. The
same trap, in its properties-driven form, is
[`docs/14-authentication-converter.md`](../14-authentication-converter.md).
## What the authorities actually look like
From a real request ([`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt)):
```json
["SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN",
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T01:52:21.799Z]"]
```
`FactorGrantedAuthority` is new in Spring Security 7 &mdash; it records *how* the principal
authenticated, for multi-factor authorisation rules. It shows up in every authority list now.
Code that asserts on the exact contents of `getAuthorities()` will fail on upgrade.
## Protected resource metadata, also new
The `WWW-Authenticate` header now carries a `resource_metadata` parameter:
```
WWW-Authenticate: Bearer error="invalid_token", error_description="…",
resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
```
That is [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728), emitted by default. It
tells a client where to learn what this API expects. Harmless, but it is a new endpoint on
your resource server that you did not add.
Next: [07 &mdash; Diagnostics](07-diagnostics.md)

View File

@@ -0,0 +1,61 @@
[&larr; 06 Resource server](06-resource-server.md) &middot; [index](README.md) &middot; next: [08 &mdash; The relying party](08-client.md)
# Diagnostics
The interesting configuration in an authorization server is spread across three builders
and two filter chains, and the effective result is printed nowhere at startup. Reading the
beans back is faster than reasoning about them.
Source:
[`ProviderDiagnostics.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java).
**Delete it before shipping** &mdash; it exposes client ids, scopes, grant types and your
chain ordering to anyone who can reach `/diag`.
## `/diag/settings`
Every endpoint path the server resolved, including the ones you never configured. Useful
when a client insists your token endpoint is somewhere else.
## `/diag/clients`
The registered clients as the server actually holds them. This is where
`requireProofKey: true` on a client you never configured shows up
([`as-discovery.txt`](../output/as-discovery.txt)):
```json
{
"clientId": "demo-service",
"grantTypes": ["client_credentials"],
"requireProofKey": true,
"requireAuthorizationConsent": false,
"accessTokenFormat": "self-contained",
"accessTokenTtlSeconds": 600,
"reuseRefreshTokens": true
}
```
The client secret is deliberately not returned. It is a hash, and printing it invites
someone to try to use it as a secret.
## `/diag/chains`
The filter chains in the order Spring Security will consult them. If the authorization
server chain is not first, the token endpoint is unreachable, and this is where you see
that rather than inferring it from a 302 to `/login`. The equivalent for the resource-server
project is [`docs/02-filter-chain-and-ordering.md`](../02-filter-chain-and-ordering.md).
## The `trace` profile
```bash
./scripts/run.sh auth trace
```
Turns `org.springframework.security` up to TRACE. Verbose, but it is the only way to see
which `AuthenticationProvider` handled &mdash; or declined &mdash; a token request.
## Decoding a token without verifying it
`scripts/lib.sh` has `jwt_header` and `jwt_payload`, three lines of base64url each. Debug
only. Never make a decision on an unverified payload; that is the entire attack.
Next: [08 &mdash; The relying party](08-client.md)

View File

@@ -0,0 +1,124 @@
[&larr; 07 Diagnostics](07-diagnostics.md) &middot; [index](README.md) &middot; next: [09 &mdash; The entry point and the Accept header](09-entry-point.md)
# The relying party
Source:
[`ClientSecurityConfig.java`](../../authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java),
[`HomeController.java`](../../authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java),
[`PkceConfig.java`](../../authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java).
## The whole client side, in one method
```java
http
.authorizeHttpRequests(auth -> auth.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.oauth2Client(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"));
```
Plus one provider entry and one registration in `application.yaml`. Spring reads
`/.well-known/openid-configuration` at first use and fills in every endpoint from it.
## Run the client on 127.0.0.1, not localhost
The authorization server is on `localhost:9000` and the client on `127.0.0.1:8080`. Those
are different origins to a browser cookie jar. Put both on `localhost` and the two
`JSESSIONID` cookies collide &mdash; one app's session clobbers the other's &mdash; and you
get a login loop that looks like a Spring Security bug.
## Use the access token, not the id_token
```java
@GetMapping("/orders")
public String orders(@RegisteredOAuth2AuthorizedClient("demo-web") OAuth2AuthorizedClient client, )
```
`@RegisteredOAuth2AuthorizedClient` hands you the access token Spring already holds. Reading
a token out of the `OidcUser` gives you the *id_token* instead, which produces a 401 from a
resource server with a token that looks perfectly valid &mdash; because it is; it is just
the wrong one. See [05](05-token-customisation.md).
## The full flow, hop by hop
[`as-client-flow.txt`](../output/as-client-flow.txt) is the real thing, driven with curl so
every redirect is visible:
```
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?…&code_challenge=…&code_challenge_method=S256
200 http://localhost:9000/login
302 POST http://localhost:9000/login
302 http://localhost:9000/oauth2/authorize?…&continue
200 http://localhost:9000/oauth2/consent?…
302 POST http://localhost:9000/oauth2/authorize
302 http://127.0.0.1:8080/login/oauth2/code/demo-web?code=g51U-dZi…&state=…
200 http://127.0.0.1:8080/orders
```
Ten steps for one login. Ending with the resource server's answer rendered by the client:
```
{orders=[{id=1, total=42.00}], subject=alice, scopes=[orders.write, openid, profile,
orders.read], roles=[ADMIN, USER], tenant=acme, audience=[orders-api]}
```
## The client-side PKCE rule
`DefaultOAuth2AuthorizationRequestResolver.getBuilder(...)`, disassembled
([`as-pkce-applier.txt`](../output/as-pkce-applier.txt)), applies its PKCE customizer when
**either** the registration's authentication method is `NONE` **or**
`registration.getClientSettings().isRequireProofKey()`:
```
57: getstatic ClientAuthenticationMethod.NONE
64: invokevirtual ClientAuthenticationMethod.equals
67: ifne 80
71: invokevirtual ClientRegistration$ClientSettings.isRequireProofKey
77: ifeq 89
80: getstatic DEFAULT_PKCE_APPLIER
```
And `ClientRegistration.ClientSettings.Builder` initialises `requireProofKey` to `false` in
Spring Security 6.5.1 and to `true` in 7.1.1. So a confidential Spring client now sends
PKCE where it previously did not.
Note the consequence for configuration: setting an *authorization request customizer* can
turn PKCE on, but cannot turn it off, because the default applier runs inside `getBuilder`
independently of the customizer. To disable it you have to rebuild the `ClientRegistration`
with `requireProofKey(false)`, which is what the `nopkce` profile does.
## What a pre-7.0 client looks like against a 7.1 server
[`as-client-flow-nopkce.txt`](../output/as-client-flow-nopkce.txt):
```
302 http://127.0.0.1:8080/oauth2/authorization/demo-web
302 http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&…&nonce=…
>>> NO code_challenge
302 http://127.0.0.1:8080/login/oauth2/code/demo-web
?error=invalid_request
&error_description=OAuth%202.0%20Parameter%3A%20code_challenge
200 http://127.0.0.1:8080/login?error
```
The user never sees a login page. They land on the **client's** error page, and nothing in
the client's logs names the provider as the cause &mdash; the reason exists only in a query
string that the client discards. Fix it on either side: `requireProofKey(false)` on the
`RegisteredClient`, or `OAuth2AuthorizationRequestCustomizers.withPkce()` on the client.
Prefer the second.
## A dependency-cycle trap
A `@Bean` that takes `ClientRegistrationRepository` and returns one is a cycle, and Boot
refuses to start:
```
Relying upon circular references is discouraged and they are prohibited by default.
```
Post-process the repository Boot already built with a `static BeanPostProcessor` instead.
Next: [09 &mdash; The entry point and the Accept header](09-entry-point.md)

View File

@@ -0,0 +1,71 @@
[&larr; 08 The relying party](08-client.md) &middot; [index](README.md) &middot; next: [10 &mdash; Should you run one at all](10-should-you.md)
# The entry point and the Accept header
## The symptom
A failed token request answers `302 -> /login` instead of a JSON `401`. Your API client
follows the redirect, gets 200 and an HTML login page, and reports &ldquo;the token endpoint
returned HTML&rdquo;.
## The cause
The authorization server chain needs two behaviours from one entry point: send a *browser*
hitting `/oauth2/authorize` to the login page, and send a *machine* hitting `/oauth2/token`
a protocol error. The documented way to express that is:
```java
.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
```
On its own, that does not work. `MediaTypeRequestMatcher` treats `*/*` as matching
`text/html`, and `*/*` is what curl, most HTTP clients, and anything that does not set
`Accept` send. So the matcher fires for API callers too.
## The fix
```java
MediaTypeRequestMatcher matcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
```
## The difference, measured
[`as-entrypoint-accept.txt`](../output/as-entrypoint-accept.txt), same request three ways
against both configurations:
| `Accept` | without `setIgnoredMediaTypes` | with it |
|---|---|---|
| `*/*` | **302 &rarr; /login** | **401** |
| `application/json` | 401 | 401 |
| `text/html` | 302 &rarr; /login | 302 &rarr; /login |
The browser case is preserved either way. Only the `*/*` case changes, and that is the case
every API client falls into.
## Why only public clients hit it
A confidential client presenting a wrong secret never reaches the entry point at all:
`OAuth2ClientAuthenticationFilter` writes the error itself, so the `Accept` header makes no
difference and you get a clean 401. It is the *public* client &mdash; whose only
authentication mechanism is the code verifier &mdash; that falls through to the entry point
when there is nothing to authenticate with. Which means the bug is invisible until you add
your first SPA.
## The mirror image in the test suite
```java
this.mvc.perform(post("/oauth2/token")
.accept(MediaType.ALL)
.param("grant_type", "authorization_code")
.param("code", "bogus")
.param("client_id", "demo-spa"))
.andExpect(status().isUnauthorized());
```
Pinning it as a test matters because the fix is one line in an `exceptionHandling` lambda
and is exactly the kind of thing a later refactor drops.
Next: [10 &mdash; Should you run one at all](10-should-you.md)

View File

@@ -0,0 +1,49 @@
[&larr; 09 Entry point](09-entry-point.md) &middot; [index](README.md)
# Should you run one at all
Mostly: no.
## What the demo does not have
This project is roughly 700 lines and it is a demo. What it is missing is the actual work:
| missing | what production needs |
|---|---|
| Key management | keys generated per boot; restart invalidates every token. Real deployments need persistent keys, a rotating JWK Set serving current **and** previous public keys, and an HSM or KMS for the private half |
| Storage | `InMemoryOAuth2AuthorizationService` / `…ConsentService` / `…RegisteredClientRepository`. Two replicas cannot complete each other's code exchanges. The JDBC implementations exist and bring schema migrations with them |
| User management | two hard-coded users. No registration, password reset, lockout, MFA, or audit |
| Operations | no rate limiting on `/oauth2/token`, no metrics on grant failures, no alerting on a spike in `invalid_client` |
| Compliance | consent records are the artefact an auditor asks for. In-memory ones do not exist |
| Upgrades | you now own an OAuth2 implementation. The `requireProofKey` default change in [03](03-clients-and-pkce.md) is the kind of thing that will break your clients on a patch upgrade |
## When it is the right call
- **You need control an off-the-shelf product will not give you** &mdash; a bespoke consent
flow, a token shape a vendor cannot express, an unusual grant.
- **The identity source is already yours** and adding a second user store is worse than
running the protocol.
- **Air-gapped or heavily regulated deployment** where a hosted IdP is not permitted and a
commercial on-prem product is not affordable.
- **You want to understand the protocol.** This is a real reason. Running one for a week
teaches you more about OAuth2 than any amount of integrating with one.
## When to use something else
If you want an authorization server because you need &ldquo;login&rdquo;, use Keycloak, or
your cloud provider's identity service, or a hosted IdP. All of them do key rotation,
storage, user management, MFA and audit already, and the reason they look heavy is that
those things are heavy.
[`docs/17-keycloak-setup.md`](../17-keycloak-setup.md) in this repository sets up Keycloak
against the same resource server, so you can compare the two directly.
## The middle path
Run Spring Authorization Server as an **internal** provider for machine-to-machine traffic
&mdash; `client_credentials` only, no users, no consent, no browser flows &mdash; and use a
real IdP for humans. That configuration is a fraction of this one, has no session handling,
and removes most of the table above. It is the only version of &ldquo;write your own&rdquo;
that I would defend without qualification.
[&larr; back to the index](README.md)

View File

@@ -0,0 +1,64 @@
# Running your own OAuth2 / OIDC provider
Companion documentation for
[Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/)
on ankurm.com, and for the code in [`authorization-server/`](../../authorization-server).
Where the other two projects in this repository *consume* tokens, this one **mints** them.
[`docs/01`&ndash;`18`](../) cover a hand-written JWT filter and a resource server in front of
somebody else's issuer; the chapters here cover the issuer itself.
| | |
|---|---|
| JDK | Temurin **25.0.4.1+1** (current LTS) |
| Spring Boot | **4.1.1** |
| Spring Framework | **7.0.9** |
| Spring Security | **7.1.1** |
| Spring Authorization Server | **7.1.1** &mdash; the same artifact, now versioned with Spring Security |
| Maven | 3.9.11 |
Everything in [`docs/output/as-*.txt`](../output) is real program output, regenerated by
[`authorization-server/scripts/run-all.sh`](../../authorization-server/scripts/run-all.sh).
## Chapters
| # | chapter | what it settles |
|---|---|---|
| 01 | [Versions, artifacts and the 7.0 move](01-versions.md) | why there is no SAS version to pin any more, and which starter to use |
| 02 | [The minimum working provider](02-minimum-provider.md) | two filter chains, and the API that replaced `applyDefaultSecurity` |
| 03 | [Clients, PKCE and the defaults that moved](03-clients-and-pkce.md) | `requireProofKey` flipped to `true` on both sides |
| 04 | [The consent page](04-consent-page.md) | the form contract, and the redirect loop you get for breaking it |
| 05 | [Token customisation](05-token-customisation.md) | the bean the JWT generator looks for, and the one it ignores |
| 06 | [The resource server side](06-resource-server.md) | what `issuer-uri` does and does not validate |
| 07 | [Diagnostics](07-diagnostics.md) | reading the effective configuration back out of the running server |
| 08 | [The relying party](08-client.md) | driving a real browser flow, and the client-side PKCE default |
| 09 | [The entry point and the Accept header](09-entry-point.md) | why the token endpoint 302s to a login page |
| 10 | [Should you run one at all](10-should-you.md) | the honest answer, and what you are signing up for |
## Captured output
| file | produced by |
|---|---|
| [`as-settings-defaults.txt`](../output/as-settings-defaults.txt) | `scripts/settings-defaults.sh` |
| [`as-legacy-compile-failure.txt`](../output/as-legacy-compile-failure.txt) | `scripts/compile-legacy.sh` |
| [`as-missing-consent-service.txt`](../output/as-missing-consent-service.txt) | a real startup failure, kept |
| [`as-discovery.txt`](../output/as-discovery.txt) | `scripts/discovery.sh` |
| [`as-client-credentials.txt`](../output/as-client-credentials.txt) | `scripts/client-credentials.sh` |
| [`as-client-credentials-noclaims.txt`](../output/as-client-credentials-noclaims.txt) | same, `noclaims` profile |
| [`as-client-credentials-opaque.txt`](../output/as-client-credentials-opaque.txt) | same, `opaque` profile |
| [`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt) | `scripts/authcode-pkce.sh`, public client |
| [`as-authcode-web.txt`](../output/as-authcode-web.txt) | same, confidential client |
| [`as-authcode-noclaims.txt`](../output/as-authcode-noclaims.txt) | same, `noclaims` profile |
| [`as-authcode-noconsent.txt`](../output/as-authcode-noconsent.txt) | same, `noconsent` profile |
| [`as-authcode-nopkce.txt`](../output/as-authcode-nopkce.txt) | same, `nopkce` profile, challenge still sent |
| [`as-authcode-nochallenge.txt`](../output/as-authcode-nochallenge.txt) | same, `nopkce` profile, no challenge at all |
| [`as-authcode-pkce-enforced.txt`](../output/as-authcode-pkce-enforced.txt) | same, defaults, no challenge &mdash; rejected |
| [`as-pkce-applier.txt`](../output/as-pkce-applier.txt) | `scripts/pkce-applier.sh` |
| [`as-client-flow.txt`](../output/as-client-flow.txt) | `scripts/client-flow.sh` |
| [`as-client-flow-nopkce.txt`](../output/as-client-flow-nopkce.txt) | same, pre-7.0 client |
| [`as-entrypoint-accept.txt`](../output/as-entrypoint-accept.txt) | `scripts/entrypoint-accept.sh` |
| [`as-audience.txt`](../output/as-audience.txt) | `scripts/audience.sh` |
| [`as-rs-startup-failure.txt`](../output/as-rs-startup-failure.txt) | `scripts/rs-startup-failure.sh` |
| [`as-test-run.txt`](../output/as-test-run.txt) | `mvn -pl auth-server test` |
Next: [01 &mdash; Versions, artifacts and the 7.0 move](01-versions.md)