Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,116 @@
with:
name: playwright-report
path: playwright/reports/

playwright-tests-oidc:
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/
Comment on lines +126 to +236
7 changes: 7 additions & 0 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ browser (ceremony then retry succeeds) and the negative path (absent `WEBAUTHN`
runs no ceremony). Run it with
`APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up`.

The social-login fallback is covered by the `chromium-step-up-oidc` project
([`step-up-oidc.spec.ts`](../playwright/tests/step-up/step-up-oidc.spec.ts)): after a real Keycloak OIDC
login, `setPassword` on the passkey-less account is governed by `allowInitialPasswordSetWithoutStepUp`
(`403` when false, success when true) rather than a permanent `401`, and a freshly logged-in OIDC user can
enroll a first passkey. It needs a Keycloak provider and runs the app on
`docker-keycloak,playwright-test,step-up`; see [TESTING.md](TESTING.md) for the run command.

## MFA

The `mfa` profile turns on `user.mfa.enabled` (`application-mfa.yml:20`), `false` in the base config
Expand Down
51 changes: 44 additions & 7 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ disables verification/reset emails (tests fetch tokens via the Test API instead)
"set initial password" flow works without a `StepUpService` bean.

The `chromium`, `firefox`, `webkit`, `Mobile Chrome`, and `Mobile Safari` projects skip specs
tagged `@mfa-enabled` and `@step-up-enabled` (`grepInvert`); separate Chromium-only projects run
those, each against a server started with the matching add-on profile. Both use the CDP virtual
authenticator, so they are Chromium-only.
tagged `@mfa-enabled`, `@step-up-enabled`, and `@step-up-oidc` (`grepInvert`); separate Chromium-only
projects run those, each against a server started with the matching add-on profile. They use the CDP
virtual authenticator (and, for OIDC, a Keycloak provider), so they are Chromium-only.

```bash
# MFA flow (@mfa-enabled)
Expand All @@ -123,8 +123,40 @@ The step-up run adds `step-up-e2e` (`application-step-up-e2e.yml`), a test-only
`bootRun` starts Mailpit automatically alongside MariaDB. The realistic demo values stay in
`application-step-up.yml` (`ttlSeconds: 120`). The step-up specs run serially
(`test.describe.configure({ mode: 'serial' })`) because concurrent account registration deadlocks in
MariaDB (framework issue devondragon/SpringUserFramework#368). One acceptance case is not covered here:
social-login (OIDC) `setPassword` fallback, which needs the Keycloak stack (tracked separately).
MariaDB (framework issue devondragon/SpringUserFramework#368).

The social-login (OIDC) `setPassword` fallback is covered separately by the `chromium-step-up-oidc`
project ([`step-up-oidc.spec.ts`](../playwright/tests/step-up/step-up-oidc.spec.ts)), because it needs a
real OpenID provider. `globalSetup` starts a dev-mode Keycloak (`quay.io/keycloak/keycloak:25.0.6
start-dev --import-realm`, the `keycloak/realm` export mounted, no external database) whenever
`KEYCLOAK_E2E` is set, and `globalTeardown` removes it; an already-running Keycloak is reused and left
alone. 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`. The `allowInitialPasswordSetWithoutStepUp` flag is
boot-time config, so each branch is its own app boot, selected by `STEP_UP_OIDC_ALLOW_INITIAL`:

```bash
# setPassword succeeds (flag true, from playwright-test)
KEYCLOAK_E2E=1 STEP_UP_OIDC_ALLOW_INITIAL=true \
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 \
APP_PROFILES=docker-keycloak,playwright-test,step-up \
npx playwright test --project=chromium-step-up-oidc

# setPassword denied with 403, not a permanent 401 (flag false)
# ...same env, but: STEP_UP_OIDC_ALLOW_INITIAL=false and
# SPRING_APPLICATION_JSON='{"user":{"security":{"allowInitialPasswordSetWithoutStepUp":false}}}'
```

The spec logs in as the realm's single seeded user (`demo@example.com`) and resets the local
KEYCLOAK-provisioned account before each test so re-runs stay deterministic. Run locally, `bootRun` uses
your normal development database (`compose.dev.yaml`, port 3306), so the reset deletes any local account
at that address: do not keep a real account you care about under `demo@example.com` in your dev database.
In CI the database is a throwaway service container, so nothing persists.

**Test API**:
[`TestDataController`](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java)
Expand All @@ -138,6 +170,11 @@ disables CSRF for `/api/test/**` and restricts it to requests from `127.0.0.1`,

[`.github/workflows/tests.yml`](../.github/workflows/tests.yml) runs on pull requests and pushes
to `main`: **`unit-tests`** runs `./gradlew test` on Java 21. **`playwright-tests`** builds the
app, starts a `mariadb:12.2` service container, installs Playwright, then runs E2E twice: once
app, starts a `mariadb:12.2` service container, installs Playwright, then runs E2E three times: once
with `APP_PROFILES=playwright-test` against `chromium` (MFA off), once with
`APP_PROFILES=playwright-test,mfa` against `chromium-mfa` (MFA on).
`APP_PROFILES=playwright-test,mfa` against `chromium-mfa` (MFA on), and once with
`APP_PROFILES=local,playwright-test,step-up,step-up-e2e` against `chromium-step-up`. **`playwright-tests-oidc`**
covers the OIDC `setPassword` fallback: it starts the same MariaDB service (plus a Mailpit service), and
`globalSetup` brings up a dev-mode Keycloak on the runner, then runs `chromium-step-up-oidc` twice (once per
`allowInitialPasswordSetWithoutStepUp` branch). Because it is heavier (two app boots plus a Keycloak
container), it runs only on pushes to `main`, not on every pull request.
13 changes: 13 additions & 0 deletions playwright/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { startKeycloak } from './src/utils/keycloak';

/**
* Global setup, run once before the suite. Only the chromium-step-up-oidc project needs an external
* OpenID provider, so this brings Keycloak up only when KEYCLOAK_E2E is set (the OIDC run command and
* CI job set it). Every other project skips this and relies solely on the `webServer` block.
*/
async function globalSetup(): Promise<void> {
if (!process.env.KEYCLOAK_E2E) return;
await startKeycloak();
}

export default globalSetup;
12 changes: 12 additions & 0 deletions playwright/global-teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { stopKeycloak } from './src/utils/keycloak';

/**
* Global teardown, run once after the suite. Removes the Keycloak container, but only the one this run
* started (stopKeycloak no-ops when the provider was reused rather than started here).
*/
async function globalTeardown(): Promise<void> {
if (!process.env.KEYCLOAK_E2E) return;
await stopKeycloak();
}

export default globalTeardown;
38 changes: 33 additions & 5 deletions playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export default defineConfig({
/* Unique output directories for this project */
outputDir: path.join(__dirname, 'test-results', PROJECT_ID),

/* Bring up a Keycloak OpenID provider for the chromium-step-up-oidc project. Both hooks no-op unless
* KEYCLOAK_E2E is set, so every other project runs without Docker. See src/utils/keycloak.ts. */
globalSetup: path.join(__dirname, 'global-setup.ts'),
globalTeardown: path.join(__dirname, 'global-teardown.ts'),

/* Run tests in files in parallel */
fullyParallel: true,

Expand Down Expand Up @@ -93,32 +98,32 @@ export default defineConfig({
projects: [
{
name: 'chromium',
grepInvert: /@mfa-enabled|@step-up-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/,
use: { ...devices['Desktop Chrome'] },
},

{
name: 'firefox',
grepInvert: /@mfa-enabled|@step-up-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/,
use: { ...devices['Desktop Firefox'] },
},

{
name: 'webkit',
grepInvert: /@mfa-enabled|@step-up-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/,
use: { ...devices['Desktop Safari'] },
},

/* Test against mobile viewports */
{
name: 'Mobile Chrome',
grepInvert: /@mfa-enabled|@step-up-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/,
use: { ...devices['Pixel 5'] },
},

{
name: 'Mobile Safari',
grepInvert: /@mfa-enabled|@step-up-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/,
use: { ...devices['iPhone 12'] },
},

Expand All @@ -138,6 +143,29 @@ export default defineConfig({
grep: /@step-up-enabled/,
use: { ...devices['Desktop Chrome'] },
},

/* Step-up OIDC fallback (issue #90): Chromium only, needs both a Keycloak OpenID provider and the
* app on the docker-keycloak,playwright-test,step-up profiles. globalSetup starts Keycloak when
* KEYCLOAK_E2E is set. The two allowInitialPasswordSetWithoutStepUp branches are separate app boots,
* selected by STEP_UP_OIDC_ALLOW_INITIAL (and the matching SPRING_APPLICATION_JSON override):
* # setPassword succeeds (flag true, from playwright-test):
* KEYCLOAK_E2E=1 STEP_UP_OIDC_ALLOW_INITIAL=true \
* 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 \
* APP_PROFILES=docker-keycloak,playwright-test,step-up \
* npx playwright test --project=chromium-step-up-oidc
* # setPassword denied with 403, not a permanent 401 (flag false):
* ... STEP_UP_OIDC_ALLOW_INITIAL=false \
* SPRING_APPLICATION_JSON='{"user":{"security":{"allowInitialPasswordSetWithoutStepUp":false}}}' ... */
{
name: 'chromium-step-up-oidc',
grep: /@step-up-oidc/,
use: { ...devices['Desktop Chrome'] },
},
],

/* Run your local dev server before starting the tests */
Expand Down
2 changes: 2 additions & 0 deletions playwright/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export {
type UnlockUserResponse,
type HealthResponse,
} from './test-api-client';

export { setupVirtualAuthenticator, addVirtualAuthenticator, getCredentialIds } from './webauthn';
Loading
Loading