1
0

Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server

Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.

Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:

  - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
    in 7.0, and both configuration classes moved into spring-security-config
  - ClientSettings.requireProofKey flipped from false to true, on the authorization server
    (1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
  - requireProofKey(false) does not make PKCE optional for a public client; the code
    verifier is that client's only authentication at the token endpoint
  - MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
    API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called

Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
This commit is contained in:
2026-08-24 08:12:36 +05:30
parent 4dc45d5e00
commit e9381dc5be
89 changed files with 5237 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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