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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions enterprise/e2e/auth-closed/hurl/denial.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions enterprise/e2e/auth-keys/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FROM one
COPY one.json .
COPY schemas schemas
RUN sourcemeta one.json --profile
1 change: 1 addition & 0 deletions enterprise/e2e/auth-keys/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include ../../../test/e2e/common.mk
11 changes: 11 additions & 0 deletions enterprise/e2e/auth-keys/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
services:
sandbox:
build:
context: .
dockerfile: Dockerfile
environment:
- SOURCEMETA_ONE_PORT=8000
env_file:
- environment
ports:
- "${PORT}:8000"
1 change: 1 addition & 0 deletions enterprise/e2e/auth-keys/environment
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ONE_E2E_VAULT_KEY=vault-secret-key
93 changes: 93 additions & 0 deletions enterprise/e2e/auth-keys/hurl/chrome.all.hurl
Original file line number Diff line number Diff line change
@@ -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: </self/v1/schemas/api/error>; 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: </self/v1/schemas/api/schemas/evaluate/response>; rel="describedby"
[Asserts]
jsonpath "$.valid" == true
26 changes: 26 additions & 0 deletions enterprise/e2e/auth-keys/one.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
6 changes: 6 additions & 0 deletions enterprise/e2e/auth-keys/schemas/open/string.json
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 13 additions & 0 deletions enterprise/e2e/auth-keys/schemas/vault/secret.json
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 40 additions & 0 deletions enterprise/e2e/auth-sso/hurl/login.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 31 additions & 13 deletions enterprise/e2e/auth-sso/playwright/login.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
13 changes: 3 additions & 10 deletions enterprise/e2e/auth-sso/playwright/renewal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading