From e9381dc5bea716aa7eef6e639c4077b99bd30233 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Mon, 24 Aug 2026 08:12:36 +0530 Subject: [PATCH] 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. --- README.md | 140 +++++++++- authorization-server/auth-server/pom.xml | 65 +++++ .../authserver/AuthServerApplication.java | 28 ++ .../config/AuthorizationServerConfig.java | 206 ++++++++++++++ .../config/DefaultSecurityConfig.java | 63 +++++ .../config/RegisteredClientConfig.java | 126 +++++++++ .../authserver/diag/ProviderDiagnostics.java | 105 +++++++ .../token/TokenClaimsCustomizer.java | 81 ++++++ .../authserver/web/ConsentController.java | 104 +++++++ .../main/resources/application-acceptall.yaml | 5 + .../main/resources/application-noclaims.yaml | 3 + .../main/resources/application-noconsent.yaml | 4 + .../main/resources/application-nopkce.yaml | 4 + .../main/resources/application-opaque.yaml | 4 + .../src/main/resources/application-trace.yaml | 5 + .../src/main/resources/application.yaml | 19 ++ .../src/main/resources/templates/consent.html | 71 +++++ .../authserver/ProviderContractTests.java | 102 +++++++ authorization-server/oidc-client/pom.xml | 40 +++ .../com/ankurm/client/ClientApplication.java | 17 ++ .../ankurm/client/ClientSecurityConfig.java | 39 +++ .../com/ankurm/client/HomeController.java | 56 ++++ .../java/com/ankurm/client/PkceConfig.java | 82 ++++++ .../main/resources/application-nopkce.yaml | 3 + .../src/main/resources/application.yaml | 27 ++ .../src/main/resources/templates/home.html | 18 ++ .../src/main/resources/templates/orders.html | 14 + authorization-server/pom.xml | 44 +++ authorization-server/resource-server/pom.xml | 39 +++ .../java/com/ankurm/rs/ApiController.java | 63 +++++ .../ankurm/rs/ResourceServerApplication.java | 17 ++ .../java/com/ankurm/rs/SecurityConfig.java | 119 ++++++++ .../src/main/resources/application-noaud.yaml | 4 + .../src/main/resources/application.yaml | 18 ++ authorization-server/scripts/audience.sh | 35 +++ authorization-server/scripts/authcode-pkce.sh | 256 ++++++++++++++++++ .../scripts/client-credentials.sh | 63 +++++ authorization-server/scripts/client-flow.sh | 110 ++++++++ .../scripts/compile-legacy.sh | 34 +++ authorization-server/scripts/discovery.sh | 30 ++ .../scripts/entrypoint-accept.sh | 37 +++ authorization-server/scripts/lib.sh | 91 +++++++ authorization-server/scripts/pkce-applier.sh | 32 +++ .../scripts/rs-startup-failure.sh | 22 ++ authorization-server/scripts/run-all.sh | 114 ++++++++ authorization-server/scripts/run.sh | 28 ++ .../scripts/settings-defaults.sh | 60 ++++ .../src-broken/LegacySasConfig.java.txt | 40 +++ .../tools/ClientPkceDefault.java | 15 + .../tools/SettingsDefaults.java | 20 ++ docs/01-architecture.md | 6 + docs/09-manual-filter-vs-resource-server.md | 7 + docs/11-spring-security-7-changes.md | 16 ++ docs/12-issuer-and-audience.md | 6 + docs/15-jwks-caching-and-rotation.md | 8 + docs/17-keycloak-setup.md | 6 + docs/18-resource-server-checklist.md | 5 + docs/authorization-server/01-versions.md | 81 ++++++ .../02-minimum-provider.md | 101 +++++++ .../03-clients-and-pkce.md | 126 +++++++++ docs/authorization-server/04-consent-page.md | 77 ++++++ .../05-token-customisation.md | 106 ++++++++ .../06-resource-server.md | 96 +++++++ docs/authorization-server/07-diagnostics.md | 61 +++++ docs/authorization-server/08-client.md | 124 +++++++++ docs/authorization-server/09-entry-point.md | 71 +++++ docs/authorization-server/10-should-you.md | 49 ++++ docs/authorization-server/README.md | 64 +++++ docs/output/as-audience.txt | 32 +++ docs/output/as-authcode-nochallenge.txt | 67 +++++ docs/output/as-authcode-noclaims.txt | 134 +++++++++ docs/output/as-authcode-noconsent.txt | 123 +++++++++ docs/output/as-authcode-nopkce.txt | 140 ++++++++++ docs/output/as-authcode-pkce-enforced.txt | 42 +++ docs/output/as-authcode-pkce.txt | 140 ++++++++++ docs/output/as-authcode-web.txt | 155 +++++++++++ .../output/as-client-credentials-noclaims.txt | 67 +++++ docs/output/as-client-credentials-opaque.txt | 68 +++++ docs/output/as-client-credentials.txt | 66 +++++ docs/output/as-client-flow-nopkce.txt | 35 +++ docs/output/as-client-flow.txt | 50 ++++ docs/output/as-discovery.txt | 240 ++++++++++++++++ docs/output/as-entrypoint-accept.txt | 65 +++++ docs/output/as-legacy-compile-failure.txt | 24 ++ docs/output/as-missing-consent-service.txt | 5 + docs/output/as-pkce-applier.txt | 15 + docs/output/as-rs-startup-failure.txt | 11 + docs/output/as-settings-defaults.txt | 32 +++ docs/output/as-test-run.txt | 3 + 89 files changed, 5237 insertions(+), 9 deletions(-) create mode 100644 authorization-server/auth-server/pom.xml create mode 100644 authorization-server/auth-server/src/main/java/com/ankurm/authserver/AuthServerApplication.java create mode 100644 authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java create mode 100644 authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java create mode 100644 authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java create mode 100644 authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java create mode 100644 authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java create mode 100644 authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java create mode 100644 authorization-server/auth-server/src/main/resources/application-acceptall.yaml create mode 100644 authorization-server/auth-server/src/main/resources/application-noclaims.yaml create mode 100644 authorization-server/auth-server/src/main/resources/application-noconsent.yaml create mode 100644 authorization-server/auth-server/src/main/resources/application-nopkce.yaml create mode 100644 authorization-server/auth-server/src/main/resources/application-opaque.yaml create mode 100644 authorization-server/auth-server/src/main/resources/application-trace.yaml create mode 100644 authorization-server/auth-server/src/main/resources/application.yaml create mode 100644 authorization-server/auth-server/src/main/resources/templates/consent.html create mode 100644 authorization-server/auth-server/src/test/java/com/ankurm/authserver/ProviderContractTests.java create mode 100644 authorization-server/oidc-client/pom.xml create mode 100644 authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientApplication.java create mode 100644 authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java create mode 100644 authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java create mode 100644 authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java create mode 100644 authorization-server/oidc-client/src/main/resources/application-nopkce.yaml create mode 100644 authorization-server/oidc-client/src/main/resources/application.yaml create mode 100644 authorization-server/oidc-client/src/main/resources/templates/home.html create mode 100644 authorization-server/oidc-client/src/main/resources/templates/orders.html create mode 100644 authorization-server/pom.xml create mode 100644 authorization-server/resource-server/pom.xml create mode 100644 authorization-server/resource-server/src/main/java/com/ankurm/rs/ApiController.java create mode 100644 authorization-server/resource-server/src/main/java/com/ankurm/rs/ResourceServerApplication.java create mode 100644 authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java create mode 100644 authorization-server/resource-server/src/main/resources/application-noaud.yaml create mode 100644 authorization-server/resource-server/src/main/resources/application.yaml create mode 100755 authorization-server/scripts/audience.sh create mode 100755 authorization-server/scripts/authcode-pkce.sh create mode 100755 authorization-server/scripts/client-credentials.sh create mode 100755 authorization-server/scripts/client-flow.sh create mode 100755 authorization-server/scripts/compile-legacy.sh create mode 100755 authorization-server/scripts/discovery.sh create mode 100755 authorization-server/scripts/entrypoint-accept.sh create mode 100755 authorization-server/scripts/lib.sh create mode 100755 authorization-server/scripts/pkce-applier.sh create mode 100755 authorization-server/scripts/rs-startup-failure.sh create mode 100755 authorization-server/scripts/run-all.sh create mode 100755 authorization-server/scripts/run.sh create mode 100755 authorization-server/scripts/settings-defaults.sh create mode 100644 authorization-server/src-broken/LegacySasConfig.java.txt create mode 100644 authorization-server/tools/ClientPkceDefault.java create mode 100644 authorization-server/tools/SettingsDefaults.java create mode 100644 docs/authorization-server/01-versions.md create mode 100644 docs/authorization-server/02-minimum-provider.md create mode 100644 docs/authorization-server/03-clients-and-pkce.md create mode 100644 docs/authorization-server/04-consent-page.md create mode 100644 docs/authorization-server/05-token-customisation.md create mode 100644 docs/authorization-server/06-resource-server.md create mode 100644 docs/authorization-server/07-diagnostics.md create mode 100644 docs/authorization-server/08-client.md create mode 100644 docs/authorization-server/09-entry-point.md create mode 100644 docs/authorization-server/10-should-you.md create mode 100644 docs/authorization-server/README.md create mode 100644 docs/output/as-audience.txt create mode 100644 docs/output/as-authcode-nochallenge.txt create mode 100644 docs/output/as-authcode-noclaims.txt create mode 100644 docs/output/as-authcode-noconsent.txt create mode 100644 docs/output/as-authcode-nopkce.txt create mode 100644 docs/output/as-authcode-pkce-enforced.txt create mode 100644 docs/output/as-authcode-pkce.txt create mode 100644 docs/output/as-authcode-web.txt create mode 100644 docs/output/as-client-credentials-noclaims.txt create mode 100644 docs/output/as-client-credentials-opaque.txt create mode 100644 docs/output/as-client-credentials.txt create mode 100644 docs/output/as-client-flow-nopkce.txt create mode 100644 docs/output/as-client-flow.txt create mode 100644 docs/output/as-discovery.txt create mode 100644 docs/output/as-entrypoint-accept.txt create mode 100644 docs/output/as-legacy-compile-failure.txt create mode 100644 docs/output/as-missing-consent-service.txt create mode 100644 docs/output/as-pkce-applier.txt create mode 100644 docs/output/as-rs-startup-failure.txt create mode 100644 docs/output/as-settings-defaults.txt create mode 100644 docs/output/as-test-run.txt diff --git a/README.md b/README.md index 4ade748..b255a47 100644 --- a/README.md +++ b/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; a few 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. diff --git a/authorization-server/auth-server/pom.xml b/authorization-server/auth-server/pom.xml new file mode 100644 index 0000000..768d9ae --- /dev/null +++ b/authorization-server/auth-server/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + com.ankurm + authorization-server-demo + 1.0.0 + + + auth-server + auth-server + The OAuth2 / OIDC provider itself + + + + + org.springframework.boot + spring-boot-starter-security-oauth2-authorization-server + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/AuthServerApplication.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/AuthServerApplication.java new file mode 100644 index 0000000..94fdd99 --- /dev/null +++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/AuthServerApplication.java @@ -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. + * + *

Start it with {@code ../scripts/run.sh auth} (default profile) or with one of the + * variant profiles that deliberately break something: + * + *

+ * + * @see + * docs/authorization-server/02-minimum-provider.md + */ +@SpringBootApplication +public class AuthServerApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthServerApplication.class, args); + } +} diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java new file mode 100644 index 0000000..8573765 --- /dev/null +++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java @@ -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. + * + *

The two import lines that break every tutorial

+ * + * Up to Spring Authorization Server 1.5.x these two classes lived in the + * {@code spring-security-oauth2-authorization-server} jar: + * + *
+ * org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
+ * org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer
+ * 
+ * + * As of 7.0 they live in {@code spring-security-config}, under different packages: + * + *
+ * org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
+ * org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer
+ * 
+ * + * 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 + * docs/authorization-server/01-versions.md + * @see + * docs/authorization-server/02-minimum-provider.md + */ +@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. + * + *

{@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 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 jwkSource) { + return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); + } +} diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java new file mode 100644 index 0000000..e5bba8d --- /dev/null +++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java @@ -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. + * + *

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 + * docs/authorization-server/03-clients-and-pkce.md + */ +@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. + * + *

{@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); + } +} diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java new file mode 100644 index 0000000..56590c2 --- /dev/null +++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java @@ -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. + * + *

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 + * docs/authorization-server/03-clients-and-pkce.md + */ +@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); + } +} diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java new file mode 100644 index 0000000..ede736f --- /dev/null +++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java @@ -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. + * + *

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. + * + *

Delete this before shipping. It exposes client ids, scopes, grant types and + * your chain ordering to anyone who can reach {@code /diag}. + * + * @see + * docs/authorization-server/07-diagnostics.md + */ +@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 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> clients() { + List> 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 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> chains() { + List> out = new ArrayList<>(); + int i = 0; + for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) { + Map 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; + } +} diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java new file mode 100644 index 0000000..0afeaef --- /dev/null +++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java @@ -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. + * + *

One bean of type {@code OAuth2TokenCustomizer} 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} 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. + * + *

The {@code noclaims} profile disables this bean, so the difference is a diff of two + * decoded tokens rather than a paragraph. + * + * @see + * docs/authorization-server/05-token-customisation.md + */ +@Configuration(proxyBeanMethods = false) +@Profile("!noclaims") +public class TokenClaimsCustomizer { + + @Bean + public OAuth2TokenCustomizer 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 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()); + } + }; + } +} diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java new file mode 100644 index 0000000..fb235a0 --- /dev/null +++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java @@ -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. + * + *

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: + * + *

+ * + *

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 + * docs/authorization-server/04-consent-page.md + */ +@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 alreadyApproved = currentConsent != null + ? currentConsent.getScopes() + : Set.of(); + + Set toApprove = new LinkedHashSet<>(); + Set 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"; + } + +} diff --git a/authorization-server/auth-server/src/main/resources/application-acceptall.yaml b/authorization-server/auth-server/src/main/resources/application-acceptall.yaml new file mode 100644 index 0000000..bb1b362 --- /dev/null +++ b/authorization-server/auth-server/src/main/resources/application-acceptall.yaml @@ -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 diff --git a/authorization-server/auth-server/src/main/resources/application-noclaims.yaml b/authorization-server/auth-server/src/main/resources/application-noclaims.yaml new file mode 100644 index 0000000..dc7c80e --- /dev/null +++ b/authorization-server/auth-server/src/main/resources/application-noclaims.yaml @@ -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. diff --git a/authorization-server/auth-server/src/main/resources/application-noconsent.yaml b/authorization-server/auth-server/src/main/resources/application-noconsent.yaml new file mode 100644 index 0000000..773d5ad --- /dev/null +++ b/authorization-server/auth-server/src/main/resources/application-noconsent.yaml @@ -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 diff --git a/authorization-server/auth-server/src/main/resources/application-nopkce.yaml b/authorization-server/auth-server/src/main/resources/application-nopkce.yaml new file mode 100644 index 0000000..8d5ae7f --- /dev/null +++ b/authorization-server/auth-server/src/main/resources/application-nopkce.yaml @@ -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 diff --git a/authorization-server/auth-server/src/main/resources/application-opaque.yaml b/authorization-server/auth-server/src/main/resources/application-opaque.yaml new file mode 100644 index 0000000..0c5959a --- /dev/null +++ b/authorization-server/auth-server/src/main/resources/application-opaque.yaml @@ -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 diff --git a/authorization-server/auth-server/src/main/resources/application-trace.yaml b/authorization-server/auth-server/src/main/resources/application-trace.yaml new file mode 100644 index 0000000..5996e4c --- /dev/null +++ b/authorization-server/auth-server/src/main/resources/application-trace.yaml @@ -0,0 +1,5 @@ +logging: + level: + org.springframework.security: TRACE + org.springframework.security.oauth2.server.authorization: TRACE + org.springframework.security.web.FilterChainProxy: DEBUG diff --git a/authorization-server/auth-server/src/main/resources/application.yaml b/authorization-server/auth-server/src/main/resources/application.yaml new file mode 100644 index 0000000..07cd408 --- /dev/null +++ b/authorization-server/auth-server/src/main/resources/application.yaml @@ -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 diff --git a/authorization-server/auth-server/src/main/resources/templates/consent.html b/authorization-server/auth-server/src/main/resources/templates/consent.html new file mode 100644 index 0000000..90a06b7 --- /dev/null +++ b/authorization-server/auth-server/src/main/resources/templates/consent.html @@ -0,0 +1,71 @@ + + + + + Approve access + + + +

