diff --git a/docs/configuration.md b/docs/configuration.md index 1a8c1cf34..b7ae40eb6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -540,7 +540,7 @@ follows is signed with a secret of the instance's own, unrelated to the provider | `/clientId` | String | :red_circle: **Yes** | N/A | The client identifier registered with the provider for this instance | | `/clientSecret` | Object | :red_circle: **Yes** | N/A | The client secret shared with the provider, read from an environment variable so that it never lives in the configuration file | | `/clientSecret/environmentVariable` | String | :red_circle: **Yes** | N/A | The name of the environment variable that holds the client secret | -| `/sessionSecrets` | Array | :red_circle: **Yes** | N/A | The secrets used to sign the session cookies this instance mints, newest first. These are the instance's own secrets, unrelated to the provider. A cookie is signed under the first and accepted under any, so adding a new secret first and dropping the old one once the sessions signed under it have expired rotates without signing anybody out | +| `/sessionSecrets` | Array | :red_circle: **Yes** | N/A | The secrets used to sign the session cookies this instance mints, newest first. These are the instance's own secrets, unrelated to the provider. A cookie is signed under the first and accepted under any, so adding a new secret first and dropping the old one once the sessions signed under it have expired rotates without signing anybody out. Unlike a `jwt` key set, these are read from the environment once at startup, so a change to them takes effect on restart | | `/sessionSecrets/*` | Object | :red_circle: **Yes** | N/A | A single session signing secret | | `/sessionSecrets/*/environmentVariable` | String | :red_circle: **Yes** | N/A | The name of the environment variable that holds the session signing secret. Generate it at random, with at least 32 characters, as with `openssl rand -base64 32`. Everything a session cookie carries but its signature travels in the open, so a secret that can be guessed is one that anybody holding a single cookie can find, after which they can mint sessions of their own | diff --git a/enterprise/authentication/authentication.cc b/enterprise/authentication/authentication.cc index 3bed5c74d..c50373e04 100644 --- a/enterprise/authentication/authentication.cc +++ b/enterprise/authentication/authentication.cc @@ -821,6 +821,43 @@ struct Authentication::Impl { decoded.default_path}; } + [[nodiscard]] auto interactive(const std::string_view path, + const std::string_view name) const + -> std::optional { + const auto mask{this->match(path)}; + if (mask == 0 || this->policy_count_ == 0 || name.empty()) { + return std::nullopt; + } + + const auto *policies{ + static_cast(this->policies_)}; + for (std::uint32_t index{0}; index < this->policy_count_; index += 1) { + if ((mask & (PolicySet{1} << index)) == 0) { + continue; + } + + const auto &entry{policies[index]}; + if (static_cast(entry.type) != + Authentication::Type::OIDC || + entry.metadata_length == 0) { + continue; + } + + const std::span metadata{ + this->view_->as(entry.metadata_offset), + entry.metadata_length}; + OIDCPolicyMetadata decoded; + if (decode_oidc_metadata(metadata, decoded) && decoded.name == name) { + return Authentication::InteractivePolicy{.issuer = decoded.issuer, + .client_id = decoded.client_id, + .default_path = + decoded.default_path}; + } + } + + return std::nullopt; + } + [[nodiscard]] auto client_secret(const std::string_view policy) const -> std::optional { OIDCPolicyMetadata decoded; @@ -1195,6 +1232,12 @@ auto Authentication::interactive(const std::string_view name) const return this->impl_->interactive(name); } +auto Authentication::interactive(const Authentication::Path &path, + const std::string_view name) const + -> std::optional { + return this->impl_->interactive(path.value(), name); +} + auto Authentication::client_secret(const std::string_view policy) const -> std::optional { return this->impl_->client_secret(policy); diff --git a/enterprise/e2e/auth-closed/hurl/sso.all.hurl b/enterprise/e2e/auth-closed/hurl/sso.all.hurl index 92d0b6f4e..f01f38d6e 100644 --- a/enterprise/e2e/auth-closed/hurl/sso.all.hurl +++ b/enterprise/e2e/auth-closed/hurl/sso.all.hurl @@ -118,9 +118,10 @@ POST {{base}}/self/v1/auth/logout HTTP 303 Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 cookie "sourcemeta_one_session[Max-Age]" == 0 cookie "sourcemeta_one_transaction[Max-Age]" == 0 +cookie "sourcemeta_one_renewal[Max-Age]" == 0 header "Location" startsWith "https://keycloak:8443/realms/main/protocol/openid-connect/logout?" # With the session gone the catalog is locked to the browser again, the denial diff --git a/enterprise/e2e/auth-path/hurl/sso.all.hurl b/enterprise/e2e/auth-path/hurl/sso.all.hurl index 727178f68..ce1029d73 100644 --- a/enterprise/e2e/auth-path/hurl/sso.all.hurl +++ b/enterprise/e2e/auth-path/hurl/sso.all.hurl @@ -156,11 +156,13 @@ POST {{base}}/registry/self/v1/auth/logout HTTP 303 Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 cookie "sourcemeta_one_session[Max-Age]" == 0 cookie "sourcemeta_one_session[Path]" == "/registry" cookie "sourcemeta_one_transaction[Max-Age]" == 0 cookie "sourcemeta_one_transaction[Path]" == "/registry" +cookie "sourcemeta_one_renewal[Max-Age]" == 0 +cookie "sourcemeta_one_renewal[Path]" == "/registry" header "Location" startsWith "https://keycloak:8443/realms/main/protocol/openid-connect/logout?" # The instance URL already carries the base path, so it is named once header "Location" contains "post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fregistry" diff --git a/enterprise/e2e/auth-sso/hurl/login.all.hurl b/enterprise/e2e/auth-sso/hurl/login.all.hurl index 3c7cb896f..1033ad8b4 100644 --- a/enterprise/e2e/auth-sso/hurl/login.all.hurl +++ b/enterprise/e2e/auth-sso/hurl/login.all.hurl @@ -168,9 +168,10 @@ POST {{base}}/self/v1/auth/logout HTTP 303 Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 cookie "sourcemeta_one_session[Max-Age]" == 0 cookie "sourcemeta_one_transaction[Max-Age]" == 0 +cookie "sourcemeta_one_renewal[Max-Age]" == 0 header "Location" startsWith "https://keycloak:8443/realms/main/protocol/openid-connect/logout?" # And the private catalog is gated again, the denial byte-identical to the first diff --git a/enterprise/e2e/auth-sso/hurl/logout.all.hurl b/enterprise/e2e/auth-sso/hurl/logout.all.hurl index d0d4f8c39..9e764181f 100644 --- a/enterprise/e2e/auth-sso/hurl/logout.all.hurl +++ b/enterprise/e2e/auth-sso/hurl/logout.all.hurl @@ -14,13 +14,16 @@ HTTP 303 Cache-Control: no-store Location: / [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 cookie "sourcemeta_one_session[Max-Age]" == 0 cookie "sourcemeta_one_session[HttpOnly]" exists cookie "sourcemeta_one_session[Path]" == "/" cookie "sourcemeta_one_transaction[Max-Age]" == 0 cookie "sourcemeta_one_transaction[HttpOnly]" exists cookie "sourcemeta_one_transaction[Path]" == "/" +cookie "sourcemeta_one_renewal[Max-Age]" == 0 +cookie "sourcemeta_one_renewal[HttpOnly]" exists +cookie "sourcemeta_one_renewal[Path]" == "/" # A value that is not a session this instance minted is cleared just the same, # and discloses nothing by being treated differently @@ -30,9 +33,10 @@ HTTP 303 Cache-Control: no-store Location: / [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 cookie "sourcemeta_one_session[Max-Age]" == 0 cookie "sourcemeta_one_transaction[Max-Age]" == 0 +cookie "sourcemeta_one_renewal[Max-Age]" == 0 # A real login, so there is a genuine session to end GET {{base}}/self/v1/auth/login/keycloak @@ -81,9 +85,10 @@ Cache-Control: no-store [Captures] provider_logout: header "Location" [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 cookie "sourcemeta_one_session[Max-Age]" == 0 cookie "sourcemeta_one_transaction[Max-Age]" == 0 +cookie "sourcemeta_one_renewal[Max-Age]" == 0 header "Location" startsWith "https://keycloak:8443/realms/main/protocol/openid-connect/logout?" header "Location" contains "id_token_hint=" header "Location" contains "post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A8000" diff --git a/enterprise/e2e/auth-sso/hurl/renewal.all.hurl b/enterprise/e2e/auth-sso/hurl/renewal.all.hurl new file mode 100644 index 000000000..eb43c937e --- /dev/null +++ b/enterprise/e2e/auth-sso/hurl/renewal.all.hurl @@ -0,0 +1,280 @@ +# A session lasts an hour, and asking somebody to click their provider again +# every hour is the cost of that. A browser that has signed in before carries a +# marker naming the policy it used, so a denial can ask the provider whether +# that sign-in still stands instead of asking the person. The provider answers +# without showing anything when it can, so the hour passes unnoticed. +# +# The marker is not a credential. It names a policy and nothing else, and +# whoever holds it can only start a login they were free to start anyway. + +# A denied browser navigation carrying the marker is sent to its provider +# rather than shown the sign-in page. The page it was denied travels along, so +# the renewal lands where the person already was +GET {{base}}/private/ +Accept: text/html +Cookie: sourcemeta_one_renewal=keycloak +HTTP 303 +Cache-Control: no-store +[Asserts] +header "Location" == "/self/v1/auth/login/keycloak?silent=1&to=%2Fprivate%2F" +header "Set-Cookie" not exists +header "WWW-Authenticate" not exists + +# The same navigation without the marker is the sign-in page, unchanged. A +# stranger is never sent to a provider they may have no account with, and the +# provider never learns they were here +GET {{base}}/private/ +Accept: text/html +HTTP 401 +Cache-Control: no-store +Content-Type: text/html; charset=utf-8 +WWW-Authenticate: Bearer realm="registry" +[Asserts] +header "Location" not exists +xpath "string(//title)" == "Sign In" +xpath "count(//a[@data-sourcemeta-ui-login])" == 1 + +# A marker naming a policy that gates somewhere else is ignored. Honouring it +# would send somebody to a provider whose answer cannot admit them here, which +# would deny them again and start the whole thing over +GET {{base}}/private/ +Accept: text/html +Cookie: sourcemeta_one_renewal=cleartext +HTTP 401 +Cache-Control: no-store +Content-Type: text/html; charset=utf-8 +WWW-Authenticate: Bearer realm="registry" +[Asserts] +header "Location" not exists +xpath "string(//title)" == "Sign In" + +# A marker naming nothing this instance serves is ignored the same way +GET {{base}}/private/ +Accept: text/html +Cookie: sourcemeta_one_renewal=not-a-policy +HTTP 401 +Cache-Control: no-store +Content-Type: text/html; charset=utf-8 +[Asserts] +header "Location" not exists +xpath "string(//title)" == "Sign In" + +# A client asking for JSON is answered plainly whatever it carries. Renewal is +# a browser navigation, and redirecting a script to an identity provider would +# turn a denial it can handle into a redirect chain it cannot +GET {{base}}/private/secret.json +Accept: application/json +Cookie: sourcemeta_one_renewal=keycloak +HTTP 401 +Cache-Control: no-store +Content-Type: application/problem+json +WWW-Authenticate: Bearer realm="registry" +Link: ; rel="describedby" +[Captures] +denied_body: body +error_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "Location" not exists +{ + "type": "urn:sourcemeta:one:authentication-required", + "title": "Unauthorized", + "status": 401, + "detail": "This resource requires authentication" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{error_schema}} +``` +{{denied_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +# A public page is not a denial, so the marker changes nothing about it +GET {{base}}/public/ +Accept: text/html +Cookie: sourcemeta_one_renewal=keycloak +HTTP 200 +Content-Type: text/html; charset=utf-8 +[Asserts] +header "Location" not exists + +# The login endpoint asked for a silent attempt tells the provider not to +# interact, which is what lets the answer arrive without the person seeing +# anything. OpenID Connect Core 1.0 Section 3.1.2.1 requires `none` to appear +# alone, so it is the whole of the parameter +GET {{base}}/self/v1/auth/login/keycloak?silent=1 +HTTP 303 +Cache-Control: no-store +[Asserts] +header "Location" startsWith "https://keycloak:8443/realms/main/protocol/openid-connect/auth?" +header "Location" contains "prompt=none" +header "Location" contains "code_challenge_method=S256" +header "Location" contains "state=" +header "Location" contains "nonce=" +cookie "sourcemeta_one_transaction" exists +cookie "sourcemeta_one_transaction[HttpOnly]" exists + +# A login somebody started themselves never says that, since a provider told +# not to interact cannot ask them who they are, and they came here to be asked +GET {{base}}/self/v1/auth/login/keycloak +HTTP 303 +Cache-Control: no-store +[Asserts] +header "Location" startsWith "https://keycloak:8443/realms/main/protocol/openid-connect/auth?" +header "Location" not contains "prompt" +cookie "sourcemeta_one_transaction" exists + +# A silent attempt carries the page to return to through the sealed +# transaction, exactly as an ordinary login does +GET {{base}}/self/v1/auth/login/keycloak?silent=1&to=/private/secret +HTTP 303 +Cache-Control: no-store +[Asserts] +header "Location" contains "prompt=none" +cookie "sourcemeta_one_transaction" exists + +# The renewal redirect is a navigation, so a method that cannot navigate is +# answered as one, and the marker does not change which methods a gate accepts +POST {{base}}/private/ +Accept: text/html +Cookie: sourcemeta_one_renewal=keycloak +HTTP 401 +Cache-Control: no-store +[Asserts] +header "Location" not exists + +# A silent attempt can fail well past the provider's answer: with no code at +# all, with a code the provider will not redeem, or with an identity that does +# not validate. None of that was asked for, so none of it is shown. Each one +# puts the browser back where it started and takes the marker with it, since an +# attempt that did not end in a session must not be made again on the next +# navigation, and every navigation after that. + +# A silent attempt whose callback carries no code +GET {{base}}/self/v1/auth/login/keycloak?silent=1&to=/private/ +HTTP 303 +[Captures] +codeless_state: header "Location" regex "state=([^&]+)" + +GET {{base}}/self/v1/auth/callback/keycloak?state={{codeless_state}} +HTTP 303 +Cache-Control: no-store +[Asserts] +header "Location" == "/private/" +cookie "sourcemeta_one_renewal[Max-Age]" == 0 +cookie "sourcemeta_one_transaction[Max-Age]" == 0 + +# A silent attempt whose code the provider refuses to redeem. Without the rule +# this would be the `502` an ordinary login gets, which nobody navigating to a +# page has any way to act on +GET {{base}}/self/v1/auth/login/keycloak?silent=1&to=/private/secret +HTTP 303 +[Captures] +refused_state: header "Location" regex "state=([^&]+)" + +GET {{base}}/self/v1/auth/callback/keycloak?state={{refused_state}}&code=a-code-nobody-issued +HTTP 303 +Cache-Control: no-store +[Asserts] +header "Location" == "/private/secret" +header "Set-Cookie" not contains "sourcemeta_one_session=" +cookie "sourcemeta_one_renewal[Max-Age]" == 0 + +# A provider that will not answer without asking the person, which is the +# ordinary end of a silent attempt rather than a fault (OpenID Connect Core 1.0 +# Section 3.1.2.6). It reads exactly like the failures above +GET {{base}}/self/v1/auth/login/keycloak?silent=1&to=/private/ +HTTP 303 +[Captures] +required_state: header "Location" regex "state=([^&]+)" + +GET {{base}}/self/v1/auth/callback/keycloak?state={{required_state}}&error=login_required +HTTP 303 +Cache-Control: no-store +[Asserts] +header "Location" == "/private/" +cookie "sourcemeta_one_renewal[Max-Age]" == 0 + +# A fault the provider names, rather than a refusal to interact, is treated the +# same way. Only an operator reading the log needs to tell them apart, and the +# marker goes either way so the next navigation does not repeat the detour +GET {{base}}/self/v1/auth/login/keycloak?silent=1&to=/private/ +HTTP 303 +[Captures] +faulty_state: header "Location" regex "state=([^&]+)" + +GET {{base}}/self/v1/auth/callback/keycloak?state={{faulty_state}}&error=server_error +HTTP 303 +Cache-Control: no-store +[Asserts] +header "Location" == "/private/" +cookie "sourcemeta_one_renewal[Max-Age]" == 0 + +# The same failures on a login somebody started themselves are still reported +# to them, since they are watching and can act on what they are told +GET {{base}}/self/v1/auth/login/keycloak?to=/private/ +HTTP 303 +[Captures] +visible_state: header "Location" regex "state=([^&]+)" + +GET {{base}}/self/v1/auth/callback/keycloak?state={{visible_state}}&code=a-code-nobody-issued +HTTP 500 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +[Captures] +visible_body: body +visible_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "Location" not exists +{ + "type": "urn:sourcemeta:one:auth-incomplete", + "title": "Internal Server Error", + "status": 500, + "detail": "The session could not be established" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{visible_schema}} +``` +{{visible_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +# And a decline on a login somebody started is still a decline, not a quiet +# redirect, so the one outcome a person caused is the one they are told about +GET {{base}}/self/v1/auth/login/keycloak?to=/private/ +HTTP 303 +[Captures] +declined_state: header "Location" regex "state=([^&]+)" + +GET {{base}}/self/v1/auth/callback/keycloak?state={{declined_state}}&error=access_denied +HTTP 403 +Cache-Control: no-store +Content-Type: application/problem+json +[Captures] +declined_body: body +[Asserts] +header "Location" not exists +{ + "type": "urn:sourcemeta:one:auth-login-declined", + "title": "Forbidden", + "status": 403, + "detail": "The identity provider declined the login" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{visible_schema}} +``` +{{declined_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true diff --git a/enterprise/e2e/auth-sso/playwright/renewal.spec.js b/enterprise/e2e/auth-sso/playwright/renewal.spec.js new file mode 100644 index 000000000..fd39e2a37 --- /dev/null +++ b/enterprise/e2e/auth-sso/playwright/renewal.spec.js @@ -0,0 +1,166 @@ +import { test, expect } from '@playwright/test'; + +// A session lasts an hour. Rather than ask somebody to click their provider +// again when it ends, a browser that has signed in before is sent back to the +// provider to be asked whether that sign-in still stands. When it does, the +// answer arrives without anything being shown and the hour passes unnoticed. +// +// An expired session is reproduced by deleting the session cookie and leaving +// the marker, which is exactly what a browser holds once the session's own +// lifetime has run out. + +const MARKER = 'sourcemeta_one_renewal'; +const SESSION = 'sourcemeta_one_session'; + +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(); +} + +async function cookieNamed(context, name) { + return (await context.cookies()).find((entry) => entry.name === name); +} + +async function expireSession(context) { + const kept = (await context.cookies()).filter( + (entry) => entry.name !== SESSION + ); + await context.clearCookies(); + await context.addCookies(kept); +} + +test.describe('Silent session renewal', () => { + test('signing in leaves the marker that makes renewal possible', async ({ + page, + context + }) => { + await page.goto('/private/'); + await signIn(page); + await expect(page).toHaveURL(/\/private\b/); + + const marker = await cookieNamed(context, MARKER); + expect(marker).toBeDefined(); + expect(marker.value).toBe('keycloak'); + expect(marker.httpOnly).toBe(true); + // It is only of use once the session has expired, so it has to outlive it + const session = await cookieNamed(context, SESSION); + expect(marker.expires).toBeGreaterThan(session.expires); + }); + + test('an expired session renews without the person seeing anything', async ({ + page, + context + }) => { + await page.goto('/private/'); + await signIn(page); + await expect(page.locator('table tbody tr').first()).toBeVisible(); + + await expireSession(context); + expect(await cookieNamed(context, SESSION)).toBeUndefined(); + + // The gated page is simply browsed to again. No sign-in card appears, and + // the listing renders as though the session had never lapsed + await page.goto('/private/'); + await expect(page).not.toHaveTitle('Sign In'); + await expect(page.locator('table tbody tr').first()).toBeVisible(); + await expect(page.locator('a[data-sourcemeta-ui-login]')).toHaveCount(0); + expect(await cookieNamed(context, SESSION)).toBeDefined(); + }); + + test('renewal lands on the exact page that was denied', async ({ + page, + context + }) => { + await page.goto('/private/secret'); + await signIn(page); + await expect(page).toHaveURL(/\/private\/secret$/); + + await expireSession(context); + await page.goto('/private/secret'); + await expect(page).toHaveURL(/\/private\/secret$/); + await expect(page).not.toHaveTitle('Sign In'); + }); + + test('a marker without a provider session falls back to the sign-in page', async ({ + page, + context + }) => { + // A browser that never signed in at the provider, carrying only the + // marker. The provider is asked and says it cannot answer without + // interaction, which is the ordinary end of a silent attempt rather than a + // failure, so the person is left where they were and offered the page + await context.addCookies([ + { + name: MARKER, + value: 'keycloak', + url: process.env.PLAYWRIGHT_BASE_URL + } + ]); + + await page.goto('/private/'); + await expect(page).toHaveTitle('Sign In'); + await expect( + page.locator('a[data-sourcemeta-ui-login="keycloak"]') + ).toBeVisible(); + + // The marker is gone, so the next denial does not go round again. Without + // this the browser would be sent to the provider on every navigation, for + // an answer that is never going to change + expect(await cookieNamed(context, MARKER)).toBeUndefined(); + }); + + test('a failed renewal does not repeat itself', async ({ page, context }) => { + await context.addCookies([ + { + name: MARKER, + value: 'keycloak', + url: process.env.PLAYWRIGHT_BASE_URL + } + ]); + + await page.goto('/private/'); + await expect(page).toHaveTitle('Sign In'); + + // A second navigation reaches the sign-in page directly. If the marker had + // survived, this would be another round trip through the provider, and + // every navigation after it too + const responses = []; + page.on('response', (response) => responses.push(response.url())); + await page.goto('/private/'); + await expect(page).toHaveTitle('Sign In'); + expect(responses.filter((url) => url.includes('keycloak:8443'))).toHaveLength( + 0 + ); + }); + + test('signing out stops the renewal it would otherwise trigger', async ({ + page, + context + }) => { + await page.goto('/private/'); + await signIn(page); + 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(); + }); + await page.waitForURL((url) => !url.pathname.startsWith('/private')); + + // The marker goes with the session. Somebody who has signed out is asking + // not to be signed in, and leaving it would undo that at the very next + // navigation without them doing anything + expect(await cookieNamed(context, MARKER)).toBeUndefined(); + await page.goto('/private/'); + await expect(page).toHaveTitle('Sign In'); + }); +}); diff --git a/enterprise/e2e/auth/hurl/sso.all.hurl b/enterprise/e2e/auth/hurl/sso.all.hurl index cf86435ef..90030ea33 100644 --- a/enterprise/e2e/auth/hurl/sso.all.hurl +++ b/enterprise/e2e/auth/hurl/sso.all.hurl @@ -154,9 +154,10 @@ POST {{base}}/self/v1/auth/logout HTTP 303 Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 cookie "sourcemeta_one_session[Max-Age]" == 0 cookie "sourcemeta_one_transaction[Max-Age]" == 0 +cookie "sourcemeta_one_renewal[Max-Age]" == 0 header "Location" startsWith "https://keycloak:8443/realms/main/protocol/openid-connect/logout?" # The browser is gated again, the denial byte-identical to the anonymous one diff --git a/enterprise/e2e/empty/hurl/auth-logout.all.hurl b/enterprise/e2e/empty/hurl/auth-logout.all.hurl index 7c0b58cda..5df504275 100644 --- a/enterprise/e2e/empty/hurl/auth-logout.all.hurl +++ b/enterprise/e2e/empty/hurl/auth-logout.all.hurl @@ -6,9 +6,10 @@ HTTP 303 Location: / Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 header "Set-Cookie" contains "sourcemeta_one_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" header "Set-Cookie" contains "sourcemeta_one_transaction=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" +header "Set-Cookie" contains "sourcemeta_one_renewal=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" header "Vary" not exists header "Referrer-Policy" not exists header "Content-Security-Policy" not exists @@ -24,9 +25,10 @@ HTTP 303 Location: / Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 header "Set-Cookie" contains "sourcemeta_one_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" header "Set-Cookie" contains "sourcemeta_one_transaction=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" +header "Set-Cookie" contains "sourcemeta_one_renewal=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" header "Vary" not exists header "Referrer-Policy" not exists header "Content-Security-Policy" not exists @@ -43,9 +45,10 @@ HTTP 303 Location: / Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 header "Set-Cookie" contains "sourcemeta_one_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" header "Set-Cookie" contains "sourcemeta_one_transaction=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" +header "Set-Cookie" contains "sourcemeta_one_renewal=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ bytes count == 0 diff --git a/enterprise/e2e/path/hurl/auth-logout.all.hurl b/enterprise/e2e/path/hurl/auth-logout.all.hurl index 455bf525c..fc322271f 100644 --- a/enterprise/e2e/path/hurl/auth-logout.all.hurl +++ b/enterprise/e2e/path/hurl/auth-logout.all.hurl @@ -6,9 +6,10 @@ HTTP 303 Location: /v1/catalog Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 header "Set-Cookie" contains "sourcemeta_one_session=; Path=/v1/catalog; Max-Age=0; HttpOnly; SameSite=Lax" header "Set-Cookie" contains "sourcemeta_one_transaction=; Path=/v1/catalog; Max-Age=0; HttpOnly; SameSite=Lax" +header "Set-Cookie" contains "sourcemeta_one_renewal=; Path=/v1/catalog; Max-Age=0; HttpOnly; SameSite=Lax" header "Vary" not exists header "Referrer-Policy" not exists header "Content-Security-Policy" not exists @@ -22,11 +23,13 @@ HTTP 303 Location: /v1/catalog Cache-Control: no-store [Asserts] -header "Set-Cookie" count == 2 +header "Set-Cookie" count == 3 cookie "sourcemeta_one_session[Max-Age]" == 0 cookie "sourcemeta_one_session[Path]" == "/v1/catalog" cookie "sourcemeta_one_transaction[Max-Age]" == 0 cookie "sourcemeta_one_transaction[Path]" == "/v1/catalog" +cookie "sourcemeta_one_renewal[Max-Age]" == 0 +cookie "sourcemeta_one_renewal[Path]" == "/v1/catalog" header "Vary" not exists header "Referrer-Policy" not exists header "Content-Security-Policy" not exists diff --git a/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_callback_v1.h b/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_callback_v1.h index d169d1d85..07226a6f6 100644 --- a/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_callback_v1.h +++ b/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_callback_v1.h @@ -41,6 +41,12 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { // its usefulness, with silent re-authentication as the eventual refresh static constexpr std::chrono::seconds SESSION_LIFETIME{3600}; + // How long a browser stays eligible for a silent renewal after signing in. + // Long enough to outlast a provider session, since the provider is the one + // that decides whether a renewal succeeds, and losing it early only costs a + // sign-in page that would otherwise have been skipped + static constexpr std::chrono::seconds RENEWAL_LIFETIME{43200}; + // RFC 6265 Section 6.1 asks a user agent to support "at least 4096 bytes per // cookie (as measured by the sum of the length of the cookie's name, value, // and attributes)". That is a floor they should honour rather than a ceiling @@ -116,6 +122,10 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { return; } + // Nobody asked for a silent attempt, so from here on nothing it does is + // shown to them: every way this can end without a session puts the browser + // back where it started instead + const auto silent{transaction.value().try_at("silent") != nullptr}; const auto *nonce{transaction.value().try_at("nonce")}; const auto *verifier{transaction.value().try_at("verifier")}; @@ -125,9 +135,10 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { // only thing this URL ever says about a name const auto policy{authentication.interactive(policy_name)}; if (!policy.has_value()) { - this->fail(request, response, sourcemeta::core::HTTP_STATUS_BAD_REQUEST, - "urn:sourcemeta:one:auth-invalid-callback", - "The login could not be completed"); + this->abandon(silent, transaction.value(), request, response, + sourcemeta::core::HTTP_STATUS_BAD_REQUEST, + "urn:sourcemeta:one:auth-invalid-callback", + "The login could not be completed"); return; } @@ -137,9 +148,10 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { // checked that way, so this runs only when one arrives, and it runs ahead // of the outcome so that no answer is acted on before it is placed if (request.has_query("iss") && request.query("iss") != policy->issuer) { - this->fail(request, response, sourcemeta::core::HTTP_STATUS_BAD_REQUEST, - "urn:sourcemeta:one:auth-invalid-callback", - "The login could not be completed"); + this->abandon(silent, transaction.value(), request, response, + sourcemeta::core::HTTP_STATUS_BAD_REQUEST, + "urn:sourcemeta:one:auth-invalid-callback", + "The login could not be completed"); return; } @@ -151,13 +163,15 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { // grant either, and a code arriving beside it is left unredeemed if (request.has_query("error")) { if (request.query("error").empty()) { - this->fail(request, response, sourcemeta::core::HTTP_STATUS_BAD_REQUEST, - "urn:sourcemeta:one:auth-invalid-callback", - "The login could not be completed"); + this->abandon(silent, transaction.value(), request, response, + sourcemeta::core::HTTP_STATUS_BAD_REQUEST, + "urn:sourcemeta:one:auth-invalid-callback", + "The login could not be completed"); } else { - this->fail(request, response, sourcemeta::core::HTTP_STATUS_FORBIDDEN, - "urn:sourcemeta:one:auth-login-declined", - "The identity provider declined the login"); + this->abandon(silent, transaction.value(), request, response, + sourcemeta::core::HTTP_STATUS_FORBIDDEN, + "urn:sourcemeta:one:auth-login-declined", + "The identity provider declined the login"); } return; @@ -165,9 +179,10 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { const auto code{request.query("code")}; if (code.empty()) { - this->fail(request, response, sourcemeta::core::HTTP_STATUS_BAD_REQUEST, - "urn:sourcemeta:one:auth-invalid-callback", - "The login could not be completed"); + this->abandon(silent, transaction.value(), request, response, + sourcemeta::core::HTTP_STATUS_BAD_REQUEST, + "urn:sourcemeta:one:auth-invalid-callback", + "The login could not be completed"); return; } @@ -175,7 +190,7 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { if (!client_secret.has_value()) { sourcemeta::one::HTTP_LOG("No client secret is set for the policy", policy_name); - this->incomplete(request, response); + this->incomplete(silent, transaction.value(), request, response); return; } @@ -184,7 +199,7 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { sourcemeta::one::HTTP_LOG("The provider named no token endpoint, or " "could not be reached, for the policy", policy_name); - this->incomplete(request, response); + this->incomplete(silent, transaction.value(), request, response); return; } @@ -200,7 +215,7 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { sourcemeta::one::HTTP_LOG( "The authorization code could not be redeemed for the policy", policy_name); - this->incomplete(request, response); + this->incomplete(silent, transaction.value(), request, response); return; } @@ -210,7 +225,7 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { "The provider returned an identity token that could not be read, " "for the policy", policy_name); - this->incomplete(request, response); + this->incomplete(silent, transaction.value(), request, response); return; } @@ -223,7 +238,7 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { if (!identity.has_value()) { sourcemeta::one::HTTP_LOG( "The identity token did not validate for the policy", policy_name); - this->incomplete(request, response); + this->incomplete(silent, transaction.value(), request, response); return; } @@ -261,12 +276,12 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { sourcemeta::one::HTTP_LOG( "The provider returned more than a session can hold, for the policy", policy_name); - this->incomplete(request, response); + this->incomplete(silent, transaction.value(), request, response); return; } if (!session_cookie.has_value()) { - this->incomplete(request, response); + this->incomplete(silent, transaction.value(), request, response); return; } @@ -285,9 +300,14 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { response.write_status(sourcemeta::core::HTTP_STATUS_SEE_OTHER); response.write_header("Set-Cookie", session_cookie.value()); + // Signing in is what earns a browser a silent renewal later, and the + // marker outlives the session it accompanies because it is only of use + // once that session has expired + this->remember_renewal(response, policy_name, scope, secure); // The single-use transaction has served its purpose, so it is expired // alongside minting the session - this->expire_transaction(response, scope, secure); + this->expire(response, sourcemeta::one::Authentication::TRANSACTION_COOKIE, + scope, secure); response.write_header("Location", destination); response.write_header("Cache-Control", "no-store"); sourcemeta::one::send_response(sourcemeta::core::HTTP_STATUS_SEE_OTHER, @@ -430,11 +450,78 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { return cookie; } - auto expire_transaction(sourcemeta::one::HTTPResponse &response, - const std::string_view scope, const bool secure) const + // Every way a callback that belongs to a real login can end without a + // session. A silent attempt is put back where it started rather than shown + // any of this, since an error page would be the first anybody knew a renewal + // had been tried at all. The marker goes with it whatever the reason, so an + // attempt that did not come back with a grant is not made again on the next + // navigation, and every navigation after that + auto abandon(const bool silent, const sourcemeta::core::JSON &transaction, + sourcemeta::one::HTTPRequest &request, + sourcemeta::one::HTTPResponse &response, + const sourcemeta::core::HTTPStatus &status, + const std::string_view type, const std::string_view detail) const -> void { + if (silent) { + sourcemeta::one::HTTP_LOG("A silent renewal did not end in a session", + detail); + this->forget_renewal_and_redirect_back(transaction, request, response); + return; + } + + this->fail(request, response, status, type, detail); + } + + auto remember_renewal(sourcemeta::one::HTTPResponse &response, + const std::string_view policy_name, + const std::string_view scope, const bool secure) const + -> void { + const auto cookie{sourcemeta::core::http_serialize_cookie( + {.name = sourcemeta::one::Authentication::RENEWAL_COOKIE, + .value = policy_name, + .path = scope, + .max_age = RENEWAL_LIFETIME, + .http_only = true, + .secure = secure, + .same_site = sourcemeta::core::HTTPCookieSameSite::Lax})}; + if (cookie.has_value()) { + response.write_header("Set-Cookie", cookie.value()); + } + } + + // Where a silent attempt leaves the browser when it did not come back with a + // grant: back where it was denied, carrying neither a session nor the marker + // that would send it here again + auto forget_renewal_and_redirect_back( + const sourcemeta::core::JSON &transaction, + sourcemeta::one::HTTPRequest &request, + sourcemeta::one::HTTPResponse &response) const -> void { + const auto base{this->server_uri_base_path()}; + const auto scope{base.empty() ? std::string_view{"/"} : base}; + const auto secure{sourcemeta::core::URI{this->server_uri()}.is_https()}; + std::string destination{scope}; + const auto *sealed_destination{transaction.try_at("to")}; + if (sealed_destination != nullptr && sealed_destination->is_string() && + sourcemeta::one::is_local_path(sealed_destination->to_string())) { + destination = sealed_destination->to_string(); + } + + response.write_status(sourcemeta::core::HTTP_STATUS_SEE_OTHER); + this->expire(response, sourcemeta::one::Authentication::RENEWAL_COOKIE, + scope, secure); + this->expire(response, sourcemeta::one::Authentication::TRANSACTION_COOKIE, + scope, secure); + response.write_header("Location", destination); + response.write_header("Cache-Control", "no-store"); + sourcemeta::one::send_response(sourcemeta::core::HTTP_STATUS_SEE_OTHER, + request, response); + } + + auto expire(sourcemeta::one::HTTPResponse &response, + const std::string_view name, const std::string_view scope, + const bool secure) const -> void { const auto cookie{sourcemeta::core::http_serialize_cookie( - {.name = sourcemeta::one::Authentication::TRANSACTION_COOKIE, + {.name = name, .value = "", .path = scope, .max_age = std::chrono::seconds{0}, @@ -540,12 +627,13 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction { // that would not validate reports how this deployment and its provider are // faring. The cause goes to the log, where an operator looks and a caller // cannot - auto incomplete(sourcemeta::one::HTTPRequest &request, + auto incomplete(const bool silent, const sourcemeta::core::JSON &transaction, + sourcemeta::one::HTTPRequest &request, sourcemeta::one::HTTPResponse &response) const -> void { - this->fail(request, response, - sourcemeta::core::HTTP_STATUS_INTERNAL_SERVER_ERROR, - "urn:sourcemeta:one:auth-incomplete", - "The session could not be established"); + this->abandon(silent, transaction, request, response, + sourcemeta::core::HTTP_STATUS_INTERNAL_SERVER_ERROR, + "urn:sourcemeta:one:auth-incomplete", + "The session could not be established"); } std::string_view error_schema_; diff --git a/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_login_v1.h b/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_login_v1.h index 237427c4d..e000c83fd 100644 --- a/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_login_v1.h +++ b/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_login_v1.h @@ -125,9 +125,19 @@ class ActionAuthLogin_v1 : public sourcemeta::one::RouterAction { const auto nonce_token{sourcemeta::core::oidc_nonce()}; const std::string_view nonce{nonce_token.data(), nonce_token.size()}; + // A silent attempt asks the provider whether an existing sign-in still + // stands, and is answered either way without the person seeing anything. + // The callback has to know which kind it is completing, since a provider + // refusing to answer without interaction is an ordinary outcome here and a + // failure anywhere else + const auto silent{!request.query("silent").empty()}; + auto payload{sourcemeta::core::JSON::make_object()}; payload.assign_assume_new("policy", sourcemeta::core::JSON{std::string{policy_name}}); + if (silent) { + payload.assign_assume_new("silent", sourcemeta::core::JSON{true}); + } payload.assign_assume_new("state", sourcemeta::core::JSON{state}); payload.assign_assume_new("nonce", sourcemeta::core::JSON{nonce}); payload.assign_assume_new("verifier", sourcemeta::core::JSON{verifier}); @@ -183,9 +193,26 @@ class ActionAuthLogin_v1 : public sourcemeta::one::RouterAction { redirect_uri += policy_name; const auto challenge{sourcemeta::core::oauth_pkce_challenge(verifier)}; - const auto url{sourcemeta::core::oidc_authorization_url( - endpoints.value().authorization, policy->client_id, redirect_uri, state, - std::string_view{challenge.data(), challenge.size()}, nonce)}; + sourcemeta::core::OIDCAuthenticationRequest authentication_request{}; + authentication_request.client_id = policy->client_id; + authentication_request.redirect_uri = redirect_uri; + authentication_request.scope = "openid"; + authentication_request.response_type = "code"; + authentication_request.state = state; + authentication_request.code_challenge = {challenge.data(), + challenge.size()}; + authentication_request.code_challenge_method = "S256"; + authentication_request.nonce = nonce; + if (silent) { + authentication_request.prompt = "none"; + } + + std::string authorization_url; + const auto url{sourcemeta::core::oidc_build_authentication_url( + endpoints.value().authorization, authentication_request, + authorization_url) + ? std::optional{authorization_url} + : std::nullopt}; if (!url.has_value()) { sourcemeta::one::HTTP_LOG("The authorization endpoint is not a URL a " "request can be built against, for the policy", diff --git a/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_logout_v1.h b/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_logout_v1.h index 1f61dd440..83a83f752 100644 --- a/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_logout_v1.h +++ b/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_logout_v1.h @@ -90,6 +90,11 @@ class ActionAuthLogout_v1 : public sourcemeta::one::RouterAction { scope, secure); this->expire(response, sourcemeta::one::Authentication::TRANSACTION_COOKIE, scope, secure); + // Somebody who has signed out is asking not to be signed in, so the marker + // that would have renewed them silently goes too. Leaving it would undo + // this at the very next denial, without them doing anything + this->expire(response, sourcemeta::one::Authentication::RENEWAL_COOKIE, + scope, secure); // Ending the session here leaves the provider's own untouched, so signing // in again would not ask who you are. Where the session names a policy diff --git a/src/authentication/authentication.cc b/src/authentication/authentication.cc index 972a242f0..ca4d59f2a 100644 --- a/src/authentication/authentication.cc +++ b/src/authentication/authentication.cc @@ -63,6 +63,12 @@ auto Authentication::interactive(const std::string_view) const return std::nullopt; } +auto Authentication::interactive(const Authentication::Path &, + const std::string_view) const + -> std::optional { + return std::nullopt; +} + auto Authentication::client_secret(const std::string_view) const -> std::optional { return std::nullopt; diff --git a/src/authentication/include/sourcemeta/one/authentication.h b/src/authentication/include/sourcemeta/one/authentication.h index f240ea88d..b22796ff0 100644 --- a/src/authentication/include/sourcemeta/one/authentication.h +++ b/src/authentication/include/sourcemeta/one/authentication.h @@ -108,6 +108,13 @@ class SOURCEMETA_ONE_AUTHENTICATION_EXPORT Authentication { static constexpr std::string_view TRANSACTION_COOKIE{ "sourcemeta_one_transaction"}; + // Names the policy a browser last signed in under, so that a denial can ask + // the provider whether that sign-in still stands rather than asking the + // person again. It outlives a session, since it is only of use once one has + // expired, and it carries no credential: whoever holds it can start a login + // they were free to start anyway + static constexpr std::string_view RENEWAL_COOKIE{"sourcemeta_one_renewal"}; + // A policy gates a set of path prefixes. A path covered by no policy is // public struct Policy { @@ -206,6 +213,12 @@ class SOURCEMETA_ONE_AUTHENTICATION_EXPORT Authentication { [[nodiscard]] auto interactive(std::string_view name) const -> std::optional; + // The same, narrowed to a policy that governs the given path. A name that + // gates somewhere else answers nothing, so a browser carrying a stale one is + // never sent to a provider whose answer could not admit it here + [[nodiscard]] auto interactive(const Path &path, std::string_view name) const + -> std::optional; + // Where a provider says its endpoints are. The values are copies, so they // stay usable across a refresh of what the provider last said struct ProviderEndpoints { diff --git a/src/router/include/sourcemeta/one/router.h b/src/router/include/sourcemeta/one/router.h index 17aac975b..8ea310c88 100644 --- a/src/router/include/sourcemeta/one/router.h +++ b/src/router/include/sourcemeta/one/router.h @@ -155,6 +155,11 @@ class RouterAction { [[nodiscard]] auto serve_login(HTTPRequest &request, HTTPResponse &response) const -> bool; + // Send a browser that has signed in before back to its provider, to be asked + // whether that sign-in still stands, rather than asking the person again + [[nodiscard]] auto serve_renewal(HTTPRequest &request, + HTTPResponse &response) const -> bool; + [[nodiscard]] auto server_uri_base_path() const noexcept -> std::string_view { return this->server_uri_base_path_; } diff --git a/src/router/router.cc b/src/router/router.cc index 03fa07e94..10d811085 100644 --- a/src/router/router.cc +++ b/src/router/router.cc @@ -9,6 +9,7 @@ #include // std::optional, std::nullopt #include // std::string #include // std::string_view +#include // std::vector namespace sourcemeta::one { @@ -145,6 +146,61 @@ auto Router::dispatch( instance->rest(matches, credential, request, response); } +// A denial only becomes a silent renewal when the browser carries the marker a +// previous sign-in left, and only under a policy that governs the path being +// denied. Both matter: without the first every stranger would be sent to a +// provider they have no account with, and without the second a stale marker +// would send somebody to a provider whose answer could not admit them here, +// which would deny them again and start over +auto RouterAction::serve_renewal(sourcemeta::one::HTTPRequest &request, + sourcemeta::one::HTTPResponse &response) const + -> bool { + const RequestCookies cookies{request}; + if (cookies.empty()) { + return false; + } + + std::vector candidates; + for (const auto field : std::span{cookies}) { + sourcemeta::core::http_cookie_values(field, Authentication::RENEWAL_COOKIE, + candidates); + } + + if (candidates.empty()) { + return false; + } + + const auto path{Authentication::Path::parse( + request.path(), this->server_uri(), this->server_uri_base_path())}; + if (!path.has_value()) { + return false; + } + + const auto &authentication{this->dispatcher().authentication()}; + for (const auto candidate : candidates) { + if (!authentication.interactive(path.value(), candidate).has_value()) { + continue; + } + + // The denied page is named outright rather than left to a referrer, which + // a redirect carries from wherever the browser came from rather than from + // the page it is being sent away from + std::string location{this->server_uri_base_path()}; + location += "/self/v1/auth/login/"; + location += candidate; + location += "?silent=1&to="; + sourcemeta::core::URI::escape(request.path(), location); + response.write_status(sourcemeta::core::HTTP_STATUS_SEE_OTHER); + response.write_header("Location", location); + response.write_header("Cache-Control", "no-store"); + sourcemeta::one::send_response(sourcemeta::core::HTTP_STATUS_SEE_OTHER, + request, response); + return true; + } + + return false; +} + auto RouterAction::serve_login(sourcemeta::one::HTTPRequest &request, sourcemeta::one::HTTPResponse &response) const -> bool { @@ -162,6 +218,12 @@ auto RouterAction::serve_login(sourcemeta::one::HTTPRequest &request, .x_frame_options = "DENY", }; + // A browser that signed in before is asked of its provider rather than of + // the person, so an expired session renews without anybody noticing + if (this->serve_renewal(request, response)) { + return true; + } + // The login page is a per-directory artifact, so a schema or a non-existent // path is answered by the nearest directory above it that offers a login. // Because every login page under the same policies is byte-identical, this