Skip to content
Open
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
3 changes: 3 additions & 0 deletions stepup/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ services:
openconextdev:
volumes:
- ${PWD}/ssp:/var/www/simplesaml/config/cert/
# Local sp.php with mdui:UIInfo (service name) extension support, pending upstream
# inclusion in OpenConext-devssp
- ${PWD}/ssp/sp.php:/var/www/simplesaml/public/sp.php
hostname: ssp.docker


Expand Down
711 changes: 711 additions & 0 deletions stepup/ssp/sp.php

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions stepup/tests/behat/features/bootstrap/SecondFactorAuthContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,38 @@ public function startASfoAuthenticationWithGsspExtension(string $userIdentifier,
$this->minkContext->pressButton('Login');
}

/**
* @When I start an SFO authentication for :arg1 with service name :arg2
*/
public function startASfoAuthenticationWithServiceName(string $userIdentifier, string $serviceName)
{
$this->minkContext->visit($this->spTestUrl);
$this->minkContext->fillField('idp', $this->activeIdp);
$this->minkContext->fillField('sp', $this->activeSp);
$this->minkContext->fillField('loa', $this->requiredLoa);
$this->minkContext->fillField('subject', $userIdentifier);
$this->minkContext->fillField('mdui_displayname', $serviceName);
$this->minkContext->pressButton('Login');
}

/**
* @Then I see service name :arg1 on the GSSP authentication page
*/
public function iSeeServiceNameOnTheGsspAuthenticationPage(string $serviceName)
{
$this->minkContext->assertPageAddress('https://demogssp.dev.openconext.local/authentication');
$this->minkContext->assertPageContainsText($serviceName);
}

/**
* @Then I see service name :arg1 on the GSSP registration page
*/
public function iSeeServiceNameOnTheGsspRegistrationPage(string $serviceName)
{
$this->minkContext->assertPageAddress('https://demogssp.dev.openconext.local/registration');
$this->minkContext->assertPageContainsText($serviceName);
}

/**
* @When I start an SFO authentication for :arg1
*/
Expand Down
33 changes: 33 additions & 0 deletions stepup/tests/behat/features/gssp_service_name.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Tagged SKIP until Stepup-Gateway PR #624 is merged and released in the test image
# with enable_service_name_from_saml_authnrequest enabled, and the mdui-capable
# sp.php is available. Run locally with:
# ./start-dev-env.sh gateway:<Stepup-Gateway checkout> demogssp:<Stepup-gssp-example checkout>
# docker compose exec behat ./vendor/bin/behat --config config/behat.yml features/gssp_service_name.feature
@SKIP
Feature: The GSSP shows the name of the service the user is authenticating for
In order to know which service I am authenticating for
As a user
I want the GSSP authentication page to show the service name from the AuthnRequest

# Covers the cross-repo flow of the mdui:UIInfo service name:
# the SP sends an AuthnRequest with an mdui:UIInfo/mdui:DisplayName extension,
# the Stepup-Gateway (feature flag enable_service_name_from_saml_authnrequest)
# reads it and forwards it in the proxy AuthnRequest to the GSSP, where the
# GSSP (Stepup-gssp-example via Stepup-gssp-bundle and Stepup-saml-bundle)
# displays it on the authentication page.
Scenario: Service name from the AuthnRequest mdui:UIInfo is shown on the GSSP authentication page
Given a service provider configured for second-factor-only
And a user "jane-a-ra" identified by "urn:collab:person:institution-a.example.com:jane-a-ra" from institution "institution-a.example.com" with UUID "00000000-0000-4000-8000-000000000001"
And the user "urn:collab:person:institution-a.example.com:jane-a-ra" has a vetted "demo-gssp" with identifier "gssp-identifier123"
When I start an SFO authentication for "urn:collab:person:institution-a.example.com:jane-a-ra" with service name "Behat Test Service"
Then I see service name "Behat Test Service" on the GSSP authentication page
When I verify the "demo-gssp" second factor
Then I am logged on the service provider

# Reuses the identity vetted in the previous scenario, like sfo.feature does.
Scenario: No service name is shown when the AuthnRequest carries no mdui:UIInfo
Given a service provider configured for second-factor-only
When I start an SFO authentication for "urn:collab:person:institution-a.example.com:jane-a-ra"
Then I should not see "Behat Test Service"
When I verify the "demo-gssp" second factor
Then I am logged on the service provider
5 changes: 5 additions & 0 deletions stepup/tests/playwright/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
test-results/
playwright-report/
.playwright-mcp/
*.png
60 changes: 60 additions & 0 deletions stepup/tests/playwright/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Service name e2e tests (Playwright)

Cross-repo browser test for the "show service name during authentication" feature:
Stepup-Middleware#589, Stepup-Gateway#624, Stepup-saml-bundle#137,
Stepup-gssp-bundle#49, Stepup-gssp-example#141.

## Prerequisites

1. Start the devconf stepup environment (see `../../README.md`), pointing
`middleware` / `gateway` / `demogssp` at your local checkouts if you're
testing branches that aren't in the `test` images yet:

```bash
cd ../..
./start-dev-env.sh -d \
middleware:/path/to/Stepup-Middleware \
gateway:/path/to/Stepup-Gateway \
demogssp:/path/to/Stepup-gssp-example
```

2. `.env` must have `APP_ENV=smoketest` (routes the apps to `*_test` DBs).

3. Run the Behat suite at least once against this stack so the `jane-a-ra`
identity (vetted `demo-gssp` second factor) exists — this test reuses
that fixture data rather than duplicating the bootstrap:

```bash
docker compose exec behat ./vendor/bin/behat --config config/behat.yml \
--tags='~@wip' features/gssp_service_name.feature
```

4. In `Stepup-Gateway`, `enable_service_name_from_saml_authnrequest: true`
must be set (already the case in the devconf parameters).

## Install & run

```bash
npm install
npx playwright install chromium # first time only
NODE_TLS_REJECT_UNAUTHORIZED=0 npx playwright test
```

`NODE_TLS_REJECT_UNAUTHORIZED=0` is needed because the devconf stack uses a
self-signed cert and `lib/middleware.ts` pushes config over `fetch()`
directly (not through Playwright's browser context, which is configured with
`ignoreHTTPSErrors` separately).

## What it does

Each test pushes a config change to Middleware for the `second-sp` SP entity
(`lib/middleware.ts`), then drives the SP debug page
(`https://ssp.dev.openconext.local/simplesaml/sp.php`) through an SFO login
to `demogssp`, asserting on what service name is shown:

- No Middleware `service_name` → the SP's own `mdui:DisplayName` is shown.
- Middleware `service_name` set → it wins, regardless of what the SP sends.
- Neither present → no service name section renders, no error.

Each test resets `second-sp`'s `service_name` back to unset in `afterEach`,
so the shared environment is left as found.
54 changes: 54 additions & 0 deletions stepup/tests/playwright/lib/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from 'node:fs';
import path from 'node:path';

const MIDDLEWARE_CONFIG_URL = 'https://middleware.dev.openconext.local/management/configuration';
const MIDDLEWARE_CONFIG_PATH = path.resolve(__dirname, '../../../middleware/middleware-config.json');
const MANAGEMENT_USER = 'management';
const MANAGEMENT_PASSWORD = 'secret';

type ServiceProvider = { entity_id: string; service_name?: string | null; [key: string]: unknown };
type MiddlewareConfig = { gateway: { service_providers: ServiceProvider[]; [key: string]: unknown }; [key: string]: unknown };

function loadBaseConfig(): MiddlewareConfig {
const raw = fs.readFileSync(MIDDLEWARE_CONFIG_PATH, 'utf-8');
return JSON.parse(raw) as MiddlewareConfig;
}

/**
* Pushes the devconf baseline middleware-config.json, optionally overriding
* `service_name` on one SP entity. Pass `serviceName: null` to push the
* baseline unmodified (no service_name key on that entity).
*/
export async function pushServiceName(entityId: string, serviceName: string | null): Promise<void> {
const config = loadBaseConfig();
const sp = config.gateway.service_providers.find((s) => s.entity_id === entityId);
if (!sp) {
throw new Error(`No service provider with entity_id "${entityId}" found in ${MIDDLEWARE_CONFIG_PATH}`);
}
if (serviceName === null) {
delete sp.service_name;
} else {
sp.service_name = serviceName;
}

const auth = Buffer.from(`${MANAGEMENT_USER}:${MANAGEMENT_PASSWORD}`).toString('base64');
const response = await fetch(MIDDLEWARE_CONFIG_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`,
},
body: JSON.stringify(config),
// devconf uses a self-signed cert; Node's fetch needs this at the process level,
// see NODE_TLS_REJECT_UNAUTHORIZED=0 in the npm script / CI invocation.
});

if (!response.ok) {
throw new Error(`Middleware config push failed: HTTP ${response.status} ${await response.text()}`);
}
const body = (await response.json()) as { status?: string };
if (body.status !== 'OK') {
throw new Error(`Middleware config push did not return status OK: ${JSON.stringify(body)}`);
}
}
76 changes: 76 additions & 0 deletions stepup/tests/playwright/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions stepup/tests/playwright/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "stepup-service-name-e2e",
"private": true,
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.48.0"
}
}
17 changes: 17 additions & 0 deletions stepup/tests/playwright/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from '@playwright/test';

// Requires the stepup devconf environment running with the `smoketest` .env profile
// (see ../../README.md) so the app containers use *_test databases and the
// hosts file entries for *.dev.openconext.local resolve to 127.0.0.1.
export default defineConfig({
testDir: './tests',
timeout: 30_000,
fullyParallel: false,
reporter: 'list',
use: {
ignoreHTTPSErrors: true,
baseURL: 'https://ssp.dev.openconext.local',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
});
81 changes: 81 additions & 0 deletions stepup/tests/playwright/tests/service-name.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { test, expect, type Page } from '@playwright/test';
import { pushServiceName } from '../lib/middleware';

/**
* Cross-repo e2e coverage for the "service name during authentication" feature
* (Stepup-Middleware#589, Stepup-Gateway#624, Stepup-saml-bundle#137,
* Stepup-gssp-bundle#49, Stepup-gssp-example#141).
*
* Flow under test: SP (ssp debug SP) --AuthnRequest w/ mdui:UIInfo--> Gateway
* (SFO) --proxy AuthnRequest--> GSSP (demogssp) authentication page.
*
* Priority rule under test (see REFINEMENT_SERVICE_NAME.md):
* Middleware `service_name`, when configured for the SP, always wins over
* any mdui:DisplayName sent by the SP in the AuthnRequest.
*
* Prerequisites:
* - devconf-service-name/stepup environment running (./start-dev-env.sh -d
* with gateway/demogssp/middleware pointed at your local checkouts if you
* want to test in-progress branches).
* - APP_ENV=smoketest in .env (routes the apps to the *_test databases).
* - The Behat suite must have been run at least once against this stack
* (docker compose exec behat ./vendor/bin/behat --config config/behat.yml)
* so the "jane-a-ra" identity with a vetted demo-gssp second factor
* exists — that fixture setup lives in the Behat suite's @BeforeSuite
* hook, not duplicated here.
*
* Run: NODE_TLS_REJECT_UNAUTHORIZED=0 npx playwright test
*/

const SECOND_SP_ENTITY_ID = 'https://ssp.dev.openconext.local/simplesaml/module.php/saml/sp/metadata.php/second-sp';
const VETTED_SUBJECT = 'urn:collab:person:institution-a.example.com:jane-a-ra';

async function startSfoAuthentication(page: Page, mduiDisplayName?: string): Promise<void> {
await page.goto('/simplesaml/sp.php');
await page.locator('#idp').selectOption('OpenConext Stepup Gateway - gateway.dev.openconext.local - SFO');
await page.locator('#sp').selectOption('second-sp');
await page.locator('#loa').selectOption('2');
await page.locator('#subject').fill(VETTED_SUBJECT);
if (mduiDisplayName) {
await page.locator('#mdui_displayname').fill(mduiDisplayName);
} else {
await page.locator('#mdui_displayname').fill('');
}
await page.getByRole('button', { name: 'Login' }).first().click();
await expect(page).toHaveURL('https://demogssp.dev.openconext.local/authentication');
}

test.describe('Service name during authentication', () => {
test.afterEach(async () => {
// Leave the shared devconf environment as we found it.
await pushServiceName(SECOND_SP_ENTITY_ID, null);
});

test('shows the AuthnRequest mdui:DisplayName when Middleware has no service_name configured', async ({ page }) => {
await pushServiceName(SECOND_SP_ENTITY_ID, null);

await startSfoAuthentication(page, 'Behat Test Service');

await expect(page.getByText('Behat Test Service')).toBeVisible();
await page.screenshot({ path: 'screenshots/01-authnrequest-mdui-shown.png', fullPage: true });
});

test('Middleware service_name overrides the AuthnRequest mdui:DisplayName', async ({ page }) => {
await pushServiceName(SECOND_SP_ENTITY_ID, 'Middleware Configured Name');

await startSfoAuthentication(page, 'Behat Test Service');

await expect(page.getByText('Middleware Configured Name')).toBeVisible();
await expect(page.getByText('Behat Test Service')).not.toBeVisible();
await page.screenshot({ path: 'screenshots/02-middleware-overrides-authnrequest.png', fullPage: true });
});

test('renders without error when neither Middleware service_name nor mdui:DisplayName is present', async ({ page }) => {
await pushServiceName(SECOND_SP_ENTITY_ID, null);

await startSfoAuthentication(page);

await expect(page.getByText('Service name')).not.toBeVisible();
await page.screenshot({ path: 'screenshots/03-no-service-name-no-error.png', fullPage: true });
});
});
Loading