+

Approve access

+

+ Signed in as user. + The application client wants to act on your behalf. +

+ + +
+ + + + + +

This application will be able to:

+ + +
+

Already approved previously:

+ +
+ +

+ openid is requested implicitly and is not listed — the + authorization server never asks for consent on it. +

+ +

+ +

+
+ +
+ + + + +
+
+ + diff --git a/authorization-server/auth-server/src/test/java/com/ankurm/authserver/ProviderContractTests.java b/authorization-server/auth-server/src/test/java/com/ankurm/authserver/ProviderContractTests.java new file mode 100644 index 0000000..4543e0a --- /dev/null +++ b/authorization-server/auth-server/src/test/java/com/ankurm/authserver/ProviderContractTests.java @@ -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()); + } +} diff --git a/authorization-server/oidc-client/pom.xml b/authorization-server/oidc-client/pom.xml new file mode 100644 index 0000000..64ecde1 --- /dev/null +++ b/authorization-server/oidc-client/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + + com.ankurm + authorization-server-demo + 1.0.0 + + + oidc-client + oidc-client + A relying party that logs in against auth-server and calls resource-server + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-security-oauth2-client + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientApplication.java b/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientApplication.java new file mode 100644 index 0000000..46dfcdf --- /dev/null +++ b/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientApplication.java @@ -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 + * docs/authorization-server/08-client.md + */ +@SpringBootApplication +public class ClientApplication { + public static void main(String[] args) { + SpringApplication.run(ClientApplication.class, args); + } +} diff --git a/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java b/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java new file mode 100644 index 0000000..551010a --- /dev/null +++ b/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java @@ -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. + * + *

