Skip to content

Migrate the Spring Boot examples: resource server with Angular, and SAML - #46

Merged
pnzrr merged 8 commits into
p2-inc:mainfrom
Wictorgirardi:feat/spring-boot-examples
Oct 5, 2026
Merged

pnzrr merged 8 commits into
p2-inc:mainfrom
Wictorgirardi:feat/spring-boot-examples

Conversation

@Wictorgirardi

@Wictorgirardi Wictorgirardi commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #28: the branch includes its commit.

This PR replaces #41 and #42: the two Spring Boot 4.1 examples. Each one runs its own Keycloak with Docker Compose, because their tutorials use their own realms.

Each example has its own commits and touches only its own folder and workflow. The commits follow the order of the table, so the PR is easiest to review commit by commit. The section for each example is the description of the PR it replaces.

Example Folder Commits Replaces
Spring Boot + Angular frameworks/spring-boot-keycloak 1 #41
SAML IdP-initiated SSO (Spring Boot) saml2/idp-initiated 7 #42

Spring Boot + Angular

Replaces #41.

Summary

  • API toolchain:
    • Spring Boot 3.2.5 → 4.1.1 (Spring Security 7) and Gradle 8.7 → 9.7.1.
    • Java 17 → a Java 21 toolchain, resolved by the foojay plugin.
    • The dependencies use Boot 4's modular starters: spring-boot-starter-security, spring-boot-starter-security-oauth2-resource-server and spring-boot-starter-webmvc, plus their -test starters.
  • API security fixes:
    • GET /api/test/anonymous is public. The old config required a token on all of /api/**, including this endpoint.
    • Paths outside /api/**, /error and the protected resource metadata are denied.
    • CORS is configured for the Angular client, from app.cors.allowed-origins.
    • The endpoints return JSON records, and /api/test/user includes the caller's preferred_username.
    • JwtClaimsConverter is a plain converter without the unchecked cast.
    • The TRACE security logging and the redundant jwk-set-uri are removed. The issuer can be overridden with KEYCLOAK_ISSUER_URI.
  • Keycloak:
    • docker-compose.yml runs quay.io/phasetwo/phasetwo-keycloak:26.6 on port 8888.
    • It imports keycloak/demo-realm-realm.json: the public demo-spa client with PKCE, the realm role user, and two users. test / test has the role; noaccess / noaccess doesn't.
    • Before, it ran Keycloak 24 with a JDWP debug agent and an empty realm to set up by hand.
  • Angular client: rebuilt on Angular 22.2 the same way as the Angular example in Migrate the SPA examples: React, Vue, Nuxt, Angular and multitenant #44: standalone, zoneless, Vitest, angular-eslint 22.5 with ESLint 10, Tailwind 4, pnpm 10.34 and Node 24.
    • The old client declared Angular 21 with TypeScript 4.1, rxjs 6, tslint and Tailwind 2, so it could not install or build.
    • angular-oauth2-oidc 10 → 22, configured in provideAppInitializer.
    • PKCE is on; it was turned off with disablePKCE. Tokens are kept in session storage instead of local storage, and debug output is off.
    • provideOAuthClient attaches the access token only to requests to the API.
    • Buttons call both endpoints and show the response; the old client never called the API.
    • A functional authGuard protects /protected. The old guard always returned of(true).
    • It uses the shared layout and status strings, so tools/e2e-smoke runs against it.
  • Tests: TestControllerTests sends requests through the security filter chain and checks the public endpoint, 401, 403, 200 and CORS. JwtClaimsConverterTests checks the role mapping. None of them need Keycloak.
  • CI: spring-boot-keycloak.yml runs the shared Gradle workflow for the API and the shared Node workflow for the client.
  • README: rewritten to cover the architecture, the endpoints, the local Keycloak, curl calls, configuration and using your own realm.

Test plan

  • ./gradlew build passes with the Java 21 toolchain. The client's pnpm install --frozen-lockfile && pnpm lint && pnpm test && pnpm build passes on Node 24.
  • curl against the running API:
    • /api/test/anonymous answers 200 without a token;
    • /api/test/user answers 401 without a token and with a malformed one;
    • the CORS preflight is allowed from http://localhost:4200 and rejected with 403 from another origin.
  • Playwright against the local Keycloak:
    • test gets 200 from /api/test/user, and noaccess gets 403;
    • deep links to /protected go through Keycloak and come back;
    • logout ends the Keycloak session.
  • tools/e2e-smoke passes on port 4200 with test / test.
  • No console errors, failed requests, or WARN or ERROR lines in the API and dev-server logs.

Docs drift

blog/2024-05-09-secure-spring-boot.mdx:

  • L38 and L81: Java 17 → 21 and Keycloak 24 → 26.6.
  • L40 and L73–77: Spring Boot 3 → 4.1, with the starters listed above.
  • L52–61: the Initializr metadata has an invalid package name (com.example.spring-boot-keycloak), and the screenshot is outdated.
  • L83–104: the hosted starter instance no longer exists.
    • The local compose file imports demo-realm with test / test and noaccess / noaccess.
    • The client is the public demo-spa (PKCE, http://localhost:4200/*, web origin and post logout +), not a confidential client as in _oidc_client_creation_client_auth.mdx.
  • L26 and L181: angular.io → angular.dev, Angular CLI 22.

templates/frameworks/_springboot.mdx:

  • L3: hosted Keycloak → local compose.
  • L9–19: the YAML uses ${KEYCLOAK_ISSUER_URI:…}, drops jwk-set-uri and adds app.cors.allowed-origins.
  • L32 and L105: the package path is src/main/java/com/example/springbootkeycloak.
  • L34–62: SecurityConfig has CORS, public /error and GET /api/test/anonymous, and anyRequest().denyAll(), without @EnableWebSecurity or constructor injection.
  • L70–97: JwtClaimsConverter is not a @Component and has no unchecked cast.
  • L114–152: TestController uses @GetMapping and returns JSON records. /anonymous is public; the post says both endpoints need a token.
  • L154–165: the password-grant curl no longer works, since the client is public and direct grants are off. The README shows how to take a token from the Angular client.
  • L186–237: NgModule, APP_INITIALIZER, HttpClientModule, local storage and disablePKCE: true → a standalone app.config.ts with provideOAuthClient and provideAppInitializer, PKCE, and session storage.
  • L241: npm run start → pnpm start.
  • L245–291: user.component with *ngIf and a guard that always passed → home with @if, API buttons, and a functional authGuard on /protected.

SAML IdP-initiated SSO (Spring Boot)

Replaces #42.

Summary

  • Toolchain:
    • Spring Boot 3.4.3 → 4.1.1 (Spring Security 7.1), Gradle 8.12.1 → 9.7.1, and Java 17 → a Java 21 toolchain resolved by the foojay plugin.
    • The dependencies use Boot 4's modular starters, with spring-boot-starter-security-saml2 in place of the bare spring-security-saml2-service-provider.
    • The OpenSAML 5.1.3 constraint is removed, so the managed OpenSAML 5.2.3 is no longer downgraded. The Shibboleth repository moves to its current URL.
  • No committed keys:
    • The SP private key and certificate in src/main/resources/credentials/ are deleted, along with keycloak/saml-client.json, which embedded a client private key.
    • scripts/generate-sp-credentials.sh creates the key pair in a gitignored credentials/ folder. Both old keys stay in the git history, so the README says never to trust them.
  • Local Keycloak:
    • docker-compose.yml runs quay.io/phasetwo/phasetwo-keycloak:26.6 on port 8080, without the /auth path, like the tutorial.
    • It imports keycloak/test-realm-export.json, which replaces a 2,200-line export that referenced a real Okta tenant. The new realm has the SAML client okta-client, the user test / test, and a disabled okta-broker identity provider with placeholder values.
    • The file keeps the -export suffix because Keycloak imports a file named <name>-realm.json as the realm <name> and refused test-realm.json.
  • Service provider:
    • The registration is renamed okta-app → keycloak, and the invalid idp-entity-id property is removed.
    • The single logout URL is set, and a SecurityConfiguration adds saml2Logout and saml2Metadata, so the SP publishes its metadata at /saml2/metadata.
    • The page shows the NameID, the session index and every attribute. It reads them through Spring Security 7's Saml2AssertionAuthentication, which replaces the deprecated Saml2AuthenticatedPrincipal.
  • Tests: five offline tests cover the metadata endpoints, the redirect to Keycloak, the signed authentication request and the page after a SAML login. They read the IdP metadata from the classpath. The old contextLoads test needed a running Keycloak, so it couldn't pass in CI.
  • CI: saml2-idp-initiated.yml runs the shared Gradle workflow.
  • README: rewritten.
    • It explains the flow, with a sequence diagram, and the endpoints.
    • It adds a quick test without Okta through Keycloak's IdP-initiated SSO URL, then the Okta flow.
    • It covers signing keys, using your own Keycloak, and the security notes, including the replay window of unsolicited responses.

Test plan

  • ./gradlew build passes without credentials or Keycloak (5 tests), with the Java 21 toolchain.
  • Against the local Keycloak:
    • docker compose up -d --wait imports the realm, and the admin API shows the expected client, mappers, identity provider and user.
  • The IdP-initiated flow logs in.
    • The response is unsolicited (no InResponseTo) and signed, with Destination = ACS and Audience = SP entity ID.
    • The page shows NameID test, email, firstName and lastName.
  • SP-initiated login works (the response carries InResponseTo).
  • SAML single logout works.
    • The signed LogoutRequest gets a signed success LogoutResponse.
    • Opening the SP afterwards shows Keycloak's login form, so the Keycloak session really ended.
  • All flows also pass with "Client signature required" on and the SP's certificate in Keycloak.
  • A tampered response is rejected with invalid_signature.
  • No private keys or Okta tenant values are tracked.

@pnzrr
pnzrr force-pushed the feat/spring-boot-examples branch from dd7daf2 to 2088fad Compare October 5, 2026 16:53
@pnzrr
pnzrr merged commit 5cd785f into p2-inc:main Oct 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants