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

140
README.md
View File

@@ -1,15 +1,21 @@
# jwt-auth-demo # spring-auth-demo
Runnable companion code for two articles on [ankurm.com](https://ankurm.com): Runnable companion code for three articles on [ankurm.com](https://ankurm.com):
| | article | code | | | article | code |
|---|---|---| |---|---|---|
| 1 | [Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) | [`jwt-authentication/`](jwt-authentication) | | 1 | [Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) | [`jwt-authentication/`](jwt-authentication) |
| 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) | | 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) |
| 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) |
Two Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own Three Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
tokens with a hand-written filter; the second verifies tokens minted by somebody else — tokens with a hand-written filter. The second verifies tokens minted by somebody else —
a real Keycloak, and a stub issuer whose signing keys can be rotated on command. a real Keycloak, and a stub issuer whose signing keys can be rotated on command. The third
*is* the somebody else: a real OAuth2 / OIDC provider, with a client and a resource server
in front of it.
> This repository was called `jwt-auth-demo` until the third project landed. Gitea keeps the
> old URL redirecting, but please update any bookmarks to `spring-auth-demo`.
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output) is Everything here was compiled and executed. Every file under [`docs/output/`](docs/output) is
real program output, regenerated by a script — not transcribed by hand. real program output, regenerated by a script — not transcribed by hand.
@@ -23,6 +29,7 @@ real program output, regenerated by a script — not transcribed by hand.
| Nimbus JOSE+JWT | **10.9.1** | | Nimbus JOSE+JWT | **10.9.1** |
| Tomcat | **11.0.24** | | Tomcat | **11.0.24** |
| Jackson | **3.1.5** (`tools.jackson`) | | Jackson | **3.1.5** (`tools.jackson`) |
| Spring Authorization Server | **7.1.1** — the same artifact, now versioned with Spring Security |
| Keycloak | **26.7.2** (resource server project only) | | Keycloak | **26.7.2** (resource server project only) |
| Caffeine | **3.2.4** (resource server project only) | | Caffeine | **3.2.4** (resource server project only) |
@@ -33,8 +40,8 @@ real program output, regenerated by a script — not transcribed by hand.
### Project 1 — JWT authentication with a hand-written filter ### Project 1 — JWT authentication with a hand-written filter
```bash ```bash
git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git git clone https://ankurm.com/git.app/asmhatre/spring-auth-demo.git
cd jwt-auth-demo/jwt-authentication cd spring-auth-demo/jwt-authentication
./scripts/run.sh hs256 # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256 ./scripts/run.sh hs256 # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256
# in another shell # in another shell
@@ -44,7 +51,7 @@ cd jwt-auth-demo/jwt-authentication
### Project 2 — OAuth2 resource server, JWKS and rotation ### Project 2 — OAuth2 resource server, JWKS and rotation
```bash ```bash
cd jwt-auth-demo/oauth2-resource-server cd spring-auth-demo/oauth2-resource-server
# a stub issuer whose JWK Set can be mutated on command # a stub issuer whose JWK Set can be mutated on command
./scripts/run-stub-issuer.sh ./scripts/run-stub-issuer.sh
@@ -57,6 +64,20 @@ docker compose -f docker/compose.yaml up -d
./scripts/keycloak-demo.sh ./scripts/keycloak-demo.sh
``` ```
### Project 3 — your own OAuth2 / OIDC provider
```bash
cd spring-auth-demo/authorization-server
./scripts/run.sh auth # the provider, :9000
./scripts/run.sh rs # an API that trusts it, :8090
./scripts/run.sh client # a relying party, :8080
# then open http://127.0.0.1:8080/orders and log in as alice / password
# or drive the whole thing with curl:
./scripts/authcode-pkce.sh
```
--- ---
## Project 1 — `jwt-authentication/` ## Project 1 — `jwt-authentication/`
@@ -167,9 +188,70 @@ Regenerate its captured output with `./oauth2-resource-server/scripts/run-all.sh
--- ---
## Project 3 — `authorization-server/`
Three Maven modules, three JVMs, three ports. Nothing about an authorization server is
observable without a client to drive the browser redirect and a resource server to accept
or reject what comes out.
| module | port | what it is |
|---|---|---|
| [`auth-server/`](authorization-server/auth-server) | 9000 | the provider: clients, PKCE, consent, token customisation |
| [`resource-server/`](authorization-server/resource-server) | 8090 | an API that trusts its tokens |
| [`oidc-client/`](authorization-server/oidc-client) | 8080 | a relying party that logs in and calls the API |
Two users: `alice` / `password` (`ROLE_USER`, `ROLE_ADMIN`) and `bob` / `password`
(`ROLE_USER`).
Three registered clients:
| client | secret | authentication | grants |
|---|---|---|---|
| `demo-web` | `web-secret` | `client_secret_basic` | authorization code + refresh |
| `demo-spa` | — | `none` (public) | authorization code + refresh |
| `demo-service` | `service-secret` | `client_secret_basic` | client credentials |
### Profiles
| module | profile | what it changes |
|---|---|---|
| auth-server | *(none)* | consent on, PKCE required, custom claims, JWT tokens |
| auth-server | `noconsent` | `requireAuthorizationConsent(false)` on every client |
| auth-server | `nopkce` | `requireProofKey(false)` on the public client |
| auth-server | `noclaims` | the `OAuth2TokenCustomizer` bean is not registered |
| auth-server | `opaque` | `demo-service` gets reference tokens instead of JWTs |
| auth-server | `acceptall` | the entry-point matcher without `setIgnoredMediaTypes` — see [docs/authorization-server/09](docs/authorization-server/09-entry-point.md) |
| auth-server | `trace` | `TRACE` logging for `org.springframework.security` |
| resource-server | `noaud` | audience validation off, i.e. the Spring Boot default |
| oidc-client | `nopkce` | rebuilds the registration the way Spring Security 6.x would |
### Endpoints
| method | path | port | what it is |
|---|---|---|---|
| `GET` | `/.well-known/openid-configuration` | 9000 | OIDC discovery. Only present because `.oidc(...)` is on |
| `GET` | `/.well-known/oauth-authorization-server` | 9000 | the OAuth2 metadata document, always present |
| `GET` | `/oauth2/jwks` | 9000 | public keys |
| `GET` | `/oauth2/authorize` | 9000 | the authorization endpoint |
| `POST` | `/oauth2/token` | 9000 | the token endpoint |
| `POST` | `/oauth2/introspect` | 9000 | for opaque tokens |
| `GET` | `/oauth2/consent` | 9000 | **our** consent page |
| `GET` | `/userinfo` | 9000 | OIDC UserInfo |
| `GET` | `/diag/settings`, `/diag/clients`, `/diag/chains` | 9000 | **diagnostics. Delete before shipping** |
| `GET` | `/api/orders` | 8090 | needs `SCOPE_orders.read` |
| `POST` | `/api/orders` | 8090 | needs `SCOPE_orders.write` |
| `GET` | `/api/admin` | 8090 | needs `ROLE_ADMIN`, which only exists via the token customiser |
| `GET` | `/whoami` | 8090 | everything the resource server decoded |
| `GET` | `/orders` | 8080 | the relying party's page; triggers the whole flow |
Regenerate its captured output with `./authorization-server/scripts/run-all.sh`
(no Docker needed; a few minutes).
---
## Documentation ## Documentation
One numbered trail across both projects. Start at One numbered trail across the first two projects, plus a separate set for the third. Start at
[`docs/01-architecture.md`](docs/01-architecture.md). [`docs/01-architecture.md`](docs/01-architecture.md).
| doc | covers | | doc | covers |
@@ -193,6 +275,24 @@ One numbered trail across both projects. Start at
| [17 — Keycloak setup](docs/17-keycloak-setup.md) | compose, realm import, and three ways it bites | | [17 — Keycloak setup](docs/17-keycloak-setup.md) | compose, realm import, and three ways it bites |
| [18 — Resource server checklist](docs/18-resource-server-checklist.md) | the list for the resource-server side | | [18 — Resource server checklist](docs/18-resource-server-checklist.md) | the list for the resource-server side |
### Project 3 — running your own provider
A separate chapter set, indexed at
[`docs/authorization-server/`](docs/authorization-server/README.md).
| doc | covers |
|---|---|
| [01 — Versions and the 7.0 move](docs/authorization-server/01-versions.md) | why there is no SAS version to pin, and which starter to use |
| [02 — The minimum working provider](docs/authorization-server/02-minimum-provider.md) | two chains, and the API that replaced `applyDefaultSecurity` |
| [03 — Clients, PKCE and the defaults that moved](docs/authorization-server/03-clients-and-pkce.md) | `requireProofKey` flipped to `true` on both sides |
| [04 — The consent page](docs/authorization-server/04-consent-page.md) | the form contract, and the redirect loop |
| [05 — Token customisation](docs/authorization-server/05-token-customisation.md) | the bean the JWT generator looks for, and the one it ignores |
| [06 — The resource server side](docs/authorization-server/06-resource-server.md) | what `issuer-uri` does and does not validate |
| [07 — Diagnostics](docs/authorization-server/07-diagnostics.md) | reading the effective configuration back out |
| [08 — The relying party](docs/authorization-server/08-client.md) | a real browser flow, and the client-side PKCE default |
| [09 — Entry point and the Accept header](docs/authorization-server/09-entry-point.md) | why the token endpoint 302s to a login page |
| [10 — Should you run one at all](docs/authorization-server/10-should-you.md) | the honest answer |
--- ---
## Captured output ## Captured output
@@ -229,6 +329,22 @@ One numbered trail across both projects. Start at
| [`rs-keycloak-default-converter.txt`](docs/output/rs-keycloak-default-converter.txt) | real Keycloak, roles unmapped | | [`rs-keycloak-default-converter.txt`](docs/output/rs-keycloak-default-converter.txt) | real Keycloak, roles unmapped |
| [`rs-test-run.txt`](docs/output/rs-test-run.txt) | 10 tests pinning the default validator stack | | [`rs-test-run.txt`](docs/output/rs-test-run.txt) | 10 tests pinning the default validator stack |
### Project 3
Indexed in full at
[`docs/authorization-server/README.md`](docs/authorization-server/README.md). The ones worth
opening first:
| file | what it shows |
|---|---|
| [`as-settings-defaults.txt`](docs/output/as-settings-defaults.txt) | `requireProofKey` false in SAS 1.5.8 and Spring Security 6.5.1, true in 7.1.1 — both sides |
| [`as-legacy-compile-failure.txt`](docs/output/as-legacy-compile-failure.txt) | the pre-7.0 configuration, and the four compiler errors it now produces |
| [`as-authcode-pkce.txt`](docs/output/as-authcode-pkce.txt) | the whole authorization-code + PKCE flow, every parameter visible |
| [`as-client-flow-nopkce.txt`](docs/output/as-client-flow-nopkce.txt) | a pre-7.0 client against a 7.1 provider, failing on the client's own error page |
| [`as-entrypoint-accept.txt`](docs/output/as-entrypoint-accept.txt) | 302 vs 401 from the token endpoint, decided by the `Accept` header |
| [`as-client-credentials-opaque.txt`](docs/output/as-client-credentials-opaque.txt) | a reference token, and what introspection returns for it |
| [`as-test-run.txt`](docs/output/as-test-run.txt) | 7 contract tests |
--- ---
## Security note ## Security note
@@ -244,6 +360,12 @@ them at anything you care about — see
`/api/public/decoder` reads private fields by reflection and prints your JWK Set URI and `/api/public/decoder` reads private fields by reflection and prints your JWK Set URI and
cache timings. It is a diagnostic. Delete it before you ship. cache timings. It is a diagnostic. Delete it before you ship.
The authorization server's `/diag/*` endpoints are the same kind of thing: they publish
client ids, grant types, scopes and your filter-chain ordering with no authentication. Its
signing key is generated fresh on every boot, and its users are hard-coded. Read
[docs/authorization-server/10-should-you.md](docs/authorization-server/10-should-you.md)
before taking any of it near production.
## License ## License
MIT. MIT.

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>authorization-server-demo</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>auth-server</artifactId>
<name>auth-server</name>
<description>The OAuth2 / OIDC provider itself</description>
<dependencies>
<!--
Boot 4.1 ships TWO starters that pull exactly the same four dependencies:
spring-boot-starter-oauth2-authorization-server (deprecated)
spring-boot-starter-security-oauth2-authorization-server (this one)
The deprecated one still resolves and still works; its own POM description reads
"deprecated in favor of spring-boot-starter-security-oauth2-authorization-server".
Both drag in spring-boot-starter-webmvc, so you do not need spring-boot-starter-web
as well. See docs/authorization-server/01-versions.md.
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Boot 4 split the test slices out of spring-boot-starter-test; @AutoConfigureMockMvc
lives in spring-boot-starter-webmvc-test now. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,28 @@
package com.ankurm.authserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The provider. Runs on :9000 and is the issuer for everything else in this module.
*
* <p>Start it with {@code ../scripts/run.sh auth} (default profile) or with one of the
* variant profiles that deliberately break something:
*
* <ul>
* <li>{@code noconsent} &mdash; consent turned off for the confidential client</li>
* <li>{@code nopkce} &mdash; the public client no longer requires PKCE</li>
* <li>{@code noclaims} &mdash; the token customiser is not registered</li>
* <li>{@code opaque} &mdash; the service client gets reference tokens, not JWTs</li>
* </ul>
*
* @see <a href="../../../../../../../../docs/authorization-server/02-minimum-provider.md">
* docs/authorization-server/02-minimum-provider.md</a>
*/
@SpringBootApplication
public class AuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServerApplication.class, args);
}
}

View File

@@ -0,0 +1,206 @@
package com.ankurm.authserver.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Set;
import java.util.UUID;
/**
* The protocol filter chain &mdash; the one that owns {@code /oauth2/**} and the OIDC
* endpoints, and nothing else.
*
* <h2>The two import lines that break every tutorial</h2>
*
* Up to Spring Authorization Server 1.5.x these two classes lived in the
* {@code spring-security-oauth2-authorization-server} jar:
*
* <pre>
* org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
* org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer
* </pre>
*
* As of 7.0 they live in {@code spring-security-config}, under different packages:
*
* <pre>
* org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
* org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer
* </pre>
*
* And {@code OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)} &mdash; the
* static method essentially every blog post calls &mdash; no longer exists. It was public
* and static in 1.5.8; in 7.1.1 the class exposes only {@code @Bean} methods. The
* replacement is {@code http.with(new OAuth2AuthorizationServerConfigurer(), ...)}, which
* is what this class does.
*
* @see <a href="../../../../../../../../../docs/authorization-server/01-versions.md">
* docs/authorization-server/01-versions.md</a>
* @see <a href="../../../../../../../../../docs/authorization-server/02-minimum-provider.md">
* docs/authorization-server/02-minimum-provider.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
/**
* Order matters and the ordering is not cosmetic. This chain carries a
* {@code securityMatcher} restricted to the protocol endpoints, so it must be
* consulted before the catch-all form-login chain in {@link DefaultSecurityConfig}.
* If the two are swapped, the form-login chain matches {@code /oauth2/token} first,
* the token endpoint is never reached, and a token request 302s to {@code /login}.
* That redirect is the fingerprint of a mis-ordered pair of chains.
*/
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerChain(
HttpSecurity http,
@Value("${demo.ignore-accept-all:true}") boolean ignoreAcceptAll) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServer =
new OAuth2AuthorizationServerConfigurer();
http
.securityMatcher(authorizationServer.getEndpointsMatcher())
.with(authorizationServer, server -> server
// Turning OIDC on is one line, and it is not on by default: without it
// there is no /userinfo, no id_token, and no
// /.well-known/openid-configuration - only the OAuth2 metadata document
// at /.well-known/oauth-authorization-server.
.oidc(Customizer.withDefaults())
// Replace the built-in consent page with our own. The value is a path the
// authorization endpoint redirects the browser to; it is *our* MVC
// controller, protected by the form-login chain, and it must POST back to
// /oauth2/authorize. See ConsentController.
.authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent"))
)
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// An unauthenticated GET /oauth2/authorize must send a *browser* to the login
// page, and must not do that to a machine calling /oauth2/token.
//
// Scoping the entry point with MediaTypeRequestMatcher(TEXT_HTML) is the
// documented way to express that, and on its own it does not work. curl,
// most HTTP clients and anything that does not set Accept send `*/*`, and
// `*/*` matches text/html, so the matcher fires and the token endpoint
// answers a failed public-client authentication with `302 -> /login`
// instead of a JSON 401. setIgnoredMediaTypes(ALL) is the line that fixes
// it. Run with the `acceptall` profile to see the unfixed behaviour, and
// compare docs/output/as-entrypoint-accept.txt.
.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
htmlOnly(ignoreAcceptAll)))
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
return http.build();
}
private static MediaTypeRequestMatcher htmlOnly(boolean ignoreAcceptAll) {
MediaTypeRequestMatcher matcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
if (ignoreAcceptAll) {
matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
}
return matcher;
}
/**
* These two look optional and are not, the moment you write a custom consent page.
*
* <p>{@code OAuth2AuthorizationServerConfigurer} creates in-memory implementations for
* its own use when you do not supply them, but they are not published as beans you can
* inject. A {@code ConsentController} that constructor-injects
* {@code OAuth2AuthorizationConsentService} therefore fails the context at startup with
* {@code No qualifying bean of type ...OAuth2AuthorizationConsentService available} -
* the exact message is committed in
* {@code docs/output/as-missing-consent-service.txt}. Declaring them here fixes it and,
* more usefully, makes the storage decision explicit: both of these are per-instance
* memory, so a second replica of the authorization server cannot complete a code
* exchange started on the first.
*/
@Bean
public OAuth2AuthorizationService authorizationService() {
return new InMemoryOAuth2AuthorizationService();
}
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService() {
return new InMemoryOAuth2AuthorizationConsentService();
}
/**
* The issuer identifier. Everything downstream keys off this string: it is the
* {@code iss} claim, the base of the discovery document, and the value a resource
* server compares against. {@code http://localhost:9000} works only because both
* sides agree on it exactly &mdash; a trailing slash here and no trailing slash on
* the resource server is a mismatch, and produces
* {@code The iss claim is not valid} at validation time, not at startup.
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("http://localhost:9000")
.build();
}
/**
* A fresh RSA keypair per boot. That is deliberate for a demo &mdash; restart the
* server and every previously issued token stops verifying, which is exactly the
* behaviour you want to notice now rather than in production. A real deployment
* loads a persistent key (or a rotating set) and serves both the current and the
* previous public key from the JWK Set so in-flight tokens survive rotation; that is
* the subject of docs/15-jwks-caching-and-rotation.md in the resource-server project.
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}
private static KeyPair generateRsaKey() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException("cannot generate an RSA keypair", ex);
}
}
/**
* The authorization server is also a resource server for its own UserInfo endpoint.
* This static helper is one of the few things that survived the 7.0 package move
* unchanged in shape - only its package changed.
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
}

View File

@@ -0,0 +1,63 @@
package com.ankurm.authserver.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/**
* The second chain: everything that is not a protocol endpoint. The login form, the
* consent page, static assets, the diagnostics endpoint.
*
* <p>Two chains is the shape, not an optimisation. The protocol chain in
* {@link AuthorizationServerConfig} carries a {@code securityMatcher} and therefore
* declines every request that is not an OAuth2 or OIDC endpoint; something has to pick
* those up, and it needs a completely different authentication mechanism (a browser
* session, not a bearer token).
*
* @see <a href="../../../../../../../../../docs/authorization-server/03-clients-and-pkce.md">
* docs/authorization-server/03-clients-and-pkce.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {
@Bean
public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/diag/**", "/error", "/favicon.ico").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
/**
* Two users so the consent screen and the token claims have something to differ on.
* {@code alice} is an admin, {@code bob} is not, and the token customiser copies
* their authorities into a {@code roles} claim the resource server reads.
*
* <p>{@code {noop}} is not a placeholder for a password hash - it is a
* {@code DelegatingPasswordEncoder} prefix meaning "stored in clear". Useful for a
* demo, an incident in production.
*/
@Bean
public UserDetailsService users() {
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
UserDetails alice = User.withUsername("alice")
.password(encoder.encode("password"))
.roles("USER", "ADMIN")
.build();
UserDetails bob = User.withUsername("bob")
.password(encoder.encode("password"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(alice, bob);
}
}

View File

@@ -0,0 +1,126 @@
package com.ankurm.authserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import java.time.Duration;
import java.util.UUID;
/**
* Client registration. Three clients, each demonstrating a different half of the
* {@link ClientSettings} / {@link TokenSettings} surface.
*
* <p>A {@code RegisteredClient} is not a user and not a credential - it is a *policy*.
* It says which grants this caller may use, which redirect URIs are acceptable, which
* scopes it may ask for, whether the user has to consent, whether PKCE is mandatory, and
* how long the resulting tokens live. Almost every "it works in Postman but not in the
* browser" report is one of these fields.
*
* @see <a href="../../../../../../../../../docs/authorization-server/03-clients-and-pkce.md">
* docs/authorization-server/03-clients-and-pkce.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class RegisteredClientConfig {
/**
* Set to {@code false} by the {@code noconsent} profile, and to {@code false} for PKCE
* by the {@code nopkce} profile. Both exist so the failure is one flag away and can be
* captured as real output rather than described.
*/
@Bean
public RegisteredClientRepository registeredClientRepository(
@Value("${demo.require-consent:true}") boolean requireConsent,
@Value("${demo.require-pkce:true}") boolean requirePkce,
@Value("${demo.service-token-format:jwt}") String serviceTokenFormat) {
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
// ---- 1. A confidential web application: authorization_code + refresh_token ----
RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("demo-web")
// The secret is *hashed*. Registering a bare string here and then sending
// it from the client 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 single most common first-hour failure.
.clientSecret(encoder.encode("web-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// Exact-match, including scheme, port and path. No wildcards, no prefix
// matching. A mismatch is rejected *before* login, with the error rendered
// by the authorization server rather than sent to the client - by design,
// since redirecting to an unvalidated URI is the vulnerability.
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/demo-web")
.postLogoutRedirectUri("http://127.0.0.1:8080/")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("orders.read")
.scope("orders.write")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(requireConsent)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(5))
.refreshTokenTimeToLive(Duration.ofMinutes(60))
// false means every refresh returns a *new* refresh token and
// invalidates the old one. That is rotation, and it is how a
// stolen refresh token becomes detectable.
.reuseRefreshTokens(false)
.build())
.build();
// ---- 2. A public client (SPA / native): no secret, PKCE mandatory ----
RegisteredClient spaClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("demo-spa")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8080/authorized")
.scope(OidcScopes.OPENID)
.scope("orders.read")
.clientSettings(ClientSettings.builder()
// With NONE authentication this is the only thing standing between
// an intercepted authorization code and an access token. Spring
// Authorization Server does not infer it: a public client with
// requireProofKey(false) will happily complete a code exchange
// with no verifier at all. The `nopkce` profile shows that.
.requireProofKey(requirePkce)
.requireAuthorizationConsent(requireConsent)
.build())
.build();
// ---- 3. A machine client: client_credentials, no user, no refresh token ----
RegisteredClient serviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("demo-service")
.clientSecret(encoder.encode("service-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("orders.read")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(10))
// SELF_CONTAINED is a signed JWT the resource server verifies
// offline. REFERENCE is an opaque string that only means anything
// to /oauth2/introspect - which turns every API call into a
// network round trip to the authorization server, and gives you
// instant revocation in exchange. The `opaque` profile flips it.
.accessTokenFormat("reference".equals(serviceTokenFormat)
? OAuth2TokenFormat.REFERENCE
: OAuth2TokenFormat.SELF_CONTAINED)
.build())
.build();
return new InMemoryRegisteredClientRepository(webClient, spaClient, serviceClient);
}
}

View File

@@ -0,0 +1,105 @@
package com.ankurm.authserver.diag;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Runtime state that is otherwise invisible: the resolved endpoint paths, the registered
* clients as the server actually holds them, and the live filter chains in order.
*
* <p>This exists because the interesting configuration in an authorization server is
* spread across three builders and two filter chains, and the effective result is not
* printed anywhere at startup. Reading the beans back is faster than reasoning about them.
*
* <p><b>Delete this before shipping.</b> It exposes client ids, scopes, grant types and
* your chain ordering to anyone who can reach {@code /diag}.
*
* @see <a href="../../../../../../../../../docs/authorization-server/07-diagnostics.md">
* docs/authorization-server/07-diagnostics.md</a>
*/
@RestController
@RequestMapping("/diag")
public class ProviderDiagnostics {
private final RegisteredClientRepository clients;
private final AuthorizationServerSettings settings;
private final FilterChainProxy filterChainProxy;
public ProviderDiagnostics(RegisteredClientRepository clients,
AuthorizationServerSettings settings,
FilterChainProxy filterChainProxy) {
this.clients = clients;
this.settings = settings;
this.filterChainProxy = filterChainProxy;
}
/** Every endpoint path the server resolved, including the ones you did not set. */
@GetMapping("/settings")
public Map<String, Object> settings() {
return new LinkedHashMap<>(this.settings.getSettings());
}
/**
* The registered clients, flattened. Note that the client secret is not returned -
* it is a hash, and printing it invites people to try to use it as a secret.
*/
@GetMapping("/clients")
public List<Map<String, Object>> clients() {
List<Map<String, Object>> out = new ArrayList<>();
for (String id : List.of("demo-web", "demo-spa", "demo-service")) {
RegisteredClient c = this.clients.findByClientId(id);
if (c == null) {
continue;
}
Map<String, Object> m = new LinkedHashMap<>();
m.put("clientId", c.getClientId());
m.put("authenticationMethods", c.getClientAuthenticationMethods().stream()
.map(a -> a.getValue()).toList());
m.put("grantTypes", c.getAuthorizationGrantTypes().stream()
.map(g -> g.getValue()).toList());
m.put("redirectUris", c.getRedirectUris());
m.put("scopes", c.getScopes());
m.put("requireProofKey", c.getClientSettings().isRequireProofKey());
m.put("requireAuthorizationConsent",
c.getClientSettings().isRequireAuthorizationConsent());
m.put("accessTokenFormat",
c.getTokenSettings().getAccessTokenFormat().getValue());
m.put("accessTokenTtlSeconds",
c.getTokenSettings().getAccessTokenTimeToLive().toSeconds());
m.put("reuseRefreshTokens", c.getTokenSettings().isReuseRefreshTokens());
out.add(m);
}
return out;
}
/**
* The filter chains in the order Spring Security will consult them, with the matcher
* each one carries. 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.
*/
@GetMapping("/chains")
public List<Map<String, Object>> chains() {
List<Map<String, Object>> out = new ArrayList<>();
int i = 0;
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("position", i++);
m.put("matcher", String.valueOf(chain));
m.put("filters", chain.getFilters().stream()
.map(f -> f.getClass().getSimpleName()).toList());
out.add(m);
}
return out;
}
}