{@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. + * + *

The redirect URI is derived from the registration id, and it must match what the + * provider has registered byte for byte. 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(); + } +} diff --git a/authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java b/authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java new file mode 100644 index 0000000..b3f11c5 --- /dev/null +++ b/authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java @@ -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. + * + *

{@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 id_token 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 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"; + } +} diff --git a/authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java b/authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java new file mode 100644 index 0000000..d47244d --- /dev/null +++ b/authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java @@ -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. + * + *

What changed, on both sides at once

+ * + * Read out of the jars with {@code javap} — see + * {@code docs/output/as-settings-defaults.txt}: + * + * + * + * + * + * + * + *
previouscurrent
Authorization server — {@code ClientSettings.requireProofKey}{@code false} (SAS 1.5.8){@code true} (7.1.1)
Client — {@code ClientRegistration.ClientSettings.requireProofKey}{@code false} (Spring Security 6.5.1){@code true} (7.1.1)
+ * + *

Because both moved together, Spring-client-to-Spring-server keeps working. The + * combination that breaks is a 7.1 authorization server with anything older or + * anything hand-rolled 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: + * + *

+ * error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge
+ * 
+ * + *

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 + * docs/authorization-server/08-client.md + */ +@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 off. It has to be + * turned off on the registration itself. + * + *

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()); + } + }; + } +} diff --git a/authorization-server/oidc-client/src/main/resources/application-nopkce.yaml b/authorization-server/oidc-client/src/main/resources/application-nopkce.yaml new file mode 100644 index 0000000..f2d7fd1 --- /dev/null +++ b/authorization-server/oidc-client/src/main/resources/application-nopkce.yaml @@ -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. diff --git a/authorization-server/oidc-client/src/main/resources/application.yaml b/authorization-server/oidc-client/src/main/resources/application.yaml new file mode 100644 index 0000000..1c02c44 --- /dev/null +++ b/authorization-server/oidc-client/src/main/resources/application.yaml @@ -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 diff --git a/authorization-server/oidc-client/src/main/resources/templates/home.html b/authorization-server/oidc-client/src/main/resources/templates/home.html new file mode 100644 index 0000000..95c2784 --- /dev/null +++ b/authorization-server/oidc-client/src/main/resources/templates/home.html @@ -0,0 +1,18 @@ + + +OIDC client + + +

Relying party

+
+

Not signed in.

+

Go to /orders — this triggers the authorization code flow.

+
+
+

Signed in as user.

+

/orders · log out

+

id_token claims

+

+
+ diff --git a/authorization-server/oidc-client/src/main/resources/templates/orders.html b/authorization-server/oidc-client/src/main/resources/templates/orders.html new file mode 100644 index 0000000..21a787f --- /dev/null +++ b/authorization-server/oidc-client/src/main/resources/templates/orders.html @@ -0,0 +1,14 @@ + + +Orders + + +

Resource server response

+

+

Granted scopes

+

+

Access token (raw)

+

+

back

+ diff --git a/authorization-server/pom.xml b/authorization-server/pom.xml new file mode 100644 index 0000000..afea21a --- /dev/null +++ b/authorization-server/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + authorization-server-demo + 1.0.0 + pom + authorization-server-demo + Spring Authorization Server 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com + + + + auth-server + resource-server + oidc-client + + + + 25 + UTF-8 + + diff --git a/authorization-server/resource-server/pom.xml b/authorization-server/resource-server/pom.xml new file mode 100644 index 0000000..1e8832d --- /dev/null +++ b/authorization-server/resource-server/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + com.ankurm + authorization-server-demo + 1.0.0 + + + resource-server + resource-server + An API that trusts tokens minted by auth-server + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/authorization-server/resource-server/src/main/java/com/ankurm/rs/ApiController.java b/authorization-server/resource-server/src/main/java/com/ankurm/rs/ApiController.java new file mode 100644 index 0000000..bc00f6d --- /dev/null +++ b/authorization-server/resource-server/src/main/java/com/ankurm/rs/ApiController.java @@ -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 open() { + return Map.of("message", "no token required"); + } + + /** Requires SCOPE_orders.read. */ + @GetMapping("/api/orders") + public Map orders(@AuthenticationPrincipal Jwt jwt) { + Map 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 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 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 whoami(@AuthenticationPrincipal Jwt jwt) { + Map 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; + } +} diff --git a/authorization-server/resource-server/src/main/java/com/ankurm/rs/ResourceServerApplication.java b/authorization-server/resource-server/src/main/java/com/ankurm/rs/ResourceServerApplication.java new file mode 100644 index 0000000..b741c3d --- /dev/null +++ b/authorization-server/resource-server/src/main/java/com/ankurm/rs/ResourceServerApplication.java @@ -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 + * docs/authorization-server/06-resource-server.md + */ +@SpringBootApplication +public class ResourceServerApplication { + public static void main(String[] args) { + SpringApplication.run(ResourceServerApplication.class, args); + } +} diff --git a/authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java b/authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java new file mode 100644 index 0000000..fa43698 --- /dev/null +++ b/authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java @@ -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. + * + *

{@code spring.security.oauth2.resourceserver.jwt.issuer-uri} alone gives you + * signature verification, {@code exp}/{@code nbf}, and an {@code iss} check. It does + * not 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 + * docs/authorization-server/06-resource-server.md + */ +@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 withIssuer = JwtValidators.createDefaultWithIssuer(issuer); + if (!validateAudience) { + decoder.setJwtValidator(withIssuer); + return decoder; + } + + OAuth2TokenValidator audienceValidator = jwt -> { + List 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( + scopes.convert(jwt)); + List 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; + } +} diff --git a/authorization-server/resource-server/src/main/resources/application-noaud.yaml b/authorization-server/resource-server/src/main/resources/application-noaud.yaml new file mode 100644 index 0000000..8d3d818 --- /dev/null +++ b/authorization-server/resource-server/src/main/resources/application-noaud.yaml @@ -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 diff --git a/authorization-server/resource-server/src/main/resources/application.yaml b/authorization-server/resource-server/src/main/resources/application.yaml new file mode 100644 index 0000000..05879e6 --- /dev/null +++ b/authorization-server/resource-server/src/main/resources/application.yaml @@ -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 diff --git a/authorization-server/scripts/audience.sh b/authorization-server/scripts/audience.sh new file mode 100755 index 0000000..d0b197a --- /dev/null +++ b/authorization-server/scripts/audience.sh @@ -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" diff --git a/authorization-server/scripts/authcode-pkce.sh b/authorization-server/scripts/authcode-pkce.sh new file mode 100755 index 0000000..d9ed3d4 --- /dev/null +++ b/authorization-server/scripts/authcode-pkce.sh @@ -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= $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<>' "${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 \1/p' + BODY=${NOVERIFIER%%$'\n'</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" diff --git a/authorization-server/scripts/client-credentials.sh b/authorization-server/scripts/client-credentials.sh new file mode 100755 index 0000000..a4b8da0 --- /dev/null +++ b/authorization-server/scripts/client-credentials.sh @@ -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" diff --git a/authorization-server/scripts/client-flow.sh b/authorization-server/scripts/client-flow.sh new file mode 100755 index 0000000..e7a56ec --- /dev/null +++ b/authorization-server/scripts/client-flow.sh @@ -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