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:
140
README.md
140
README.md
@@ -1,15 +1,21 @@
|
||||
# jwt-auth-demo
|
||||
# spring-auth-demo
|
||||
|
||||
Runnable companion code for two articles on [ankurm.com](https://ankurm.com):
|
||||
Runnable companion code for three articles on [ankurm.com](https://ankurm.com):
|
||||
|
||||
| | article | code |
|
||||
|---|---|---|
|
||||
| 1 | [Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) | [`jwt-authentication/`](jwt-authentication) |
|
||||
| 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) |
|
||||
| 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) |
|
||||
|
||||
Two Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
|
||||
tokens with a hand-written filter; the second verifies tokens minted by somebody else —
|
||||
a real Keycloak, and a stub issuer whose signing keys can be rotated on command.
|
||||
Three Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
|
||||
tokens with a hand-written filter. The second verifies tokens minted by somebody else —
|
||||
a real Keycloak, and a stub issuer whose signing keys can be rotated on command. The third
|
||||
*is* the somebody else: a real OAuth2 / OIDC provider, with a client and a resource server
|
||||
in front of it.
|
||||
|
||||
> This repository was called `jwt-auth-demo` until the third project landed. Gitea keeps the
|
||||
> old URL redirecting, but please update any bookmarks to `spring-auth-demo`.
|
||||
|
||||
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output) is
|
||||
real program output, regenerated by a script — not transcribed by hand.
|
||||
@@ -23,6 +29,7 @@ real program output, regenerated by a script — not transcribed by hand.
|
||||
| Nimbus JOSE+JWT | **10.9.1** |
|
||||
| Tomcat | **11.0.24** |
|
||||
| Jackson | **3.1.5** (`tools.jackson`) |
|
||||
| Spring Authorization Server | **7.1.1** — the same artifact, now versioned with Spring Security |
|
||||
| Keycloak | **26.7.2** (resource server project only) |
|
||||
| Caffeine | **3.2.4** (resource server project only) |
|
||||
|
||||
@@ -33,8 +40,8 @@ real program output, regenerated by a script — not transcribed by hand.
|
||||
### Project 1 — JWT authentication with a hand-written filter
|
||||
|
||||
```bash
|
||||
git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
|
||||
cd jwt-auth-demo/jwt-authentication
|
||||
git clone https://ankurm.com/git.app/asmhatre/spring-auth-demo.git
|
||||
cd spring-auth-demo/jwt-authentication
|
||||
./scripts/run.sh hs256 # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256
|
||||
|
||||
# in another shell
|
||||
@@ -44,7 +51,7 @@ cd jwt-auth-demo/jwt-authentication
|
||||
### Project 2 — OAuth2 resource server, JWKS and rotation
|
||||
|
||||
```bash
|
||||
cd jwt-auth-demo/oauth2-resource-server
|
||||
cd spring-auth-demo/oauth2-resource-server
|
||||
|
||||
# a stub issuer whose JWK Set can be mutated on command
|
||||
./scripts/run-stub-issuer.sh
|
||||
@@ -57,6 +64,20 @@ docker compose -f docker/compose.yaml up -d
|
||||
./scripts/keycloak-demo.sh
|
||||
```
|
||||
|
||||
### Project 3 — your own OAuth2 / OIDC provider
|
||||
|
||||
```bash
|
||||
cd spring-auth-demo/authorization-server
|
||||
|
||||
./scripts/run.sh auth # the provider, :9000
|
||||
./scripts/run.sh rs # an API that trusts it, :8090
|
||||
./scripts/run.sh client # a relying party, :8080
|
||||
|
||||
# then open http://127.0.0.1:8080/orders and log in as alice / password
|
||||
# or drive the whole thing with curl:
|
||||
./scripts/authcode-pkce.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project 1 — `jwt-authentication/`
|
||||
@@ -167,9 +188,70 @@ Regenerate its captured output with `./oauth2-resource-server/scripts/run-all.sh
|
||||
|
||||
---
|
||||
|
||||
## Project 3 — `authorization-server/`
|
||||
|
||||
Three Maven modules, three JVMs, three ports. Nothing about an authorization server is
|
||||
observable without a client to drive the browser redirect and a resource server to accept
|
||||
or reject what comes out.
|
||||
|
||||
| module | port | what it is |
|
||||
|---|---|---|
|
||||
| [`auth-server/`](authorization-server/auth-server) | 9000 | the provider: clients, PKCE, consent, token customisation |
|
||||
| [`resource-server/`](authorization-server/resource-server) | 8090 | an API that trusts its tokens |
|
||||
| [`oidc-client/`](authorization-server/oidc-client) | 8080 | a relying party that logs in and calls the API |
|
||||
|
||||
Two users: `alice` / `password` (`ROLE_USER`, `ROLE_ADMIN`) and `bob` / `password`
|
||||
(`ROLE_USER`).
|
||||
|
||||
Three registered clients:
|
||||
|
||||
| client | secret | authentication | grants |
|
||||
|---|---|---|---|
|
||||
| `demo-web` | `web-secret` | `client_secret_basic` | authorization code + refresh |
|
||||
| `demo-spa` | — | `none` (public) | authorization code + refresh |
|
||||
| `demo-service` | `service-secret` | `client_secret_basic` | client credentials |
|
||||
|
||||
### Profiles
|
||||
|
||||
| module | profile | what it changes |
|
||||
|---|---|---|
|
||||
| auth-server | *(none)* | consent on, PKCE required, custom claims, JWT tokens |
|
||||
| auth-server | `noconsent` | `requireAuthorizationConsent(false)` on every client |
|
||||
| auth-server | `nopkce` | `requireProofKey(false)` on the public client |
|
||||
| auth-server | `noclaims` | the `OAuth2TokenCustomizer` bean is not registered |
|
||||
| auth-server | `opaque` | `demo-service` gets reference tokens instead of JWTs |
|
||||
| auth-server | `acceptall` | the entry-point matcher without `setIgnoredMediaTypes` — see [docs/authorization-server/09](docs/authorization-server/09-entry-point.md) |
|
||||
| auth-server | `trace` | `TRACE` logging for `org.springframework.security` |
|
||||
| resource-server | `noaud` | audience validation off, i.e. the Spring Boot default |
|
||||
| oidc-client | `nopkce` | rebuilds the registration the way Spring Security 6.x would |
|
||||
|
||||
### Endpoints
|
||||
|
||||
| method | path | port | what it is |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/.well-known/openid-configuration` | 9000 | OIDC discovery. Only present because `.oidc(...)` is on |
|
||||
| `GET` | `/.well-known/oauth-authorization-server` | 9000 | the OAuth2 metadata document, always present |
|
||||
| `GET` | `/oauth2/jwks` | 9000 | public keys |
|
||||
| `GET` | `/oauth2/authorize` | 9000 | the authorization endpoint |
|
||||
| `POST` | `/oauth2/token` | 9000 | the token endpoint |
|
||||
| `POST` | `/oauth2/introspect` | 9000 | for opaque tokens |
|
||||
| `GET` | `/oauth2/consent` | 9000 | **our** consent page |
|
||||
| `GET` | `/userinfo` | 9000 | OIDC UserInfo |
|
||||
| `GET` | `/diag/settings`, `/diag/clients`, `/diag/chains` | 9000 | **diagnostics. Delete before shipping** |
|
||||
| `GET` | `/api/orders` | 8090 | needs `SCOPE_orders.read` |
|
||||
| `POST` | `/api/orders` | 8090 | needs `SCOPE_orders.write` |
|
||||
| `GET` | `/api/admin` | 8090 | needs `ROLE_ADMIN`, which only exists via the token customiser |
|
||||
| `GET` | `/whoami` | 8090 | everything the resource server decoded |
|
||||
| `GET` | `/orders` | 8080 | the relying party's page; triggers the whole flow |
|
||||
|
||||
Regenerate its captured output with `./authorization-server/scripts/run-all.sh`
|
||||
(no Docker needed; roughly three minutes).
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
One numbered trail across both projects. Start at
|
||||
One numbered trail across the first two projects, plus a separate set for the third. Start at
|
||||
[`docs/01-architecture.md`](docs/01-architecture.md).
|
||||
|
||||
| doc | covers |
|
||||
@@ -193,6 +275,24 @@ One numbered trail across both projects. Start at
|
||||
| [17 — Keycloak setup](docs/17-keycloak-setup.md) | compose, realm import, and three ways it bites |
|
||||
| [18 — Resource server checklist](docs/18-resource-server-checklist.md) | the list for the resource-server side |
|
||||
|
||||
### Project 3 — running your own provider
|
||||
|
||||
A separate chapter set, indexed at
|
||||
[`docs/authorization-server/`](docs/authorization-server/README.md).
|
||||
|
||||
| doc | covers |
|
||||
|---|---|
|
||||
| [01 — Versions and the 7.0 move](docs/authorization-server/01-versions.md) | why there is no SAS version to pin, and which starter to use |
|
||||
| [02 — The minimum working provider](docs/authorization-server/02-minimum-provider.md) | two chains, and the API that replaced `applyDefaultSecurity` |
|
||||
| [03 — Clients, PKCE and the defaults that moved](docs/authorization-server/03-clients-and-pkce.md) | `requireProofKey` flipped to `true` on both sides |
|
||||
| [04 — The consent page](docs/authorization-server/04-consent-page.md) | the form contract, and the redirect loop |
|
||||
| [05 — Token customisation](docs/authorization-server/05-token-customisation.md) | the bean the JWT generator looks for, and the one it ignores |
|
||||
| [06 — The resource server side](docs/authorization-server/06-resource-server.md) | what `issuer-uri` does and does not validate |
|
||||
| [07 — Diagnostics](docs/authorization-server/07-diagnostics.md) | reading the effective configuration back out |
|
||||
| [08 — The relying party](docs/authorization-server/08-client.md) | a real browser flow, and the client-side PKCE default |
|
||||
| [09 — Entry point and the Accept header](docs/authorization-server/09-entry-point.md) | why the token endpoint 302s to a login page |
|
||||
| [10 — Should you run one at all](docs/authorization-server/10-should-you.md) | the honest answer |
|
||||
|
||||
---
|
||||
|
||||
## Captured output
|
||||
@@ -229,6 +329,22 @@ One numbered trail across both projects. Start at
|
||||
| [`rs-keycloak-default-converter.txt`](docs/output/rs-keycloak-default-converter.txt) | real Keycloak, roles unmapped |
|
||||
| [`rs-test-run.txt`](docs/output/rs-test-run.txt) | 10 tests pinning the default validator stack |
|
||||
|
||||
### Project 3
|
||||
|
||||
Indexed in full at
|
||||
[`docs/authorization-server/README.md`](docs/authorization-server/README.md). The ones worth
|
||||
opening first:
|
||||
|
||||
| file | what it shows |
|
||||
|---|---|
|
||||
| [`as-settings-defaults.txt`](docs/output/as-settings-defaults.txt) | `requireProofKey` false in SAS 1.5.8 and Spring Security 6.5.1, true in 7.1.1 — both sides |
|
||||
| [`as-legacy-compile-failure.txt`](docs/output/as-legacy-compile-failure.txt) | the pre-7.0 configuration, and the four compiler errors it now produces |
|
||||
| [`as-authcode-pkce.txt`](docs/output/as-authcode-pkce.txt) | the whole authorization-code + PKCE flow, every parameter visible |
|
||||
| [`as-client-flow-nopkce.txt`](docs/output/as-client-flow-nopkce.txt) | a pre-7.0 client against a 7.1 provider, failing on the client's own error page |
|
||||
| [`as-entrypoint-accept.txt`](docs/output/as-entrypoint-accept.txt) | 302 vs 401 from the token endpoint, decided by the `Accept` header |
|
||||
| [`as-client-credentials-opaque.txt`](docs/output/as-client-credentials-opaque.txt) | a reference token, and what introspection returns for it |
|
||||
| [`as-test-run.txt`](docs/output/as-test-run.txt) | 7 contract tests |
|
||||
|
||||
---
|
||||
|
||||
## Security note
|
||||
@@ -244,6 +360,12 @@ them at anything you care about — see
|
||||
`/api/public/decoder` reads private fields by reflection and prints your JWK Set URI and
|
||||
cache timings. It is a diagnostic. Delete it before you ship.
|
||||
|
||||
The authorization server's `/diag/*` endpoints are the same kind of thing: they publish
|
||||
client ids, grant types, scopes and your filter-chain ordering with no authentication. Its
|
||||
signing key is generated fresh on every boot, and its users are hard-coded. Read
|
||||
[docs/authorization-server/10-should-you.md](docs/authorization-server/10-should-you.md)
|
||||
before taking any of it near production.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
65
authorization-server/auth-server/pom.xml
Normal file
65
authorization-server/auth-server/pom.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>authorization-server-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>auth-server</artifactId>
|
||||
<name>auth-server</name>
|
||||
<description>The OAuth2 / OIDC provider itself</description>
|
||||
|
||||
<dependencies>
|
||||
<!--
|
||||
Boot 4.1 ships TWO starters that pull exactly the same four dependencies:
|
||||
|
||||
spring-boot-starter-oauth2-authorization-server (deprecated)
|
||||
spring-boot-starter-security-oauth2-authorization-server (this one)
|
||||
|
||||
The deprecated one still resolves and still works; its own POM description reads
|
||||
"deprecated in favor of spring-boot-starter-security-oauth2-authorization-server".
|
||||
Both drag in spring-boot-starter-webmvc, so you do not need spring-boot-starter-web
|
||||
as well. See docs/authorization-server/01-versions.md.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Boot 4 split the test slices out of spring-boot-starter-test; @AutoConfigureMockMvc
|
||||
lives in spring-boot-starter-webmvc-test now. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ankurm.authserver;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* The provider. Runs on :9000 and is the issuer for everything else in this module.
|
||||
*
|
||||
* <p>Start it with {@code ../scripts/run.sh auth} (default profile) or with one of the
|
||||
* variant profiles that deliberately break something:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code noconsent} — consent turned off for the confidential client</li>
|
||||
* <li>{@code nopkce} — the public client no longer requires PKCE</li>
|
||||
* <li>{@code noclaims} — the token customiser is not registered</li>
|
||||
* <li>{@code opaque} — the service client gets reference tokens, not JWTs</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see <a href="../../../../../../../../docs/authorization-server/02-minimum-provider.md">
|
||||
* docs/authorization-server/02-minimum-provider.md</a>
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class AuthServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AuthServerApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.ankurm.authserver.config;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService;
|
||||
import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The protocol filter chain — the one that owns {@code /oauth2/**} and the OIDC
|
||||
* endpoints, and nothing else.
|
||||
*
|
||||
* <h2>The two import lines that break every tutorial</h2>
|
||||
*
|
||||
* Up to Spring Authorization Server 1.5.x these two classes lived in the
|
||||
* {@code spring-security-oauth2-authorization-server} jar:
|
||||
*
|
||||
* <pre>
|
||||
* org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
|
||||
* org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer
|
||||
* </pre>
|
||||
*
|
||||
* As of 7.0 they live in {@code spring-security-config}, under different packages:
|
||||
*
|
||||
* <pre>
|
||||
* org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
|
||||
* org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer
|
||||
* </pre>
|
||||
*
|
||||
* And {@code OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)} — the
|
||||
* static method essentially every blog post calls — no longer exists. It was public
|
||||
* and static in 1.5.8; in 7.1.1 the class exposes only {@code @Bean} methods. The
|
||||
* replacement is {@code http.with(new OAuth2AuthorizationServerConfigurer(), ...)}, which
|
||||
* is what this class does.
|
||||
*
|
||||
* @see <a href="../../../../../../../../../docs/authorization-server/01-versions.md">
|
||||
* docs/authorization-server/01-versions.md</a>
|
||||
* @see <a href="../../../../../../../../../docs/authorization-server/02-minimum-provider.md">
|
||||
* docs/authorization-server/02-minimum-provider.md</a>
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class AuthorizationServerConfig {
|
||||
|
||||
/**
|
||||
* Order matters and the ordering is not cosmetic. This chain carries a
|
||||
* {@code securityMatcher} restricted to the protocol endpoints, so it must be
|
||||
* consulted before the catch-all form-login chain in {@link DefaultSecurityConfig}.
|
||||
* If the two are swapped, the form-login chain matches {@code /oauth2/token} first,
|
||||
* the token endpoint is never reached, and a token request 302s to {@code /login}.
|
||||
* That redirect is the fingerprint of a mis-ordered pair of chains.
|
||||
*/
|
||||
@Bean
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public SecurityFilterChain authorizationServerChain(
|
||||
HttpSecurity http,
|
||||
@Value("${demo.ignore-accept-all:true}") boolean ignoreAcceptAll) throws Exception {
|
||||
OAuth2AuthorizationServerConfigurer authorizationServer =
|
||||
new OAuth2AuthorizationServerConfigurer();
|
||||
|
||||
http
|
||||
.securityMatcher(authorizationServer.getEndpointsMatcher())
|
||||
.with(authorizationServer, server -> server
|
||||
// Turning OIDC on is one line, and it is not on by default: without it
|
||||
// there is no /userinfo, no id_token, and no
|
||||
// /.well-known/openid-configuration - only the OAuth2 metadata document
|
||||
// at /.well-known/oauth-authorization-server.
|
||||
.oidc(Customizer.withDefaults())
|
||||
// Replace the built-in consent page with our own. The value is a path the
|
||||
// authorization endpoint redirects the browser to; it is *our* MVC
|
||||
// controller, protected by the form-login chain, and it must POST back to
|
||||
// /oauth2/authorize. See ConsentController.
|
||||
.authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent"))
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
|
||||
// An unauthenticated GET /oauth2/authorize must send a *browser* to the login
|
||||
// page, and must not do that to a machine calling /oauth2/token.
|
||||
//
|
||||
// Scoping the entry point with MediaTypeRequestMatcher(TEXT_HTML) is the
|
||||
// documented way to express that, and on its own it does not work. curl,
|
||||
// most HTTP clients and anything that does not set Accept send `*/*`, and
|
||||
// `*/*` matches text/html, so the matcher fires and the token endpoint
|
||||
// answers a failed public-client authentication with `302 -> /login`
|
||||
// instead of a JSON 401. setIgnoredMediaTypes(ALL) is the line that fixes
|
||||
// it. Run with the `acceptall` profile to see the unfixed behaviour, and
|
||||
// compare docs/output/as-entrypoint-accept.txt.
|
||||
.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
|
||||
new LoginUrlAuthenticationEntryPoint("/login"),
|
||||
htmlOnly(ignoreAcceptAll)))
|
||||
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private static MediaTypeRequestMatcher htmlOnly(boolean ignoreAcceptAll) {
|
||||
MediaTypeRequestMatcher matcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
|
||||
if (ignoreAcceptAll) {
|
||||
matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
|
||||
}
|
||||
return matcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* These two look optional and are not, the moment you write a custom consent page.
|
||||
*
|
||||
* <p>{@code OAuth2AuthorizationServerConfigurer} creates in-memory implementations for
|
||||
* its own use when you do not supply them, but they are not published as beans you can
|
||||
* inject. A {@code ConsentController} that constructor-injects
|
||||
* {@code OAuth2AuthorizationConsentService} therefore fails the context at startup with
|
||||
* {@code No qualifying bean of type ...OAuth2AuthorizationConsentService available} -
|
||||
* the exact message is committed in
|
||||
* {@code docs/output/as-missing-consent-service.txt}. Declaring them here fixes it and,
|
||||
* more usefully, makes the storage decision explicit: both of these are per-instance
|
||||
* memory, so a second replica of the authorization server cannot complete a code
|
||||
* exchange started on the first.
|
||||
*/
|
||||
@Bean
|
||||
public OAuth2AuthorizationService authorizationService() {
|
||||
return new InMemoryOAuth2AuthorizationService();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OAuth2AuthorizationConsentService authorizationConsentService() {
|
||||
return new InMemoryOAuth2AuthorizationConsentService();
|
||||
}
|
||||
|
||||
/**
|
||||
* The issuer identifier. Everything downstream keys off this string: it is the
|
||||
* {@code iss} claim, the base of the discovery document, and the value a resource
|
||||
* server compares against. {@code http://localhost:9000} works only because both
|
||||
* sides agree on it exactly — a trailing slash here and no trailing slash on
|
||||
* the resource server is a mismatch, and produces
|
||||
* {@code The iss claim is not valid} at validation time, not at startup.
|
||||
*/
|
||||
@Bean
|
||||
public AuthorizationServerSettings authorizationServerSettings() {
|
||||
return AuthorizationServerSettings.builder()
|
||||
.issuer("http://localhost:9000")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh RSA keypair per boot. That is deliberate for a demo — restart the
|
||||
* server and every previously issued token stops verifying, which is exactly the
|
||||
* behaviour you want to notice now rather than in production. A real deployment
|
||||
* loads a persistent key (or a rotating set) and serves both the current and the
|
||||
* previous public key from the JWK Set so in-flight tokens survive rotation; that is
|
||||
* the subject of docs/15-jwks-caching-and-rotation.md in the resource-server project.
|
||||
*/
|
||||
@Bean
|
||||
public JWKSource<SecurityContext> jwkSource() {
|
||||
KeyPair keyPair = generateRsaKey();
|
||||
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
|
||||
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
|
||||
RSAKey rsaKey = new RSAKey.Builder(publicKey)
|
||||
.privateKey(privateKey)
|
||||
.keyID(UUID.randomUUID().toString())
|
||||
.build();
|
||||
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
|
||||
}
|
||||
|
||||
private static KeyPair generateRsaKey() {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return generator.generateKeyPair();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("cannot generate an RSA keypair", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The authorization server is also a resource server for its own UserInfo endpoint.
|
||||
* This static helper is one of the few things that survived the 7.0 package move
|
||||
* unchanged in shape - only its package changed.
|
||||
*/
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
|
||||
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ankurm.authserver.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* The second chain: everything that is not a protocol endpoint. The login form, the
|
||||
* consent page, static assets, the diagnostics endpoint.
|
||||
*
|
||||
* <p>Two chains is the shape, not an optimisation. The protocol chain in
|
||||
* {@link AuthorizationServerConfig} carries a {@code securityMatcher} and therefore
|
||||
* declines every request that is not an OAuth2 or OIDC endpoint; something has to pick
|
||||
* those up, and it needs a completely different authentication mechanism (a browser
|
||||
* session, not a bearer token).
|
||||
*
|
||||
* @see <a href="../../../../../../../../../docs/authorization-server/03-clients-and-pkce.md">
|
||||
* docs/authorization-server/03-clients-and-pkce.md</a>
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class DefaultSecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/diag/**", "/error", "/favicon.ico").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.formLogin(Customizer.withDefaults());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two users so the consent screen and the token claims have something to differ on.
|
||||
* {@code alice} is an admin, {@code bob} is not, and the token customiser copies
|
||||
* their authorities into a {@code roles} claim the resource server reads.
|
||||
*
|
||||
* <p>{@code {noop}} is not a placeholder for a password hash - it is a
|
||||
* {@code DelegatingPasswordEncoder} prefix meaning "stored in clear". Useful for a
|
||||
* demo, an incident in production.
|
||||
*/
|
||||
@Bean
|
||||
public UserDetailsService users() {
|
||||
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
UserDetails alice = User.withUsername("alice")
|
||||
.password(encoder.encode("password"))
|
||||
.roles("USER", "ADMIN")
|
||||
.build();
|
||||
UserDetails bob = User.withUsername("bob")
|
||||
.password(encoder.encode("password"))
|
||||
.roles("USER")
|
||||
.build();
|
||||
return new InMemoryUserDetailsManager(alice, bob);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.ankurm.authserver.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Client registration. Three clients, each demonstrating a different half of the
|
||||
* {@link ClientSettings} / {@link TokenSettings} surface.
|
||||
*
|
||||
* <p>A {@code RegisteredClient} is not a user and not a credential - it is a *policy*.
|
||||
* It says which grants this caller may use, which redirect URIs are acceptable, which
|
||||
* scopes it may ask for, whether the user has to consent, whether PKCE is mandatory, and
|
||||
* how long the resulting tokens live. Almost every "it works in Postman but not in the
|
||||
* browser" report is one of these fields.
|
||||
*
|
||||
* @see <a href="../../../../../../../../../docs/authorization-server/03-clients-and-pkce.md">
|
||||
* docs/authorization-server/03-clients-and-pkce.md</a>
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class RegisteredClientConfig {
|
||||
|
||||
/**
|
||||
* Set to {@code false} by the {@code noconsent} profile, and to {@code false} for PKCE
|
||||
* by the {@code nopkce} profile. Both exist so the failure is one flag away and can be
|
||||
* captured as real output rather than described.
|
||||
*/
|
||||
@Bean
|
||||
public RegisteredClientRepository registeredClientRepository(
|
||||
@Value("${demo.require-consent:true}") boolean requireConsent,
|
||||
@Value("${demo.require-pkce:true}") boolean requirePkce,
|
||||
@Value("${demo.service-token-format:jwt}") String serviceTokenFormat) {
|
||||
|
||||
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
|
||||
// ---- 1. A confidential web application: authorization_code + refresh_token ----
|
||||
RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("demo-web")
|
||||
// The secret is *hashed*. Registering a bare string here and then sending
|
||||
// it from the client produces "invalid_client" with no further detail,
|
||||
// because the server bcrypt-compares the presented secret against what it
|
||||
// believes is a hash. This is the single most common first-hour failure.
|
||||
.clientSecret(encoder.encode("web-secret"))
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
|
||||
// Exact-match, including scheme, port and path. No wildcards, no prefix
|
||||
// matching. A mismatch is rejected *before* login, with the error rendered
|
||||
// by the authorization server rather than sent to the client - by design,
|
||||
// since redirecting to an unvalidated URI is the vulnerability.
|
||||
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/demo-web")
|
||||
.postLogoutRedirectUri("http://127.0.0.1:8080/")
|
||||
.scope(OidcScopes.OPENID)
|
||||
.scope(OidcScopes.PROFILE)
|
||||
.scope("orders.read")
|
||||
.scope("orders.write")
|
||||
.clientSettings(ClientSettings.builder()
|
||||
.requireAuthorizationConsent(requireConsent)
|
||||
.build())
|
||||
.tokenSettings(TokenSettings.builder()
|
||||
.accessTokenTimeToLive(Duration.ofMinutes(5))
|
||||
.refreshTokenTimeToLive(Duration.ofMinutes(60))
|
||||
// false means every refresh returns a *new* refresh token and
|
||||
// invalidates the old one. That is rotation, and it is how a
|
||||
// stolen refresh token becomes detectable.
|
||||
.reuseRefreshTokens(false)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
// ---- 2. A public client (SPA / native): no secret, PKCE mandatory ----
|
||||
RegisteredClient spaClient = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("demo-spa")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
|
||||
.redirectUri("http://127.0.0.1:8080/authorized")
|
||||
.scope(OidcScopes.OPENID)
|
||||
.scope("orders.read")
|
||||
.clientSettings(ClientSettings.builder()
|
||||
// With NONE authentication this is the only thing standing between
|
||||
// an intercepted authorization code and an access token. Spring
|
||||
// Authorization Server does not infer it: a public client with
|
||||
// requireProofKey(false) will happily complete a code exchange
|
||||
// with no verifier at all. The `nopkce` profile shows that.
|
||||
.requireProofKey(requirePkce)
|
||||
.requireAuthorizationConsent(requireConsent)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
// ---- 3. A machine client: client_credentials, no user, no refresh token ----
|
||||
RegisteredClient serviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("demo-service")
|
||||
.clientSecret(encoder.encode("service-secret"))
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
|
||||
.scope("orders.read")
|
||||
.tokenSettings(TokenSettings.builder()
|
||||
.accessTokenTimeToLive(Duration.ofMinutes(10))
|
||||
// SELF_CONTAINED is a signed JWT the resource server verifies
|
||||
// offline. REFERENCE is an opaque string that only means anything
|
||||
// to /oauth2/introspect - which turns every API call into a
|
||||
// network round trip to the authorization server, and gives you
|
||||
// instant revocation in exchange. The `opaque` profile flips it.
|
||||
.accessTokenFormat("reference".equals(serviceTokenFormat)
|
||||
? OAuth2TokenFormat.REFERENCE
|
||||
: OAuth2TokenFormat.SELF_CONTAINED)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
return new InMemoryRegisteredClientRepository(webClient, spaClient, serviceClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.ankurm.authserver.diag;
|
||||
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Runtime state that is otherwise invisible: the resolved endpoint paths, the registered
|
||||
* clients as the server actually holds them, and the live filter chains in order.
|
||||
*
|
||||
* <p>This exists because the interesting configuration in an authorization server is
|
||||
* spread across three builders and two filter chains, and the effective result is not
|
||||
* printed anywhere at startup. Reading the beans back is faster than reasoning about them.
|
||||
*
|
||||
* <p><b>Delete this before shipping.</b> It exposes client ids, scopes, grant types and
|
||||
* your chain ordering to anyone who can reach {@code /diag}.
|
||||
*
|
||||
* @see <a href="../../../../../../../../../docs/authorization-server/07-diagnostics.md">
|
||||
* docs/authorization-server/07-diagnostics.md</a>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/diag")
|
||||
public class ProviderDiagnostics {
|
||||
|
||||
private final RegisteredClientRepository clients;
|
||||
private final AuthorizationServerSettings settings;
|
||||
private final FilterChainProxy filterChainProxy;
|
||||
|
||||
public ProviderDiagnostics(RegisteredClientRepository clients,
|
||||
AuthorizationServerSettings settings,
|
||||
FilterChainProxy filterChainProxy) {
|
||||
this.clients = clients;
|
||||
this.settings = settings;
|
||||
this.filterChainProxy = filterChainProxy;
|
||||
}
|
||||
|
||||
/** Every endpoint path the server resolved, including the ones you did not set. */
|
||||
@GetMapping("/settings")
|
||||
public Map<String, Object> settings() {
|
||||
return new LinkedHashMap<>(this.settings.getSettings());
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered clients, flattened. Note that the client secret is not returned -
|
||||
* it is a hash, and printing it invites people to try to use it as a secret.
|
||||
*/
|
||||
@GetMapping("/clients")
|
||||
public List<Map<String, Object>> clients() {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (String id : List.of("demo-web", "demo-spa", "demo-service")) {
|
||||
RegisteredClient c = this.clients.findByClientId(id);
|
||||
if (c == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("clientId", c.getClientId());
|
||||
m.put("authenticationMethods", c.getClientAuthenticationMethods().stream()
|
||||
.map(a -> a.getValue()).toList());
|
||||
m.put("grantTypes", c.getAuthorizationGrantTypes().stream()
|
||||
.map(g -> g.getValue()).toList());
|
||||
m.put("redirectUris", c.getRedirectUris());
|
||||
m.put("scopes", c.getScopes());
|
||||
m.put("requireProofKey", c.getClientSettings().isRequireProofKey());
|
||||
m.put("requireAuthorizationConsent",
|
||||
c.getClientSettings().isRequireAuthorizationConsent());
|
||||
m.put("accessTokenFormat",
|
||||
c.getTokenSettings().getAccessTokenFormat().getValue());
|
||||
m.put("accessTokenTtlSeconds",
|
||||
c.getTokenSettings().getAccessTokenTimeToLive().toSeconds());
|
||||
m.put("reuseRefreshTokens", c.getTokenSettings().isReuseRefreshTokens());
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter chains in the order Spring Security will consult them, with the matcher
|
||||
* each one carries. If the authorization server chain is not first, the token endpoint
|
||||
* is unreachable - and this is where you see that, rather than inferring it from a 302.
|
||||
*/
|
||||
@GetMapping("/chains")
|
||||
public List<Map<String, Object>> chains() {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
int i = 0;
|
||||
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("position", i++);
|
||||
m.put("matcher", String.valueOf(chain));
|
||||
m.put("filters", chain.getFilters().stream()
|
||||
.map(f -> f.getClass().getSimpleName()).toList());
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.ankurm.authserver.token;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
|
||||
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Token customisation — the hook that decides what a downstream service can
|
||||
* authorise on without calling back.
|
||||
*
|
||||
* <p>One bean of type {@code OAuth2TokenCustomizer<JwtEncodingContext>} is picked up
|
||||
* automatically by the JWT generator. There is no registration step and no annotation;
|
||||
* if the bean exists with that exact generic type, it runs. Declare it as
|
||||
* {@code OAuth2TokenCustomizer<OAuth2TokenClaimsContext>} by mistake — the type used
|
||||
* for *opaque* tokens — and it will be silently ignored, because the JWT generator
|
||||
* resolves the bean by generic type and simply will not find it. Nothing logs. Your
|
||||
* claims are just absent.
|
||||
*
|
||||
* <p>The {@code noclaims} profile disables this bean, so the difference is a diff of two
|
||||
* decoded tokens rather than a paragraph.
|
||||
*
|
||||
* @see <a href="../../../../../../../../../docs/authorization-server/05-token-customisation.md">
|
||||
* docs/authorization-server/05-token-customisation.md</a>
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@Profile("!noclaims")
|
||||
public class TokenClaimsCustomizer {
|
||||
|
||||
@Bean
|
||||
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
|
||||
return context -> {
|
||||
// context.getTokenType() distinguishes the access token, the id_token and the
|
||||
// refresh token. Writing the same claim into all three is a common accident:
|
||||
// an id_token is for the client, an access token is for the API, and putting
|
||||
// authorisation data in the id_token invites the client to make security
|
||||
// decisions it has no business making.
|
||||
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
|
||||
context.getClaims().claim("tenant", "acme");
|
||||
|
||||
// client_credentials has no user: getPrincipal() is the client's own
|
||||
// authentication, and asking it for roles yields nothing useful. Guard on
|
||||
// the grant type or on whether a user is present, or a machine token
|
||||
// silently inherits whatever authorities the client authentication carries.
|
||||
if (context.getPrincipal() != null
|
||||
&& context.getAuthorizationGrantType() != null
|
||||
&& !"client_credentials".equals(
|
||||
context.getAuthorizationGrantType().getValue())) {
|
||||
Set<String> roles = context.getPrincipal().getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.filter(a -> a.startsWith("ROLE_"))
|
||||
.map(a -> a.substring("ROLE_".length()))
|
||||
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||
context.getClaims().claim("roles", roles);
|
||||
}
|
||||
|
||||
// The audience is what stops a token minted for one API being replayed
|
||||
// against another. Spring Authorization Server does not populate `aud` on
|
||||
// access tokens by default - there is no per-client audience setting - so
|
||||
// if your resource servers validate audience (they should), this line or
|
||||
// something like it is mandatory.
|
||||
context.getClaims().audience(List.of("orders-api"));
|
||||
}
|
||||
|
||||
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
|
||||
// Profile-ish claims for the relying party to render. Deliberately not
|
||||
// roles: see above.
|
||||
context.getClaims().claim("preferred_username",
|
||||
context.getPrincipal().getName());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.ankurm.authserver.web;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The consent page.
|
||||
*
|
||||
* <p>Wiring a custom one is deceptively small: {@code .consentPage("/oauth2/consent")} on
|
||||
* the authorization endpoint, plus a controller that renders a form. What the
|
||||
* documentation does not spell out is the contract the form has to satisfy, and getting
|
||||
* any part of it wrong produces a redirect loop rather than an error:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the form POSTs back to <b>{@code /oauth2/authorize}</b>, not to the consent path</li>
|
||||
* <li>it must echo {@code client_id} and {@code state} exactly as received</li>
|
||||
* <li>each approved scope goes back as a separate {@code scope} parameter</li>
|
||||
* <li>CSRF token included — this is the browser chain, not the protocol chain</li>
|
||||
* <li>{@code openid} is <b>not</b> shown as a checkbox: it is requested implicitly and
|
||||
* the server does not require consent for it</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Miss the {@code state} parameter and the authorization endpoint cannot correlate the
|
||||
* approval with the pending request, so it starts a new one — which redirects to the
|
||||
* consent page again. The loop looks like a session problem and is not.
|
||||
*
|
||||
* @see <a href="../../../../../../../../../docs/authorization-server/04-consent-page.md">
|
||||
* docs/authorization-server/04-consent-page.md</a>
|
||||
*/
|
||||
@Controller
|
||||
public class ConsentController {
|
||||
|
||||
private final RegisteredClientRepository registeredClientRepository;
|
||||
private final OAuth2AuthorizationConsentService authorizationConsentService;
|
||||
|
||||
public ConsentController(RegisteredClientRepository registeredClientRepository,
|
||||
OAuth2AuthorizationConsentService authorizationConsentService) {
|
||||
this.registeredClientRepository = registeredClientRepository;
|
||||
this.authorizationConsentService = authorizationConsentService;
|
||||
}
|
||||
|
||||
@GetMapping("/oauth2/consent")
|
||||
public String consent(Principal principal, Model model,
|
||||
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
|
||||
@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
|
||||
@RequestParam(OAuth2ParameterNames.STATE) String state,
|
||||
@RequestParam(name = "user_code", required = false) String userCode,
|
||||
HttpServletRequest request) {
|
||||
|
||||
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
|
||||
if (registeredClient == null) {
|
||||
throw new IllegalArgumentException("unknown client_id: " + clientId);
|
||||
}
|
||||
|
||||
// Scopes the user has already approved in an earlier authorization are pre-ticked
|
||||
// and, in the default flow, would not have brought us here at all - the consent
|
||||
// page only appears for scopes not yet consented to.
|
||||
OAuth2AuthorizationConsent currentConsent =
|
||||
this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());
|
||||
Set<String> alreadyApproved = currentConsent != null
|
||||
? currentConsent.getScopes()
|
||||
: Set.of();
|
||||
|
||||
Set<String> toApprove = new LinkedHashSet<>();
|
||||
Set<String> previouslyApproved = new LinkedHashSet<>();
|
||||
for (String requested : scope.split(" ")) {
|
||||
// openid is requested implicitly by every OIDC client and the authorization
|
||||
// endpoint never asks for consent on it. Rendering it as a checkbox is
|
||||
// harmless but misleading, and unticking it does nothing.
|
||||
if (OidcScopes.OPENID.equals(requested)) {
|
||||
continue;
|
||||
}
|
||||
if (alreadyApproved.contains(requested)) {
|
||||
previouslyApproved.add(requested);
|
||||
} else {
|
||||
toApprove.add(requested);
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("clientId", clientId);
|
||||
model.addAttribute("clientName", registeredClient.getClientName());
|
||||
model.addAttribute("state", state);
|
||||
model.addAttribute("scopes", toApprove);
|
||||
model.addAttribute("previouslyApprovedScopes", previouslyApproved);
|
||||
model.addAttribute("principalName", principal.getName());
|
||||
model.addAttribute("userCode", userCode);
|
||||
model.addAttribute("requestURI", "/oauth2/authorize");
|
||||
return "consent";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# The entry-point matcher is left as MediaTypeRequestMatcher(TEXT_HTML) with no ignored
|
||||
# types. `Accept: */*` then matches text/html and the token endpoint redirects API callers
|
||||
# to the HTML login page. See docs/authorization-server/09-entry-point.md.
|
||||
demo:
|
||||
ignore-accept-all: false
|
||||
@@ -0,0 +1,3 @@
|
||||
# The OAuth2TokenCustomizer bean is not registered (see TokenClaimsCustomizer's @Profile).
|
||||
# Diff a token from this profile against a default one to see exactly what the default
|
||||
# access token does and does not carry.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Consent off. The authorization endpoint issues the code immediately after login.
|
||||
# Correct for a first-party client you own; wrong the moment a third party registers.
|
||||
demo:
|
||||
require-consent: false
|
||||
@@ -0,0 +1,4 @@
|
||||
# The public client no longer requires a code_verifier. The code exchange then succeeds
|
||||
# with nothing but the authorization code - which is the whole attack PKCE prevents.
|
||||
demo:
|
||||
require-pkce: false
|
||||
@@ -0,0 +1,4 @@
|
||||
# demo-service gets REFERENCE (opaque) access tokens instead of self-contained JWTs.
|
||||
# The string is meaningless to a resource server without a call to /oauth2/introspect.
|
||||
demo:
|
||||
service-token-format: reference
|
||||
@@ -0,0 +1,5 @@
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: TRACE
|
||||
org.springframework.security.oauth2.server.authorization: TRACE
|
||||
org.springframework.security.web.FilterChainProxy: DEBUG
|
||||
@@ -0,0 +1,19 @@
|
||||
server:
|
||||
port: 9000
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: auth-server
|
||||
thymeleaf:
|
||||
cache: false
|
||||
|
||||
# Flags the RegisteredClientRepository reads. Every variant profile below flips exactly
|
||||
# one of them, so the resulting failure is attributable to one line.
|
||||
demo:
|
||||
require-consent: true
|
||||
require-pkce: true
|
||||
service-token-format: jwt
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: INFO
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Approve access</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 4rem auto; color: #222; }
|
||||
.card { border: 1px solid #e2e5ea; border-radius: 8px; padding: 1.5rem 1.75rem; }
|
||||
.scope { display: block; margin: .4rem 0; }
|
||||
code { background: #f4f5f7; padding: .1rem .3rem; border-radius: 3px; }
|
||||
button { padding: .5rem 1rem; border-radius: 6px; border: 1px solid #b7bec9; cursor: pointer; }
|
||||
.primary { background: #2f6fdb; color: #fff; border-color: #2f6fdb; }
|
||||
.muted { color: #667; font-size: .9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Approve access</h2>
|
||||
<p>
|
||||
Signed in as <b th:text="${principalName}">user</b>.
|
||||
The application <code th:text="${clientId}">client</code> wants to act on your behalf.
|
||||
</p>
|
||||
|
||||
<!--
|
||||
The form POSTs to /oauth2/authorize, NOT to /oauth2/consent. The authorization
|
||||
endpoint owns both halves of the exchange; the consent page is only a renderer.
|
||||
-->
|
||||
<form method="post" th:action="@{${requestURI}}">
|
||||
<!-- Echoed back verbatim. Drop `state` and the endpoint cannot correlate this
|
||||
approval with the pending authorization request, and you get a redirect loop. -->
|
||||
<input type="hidden" name="client_id" th:value="${clientId}">
|
||||
<input type="hidden" name="state" th:value="${state}">
|
||||
<input type="hidden" name="user_code" th:value="${userCode}" th:if="${userCode}">
|
||||
|
||||
<p><b>This application will be able to:</b></p>
|
||||
<label class="scope" th:each="s : ${scopes}">
|
||||
<!-- One `scope` parameter per approved scope. A single space-joined value is
|
||||
accepted by the parameter binder and then silently treated as one unknown
|
||||
scope name, so consent appears to succeed and the token comes back short. -->
|
||||
<input type="checkbox" name="scope" th:value="${s}" checked>
|
||||
<code th:text="${s}">scope</code>
|
||||
</label>
|
||||
|
||||
<div th:if="${!previouslyApprovedScopes.isEmpty()}">
|
||||
<p class="muted">Already approved previously:</p>
|
||||
<label class="scope" th:each="s : ${previouslyApprovedScopes}">
|
||||
<input type="checkbox" disabled checked>
|
||||
<code th:text="${s}">scope</code>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="muted">
|
||||
<code>openid</code> is requested implicitly and is not listed — the
|
||||
authorization server never asks for consent on it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<button class="primary" type="submit" id="approve">Approve</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<form method="post" th:action="@{${requestURI}}">
|
||||
<input type="hidden" name="client_id" th:value="${clientId}">
|
||||
<input type="hidden" name="state" th:value="${state}">
|
||||
<!-- No `scope` parameters at all is how you say "denied". The endpoint then redirects
|
||||
back to the client with error=access_denied. -->
|
||||
<button type="submit" id="deny">Deny</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ankurm.authserver;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Contract tests, not happy-path tests. Each one pins a decision that would otherwise be
|
||||
* silently reversible by a config change.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class ProviderContractTests {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired RegisteredClientRepository clients;
|
||||
|
||||
@Test
|
||||
void discoveryAdvertisesTheConfiguredIssuer() throws Exception {
|
||||
this.mvc.perform(get("/.well-known/openid-configuration"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.issuer").value("http://localhost:9000"))
|
||||
// Present only because .oidc(...) was switched on.
|
||||
.andExpect(jsonPath("$.userinfo_endpoint").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jwkSetNeverExposesPrivateMaterial() throws Exception {
|
||||
String body = this.mvc.perform(get("/oauth2/jwks"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
// "d" is the RSA private exponent. Its presence here would be a full compromise.
|
||||
assertThat(body).doesNotContain("\"d\"").doesNotContain("\"p\"").doesNotContain("\"q\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePublicClientRequiresProofKey() {
|
||||
RegisteredClient spa = this.clients.findByClientId("demo-spa");
|
||||
assertThat(spa).isNotNull();
|
||||
assertThat(spa.getClientSettings().isRequireProofKey())
|
||||
.as("a public client without PKCE can have its authorization code replayed")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireProofKeyIsOnByDefaultInSevenPointOne() {
|
||||
// demo-service never touches ClientSettings. In Spring Authorization Server 1.5.8
|
||||
// this was false; in 7.1.1 the default flipped to true, which silently makes PKCE
|
||||
// mandatory for every client you did not think about. See
|
||||
// docs/output/as-settings-defaults.txt.
|
||||
RegisteredClient service = this.clients.findByClientId("demo-service");
|
||||
assertThat(service.getClientSettings().isRequireProofKey()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshTokensRotate() {
|
||||
RegisteredClient web = this.clients.findByClientId("demo-web");
|
||||
assertThat(web.getTokenSettings().isReuseRefreshTokens())
|
||||
.as("reuse means a stolen refresh token stays valid until it expires")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedTokenRequestFromAnApiClientIsNotAnHtmlRedirect() throws Exception {
|
||||
// Accept: */* is what curl and most HTTP clients send. Without
|
||||
// setIgnoredMediaTypes(ALL) on the entry-point matcher this is a 302 to /login.
|
||||
this.mvc.perform(post("/oauth2/token")
|
||||
.accept(MediaType.ALL)
|
||||
.param("grant_type", "authorization_code")
|
||||
.param("code", "bogus")
|
||||
.param("client_id", "demo-spa"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBrowserStillGetsRedirectedToTheLoginPage() throws Exception {
|
||||
this.mvc.perform(get("/oauth2/authorize")
|
||||
.accept(MediaType.TEXT_HTML)
|
||||
.queryParam("response_type", "code")
|
||||
.queryParam("client_id", "demo-web")
|
||||
.queryParam("redirect_uri", "http://127.0.0.1:8080/login/oauth2/code/demo-web")
|
||||
.queryParam("scope", "openid")
|
||||
// Required even for a confidential client, because requireProofKey
|
||||
// now defaults to true. Omit it and the authorization endpoint
|
||||
// redirects to the client with error=invalid_request rather than
|
||||
// sending the browser to the login page.
|
||||
.queryParam("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
|
||||
.queryParam("code_challenge_method", "S256"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
}
|
||||
}
|
||||
40
authorization-server/oidc-client/pom.xml
Normal file
40
authorization-server/oidc-client/pom.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>authorization-server-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>oidc-client</artifactId>
|
||||
<name>oidc-client</name>
|
||||
<description>A relying party that logs in against auth-server and calls resource-server</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-oauth2-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.client;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* The relying party. Runs on :8080 and is the only piece a human touches.
|
||||
*
|
||||
* @see <a href="../../../../../../../../docs/authorization-server/08-client.md">
|
||||
* docs/authorization-server/08-client.md</a>
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class ClientApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ClientApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.client;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* The whole client side of OIDC, in one method.
|
||||
*
|
||||
* <p>{@code oauth2Login()} makes this an OIDC relying party: it adds the redirect
|
||||
* endpoint at {@code /login/oauth2/code/{registrationId}}, exchanges the code, validates
|
||||
* the {@code id_token}, and creates a session. {@code oauth2Client()} additionally makes
|
||||
* the access token available to outgoing HTTP calls.
|
||||
*
|
||||
* <p>The redirect URI is derived from the registration id, and it must match what the
|
||||
* provider has registered <b>byte for byte</b>. This app is reached at
|
||||
* {@code 127.0.0.1:8080} rather than {@code localhost:8080} for a reason: they are
|
||||
* different origins to a browser cookie jar, and running the client on {@code localhost}
|
||||
* next to an authorization server on {@code localhost} means one session cookie can
|
||||
* clobber the other. Using {@code 127.0.0.1} for the client keeps the two sessions
|
||||
* separate, which is the difference between a working demo and a login loop.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class ClientSecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/", "/error").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.oauth2Client(Customizer.withDefaults())
|
||||
.logout(logout -> logout.logoutSuccessUrl("/"));
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.client;
|
||||
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Renders what the client received, and calls the resource server with it.
|
||||
*
|
||||
* <p>{@code @RegisteredOAuth2AuthorizedClient} hands you the access token Spring already
|
||||
* holds for this user and this registration. Reading it out of the {@code OidcUser} would
|
||||
* give you the <i>id_token</i> instead - a mistake that produces a 401 from the resource
|
||||
* server with a token that looks perfectly valid, because it is: it is just the wrong one.
|
||||
*/
|
||||
@Controller
|
||||
public class HomeController {
|
||||
|
||||
private final RestClient restClient = RestClient.create();
|
||||
|
||||
@GetMapping("/")
|
||||
public String home(@AuthenticationPrincipal OidcUser user, Model model) {
|
||||
model.addAttribute("user", user);
|
||||
if (user != null) {
|
||||
model.addAttribute("idTokenClaims", user.getIdToken().getClaims());
|
||||
}
|
||||
return "home";
|
||||
}
|
||||
|
||||
@GetMapping("/orders")
|
||||
public String orders(@RegisteredOAuth2AuthorizedClient("demo-web") OAuth2AuthorizedClient client,
|
||||
Model model) {
|
||||
String token = client.getAccessToken().getTokenValue();
|
||||
Map<String, Object> body;
|
||||
try {
|
||||
body = this.restClient.get()
|
||||
.uri("http://localhost:8090/api/orders")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
} catch (Exception ex) {
|
||||
body = new LinkedHashMap<>(Map.of("error", String.valueOf(ex.getMessage())));
|
||||
}
|
||||
model.addAttribute("accessToken", token);
|
||||
model.addAttribute("scopes", client.getAccessToken().getScopes());
|
||||
model.addAttribute("orders", body);
|
||||
return "orders";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.ankurm.client;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
|
||||
|
||||
/**
|
||||
* Reproduces the pre-7.0 client, so the mismatch can be observed rather than described.
|
||||
*
|
||||
* <h2>What changed, on both sides at once</h2>
|
||||
*
|
||||
* Read out of the jars with {@code javap} — see
|
||||
* {@code docs/output/as-settings-defaults.txt}:
|
||||
*
|
||||
* <table>
|
||||
* <tr><th></th><th>previous</th><th>current</th></tr>
|
||||
* <tr><td>Authorization server — {@code ClientSettings.requireProofKey}</td>
|
||||
* <td>{@code false} (SAS 1.5.8)</td><td>{@code true} (7.1.1)</td></tr>
|
||||
* <tr><td>Client — {@code ClientRegistration.ClientSettings.requireProofKey}</td>
|
||||
* <td>{@code false} (Spring Security 6.5.1)</td><td>{@code true} (7.1.1)</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p>Because both moved together, Spring-client-to-Spring-server keeps working. The
|
||||
* combination that breaks is a <b>7.1 authorization server with anything older or
|
||||
* anything hand-rolled</b> in front of it: a Spring Security 6.x confidential client, a
|
||||
* bespoke server-side integration, a Postman collection someone saved last year. The
|
||||
* server rejects the authorization request before login, and the user sees the client's
|
||||
* error page rather than the provider's:
|
||||
*
|
||||
* <pre>
|
||||
* error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge
|
||||
* </pre>
|
||||
*
|
||||
* <p>Activating the {@code nopkce} profile rebuilds this client's registration with
|
||||
* {@code requireProofKey(false)}, which is exactly what a 6.x client would have sent.
|
||||
*
|
||||
* @see <a href="../../../../../../../../docs/authorization-server/08-client.md">
|
||||
* docs/authorization-server/08-client.md</a>
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@Profile("nopkce")
|
||||
public class PkceConfig {
|
||||
|
||||
/**
|
||||
* Note that {@code DefaultOAuth2AuthorizationRequestResolver} consults
|
||||
* {@code ClientRegistration}'s own {@code ClientSettings}, not the resolver's
|
||||
* customizer — so setting a customizer cannot turn PKCE <i>off</i>. It has to be
|
||||
* turned off on the registration itself.
|
||||
*
|
||||
* <p>A {@code @Bean} that takes {@code ClientRegistrationRepository} and returns one
|
||||
* is a dependency cycle, and Boot refuses to start
|
||||
* ({@code Relying upon circular references is discouraged and they are prohibited by
|
||||
* default}). Post-processing the bean Boot already built avoids that.
|
||||
*/
|
||||
@Bean
|
||||
static BeanPostProcessor downgradeToPreSevenClient() {
|
||||
return new BeanPostProcessor() {
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String name)
|
||||
throws BeansException {
|
||||
if (!(bean instanceof ClientRegistrationRepository repo)) {
|
||||
return bean;
|
||||
}
|
||||
ClientRegistration original = repo.findByRegistrationId("demo-web");
|
||||
if (original == null) {
|
||||
return bean;
|
||||
}
|
||||
return new InMemoryClientRegistrationRepository(
|
||||
ClientRegistration.withClientRegistration(original)
|
||||
.clientSettings(ClientRegistration.ClientSettings.builder()
|
||||
.requireProofKey(false)
|
||||
.build())
|
||||
.build());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# PkceConfig is @Profile("nopkce"): it rebuilds the demo-web registration with
|
||||
# requireProofKey(false), reproducing what a Spring Security 6.x confidential client
|
||||
# sends. See docs/authorization-server/08-client.md.
|
||||
@@ -0,0 +1,27 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: oidc-client
|
||||
thymeleaf:
|
||||
cache: false
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
# One provider entry. Spring reads
|
||||
# http://localhost:9000/.well-known/openid-configuration at first use and fills in
|
||||
# every endpoint from it - authorization, token, jwks, userinfo, issuer.
|
||||
provider:
|
||||
demo-provider:
|
||||
issuer-uri: http://localhost:9000
|
||||
registration:
|
||||
demo-web:
|
||||
provider: demo-provider
|
||||
client-id: demo-web
|
||||
client-secret: web-secret
|
||||
authorization-grant-type: authorization_code
|
||||
client-authentication-method: client_secret_basic
|
||||
# Must equal the redirect-uri registered on the server, exactly.
|
||||
redirect-uri: "http://127.0.0.1:8080/login/oauth2/code/demo-web"
|
||||
scope: openid,profile,orders.read,orders.write
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>OIDC client</title>
|
||||
<style>body{font-family:system-ui,sans-serif;max-width:44rem;margin:3rem auto;color:#222}
|
||||
pre{background:#f4f5f7;padding:1rem;border-radius:6px;overflow:auto}</style></head>
|
||||
<body>
|
||||
<h2>Relying party</h2>
|
||||
<div th:if="${user == null}">
|
||||
<p>Not signed in.</p>
|
||||
<p><a href="/orders">Go to /orders</a> — this triggers the authorization code flow.</p>
|
||||
</div>
|
||||
<div th:if="${user != null}">
|
||||
<p>Signed in as <b th:text="${user.name}">user</b>.</p>
|
||||
<p><a href="/orders">/orders</a> · <a href="/logout">log out</a></p>
|
||||
<h3>id_token claims</h3>
|
||||
<pre th:text="${idTokenClaims}"></pre>
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Orders</title>
|
||||
<style>body{font-family:system-ui,sans-serif;max-width:44rem;margin:3rem auto;color:#222}
|
||||
pre{background:#f4f5f7;padding:1rem;border-radius:6px;overflow:auto;word-break:break-all;white-space:pre-wrap}</style></head>
|
||||
<body>
|
||||
<h2>Resource server response</h2>
|
||||
<pre th:text="${orders}"></pre>
|
||||
<h3>Granted scopes</h3>
|
||||
<pre th:text="${scopes}"></pre>
|
||||
<h3>Access token (raw)</h3>
|
||||
<pre th:text="${accessToken}"></pre>
|
||||
<p><a href="/">back</a></p>
|
||||
</body></html>
|
||||
44
authorization-server/pom.xml
Normal file
44
authorization-server/pom.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>authorization-server-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>authorization-server-demo</name>
|
||||
<description>Spring Authorization Server 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com</description>
|
||||
|
||||
<!--
|
||||
Three modules, three JVMs, three ports. Running your own provider means running all
|
||||
three: nothing about the auth server is observable without a client that drives the
|
||||
browser redirect and a resource server that accepts (or rejects) what comes out.
|
||||
|
||||
auth-server :9000 the provider
|
||||
resource-server :8090 validates its tokens
|
||||
oidc-client :8080 the relying party
|
||||
|
||||
Every dependency version comes from spring-boot-dependencies 4.1.1. Nothing here
|
||||
pins a Spring Authorization Server version explicitly, because as of Spring Security
|
||||
7.0 there is no separate version to pin - see docs/authorization-server/01-versions.md.
|
||||
-->
|
||||
<modules>
|
||||
<module>auth-server</module>
|
||||
<module>resource-server</module>
|
||||
<module>oidc-client</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
</project>
|
||||
39
authorization-server/resource-server/pom.xml
Normal file
39
authorization-server/resource-server/pom.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>authorization-server-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>resource-server</artifactId>
|
||||
<name>resource-server</name>
|
||||
<description>An API that trusts tokens minted by auth-server</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<!-- Same rename as the authorization server starter: the canonical name in Boot 4.1
|
||||
is spring-boot-starter-security-oauth2-resource-server. The older
|
||||
spring-boot-starter-oauth2-resource-server is deprecated but still resolves. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ankurm.rs;
|
||||
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Four endpoints, each guarded by a different consequence of the token's contents. */
|
||||
@RestController
|
||||
public class ApiController {
|
||||
|
||||
@GetMapping("/public")
|
||||
public Map<String, Object> open() {
|
||||
return Map.of("message", "no token required");
|
||||
}
|
||||
|
||||
/** Requires SCOPE_orders.read. */
|
||||
@GetMapping("/api/orders")
|
||||
public Map<String, Object> orders(@AuthenticationPrincipal Jwt jwt) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("orders", List.of(Map.of("id", 1, "total", "42.00")));
|
||||
out.put("subject", jwt.getSubject());
|
||||
out.put("clientId", jwt.getClaimAsString("azp") != null
|
||||
? jwt.getClaimAsString("azp") : jwt.getClaimAsString("client_id"));
|
||||
out.put("scopes", jwt.getClaimAsStringList("scope"));
|
||||
out.put("roles", jwt.getClaimAsStringList("roles"));
|
||||
out.put("tenant", jwt.getClaimAsString("tenant"));
|
||||
out.put("audience", jwt.getAudience());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Requires SCOPE_orders.write. */
|
||||
@PostMapping("/api/orders")
|
||||
public Map<String, Object> create(@AuthenticationPrincipal Jwt jwt) {
|
||||
return Map.of("created", true, "by", jwt.getSubject());
|
||||
}
|
||||
|
||||
/** Requires ROLE_ADMIN, which only exists because of the provider's token customiser. */
|
||||
@GetMapping("/api/admin")
|
||||
public Map<String, Object> admin() {
|
||||
return Map.of(
|
||||
"message", "admin only",
|
||||
"authorities", SecurityContextHolder.getContext().getAuthentication()
|
||||
.getAuthorities().stream().map(Object::toString).toList());
|
||||
}
|
||||
|
||||
/** Whatever the resource server actually decoded. Useful when a 403 makes no sense. */
|
||||
@GetMapping("/whoami")
|
||||
public Map<String, Object> whoami(@AuthenticationPrincipal Jwt jwt) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("claims", jwt.getClaims());
|
||||
out.put("headers", jwt.getHeaders());
|
||||
out.put("authorities", SecurityContextHolder.getContext().getAuthentication()
|
||||
.getAuthorities().stream().map(Object::toString).toList());
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.rs;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* The API that trusts what the provider mints. Runs on :8090.
|
||||
*
|
||||
* @see <a href="../../../../../../../../docs/authorization-server/06-resource-server.md">
|
||||
* docs/authorization-server/06-resource-server.md</a>
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class ResourceServerApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ResourceServerApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.ankurm.rs;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimNames;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoders;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What a resource server has to do with the provider's tokens, and what it gets for free.
|
||||
*
|
||||
* <p>{@code spring.security.oauth2.resourceserver.jwt.issuer-uri} alone gives you
|
||||
* signature verification, {@code exp}/{@code nbf}, and an {@code iss} check. It does
|
||||
* <b>not</b> give you an audience check, and it does not map {@code scope} to anything
|
||||
* other than {@code SCOPE_*} authorities. Both of those gaps are filled here.
|
||||
*
|
||||
* @see <a href="../../../../../../../../docs/authorization-server/06-resource-server.md">
|
||||
* docs/authorization-server/06-resource-server.md</a>
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain api(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/public").permitAll()
|
||||
// SCOPE_ is the prefix JwtGrantedAuthoritiesConverter uses for the
|
||||
// `scope` (or `scp`) claim. ROLE_ comes from our own mapping below.
|
||||
.requestMatchers("/api/orders").hasAuthority("SCOPE_orders.read")
|
||||
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders.write")
|
||||
.requestMatchers("/api/admin").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
||||
// No sessions, no CSRF - the token is the entire request context. Leaving CSRF
|
||||
// on for a bearer-token API produces a 403 on every POST with no useful body,
|
||||
// which is the single most-reported false alarm in this space.
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(
|
||||
org.springframework.security.config.http.SessionCreationPolicy.STATELESS));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Built explicitly rather than left to auto-configuration, purely so the audience
|
||||
* validator can be added. {@code JwtValidators.createDefaultWithIssuer} is the same
|
||||
* stack Boot would have installed: timestamp + issuer.
|
||||
*/
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder(
|
||||
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
|
||||
@Value("${demo.required-audience:orders-api}") String audience,
|
||||
@Value("${demo.validate-audience:true}") boolean validateAudience) {
|
||||
|
||||
NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuer);
|
||||
|
||||
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
|
||||
if (!validateAudience) {
|
||||
decoder.setJwtValidator(withIssuer);
|
||||
return decoder;
|
||||
}
|
||||
|
||||
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
|
||||
List<String> aud = jwt.getClaimAsStringList(JwtClaimNames.AUD);
|
||||
if (aud != null && aud.contains(audience)) {
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
// The error code matters: `invalid_token` is what puts the reason into the
|
||||
// WWW-Authenticate header, which is the only place a client will see it.
|
||||
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
|
||||
"invalid_token",
|
||||
"the required audience " + audience + " is missing",
|
||||
"https://tools.ietf.org/html/rfc6750#section-3.1"));
|
||||
};
|
||||
|
||||
decoder.setJwtValidator(new org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator<>(
|
||||
withIssuer, audienceValidator));
|
||||
return decoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the custom {@code roles} claim the provider's token customiser writes onto
|
||||
* {@code ROLE_*} authorities, while keeping the default {@code SCOPE_*} mapping.
|
||||
* Returning a converter that only handles {@code roles} silently deletes every
|
||||
* scope authority, which turns {@code hasAuthority("SCOPE_orders.read")} into a 403
|
||||
* on a perfectly valid token.
|
||||
*/
|
||||
@Bean
|
||||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
|
||||
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
|
||||
var authorities = new java.util.ArrayList<org.springframework.security.core.GrantedAuthority>(
|
||||
scopes.convert(jwt));
|
||||
List<String> roles = jwt.getClaimAsStringList("roles");
|
||||
if (roles != null) {
|
||||
roles.forEach(r -> authorities.add(
|
||||
new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_" + r)));
|
||||
}
|
||||
return authorities;
|
||||
});
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Audience validation off - the default Boot behaviour. A token minted for a different
|
||||
# API but signed by the same issuer is now accepted here.
|
||||
demo:
|
||||
validate-audience: false
|
||||
@@ -0,0 +1,18 @@
|
||||
server:
|
||||
port: 8090
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: resource-server
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
# One property. From it Spring fetches /.well-known/openid-configuration at
|
||||
# startup, reads jwks_uri out of it, and builds a decoder. If the auth server
|
||||
# is not up when this one starts, startup fails - by design.
|
||||
issuer-uri: http://localhost:9000
|
||||
|
||||
demo:
|
||||
required-audience: orders-api
|
||||
validate-audience: true
|
||||
35
authorization-server/scripts/audience.sh
Executable file
35
authorization-server/scripts/audience.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# The gap between "the signature is valid" and "this token was meant for me".
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
OUT=../docs/output/as-audience.txt
|
||||
mkdir -p ../docs/output
|
||||
{
|
||||
section "A token for a DIFFERENT audience, signed by the SAME issuer"
|
||||
echo "demo-service's tokens carry aud=[orders-api] thanks to the token customiser."
|
||||
echo "Here we ask for one and then present it to a resource server configured to"
|
||||
echo "require a different audience - and to one that does not check at all."
|
||||
TOKEN=$(curl -s -u demo-service:service-secret -d grant_type=client_credentials \
|
||||
-d scope=orders.read "$AS/oauth2/token" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
|
||||
echo
|
||||
echo "aud claim in the token:"
|
||||
jwt_payload "$TOKEN" | grep -A3 '"aud"'
|
||||
|
||||
section "Resource server running with demo.validate-audience=false"
|
||||
echo "This is the Spring Boot default: issuer-uri alone validates signature, exp/nbf"
|
||||
echo "and iss. Audience is not checked unless you add a validator."
|
||||
curl -s -o /tmp/b -w 'GET /api/orders -> %{http_code}\n' -H "Authorization: Bearer $TOKEN" "$RS/api/orders"
|
||||
head -c 300 /tmp/b; echo
|
||||
|
||||
section "The same token with a deliberately mangled signature"
|
||||
BAD="${TOKEN%?}X"
|
||||
curl -s -D - -o /dev/null -H "Authorization: Bearer $BAD" "$RS/api/orders" \
|
||||
| sed -n '1p;/^WWW-Authenticate/p'
|
||||
|
||||
section "No token at all"
|
||||
curl -s -D - -o /dev/null "$RS/api/orders" | sed -n '1p;/^WWW-Authenticate/p'
|
||||
} > "$OUT" 2>&1
|
||||
sed -i 's/[[:space:]]*$//' "$OUT"
|
||||
echo "wrote $OUT"
|
||||
256
authorization-server/scripts/authcode-pkce.sh
Executable file
256
authorization-server/scripts/authcode-pkce.sh
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
# The full authorization code flow with PKCE, driven entirely by curl so that every
|
||||
# redirect, form and parameter is visible. A browser hides all of this.
|
||||
#
|
||||
# ./scripts/authcode-pkce.sh [output-name] [client]
|
||||
#
|
||||
# client defaults to demo-spa (public, PKCE required). Pass demo-web for the confidential
|
||||
# client with a secret.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
|
||||
NAME="${1:-as-authcode-pkce}"
|
||||
CLIENT="${2:-demo-spa}"
|
||||
# NO_CHALLENGE=1 sends an authorization request with no code_challenge at all. That is a
|
||||
# different thing from sending one and then omitting the verifier: the server only demands
|
||||
# a verifier if the authorization request carried a challenge, OR if the client is
|
||||
# registered with requireProofKey(true).
|
||||
NO_CHALLENGE="${NO_CHALLENGE:-0}"
|
||||
OUT="../docs/output/${NAME}.txt"
|
||||
mkdir -p ../docs/output
|
||||
|
||||
if [ "$CLIENT" = "demo-web" ]; then
|
||||
REDIRECT="http://127.0.0.1:8080/login/oauth2/code/demo-web"
|
||||
SCOPE="openid orders.read orders.write"
|
||||
else
|
||||
REDIRECT="http://127.0.0.1:8080/authorized"
|
||||
SCOPE="openid orders.read"
|
||||
fi
|
||||
|
||||
JAR=$(mktemp)
|
||||
trap 'rm -f "$JAR" /tmp/as-page.html' EXIT
|
||||
|
||||
# --- PKCE parameters. RFC 7636: verifier is 43-128 chars of unreserved characters,
|
||||
# --- challenge is BASE64URL(SHA256(verifier)) with the padding stripped.
|
||||
read -r VERIFIER CHALLENGE <<<"$(python3 - <<'PY'
|
||||
import base64, hashlib, secrets
|
||||
v = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip('=')
|
||||
c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).decode().rstrip('=')
|
||||
print(v, c)
|
||||
PY
|
||||
)"
|
||||
|
||||
{
|
||||
section "PKCE parameters (RFC 7636)"
|
||||
echo "code_verifier ${VERIFIER} (${#VERIFIER} chars)"
|
||||
echo "code_challenge ${CHALLENGE}"
|
||||
echo "code_challenge_method S256"
|
||||
echo
|
||||
echo "The verifier never leaves the client until the token request. The challenge is"
|
||||
echo "all the authorization request carries, and it is a one-way hash of the verifier."
|
||||
|
||||
section "1. Log in to the authorization server (browser session)"
|
||||
# The login page carries a CSRF token; the form chain has CSRF enabled, as it should.
|
||||
curl -s -c "$JAR" "$AS/login" -o /tmp/as-page.html
|
||||
CSRF=$(form_value /tmp/as-page.html _csrf)
|
||||
echo "\$ curl -c jar -d username=alice -d password=password -d _csrf=<token> $AS/login"
|
||||
curl -s -i -b "$JAR" -c "$JAR" \
|
||||
-d "username=alice" -d "password=password" -d "_csrf=$CSRF" \
|
||||
"$AS/login" | sed -n '1p;/^[Ll]ocation:/p'
|
||||
|
||||
section "2. GET /oauth2/authorize (client=$CLIENT)"
|
||||
PKCE_PARAMS="&code_challenge=$CHALLENGE&code_challenge_method=S256"
|
||||
if [ "$NO_CHALLENGE" = "1" ]; then
|
||||
PKCE_PARAMS=""
|
||||
echo "NO_CHALLENGE=1: the authorization request carries no code_challenge."
|
||||
echo
|
||||
fi
|
||||
AUTHZ="$AS/oauth2/authorize?response_type=code&client_id=$CLIENT&redirect_uri=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$REDIRECT")&scope=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$SCOPE")&state=xyz123${PKCE_PARAMS}"
|
||||
echo "\$ curl -b jar '$AUTHZ'"
|
||||
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
echo "-> 302 $LOC"
|
||||
|
||||
if [ -z "$LOC" ]; then
|
||||
echo "no redirect - the authorization endpoint rendered a page instead:"
|
||||
curl -s -b "$JAR" "$AUTHZ" | head -30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$LOC" in
|
||||
*/oauth2/consent*)
|
||||
section "3. The consent page"
|
||||
echo "The authorization endpoint redirected to OUR page, at the path given to"
|
||||
echo ".consentPage(\"/oauth2/consent\"). Note the query string it hands over:"
|
||||
echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
|
||||
curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
|
||||
echo
|
||||
echo "Scopes rendered as checkboxes (openid deliberately not among them):"
|
||||
form_values /tmp/as-page.html scope | sed 's/^/ /'
|
||||
CSRF=$(form_value /tmp/as-page.html _csrf)
|
||||
STATE=$(form_value /tmp/as-page.html state)
|
||||
echo
|
||||
echo "The hidden state the form must echo back: $STATE"
|
||||
echo "(this is NOT the client's state=xyz123 - it is the server's own correlation"
|
||||
echo " handle for the pending authorization request, and sending the client's value"
|
||||
echo " instead is what produces the consent redirect loop)"
|
||||
|
||||
section "4. POST the approval to /oauth2/authorize"
|
||||
ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
|
||||
for s in $(form_values /tmp/as-page.html scope); do
|
||||
ARGS+=(-d "scope=$s")
|
||||
done
|
||||
echo "\$ curl -b jar -X POST ${ARGS[*]} $AS/oauth2/authorize"
|
||||
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
|
||||
| tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
echo "-> 302 $LOC"
|
||||
;;
|
||||
*)
|
||||
section "3. No consent page"
|
||||
echo "The authorization endpoint went straight back to the client. Either consent is"
|
||||
echo "off for this client, or every requested scope was already approved."
|
||||
;;
|
||||
esac
|
||||
|
||||
CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
|
||||
RETURNED_STATE=$(echo "$LOC" | sed -n 's/.*[?&]state=\([^&]*\).*/\1/p')
|
||||
section "5. The authorization code"
|
||||
echo "code = $CODE"
|
||||
echo "state = $RETURNED_STATE (the client's own value, returned untouched - compare it)"
|
||||
if [ -z "$CODE" ]; then
|
||||
echo "no code in the redirect. The error was:"
|
||||
echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
section "6a. Exchange the code WITHOUT the verifier"
|
||||
echo "This is the request an attacker who stole the code can make."
|
||||
AUTH_ARGS=()
|
||||
[ "$CLIENT" = "demo-web" ] && AUTH_ARGS=(-u demo-web:web-secret)
|
||||
NOVERIFIER=$(curl -s -w '\n<<HTTP %{http_code}>>' "${AUTH_ARGS[@]}" \
|
||||
-d grant_type=authorization_code -d "code=$CODE" \
|
||||
-d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
|
||||
"$AS/oauth2/token")
|
||||
echo "$NOVERIFIER" | sed -n 's/^<<HTTP \(.*\)>>$/HTTP \1/p'
|
||||
BODY=${NOVERIFIER%%$'\n'<<HTTP*}
|
||||
if [ -n "$BODY" ]; then
|
||||
echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY"
|
||||
else
|
||||
echo "(empty response body)"
|
||||
fi
|
||||
echo
|
||||
case "$NOVERIFIER" in
|
||||
*access_token*)
|
||||
echo ">>> A TOKEN WAS ISSUED. The code alone was sufficient. This is what"
|
||||
echo ">>> requireProofKey(false) on a public client means in practice."
|
||||
;;
|
||||
*)
|
||||
echo ">>> Rejected. invalid_grant is deliberately vague: the server will not tell"
|
||||
echo ">>> a caller whether the code was wrong, expired, already used, or missing a"
|
||||
echo ">>> verifier, because each of those is information an attacker can use."
|
||||
;;
|
||||
esac
|
||||
echo "Note: this consumed the code. Authorization codes are single-use, so the"
|
||||
echo "successful exchange below needs a fresh one."
|
||||
|
||||
section "6b. A fresh code, exchanged properly"
|
||||
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
case "$LOC" in
|
||||
*/oauth2/consent*)
|
||||
curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
|
||||
CSRF=$(form_value /tmp/as-page.html _csrf)
|
||||
STATE=$(form_value /tmp/as-page.html state)
|
||||
ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
|
||||
for s in $(form_values /tmp/as-page.html scope); do
|
||||
ARGS+=(-d "scope=$s")
|
||||
done
|
||||
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
|
||||
| tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
;;
|
||||
esac
|
||||
CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
|
||||
echo "fresh code = $CODE"
|
||||
echo
|
||||
if [ "$NO_CHALLENGE" = "1" ]; then
|
||||
echo "\$ curl -d grant_type=authorization_code -d code=... $AS/oauth2/token"
|
||||
echo " (no code_verifier - there was no challenge to verify against)"
|
||||
else
|
||||
echo "\$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... $AS/oauth2/token"
|
||||
fi
|
||||
VERIFIER_ARG=(-d "code_verifier=$VERIFIER")
|
||||
[ "$NO_CHALLENGE" = "1" ] && VERIFIER_ARG=()
|
||||
HTTPCODE=$(curl -s -o /tmp/as-tok.json -w '%{http_code}' "${AUTH_ARGS[@]}" \
|
||||
-d grant_type=authorization_code -d "code=$CODE" \
|
||||
-d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
|
||||
"${VERIFIER_ARG[@]}" \
|
||||
"$AS/oauth2/token")
|
||||
RESP=$(cat /tmp/as-tok.json)
|
||||
echo "HTTP $HTTPCODE"
|
||||
if [ -s /tmp/as-tok.json ]; then
|
||||
python3 -m json.tool < /tmp/as-tok.json 2>/dev/null || cat /tmp/as-tok.json
|
||||
else
|
||||
echo "(empty response body)"
|
||||
fi
|
||||
if [ "$HTTPCODE" != "200" ] && [ "$NO_CHALLENGE" = "1" ]; then
|
||||
echo
|
||||
echo ">>> No token, even though the client is registered with requireProofKey(false)"
|
||||
echo ">>> and the authorization request carried no challenge. The reason is that a"
|
||||
echo ">>> public client has no other way to authenticate at the token endpoint:"
|
||||
echo ">>> PublicClientAuthenticationProvider delegates entirely to"
|
||||
echo ">>> CodeVerifierAuthenticator, and raises invalid_client when there is nothing"
|
||||
echo ">>> to verify. requireProofKey(false) relaxes the AUTHORIZATION endpoint only."
|
||||
fi
|
||||
|
||||
read_claim() { python3 -c 'import sys,json
|
||||
try: print(json.load(sys.stdin).get(sys.argv[1],""))
|
||||
except Exception: print("")' "$1" < /tmp/as-tok.json; }
|
||||
AT=$(read_claim access_token)
|
||||
IDT=$(read_claim id_token)
|
||||
RT=$(read_claim refresh_token)
|
||||
|
||||
if [ -n "$AT" ]; then
|
||||
section "7. The access token"
|
||||
jwt_header "$AT"
|
||||
jwt_payload "$AT"
|
||||
fi
|
||||
if [ -n "$IDT" ]; then
|
||||
section "8. The id_token - a different token, for a different audience"
|
||||
jwt_payload "$IDT"
|
||||
echo
|
||||
echo "aud is the CLIENT here, not the API. Sending this to a resource server is the"
|
||||
echo "classic mix-up: it verifies (same issuer, same key) and then fails the audience"
|
||||
echo "check, or worse, passes it if nobody checks audience."
|
||||
fi
|
||||
|
||||
if [ -n "$AT" ]; then
|
||||
section "9. Calling the resource server"
|
||||
for path in /api/orders /api/admin; do
|
||||
CODE_HTTP=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $AT" "$RS$path")
|
||||
echo "GET $path -> $CODE_HTTP"
|
||||
head -c 500 /tmp/rsbody; echo
|
||||
done
|
||||
|
||||
section "10. Sending the id_token instead"
|
||||
if [ -n "$IDT" ]; then
|
||||
curl -s -i -H "Authorization: Bearer $IDT" "$RS/api/orders" \
|
||||
| sed -n '1p;/^WWW-Authenticate/p'
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$RT" ]; then
|
||||
section "11. Refresh, with rotation"
|
||||
echo "old refresh token: ${RT:0:24}..."
|
||||
R2=$(curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
|
||||
-d "client_id=$CLIENT" "$AS/oauth2/token")
|
||||
NEW=$(echo "$R2" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("refresh_token",""))')
|
||||
echo "new refresh token: ${NEW:0:24}..."
|
||||
[ "$RT" = "$NEW" ] && echo "SAME - reuseRefreshTokens(true)" || echo "DIFFERENT - reuseRefreshTokens(false), the old one is now dead"
|
||||
echo
|
||||
echo "Replaying the old one:"
|
||||
curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
|
||||
-d "client_id=$CLIENT" "$AS/oauth2/token"
|
||||
echo
|
||||
fi
|
||||
} > "$OUT" 2>&1
|
||||
sed -i 's/[[:space:]]*$//' "$OUT"
|
||||
echo "wrote $OUT"; tail -5 "$OUT"
|
||||
63
authorization-server/scripts/client-credentials.sh
Executable file
63
authorization-server/scripts/client-credentials.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# The simplest grant, and what the token customiser does and does not add to it.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
OUT="../docs/output/${1:-as-client-credentials}.txt"
|
||||
mkdir -p ../docs/output
|
||||
{
|
||||
section "POST /oauth2/token grant_type=client_credentials"
|
||||
echo "\$ curl -su demo-service:service-secret -d grant_type=client_credentials \\"
|
||||
echo " -d scope=orders.read $AS/oauth2/token"
|
||||
RESP=$(curl -s -u demo-service:service-secret \
|
||||
-d grant_type=client_credentials -d scope=orders.read \
|
||||
"$AS/oauth2/token")
|
||||
echo "$RESP" | python3 -m json.tool
|
||||
|
||||
TOKEN=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
|
||||
if [ -z "$TOKEN" ]; then echo "no access token - stopping"; exit 1; fi
|
||||
|
||||
case "$TOKEN" in
|
||||
*.*.*)
|
||||
section "JOSE header"
|
||||
jwt_header "$TOKEN"
|
||||
section "Claims"
|
||||
jwt_payload "$TOKEN"
|
||||
;;
|
||||
*)
|
||||
section "Not a JWT"
|
||||
echo "The access token is an opaque reference: $TOKEN"
|
||||
echo "Length ${#TOKEN}. It carries no claims; the resource server must introspect it."
|
||||
section "POST /oauth2/introspect"
|
||||
curl -s -u demo-service:service-secret -d "token=$TOKEN" \
|
||||
"$AS/oauth2/introspect" | python3 -m json.tool
|
||||
;;
|
||||
esac
|
||||
|
||||
section "Wrong secret"
|
||||
echo "\$ curl -si -u demo-service:WRONG -d grant_type=client_credentials $AS/oauth2/token"
|
||||
curl -s -i -u demo-service:WRONG -d grant_type=client_credentials \
|
||||
"$AS/oauth2/token" | sed -n '1p;/^WWW-Authenticate/p;/^{/p'
|
||||
|
||||
section "A grant the client is not registered for"
|
||||
echo "\$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x $AS/oauth2/token"
|
||||
curl -s -i -u demo-service:service-secret -d grant_type=authorization_code -d code=x \
|
||||
"$AS/oauth2/token" | sed -n '1p;/^{/p'
|
||||
|
||||
section "A scope the client is not registered for"
|
||||
echo "\$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write $AS/oauth2/token"
|
||||
curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write \
|
||||
"$AS/oauth2/token"
|
||||
echo
|
||||
|
||||
section "Calling the resource server with the token"
|
||||
for path in /public /api/orders /api/admin; do
|
||||
CODE=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $TOKEN" "$RS$path")
|
||||
WWW=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $TOKEN" "$RS$path" | grep -i '^WWW-Authenticate' || true)
|
||||
echo "GET $path -> $CODE"
|
||||
[ -n "$WWW" ] && echo " $WWW"
|
||||
head -c 400 /tmp/rsbody; echo
|
||||
done
|
||||
} > "$OUT" 2>&1
|
||||
sed -i 's/[[:space:]]*$//' "$OUT"
|
||||
echo "wrote $OUT"
|
||||
110
authorization-server/scripts/client-flow.sh
Executable file
110
authorization-server/scripts/client-flow.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drives the real Spring OAuth2 client through a real browser flow with curl, so the
|
||||
# behaviour is the client's own and not this script's.
|
||||
#
|
||||
# ./scripts/client-flow.sh <output-name> <label>
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
CLIENT=http://127.0.0.1:8080
|
||||
LAST_AUTHZ=""
|
||||
NAME="${1:-as-client-flow}"
|
||||
LABEL="${2:-default}"
|
||||
OUT="../docs/output/${NAME}.txt"
|
||||
mkdir -p ../docs/output
|
||||
JAR=$(mktemp); trap 'rm -f "$JAR" /tmp/cf.html' EXIT
|
||||
|
||||
follow() { # url -> prints status + location, follows same-host redirects up to 6 hops
|
||||
local url="$1" hop=0
|
||||
while [ $hop -lt 8 ]; do
|
||||
local hdrs status loc
|
||||
# A browser sends Accept: text/html. curl's default is */*, and with the entry-point
|
||||
# matcher configured to ignore */* that difference decides whether /oauth2/authorize
|
||||
# redirects you to the login page or answers 401.
|
||||
hdrs=$(curl -s -D - -o /tmp/cf.html -b "$JAR" -c "$JAR" -H 'Accept: text/html' "$url" | tr -d '\r')
|
||||
status=$(echo "$hdrs" | head -1 | awk '{print $2}')
|
||||
loc=$(echo "$hdrs" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
echo " $status $url"
|
||||
case "$url" in *"/oauth2/authorize?"*) LAST_AUTHZ="$url" ;; esac
|
||||
[ -z "$loc" ] && { LAST_URL="$url"; return 0; }
|
||||
case "$loc" in http*) url="$loc" ;; *) url="$(echo "$url" | grep -oE '^https?://[^/]+')$loc" ;; esac
|
||||
hop=$((hop+1))
|
||||
done
|
||||
LAST_URL="$url"
|
||||
}
|
||||
|
||||
{
|
||||
section "The relying party drives the flow [$LABEL]"
|
||||
echo "GET $CLIENT/orders while unauthenticated. Every hop below is a real redirect."
|
||||
echo
|
||||
follow "$CLIENT/orders"
|
||||
echo
|
||||
echo "The authorization request the client built:"
|
||||
echo "$LAST_AUTHZ" | tr '&?' '\n\n' | sed 's/^/ /'
|
||||
case "$LAST_AUTHZ" in
|
||||
*code_challenge*) echo " >>> code_challenge IS present" ;;
|
||||
*) echo " >>> NO code_challenge - a client registered with"
|
||||
echo " >>> requireProofKey(true) will reject this outright" ;;
|
||||
esac
|
||||
|
||||
case "$LAST_URL" in
|
||||
"$AS/login"*)
|
||||
echo
|
||||
echo "Landed on the authorization server's login page. Submitting credentials:"
|
||||
CSRF=$(form_value /tmp/cf.html _csrf)
|
||||
HDRS=$(curl -s -D - -o /dev/null -b "$JAR" -c "$JAR" -H 'Accept: text/html' \
|
||||
-d username=alice -d password=password -d "_csrf=$CSRF" \
|
||||
"$AS/login" | tr -d '\r')
|
||||
echo " $(echo "$HDRS" | head -1 | awk '{print $2}') POST $AS/login"
|
||||
NEXT=$(echo "$HDRS" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
echo
|
||||
echo "Resuming the authorization request:"
|
||||
follow "$NEXT"
|
||||
;;
|
||||
esac
|
||||
|
||||
# The consent page, if we reached it.
|
||||
if grep -q 'name="scope"' /tmp/cf.html 2>/dev/null; then
|
||||
echo
|
||||
echo "Consent page reached. Approving:"
|
||||
CSRF=$(form_value /tmp/cf.html _csrf)
|
||||
STATE=$(form_value /tmp/cf.html state)
|
||||
CID=$(form_value /tmp/cf.html client_id)
|
||||
ARGS=(-d "client_id=$CID" -d "state=$STATE" -d "_csrf=$CSRF")
|
||||
for s in $(form_values /tmp/cf.html scope); do ARGS+=(-d "scope=$s"); done
|
||||
HDRS=$(curl -s -D - -o /dev/null -b "$JAR" -c "$JAR" -H 'Accept: text/html' "${ARGS[@]}" "$AS/oauth2/authorize" | tr -d '\r')
|
||||
echo " $(echo "$HDRS" | head -1 | awk '{print $2}') POST $AS/oauth2/authorize"
|
||||
NEXT=$(echo "$HDRS" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
|
||||
echo
|
||||
echo "Back to the client with the code:"
|
||||
follow "$NEXT"
|
||||
fi
|
||||
|
||||
case "$LAST_URL" in
|
||||
*error*)
|
||||
echo
|
||||
echo "The flow ended at the CLIENT's error page, not the provider's. The provider"
|
||||
echo "rejected the authorization request and redirected the failure back to the"
|
||||
echo "registered redirect_uri, so nothing in the client's logs names the provider"
|
||||
echo "as the cause. The reason is only in the query string above."
|
||||
;;
|
||||
esac
|
||||
|
||||
section "What the client rendered"
|
||||
if grep -qi 'error' /tmp/cf.html && ! grep -q 'orders' /tmp/cf.html; then
|
||||
echo "An error page. The provider rejected the authorization request:"
|
||||
echo "$LAST_URL" | tr '&?' '\n\n' | sed 's/^/ /'
|
||||
echo
|
||||
python3 -c "
|
||||
import html,re,sys
|
||||
t = re.sub(r'<[^>]+>', ' ', open('/tmp/cf.html', encoding='utf-8', errors='replace').read())
|
||||
print(' '.join(html.unescape(t).split())[:600])"
|
||||
else
|
||||
python3 -c "
|
||||
import html,re
|
||||
t = re.sub(r'<[^>]+>', '\n', open('/tmp/cf.html', encoding='utf-8', errors='replace').read())
|
||||
print('\n'.join(l.strip() for l in html.unescape(t).splitlines() if l.strip())[:1400])"
|
||||
fi
|
||||
} > "$OUT" 2>&1
|
||||
sed -i 's/[[:space:]]*$//' "$OUT"
|
||||
echo "wrote $OUT"
|
||||
34
authorization-server/scripts/compile-legacy.sh
Executable file
34
authorization-server/scripts/compile-legacy.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compiles the SAS 1.x-style configuration against the 7.1.1 classpath and records the
|
||||
# compiler's own words. The point is that the error text is what you will actually see,
|
||||
# not a paraphrase of it.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=../docs/output/as-legacy-compile-failure.txt
|
||||
mkdir -p ../docs/output
|
||||
|
||||
mvn -B -q -pl auth-server dependency:build-classpath \
|
||||
-Dmdep.outputFile=/tmp/as-cp.txt -DincludeScope=compile >/dev/null 2>&1
|
||||
CP=$(cat /tmp/as-cp.txt)
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
mkdir -p "$WORK/com/ankurm/authserver/legacy" "$WORK/out"
|
||||
cp src-broken/LegacySasConfig.java.txt "$WORK/com/ankurm/authserver/legacy/LegacySasConfig.java"
|
||||
|
||||
javac -nowarn -d "$WORK/out" -cp "$CP" \
|
||||
"$WORK/com/ankurm/authserver/legacy/LegacySasConfig.java" > "$WORK/err.txt" 2>&1
|
||||
STATUS=$?
|
||||
|
||||
{
|
||||
echo "# The SAS 1.x configuration, compiled against Spring Boot 4.1.1 / Spring Security 7.1.1."
|
||||
echo "# Source: src-broken/LegacySasConfig.java.txt"
|
||||
echo
|
||||
echo "\$ javac -cp <spring-boot-4.1.1 classpath> LegacySasConfig.java"
|
||||
echo
|
||||
sed "s|$WORK|.|g" "$WORK/err.txt"
|
||||
echo
|
||||
echo "javac exit status: $STATUS"
|
||||
} > "$OUT"
|
||||
|
||||
rm -rf "$WORK"
|
||||
cat "$OUT"
|
||||
30
authorization-server/scripts/discovery.sh
Executable file
30
authorization-server/scripts/discovery.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# What the provider advertises, and the difference between the two metadata documents.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
OUT=../docs/output/as-discovery.txt
|
||||
mkdir -p ../docs/output
|
||||
{
|
||||
section "OpenID Connect discovery: GET /.well-known/openid-configuration"
|
||||
echo "\$ curl -s $AS/.well-known/openid-configuration"
|
||||
curl -s "$AS/.well-known/openid-configuration" | python3 -m json.tool
|
||||
|
||||
section "OAuth2 metadata: GET /.well-known/oauth-authorization-server"
|
||||
echo "Present even with .oidc(...) switched off. The OIDC document above is the one"
|
||||
echo "that additionally advertises userinfo_endpoint and id_token signing algorithms."
|
||||
echo "\$ curl -s $AS/.well-known/oauth-authorization-server"
|
||||
curl -s "$AS/.well-known/oauth-authorization-server" | python3 -m json.tool
|
||||
|
||||
section "JWK Set: GET /oauth2/jwks"
|
||||
echo "Public keys only. No 'd' member - if you ever see one here, stop the server."
|
||||
curl -s "$AS/oauth2/jwks" | python3 -m json.tool
|
||||
|
||||
section "Resolved endpoint settings, read back from AuthorizationServerSettings"
|
||||
curl -s "$AS/diag/settings" | python3 -m json.tool
|
||||
|
||||
section "Registered clients, as the server actually holds them"
|
||||
curl -s "$AS/diag/clients" | python3 -m json.tool
|
||||
} > "$OUT" 2>&1
|
||||
sed -i 's/[[:space:]]*$//' "$OUT"
|
||||
echo "wrote $OUT"; wc -l "$OUT"
|
||||
37
authorization-server/scripts/entrypoint-accept.sh
Executable file
37
authorization-server/scripts/entrypoint-accept.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Why a failed token request sometimes answers 302 -> /login instead of 401.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
OUT=../docs/output/as-entrypoint-accept.txt
|
||||
LABEL="${1:-}"
|
||||
mkdir -p ../docs/output
|
||||
probe() {
|
||||
local accept="$1" desc="$2"
|
||||
echo "--- $desc"
|
||||
echo "\$ curl -H 'Accept: $accept' -d grant_type=authorization_code -d code=bogus \\"
|
||||
echo " -d client_id=demo-spa $AS/oauth2/token"
|
||||
curl -s -i -H "Accept: $accept" -d grant_type=authorization_code -d code=bogus \
|
||||
-d client_id=demo-spa "$AS/oauth2/token" \
|
||||
| sed -n '1p;/^[Ll]ocation:/p;/^WWW-Authenticate/p'
|
||||
echo
|
||||
}
|
||||
{
|
||||
section "Public client, failed authentication at the token endpoint [$LABEL]"
|
||||
echo "A public client authenticates at /oauth2/token by presenting a code_verifier."
|
||||
echo "With no verifier there is nothing to authenticate with, so the request falls"
|
||||
echo "through to the AuthenticationEntryPoint - and which entry point runs depends on"
|
||||
echo "the Accept header."
|
||||
echo
|
||||
probe "*/*" "Accept: */* (curl's default, and most HTTP clients')"
|
||||
probe "application/json" "Accept: application/json"
|
||||
probe "text/html" "Accept: text/html (a browser)"
|
||||
|
||||
section "Confidential client with a wrong secret, for contrast"
|
||||
echo "This never reaches the entry point: OAuth2ClientAuthenticationFilter writes the"
|
||||
echo "error itself, so the Accept header makes no difference."
|
||||
curl -s -i -u demo-web:wrong -d grant_type=client_credentials "$AS/oauth2/token" \
|
||||
| sed -n '1p;/^[Ll]ocation:/p'
|
||||
} >> "$OUT" 2>&1
|
||||
sed -i 's/[[:space:]]*$//' "$OUT"
|
||||
echo "appended $LABEL to $OUT"
|
||||
91
authorization-server/scripts/lib.sh
Executable file
91
authorization-server/scripts/lib.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers. Sourced by every demo script.
|
||||
AS=http://localhost:9000
|
||||
RS=http://localhost:8090
|
||||
|
||||
# Kill by main class, never by a pattern that could match this script's own command line.
|
||||
# `pkill -f spring-boot` matches the shell running it and takes the shell with it.
|
||||
kill_app() {
|
||||
local mainclass="$1" port="${2:-}"
|
||||
for p in $(ps -eo pid,cmd | grep "[${mainclass:0:1}]${mainclass:1}" | awk '{print $1}'); do
|
||||
kill -9 "$p" 2>/dev/null || true
|
||||
done
|
||||
# Then wait for the port to actually close. `ss -lptn` often reports the socket with no
|
||||
# PID, so a port-based kill can silently do nothing while the old process keeps serving -
|
||||
# which looks exactly like your config change having had no effect. Waiting for the
|
||||
# listener to disappear is the only reliable signal that the restart is real.
|
||||
if [ -n "$port" ]; then
|
||||
for _ in $(seq 1 30); do
|
||||
curl -s -o /dev/null --max-time 1 "http://localhost:$port/" || return 0
|
||||
sleep 1
|
||||
done
|
||||
echo "WARNING: something is still listening on :$port after kill_app $mainclass" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
}
|
||||
|
||||
wait_for() {
|
||||
local url="$1" tries="${2:-90}"
|
||||
for _ in $(seq 1 "$tries"); do
|
||||
if curl -s -o /dev/null --max-time 2 "$url"; then return 0; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "timed out waiting for $url - check the app log" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
hr() { printf '%s\n' "------------------------------------------------------------------"; }
|
||||
|
||||
section() { echo; hr; echo "== $*"; hr; }
|
||||
|
||||
# Decode a JWS payload without verifying it. Debug only - never do this to decide anything.
|
||||
jwt_payload() {
|
||||
python3 - "$1" <<'PY'
|
||||
import base64, json, sys
|
||||
part = sys.argv[1].split('.')[1]
|
||||
part += '=' * (-len(part) % 4)
|
||||
print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
|
||||
PY
|
||||
}
|
||||
|
||||
jwt_header() {
|
||||
python3 - "$1" <<'PY'
|
||||
import base64, json, sys
|
||||
part = sys.argv[1].split('.')[0]
|
||||
part += '=' * (-len(part) % 4)
|
||||
print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
|
||||
PY
|
||||
}
|
||||
|
||||
# Pull a hidden input's value out of a page. Attribute order is not fixed - Spring
|
||||
# Security's default login page renders name before value, Thymeleaf renders value before
|
||||
# name - so a naive grep for name="x" value="y" works on one and silently returns empty on
|
||||
# the other. Empty CSRF token, HTTP 403, and an hour lost.
|
||||
form_value() {
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import re, sys
|
||||
html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
|
||||
want = sys.argv[2]
|
||||
for tag in re.findall(r'<input\b[^>]*>', html, re.I):
|
||||
attrs = dict((m.group(1).lower(), m.group(2))
|
||||
for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
|
||||
if attrs.get('name') == want:
|
||||
print(attrs.get('value', ''))
|
||||
break
|
||||
PY
|
||||
}
|
||||
|
||||
# Every value of a repeated input (the scope checkboxes on the consent page).
|
||||
form_values() {
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import re, sys
|
||||
html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
|
||||
want = sys.argv[2]
|
||||
for tag in re.findall(r'<input\b[^>]*>', html, re.I):
|
||||
attrs = dict((m.group(1).lower(), m.group(2))
|
||||
for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
|
||||
if attrs.get('name') == want and 'disabled' not in tag.lower():
|
||||
print(attrs.get('value', ''))
|
||||
PY
|
||||
}
|
||||
32
authorization-server/scripts/pkce-applier.sh
Executable file
32
authorization-server/scripts/pkce-applier.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Why a confidential Spring OAuth2 client does not send PKCE, straight from the bytecode
|
||||
# of DefaultOAuth2AuthorizationRequestResolver rather than from documentation.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=../docs/output/as-pkce-applier.txt
|
||||
mkdir -p ../docs/output
|
||||
mvn -B -q -pl oidc-client dependency:build-classpath \
|
||||
-Dmdep.outputFile=/tmp/cl-cp.txt -DincludeScope=compile >/dev/null 2>&1
|
||||
JAR=$(tr ':' '\n' < /tmp/cl-cp.txt | grep 'spring-security-oauth2-client-' | head -1)
|
||||
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
||||
(cd "$WORK" && unzip -o -q "$JAR" 'org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver*')
|
||||
{
|
||||
echo "# From $(basename "$JAR")"
|
||||
echo "#"
|
||||
echo "# The resolver applies its default PKCE customizer only when the registration's"
|
||||
echo "# client authentication method is NONE - that is, only for public clients."
|
||||
echo "# A registration that has a client secret gets no code_challenge."
|
||||
echo
|
||||
javap -p -c -cp "$WORK" \
|
||||
org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver \
|
||||
2>/dev/null \
|
||||
| grep -E 'ClientAuthenticationMethod.NONE|DEFAULT_PKCE_APPLIER|withPkce' \
|
||||
| sed 's/^ *//' | head -8
|
||||
echo
|
||||
echo "# The fields and the opt-in setter:"
|
||||
javap -p -cp "$WORK" \
|
||||
org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver \
|
||||
2>/dev/null \
|
||||
| grep -E 'DEFAULT_PKCE_APPLIER|setAuthorizationRequestCustomizer' | sed 's/^ *//'
|
||||
} > "$OUT" 2>&1
|
||||
cat "$OUT"
|
||||
22
authorization-server/scripts/rs-startup-failure.sh
Executable file
22
authorization-server/scripts/rs-startup-failure.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# What happens when the resource server starts and the issuer is not reachable.
|
||||
# Worth capturing because the failure is at STARTUP, not at first request - which means
|
||||
# a provider outage during a rolling deploy takes your API down with it.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
OUT=../docs/output/as-rs-startup-failure.txt
|
||||
mkdir -p ../docs/output
|
||||
kill_app ResourceServerApplication 8090
|
||||
kill_app AuthServerApplication 9000
|
||||
sleep 1
|
||||
mvn -B -o -pl resource-server org.springframework.boot:spring-boot-maven-plugin:run \
|
||||
> /tmp/rs-fail.log 2>&1 || true
|
||||
{
|
||||
echo "# resource-server started with spring.security.oauth2.resourceserver.jwt.issuer-uri"
|
||||
echo "# pointing at an authorization server that is not running."
|
||||
echo
|
||||
grep -E '^Caused by|Unable to resolve the Configuration' /tmp/rs-fail.log \
|
||||
| sed 's/^Caused by: //' | head -8
|
||||
} > "$OUT"
|
||||
cat "$OUT"
|
||||
114
authorization-server/scripts/run-all.sh
Executable file
114
authorization-server/scripts/run-all.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file in ../docs/output that belongs to this project.
|
||||
#
|
||||
# ./scripts/run-all.sh
|
||||
#
|
||||
# Starts and stops the servers itself. Takes a few minutes. The only non-deterministic
|
||||
# content is timestamps, key ids and token values, which change on every run by design.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
|
||||
start_auth() {
|
||||
kill_app AuthServerApplication 9000
|
||||
local profiles="${1:-}"
|
||||
local args=(-B -o -pl auth-server org.springframework.boot:spring-boot-maven-plugin:run)
|
||||
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
|
||||
setsid nohup mvn "${args[@]}" > /tmp/auth-server.log 2>&1 < /dev/null &
|
||||
wait_for "$AS/oauth2/jwks" 90
|
||||
}
|
||||
start_client() {
|
||||
kill_app ClientApplication 8080
|
||||
local profiles="${1:-}"
|
||||
local args=(-B -o -pl oidc-client org.springframework.boot:spring-boot-maven-plugin:run)
|
||||
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
|
||||
setsid nohup mvn "${args[@]}" > /tmp/oidc-client.log 2>&1 < /dev/null &
|
||||
wait_for "http://127.0.0.1:8080/" 90
|
||||
}
|
||||
start_rs() {
|
||||
kill_app ResourceServerApplication 8090
|
||||
local profiles="${1:-}"
|
||||
local args=(-B -o -pl resource-server org.springframework.boot:spring-boot-maven-plugin:run)
|
||||
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
|
||||
setsid nohup mvn "${args[@]}" > /tmp/rs.log 2>&1 < /dev/null &
|
||||
wait_for "$RS/public" 90
|
||||
}
|
||||
|
||||
echo "== ClientSettings / TokenSettings defaults, 1.5.8 vs 7.1.1"
|
||||
./scripts/settings-defaults.sh > /dev/null
|
||||
|
||||
echo "== the SAS 1.x configuration against 7.1.1 (compile only)"
|
||||
./scripts/compile-legacy.sh > /dev/null
|
||||
|
||||
echo "== default profile"
|
||||
start_auth ""
|
||||
start_rs ""
|
||||
./scripts/discovery.sh
|
||||
./scripts/client-credentials.sh as-client-credentials
|
||||
./scripts/authcode-pkce.sh as-authcode-pkce demo-spa
|
||||
./scripts/authcode-pkce.sh as-authcode-web demo-web
|
||||
|
||||
echo "== why a confidential Spring client does not send PKCE by default"
|
||||
./scripts/pkce-applier.sh > /dev/null
|
||||
|
||||
echo "== the real Spring OAuth2 client, end to end"
|
||||
start_client ""
|
||||
./scripts/client-flow.sh as-client-flow "client sends PKCE"
|
||||
# Restart the authorization server too, so the consent already granted above does not
|
||||
# short-circuit the second run.
|
||||
start_auth ""
|
||||
start_client "nopkce"
|
||||
./scripts/client-flow.sh as-client-flow-nopkce "confidential client, no PKCE - the Boot default"
|
||||
kill_app ClientApplication 8080
|
||||
|
||||
echo "== noclaims: the token customiser removed"
|
||||
start_auth "noclaims"
|
||||
./scripts/client-credentials.sh as-client-credentials-noclaims
|
||||
./scripts/authcode-pkce.sh as-authcode-noclaims demo-spa
|
||||
|
||||
echo "== nopkce: the public client no longer requires a verifier"
|
||||
start_auth "nopkce"
|
||||
# With a challenge present, the server still demands the verifier - requireProofKey only
|
||||
# controls whether a challenge is MANDATORY, not whether one that was sent is honoured.
|
||||
./scripts/authcode-pkce.sh as-authcode-nopkce demo-spa
|
||||
# Without any challenge at all, the code alone is enough. This is the actual exposure.
|
||||
NO_CHALLENGE=1 ./scripts/authcode-pkce.sh as-authcode-nochallenge demo-spa
|
||||
|
||||
echo "== the same request against a client that DOES require PKCE"
|
||||
start_auth ""
|
||||
NO_CHALLENGE=1 ./scripts/authcode-pkce.sh as-authcode-pkce-enforced demo-spa
|
||||
|
||||
echo "== noconsent: consent turned off"
|
||||
start_auth "noconsent"
|
||||
./scripts/authcode-pkce.sh as-authcode-noconsent demo-spa
|
||||
|
||||
echo "== entry point and the Accept header"
|
||||
rm -f ../docs/output/as-entrypoint-accept.txt
|
||||
start_auth "acceptall"
|
||||
./scripts/entrypoint-accept.sh "acceptall profile: setIgnoredMediaTypes NOT called"
|
||||
start_auth ""
|
||||
./scripts/entrypoint-accept.sh "default profile: setIgnoredMediaTypes(ALL) called"
|
||||
|
||||
echo "== opaque: reference tokens for the service client"
|
||||
start_auth "opaque"
|
||||
./scripts/client-credentials.sh as-client-credentials-opaque
|
||||
|
||||
echo "== audience validation off on the resource server"
|
||||
start_auth ""
|
||||
start_rs "noaud"
|
||||
./scripts/audience.sh
|
||||
|
||||
echo "== the contract tests"
|
||||
mvn -B -o -pl auth-server test 2>&1 | grep -E "Tests run:|^\[INFO\] Running" \
|
||||
> ../docs/output/as-test-run.txt || true
|
||||
|
||||
kill_app AuthServerApplication 9000
|
||||
kill_app ResourceServerApplication 8090
|
||||
kill_app ClientApplication 8080
|
||||
|
||||
echo "== resource server startup with no provider"
|
||||
./scripts/rs-startup-failure.sh > /dev/null
|
||||
|
||||
echo
|
||||
echo "docs/output:"
|
||||
ls -1 ../docs/output/as-*.txt
|
||||
28
authorization-server/scripts/run.sh
Executable file
28
authorization-server/scripts/run.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# ./scripts/run.sh <auth|rs|client> [profiles]
|
||||
#
|
||||
# ./scripts/run.sh auth the provider on :9000
|
||||
# ./scripts/run.sh auth nopkce the provider with PKCE not required
|
||||
# ./scripts/run.sh rs the API on :8090
|
||||
# ./scripts/run.sh client the relying party on :8080
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
. scripts/lib.sh
|
||||
|
||||
case "${1:-}" in
|
||||
auth) MODULE=auth-server; MAIN=AuthServerApplication; PORT=9000 ;;
|
||||
rs) MODULE=resource-server; MAIN=ResourceServerApplication; PORT=8090 ;;
|
||||
client) MODULE=oidc-client; MAIN=ClientApplication; PORT=8080 ;;
|
||||
*) echo "usage: $0 <auth|rs|client> [profiles]" >&2; exit 2 ;;
|
||||
esac
|
||||
PROFILES="${2:-}"
|
||||
|
||||
kill_app "$MAIN" "$PORT"
|
||||
LOG="/tmp/${MODULE}.log"
|
||||
ARGS=(-B -pl "$MODULE" org.springframework.boot:spring-boot-maven-plugin:run)
|
||||
[ -n "$PROFILES" ] && ARGS+=("-Dspring-boot.run.profiles=$PROFILES")
|
||||
|
||||
# Detached, so the script returns and the demo scripts can drive it.
|
||||
setsid nohup mvn "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null &
|
||||
echo "starting $MODULE${PROFILES:+ [$PROFILES]} -> $LOG"
|
||||
wait_for "http://localhost:$PORT/" 120 && echo "$MODULE up on :$PORT"
|
||||
60
authorization-server/scripts/settings-defaults.sh
Executable file
60
authorization-server/scripts/settings-defaults.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compiles one tiny program twice - once against Spring Authorization Server 1.5.8, once
|
||||
# against 7.1.1 - and prints the defaults each version hands you. This is how the
|
||||
# requireProofKey change was found; no documentation was involved.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=../docs/output/as-settings-defaults.txt
|
||||
mkdir -p ../docs/output
|
||||
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$WORK/old"
|
||||
|
||||
fetch() { # group artifact version
|
||||
curl -sL -o "$WORK/old/$2-$3.jar" \
|
||||
"https://repo1.maven.org/maven2/$1/$2/$3/$2-$3.jar"
|
||||
}
|
||||
for a in spring-security-oauth2-authorization-server:1.5.8 spring-security-oauth2-core:6.5.1 \
|
||||
spring-security-oauth2-jose:6.5.1 spring-security-core:6.5.1 \
|
||||
spring-security-oauth2-client:6.5.1 spring-security-web:6.5.1; do
|
||||
fetch org/springframework/security "${a%%:*}" "${a##*:}"
|
||||
done
|
||||
for a in spring-core:6.2.7 spring-jcl:6.2.7; do
|
||||
fetch org/springframework "${a%%:*}" "${a##*:}"
|
||||
done
|
||||
|
||||
mvn -B -q -pl auth-server dependency:build-classpath \
|
||||
-Dmdep.outputFile=/tmp/as-cp.txt -DincludeScope=compile >/dev/null 2>&1
|
||||
CP_NEW=$(cat /tmp/as-cp.txt)
|
||||
CP_OLD=$(ls "$WORK"/old/*.jar | tr '\n' ':')
|
||||
|
||||
{
|
||||
echo "# Defaults of ClientSettings.builder().build() and TokenSettings.builder().build(),"
|
||||
echo "# read out of the jars themselves rather than from documentation."
|
||||
echo "# Source: tools/SettingsDefaults.java"
|
||||
echo
|
||||
echo "=== Spring Authorization Server 1.5.8 (last release of the standalone project) ==="
|
||||
javac -nowarn -cp "$CP_OLD" -d "$WORK/out-old" tools/SettingsDefaults.java \
|
||||
&& java -cp "$CP_OLD:$WORK/out-old" SettingsDefaults
|
||||
echo
|
||||
echo "=== Spring Authorization Server 7.1.1 (inside Spring Security, Boot 4.1.1 BOM) ==="
|
||||
javac -nowarn -cp "$CP_NEW" -d "$WORK/out-new" tools/SettingsDefaults.java \
|
||||
&& java -cp "$CP_NEW:$WORK/out-new" SettingsDefaults
|
||||
|
||||
echo
|
||||
echo "# The same question on the CLIENT side. Source: tools/ClientPkceDefault.java"
|
||||
mvn -B -q -pl oidc-client dependency:build-classpath \
|
||||
-Dmdep.outputFile=/tmp/cl-cp.txt -DincludeScope=compile >/dev/null 2>&1
|
||||
CP_CLIENT=$(cat /tmp/cl-cp.txt)
|
||||
echo
|
||||
echo "=== spring-security-oauth2-client 6.5.1 ==="
|
||||
javac -nowarn -cp "$CP_OLD" -d "$WORK/co" tools/ClientPkceDefault.java \
|
||||
&& java -cp "$CP_OLD:$WORK/co" ClientPkceDefault
|
||||
echo
|
||||
echo "=== spring-security-oauth2-client 7.1.1 (Boot 4.1.1 BOM) ==="
|
||||
javac -nowarn -cp "$CP_CLIENT" -d "$WORK/cn" tools/ClientPkceDefault.java \
|
||||
&& java -cp "$CP_CLIENT:$WORK/cn" ClientPkceDefault
|
||||
echo
|
||||
echo "# Both sides flipped in the 7.x line. Spring-to-Spring therefore still works;"
|
||||
echo "# a 7.1 authorization server in front of a 6.x or hand-rolled client does not."
|
||||
} > "$OUT" 2>&1
|
||||
cat "$OUT"
|
||||
40
authorization-server/src-broken/LegacySasConfig.java.txt
Normal file
40
authorization-server/src-broken/LegacySasConfig.java.txt
Normal file
@@ -0,0 +1,40 @@
|
||||
// The configuration every Spring Authorization Server tutorial written before
|
||||
// September 2025 tells you to write. It compiled against 1.5.8 and does not compile
|
||||
// against 7.1.1. Kept out of the build on purpose - scripts/compile-legacy.sh runs
|
||||
// javac against it and captures the real compiler output into
|
||||
// docs/output/as-legacy-compile-failure.txt.
|
||||
//
|
||||
// Three separate breakages in nine lines:
|
||||
// 1. the *Configuration class moved out of the SAS jar into spring-security-config
|
||||
// 2. the *Configurer class moved AND changed package shape
|
||||
// 3. applyDefaultSecurity(HttpSecurity) was deleted outright
|
||||
package com.ankurm.authserver.legacy;
|
||||
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
public class LegacySasConfig {
|
||||
|
||||
@Bean
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
|
||||
throws Exception {
|
||||
|
||||
// The 1.x one-liner.
|
||||
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
|
||||
|
||||
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
|
||||
.oidc(Customizer.withDefaults());
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
15
authorization-server/tools/ClientPkceDefault.java
Normal file
15
authorization-server/tools/ClientPkceDefault.java
Normal file
@@ -0,0 +1,15 @@
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
|
||||
/**
|
||||
* Prints whether a Spring OAuth2 <i>client</i> registration requires PKCE out of the box.
|
||||
* Run against spring-security-oauth2-client 6.5.1 and 7.1.1, it shows the default flipping
|
||||
* in the same release that flipped it on the authorization server.
|
||||
*/
|
||||
public class ClientPkceDefault {
|
||||
public static void main(String[] args) {
|
||||
ClientRegistration.ClientSettings cs =
|
||||
ClientRegistration.ClientSettings.builder().build();
|
||||
System.out.println("ClientRegistration.ClientSettings.requireProofKey = "
|
||||
+ cs.isRequireProofKey());
|
||||
}
|
||||
}
|
||||
20
authorization-server/tools/SettingsDefaults.java
Normal file
20
authorization-server/tools/SettingsDefaults.java
Normal file
@@ -0,0 +1,20 @@
|
||||
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
|
||||
/**
|
||||
* Prints the out-of-the-box values of ClientSettings and TokenSettings. Run against two
|
||||
* different Spring Authorization Server jars, it is the shortest proof that a default
|
||||
* changed. scripts/settings-defaults.sh does exactly that for 1.5.8 and 7.1.1.
|
||||
*/
|
||||
public class SettingsDefaults {
|
||||
public static void main(String[] a) {
|
||||
ClientSettings cs = ClientSettings.builder().build();
|
||||
TokenSettings ts = TokenSettings.builder().build();
|
||||
System.out.println("requireProofKey = " + cs.isRequireProofKey());
|
||||
System.out.println("requireAuthorizationConsent= " + cs.isRequireAuthorizationConsent());
|
||||
System.out.println("accessTokenTimeToLive = " + ts.getAccessTokenTimeToLive());
|
||||
System.out.println("accessTokenFormat = " + ts.getAccessTokenFormat().getValue());
|
||||
System.out.println("refreshTokenTimeToLive = " + ts.getRefreshTokenTimeToLive());
|
||||
System.out.println("reuseRefreshTokens = " + ts.isReuseRefreshTokens());
|
||||
System.out.println("authorizationCodeTTL = " + ts.getAuthorizationCodeTimeToLive());
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
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)
|
||||
123
docs/authorization-server/03-clients-and-pkce.md
Normal file
123
docs/authorization-server/03-clients-and-pkce.md
Normal file
@@ -0,0 +1,123 @@
|
||||
[← 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`
|
||||
([`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
|
||||
```
|
||||
|
||||
Nine hops 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)
|
||||
32
docs/output/as-audience.txt
Normal file
32
docs/output/as-audience.txt
Normal 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"
|
||||
67
docs/output/as-authcode-nochallenge.txt
Normal file
67
docs/output/as-authcode-nochallenge.txt
Normal 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.
|
||||
134
docs/output/as-authcode-noclaims.txt
Normal file
134
docs/output/as-authcode-noclaims.txt
Normal 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"
|
||||
123
docs/output/as-authcode-noconsent.txt
Normal file
123
docs/output/as-authcode-noconsent.txt
Normal 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"
|
||||
140
docs/output/as-authcode-nopkce.txt
Normal file
140
docs/output/as-authcode-nopkce.txt
Normal 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"
|
||||
42
docs/output/as-authcode-pkce-enforced.txt
Normal file
42
docs/output/as-authcode-pkce-enforced.txt
Normal 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
|
||||
140
docs/output/as-authcode-pkce.txt
Normal file
140
docs/output/as-authcode-pkce.txt
Normal 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"
|
||||
155
docs/output/as-authcode-web.txt
Normal file
155
docs/output/as-authcode-web.txt
Normal 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"}
|
||||
67
docs/output/as-client-credentials-noclaims.txt
Normal file
67
docs/output/as-client-credentials-noclaims.txt
Normal 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"
|
||||
|
||||
68
docs/output/as-client-credentials-opaque.txt
Normal file
68
docs/output/as-client-credentials-opaque.txt
Normal 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"
|
||||
|
||||
66
docs/output/as-client-credentials.txt
Normal file
66
docs/output/as-client-credentials.txt
Normal 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"
|
||||
|
||||
35
docs/output/as-client-flow-nopkce.txt
Normal file
35
docs/output/as-client-flow-nopkce.txt
Normal 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
|
||||
50
docs/output/as-client-flow.txt
Normal file
50
docs/output/as-client-flow.txt
Normal 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
|
||||
240
docs/output/as-discovery.txt
Normal file
240
docs/output/as-discovery.txt
Normal 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
|
||||
}
|
||||
]
|
||||
65
docs/output/as-entrypoint-accept.txt
Normal file
65
docs/output/as-entrypoint-accept.txt
Normal 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
|
||||
24
docs/output/as-legacy-compile-failure.txt
Normal file
24
docs/output/as-legacy-compile-failure.txt
Normal 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
|
||||
5
docs/output/as-missing-consent-service.txt
Normal file
5
docs/output/as-missing-consent-service.txt
Normal 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: {}
|
||||
15
docs/output/as-pkce-applier.txt
Normal file
15
docs/output/as-pkce-applier.txt
Normal 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>);
|
||||
11
docs/output/as-rs-startup-failure.txt
Normal file
11
docs/output/as-rs-startup-failure.txt
Normal 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)
|
||||
32
docs/output/as-settings-defaults.txt
Normal file
32
docs/output/as-settings-defaults.txt
Normal 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.
|
||||
3
docs/output/as-test-run.txt
Normal file
3
docs/output/as-test-run.txt
Normal 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
|
||||
Reference in New Issue
Block a user