View File

@@ -0,0 +1,81 @@
package com.ankurm.authserver.token;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Token customisation &mdash; the hook that decides what a downstream service can
* authorise on without calling back.
*
* <p>One bean of type {@code OAuth2TokenCustomizer<JwtEncodingContext>} is picked up
* automatically by the JWT generator. There is no registration step and no annotation;
* if the bean exists with that exact generic type, it runs. Declare it as
* {@code OAuth2TokenCustomizer<OAuth2TokenClaimsContext>} by mistake &mdash; the type used
* for *opaque* tokens &mdash; and it will be silently ignored, because the JWT generator
* resolves the bean by generic type and simply will not find it. Nothing logs. Your
* claims are just absent.
*
* <p>The {@code noclaims} profile disables this bean, so the difference is a diff of two
* decoded tokens rather than a paragraph.
*
* @see <a href="../../../../../../../../../docs/authorization-server/05-token-customisation.md">
* docs/authorization-server/05-token-customisation.md</a>
*/
@Configuration(proxyBeanMethods = false)
@Profile("!noclaims")
public class TokenClaimsCustomizer {
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
// context.getTokenType() distinguishes the access token, the id_token and the
// refresh token. Writing the same claim into all three is a common accident:
// an id_token is for the client, an access token is for the API, and putting
// authorisation data in the id_token invites the client to make security
// decisions it has no business making.
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
context.getClaims().claim("tenant", "acme");
// client_credentials has no user: getPrincipal() is the client's own
// authentication, and asking it for roles yields nothing useful. Guard on
// the grant type or on whether a user is present, or a machine token
// silently inherits whatever authorities the client authentication carries.
if (context.getPrincipal() != null
&& context.getAuthorizationGrantType() != null
&& !"client_credentials".equals(
context.getAuthorizationGrantType().getValue())) {
Set<String> roles = context.getPrincipal().getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(a -> a.startsWith("ROLE_"))
.map(a -> a.substring("ROLE_".length()))
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
context.getClaims().claim("roles", roles);
}
// The audience is what stops a token minted for one API being replayed
// against another. Spring Authorization Server does not populate `aud` on
// access tokens by default - there is no per-client audience setting - so
// if your resource servers validate audience (they should), this line or
// something like it is mandatory.
context.getClaims().audience(List.of("orders-api"));
}
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
// Profile-ish claims for the relying party to render. Deliberately not
// roles: see above.
context.getClaims().claim("preferred_username",
context.getPrincipal().getName());
}
};
}
}

View File

@@ -0,0 +1,104 @@
package com.ankurm.authserver.web;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.security.Principal;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* The consent page.
*
* <p>Wiring a custom one is deceptively small: {@code .consentPage("/oauth2/consent")} on
* the authorization endpoint, plus a controller that renders a form. What the
* documentation does not spell out is the contract the form has to satisfy, and getting
* any part of it wrong produces a redirect loop rather than an error:
*
* <ul>
* <li>the form POSTs back to <b>{@code /oauth2/authorize}</b>, not to the consent path</li>
* <li>it must echo {@code client_id} and {@code state} exactly as received</li>
* <li>each approved scope goes back as a separate {@code scope} parameter</li>
* <li>CSRF token included &mdash; this is the browser chain, not the protocol chain</li>
* <li>{@code openid} is <b>not</b> shown as a checkbox: it is requested implicitly and
* the server does not require consent for it</li>
* </ul>
*
* <p>Miss the {@code state} parameter and the authorization endpoint cannot correlate the
* approval with the pending request, so it starts a new one &mdash; which redirects to the
* consent page again. The loop looks like a session problem and is not.
*
* @see <a href="../../../../../../../../../docs/authorization-server/04-consent-page.md">
* docs/authorization-server/04-consent-page.md</a>
*/
@Controller
public class ConsentController {
private final RegisteredClientRepository registeredClientRepository;
private final OAuth2AuthorizationConsentService authorizationConsentService;
public ConsentController(RegisteredClientRepository registeredClientRepository,
OAuth2AuthorizationConsentService authorizationConsentService) {
this.registeredClientRepository = registeredClientRepository;
this.authorizationConsentService = authorizationConsentService;
}
@GetMapping("/oauth2/consent")
public String consent(Principal principal, Model model,
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
@RequestParam(OAuth2ParameterNames.STATE) String state,
@RequestParam(name = "user_code", required = false) String userCode,
HttpServletRequest request) {
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
if (registeredClient == null) {
throw new IllegalArgumentException("unknown client_id: " + clientId);
}
// Scopes the user has already approved in an earlier authorization are pre-ticked
// and, in the default flow, would not have brought us here at all - the consent
// page only appears for scopes not yet consented to.
OAuth2AuthorizationConsent currentConsent =
this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());
Set<String> alreadyApproved = currentConsent != null
? currentConsent.getScopes()
: Set.of();
Set<String> toApprove = new LinkedHashSet<>();
Set<String> previouslyApproved = new LinkedHashSet<>();
for (String requested : scope.split(" ")) {
// openid is requested implicitly by every OIDC client and the authorization
// endpoint never asks for consent on it. Rendering it as a checkbox is
// harmless but misleading, and unticking it does nothing.
if (OidcScopes.OPENID.equals(requested)) {
continue;
}
if (alreadyApproved.contains(requested)) {
previouslyApproved.add(requested);
} else {
toApprove.add(requested);
}
}
model.addAttribute("clientId", clientId);
model.addAttribute("clientName", registeredClient.getClientName());
model.addAttribute("state", state);
model.addAttribute("scopes", toApprove);
model.addAttribute("previouslyApprovedScopes", previouslyApproved);
model.addAttribute("principalName", principal.getName());
model.addAttribute("userCode", userCode);
model.addAttribute("requestURI", "/oauth2/authorize");
return "consent";
}
}

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>

View File

@@ -0,0 +1,102 @@
package com.ankurm.authserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Contract tests, not happy-path tests. Each one pins a decision that would otherwise be
* silently reversible by a config change.
*/
@SpringBootTest
@AutoConfigureMockMvc
class ProviderContractTests {
@Autowired MockMvc mvc;
@Autowired RegisteredClientRepository clients;
@Test
void discoveryAdvertisesTheConfiguredIssuer() throws Exception {
this.mvc.perform(get("/.well-known/openid-configuration"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.issuer").value("http://localhost:9000"))
// Present only because .oidc(...) was switched on.
.andExpect(jsonPath("$.userinfo_endpoint").exists());
}
@Test
void jwkSetNeverExposesPrivateMaterial() throws Exception {
String body = this.mvc.perform(get("/oauth2/jwks"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
// "d" is the RSA private exponent. Its presence here would be a full compromise.
assertThat(body).doesNotContain("\"d\"").doesNotContain("\"p\"").doesNotContain("\"q\"");
}
@Test
void thePublicClientRequiresProofKey() {
RegisteredClient spa = this.clients.findByClientId("demo-spa");
assertThat(spa).isNotNull();
assertThat(spa.getClientSettings().isRequireProofKey())
.as("a public client without PKCE can have its authorization code replayed")
.isTrue();
}
@Test
void requireProofKeyIsOnByDefaultInSevenPointOne() {
// demo-service never touches ClientSettings. In Spring Authorization Server 1.5.8
// this was false; in 7.1.1 the default flipped to true, which silently makes PKCE
// mandatory for every client you did not think about. See
// docs/output/as-settings-defaults.txt.
RegisteredClient service = this.clients.findByClientId("demo-service");
assertThat(service.getClientSettings().isRequireProofKey()).isTrue();
}
@Test
void refreshTokensRotate() {
RegisteredClient web = this.clients.findByClientId("demo-web");
assertThat(web.getTokenSettings().isReuseRefreshTokens())
.as("reuse means a stolen refresh token stays valid until it expires")
.isFalse();
}
@Test
void aFailedTokenRequestFromAnApiClientIsNotAnHtmlRedirect() throws Exception {
// Accept: */* is what curl and most HTTP clients send. Without
// setIgnoredMediaTypes(ALL) on the entry-point matcher this is a 302 to /login.
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());
}
@Test
void aBrowserStillGetsRedirectedToTheLoginPage() throws Exception {
this.mvc.perform(get("/oauth2/authorize")
.accept(MediaType.TEXT_HTML)
.queryParam("response_type", "code")
.queryParam("client_id", "demo-web")
.queryParam("redirect_uri", "http://127.0.0.1:8080/login/oauth2/code/demo-web")
.queryParam("scope", "openid")
// Required even for a confidential client, because requireProofKey
// now defaults to true. Omit it and the authorization endpoint
// redirects to the client with error=invalid_request rather than
// sending the browser to the login page.
.queryParam("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
.queryParam("code_challenge_method", "S256"))
.andExpect(status().is3xxRedirection());
}
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>authorization-server-demo</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>oidc-client</artifactId>
<name>oidc-client</name>
<description>A relying party that logs in against auth-server and calls resource-server</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,17 @@
package com.ankurm.client;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The relying party. Runs on :8080 and is the only piece a human touches.
*
* @see <a href="../../../../../../../../docs/authorization-server/08-client.md">
* docs/authorization-server/08-client.md</a>
*/
@SpringBootApplication
public class ClientApplication {
public static void main(String[] args) {
SpringApplication.run(ClientApplication.class, args);
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.client;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* The whole client side of OIDC, in one method.
*
* <p>{@code oauth2Login()} makes this an OIDC relying party: it adds the redirect
* endpoint at {@code /login/oauth2/code/{registrationId}}, exchanges the code, validates
* the {@code id_token}, and creates a session. {@code oauth2Client()} additionally makes
* the access token available to outgoing HTTP calls.
*
* <p>The redirect URI is derived from the registration id, and it must match what the
* provider has registered <b>byte for byte</b>. This app is reached at
* {@code 127.0.0.1:8080} rather than {@code localhost:8080} for a reason: they are
* different origins to a browser cookie jar, and running the client on {@code localhost}
* next to an authorization server on {@code localhost} means one session cookie can
* clobber the other. Using {@code 127.0.0.1} for the client keeps the two sessions
* separate, which is the difference between a working demo and a login loop.
*/
@Configuration(proxyBeanMethods = false)
public class ClientSecurityConfig {
@Bean
public SecurityFilterChain chain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.oauth2Client(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}
}

View File

@@ -0,0 +1,56 @@
package com.ankurm.client;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.client.RestClient;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Renders what the client received, and calls the resource server with it.
*
* <p>{@code @RegisteredOAuth2AuthorizedClient} hands you the access token Spring already
* holds for this user and this registration. Reading it out of the {@code OidcUser} would
* give you the <i>id_token</i> instead - a mistake that produces a 401 from the resource
* server with a token that looks perfectly valid, because it is: it is just the wrong one.
*/
@Controller
public class HomeController {
private final RestClient restClient = RestClient.create();
@GetMapping("/")
public String home(@AuthenticationPrincipal OidcUser user, Model model) {
model.addAttribute("user", user);
if (user != null) {
model.addAttribute("idTokenClaims", user.getIdToken().getClaims());
}
return "home";
}
@GetMapping("/orders")
public String orders(@RegisteredOAuth2AuthorizedClient("demo-web") OAuth2AuthorizedClient client,
Model model) {
String token = client.getAccessToken().getTokenValue();
Map<String, Object> body;
try {
body = this.restClient.get()
.uri("http://localhost:8090/api/orders")
.header("Authorization", "Bearer " + token)
.retrieve()
.body(Map.class);
} catch (Exception ex) {
body = new LinkedHashMap<>(Map.of("error", String.valueOf(ex.getMessage())));
}
model.addAttribute("accessToken", token);
model.addAttribute("scopes", client.getAccessToken().getScopes());
model.addAttribute("orders", body);
return "orders";
}
}

View File

@@ -0,0 +1,82 @@
package com.ankurm.client;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
/**
* Reproduces the pre-7.0 client, so the mismatch can be observed rather than described.
*
* <h2>What changed, on both sides at once</h2>
*
* Read out of the jars with {@code javap} &mdash; see
* {@code docs/output/as-settings-defaults.txt}:
*
* <table>
* <tr><th></th><th>previous</th><th>current</th></tr>
* <tr><td>Authorization server &mdash; {@code ClientSettings.requireProofKey}</td>
* <td>{@code false} (SAS 1.5.8)</td><td>{@code true} (7.1.1)</td></tr>
* <tr><td>Client &mdash; {@code ClientRegistration.ClientSettings.requireProofKey}</td>
* <td>{@code false} (Spring Security 6.5.1)</td><td>{@code true} (7.1.1)</td></tr>
* </table>
*
* <p>Because both moved together, Spring-client-to-Spring-server keeps working. The
* combination that breaks is a <b>7.1 authorization server with anything older or
* anything hand-rolled</b> in front of it: a Spring Security 6.x confidential client, a
* bespoke server-side integration, a Postman collection someone saved last year. The
* server rejects the authorization request before login, and the user sees the client's
* error page rather than the provider's:
*
* <pre>
* error=invalid_request&amp;error_description=OAuth%202.0%20Parameter%3A%20code_challenge
* </pre>
*
* <p>Activating the {@code nopkce} profile rebuilds this client's registration with
* {@code requireProofKey(false)}, which is exactly what a 6.x client would have sent.
*
* @see <a href="../../../../../../../../docs/authorization-server/08-client.md">
* docs/authorization-server/08-client.md</a>
*/
@Configuration(proxyBeanMethods = false)
@Profile("nopkce")
public class PkceConfig {
/**
* Note that {@code DefaultOAuth2AuthorizationRequestResolver} consults
* {@code ClientRegistration}'s own {@code ClientSettings}, not the resolver's
* customizer &mdash; so setting a customizer cannot turn PKCE <i>off</i>. It has to be
* turned off on the registration itself.
*
* <p>A {@code @Bean} that takes {@code ClientRegistrationRepository} and returns one
* is a dependency cycle, and Boot refuses to start
* ({@code Relying upon circular references is discouraged and they are prohibited by
* default}). Post-processing the bean Boot already built avoids that.
*/
@Bean
static BeanPostProcessor downgradeToPreSevenClient() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String name)
throws BeansException {
if (!(bean instanceof ClientRegistrationRepository repo)) {
return bean;
}
ClientRegistration original = repo.findByRegistrationId("demo-web");
if (original == null) {
return bean;
}
return new InMemoryClientRegistrationRepository(
ClientRegistration.withClientRegistration(original)
.clientSettings(ClientRegistration.ClientSettings.builder()
.requireProofKey(false)
.build())
.build());
}
};
}
}

View File

@@ -0,0 +1,3 @@
# PkceConfig is @Profile("nopkce"): it rebuilds the demo-web registration with
# requireProofKey(false), reproducing what a Spring Security 6.x confidential client
# sends. See docs/authorization-server/08-client.md.

View File

@@ -0,0 +1,27 @@
server:
port: 8080
spring:
application:
name: oidc-client
thymeleaf:
cache: false
security:
oauth2:
client:
# One provider entry. Spring reads
# http://localhost:9000/.well-known/openid-configuration at first use and fills in
# every endpoint from it - authorization, token, jwks, userinfo, issuer.
provider:
demo-provider:
issuer-uri: http://localhost:9000
registration:
demo-web:
provider: demo-provider
client-id: demo-web
client-secret: web-secret
authorization-grant-type: authorization_code
client-authentication-method: client_secret_basic
# Must equal the redirect-uri registered on the server, exactly.
redirect-uri: "http://127.0.0.1:8080/login/oauth2/code/demo-web"
scope: openid,profile,orders.read,orders.write

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>OIDC client</title>
<style>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}</style></head>
<body>
<h2>Relying party</h2>
<div th:if="${user == null}">
<p>Not signed in.</p>
<p><a href="/orders">Go to /orders</a> &mdash; this triggers the authorization code flow.</p>
</div>
<div th:if="${user != null}">
<p>Signed in as <b th:text="${user.name}">user</b>.</p>
<p><a href="/orders">/orders</a> &middot; <a href="/logout">log out</a></p>
<h3>id_token claims</h3>
<pre th:text="${idTokenClaims}"></pre>
</div>
</body></html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Orders</title>
<style>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}</style></head>
<body>
<h2>Resource server response</h2>
<pre th:text="${orders}"></pre>
<h3>Granted scopes</h3>
<pre th:text="${scopes}"></pre>
<h3>Access token (raw)</h3>
<pre th:text="${accessToken}"></pre>
<p><a href="/">back</a></p>
</body></html>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>authorization-server-demo</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>authorization-server-demo</name>
<description>Spring Authorization Server 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com</description>
<!--
Three modules, three JVMs, three ports. Running your own provider means running all
three: nothing about the auth server is observable without a client that drives the
browser redirect and a resource server that accepts (or rejects) what comes out.
auth-server :9000 the provider
resource-server :8090 validates its tokens
oidc-client :8080 the relying party
Every dependency version comes from spring-boot-dependencies 4.1.1. Nothing here
pins a Spring Authorization Server version explicitly, because as of Spring Security
7.0 there is no separate version to pin - see docs/authorization-server/01-versions.md.
-->
<modules>
<module>auth-server</module>
<module>resource-server</module>
<module>oidc-client</module>
</modules>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>authorization-server-demo</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>resource-server</artifactId>
<name>resource-server</name>
<description>An API that trusts tokens minted by auth-server</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Same rename as the authorization server starter: the canonical name in Boot 4.1
is spring-boot-starter-security-oauth2-resource-server. The older
spring-boot-starter-oauth2-resource-server is deprecated but still resolves. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,63 @@
package com.ankurm.rs;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Four endpoints, each guarded by a different consequence of the token's contents. */
@RestController
public class ApiController {
@GetMapping("/public")
public Map<String, Object> open() {
return Map.of("message", "no token required");
}
/** Requires SCOPE_orders.read. */
@GetMapping("/api/orders")
public Map<String, Object> orders(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("orders", List.of(Map.of("id", 1, "total", "42.00")));
out.put("subject", jwt.getSubject());
out.put("clientId", jwt.getClaimAsString("azp") != null
? jwt.getClaimAsString("azp") : jwt.getClaimAsString("client_id"));
out.put("scopes", jwt.getClaimAsStringList("scope"));
out.put("roles", jwt.getClaimAsStringList("roles"));
out.put("tenant", jwt.getClaimAsString("tenant"));
out.put("audience", jwt.getAudience());
return out;
}
/** Requires SCOPE_orders.write. */
@PostMapping("/api/orders")
public Map<String, Object> create(@AuthenticationPrincipal Jwt jwt) {
return Map.of("created", true, "by", jwt.getSubject());
}
/** Requires ROLE_ADMIN, which only exists because of the provider's token customiser. */
@GetMapping("/api/admin")
public Map<String, Object> admin() {
return Map.of(
"message", "admin only",
"authorities", SecurityContextHolder.getContext().getAuthentication()
.getAuthorities().stream().map(Object::toString).toList());
}
/** Whatever the resource server actually decoded. Useful when a 403 makes no sense. */
@GetMapping("/whoami")
public Map<String, Object> whoami(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("claims", jwt.getClaims());
out.put("headers", jwt.getHeaders());
out.put("authorities", SecurityContextHolder.getContext().getAuthentication()
.getAuthorities().stream().map(Object::toString).toList());
return out;
}
}

View File

@@ -0,0 +1,17 @@
package com.ankurm.rs;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The API that trusts what the provider mints. Runs on :8090.
*
* @see <a href="../../../../../../../../docs/authorization-server/06-resource-server.md">
* docs/authorization-server/06-resource-server.md</a>
*/
@SpringBootApplication
public class ResourceServerApplication {
public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}

View File

@@ -0,0 +1,119 @@
package com.ankurm.rs;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import java.util.List;
/**
* What a resource server has to do with the provider's tokens, and what it gets for free.
*
* <p>{@code spring.security.oauth2.resourceserver.jwt.issuer-uri} alone gives you
* signature verification, {@code exp}/{@code nbf}, and an {@code iss} check. It does
* <b>not</b> give you an audience check, and it does not map {@code scope} to anything
* other than {@code SCOPE_*} authorities. Both of those gaps are filled here.
*
* @see <a href="../../../../../../../../docs/authorization-server/06-resource-server.md">
* docs/authorization-server/06-resource-server.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Bean
public SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public").permitAll()
// SCOPE_ is the prefix JwtGrantedAuthoritiesConverter uses for the
// `scope` (or `scp`) claim. ROLE_ comes from our own mapping below.
.requestMatchers("/api/orders").hasAuthority("SCOPE_orders.read")
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders.write")
.requestMatchers("/api/admin").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
// No sessions, no CSRF - the token is the entire request context. Leaving CSRF
// on for a bearer-token API produces a 403 on every POST with no useful body,
// which is the single most-reported false alarm in this space.
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(
org.springframework.security.config.http.SessionCreationPolicy.STATELESS));
return http.build();
}
/**
* Built explicitly rather than left to auto-configuration, purely so the audience
* validator can be added. {@code JwtValidators.createDefaultWithIssuer} is the same
* stack Boot would have installed: timestamp + issuer.
*/
@Bean
public JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
@Value("${demo.required-audience:orders-api}") String audience,
@Value("${demo.validate-audience:true}") boolean validateAudience) {
NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuer);
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
if (!validateAudience) {
decoder.setJwtValidator(withIssuer);
return decoder;
}
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
List<String> aud = jwt.getClaimAsStringList(JwtClaimNames.AUD);
if (aud != null && aud.contains(audience)) {
return OAuth2TokenValidatorResult.success();
}
// The error code matters: `invalid_token` is what puts the reason into the
// WWW-Authenticate header, which is the only place a client will see it.
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token",
"the required audience " + audience + " is missing",
"https://tools.ietf.org/html/rfc6750#section-3.1"));
};
decoder.setJwtValidator(new org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator<>(
withIssuer, audienceValidator));
return decoder;
}
/**
* Maps the custom {@code roles} claim the provider's token customiser writes onto
* {@code ROLE_*} authorities, while keeping the default {@code SCOPE_*} mapping.
* Returning a converter that only handles {@code roles} silently deletes every
* scope authority, which turns {@code hasAuthority("SCOPE_orders.read")} into a 403
* on a perfectly valid token.
*/
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
var authorities = new java.util.ArrayList<org.springframework.security.core.GrantedAuthority>(
scopes.convert(jwt));
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(r -> authorities.add(
new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_" + r)));
}
return authorities;
});
return converter;
}
}

View File

@@ -0,0 +1,4 @@
# Audience validation off - the default Boot behaviour. A token minted for a different
# API but signed by the same issuer is now accepted here.
demo:
validate-audience: false

View File

@@ -0,0 +1,18 @@
server:
port: 8090
spring:
application:
name: resource-server
security:
oauth2:
resourceserver:
jwt:
# One property. From it Spring fetches /.well-known/openid-configuration at
# startup, reads jwks_uri out of it, and builds a decoder. If the auth server
# is not up when this one starts, startup fails - by design.
issuer-uri: http://localhost:9000
demo:
required-audience: orders-api
validate-audience: true

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# The gap between "the signature is valid" and "this token was meant for me".
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT=../docs/output/as-audience.txt
mkdir -p ../docs/output
{
section "A token for a DIFFERENT audience, signed by the SAME issuer"
echo "demo-service's tokens carry aud=[orders-api] thanks to the token customiser."
echo "Here we ask for one and then present it to a resource server configured to"
echo "require a different audience - and to one that does not check at all."
TOKEN=$(curl -s -u demo-service:service-secret -d grant_type=client_credentials \
-d scope=orders.read "$AS/oauth2/token" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
echo
echo "aud claim in the token:"
jwt_payload "$TOKEN" | grep -A3 '"aud"'
section "Resource server running with demo.validate-audience=false"
echo "This is the Spring Boot default: issuer-uri alone validates signature, exp/nbf"
echo "and iss. Audience is not checked unless you add a validator."
curl -s -o /tmp/b -w 'GET /api/orders -> %{http_code}\n' -H "Authorization: Bearer $TOKEN" "$RS/api/orders"
head -c 300 /tmp/b; echo
section "The same token with a deliberately mangled signature"
BAD="${TOKEN%?}X"
curl -s -D - -o /dev/null -H "Authorization: Bearer $BAD" "$RS/api/orders" \
| sed -n '1p;/^WWW-Authenticate/p'
section "No token at all"
curl -s -D - -o /dev/null "$RS/api/orders" | sed -n '1p;/^WWW-Authenticate/p'
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
# The full authorization code flow with PKCE, driven entirely by curl so that every
# redirect, form and parameter is visible. A browser hides all of this.
#
# ./scripts/authcode-pkce.sh [output-name] [client]
#
# client defaults to demo-spa (public, PKCE required). Pass demo-web for the confidential
# client with a secret.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
NAME="${1:-as-authcode-pkce}"
CLIENT="${2:-demo-spa}"
# NO_CHALLENGE=1 sends an authorization request with no code_challenge at all. That is a
# different thing from sending one and then omitting the verifier: the server only demands
# a verifier if the authorization request carried a challenge, OR if the client is
# registered with requireProofKey(true).
NO_CHALLENGE="${NO_CHALLENGE:-0}"
OUT="../docs/output/${NAME}.txt"
mkdir -p ../docs/output
if [ "$CLIENT" = "demo-web" ]; then
REDIRECT="http://127.0.0.1:8080/login/oauth2/code/demo-web"
SCOPE="openid orders.read orders.write"
else
REDIRECT="http://127.0.0.1:8080/authorized"
SCOPE="openid orders.read"
fi
JAR=$(mktemp)
trap 'rm -f "$JAR" /tmp/as-page.html' EXIT
# --- PKCE parameters. RFC 7636: verifier is 43-128 chars of unreserved characters,
# --- challenge is BASE64URL(SHA256(verifier)) with the padding stripped.
read -r VERIFIER CHALLENGE <<<"$(python3 - <<'PY'
import base64, hashlib, secrets
v = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip('=')
c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).decode().rstrip('=')
print(v, c)
PY
)"
{
section "PKCE parameters (RFC 7636)"
echo "code_verifier ${VERIFIER} (${#VERIFIER} chars)"
echo "code_challenge ${CHALLENGE}"
echo "code_challenge_method S256"
echo
echo "The verifier never leaves the client until the token request. The challenge is"
echo "all the authorization request carries, and it is a one-way hash of the verifier."
section "1. Log in to the authorization server (browser session)"
# The login page carries a CSRF token; the form chain has CSRF enabled, as it should.
curl -s -c "$JAR" "$AS/login" -o /tmp/as-page.html
CSRF=$(form_value /tmp/as-page.html _csrf)
echo "\$ curl -c jar -d username=alice -d password=password -d _csrf=<token> $AS/login"
curl -s -i -b "$JAR" -c "$JAR" \
-d "username=alice" -d "password=password" -d "_csrf=$CSRF" \
"$AS/login" | sed -n '1p;/^[Ll]ocation:/p'
section "2. GET /oauth2/authorize (client=$CLIENT)"
PKCE_PARAMS="&code_challenge=$CHALLENGE&code_challenge_method=S256"
if [ "$NO_CHALLENGE" = "1" ]; then
PKCE_PARAMS=""
echo "NO_CHALLENGE=1: the authorization request carries no code_challenge."
echo
fi
AUTHZ="$AS/oauth2/authorize?response_type=code&client_id=$CLIENT&redirect_uri=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$REDIRECT")&scope=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$SCOPE")&state=xyz123${PKCE_PARAMS}"
echo "\$ curl -b jar '$AUTHZ'"
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo "-> 302 $LOC"
if [ -z "$LOC" ]; then
echo "no redirect - the authorization endpoint rendered a page instead:"
curl -s -b "$JAR" "$AUTHZ" | head -30
exit 1
fi
case "$LOC" in
*/oauth2/consent*)
section "3. The consent page"
echo "The authorization endpoint redirected to OUR page, at the path given to"
echo ".consentPage(\"/oauth2/consent\"). Note the query string it hands over:"
echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
echo
echo "Scopes rendered as checkboxes (openid deliberately not among them):"
form_values /tmp/as-page.html scope | sed 's/^/ /'
CSRF=$(form_value /tmp/as-page.html _csrf)
STATE=$(form_value /tmp/as-page.html state)
echo
echo "The hidden state the form must echo back: $STATE"
echo "(this is NOT the client's state=xyz123 - it is the server's own correlation"
echo " handle for the pending authorization request, and sending the client's value"
echo " instead is what produces the consent redirect loop)"
section "4. POST the approval to /oauth2/authorize"
ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
for s in $(form_values /tmp/as-page.html scope); do
ARGS+=(-d "scope=$s")
done
echo "\$ curl -b jar -X POST ${ARGS[*]} $AS/oauth2/authorize"
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
| tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo "-> 302 $LOC"
;;
*)
section "3. No consent page"
echo "The authorization endpoint went straight back to the client. Either consent is"
echo "off for this client, or every requested scope was already approved."
;;
esac
CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
RETURNED_STATE=$(echo "$LOC" | sed -n 's/.*[?&]state=\([^&]*\).*/\1/p')
section "5. The authorization code"
echo "code = $CODE"
echo "state = $RETURNED_STATE (the client's own value, returned untouched - compare it)"
if [ -z "$CODE" ]; then
echo "no code in the redirect. The error was:"
echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
exit 1
fi
section "6a. Exchange the code WITHOUT the verifier"
echo "This is the request an attacker who stole the code can make."
AUTH_ARGS=()
[ "$CLIENT" = "demo-web" ] && AUTH_ARGS=(-u demo-web:web-secret)
NOVERIFIER=$(curl -s -w '\n<<HTTP %{http_code}>>' "${AUTH_ARGS[@]}" \
-d grant_type=authorization_code -d "code=$CODE" \
-d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
"$AS/oauth2/token")
echo "$NOVERIFIER" | sed -n 's/^<<HTTP \(.*\)>>$/HTTP \1/p'
BODY=${NOVERIFIER%%$'\n'<<HTTP*}
if [ -n "$BODY" ]; then
echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY"
else
echo "(empty response body)"
fi
echo
case "$NOVERIFIER" in
*access_token*)
echo ">>> A TOKEN WAS ISSUED. The code alone was sufficient. This is what"
echo ">>> requireProofKey(false) on a public client means in practice."
;;
*)
echo ">>> Rejected. invalid_grant is deliberately vague: the server will not tell"
echo ">>> a caller whether the code was wrong, expired, already used, or missing a"
echo ">>> verifier, because each of those is information an attacker can use."
;;
esac
echo "Note: this consumed the code. Authorization codes are single-use, so the"
echo "successful exchange below needs a fresh one."
section "6b. A fresh code, exchanged properly"
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
case "$LOC" in
*/oauth2/consent*)
curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
CSRF=$(form_value /tmp/as-page.html _csrf)
STATE=$(form_value /tmp/as-page.html state)
ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
for s in $(form_values /tmp/as-page.html scope); do
ARGS+=(-d "scope=$s")
done
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
| tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
;;
esac
CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
echo "fresh code = $CODE"
echo
if [ "$NO_CHALLENGE" = "1" ]; then
echo "\$ curl -d grant_type=authorization_code -d code=... $AS/oauth2/token"
echo " (no code_verifier - there was no challenge to verify against)"
else
echo "\$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... $AS/oauth2/token"
fi
VERIFIER_ARG=(-d "code_verifier=$VERIFIER")
[ "$NO_CHALLENGE" = "1" ] && VERIFIER_ARG=()
HTTPCODE=$(curl -s -o /tmp/as-tok.json -w '%{http_code}' "${AUTH_ARGS[@]}" \
-d grant_type=authorization_code -d "code=$CODE" \
-d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
"${VERIFIER_ARG[@]}" \
"$AS/oauth2/token")
RESP=$(cat /tmp/as-tok.json)
echo "HTTP $HTTPCODE"
if [ -s /tmp/as-tok.json ]; then
python3 -m json.tool < /tmp/as-tok.json 2>/dev/null || cat /tmp/as-tok.json
else
echo "(empty response body)"
fi
if [ "$HTTPCODE" != "200" ] && [ "$NO_CHALLENGE" = "1" ]; then
echo
echo ">>> No token, even though the client is registered with requireProofKey(false)"
echo ">>> and the authorization request carried no challenge. The reason is that a"
echo ">>> public client has no other way to authenticate at the token endpoint:"
echo ">>> PublicClientAuthenticationProvider delegates entirely to"
echo ">>> CodeVerifierAuthenticator, and raises invalid_client when there is nothing"
echo ">>> to verify. requireProofKey(false) relaxes the AUTHORIZATION endpoint only."
fi
read_claim() { python3 -c 'import sys,json
try: print(json.load(sys.stdin).get(sys.argv[1],""))
except Exception: print("")' "$1" < /tmp/as-tok.json; }
AT=$(read_claim access_token)
IDT=$(read_claim id_token)
RT=$(read_claim refresh_token)
if [ -n "$AT" ]; then
section "7. The access token"
jwt_header "$AT"
jwt_payload "$AT"
fi
if [ -n "$IDT" ]; then
section "8. The id_token - a different token, for a different audience"
jwt_payload "$IDT"
echo
echo "aud is the CLIENT here, not the API. Sending this to a resource server is the"
echo "classic mix-up: it verifies (same issuer, same key) and then fails the audience"
echo "check, or worse, passes it if nobody checks audience."
fi
if [ -n "$AT" ]; then
section "9. Calling the resource server"
for path in /api/orders /api/admin; do
CODE_HTTP=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $AT" "$RS$path")
echo "GET $path -> $CODE_HTTP"
head -c 500 /tmp/rsbody; echo
done
section "10. Sending the id_token instead"
if [ -n "$IDT" ]; then
curl -s -i -H "Authorization: Bearer $IDT" "$RS/api/orders" \
| sed -n '1p;/^WWW-Authenticate/p'
fi
fi
if [ -n "$RT" ]; then
section "11. Refresh, with rotation"
echo "old refresh token: ${RT:0:24}..."
R2=$(curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
-d "client_id=$CLIENT" "$AS/oauth2/token")
NEW=$(echo "$R2" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("refresh_token",""))')
echo "new refresh token: ${NEW:0:24}..."
[ "$RT" = "$NEW" ] && echo "SAME - reuseRefreshTokens(true)" || echo "DIFFERENT - reuseRefreshTokens(false), the old one is now dead"
echo
echo "Replaying the old one:"
curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
-d "client_id=$CLIENT" "$AS/oauth2/token"
echo
fi
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"; tail -5 "$OUT"

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# The simplest grant, and what the token customiser does and does not add to it.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT="../docs/output/${1:-as-client-credentials}.txt"
mkdir -p ../docs/output
{
section "POST /oauth2/token grant_type=client_credentials"
echo "\$ curl -su demo-service:service-secret -d grant_type=client_credentials \\"
echo " -d scope=orders.read $AS/oauth2/token"
RESP=$(curl -s -u demo-service:service-secret \
-d grant_type=client_credentials -d scope=orders.read \
"$AS/oauth2/token")
echo "$RESP" | python3 -m json.tool
TOKEN=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
if [ -z "$TOKEN" ]; then echo "no access token - stopping"; exit 1; fi
case "$TOKEN" in
*.*.*)
section "JOSE header"
jwt_header "$TOKEN"
section "Claims"
jwt_payload "$TOKEN"
;;
*)
section "Not a JWT"
echo "The access token is an opaque reference: $TOKEN"
echo "Length ${#TOKEN}. It carries no claims; the resource server must introspect it."
section "POST /oauth2/introspect"
curl -s -u demo-service:service-secret -d "token=$TOKEN" \
"$AS/oauth2/introspect" | python3 -m json.tool
;;
esac
section "Wrong secret"
echo "\$ curl -si -u demo-service:WRONG -d grant_type=client_credentials $AS/oauth2/token"
curl -s -i -u demo-service:WRONG -d grant_type=client_credentials \
"$AS/oauth2/token" | sed -n '1p;/^WWW-Authenticate/p;/^{/p'
section "A grant the client is not registered for"
echo "\$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x $AS/oauth2/token"
curl -s -i -u demo-service:service-secret -d grant_type=authorization_code -d code=x \
"$AS/oauth2/token" | sed -n '1p;/^{/p'
section "A scope the client is not registered for"
echo "\$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write $AS/oauth2/token"
curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write \
"$AS/oauth2/token"
echo
section "Calling the resource server with the token"
for path in /public /api/orders /api/admin; do
CODE=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $TOKEN" "$RS$path")
WWW=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $TOKEN" "$RS$path" | grep -i '^WWW-Authenticate' || true)
echo "GET $path -> $CODE"
[ -n "$WWW" ] && echo " $WWW"
head -c 400 /tmp/rsbody; echo
done
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Drives the real Spring OAuth2 client through a real browser flow with curl, so the
# behaviour is the client's own and not this script's.
#
# ./scripts/client-flow.sh <output-name> <label>
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
CLIENT=http://127.0.0.1:8080
LAST_AUTHZ=""
NAME="${1:-as-client-flow}"
LABEL="${2:-default}"
OUT="../docs/output/${NAME}.txt"
mkdir -p ../docs/output
JAR=$(mktemp); trap 'rm -f "$JAR" /tmp/cf.html' EXIT
follow() { # url -> prints status + location, follows same-host redirects up to 6 hops
local url="$1" hop=0
while [ $hop -lt 8 ]; do
local hdrs status loc
# A browser sends Accept: text/html. curl's default is */*, and with the entry-point
# matcher configured to ignore */* that difference decides whether /oauth2/authorize
# redirects you to the login page or answers 401.
hdrs=$(curl -s -D - -o /tmp/cf.html -b "$JAR" -c "$JAR" -H 'Accept: text/html' "$url" | tr -d '\r')
status=$(echo "$hdrs" | head -1 | awk '{print $2}')
loc=$(echo "$hdrs" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo " $status $url"
case "$url" in *"/oauth2/authorize?"*) LAST_AUTHZ="$url" ;; esac
[ -z "$loc" ] && { LAST_URL="$url"; return 0; }
case "$loc" in http*) url="$loc" ;; *) url="$(echo "$url" | grep -oE '^https?://[^/]+')$loc" ;; esac
hop=$((hop+1))
done
LAST_URL="$url"
}
{
section "The relying party drives the flow [$LABEL]"
echo "GET $CLIENT/orders while unauthenticated. Every hop below is a real redirect."
echo
follow "$CLIENT/orders"
echo
echo "The authorization request the client built:"
echo "$LAST_AUTHZ" | tr '&?' '\n\n' | sed 's/^/ /'
case "$LAST_AUTHZ" in
*code_challenge*) echo " >>> code_challenge IS present" ;;
*) echo " >>> NO code_challenge - a client registered with"
echo " >>> requireProofKey(true) will reject this outright" ;;
esac
case "$LAST_URL" in
"$AS/login"*)
echo
echo "Landed on the authorization server's login page. Submitting credentials:"
CSRF=$(form_value /tmp/cf.html _csrf)
HDRS=$(curl -s -D - -o /dev/null -b "$JAR" -c "$JAR" -H 'Accept: text/html' \
-d username=alice -d password=password -d "_csrf=$CSRF" \
"$AS/login" | tr -d '\r')
echo " $(echo "$HDRS" | head -1 | awk '{print $2}') POST $AS/login"
NEXT=$(echo "$HDRS" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo
echo "Resuming the authorization request:"
follow "$NEXT"
;;
esac
# The consent page, if we reached it.
if grep -q 'name="scope"' /tmp/cf.html 2>/dev/null; then
echo
echo "Consent page reached. Approving:"
CSRF=$(form_value /tmp/cf.html _csrf)
STATE=$(form_value /tmp/cf.html state)
CID=$(form_value /tmp/cf.html client_id)
ARGS=(-d "client_id=$CID" -d "state=$STATE" -d "_csrf=$CSRF")
for s in $(form_values /tmp/cf.html scope); do ARGS+=(-d "scope=$s"); done
HDRS=$(curl -s -D - -o /dev/null -b "$JAR" -c "$JAR" -H 'Accept: text/html' "${ARGS[@]}" "$AS/oauth2/authorize" | tr -d '\r')
echo " $(echo "$HDRS" | head -1 | awk '{print $2}') POST $AS/oauth2/authorize"
NEXT=$(echo "$HDRS" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo
echo "Back to the client with the code:"
follow "$NEXT"
fi
case "$LAST_URL" in
*error*)
echo
echo "The flow ended at the CLIENT's error page, not the provider's. The provider"
echo "rejected the authorization request and redirected the failure back to the"
echo "registered redirect_uri, so nothing in the client's logs names the provider"
echo "as the cause. The reason is only in the query string above."
;;
esac
section "What the client rendered"
if grep -qi 'error' /tmp/cf.html && ! grep -q 'orders' /tmp/cf.html; then
echo "An error page. The provider rejected the authorization request:"
echo "$LAST_URL" | tr '&?' '\n\n' | sed 's/^/ /'
echo
python3 -c "
import html,re,sys
t = re.sub(r'<[^>]+>', ' ', open('/tmp/cf.html', encoding='utf-8', errors='replace').read())
print(' '.join(html.unescape(t).split())[:600])"
else
python3 -c "
import html,re
t = re.sub(r'<[^>]+>', '\n', open('/tmp/cf.html', encoding='utf-8', errors='replace').read())
print('\n'.join(l.strip() for l in html.unescape(t).splitlines() if l.strip())[:1400])"
fi
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Compiles the SAS 1.x-style configuration against the 7.1.1 classpath and records the
# compiler's own words. The point is that the error text is what you will actually see,
# not a paraphrase of it.
set -u
cd "$(dirname "$0")/.."
OUT=../docs/output/as-legacy-compile-failure.txt
mkdir -p ../docs/output
mvn -B -q -pl auth-server dependency:build-classpath \
-Dmdep.outputFile=/tmp/as-cp.txt -DincludeScope=compile >/dev/null 2>&1
CP=$(cat /tmp/as-cp.txt)
WORK=$(mktemp -d)
mkdir -p "$WORK/com/ankurm/authserver/legacy" "$WORK/out"
cp src-broken/LegacySasConfig.java.txt "$WORK/com/ankurm/authserver/legacy/LegacySasConfig.java"
javac -nowarn -d "$WORK/out" -cp "$CP" \
"$WORK/com/ankurm/authserver/legacy/LegacySasConfig.java" > "$WORK/err.txt" 2>&1
STATUS=$?
{
echo "# The SAS 1.x configuration, compiled against Spring Boot 4.1.1 / Spring Security 7.1.1."
echo "# Source: src-broken/LegacySasConfig.java.txt"
echo
echo "\$ javac -cp <spring-boot-4.1.1 classpath> LegacySasConfig.java"
echo
sed "s|$WORK|.|g" "$WORK/err.txt"
echo
echo "javac exit status: $STATUS"
} > "$OUT"
rm -rf "$WORK"
cat "$OUT"

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# What the provider advertises, and the difference between the two metadata documents.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT=../docs/output/as-discovery.txt
mkdir -p ../docs/output
{
section "OpenID Connect discovery: GET /.well-known/openid-configuration"
echo "\$ curl -s $AS/.well-known/openid-configuration"
curl -s "$AS/.well-known/openid-configuration" | python3 -m json.tool
section "OAuth2 metadata: GET /.well-known/oauth-authorization-server"
echo "Present even with .oidc(...) switched off. The OIDC document above is the one"
echo "that additionally advertises userinfo_endpoint and id_token signing algorithms."
echo "\$ curl -s $AS/.well-known/oauth-authorization-server"
curl -s "$AS/.well-known/oauth-authorization-server" | python3 -m json.tool
section "JWK Set: GET /oauth2/jwks"
echo "Public keys only. No 'd' member - if you ever see one here, stop the server."
curl -s "$AS/oauth2/jwks" | python3 -m json.tool
section "Resolved endpoint settings, read back from AuthorizationServerSettings"
curl -s "$AS/diag/settings" | python3 -m json.tool
section "Registered clients, as the server actually holds them"
curl -s "$AS/diag/clients" | python3 -m json.tool
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"; wc -l "$OUT"

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Why a failed token request sometimes answers 302 -> /login instead of 401.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT=../docs/output/as-entrypoint-accept.txt
LABEL="${1:-}"
mkdir -p ../docs/output
probe() {
local accept="$1" desc="$2"
echo "--- $desc"
echo "\$ curl -H 'Accept: $accept' -d grant_type=authorization_code -d code=bogus \\"
echo " -d client_id=demo-spa $AS/oauth2/token"
curl -s -i -H "Accept: $accept" -d grant_type=authorization_code -d code=bogus \
-d client_id=demo-spa "$AS/oauth2/token" \
| sed -n '1p;/^[Ll]ocation:/p;/^WWW-Authenticate/p'
echo
}
{
section "Public client, failed authentication at the token endpoint [$LABEL]"
echo "A public client authenticates at /oauth2/token by presenting a code_verifier."
echo "With no verifier there is nothing to authenticate with, so the request falls"
echo "through to the AuthenticationEntryPoint - and which entry point runs depends on"
echo "the Accept header."
echo
probe "*/*" "Accept: */* (curl's default, and most HTTP clients')"
probe "application/json" "Accept: application/json"
probe "text/html" "Accept: text/html (a browser)"
section "Confidential client with a wrong secret, for contrast"
echo "This never reaches the entry point: OAuth2ClientAuthenticationFilter writes the"
echo "error itself, so the Accept header makes no difference."
curl -s -i -u demo-web:wrong -d grant_type=client_credentials "$AS/oauth2/token" \
| sed -n '1p;/^[Ll]ocation:/p'
} >> "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "appended $LABEL to $OUT"

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Shared helpers. Sourced by every demo script.
AS=http://localhost:9000
RS=http://localhost:8090
# Kill by main class, never by a pattern that could match this script's own command line.
# `pkill -f spring-boot` matches the shell running it and takes the shell with it.
kill_app() {
local mainclass="$1" port="${2:-}"
for p in $(ps -eo pid,cmd | grep "[${mainclass:0:1}]${mainclass:1}" | awk '{print $1}'); do
kill -9 "$p" 2>/dev/null || true
done
# Then wait for the port to actually close. `ss -lptn` often reports the socket with no
# PID, so a port-based kill can silently do nothing while the old process keeps serving -
# which looks exactly like your config change having had no effect. Waiting for the
# listener to disappear is the only reliable signal that the restart is real.
if [ -n "$port" ]; then
for _ in $(seq 1 30); do
curl -s -o /dev/null --max-time 1 "http://localhost:$port/" || return 0
sleep 1
done
echo "WARNING: something is still listening on :$port after kill_app $mainclass" >&2
return 1
fi
sleep 1
}
wait_for() {
local url="$1" tries="${2:-90}"
for _ in $(seq 1 "$tries"); do
if curl -s -o /dev/null --max-time 2 "$url"; then return 0; fi
sleep 1
done
echo "timed out waiting for $url - check the app log" >&2
return 1
}
hr() { printf '%s\n' "------------------------------------------------------------------"; }
section() { echo; hr; echo "== $*"; hr; }
# Decode a JWS payload without verifying it. Debug only - never do this to decide anything.
jwt_payload() {
python3 - "$1" <<'PY'
import base64, json, sys
part = sys.argv[1].split('.')[1]
part += '=' * (-len(part) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
PY
}
jwt_header() {
python3 - "$1" <<'PY'
import base64, json, sys
part = sys.argv[1].split('.')[0]
part += '=' * (-len(part) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
PY
}
# Pull a hidden input's value out of a page. Attribute order is not fixed - Spring
# Security's default login page renders name before value, Thymeleaf renders value before
# name - so a naive grep for name="x" value="y" works on one and silently returns empty on
# the other. Empty CSRF token, HTTP 403, and an hour lost.
form_value() {
python3 - "$1" "$2" <<'PY'
import re, sys
html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
want = sys.argv[2]
for tag in re.findall(r'<input\b[^>]*>', html, re.I):
attrs = dict((m.group(1).lower(), m.group(2))
for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
if attrs.get('name') == want:
print(attrs.get('value', ''))
break
PY
}
# Every value of a repeated input (the scope checkboxes on the consent page).
form_values() {
python3 - "$1" "$2" <<'PY'
import re, sys
html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
want = sys.argv[2]
for tag in re.findall(r'<input\b[^>]*>', html, re.I):
attrs = dict((m.group(1).lower(), m.group(2))
for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
if attrs.get('name') == want and 'disabled' not in tag.lower():
print(attrs.get('value', ''))
PY
}

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Why a confidential Spring OAuth2 client does not send PKCE, straight from the bytecode
# of DefaultOAuth2AuthorizationRequestResolver rather than from documentation.
set -u
cd "$(dirname "$0")/.."
OUT=../docs/output/as-pkce-applier.txt
mkdir -p ../docs/output
mvn -B -q -pl oidc-client dependency:build-classpath \
-Dmdep.outputFile=/tmp/cl-cp.txt -DincludeScope=compile >/dev/null 2>&1
JAR=$(tr ':' '\n' < /tmp/cl-cp.txt | grep 'spring-security-oauth2-client-' | head -1)
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
(cd "$WORK" && unzip -o -q "$JAR" 'org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver*')
{
echo "# From $(basename "$JAR")"
echo "#"
echo "# The resolver applies its default PKCE customizer only when the registration's"
echo "# client authentication method is NONE - that is, only for public clients."
echo "# A registration that has a client secret gets no code_challenge."
echo
javap -p -c -cp "$WORK" \
org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver \
2>/dev/null \
| grep -E 'ClientAuthenticationMethod.NONE|DEFAULT_PKCE_APPLIER|withPkce' \
| sed 's/^ *//' | head -8
echo
echo "# The fields and the opt-in setter:"
javap -p -cp "$WORK" \
org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver \
2>/dev/null \
| grep -E 'DEFAULT_PKCE_APPLIER|setAuthorizationRequestCustomizer' | sed 's/^ *//'
} > "$OUT" 2>&1
cat "$OUT"

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# What happens when the resource server starts and the issuer is not reachable.
# Worth capturing because the failure is at STARTUP, not at first request - which means
# a provider outage during a rolling deploy takes your API down with it.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT=../docs/output/as-rs-startup-failure.txt
mkdir -p ../docs/output
kill_app ResourceServerApplication 8090
kill_app AuthServerApplication 9000
sleep 1
mvn -B -o -pl resource-server org.springframework.boot:spring-boot-maven-plugin:run \
> /tmp/rs-fail.log 2>&1 || true
{
echo "# resource-server started with spring.security.oauth2.resourceserver.jwt.issuer-uri"
echo "# pointing at an authorization server that is not running."
echo
grep -E '^Caused by|Unable to resolve the Configuration' /tmp/rs-fail.log \
| sed 's/^Caused by: //' | head -8
} > "$OUT"
cat "$OUT"

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Regenerates every file in ../docs/output that belongs to this project.
#
# ./scripts/run-all.sh
#
# Starts and stops the servers itself. Takes a few minutes. The only non-deterministic
# content is timestamps, key ids and token values, which change on every run by design.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
start_auth() {
kill_app AuthServerApplication 9000
local profiles="${1:-}"
local args=(-B -o -pl auth-server org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
setsid nohup mvn "${args[@]}" > /tmp/auth-server.log 2>&1 < /dev/null &
wait_for "$AS/oauth2/jwks" 90
}
start_client() {
kill_app ClientApplication 8080
local profiles="${1:-}"
local args=(-B -o -pl oidc-client org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
setsid nohup mvn "${args[@]}" > /tmp/oidc-client.log 2>&1 < /dev/null &
wait_for "http://127.0.0.1:8080/" 90
}
start_rs() {
kill_app ResourceServerApplication 8090
local profiles="${1:-}"
local args=(-B -o -pl resource-server org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
setsid nohup mvn "${args[@]}" > /tmp/rs.log 2>&1 < /dev/null &
wait_for "$RS/public" 90
}
echo "== ClientSettings / TokenSettings defaults, 1.5.8 vs 7.1.1"
./scripts/settings-defaults.sh > /dev/null
echo "== the SAS 1.x configuration against 7.1.1 (compile only)"
./scripts/compile-legacy.sh > /dev/null
echo "== default profile"
start_auth ""
start_rs ""
./scripts/discovery.sh
./scripts/client-credentials.sh as-client-credentials
./scripts/authcode-pkce.sh as-authcode-pkce demo-spa
./scripts/authcode-pkce.sh as-authcode-web demo-web
echo "== why a confidential Spring client does not send PKCE by default"
./scripts/pkce-applier.sh > /dev/null
echo "== the real Spring OAuth2 client, end to end"
start_client ""
./scripts/client-flow.sh as-client-flow "client sends PKCE"
# Restart the authorization server too, so the consent already granted above does not
# short-circuit the second run.
start_auth ""
start_client "nopkce"
./scripts/client-flow.sh as-client-flow-nopkce "confidential client, no PKCE - the Boot default"
kill_app ClientApplication 8080
echo "== noclaims: the token customiser removed"
start_auth "noclaims"
./scripts/client-credentials.sh as-client-credentials-noclaims
./scripts/authcode-pkce.sh as-authcode-noclaims demo-spa
echo "== nopkce: the public client no longer requires a verifier"
start_auth "nopkce"
# With a challenge present, the server still demands the verifier - requireProofKey only
# controls whether a challenge is MANDATORY, not whether one that was sent is honoured.
./scripts/authcode-pkce.sh as-authcode-nopkce demo-spa
# Without any challenge at all, the code alone is enough. This is the actual exposure.
NO_CHALLENGE=1 ./scripts/authcode-pkce.sh as-authcode-nochallenge demo-spa
echo "== the same request against a client that DOES require PKCE"
start_auth ""
NO_CHALLENGE=1 ./scripts/authcode-pkce.sh as-authcode-pkce-enforced demo-spa
echo "== noconsent: consent turned off"
start_auth "noconsent"
./scripts/authcode-pkce.sh as-authcode-noconsent demo-spa
echo "== entry point and the Accept header"
rm -f ../docs/output/as-entrypoint-accept.txt
start_auth "acceptall"
./scripts/entrypoint-accept.sh "acceptall profile: setIgnoredMediaTypes NOT called"
start_auth ""
./scripts/entrypoint-accept.sh "default profile: setIgnoredMediaTypes(ALL) called"
echo "== opaque: reference tokens for the service client"
start_auth "opaque"
./scripts/client-credentials.sh as-client-credentials-opaque
echo "== audience validation off on the resource server"
start_auth ""
start_rs "noaud"
./scripts/audience.sh
echo "== the contract tests"
mvn -B -o -pl auth-server test 2>&1 | grep -E "Tests run:|^\[INFO\] Running" \
> ../docs/output/as-test-run.txt || true
kill_app AuthServerApplication 9000
kill_app ResourceServerApplication 8090
kill_app ClientApplication 8080
echo "== resource server startup with no provider"
./scripts/rs-startup-failure.sh > /dev/null
echo
echo "docs/output:"
ls -1 ../docs/output/as-*.txt

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# ./scripts/run.sh <auth|rs|client> [profiles]
#
# ./scripts/run.sh auth the provider on :9000
# ./scripts/run.sh auth nopkce the provider with PKCE not required
# ./scripts/run.sh rs the API on :8090
# ./scripts/run.sh client the relying party on :8080
set -eu
cd "$(dirname "$0")/.."
. scripts/lib.sh
case "${1:-}" in
auth) MODULE=auth-server; MAIN=AuthServerApplication; PORT=9000 ;;
rs) MODULE=resource-server; MAIN=ResourceServerApplication; PORT=8090 ;;
client) MODULE=oidc-client; MAIN=ClientApplication; PORT=8080 ;;
*) echo "usage: $0 <auth|rs|client> [profiles]" >&2; exit 2 ;;
esac
PROFILES="${2:-}"
kill_app "$MAIN" "$PORT"
LOG="/tmp/${MODULE}.log"
ARGS=(-B -pl "$MODULE" org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$PROFILES" ] && ARGS+=("-Dspring-boot.run.profiles=$PROFILES")
# Detached, so the script returns and the demo scripts can drive it.
setsid nohup mvn "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null &
echo "starting $MODULE${PROFILES:+ [$PROFILES]} -> $LOG"
wait_for "http://localhost:$PORT/" 120 && echo "$MODULE up on :$PORT"

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Compiles one tiny program twice - once against Spring Authorization Server 1.5.8, once
# against 7.1.1 - and prints the defaults each version hands you. This is how the
# requireProofKey change was found; no documentation was involved.
set -u
cd "$(dirname "$0")/.."
OUT=../docs/output/as-settings-defaults.txt
mkdir -p ../docs/output
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/old"
fetch() { # group artifact version
curl -sL -o "$WORK/old/$2-$3.jar" \
"https://repo1.maven.org/maven2/$1/$2/$3/$2-$3.jar"
}
for a in spring-security-oauth2-authorization-server:1.5.8 spring-security-oauth2-core:6.5.1 \
spring-security-oauth2-jose:6.5.1 spring-security-core:6.5.1 \
spring-security-oauth2-client:6.5.1 spring-security-web:6.5.1; do
fetch org/springframework/security "${a%%:*}" "${a##*:}"
done
for a in spring-core:6.2.7 spring-jcl:6.2.7; do
fetch org/springframework "${a%%:*}" "${a##*:}"
done
mvn -B -q -pl auth-server dependency:build-classpath \
-Dmdep.outputFile=/tmp/as-cp.txt -DincludeScope=compile >/dev/null 2>&1
CP_NEW=$(cat /tmp/as-cp.txt)
CP_OLD=$(ls "$WORK"/old/*.jar | tr '\n' ':')
{
echo "# Defaults of ClientSettings.builder().build() and TokenSettings.builder().build(),"
echo "# read out of the jars themselves rather than from documentation."
echo "# Source: tools/SettingsDefaults.java"
echo
echo "=== Spring Authorization Server 1.5.8 (last release of the standalone project) ==="
javac -nowarn -cp "$CP_OLD" -d "$WORK/out-old" tools/SettingsDefaults.java \
&& java -cp "$CP_OLD:$WORK/out-old" SettingsDefaults
echo
echo "=== Spring Authorization Server 7.1.1 (inside Spring Security, Boot 4.1.1 BOM) ==="
javac -nowarn -cp "$CP_NEW" -d "$WORK/out-new" tools/SettingsDefaults.java \
&& java -cp "$CP_NEW:$WORK/out-new" SettingsDefaults
echo
echo "# The same question on the CLIENT side. Source: tools/ClientPkceDefault.java"
mvn -B -q -pl oidc-client dependency:build-classpath \
-Dmdep.outputFile=/tmp/cl-cp.txt -DincludeScope=compile >/dev/null 2>&1
CP_CLIENT=$(cat /tmp/cl-cp.txt)
echo
echo "=== spring-security-oauth2-client 6.5.1 ==="
javac -nowarn -cp "$CP_OLD" -d "$WORK/co" tools/ClientPkceDefault.java \
&& java -cp "$CP_OLD:$WORK/co" ClientPkceDefault
echo
echo "=== spring-security-oauth2-client 7.1.1 (Boot 4.1.1 BOM) ==="
javac -nowarn -cp "$CP_CLIENT" -d "$WORK/cn" tools/ClientPkceDefault.java \
&& java -cp "$CP_CLIENT:$WORK/cn" ClientPkceDefault
echo
echo "# Both sides flipped in the 7.x line. Spring-to-Spring therefore still works;"
echo "# a 7.1 authorization server in front of a 6.x or hand-rolled client does not."
} > "$OUT" 2>&1
cat "$OUT"

View File

@@ -0,0 +1,40 @@
// The configuration every Spring Authorization Server tutorial written before
// September 2025 tells you to write. It compiled against 1.5.8 and does not compile
// against 7.1.1. Kept out of the build on purpose - scripts/compile-legacy.sh runs
// javac against it and captures the real compiler output into
// docs/output/as-legacy-compile-failure.txt.
//
// Three separate breakages in nine lines:
// 1. the *Configuration class moved out of the SAS jar into spring-security-config
// 2. the *Configurer class moved AND changed package shape
// 3. applyDefaultSecurity(HttpSecurity) was deleted outright
package com.ankurm.authserver.legacy;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class LegacySasConfig {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
// The 1.x one-liner.
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
return http.build();
}
}

View File

@@ -0,0 +1,15 @@
import org.springframework.security.oauth2.client.registration.ClientRegistration;
/**
* Prints whether a Spring OAuth2 <i>client</i> registration requires PKCE out of the box.
* Run against spring-security-oauth2-client 6.5.1 and 7.1.1, it shows the default flipping
* in the same release that flipped it on the authorization server.
*/
public class ClientPkceDefault {
public static void main(String[] args) {
ClientRegistration.ClientSettings cs =
ClientRegistration.ClientSettings.builder().build();
System.out.println("ClientRegistration.ClientSettings.requireProofKey = "
+ cs.isRequireProofKey());
}
}

View File

@@ -0,0 +1,20 @@
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
/**
* Prints the out-of-the-box values of ClientSettings and TokenSettings. Run against two
* different Spring Authorization Server jars, it is the shortest proof that a default
* changed. scripts/settings-defaults.sh does exactly that for 1.5.8 and 7.1.1.
*/
public class SettingsDefaults {
public static void main(String[] a) {
ClientSettings cs = ClientSettings.builder().build();
TokenSettings ts = TokenSettings.builder().build();
System.out.println("requireProofKey = " + cs.isRequireProofKey());
System.out.println("requireAuthorizationConsent= " + cs.isRequireAuthorizationConsent());
System.out.println("accessTokenTimeToLive = " + ts.getAccessTokenTimeToLive());
System.out.println("accessTokenFormat = " + ts.getAccessTokenFormat().getValue());
System.out.println("refreshTokenTimeToLive = " + ts.getRefreshTokenTimeToLive());
System.out.println("reuseRefreshTokens = " + ts.isReuseRefreshTokens());
System.out.println("authorizationCodeTTL = " + ts.getAuthorizationCodeTimeToLive());
}
}

View File

@@ -95,3 +95,9 @@ A JWT deployment is an **issuer** that trades a password for a signed claims set
**verifier** that trades a signed claims set for an `Authentication` — and every failure **verifier** that trades a signed claims set for an `Authentication` — and every failure
mode in this repository comes from one of the two doing slightly less checking than the mode in this repository comes from one of the two doing slightly less checking than the
other assumed. other assumed.
---
A third project joined this repository later: a real OAuth2 / OIDC provider, with its own
client and resource server. Its architecture is a superset of the one drawn above &mdash;
see [`docs/authorization-server/`](authorization-server/README.md).

View File

@@ -121,3 +121,10 @@ A useful third option for a real system: run
[Spring Authorization Server](https://spring.io/projects/spring-authorization-server) as [Spring Authorization Server](https://spring.io/projects/spring-authorization-server) as
the issuer and consume its tokens with `oauth2ResourceServer()`. Then neither half of the issuer and consume its tokens with `oauth2ResourceServer()`. Then neither half of
this repository is your code. this repository is your code.
---
There is a third option this comparison leaves out: **do not mint tokens at all in your
application, and run a real authorization server instead**. That is
[`docs/authorization-server/`](authorization-server/README.md), and the honest cost/benefit
is in [10 &mdash; Should you run one at all](authorization-server/10-should-you.md).

View File

@@ -130,3 +130,19 @@ ankurm.com has a dedicated
[gh-18926]: https://github.com/spring-projects/spring-security/issues/18926 [gh-18926]: https://github.com/spring-projects/spring-security/issues/18926
[gh-18634]: https://github.com/spring-projects/spring-security/pull/18634 [gh-18634]: https://github.com/spring-projects/spring-security/pull/18634
[gh-18113]: https://github.com/spring-projects/spring-security/issues/18113 [gh-18113]: https://github.com/spring-projects/spring-security/issues/18113
---
Two more 7.x changes surfaced while building the authorization-server project, both
verified by reading the jars rather than the release notes:
- `ClientSettings.requireProofKey` flipped from `false` to `true` on **both** the
authorization server and the OAuth2 client &mdash;
[`authorization-server/03`](authorization-server/03-clients-and-pkce.md)
- `OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity)` was deleted,
and its class moved into `spring-security-config` &mdash;
[`authorization-server/02`](authorization-server/02-minimum-provider.md)
`FactorGrantedAuthority` now appears in every authority list, and `WWW-Authenticate` carries
an RFC 9728 `resource_metadata` parameter &mdash;
[`authorization-server/06`](authorization-server/06-resource-server.md).

View File

@@ -208,3 +208,9 @@ there and that it is yours.
--- ---
[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md) [← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md)
---
Seen from the issuer's side, `aud` on an access token defaults to the **client id**, and
there is no per-client audience setting &mdash; you set it in an `OAuth2TokenCustomizer` or
not at all. [`authorization-server/05`](authorization-server/05-token-customisation.md).

View File

@@ -178,3 +178,11 @@ JWK Set its own dedicated cache; do not point it at a cache you share with anyth
--- ---
[← the authentication converter](14-authentication-converter.md) · [next: what an unknown kid costs →](16-jwks-amplification.md) [← the authentication converter](14-authentication-converter.md) · [next: what an unknown kid costs →](16-jwks-amplification.md)
---
The provider side of rotation &mdash; generating, publishing and retiring the keys this
chapter watches from the outside &mdash; is
[`authorization-server/02`](authorization-server/02-minimum-provider.md), and why a demo
provider regenerating its keypair per boot is a feature rather than a bug is in
[`authorization-server/10`](authorization-server/10-should-you.md).

View File

@@ -151,3 +151,9 @@ unless reconfigured. Keycloak puts the client in `azp`.
--- ---
[← what an unknown kid costs](16-jwks-amplification.md) · [next: resource server checklist →](18-resource-server-checklist.md) [← what an unknown kid costs](16-jwks-amplification.md) · [next: resource server checklist →](18-resource-server-checklist.md)
---
For the comparison Keycloak invites &mdash; what it costs to run the equivalent yourself in
Spring &mdash; see [`authorization-server/`](authorization-server/README.md), and in
particular [10 &mdash; Should you run one at all](authorization-server/10-should-you.md).

View File

@@ -80,3 +80,8 @@ document is a list of ways to get something wrong that you could simply not have
--- ---
[← Keycloak setup](17-keycloak-setup.md) · [README](../README.md) [← Keycloak setup](17-keycloak-setup.md) · [README](../README.md)
---
If you also own the issuer, the matching list for that side is
[`authorization-server/10`](authorization-server/10-should-you.md).

View File

@@ -0,0 +1,81 @@
[&larr; index](README.md) &middot; next: [02 &mdash; 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 &ldquo;pin the SAS version from the Boot 4.1 BOM&rdquo;. 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)

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