From 88f197ba4d3b77836411429e7f441adf7d8ffe7b Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 13 Aug 2026 14:28:35 -0300 Subject: [PATCH 1/2] Render login/logout buttons on the nav bar Signed-off-by: Juan Cruz Viotti --- .github/workflows/ci.yml | 6 ++ Makefile | 12 ++- docs/configuration.md | 14 ++- .../e2e/auth-closed/hurl/denial.all.hurl | 12 ++- enterprise/e2e/auth-keys/Dockerfile | 4 + enterprise/e2e/auth-keys/Makefile | 1 + enterprise/e2e/auth-keys/compose.yml | 10 ++ enterprise/e2e/auth-keys/environment | 1 + enterprise/e2e/auth-keys/hurl/chrome.all.hurl | 93 ++++++++++++++++++ enterprise/e2e/auth-keys/one.json | 26 +++++ .../e2e/auth-keys/schemas/open/string.json | 6 ++ .../e2e/auth-keys/schemas/vault/secret.json | 13 +++ enterprise/e2e/auth-sso/hurl/login.all.hurl | 40 ++++++++ .../e2e/auth-sso/playwright/login.spec.js | 44 ++++++--- .../e2e/auth-sso/playwright/renewal.spec.js | 13 +-- enterprise/e2e/auth/hurl/chrome.all.hurl | 85 +++++++++++++++++ enterprise/e2e/auth/hurl/sso.all.hurl | 24 +++++ enterprise/e2e/public/hurl/chrome.all.hurl | 63 +++++++++++++ src/build/delta.cc | 42 +++++---- .../include/sourcemeta/one/build_state.h | 6 +- src/web/CMakeLists.txt | 1 + src/web/page.h | 94 ++++++++++++++++--- src/web/pages/directory.cc | 4 +- src/web/pages/index.cc | 4 +- src/web/pages/login.cc | 9 +- src/web/pages/not_found.cc | 2 +- src/web/pages/schema.cc | 2 +- src/web/pages/unauthorized.cc | 2 +- test/e2e/html/hurl/chrome.all.hurl | 80 ++++++++++++++++ test/e2e/html/hurl/chrome.community.hurl | 68 ++++++++++++++ 30 files changed, 702 insertions(+), 79 deletions(-) create mode 100644 enterprise/e2e/auth-keys/Dockerfile create mode 100644 enterprise/e2e/auth-keys/Makefile create mode 100644 enterprise/e2e/auth-keys/compose.yml create mode 100644 enterprise/e2e/auth-keys/environment create mode 100644 enterprise/e2e/auth-keys/hurl/chrome.all.hurl create mode 100644 enterprise/e2e/auth-keys/one.json create mode 100644 enterprise/e2e/auth-keys/schemas/open/string.json create mode 100644 enterprise/e2e/auth-keys/schemas/vault/secret.json create mode 100644 enterprise/e2e/auth/hurl/chrome.all.hurl create mode 100644 enterprise/e2e/public/hurl/chrome.all.hurl create mode 100644 test/e2e/html/hurl/chrome.all.hurl create mode 100644 test/e2e/html/hurl/chrome.community.hurl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d21d28675..33e764939 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,12 @@ jobs: path: enterprise/e2e/public edition: ${{ matrix.edition.name }} if: matrix.edition.name == 'enterprise' + - name: E2E (enterprise/auth-keys) + uses: ./.github/actions/e2e + with: + path: enterprise/e2e/auth-keys + edition: ${{ matrix.edition.name }} + if: matrix.edition.name == 'enterprise' - name: E2E (enterprise/auth) uses: ./.github/actions/e2e with: diff --git a/Makefile b/Makefile index 2994cdf9b..768c407a5 100644 --- a/Makefile +++ b/Makefile @@ -83,8 +83,11 @@ ifeq ($(ENTERPRISE),ON) ./contrib/e2e-native.sh enterprise/e2e/empty $(EDITION) $(SANDBOX_PORT) ./contrib/e2e-native.sh enterprise/e2e/html $(EDITION) $(SANDBOX_PORT) ./contrib/e2e-native.sh enterprise/e2e/public $(EDITION) $(SANDBOX_PORT) - # The authentication sandboxes stand up an identity provider alongside the - # registry, so they only run under Docker Compose, never the native path + # Machine credentials need nobody to sign in, so this authentication + # sandbox is the one that runs without an identity provider beside it + ./contrib/e2e-native.sh enterprise/e2e/auth-keys $(EDITION) $(SANDBOX_PORT) + # The rest stand up an identity provider alongside the registry, so they + # only run under Docker Compose, never the native path endif .PHONY: docker @@ -99,8 +102,9 @@ ifeq ($(ENTERPRISE),ON) $(MAKE) -C enterprise/e2e/empty EDITION=$(EDITION) $(MAKE) -C enterprise/e2e/html EDITION=$(EDITION) $(MAKE) -C enterprise/e2e/public EDITION=$(EDITION) - # The authentication sandboxes each stand up an identity provider alongside - # the registry, exercising both JWT and apiKey policies + $(MAKE) -C enterprise/e2e/auth-keys EDITION=$(EDITION) + # The rest each stand up an identity provider alongside the registry, + # exercising both JWT and apiKey policies $(MAKE) -C enterprise/e2e/auth EDITION=$(EDITION) $(MAKE) -C enterprise/e2e/auth-closed EDITION=$(EDITION) $(MAKE) -C enterprise/e2e/auth-sso EDITION=$(EDITION) diff --git a/docs/configuration.md b/docs/configuration.md index a165b3ffe..da8e6e898 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -583,9 +583,17 @@ An `oidc` policy grants access to a user who signs in through an OpenID Connect provider in the browser. Where an `apiKey` or `jwt` policy admits a machine that presents a credential on every request, an `oidc` policy authenticates a user once at their provider and then relies on a session the instance establishes and -signs itself. Until that session exists, a browser that navigates to a governed -page is sent to begin a login, while a request for the raw schema, or from a -machine, is denied like any other unauthenticated request. +signs itself. Until that session exists, a governed page is not there for the +browser at all, exactly as a page that never existed is not. + +Signing in is therefore somewhere a person goes rather than something a page +they were refused hands them. The web explorer's bar carries a sign-in control +on every page an anonymous reader is served, pointing at one login page for the +whole instance that names every `oidc` policy declared. Once a session exists, +the bar offers signing out in its place. Neither control appears where it would +lead nowhere: an instance declaring no `oidc` policy has no login page and no +sign-in control however much of it is gated, and a page served to a machine +credential offers no way out, since there is no session to end. The instance registers with the provider as a client, identified by its `clientId` and the client secret shared with it. It trusts the `issuer` both as diff --git a/enterprise/e2e/auth-closed/hurl/denial.all.hurl b/enterprise/e2e/auth-closed/hurl/denial.all.hurl index 01b2b6e7a..3573d53fb 100644 --- a/enterprise/e2e/auth-closed/hurl/denial.all.hurl +++ b/enterprise/e2e/auth-closed/hurl/denial.all.hurl @@ -126,9 +126,11 @@ Access-Control-Allow-Origin: * } # A browser navigating anywhere in a registry gated whole is told there is -# nothing there, the root included. Where to sign in is not something this -# page offers, since it is somewhere a caller goes rather than something a -# dead end hands them +# nothing there, the root included. The page names no provider, since choosing +# one is what the login page is for, but the bar carries the way to it. On an +# instance where nothing at all is open that link is the only thing a visitor +# can act on, and it is what makes this answer somewhere to start rather than +# a dead end GET {{base}}/ Accept: text/html HTTP 404 @@ -142,6 +144,10 @@ header "Set-Cookie" not exists header "WWW-Authenticate" not exists xpath "string(//title)" == "Not Found" xpath "count(//a[@data-sourcemeta-ui-login])" == 0 +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 1 +xpath "string(//a[@data-sourcemeta-ui-signin]/@href)" == "/self/v1/auth/login" +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 # A nested directory is answered by the byte-identical page, so the whole gated # tree reads the same from one path to the next diff --git a/enterprise/e2e/auth-keys/Dockerfile b/enterprise/e2e/auth-keys/Dockerfile new file mode 100644 index 000000000..966dce62d --- /dev/null +++ b/enterprise/e2e/auth-keys/Dockerfile @@ -0,0 +1,4 @@ +FROM one +COPY one.json . +COPY schemas schemas +RUN sourcemeta one.json --profile diff --git a/enterprise/e2e/auth-keys/Makefile b/enterprise/e2e/auth-keys/Makefile new file mode 100644 index 000000000..bfe25ace0 --- /dev/null +++ b/enterprise/e2e/auth-keys/Makefile @@ -0,0 +1 @@ +include ../../../test/e2e/common.mk diff --git a/enterprise/e2e/auth-keys/compose.yml b/enterprise/e2e/auth-keys/compose.yml new file mode 100644 index 000000000..7bc7c7b42 --- /dev/null +++ b/enterprise/e2e/auth-keys/compose.yml @@ -0,0 +1,10 @@ +services: + sandbox: + build: + context: . + dockerfile: Dockerfile + environment: + - SOURCEMETA_ONE_PORT=8000 + - ONE_E2E_VAULT_KEY=vault-secret-key + ports: + - "${PORT}:8000" diff --git a/enterprise/e2e/auth-keys/environment b/enterprise/e2e/auth-keys/environment new file mode 100644 index 000000000..49386d0ed --- /dev/null +++ b/enterprise/e2e/auth-keys/environment @@ -0,0 +1 @@ +ONE_E2E_VAULT_KEY=vault-secret-key diff --git a/enterprise/e2e/auth-keys/hurl/chrome.all.hurl b/enterprise/e2e/auth-keys/hurl/chrome.all.hurl new file mode 100644 index 000000000..b51cddc62 --- /dev/null +++ b/enterprise/e2e/auth-keys/hurl/chrome.all.hurl @@ -0,0 +1,93 @@ +# A registry gated by machine credentials alone. Something here is private, so +# this is not an open instance, and yet nobody signs in: an apiKey policy +# admits a program holding a key rather than a person at a provider. +# +# That is what this sandbox is for. The bar offers a way in only where there is +# somewhere to go, and here there is not, so an instance with policies looks +# exactly like an instance with none. Offering one would link to a page that +# holds nothing, and it would invite a person to do something no policy here +# lets them do. + +# The anonymous view, which is what a visitor is served +GET {{base}}/ +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 +xpath "count(//form[@action='/self/v1/auth/logout'])" == 0 + +# The open collection, reachable by anybody +GET {{base}}/open/ +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# The page for the gated collection, which this view does not hold. Nothing +# here hints that a key would open it, the bar included +GET {{base}}/vault/ +Accept: text/html +HTTP 404 +Cache-Control: no-store +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "string(/html/head/title)" == "Not Found" +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# And the view the key opens, which is a view with no session behind it, so it +# is offered no way out any more than the anonymous one is offered a way in +GET {{base}}/vault/ +Accept: text/html +Authorization: Bearer vault-secret-key +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +GET {{base}}/vault/secret +Accept: text/html +Authorization: Bearer vault-secret-key +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# There is no login page either, since no policy signs anybody in. The bar +# offering nothing and this holding nothing are the same fact +GET {{base}}/self/v1/auth/login +Accept: text/html +HTTP 404 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +[Captures] +missing_body: body +missing_schema: header "Link" regex "<([^>]+)>" +{ + "type": "urn:sourcemeta:one:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{missing_schema}} +``` +{{missing_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true diff --git a/enterprise/e2e/auth-keys/one.json b/enterprise/e2e/auth-keys/one.json new file mode 100644 index 000000000..f25e2de5e --- /dev/null +++ b/enterprise/e2e/auth-keys/one.json @@ -0,0 +1,26 @@ +{ + "url": "http://localhost:8000", + "html": { + "name": "Keys Sandbox", + "description": "A registry gated by machine credentials alone, with nobody to sign in interactively" + }, + "authentication": [ + { + "type": "apiKey", + "algorithm": "identity", + "name": "vault", + "paths": [ "/vault" ], + "keys": [ { "environmentVariable": "ONE_E2E_VAULT_KEY" } ] + } + ], + "contents": { + "open": { + "baseUri": "https://example.com/open/", + "path": "./schemas/open" + }, + "vault": { + "baseUri": "https://example.com/vault/", + "path": "./schemas/vault" + } + } +} diff --git a/enterprise/e2e/auth-keys/schemas/open/string.json b/enterprise/e2e/auth-keys/schemas/open/string.json new file mode 100644 index 000000000..5d1a0f77d --- /dev/null +++ b/enterprise/e2e/auth-keys/schemas/open/string.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/open/string", + "title": "An open string", + "type": "string" +} diff --git a/enterprise/e2e/auth-keys/schemas/vault/secret.json b/enterprise/e2e/auth-keys/schemas/vault/secret.json new file mode 100644 index 000000000..f124d374c --- /dev/null +++ b/enterprise/e2e/auth-keys/schemas/vault/secret.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/vault/secret", + "title": "A gated object", + "type": "object", + "required": [ "token" ], + "properties": { + "token": { + "$ref": "../open/string" + } + }, + "additionalProperties": false +} diff --git a/enterprise/e2e/auth-sso/hurl/login.all.hurl b/enterprise/e2e/auth-sso/hurl/login.all.hurl index 29f87ee09..57d2209d9 100644 --- a/enterprise/e2e/auth-sso/hurl/login.all.hurl +++ b/enterprise/e2e/auth-sso/hurl/login.all.hurl @@ -15,6 +15,26 @@ header "ETag" exists "type": "string" } +# An anonymous reader is offered the way in, on an open page and on the page +# for something this view does not hold alike +GET {{base}}/public/ +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//a[@data-sourcemeta-ui-signin])" == 1 +xpath "string(//a[@data-sourcemeta-ui-signin]/@href)" == "/self/v1/auth/login" +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +GET {{base}}/private/ +Accept: text/html +HTTP 404 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "string(/html/head/title)" == "Not Found" +xpath "count(//a[@data-sourcemeta-ui-signin])" == 1 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + # The instance holds one login page, at a URL of its own, public and identical # for everybody. It names every interactive policy declared, whichever path # each one governs, since somebody signing in has not reached a path yet @@ -187,6 +207,26 @@ cookie "sourcemeta_one_session[HttpOnly]" exists cookie "sourcemeta_one_session[SameSite]" == "Lax" cookie "sourcemeta_one_transaction[Max-Age]" == 0 +# The bar in the view the session resolves to offers the way out. Every page +# written for that view carries it, the listing and a schema page alike +GET {{base}}/private/ +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 1 +xpath "string(//form[button/@data-sourcemeta-ui-signout]/@action)" == "/self/v1/auth/logout" + +GET {{base}}/private/secret +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 1 + # The session now opens the private catalog that was denied before, served # private so a shared cache cannot retain it GET {{base}}/private/secret.json diff --git a/enterprise/e2e/auth-sso/playwright/login.spec.js b/enterprise/e2e/auth-sso/playwright/login.spec.js index 0999cee7a..88383f466 100644 --- a/enterprise/e2e/auth-sso/playwright/login.spec.js +++ b/enterprise/e2e/auth-sso/playwright/login.spec.js @@ -121,28 +121,46 @@ test.describe('Interactive SSO login on a subpath', () => { ).toBeVisible(); }); - test('logging out puts the collection back out of reach', async ({ + test('the bar offers the way in, and it goes to the login page', async ({ + page + }) => { + await page.goto('/'); + const control = page.locator('a[data-sourcemeta-ui-signin]'); + await expect(control).toBeVisible(); + await expect(control).toHaveText('Sign In'); + + await control.click(); + await expect(page).toHaveURL(/\/self\/v1\/auth\/login$/); + await expect(page).toHaveTitle('Sign In'); + await expect( + page.locator('a[data-sourcemeta-ui-login="keycloak"]') + ).toBeVisible(); + }); + + test('the bar offers the way out once signed in, and it works', async ({ page }) => { await signIn(page); await expect(page.locator('table tbody tr').first()).toBeVisible(); - // Signing out is a form submit rather than a navigation, since it ends a - // session at the provider. This is the shape the sign-out control will - // take once the explorer renders one. - // TODO: Replace this with clicking the sign-out control once the explorer - // renders one, which is a form submit for the same reason - await page.evaluate(() => { - const form = document.createElement('form'); - form.method = 'post'; - form.action = '/self/v1/auth/logout'; - document.body.appendChild(form); - form.submit(); - }); + // The way in is gone and the way out is there, which is the whole of what + // the bar has to say about a session + await expect(page.locator('a[data-sourcemeta-ui-signin]')).toHaveCount(0); + const control = page.locator('button[data-sourcemeta-ui-signout]'); + await expect(control).toBeVisible(); + await expect(control).toHaveText('Sign Out'); + + // Submitting it ends the session at the provider too, which is why it is + // a form rather than a link + await control.click(); await page.waitForURL((url) => !url.pathname.startsWith('/private')); const response = await page.goto('/private/'); expect(response.status()).toBe(404); await expect(page).toHaveTitle('Not Found'); + await expect(page.locator('button[data-sourcemeta-ui-signout]')).toHaveCount( + 0 + ); + await expect(page.locator('a[data-sourcemeta-ui-signin]')).toHaveCount(1); }); }); diff --git a/enterprise/e2e/auth-sso/playwright/renewal.spec.js b/enterprise/e2e/auth-sso/playwright/renewal.spec.js index 690e28719..d634418db 100644 --- a/enterprise/e2e/auth-sso/playwright/renewal.spec.js +++ b/enterprise/e2e/auth-sso/playwright/renewal.spec.js @@ -142,16 +142,9 @@ test.describe('Silent session renewal', () => { await expect(page.locator('table tbody tr').first()).toBeVisible(); expect(await cookieNamed(context, MARKER)).toBeDefined(); - // Signing out is a form submit rather than a navigation, since it ends a - // session at the provider. This is the shape the sign-out control will - // take once the explorer renders one. - await page.evaluate(() => { - const form = document.createElement('form'); - form.method = 'POST'; - form.action = '/self/v1/auth/logout'; - document.body.appendChild(form); - form.submit(); - }); + // Signing out is what the bar offers a caller holding a session, and it + // submits rather than navigates, since it ends a session at the provider + await page.locator('button[data-sourcemeta-ui-signout]').click(); await page.waitForURL((url) => !url.pathname.startsWith('/private')); // The marker goes with the session. Somebody who has signed out is asking diff --git a/enterprise/e2e/auth/hurl/chrome.all.hurl b/enterprise/e2e/auth/hurl/chrome.all.hurl new file mode 100644 index 000000000..10a5b57ee --- /dev/null +++ b/enterprise/e2e/auth/hurl/chrome.all.hurl @@ -0,0 +1,85 @@ +# The bar is written per view, so what it offers follows from who the page was +# written for rather than from anything decided while serving it. This instance +# gates one collection interactively and the rest with machine credentials, so +# it exercises all three answers: a way in, a way out, and neither. + +# The anonymous view offers the way in, since somebody reading it has no +# session and this instance has somewhere to sign in to +GET {{base}}/ +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 1 +xpath "string(//a[@data-sourcemeta-ui-signin]/@href)" == "/self/v1/auth/login" +xpath "normalize-space(//a[@data-sourcemeta-ui-signin])" == "Sign In" +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# A nested page in the same view says the same, since the bar is the page's +# rather than the path's +GET {{base}}/public/ +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//a[@data-sourcemeta-ui-signin])" == 1 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# So does a schema page +GET {{base}}/public/string +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//a[@data-sourcemeta-ui-signin])" == 1 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# And so does the page for a path this view does not hold, which is the one +# that matters most: a visitor who followed a link to something gated is told +# there is nothing there, and the way in is still on the page +GET {{base}}/private/ +Accept: text/html +HTTP 404 +Cache-Control: no-store +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "string(/html/head/title)" == "Not Found" +xpath "count(//a[@data-sourcemeta-ui-signin])" == 1 +xpath "string(//a[@data-sourcemeta-ui-signin]/@href)" == "/self/v1/auth/login" +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# A view a machine credential reaches has no session behind it, so it is +# offered neither a way in nor a way out. Signing out of a key is not a thing, +# and the way in would be no use to whoever presented one +GET {{base}}/private/ +Accept: text/html +Authorization: Bearer primary-secret-key +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# The same for a schema page under that key +GET {{base}}/private/secret +Accept: text/html +Authorization: Bearer primary-secret-key +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# The login page carries no bar at all. A caller reading it is already where +# the bar would have sent them, and it names its providers instead +GET {{base}}/self/v1/auth/login +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav)" == 0 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 +xpath "count(//a[@data-sourcemeta-ui-login])" == 1 diff --git a/enterprise/e2e/auth/hurl/sso.all.hurl b/enterprise/e2e/auth/hurl/sso.all.hurl index 1e9d16e88..e3a18fb2f 100644 --- a/enterprise/e2e/auth/hurl/sso.all.hurl +++ b/enterprise/e2e/auth/hurl/sso.all.hurl @@ -138,6 +138,20 @@ cookie "sourcemeta_one_session[HttpOnly]" exists cookie "sourcemeta_one_session[SameSite]" == "Lax" cookie "sourcemeta_one_transaction[Max-Age]" == 0 +# The bar the session is shown offers the way out rather than the way in, since +# the view it was written for is one somebody signs into +GET {{base}}/console/ +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 1 +xpath "normalize-space(//button[@data-sourcemeta-ui-signout])" == "Sign Out" +xpath "string(//form[button/@data-sourcemeta-ui-signout]/@method)" == "post" +xpath "string(//form[button/@data-sourcemeta-ui-signout]/@action)" == "/self/v1/auth/logout" + # The human session now opens the console without any machine key GET {{base}}/console/dashboard.json HTTP 200 @@ -205,6 +219,16 @@ Link: ; rel="describedby" [Asserts] jsonpath "$.valid" == true +# And with the session gone the bar offers the way back in, which is the same +# bar any other anonymous caller is shown +GET {{base}}/ +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//a[@data-sourcemeta-ui-signin])" == 1 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + # But the machine key still opens the very same console the human just left GET {{base}}/console/dashboard.json Authorization: Bearer console-secret-key diff --git a/enterprise/e2e/public/hurl/chrome.all.hurl b/enterprise/e2e/public/hurl/chrome.all.hurl new file mode 100644 index 000000000..572035ae4 --- /dev/null +++ b/enterprise/e2e/public/hurl/chrome.all.hurl @@ -0,0 +1,63 @@ +# A registry nothing gates grows no session control at all. There is nowhere to +# sign in to and nothing to sign out of, so the bar carries only what an open +# instance has always carried. + +GET {{base}} +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 +xpath "count(//form[@action='/self/v1/auth/logout'])" == 0 + +# Every page carries the same bar, so a nested one says the same +GET {{base}}/jsonschema/2020-12 +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# Including the page for a path that is not there +GET {{base}}/nothing-is-here +Accept: text/html +HTTP 404 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "string(/html/head/title)" == "Not Found" +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# And there is no login page to reach either, since nothing signs anybody in +GET {{base}}/self/v1/auth/login +Accept: text/html +HTTP 404 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +[Captures] +missing_body: body +missing_schema: header "Link" regex "<([^>]+)>" +{ + "type": "urn:sourcemeta:one:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{missing_schema}} +``` +{{missing_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true diff --git a/src/build/delta.cc b/src/build/delta.cc index 67f011ba2..638f7de53 100644 --- a/src/build/delta.cc +++ b/src/build/delta.cc @@ -183,6 +183,7 @@ struct Target { BuildPlan::Action::Type action; std::vector dependencies; std::string_view data; + std::string_view view; }; using TargetMap = std::unordered_map; @@ -190,20 +191,24 @@ using TargetMap = std::unordered_map; static auto declare_target(TargetMap &targets, BuildPlan::Action::Type action, std::string destination, std::vector dependencies, - const std::string_view data = {}) -> void { + const std::string_view data = {}, + const std::string_view view = {}) -> void { auto iterator{targets.try_emplace(std::move(destination)).first}; - iterator->second = Target{ - .action = action, .dependencies = std::move(dependencies), .data = data}; + iterator->second = Target{.action = action, + .dependencies = std::move(dependencies), + .data = data, + .view = view}; } static auto declare_target_direct(TargetMap &targets, BuildPlan::Action::Type action, - std::string destination, const std::string_view data = {}) - -> Target & { + std::string destination, const std::string_view data = {}, + const std::string_view view = {}) -> Target & { auto iterator{targets.try_emplace(std::move(destination)).first}; iterator->second.action = action; iterator->second.dependencies.clear(); iterator->second.data = data; + iterator->second.view = view; return iterator->second; } @@ -213,8 +218,8 @@ static auto declare_leaf_targets( const bool evaluate, const BuildPlan::Type build_type, const BuildPlan::Type full_mode, const std::string &configuration_string, const std::string_view uri, const BuildPhase phase, - std::span leaf_rules, const bool only_secondary, - const bool only_primary = false) -> void { + std::span leaf_rules, const std::string_view view, + const bool only_secondary, const bool only_primary = false) -> void { for (std::size_t index{0}; index < leaf_rules.size(); index++) { const auto &rule{leaf_rules[index]}; @@ -244,7 +249,8 @@ static auto declare_leaf_targets( const auto &base{bases[rule.base]}; auto &target{declare_target_direct( - targets, rule.action, append_filename(base, rule.filename), uri)}; + targets, rule.action, append_filename(base, rule.filename), uri, + rule.base == 0 ? std::string_view{} : view)}; target.dependencies.reserve(rule.dependency_count); for (std::uint8_t dependency_index{0}; dependency_index < rule.dependency_count; dependency_index++) { @@ -623,7 +629,8 @@ auto delta_engine(const BuildPhase phase, const BuildPlan::Type build_type, .destination = std::move(destination), .dependencies = std::move(action_dependencies), .data = uri, - .view = static_cast(in_secondary ? view : 0)}); + .view = + in_secondary ? secondary_views[view] : std::string_view{}}); } } @@ -922,16 +929,17 @@ auto delta_engine(const BuildPhase phase, const BuildPlan::Type build_type, if (needs_targets) { bool declared_primary{false}; - for (const auto &secondary_base : secondary_bases) { - if (secondary_base.empty()) { + for (std::size_t view{0}; view < secondary_bases.size(); view++) { + if (secondary_bases[view].empty()) { continue; } - const std::array bases{{primary_base, secondary_base}}; + const std::array bases{ + {primary_base, secondary_bases[view]}}; declare_leaf_targets(targets, bases, output_string, info.path->native(), info.evaluate, build_type, full_mode, configuration_string, uri, phase, leaf_rules, - declared_primary); + secondary_views[view], declared_primary); declared_primary = true; } @@ -941,7 +949,7 @@ auto delta_engine(const BuildPhase phase, const BuildPlan::Type build_type, const std::array bases{{primary_base, std::string{}}}; declare_leaf_targets(targets, bases, output_string, info.path->native(), info.evaluate, build_type, full_mode, - configuration_string, uri, phase, leaf_rules, + configuration_string, uri, phase, leaf_rules, {}, false, true); } } @@ -1472,7 +1480,8 @@ auto delta_engine(const BuildPhase phase, const BuildPlan::Type build_type, } declare_target(targets, rule.action, destination, - std::move(rule_dependencies)); + std::move(rule_dependencies), {}, + secondary_views[view]); dirty_set.insert(std::move(destination)); } } @@ -1516,7 +1525,8 @@ auto delta_engine(const BuildPhase phase, const BuildPlan::Type build_type, {.type = target.action, .destination = std::filesystem::path{target_path}, .dependencies = std::move(action_dependencies), - .data = target.data}); + .data = target.data, + .view = target.view}); } } diff --git a/src/build/include/sourcemeta/one/build_state.h b/src/build/include/sourcemeta/one/build_state.h index 218d7bf3f..dcb58f9f8 100644 --- a/src/build/include/sourcemeta/one/build_state.h +++ b/src/build/include/sourcemeta/one/build_state.h @@ -33,10 +33,10 @@ struct BuildPlan { std::filesystem::path destination; Dependencies dependencies; std::string_view data; - // Which view this was built for, as a position in the list the build was - // given. Zero wherever the tree written into is not namespaced, so a + // Which view this was built for, named as the tree it lands in names it. + // Empty wherever what is written is one artifact whoever asks, so a // handler that does not care never has to ask - std::uint8_t view{0}; + std::string_view view{}; }; std::filesystem::path output; diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt index 5e743f7ba..d8121747f 100644 --- a/src/web/CMakeLists.txt +++ b/src/web/CMakeLists.txt @@ -12,6 +12,7 @@ target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::core::json) target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::core::html) target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::core::markdown) target_link_libraries(sourcemeta_one_web PUBLIC sourcemeta::one::configuration) +target_link_libraries(sourcemeta_one_web PUBLIC sourcemeta::one::authentication) target_link_libraries(sourcemeta_one_web PUBLIC sourcemeta::one::resolver) target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::one::shared) target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::one::metapack) diff --git a/src/web/page.h b/src/web/page.h index 633fcc361..91ebd8d3a 100644 --- a/src/web/page.h +++ b/src/web/page.h @@ -2,19 +2,84 @@ #define SOURCEMETA_ONE_WEB_PAGE_H_ #include +#include #include #include #include "checksum_css.h" #include "checksum_js.h" -#include // std::string -#include // std::forward +#include // std::ranges::any_of, std::ranges::find +#include // std::string +#include // std::string_view +#include // std::forward namespace sourcemeta::one::html { +// Whether a policy signs a person in rather than admitting a program, which is +// what decides everywhere a way in or out could be offered +[[nodiscard]] inline auto +is_interactive(const Configuration::AuthenticationEntry &policy) -> bool { + return policy.type == Configuration::AuthenticationEntry::Type::OIDC; +} + +// What the bar offers follows from the view it is written for, since a page is +// written once per view and read by whoever that view is for +inline auto make_session_control(sourcemeta::core::HTMLWriter &writer, + const Configuration &configuration, + const std::string_view view) -> void { + // The anonymous view is what somebody without a session is served, so it + // offers the way in, and only where there is somewhere to go. An instance + // nobody signs into interactively grows no control at all, which is also + // what an instance with no policies at all gets + if (view == VIEW_PUBLIC) { + if (!std::ranges::any_of(configuration.authentication, is_interactive)) { + return; + } + + writer.a() + .attribute("class", "ms-md-3 btn btn-outline-secondary mt-2 mt-md-0 " + "w-100 w-md-auto") + .attribute("role", "button") + .attribute("data-sourcemeta-ui-signin", "") + .attribute("href", "/self/v1/auth/login"); + writer.i().attribute("class", "me-2 bi bi-box-arrow-in-right").close(); + writer.text("Sign In"); + writer.close(); + return; + } + + // Every other view is named after what admits it, so a view somebody signed + // into is one a policy of that name signs people into. A view a program + // reaches has no session behind it and so nothing to end + const auto policy{ + std::ranges::find(configuration.authentication, view, + &Configuration::AuthenticationEntry::name)}; + if (policy == configuration.authentication.cend() || + !is_interactive(*policy)) { + return; + } + + // Signing out ends a session at the provider, which RFC 9110 Section 9.2.1 + // puts outside what a link may do, so the control is a form rather than an + // anchor + writer.form() + .attribute("class", "ms-md-3 mt-2 mt-md-0 w-100 w-md-auto") + .attribute("method", "post") + .attribute("action", "/self/v1/auth/logout"); + writer.button() + .attribute("class", "btn btn-outline-secondary w-100") + .attribute("type", "submit") + .attribute("data-sourcemeta-ui-signout", ""); + writer.i().attribute("class", "me-2 bi bi-box-arrow-right").close(); + writer.text("Sign Out"); + writer.close(); + writer.close(); +} + inline auto make_navigation(sourcemeta::core::HTMLWriter &writer, - const Configuration &configuration) -> void { + const Configuration &configuration, + const std::string_view view) -> void { writer.nav().attribute("class", "navbar navbar-expand border-bottom bg-body"); writer.div().attribute("class", @@ -70,6 +135,8 @@ inline auto make_navigation(sourcemeta::core::HTMLWriter &writer, writer.close(); } + make_session_control(writer, configuration, view); + writer.close(); writer.close(); } @@ -178,14 +245,14 @@ inline auto make_head(sourcemeta::core::HTMLWriter &writer, template inline auto make_page(sourcemeta::core::HTMLWriter &writer, const Configuration &configuration, - const std::string &canonical, const std::string &title, - const std::string &description, BodyWriter &&write_body) - -> void { + const std::string_view view, const std::string &canonical, + const std::string &title, const std::string &description, + BodyWriter &&write_body) -> void { writer.raw(""); writer.html().attribute("class", "h-100").attribute("lang", "en"); make_head(writer, configuration, canonical, title, description); writer.body().attribute("class", "h-100 d-flex flex-column"); - make_navigation(writer, configuration); + make_navigation(writer, configuration, view); std::forward(write_body)(writer); make_footer(writer); writer.script() @@ -201,13 +268,12 @@ inline auto make_page(sourcemeta::core::HTMLWriter &writer, writer.close(); } -inline auto make_error_page(sourcemeta::core::HTMLWriter &writer, - const Configuration &configuration, - const std::string &title, - const std::string &description, - const std::string &heading, const std::string &lead) - -> void { - make_page(writer, configuration, configuration.url, title, description, +inline auto +make_error_page(sourcemeta::core::HTMLWriter &writer, + const Configuration &configuration, const std::string_view view, + const std::string &title, const std::string &description, + const std::string &heading, const std::string &lead) -> void { + make_page(writer, configuration, view, configuration.url, title, description, [&](sourcemeta::core::HTMLWriter &body) -> void { body.div().attribute("class", "container-fluid p-4"); body.h2().attribute("class", "fw-bold"); diff --git a/src/web/pages/directory.cc b/src/web/pages/directory.cc index 5a3918121..dceaf39c6 100644 --- a/src/web/pages/directory.cc +++ b/src/web/pages/directory.cc @@ -32,8 +32,8 @@ auto GENERATE_WEB_DIRECTORY::handler( ? directory.at("description").to_string() : ("Schemas located at " + directory.at("path").to_string())}; sourcemeta::core::HTMLWriter writer; - html::make_page(writer, configuration, canonical, title, description, - [&](sourcemeta::core::HTMLWriter &w) -> void { + html::make_page(writer, configuration, action.view, canonical, title, + description, [&](sourcemeta::core::HTMLWriter &w) -> void { html::make_breadcrumb(w, directory.at("breadcrumb")); html::make_directory_header(w, directory); html::make_file_manager(w, directory); diff --git a/src/web/pages/index.cc b/src/web/pages/index.cc index e6a4ff5d1..a4060385e 100644 --- a/src/web/pages/index.cc +++ b/src/web/pages/index.cc @@ -45,8 +45,8 @@ auto GENERATE_WEB_INDEX::handler( const auto title{configuration.html->name + " Schemas"}; const auto &description{configuration.html->description}; sourcemeta::core::HTMLWriter writer; - html::make_page(writer, configuration, canonical, title, description, - [&](sourcemeta::core::HTMLWriter &w) -> void { + html::make_page(writer, configuration, action.view, canonical, title, + description, [&](sourcemeta::core::HTMLWriter &w) -> void { make_hero(w, configuration); html::make_file_manager(w, directory); }); diff --git a/src/web/pages/login.cc b/src/web/pages/login.cc index 6b687fb36..e85848dfc 100644 --- a/src/web/pages/login.cc +++ b/src/web/pages/login.cc @@ -15,10 +15,6 @@ namespace sourcemeta::one { namespace { -auto is_interactive(const Configuration::AuthenticationEntry &policy) -> bool { - return policy.type == Configuration::AuthenticationEntry::Type::OIDC; -} - auto write_providers( sourcemeta::core::HTMLWriter &body, const std::vector &policies) -> void { @@ -28,7 +24,7 @@ auto write_providers( body.div().attribute("class", "d-grid gap-2"); for (const auto &policy : policies) { - if (!is_interactive(policy)) { + if (!html::is_interactive(policy)) { continue; } @@ -65,7 +61,8 @@ auto GENERATE_WEB_LOGIN::handler( // it gets an empty artifact rather than a page. Its presence keeps the build // plan uniform, and the empty body is the signal to offer nothing when // serving - if (!std::ranges::any_of(configuration.authentication, is_interactive)) { + if (!std::ranges::any_of(configuration.authentication, + html::is_interactive)) { const auto timestamp_end{std::chrono::steady_clock::now()}; metapack_write_text(action.destination, "", "text/html; charset=utf-8", MetapackEncoding::GZIP, {}, diff --git a/src/web/pages/not_found.cc b/src/web/pages/not_found.cc index 7b6692d48..60b04d620 100644 --- a/src/web/pages/not_found.cc +++ b/src/web/pages/not_found.cc @@ -19,7 +19,7 @@ auto GENERATE_WEB_NOT_FOUND::handler( const auto timestamp_start{std::chrono::steady_clock::now()}; sourcemeta::core::HTMLWriter writer; - html::make_error_page(writer, configuration, "Not Found", + html::make_error_page(writer, configuration, action.view, "Not Found", "What you are looking for is not here", "Oops! What you are looking for is not here", "Are you sure the link you got is correct?"); diff --git a/src/web/pages/schema.cc b/src/web/pages/schema.cc index fa076ffc8..d44f6ed75 100644 --- a/src/web/pages/schema.cc +++ b/src/web/pages/schema.cc @@ -42,7 +42,7 @@ auto GENERATE_WEB_SCHEMA::handler( sourcemeta::core::HTMLWriter writer; html::make_page( - writer, configuration, canonical, title, description, + writer, configuration, action.view, canonical, title, description, [&](sourcemeta::core::HTMLWriter &w) -> void { html::make_breadcrumb(w, meta.at("breadcrumb")); diff --git a/src/web/pages/unauthorized.cc b/src/web/pages/unauthorized.cc index bc768dba2..53b6d556e 100644 --- a/src/web/pages/unauthorized.cc +++ b/src/web/pages/unauthorized.cc @@ -19,7 +19,7 @@ auto GENERATE_WEB_UNAUTHORIZED::handler( const auto timestamp_start{std::chrono::steady_clock::now()}; sourcemeta::core::HTMLWriter writer; - html::make_error_page(writer, configuration, "Unauthorized", + html::make_error_page(writer, configuration, action.view, "Unauthorized", "This page requires authentication", "This page requires authentication", "Present a valid credential to access it"); diff --git a/test/e2e/html/hurl/chrome.all.hurl b/test/e2e/html/hurl/chrome.all.hurl new file mode 100644 index 000000000..f8e1beff5 --- /dev/null +++ b/test/e2e/html/hurl/chrome.all.hurl @@ -0,0 +1,80 @@ +# Nothing in this instance is gated, so nothing in its bar is about being +# gated. The Community edition cannot declare a policy at all, which is +# refused when the configuration is read rather than here, so this is the +# whole of what its chrome ever looks like. An Enterprise instance declaring +# no policy is served the same bar, which is the contract the two editions +# hold to each other. + +GET {{base}} +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 +xpath "count(//form[@action='/self/v1/auth/logout'])" == 0 +xpath "count(//a[@data-sourcemeta-ui-login])" == 0 + +# A directory page +GET {{base}}/test/doc +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# A schema page +GET {{base}}/test/doc/string-1 +Accept: text/html +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# And the page for a path that is not there, which is the one place a bar +# offering a way in would be most tempting to put +GET {{base}}/nothing-is-here +Accept: text/html +HTTP 404 +Content-Type: text/html; charset=utf-8 +[Asserts] +xpath "string(/html/head/title)" == "Not Found" +xpath "count(//nav[contains(@class, 'navbar')])" == 1 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 0 + +# There is no login page to reach either. Nothing signs anybody in, so the +# endpoint holds nothing, exactly as it does on an Enterprise instance that +# declares no interactive policy +GET {{base}}/self/v1/auth/login +Accept: text/html +HTTP 404 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +[Captures] +missing_body: body +missing_schema: header "Link" regex "<([^>]+)>" +{ + "type": "urn:sourcemeta:one:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{missing_schema}} +``` +{{missing_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true diff --git a/test/e2e/html/hurl/chrome.community.hurl b/test/e2e/html/hurl/chrome.community.hurl new file mode 100644 index 000000000..7756838d5 --- /dev/null +++ b/test/e2e/html/hurl/chrome.community.hurl @@ -0,0 +1,68 @@ +# Why the bar can never grow a session control here: this edition signs nobody +# in. Declaring a policy is refused when the configuration is read, and every +# endpoint a control would have pointed at answers that it is not this +# edition's to offer. The bar has nothing to put in front of a caller because +# there is nothing behind it. + +# Ending a session, which is what a sign-out control submits to +POST {{base}}/self/v1/auth/logout +HTTP 403 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +[Captures] +logout_body: body +logout_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "Set-Cookie" not exists +{ + "type": "urn:sourcemeta:one:enterprise-required", + "title": "Forbidden", + "status": 403, + "detail": "This feature is only available in the Enterprise edition" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{logout_schema}} +``` +{{logout_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +# Starting one, which is what the login page would have linked to +GET {{base}}/self/v1/auth/login/anything +HTTP 403 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Asserts] +header "Set-Cookie" not exists +header "Location" not exists +{ + "type": "urn:sourcemeta:one:enterprise-required", + "title": "Forbidden", + "status": 403, + "detail": "This feature is only available in the Enterprise edition" +} + +# And completing one +GET {{base}}/self/v1/auth/callback/anything?code=a-code&state=a-state +HTTP 403 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Asserts] +header "Set-Cookie" not exists +{ + "type": "urn:sourcemeta:one:enterprise-required", + "title": "Forbidden", + "status": 403, + "detail": "This feature is only available in the Enterprise edition" +} From 821bafc0546fd3ecc3a124b327e81bb6d8756852 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 13 Aug 2026 15:07:13 -0300 Subject: [PATCH 2/2] Fix Signed-off-by: Juan Cruz Viotti --- enterprise/e2e/auth-keys/compose.yml | 3 ++- src/web/CMakeLists.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/enterprise/e2e/auth-keys/compose.yml b/enterprise/e2e/auth-keys/compose.yml index 7bc7c7b42..2d5f53451 100644 --- a/enterprise/e2e/auth-keys/compose.yml +++ b/enterprise/e2e/auth-keys/compose.yml @@ -5,6 +5,7 @@ services: dockerfile: Dockerfile environment: - SOURCEMETA_ONE_PORT=8000 - - ONE_E2E_VAULT_KEY=vault-secret-key + env_file: + - environment ports: - "${PORT}:8000" diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt index d8121747f..fbb7d91d3 100644 --- a/src/web/CMakeLists.txt +++ b/src/web/CMakeLists.txt @@ -12,7 +12,7 @@ target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::core::json) target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::core::html) target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::core::markdown) target_link_libraries(sourcemeta_one_web PUBLIC sourcemeta::one::configuration) -target_link_libraries(sourcemeta_one_web PUBLIC sourcemeta::one::authentication) +target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::one::authentication) target_link_libraries(sourcemeta_one_web PUBLIC sourcemeta::one::resolver) target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::one::shared) target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::one::metapack)