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:
81
docs/authorization-server/01-versions.md
Normal file
81
docs/authorization-server/01-versions.md
Normal 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 —
|
||||
`org.springframework.security:spring-security-oauth2-authorization-server` — 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 “quite minimal” with “a
|
||||
couple of minor package relocation changes”. 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 — 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) — the rest of what moved in Spring Security 7
|
||||
- [`docs/output/as-settings-defaults.txt`](../output/as-settings-defaults.txt) — defaults read out of the 1.5.8 and 7.1.1 jars side by side
|
||||
|
||||
Next: [02 — The minimum working provider](02-minimum-provider.md)
|
||||
101
docs/authorization-server/02-minimum-provider.md
Normal file
101
docs/authorization-server/02-minimum-provider.md
Normal file
@@ -0,0 +1,101 @@
|
||||
[← 01 Versions](01-versions.md) · [index](README.md) · next: [03 — 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 — 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` — 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 — Clients, PKCE and the defaults that moved](03-clients-and-pkce.md)
|
||||
126
docs/authorization-server/03-clients-and-pkce.md
Normal file
126
docs/authorization-server/03-clients-and-pkce.md
Normal file
@@ -0,0 +1,126 @@
|
||||
[← 02 Minimum provider](02-minimum-provider.md) · [index](README.md) · next: [04 — 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 “works in
|
||||
Postman, not in the browser” 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) — `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) —
|
||||
`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 — 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` —
|
||||
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) — the same `iss`/`aud` questions from the resource server's side
|
||||
- [`docs/05-hs256-vs-rs256.md`](../05-hs256-vs-rs256.md) — why the provider signs with RS256 here
|
||||
|
||||
Next: [04 — The consent page](04-consent-page.md)
|
||||
77
docs/authorization-server/04-consent-page.md
Normal file
77
docs/authorization-server/04-consent-page.md
Normal file
@@ -0,0 +1,77 @@
|
||||
[← 03 Clients and PKCE](03-clients-and-pkce.md) · [index](README.md) · next: [05 — 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=` — 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 — 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 — Token customisation](05-token-customisation.md)
|
||||
106
docs/authorization-server/05-token-customisation.md
Normal file
106
docs/authorization-server/05-token-customisation.md
Normal file
@@ -0,0 +1,106 @@
|
||||
[← 04 Consent page](04-consent-page.md) · [index](README.md) · next: [06 — 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>` — the type used for
|
||||
*opaque* tokens — 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 — and they should
|
||||
— 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 — roles, tenant, entitlements — 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 — same
|
||||
issuer, same signing key — 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"]` — the customiser did not run,
|
||||
because opaque tokens go through `OAuth2TokenClaimsContext`, not `JwtEncodingContext`.
|
||||
|
||||
Next: [06 — The resource server side](06-resource-server.md)
|
||||
96
docs/authorization-server/06-resource-server.md
Normal file
96
docs/authorization-server/06-resource-server.md
Normal file
@@ -0,0 +1,96 @@
|
||||
[← 05 Token customisation](05-token-customisation.md) · [index](README.md) · next: [07 — 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`–`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 — fail fast rather than serve unauthenticated traffic — 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 — 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 — Diagnostics](07-diagnostics.md)
|
||||
61
docs/authorization-server/07-diagnostics.md
Normal file
61
docs/authorization-server/07-diagnostics.md
Normal file
@@ -0,0 +1,61 @@
|
||||
[← 06 Resource server](06-resource-server.md) · [index](README.md) · next: [08 — 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** — 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 — or declined — 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 — The relying party](08-client.md)
|
||||
124
docs/authorization-server/08-client.md
Normal file
124
docs/authorization-server/08-client.md
Normal file
@@ -0,0 +1,124 @@
|
||||
[← 07 Diagnostics](07-diagnostics.md) · [index](README.md) · next: [09 — 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 — one app's session clobbers the other's — 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 — 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 — 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 — The entry point and the Accept header](09-entry-point.md)
|
||||
71
docs/authorization-server/09-entry-point.md
Normal file
71
docs/authorization-server/09-entry-point.md
Normal file
@@ -0,0 +1,71 @@
|
||||
[← 08 The relying party](08-client.md) · [index](README.md) · next: [10 — 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 “the token endpoint
|
||||
returned HTML”.
|
||||
|
||||
## 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 → /login** | **401** |
|
||||
| `application/json` | 401 | 401 |
|
||||
| `text/html` | 302 → /login | 302 → /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 — whose only
|
||||
authentication mechanism is the code verifier — 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 — Should you run one at all](10-should-you.md)
|
||||
49
docs/authorization-server/10-should-you.md
Normal file
49
docs/authorization-server/10-should-you.md
Normal file
@@ -0,0 +1,49 @@
|
||||
[← 09 Entry point](09-entry-point.md) · [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** — 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 “login”, 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
|
||||
— `client_credentials` only, no users, no consent, no browser flows — 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 “write your own”
|
||||
that I would defend without qualification.
|
||||
|
||||
[← back to the index](README.md)
|
||||
64
docs/authorization-server/README.md
Normal file
64
docs/authorization-server/README.md
Normal 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`–`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** — 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 — 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 — Versions, artifacts and the 7.0 move](01-versions.md)
|
||||
Reference in New Issue
Block a user