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
104 changes: 104 additions & 0 deletions enterprise/e2e/auth-sso/playwright/login.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test';

// The keycloak policy governs only /private and /archive, while /public and the
// root listing are open: SSO covering a subpath, not the whole instance.

async function signIn(page) {
await page.locator('a[data-sourcemeta-ui-login="keycloak"]').click();
await page.locator('#username').fill('jane');
await page.locator('#password').fill('jane-password');
await page.locator('#kc-login').click();
}

test.describe('Interactive SSO login on a subpath', () => {
test('open areas are browsable without any login', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Schemas/);
// The root listing is public and never fronted by a login
await expect(page.locator('a[data-sourcemeta-ui-login]')).toHaveCount(0);

// A public collection opens directly
await page.locator('table a', { hasText: 'public' }).first().click();
await expect(page).toHaveURL(/\/public\/$/);
await expect(page.locator('a[data-sourcemeta-ui-login]')).toHaveCount(0);
});

test('a gated collection shows the login card in place', async ({ page }) => {
const response = await page.goto('/private/');
expect(response.status()).toBe(401);
await expect(page).toHaveTitle('Sign In');

// A real, styled card rather than the plain denial page
await expect(page.locator('.card')).toBeVisible();
await expect(page.locator('h1')).toHaveText('SSO Sandbox');

const provider = page.locator('a[data-sourcemeta-ui-login="keycloak"]');
await expect(provider).toBeVisible();
await expect(provider).toHaveText('keycloak');
await expect(provider).toHaveAttribute(
'href',
'/self/v1/auth/login/keycloak'
);
});

test('a path that resolves to nothing shows the identical login', async ({
page
}) => {
await page.goto('/private/does-not-exist/deeper');
await expect(page).toHaveTitle('Sign In');
await expect(
page.locator('a[data-sourcemeta-ui-login="keycloak"]')
).toBeVisible();
});

test('navigating from an open page into a gated folder and authenticating', async ({
page
}) => {
await page.goto('/');
// Click into the gated collection from the open root listing
await page.locator('table a', { hasText: 'private' }).first().click();

// The login is shown in place, at the gated URL
await expect(page).toHaveURL(/\/private\//);
await expect(page).toHaveTitle('Sign In');

await signIn(page);

// Back in the gated collection, now authenticated: the listing renders and
// the login card is gone
await expect(page).toHaveURL(/\/private\b/);
await expect(page.locator('a[data-sourcemeta-ui-login]')).toHaveCount(0);
await expect(page.locator('table tbody tr').first()).toBeVisible();
});

test('authenticating from a deep gated page returns to that exact path', async ({
page
}) => {
// A specific schema deep under the policy, not the collection root
await page.goto('/private/secret');
await expect(page).toHaveTitle('Sign In');

await signIn(page);

// The login page hands the endpoint the denied path through a same-origin
// referrer, so authentication lands back on the exact schema, not the
// governing folder
await expect(page).toHaveURL(/\/private\/secret$/);
await expect(page.locator('a[data-sourcemeta-ui-login]')).toHaveCount(0);
});

test('logging out re-gates the collection', async ({ page }) => {
await page.goto('/private/');
await signIn(page);
await expect(page.locator('table tbody tr').first()).toBeVisible();

// Logging out clears the session, so the gated collection is fronted by the
// login again
await page.goto('/self/v1/auth/logout');
await page.goto('/private/');
await expect(page).toHaveTitle('Sign In');
await expect(
page.locator('a[data-sourcemeta-ui-login="keycloak"]')
).toBeVisible();
});
});
33 changes: 33 additions & 0 deletions enterprise/e2e/auth-sso/playwright/playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';

// See https://playwright.dev/docs/test-configuration
export default defineConfig({
testDir: '.',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'list',
outputDir: '../../../../build/test-results',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL,
trace: 'on-first-retry'
},
// Chromium only: the OIDC redirect chain relies on a Chromium-specific
// host resolver rule, so the suite never runs under Firefox or WebKit
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Keycloak advertises itself as `keycloak:8080` (its KC_HOSTNAME), so
// the browser must resolve that container name to the mapped local
// port to follow the OIDC redirect, exactly as a developer would via
// /etc/hosts
launchOptions: {
args: ['--host-resolver-rules=MAP keycloak 127.0.0.1']
}
}
}
]
});
3 changes: 3 additions & 0 deletions src/router/router.cc
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ auto RouterAction::serve_login(sourcemeta::one::HTTPRequest &request,
return false;
}

// The same headers every HTML response carries. The provider link opts into
// a same-origin referrer on its own, so the page as a whole keeps the strict
// default and leaks no referrer from any other request
static constexpr BrowserSecurityHeaders SECURITY{
.referrer_policy = "strict-origin-when-cross-origin",
.frame_ancestors = "'none'",
Expand Down
6 changes: 5 additions & 1 deletion src/web/pages/login.cc
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,18 @@ auto write_providers(sourcemeta::core::HTMLWriter &body,
}

// The link carries no return target on purpose. The login endpoint decides
// where to land the caller, so nothing here depends on the requested path
// where to land the caller, so nothing here depends on the requested path.
// The whole page stays at the site-wide no-referrer default, so only this
// navigation opts in to a same-origin referrer, handing the endpoint the
// denied path while every other request from the page still leaks nothing
std::string href{configuration.base_path};
href += "/self/v1/auth/login/";
href += policy.at("name").to_string();
body.a()
.attribute("class", "btn btn-primary d-flex align-items-center "
"justify-content-center")
.attribute("data-sourcemeta-ui-login", policy.at("name").to_string())
.attribute("referrerpolicy", "same-origin")
.attribute("href", href);
body.i().attribute("class", "bi bi-box-arrow-in-right me-2").close();
body.text(policy.at("title").to_string());
Expand Down
Loading