Skip to content

test: OIDC social-login setPassword step-up fallback E2E (#90) - #93

Merged
devondragon merged 3 commits into
mainfrom
feature/90-step-up-oidc-setpassword-e2e
Aug 24, 2026
Merged

test: OIDC social-login setPassword step-up fallback E2E (#90)#93
devondragon merged 3 commits into
mainfrom
feature/90-step-up-oidc-setpassword-e2e

Conversation

@devondragon

Copy link
Copy Markdown
Owner

Closes #90. Follow-up to #75 (WebAuthn step-up / SUF-02), which deferred this acceptance case because it needs a Keycloak-backed harness the standard Playwright setup did not have.

What this proves

An OIDC (Keycloak) social-login account has no passkey, so it can never satisfy a WEBAUTHN step-up. This E2E confirms that its initial POST /user/setPassword is governed by allowInitialPasswordSetWithoutStepUp403 when the flag is false, 200 when true — rather than the permanent 401 code 6 a passkey-only account gets. It also exercises that OIDC login stamps FACTOR_AUTHORIZATION_CODE, so a freshly logged-in OIDC user can enroll a first passkey.

Changes

  • chromium-step-up-oidc Playwright project (grep @step-up-oidc), excluded from the default and existing step-up projects.
  • globalSetup/globalTeardown start a dev-mode Keycloak container (the keycloak/realm export mounted, no external DB) when KEYCLOAK_E2E is set; an already-running provider is reused, and a live container is never torn down out from under a concurrent run.
  • step-up-oidc.spec.ts: real Keycloak redirect login (demo/demo), both fallback branches, and first-passkey enrollment. The two flag branches are separate app boots selected by STEP_UP_OIDC_ALLOW_INITIAL plus a SPRING_APPLICATION_JSON override; each of the three tests runs exactly once across the two boots.
  • playwright-tests-oidc CI job mirroring playwright-tests, with the Keycloak provider (via globalSetup) and a Mailpit service so the passkey-registration notification stays local instead of attempting a real SES send.
  • Docs: docs/TESTING.md and docs/AUTHENTICATION.md describe the new project, run commands, and the local-DB reset caveat (demo@example.com).

The app runs on the host on docker-keycloak,playwright-test,step-up, with the DS_SPRING_USER_KEYCLOAK_* provider URIs pointed at the host's published Keycloak port (localhost:8180) rather than the compose-network keycloak:8080.

Testing

Ran locally against a real Keycloak (both branches, twice):

  • Flag true: setPassword succeeds + first-passkey enrollment — 2 passed, 1 skipped.
  • Flag false: setPassword returns 403, not 401 — 1 passed, 2 skipped.
  • Keycloak container lifecycle starts and cleans up correctly.

Notes

  • The Keycloak client secret in the new CI job is a pre-existing dev-only value already committed in keycloak.env and keycloak/realm/realm-export.json, not a new secret.
  • Concurrent local KEYCLOAK_E2E=1 runs are unsupported by design (they share host port 8180); the harness fails fast with a clear message rather than corrupting state. CI runners are fresh VMs, so this does not apply there.

https://claude.ai/code/session_01EJY7pA4NvY9vJt6CfDBVWt

Follow-up to #75. Adds the browser E2E that proves an OIDC (Keycloak)
account with no passkey has POST /user/setPassword governed by
allowInitialPasswordSetWithoutStepUp (403 when false, succeeds when true)
rather than a permanent 401 step-up, and that a freshly logged-in OIDC
user can enroll a first passkey (FACTOR_AUTHORIZATION_CODE).

- New chromium-step-up-oidc Playwright project (grep @step-up-oidc),
  excluded from the default/step-up projects.
- globalSetup/globalTeardown start a dev-mode Keycloak container (realm
  mounted, no external DB) when KEYCLOAK_E2E is set; reuse an already
  running provider, and fail fast rather than destroy a live one.
- step-up-oidc.spec.ts: real Keycloak redirect login, both fallback
  branches (selected per app boot via STEP_UP_OIDC_ALLOW_INITIAL and a
  SPRING_APPLICATION_JSON override), and first-passkey enrollment.
- New playwright-tests-oidc CI job mirroring playwright-tests, with a
  Keycloak provider (via globalSetup) and a Mailpit service so the
  passkey-registration notification stays local instead of hitting SES.
- Docs: TESTING.md and AUTHENTICATION.md describe the new project, run
  commands, and the local-DB reset caveat.

Claude-Session: https://claude.ai/code/session_01EJY7pA4NvY9vJt6CfDBVWt
Comment thread .github/workflows/tests.yml Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new Keycloak harness has a real concurrency/ownership edge case and the new OIDC E2E spec hard-codes localhost:8080, which can break runs that override BASE_URL.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds end-to-end coverage for the step-up fallback behavior on OIDC (Keycloak) “social-login” accounts, demonstrating that POST /user/setPassword is governed by allowInitialPasswordSetWithoutStepUp (403 vs 200 depending on the flag) instead of returning a permanent step-up 401 for passkey-less OIDC accounts. This fits the demo app’s role as the reference implementation for Spring User Framework step-up behavior and its Playwright E2E harness.

Changes:

  • Add a new Chromium-only Playwright project/spec (@step-up-oidc) that performs a real Keycloak redirect login and asserts both allowInitialPasswordSetWithoutStepUp branches plus first-passkey enrollment.
  • Add Playwright global setup/teardown + a Docker-based Keycloak lifecycle helper to provision an OIDC provider for the OIDC E2E run.
  • Add a dedicated CI job to run the OIDC suite (twice, once per boot-time flag branch) and document how to run it locally.
File summaries
File Description
playwright/tsconfig.json Includes new global setup/teardown TypeScript entrypoints in the Playwright TS project.
playwright/tests/step-up/step-up-oidc.spec.ts New OIDC step-up fallback E2E spec (Keycloak redirect login + setPassword + first passkey enrollment).
playwright/src/utils/keycloak.ts New Docker-driven Keycloak lifecycle utilities for the OIDC Playwright project.
playwright/playwright.config.ts Adds globalSetup/globalTeardown hooks and the chromium-step-up-oidc project + grepInvert updates.
playwright/global-setup.ts Starts Keycloak for OIDC E2E runs when KEYCLOAK_E2E is set.
playwright/global-teardown.ts Stops Keycloak only when the run started it.
docs/TESTING.md Documents the new OIDC Playwright project, run commands, and CI job behavior.
docs/AUTHENTICATION.md Adds a reference to the OIDC fallback E2E coverage and where/how it runs.
.github/workflows/tests.yml Adds playwright-tests-oidc CI job with Mailpit + Keycloak harness for OIDC step-up tests.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +84 to +88
export async function startKeycloak(): Promise<void> {
if (await metadataReady()) {
// Reuse an already-running provider; do not claim ownership, so teardown leaves it alone.
return;
}
Comment on lines +26 to +48
/** Which allowInitialPasswordSetWithoutStepUp branch the app under test is booted with. */
const allowInitial = process.env.STEP_UP_OIDC_ALLOW_INITIAL === 'true';

/**
* Drive the full OIDC redirect login: app login page -> Keycloak form -> back to the app authenticated.
* Uses a fresh browser context (Playwright's per-test default), so there is no Keycloak SSO cookie and
* the login form is always shown.
*/
async function loginWithKeycloak(page: Page): Promise<void> {
await page.goto('/user/login.html');
await page.locator('a[href$="/oauth2/authorization/keycloak"]').click();

// Now on the Keycloak-hosted login form (published on host port 8180).
await page.waitForURL((url) => url.port === '8180', { timeout: 30000 });
await page.locator('#username').fill(KEYCLOAK_USER.username);
await page.locator('#password').fill(KEYCLOAK_USER.password);
await page.locator('#kc-login').click();

// Back on the app, authenticated, off the login page.
await page.waitForURL((url) => url.host === 'localhost:8080' && !url.pathname.includes('/login'), {
timeout: 30000,
});
}
The playwright-tests-oidc job is heavier than the others (two app boots
plus a Keycloak container), so gate it to pushes on main rather than
running it on every pull request.

Claude-Session: https://claude.ai/code/session_01EJY7pA4NvY9vJt6CfDBVWt
Comment on lines +126 to +236
name: Playwright OIDC Step-Up Tests
# Heavier than the other jobs (two app boots plus a Keycloak container), so it runs only on pushes
# to main, not on every pull request. The `push` trigger is already scoped to main; the ref check
# keeps it correct if more push branches are added later.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 30
services:
mariadb:
image: mariadb:12.2
env:
MARIADB_DATABASE: springuser
MARIADB_USER: springuser
MARIADB_PASSWORD: springuser
MARIADB_ROOT_PASSWORD: rootpassword
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=5
mailpit:
# The first-passkey-enrollment test registers a passkey, and user.webauthn.notifyOnRegistration
# is on by default, so the app sends a notification email. Without a relay it would attempt a real
# outbound SMTP connection to the base config's SES host on every run; catch it in Mailpit instead
# (the mail env below points spring.mail here). The test does not assert the mail; this just keeps
# the send local.
image: axllent/mailpit:v1.30.7
env:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
ports:
- 1025:1025
- 8025:8025
options: >-
--health-cmd="/mailpit readyz"
--health-interval=10s
--health-timeout=5s
--health-retries=5
env:
# The MariaDB service container replaces Spring Boot's Docker Compose integration.
SPRING_DOCKER_COMPOSE_ENABLED: "false"
# Point mail at the Mailpit service container so passkey-registration notifications stay local
# (mirrors docker-compose-keycloak.yml's SPRING_MAIL_* for the same plain-SMTP, no-auth setup).
SPRING_MAIL_HOST: localhost
SPRING_MAIL_PORT: "1025"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false"
# Tell Playwright's globalSetup to start a Keycloak provider (via docker) for this run.
KEYCLOAK_E2E: "1"
# OIDC client + provider config for the app under test. The app runs on the runner host (started by
# Playwright's webServer), not inside a compose network, so every provider URI points at the host's
# published Keycloak port 8180 rather than the compose-network keycloak:8080. Client id/secret match
# keycloak/realm/realm-export.json (dev-only credentials committed to the repo).
DS_SPRING_USER_KEYCLOAK_CLIENT_ID: ds-spring-user-framework-demo
DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET: FTp1j7sGvc4g3MFdghEX4n7RPhbu86PQ
DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI: http://localhost:8180/realms/demo/protocol/openid-connect/auth
DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI: http://localhost:8180/realms/demo/protocol/openid-connect/token
DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI: http://localhost:8180/realms/demo/protocol/openid-connect/userinfo
DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI: http://localhost:8180/realms/demo/protocol/openid-connect/certs
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21

- uses: gradle/actions/setup-gradle@v4

- name: Build application
run: ./gradlew assemble

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: playwright/package-lock.json

- name: Install Playwright dependencies
working-directory: playwright
run: |
npm ci
npx playwright install --with-deps chromium

# Two boots: the allowInitialPasswordSetWithoutStepUp flag is boot-time config, so each branch of
# the OIDC fallback needs its own app start. globalSetup brings Keycloak up for each run.
- name: Run E2E (OIDC step-up, initial password allowed)
working-directory: playwright
env:
APP_PROFILES: docker-keycloak,playwright-test,step-up
STEP_UP_OIDC_ALLOW_INITIAL: "true"
run: npx playwright test --project=chromium-step-up-oidc

- name: Run E2E (OIDC step-up, initial password denied)
working-directory: playwright
env:
APP_PROFILES: docker-keycloak,playwright-test,step-up
STEP_UP_OIDC_ALLOW_INITIAL: "false"
# Override playwright-test's flag (true) so the denial branch is exercised.
SPRING_APPLICATION_JSON: '{"user":{"security":{"allowInitialPasswordSetWithoutStepUp":false}}}'
run: npx playwright test --project=chromium-step-up-oidc

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-oidc
path: playwright/reports/
Addresses the two low-severity review findings on PR #93 (#90).

- Extract setupVirtualAuthenticator, addVirtualAuthenticator, and
  getCredentialIds into playwright/src/utils/webauthn.ts and export
  them from utils/index.ts. The step-up-oidc, step-up-flow, and
  mfa-flow specs now import these instead of each carrying a local
  copy (mfa-flow previously inlined the authenticator setup).

- startKeycloak now removes the container it started if waitForMetadata
  times out. Playwright's separate globalTeardown is not guaranteed to
  run when globalSetup throws, so a readiness timeout could otherwise
  leave the container and ownership marker behind, which the next run's
  "already running but not answering" guard would reject until a manual
  docker rm -f.

Claude-Session: https://claude.ai/code/session_01EJY7pA4NvY9vJt6CfDBVWt
@devondragon
devondragon merged commit 44e9cda into main Aug 24, 2026
9 checks passed
@devondragon
devondragon deleted the feature/90-step-up-oidc-setpassword-e2e branch August 24, 2026 19:25
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.

Step-up E2E: OIDC social-login setPassword fallback (Keycloak harness) — follow-up to #75

3 participants