From a476f4d4dbc8ad2df14460825556e2a1838f7242 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Mon, 24 Aug 2026 14:39:44 -0300 Subject: [PATCH] [WIP] Experiment with a new `github` policy type Signed-off-by: Juan Cruz Viotti --- .github/workflows/ci.yml | 6 + Makefile | 1 + docs/api.md | 48 +- docs/configuration.md | 154 +++++- enterprise/authentication/authentication.cc | 425 ++++++++------ .../authentication/authentication_claims.h | 25 +- .../authentication/authentication_format.h | 121 +++- .../authentication/authentication_github.h | 481 ++++++++++++++++ .../authentication/authentication_save.cc | 70 +++ .../authentication/authentication_table.h | 134 ++++- enterprise/e2e/auth-github/Dockerfile | 9 + enterprise/e2e/auth-github/Makefile | 6 + enterprise/e2e/auth-github/compose.yml | 58 ++ enterprise/e2e/auth-github/environment | 4 + enterprise/e2e/auth-github/github/Dockerfile | 3 + enterprise/e2e/auth-github/github/server.mjs | 328 +++++++++++ .../e2e/auth-github/hurl/denial.all.hurl | 146 +++++ .../e2e/auth-github/hurl/deployment.all.hurl | 62 +++ .../e2e/auth-github/hurl/login.all.hurl | 274 +++++++++ .../hurl/rule-account-denied.all.hurl | 71 +++ .../hurl/rule-domain-denied.all.hurl | 71 +++ .../e2e/auth-github/hurl/rule-domain.all.hurl | 55 ++ .../hurl/rule-team-denied.all.hurl | 71 +++ .../e2e/auth-github/hurl/rule-team.all.hurl | 55 ++ enterprise/e2e/auth-github/one.json | 134 +++++ .../e2e/auth-github/playwright/login.spec.js | 140 +++++ .../playwright/playwright.config.js | 36 ++ .../auth-github/playwright/session.spec.js | 44 ++ enterprise/e2e/auth-github/realm.json | 197 +++++++ .../auth-github/schemas/archive/record.json | 7 + .../e2e/auth-github/schemas/corp/policy.json | 9 + .../e2e/auth-github/schemas/desk/ticket.json | 9 + .../auth-github/schemas/private/secret.json | 9 + .../auth-github/schemas/public/string.json | 4 + .../e2e/auth-github/schemas/team/roster.json | 7 + .../schemas/unavailable/thing.json | 4 + enterprise/e2e/auth-github/tls/ca.crt | 20 + enterprise/e2e/auth-github/tls/github.crt | 20 + enterprise/e2e/auth-github/tls/github.key | 28 + .../e2e/auth/hurl/mcp-resources.all.hurl | 6 +- .../hurl/mcp-2025-11-25-resources.all.hurl | 4 +- enterprise/scripts/e2e-tls.sh | 4 + enterprise/unit/authentication/CMakeLists.txt | 1 + .../authentication_github_test.cc | 521 ++++++++++++++++++ .../authentication/authentication_helpers.h | 91 +++ .../include/sourcemeta/one/authentication.h | 33 +- .../include/sourcemeta/one/configuration.h | 17 +- src/configuration/parse.cc | 75 ++- src/configuration/schema/configuration.json | 108 ++++ src/index/generators.h | 133 +++-- src/index/index.cc | 10 +- src/router/router.cc | 4 + src/self/v1/schemas/api/error.json | 8 + ...authentication-apikey-without-keys.clitest | 5 +- .../fail-authentication-name-empty.clitest | 5 +- .../fail-authentication-name-invalid.clitest | 5 +- .../fail-authentication-name-missing.clitest | 5 +- .../fail-authentication-public-type.clitest | 5 +- ...ntication-session-secret-duplicate.clitest | 8 +- ...uthentication-session-secret-empty.clitest | 8 +- ...l-authentication-unknown-algorithm.clitest | 5 +- 61 files changed, 4118 insertions(+), 289 deletions(-) create mode 100644 enterprise/authentication/authentication_github.h create mode 100644 enterprise/e2e/auth-github/Dockerfile create mode 100644 enterprise/e2e/auth-github/Makefile create mode 100644 enterprise/e2e/auth-github/compose.yml create mode 100644 enterprise/e2e/auth-github/environment create mode 100644 enterprise/e2e/auth-github/github/Dockerfile create mode 100644 enterprise/e2e/auth-github/github/server.mjs create mode 100644 enterprise/e2e/auth-github/hurl/denial.all.hurl create mode 100644 enterprise/e2e/auth-github/hurl/deployment.all.hurl create mode 100644 enterprise/e2e/auth-github/hurl/login.all.hurl create mode 100644 enterprise/e2e/auth-github/hurl/rule-account-denied.all.hurl create mode 100644 enterprise/e2e/auth-github/hurl/rule-domain-denied.all.hurl create mode 100644 enterprise/e2e/auth-github/hurl/rule-domain.all.hurl create mode 100644 enterprise/e2e/auth-github/hurl/rule-team-denied.all.hurl create mode 100644 enterprise/e2e/auth-github/hurl/rule-team.all.hurl create mode 100644 enterprise/e2e/auth-github/one.json create mode 100644 enterprise/e2e/auth-github/playwright/login.spec.js create mode 100644 enterprise/e2e/auth-github/playwright/playwright.config.js create mode 100644 enterprise/e2e/auth-github/playwright/session.spec.js create mode 100644 enterprise/e2e/auth-github/realm.json create mode 100644 enterprise/e2e/auth-github/schemas/archive/record.json create mode 100644 enterprise/e2e/auth-github/schemas/corp/policy.json create mode 100644 enterprise/e2e/auth-github/schemas/desk/ticket.json create mode 100644 enterprise/e2e/auth-github/schemas/private/secret.json create mode 100644 enterprise/e2e/auth-github/schemas/public/string.json create mode 100644 enterprise/e2e/auth-github/schemas/team/roster.json create mode 100644 enterprise/e2e/auth-github/schemas/unavailable/thing.json create mode 100644 enterprise/e2e/auth-github/tls/ca.crt create mode 100644 enterprise/e2e/auth-github/tls/github.crt create mode 100644 enterprise/e2e/auth-github/tls/github.key create mode 100644 enterprise/unit/authentication/authentication_github_test.cc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b248acff..5dff1e952 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,12 @@ jobs: path: enterprise/e2e/auth-sso edition: ${{ matrix.edition.name }} if: matrix.edition.name == 'enterprise' + - name: E2E (enterprise/auth-github) + uses: ./.github/actions/e2e + with: + path: enterprise/e2e/auth-github + edition: ${{ matrix.edition.name }} + if: matrix.edition.name == 'enterprise' - name: Benchmark run: | diff --git a/Makefile b/Makefile index 665f5ff15..da158032c 100644 --- a/Makefile +++ b/Makefile @@ -115,6 +115,7 @@ ifeq ($(ENTERPRISE),ON) $(MAKE) -C enterprise/e2e/auth EDITION=$(EDITION) $(MAKE) -C enterprise/e2e/auth-closed EDITION=$(EDITION) $(MAKE) -C enterprise/e2e/auth-sso EDITION=$(EDITION) + $(MAKE) -C enterprise/e2e/auth-github EDITION=$(EDITION) endif .PHONY: docker-benchmark diff --git a/docs/api.md b/docs/api.md index b823fd002..f28600500 100644 --- a/docs/api.md +++ b/docs/api.md @@ -160,9 +160,10 @@ For an [`apiKey`](configuration.md#api-key) policy that credential is one of the keys the policy declares. For a [`jwt`](configuration.md#jwt) policy it is an access token from the issuer the policy trusts. -An [`oidc`](configuration.md#oidc) policy signs a person in at their provider -instead, through the three endpoints below, leaving the browser holding a -session cookie that admits it exactly as a credential would. That cookie is +An [`oidc`](configuration.md#oidc) or [`github`](configuration.md#github) +policy signs a person in at their provider instead, through the three endpoints +below, leaving the browser holding a session cookie that admits it exactly as a +credential would. That cookie is `HttpOnly`, `SameSite=Lax`, scoped to the instance rather than to the whole host, and `Secure` whenever the instance URL is `https`. It carries its own expiry and the signature that proves this instance minted it, so no session is @@ -170,9 +171,11 @@ kept in memory and every replica of an instance accepts the sessions the others mint. A signed-in browser also holds a short-lived transaction cookie during a login, -and a renewal marker naming the policy it signed in under, which is what lets an -expired session be renewed against the provider without asking the person again. -The marker carries no credential. +and, where the policy was an `oidc` one, a renewal marker naming the policy it +signed in under, which is what lets an expired session be renewed against the +provider without asking the person again. The marker carries no credential. A +`github` policy leaves none, since GitHub cannot be asked whether a sign-in +still stands without showing the person its own pages. ### Providers @@ -183,9 +186,10 @@ browser and as data for anything else.* GET /self/v1/auth/login ``` -Only [`oidc`](configuration.md#oidc) policies appear here. A policy that admits -a program has nowhere to send a person, so naming it would offer a way in that -does not exist. +Only policies that sign a person in appear here, which is +[`oidc`](configuration.md#oidc) and [`github`](configuration.md#github). A +policy that admits a program has nowhere to send a person, so naming it would +offer a way in that does not exist. === "200" @@ -214,10 +218,12 @@ provider.* GET /self/v1/auth/login/{policy}[?to={redirect-location}] ``` -The response redirects the browser to the provider's authorization endpoint, -discovered from the policy's issuer, and sets a short-lived transaction cookie -that binds the login to this browser. That cookie is what the callback checks -before it acts on anything the provider says. +The response redirects the browser to the provider's authorization endpoint, and +sets a short-lived transaction cookie that binds the login to this browser. That +cookie is what the callback checks before it acts on anything the provider says. +An [`oidc`](configuration.md#oidc) policy discovers that endpoint from its +issuer, while a [`github`](configuration.md#github) policy composes it from the +deployment origin it names, since GitHub publishes nothing to discover. `to` names where to land once the login completes, and is honoured only when it is a path on this instance, so the endpoint cannot be turned into an open @@ -231,8 +237,9 @@ page, and then to the first path the policy declares. === "404" - No `oidc` policy carries that name. A policy of another type answers the - same way, so the endpoint discloses nothing about what is configured. + No policy that signs a person in carries that name. A policy of another + type answers the same way, so the endpoint discloses nothing about what is + configured. === "405" @@ -279,8 +286,10 @@ behalf. === "403" - The provider declined the login, in a callback that does belong to a login - this instance started. + Either the provider declined the login, or it authenticated somebody the + policy does not admit. Both arrive in a callback that does belong to a login + this instance started, and the two are told apart, since a person the policy + will never admit is better told so than left to try again. === "405" @@ -291,8 +300,9 @@ behalf. The callback belongs to a login this instance started, but no session came of it. Every cause answers this way, among them a client secret or session secret absent from the environment, a provider that could not be reached or - that refused the authorization code, and an identity token that does not - validate or that no session cookie can hold. Anybody can start a login and + that refused the authorization code, an identity token that does not + validate or that no session cookie can hold, and a deployment that would not + say who an access token was issued for. Anybody can start a login and return with a code of their own invention, so reaching this says nothing about who is asking, and nothing distinguishes the causes. The cause goes to the server log. diff --git a/docs/configuration.md b/docs/configuration.md index ba416aeb9..65c9cbafb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -355,13 +355,14 @@ contains the schema collections they own. Authentication is only available in the [Enterprise](commercial.md) edition. Learn more about [commercial licensing](commercial.md). -Authentication supports three policy types. An `apiKey` policy grants access to a +Authentication supports four policy types. An `apiKey` policy grants access to a consumer that presents a pre-shared key, a `jwt` policy grants access to a consumer that presents a signed JSON Web Token, verified against the issuer's -published key set, and an `oidc` policy grants access to a user who signs in -through their identity provider in the browser. The first two admit machines that -present a credential on every request, while the third authenticates a user once -and then rides a session the instance establishes. Anything not covered by a +published key set, an `oidc` policy grants access to a user who signs in +through their identity provider in the browser, and a `github` policy grants +access to a user who signs in through GitHub. The first two admit machines that +present a credential on every request, while the last two authenticate a user +once and then ride a session the instance establishes. Anything not covered by a policy stays public, so the configuration only ever describes what to protect, never what to expose. When a path is governed by more than one policy, the policies are unioned, so a single collection can admit both a machine presenting a @@ -382,8 +383,8 @@ others. Public key material is still fetched from an issuer and cached, which is a lookup of what an issuer publishes rather than of who is signed in. That is what lets an instance scale horizontally, and it is why taking access away works differently for each policy type, described under each below. Sessions -belong to `oidc` alone: a `jwt` policy has no session and no cookie, whether or -not it names the same provider. +belong to `oidc` and `github` alone: a `jwt` policy has no session and no +cookie, whether or not it names the same provider. A policy governs a [Collection](#collections) or [Page](#pages), or a namespace above them (the instance root governs everything). It cannot gate an individual @@ -397,7 +398,7 @@ regardless of type: | Property | Type | Required | Default | Description | |-----------------|------|----------|---------|-------------| -| `/type` | String | :red_circle: **Yes** | N/A | The policy type, one of `apiKey`, `jwt`, or `oidc` | +| `/type` | String | :red_circle: **Yes** | N/A | The policy type, one of `apiKey`, `jwt`, `oidc`, or `github` | | `/name` | String | :red_circle: **Yes** | N/A | The policy name, surfaced in directory listings. Must consist of lowercase letters, digits, and hyphens. The name `public` is reserved | | `/paths` | Array | :red_circle: **Yes** | N/A | The registry paths this policy governs, each rooted at `/`. Every path must be `/` itself (governing the whole instance) or name a known collection, page, or route | @@ -592,11 +593,12 @@ 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. +whole instance that names every `oidc` and `github` 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 policy that signs a person +in 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 @@ -631,8 +633,8 @@ follows is signed with a secret of the instance's own, unrelated to the provider policy declares. The order of `paths` therefore decides where signing in leaves somebody, though it never changes what the policy gates. -A browser holds one session per instance, whichever interactive policy -established it, so signing in with a second one ends the first. A session lasts +A browser holds one session per instance, whichever policy that signs a person +in established it, so signing in with a second one ends the first. A session lasts an hour and renews without anybody noticing, by sending the browser back to the provider, which answers without displaying anything where the sign-in still stands. Only a navigation renews: a script calling the API with an expired @@ -730,6 +732,128 @@ sign in through their identity provider to reach it: admits both a machine that presents a credential and a user who signs in, so one endpoint can serve continuous integration and users at once. +### GitHub + +A `github` policy grants access to a user who signs in through GitHub. It +establishes the same session an [`oidc`](#oidc) policy does, under the same +cookie, with the same lifetime and the same rotation, and everything above about +sessions applies to it unchanged. What differs is how the person is identified, +and that difference is worth reading before choosing this over `oidc`. + +GitHub is an OAuth 2.0 authorization server rather than an OpenID Connect +provider. It publishes no discovery document, issues no identity token, and +asserts no claims, so **nothing in this flow is signed**. Who somebody is, and +what they belong to, is read from GitHub's REST API over TLS after the +authorization code is redeemed. That is what every other project integrating +with GitHub does, and it is a real reduction in assurance next to an `oidc` +policy, where the provider signs an assertion this instance verifies against a +published key set. Prefer `oidc` where a provider offers it. + +Two further differences are visible to the person signing in. There is no way to +ask GitHub whether a sign-in still stands without showing them its own pages, so +a `github` session is **never renewed silently**: it lasts an hour and then the +person signs in again. And GitHub offers nowhere to end its own session, so +signing out of the registry leaves the GitHub session alone, and the next +sign-in is one click. + +| Property | Type | Required | Default | Description | +|-----------------|------|----------|---------|-------------| +| `/title` | String | No | The policy name | A human readable version of the policy name | +| `/clientId` | String | :red_circle: **Yes** | N/A | The client identifier of the OAuth App registered for this instance | +| `/clientSecret` | Object | :red_circle: **Yes** | N/A | The client secret of that OAuth App, 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, read exactly as on an [`oidc`](#oidc) policy | +| `/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` | +| `/host` | String | No | `https://github.com` | The origin of the GitHub deployment to sign people in against, for GitHub Enterprise Server. The public service answers its API at `https://api.github.com`, and every other deployment answers it below its own origin at `/api/v3` | +| `/users` | Array | No | An account handle is not consulted | The account handles admitted, compared without regard to case. A handle can be changed and then taken by somebody else, so this admits and nothing more: the session is keyed off the numeric account identifier, which is the account | +| `/organizations` | Array | No | An organisation is not consulted | The organisations whose members are admitted, named by handle and compared without regard to case | +| `/teams` | Array | No | A team is not consulted | The teams whose members are admitted, each named as `organisation/team-slug`. A slug is what appears in a URL and survives a change of display name, though not a rename | +| `/emailDomains` | Array | No | An address is not consulted | The domains an admitted address sits at, read exactly as on an [`oidc`](#oidc) policy. The address on a GitHub account is the public one and is frequently unset, so this is answered against the account's primary address, and only where GitHub marks it verified | + +Values within a rule are alternatives, and separate rules all have to hold, +exactly as on an `oidc` policy. A rule is answered when somebody signs in rather +than on every request afterwards, for the same reasons and with the same +consequences. + +```json title="one.json" +{ + "type": "github", + "name": "engineering", + "title": "GitHub", + "paths": [ "/internal" ], + "clientId": "Iv1.0123456789abcdef", + "clientSecret": { "environmentVariable": "ONE_GITHUB_CLIENT_SECRET" }, + "sessionSecrets": [ { "environmentVariable": "ONE_GITHUB_SESSION_SECRET" } ], + "organizations": [ "acme" ], + "teams": [ "acme/platform" ] +} +``` + +!!! warning "Register an OAuth App, not a GitHub App" + + GitHub's own advice prefers GitHub Apps for most integrations. This is the + exception, and the reason is checkable rather than a matter of taste: a + GitHub App's user access token reads `GET /user/orgs` as **a 200 carrying an + empty list** rather than as an error. A policy naming `organizations` would + therefore refuse everybody, successfully, with nothing anywhere saying why. + `teams` is only marginally better, since a fine-grained token sees the teams + of a single organisation. Only an OAuth App reads both correctly. + +What the operator does, in full: + +1. Create an OAuth App at `github.com/settings/developers`, under a personal + account or under an organisation they administer. +2. Set its **Authorization callback URL** to + `{url}/self/v1/auth/callback/{name}`, derived from the instance's `url` and + the policy's name. One app accepts several, so one app can serve several + policies or several environments. +3. Generate a client secret. Put the client identifier in `one.json` and the + secret in the environment variable the policy names. +4. **Only if the policy names `organizations` or `teams`**: have an owner of + each organisation approve the app under that organisation's third-party + access settings. Without that approval GitHub refuses the app its private + organisation data, and the rule denies everybody who is genuinely a member. + +The login asks for the least its rules need: `read:org` where the policy names +`organizations` or `teams`, `user:email` where it names `emailDomains`, and +nothing at all where it names only `users`. + +!!! note + + A GitHub personal access token, and a GitHub App installation token, are not + supported as credentials. Both are opaque, so validating one means asking + GitHub on every request, against a third party with a rate limit, inside the + request path. That contradicts the property the rest of this section rests + on: any replica verifies any credential on its own. An + [`apiKey`](#api-key) policy is the answer for a machine consumer. + +!!! tip "GitHub Actions" + + Authenticating a **workflow** rather than a person needs no `github` policy + at all. GitHub Actions issues OpenID Connect tokens against a real issuer + with a published key set, so a [`jwt`](#jwt) policy covers it: + + ```json title="one.json" + { + "type": "jwt", + "name": "ci", + "paths": [ "/internal" ], + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/acme", + "algorithms": [ "RS256" ], + "claims": { + "repository_owner": [ "acme" ], + "repository": [ "acme/schemas" ] + } + } + ``` + + Write rules against `repository` and `repository_owner` rather than against + `sub`. GitHub changed the `sub` format for repositories created after + 2026-07-15, so a fleet holding repositories of both vintages has two + spellings of it while the other two claims stay stable. + ## Extends The `extends` property enables configuration inheritance, allowing you to build diff --git a/enterprise/authentication/authentication.cc b/enterprise/authentication/authentication.cc index c85240a0e..0c292d4ec 100644 --- a/enterprise/authentication/authentication.cc +++ b/enterprise/authentication/authentication.cc @@ -321,9 +321,17 @@ auto Authentication::login(const std::string_view policy_name, -> Authentication::Outcome { Authentication::Outcome result; + // A GitHub deployment publishes nothing to discover, issues no identity + // token and asserts no claims, so where a login begins and what it asks for + // is composed here rather than fetched. Everything after that is the login + // any policy that signs a person in performs + const auto github{this->table_.impl_->github(policy_name)}; + const auto interactive{github.has_value() + ? std::optional{std::nullopt} + : this->table_.impl_->interactive(policy_name)}; + // An unknown or non-interactive policy name reveals nothing - const auto policy{this->table_.impl_->interactive(policy_name)}; - if (!policy.has_value()) { + if (!github.has_value() && !interactive.has_value()) { result.result = Authentication::Outcome::Result::Missing; return result; } @@ -335,29 +343,77 @@ auto Authentication::login(const std::string_view policy_name, return result; } - const auto endpoints{this->table_.impl_->endpoints(policy_name)}; - if (!endpoints.has_value() || endpoints.value().authorization.empty()) { - result.log.emplace_back("The provider named no authorization endpoint, or " - "could not be reached, for the policy"); - return result; + std::string authorization_endpoint; + std::string scope_request; + std::string claims_parameter; + if (github.has_value()) { + authorization_endpoint = github_authorization_endpoint(github->host); + // Ask for the least that answers the rules the policy names, since every + // scope beyond them is access nobody here has a use for + scope_request = github_scope(github.value()); + } else { + const auto endpoints{this->table_.impl_->endpoints(policy_name)}; + if (!endpoints.has_value() || endpoints.value().authorization.empty()) { + result.log.emplace_back("The provider named no authorization endpoint, " + "or could not be reached, for the policy"); + return result; + } + + authorization_endpoint = endpoints.value().authorization; + + // A provider sends only the claims a request asks for, so a policy whose + // rules name any is asked for them here. The standard way is the claims + // request parameter, and where a provider does not honour that, the scopes + // that carry the standard claims are the fallback. A claim no standard + // scope carries is then arranged at the provider instead, since inventing a + // scope name risks a request refused outright. + // The rules outlive every request built from them, since a claim request + // names its claim by pointing into them rather than copying + const auto rules{ + interactive->claims.empty() + ? std::optional{std::nullopt} + : sourcemeta::core::try_parse_json(interactive->claims)}; + const auto wanted{wanted_claims(interactive.value(), rules)}; + if (endpoints.value().claims_parameter_supported && !wanted.empty()) { + std::ostringstream text; + sourcemeta::core::stringify( + sourcemeta::core::oidc_build_claims_parameter({}, wanted), text); + claims_parameter = text.str(); + } + + requested_scope(wanted, scope_request); + report_unadvertised_claims(wanted, endpoints.value(), policy_name, + result.log); } const auto secrets{sourcemeta::core::oauth_transaction_mint()}; const std::string_view state{secrets.state.data(), secrets.state.size()}; const std::string_view verifier{secrets.code_verifier.data(), secrets.code_verifier.size()}; - const auto nonce_token{sourcemeta::core::oidc_nonce()}; - const std::string_view nonce{nonce_token.data(), nonce_token.size()}; auto payload{sourcemeta::core::JSON::make_object()}; payload.assign_assume_new("policy", sourcemeta::core::JSON{std::string{policy_name}}); - if (silent) { + + // A silent attempt asks a provider whether a sign-in still stands without + // showing the person anything. A GitHub deployment offers no way to ask, and + // the attempt is a navigation rather than something hidden, so one against it + // would land somebody on its sign-in page in the middle of browsing here + if (silent && !github.has_value()) { 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}); + + // A nonce binds an identity token to the login that asked for it, so only a + // login that will receive one carries it + if (!github.has_value()) { + const auto nonce_token{sourcemeta::core::oidc_nonce()}; + payload.assign_assume_new( + "nonce", sourcemeta::core::JSON{ + std::string_view{nonce_token.data(), nonce_token.size()}}); + } + payload.assign_assume_new("verifier", sourcemeta::core::JSON{verifier}); // Sealed so that a callback completing this login has to name the same place // the provider was told to come back to. The provider checks it too, and this @@ -367,12 +423,14 @@ auto Authentication::login(const std::string_view policy_name, // Where the browser goes once this completes. What the request asked for // wins, and what the policy governs stands in where it asked for nothing + const auto default_path{github.has_value() ? github->default_path + : interactive->default_path}; if (!return_to.empty()) { payload.assign_assume_new("to", sourcemeta::core::JSON{std::string{return_to}}); - } else if (!policy->default_path.empty()) { + } else if (!default_path.empty()) { payload.assign_assume_new( - "to", sourcemeta::core::JSON{std::string{policy->default_path}}); + "to", sourcemeta::core::JSON{std::string{default_path}}); } std::ostringstream payload_text; @@ -388,53 +446,44 @@ auto Authentication::login(const std::string_view policy_name, return result; } - // A provider sends only the claims a request asks for, so a policy whose - // rules name any is asked for them here. The standard way is the claims - // request parameter, and where a provider does not honour that, the scopes - // that carry the standard claims are the fallback. A claim no standard scope - // carries is then arranged at the provider instead, since inventing a scope - // name risks a request refused outright. - // The rules outlive every request built from them, since a claim request - // names its claim by pointing into them rather than copying - const auto rules{policy->claims.empty() - ? std::optional{std::nullopt} - : sourcemeta::core::try_parse_json(policy->claims)}; - const auto wanted{wanted_claims(policy.value(), rules)}; - std::string scope_request; - std::string claims_parameter; - if (endpoints.value().claims_parameter_supported && !wanted.empty()) { - std::ostringstream text; - sourcemeta::core::stringify( - sourcemeta::core::oidc_build_claims_parameter({}, wanted), text); - claims_parameter = text.str(); - } - - requested_scope(wanted, scope_request); - report_unadvertised_claims(wanted, endpoints.value(), policy_name, - result.log); - const auto challenge{sourcemeta::core::oauth_pkce_challenge(verifier)}; - sourcemeta::core::OIDCAuthenticationRequest authentication_request{}; - authentication_request.client_id = policy->client_id; - authentication_request.redirect_uri = redirect_uri; - authentication_request.scope = scope_request; - authentication_request.claims = claims_parameter; - 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; - if (!sourcemeta::core::oidc_build_authentication_url( - endpoints.value().authorization, authentication_request, - authorization_url)) { - result.log.emplace_back("The authorization endpoint is not a URL a request " - "can be built against, for the policy"); - return result; + if (github.has_value()) { + sourcemeta::core::OAuthAuthorizationRequest authorization_request{}; + authorization_request.client_id = github->client_id; + authorization_request.redirect_uri = redirect_uri; + authorization_request.scope = scope_request; + authorization_request.response_type = "code"; + authorization_request.state = state; + authorization_request.code_challenge = {challenge.data(), challenge.size()}; + authorization_request.code_challenge_method = "S256"; + sourcemeta::core::oauth_build_authorization_url( + authorization_endpoint, authorization_request, authorization_url); + } else { + sourcemeta::core::OIDCAuthenticationRequest authentication_request{}; + authentication_request.client_id = interactive->client_id; + authentication_request.redirect_uri = redirect_uri; + authentication_request.scope = scope_request; + authentication_request.claims = claims_parameter; + authentication_request.response_type = "code"; + authentication_request.state = state; + authentication_request.code_challenge = {challenge.data(), + challenge.size()}; + authentication_request.code_challenge_method = "S256"; + const auto *nonce{payload.try_at("nonce")}; + assert(nonce != nullptr && nonce->is_string()); + authentication_request.nonce = nonce->to_string(); + if (silent) { + authentication_request.prompt = "none"; + } + + if (!sourcemeta::core::oidc_build_authentication_url(authorization_endpoint, + authentication_request, + authorization_url)) { + result.log.emplace_back("The authorization endpoint is not a URL a " + "request can be built against, for the policy"); + return result; + } } // A redirect without the transaction cookie could never complete at the @@ -553,8 +602,18 @@ auto Authentication::callback(const std::string_view policy_name, // Which policy a callback belongs to is settled by opening its transaction, // so nothing reaching here names one this instance does not serve - const auto policy{this->table_.impl_->interactive(policy_name)}; - if (!policy.has_value()) { + const auto github{this->table_.impl_->github(policy_name)}; + const auto interactive{github.has_value() + ? std::optional{std::nullopt} + : this->table_.impl_->interactive(policy_name)}; + if (!github.has_value() && !interactive.has_value()) { + return abandon(Authentication::Outcome::Result::Invalid); + } + + // Only a login that will receive an identity token sealed a nonce to bind it + // to, so one arriving without it belongs to no login this instance started + if (!github.has_value() && + (nonce == nullptr || !nonce->is_string() || nonce->to_string().empty())) { return abandon(Authentication::Outcome::Result::Invalid); } @@ -563,7 +622,8 @@ auto Authentication::callback(const std::string_view policy_name, // answer relayed from somewhere else. A provider naming none cannot be // 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 (incoming.has_issuer && incoming.issuer != policy->issuer) { + if (!github.has_value() && incoming.has_issuer && + incoming.issuer != interactive->issuer) { return abandon(Authentication::Outcome::Result::Invalid); } @@ -589,82 +649,128 @@ auto Authentication::callback(const std::string_view policy_name, return abandon(Authentication::Outcome::Result::Incomplete); } - const auto endpoints{this->table_.impl_->endpoints(policy_name)}; - if (!endpoints.has_value() || endpoints.value().token.empty()) { - result.log.emplace_back("The provider named no token endpoint, or could " - "not be reached, for the policy"); - return abandon(Authentication::Outcome::Result::Incomplete); - } + // Who signed in, and what the policy makes of them. A GitHub deployment + // asserts nothing, so both come from its API, while a provider asserts both + // in a token it signed. This is the whole of what parts the two, and what + // follows is the session either of them ends in + std::string subject; + std::string id_token; + if (github.has_value()) { + const auto access_token{github_exchange( + this->table_.impl_->fetcher(), github_token_endpoint(github->host), + github->client_id, client_secret.value(), redirect_uri, incoming.code, + verifier->to_string(), result.log)}; + if (!access_token.has_value()) { + result.log.emplace_back( + "The authorization code could not be redeemed for the policy"); + return abandon(Authentication::Outcome::Result::Incomplete); + } - const auto grant{exchange( - this->table_.impl_->fetcher(), endpoints.value().token, policy->client_id, - client_secret.value(), redirect_uri, incoming.code, verifier->to_string(), - endpoints.value().token_endpoint_basic_auth)}; - if (!grant.has_value()) { - result.log.emplace_back( - "The authorization code could not be redeemed for the policy"); - return abandon(Authentication::Outcome::Result::Incomplete); - } + const auto identity{github_identity(this->table_.impl_->fetcher(), + github->host, access_token.value())}; + if (!identity.has_value()) { + result.log.emplace_back("The deployment did not say who the access token " + "was issued for, for the policy"); + return abandon(Authentication::Outcome::Result::Incomplete); + } - const auto token{sourcemeta::core::JWT::from(grant.value().id_token)}; - if (!token.has_value()) { - result.log.emplace_back("The provider returned an identity token that " - "could not be read, for the policy"); - return abandon(Authentication::Outcome::Result::Incomplete); - } + // A policy's rules are answered here rather than at the gate, for the same + // reason a provider's claims are: a session only ever exists for somebody + // the policy admits. The access token is spent answering them and then + // discarded, since it is a live credential against the person's own + // repositories rather than an assertion about who they are + if (github_admits(this->table_.impl_->fetcher(), github.value(), + identity.value(), access_token.value(), + result.log) != Admission::Admitted) { + result.log.emplace_back("The deployment authenticated somebody the " + "policy does not admit, for the policy"); + return abandon(Authentication::Outcome::Result::NotAdmitted); + } - auto provider{id_token_keys(endpoints.value().jwks_uri, - this->table_.impl_->key_fetcher())}; - sourcemeta::core::OIDCValidationOptions options; - options.nonce = nonce->to_string(); - const auto identity{sourcemeta::core::oidc_validate_id_token( - provider, token.value(), ID_TOKEN_ALGORITHMS, policy->issuer, - policy->client_id, options)}; - if (!identity.has_value()) { - result.log.emplace_back("The identity token did not validate for the " - "policy"); - return abandon(Authentication::Outcome::Result::Incomplete); - } + subject = identity.value().subject; + } else { + const auto endpoints{this->table_.impl_->endpoints(policy_name)}; + if (!endpoints.has_value() || endpoints.value().token.empty()) { + result.log.emplace_back("The provider named no token endpoint, or could " + "not be reached, for the policy"); + return abandon(Authentication::Outcome::Result::Incomplete); + } + + const auto grant{exchange(this->table_.impl_->fetcher(), + endpoints.value().token, interactive->client_id, + client_secret.value(), redirect_uri, + incoming.code, verifier->to_string(), + endpoints.value().token_endpoint_basic_auth)}; + if (!grant.has_value()) { + result.log.emplace_back( + "The authorization code could not be redeemed for the policy"); + return abandon(Authentication::Outcome::Result::Incomplete); + } + + const auto token{sourcemeta::core::JWT::from(grant.value().id_token)}; + if (!token.has_value()) { + result.log.emplace_back("The provider returned an identity token that " + "could not be read, for the policy"); + return abandon(Authentication::Outcome::Result::Incomplete); + } + + auto provider{id_token_keys(endpoints.value().jwks_uri, + this->table_.impl_->key_fetcher())}; + sourcemeta::core::OIDCValidationOptions options; + options.nonce = nonce->to_string(); + const auto identity{sourcemeta::core::oidc_validate_id_token( + provider, token.value(), ID_TOKEN_ALGORITHMS, interactive->issuer, + interactive->client_id, options)}; + if (!identity.has_value()) { + result.log.emplace_back("The identity token did not validate for the " + "policy"); + return abandon(Authentication::Outcome::Result::Incomplete); + } - // A policy's rules are answered here rather than at the gate, so that a - // session only ever exists for somebody the policy admits. Answering it - // afterwards would leave a valid session denied on every request, and a - // denial asks the provider again, which is a loop rather than an answer - std::optional combined; - auto admission{this->table_.impl_->admits_identity(policy_name, - token.value().payload())}; - - // OpenID Connect Core Section 5.4 has a provider answer for the claims a - // scope requested at its UserInfo endpoint rather than in the token, by - // default, under the flow this is completing. So a rule naming a claim the - // token does not carry is asked there before it is refused, and only then, - // since a token carrying everything needed spares the round trip - if (admission == Admission::Incomplete && - !endpoints.value().userinfo.empty()) { - const auto extra{userinfo( - this->table_.impl_->fetcher(), endpoints.value().userinfo, - grant.value().access_token, identity.value().subject, result.log)}; - if (extra.has_value()) { - combined = sourcemeta::core::oidc_merge_claims(token.value().payload(), - extra.value()); - if (combined.has_value()) { - admission = - this->table_.impl_->admits_identity(policy_name, combined.value()); + // A policy's rules are answered here rather than at the gate, so that a + // session only ever exists for somebody the policy admits. Answering it + // afterwards would leave a valid session denied on every request, and a + // denial asks the provider again, which is a loop rather than an answer + std::optional combined; + auto admission{this->table_.impl_->admits_identity( + policy_name, token.value().payload())}; + + // OpenID Connect Core Section 5.4 has a provider answer for the claims a + // scope requested at its UserInfo endpoint rather than in the token, by + // default, under the flow this is completing. So a rule naming a claim the + // token does not carry is asked there before it is refused, and only then, + // since a token carrying everything needed spares the round trip + if (admission == Admission::Incomplete && + !endpoints.value().userinfo.empty()) { + const auto extra{userinfo( + this->table_.impl_->fetcher(), endpoints.value().userinfo, + grant.value().access_token, identity.value().subject, result.log)}; + if (extra.has_value()) { + combined = sourcemeta::core::oidc_merge_claims(token.value().payload(), + extra.value()); + if (combined.has_value()) { + admission = this->table_.impl_->admits_identity(policy_name, + combined.value()); + } } } - } - if (admission != Admission::Admitted) { - result.log.emplace_back("The provider authenticated somebody the policy " - "does not admit, for the policy"); - // Whatever the decision was actually made against, which is the pair taken - // together once a second answer arrived. Explaining a refusal against the - // token alone would miss a claim the UserInfo endpoint supplied - const auto &asserted{combined.has_value() ? combined.value() - : token.value().payload()}; - this->table_.impl_->report_object_shaped_claims(policy_name, asserted, - result.log); - return abandon(Authentication::Outcome::Result::NotAdmitted); + if (admission != Admission::Admitted) { + result.log.emplace_back("The provider authenticated somebody the policy " + "does not admit, for the policy"); + // Whatever the decision was actually made against, which is the pair + // taken together once a second answer arrived. Explaining a refusal + // against the token alone would miss a claim the UserInfo endpoint + // supplied + const auto &asserted{combined.has_value() ? combined.value() + : token.value().payload()}; + this->table_.impl_->report_object_shaped_claims(policy_name, asserted, + result.log); + return abandon(Authentication::Outcome::Result::NotAdmitted); + } + + subject = identity.value().subject; + id_token = grant.value().id_token; } const auto expiry{std::chrono::time_point_cast( @@ -679,11 +785,10 @@ auto Authentication::callback(const std::string_view policy_name, // signed in. So the whole cookie is measured, and the token is left out when // it does not fit, which costs the confirmation page and nothing else auto session{this->table_.impl_->session_cookie( - policy_name, identity.value().subject, grant.value().id_token, expiry, - secure, result.log)}; + policy_name, subject, id_token, expiry, secure, result.log)}; if (!session.has_value() || session.value().size() > MAXIMUM_COOKIE_LENGTH) { - session = this->table_.impl_->session_cookie( - policy_name, identity.value().subject, "", expiry, secure, result.log); + session = this->table_.impl_->session_cookie(policy_name, subject, "", + expiry, secure, result.log); } // Without the token there is very little left, so exceeding the limit here @@ -701,31 +806,35 @@ auto Authentication::callback(const std::string_view policy_name, // 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 - auto marker{sourcemeta::core::JSON::make_object()}; - marker.assign_assume_new("policy", - sourcemeta::core::JSON{std::string{policy_name}}); - std::ostringstream marker_text; - sourcemeta::core::stringify(marker, marker_text); - const auto marker_expiry{std::chrono::time_point_cast( - std::chrono::system_clock::now()) + - RENEWAL_LIFETIME}; - const auto sealed_marker{this->table_.impl_->seal( - policy_name, SealPurpose::Renewal, marker_text.str(), marker_expiry)}; - if (!sealed_marker.has_value()) { - return abandon(Authentication::Outcome::Result::Incomplete); - } + // session has expired. A GitHub deployment cannot be asked whether a sign-in + // still stands without showing the person its own pages, so a browser signed + // in through one is left no marker at all + if (!github.has_value()) { + auto marker{sourcemeta::core::JSON::make_object()}; + marker.assign_assume_new("policy", + sourcemeta::core::JSON{std::string{policy_name}}); + std::ostringstream marker_text; + sourcemeta::core::stringify(marker, marker_text); + const auto marker_expiry{std::chrono::time_point_cast( + std::chrono::system_clock::now()) + + RENEWAL_LIFETIME}; + const auto sealed_marker{this->table_.impl_->seal( + policy_name, SealPurpose::Renewal, marker_text.str(), marker_expiry)}; + if (!sealed_marker.has_value()) { + return abandon(Authentication::Outcome::Result::Incomplete); + } - auto renewal{sourcemeta::core::http_serialize_cookie( - {.name = RENEWAL_COOKIE, - .value = sealed_marker.value(), - .path = COOKIE_PATH, - .max_age = RENEWAL_LIFETIME, - .http_only = true, - .secure = secure, - .same_site = sourcemeta::core::HTTPCookieSameSite::Lax})}; - if (renewal.has_value()) { - result.cookies.push_back(std::move(renewal).value()); + auto renewal{sourcemeta::core::http_serialize_cookie( + {.name = RENEWAL_COOKIE, + .value = sealed_marker.value(), + .path = COOKIE_PATH, + .max_age = RENEWAL_LIFETIME, + .http_only = true, + .secure = secure, + .same_site = sourcemeta::core::HTTPCookieSameSite::Lax})}; + if (renewal.has_value()) { + result.cookies.push_back(std::move(renewal).value()); + } } // The single-use transaction has served its purpose, so it is expired diff --git a/enterprise/authentication/authentication_claims.h b/enterprise/authentication/authentication_claims.h index c25111e9c..8825baa6d 100644 --- a/enterprise/authentication/authentication_claims.h +++ b/enterprise/authentication/authentication_claims.h @@ -13,6 +13,7 @@ #include "authentication_format.h" +#include // std::ranges::find #include // std::size_t #include // std::getenv #include // std::optional, std::nullopt @@ -225,7 +226,7 @@ inline auto admits_claims(const sourcemeta::core::JSON &payload, // compared without regard to case against domains the artifact already holds // in lower case inline auto admits_email_domain(const sourcemeta::core::JSON &claims, - const std::span domains) + const std::span domains) -> Admission { const auto *verified{claims.try_at("email_verified")}; const auto *address{claims.try_at("email")}; @@ -253,15 +254,25 @@ inline auto admits_email_domain(const sourcemeta::core::JSON &claims, std::string domain{asserted}; sourcemeta::core::to_lowercase(domain); - bool admitted{false}; - if (!each_counted_string(domains, - [&admitted, &domain](const auto candidate) -> void { - admitted = admitted || candidate == domain; - })) { + return std::ranges::find(domains, domain) == domains.end() + ? Admission::Refused + : Admission::Admitted; +} + +// The same question asked of the domains as the artifact stores them. A run +// this cannot read to its end says nothing about which domains a policy +// admits, so none of it is trusted rather than the part read before it +inline auto admits_email_domain(const sourcemeta::core::JSON &claims, + const std::span domains) + -> Admission { + std::vector decoded; + if (!each_counted_string(domains, [&decoded](const auto domain) -> void { + decoded.push_back(domain); + })) { return Admission::Refused; } - return admitted ? Admission::Admitted : Admission::Refused; + return admits_email_domain(claims, decoded); } } // namespace sourcemeta::one diff --git a/enterprise/authentication/authentication_format.h b/enterprise/authentication/authentication_format.h index 8a7253ada..3be2d0292 100644 --- a/enterprise/authentication/authentication_format.h +++ b/enterprise/authentication/authentication_format.h @@ -21,9 +21,19 @@ namespace sourcemeta::one { enum class AuthenticationPolicyType : std::uint8_t { ApiKey = 0, JWT = 1, - OIDC = 2 + OIDC = 2, + GitHub = 3 }; +// Whether a policy of this kind signs a person in and holds a session, which is +// what the parts that only care that a session exists ask rather than naming +// each kind that establishes one +inline auto is_interactive_type(const AuthenticationPolicyType type) noexcept + -> bool { + return type == AuthenticationPolicyType::OIDC || + type == AuthenticationPolicyType::GitHub; +} + // How many policies one artifact can name. Each occupies a bit of the node // masks, so this is what a mask has room for rather than a matter of taste constexpr std::size_t AUTHENTICATION_MAXIMUM_POLICIES{64}; @@ -40,7 +50,7 @@ inline auto at_offset(const std::span bytes, } constexpr std::uint32_t AUTHENTICATION_MAGIC{0x48545541}; -constexpr std::uint32_t AUTHENTICATION_VERSION{14}; +constexpr std::uint32_t AUTHENTICATION_VERSION{15}; // The artifact begins with this header. Every variable-length section is // located through an absolute byte offset so the matcher can address it @@ -158,6 +168,31 @@ struct OIDCPolicyMetadata { std::string_view default_path; }; +struct GitHubPolicyMetadata { + std::string_view host; + std::string_view client_id; + // Each of these is kept as the bytes it occupies so that reading a policy + // costs nothing until one of them is wanted + std::span users; + std::span organizations; + std::span teams; + std::span email_domains; + std::string_view client_secret_variable; + std::string_view name; + std::span session_secrets; + std::string_view default_path; +}; + +// What every policy holding a session carries, whichever kind it is. Reading it +// through one shape is what keeps the answer to whether a browser holds a +// session from being given once per kind +struct SessionPolicyMetadata { + std::string_view client_secret_variable; + std::string_view name; + std::span session_secrets; + std::string_view default_path; +}; + // Reading the artifact back, which is the other half of what writes it. // Each is a pure function of the bytes it is handed, and each answers // whether what it was handed was well formed @@ -287,7 +322,7 @@ inline auto structurally_valid(const std::span bytes) noexcept entry.algorithm > static_cast(Authentication::Algorithm::Sha256) || entry.type > - static_cast(AuthenticationPolicyType::OIDC)) { + static_cast(AuthenticationPolicyType::GitHub)) { return false; } @@ -543,6 +578,86 @@ inline auto decode_oidc_metadata(const std::span metadata, return read_string(metadata, cursor, result.default_path); } +inline auto decode_github_metadata(const std::span metadata, + GitHubPolicyMetadata &result) -> bool { + std::size_t cursor{0}; + if (!read_string(metadata, cursor, result.host) || + !read_string(metadata, cursor, result.client_id) || + !read_counted_strings(metadata, cursor, result.users) || + !read_counted_strings(metadata, cursor, result.organizations) || + !read_counted_strings(metadata, cursor, result.teams) || + !read_counted_strings(metadata, cursor, result.email_domains) || + !read_string(metadata, cursor, result.client_secret_variable) || + !read_string(metadata, cursor, result.name) || + !read_counted_strings(metadata, cursor, result.session_secrets)) { + return false; + } + + return read_string(metadata, cursor, result.default_path); +} + +// What a policy holding a session carries, read through whichever layout its +// kind is stored in. A kind that establishes no session has none of this, so it +// answers that it could not be read rather than answering with nothing +inline auto decode_session_metadata(const AuthenticationPolicyType type, + const std::span metadata, + SessionPolicyMetadata &result) -> bool { + if (type == AuthenticationPolicyType::OIDC) { + OIDCPolicyMetadata decoded; + if (!decode_oidc_metadata(metadata, decoded)) { + return false; + } + + result.client_secret_variable = decoded.client_secret_variable; + result.name = decoded.name; + result.session_secrets = decoded.session_secrets; + result.default_path = decoded.default_path; + return true; + } + + if (type == AuthenticationPolicyType::GitHub) { + GitHubPolicyMetadata decoded; + if (!decode_github_metadata(metadata, decoded)) { + return false; + } + + result.client_secret_variable = decoded.client_secret_variable; + result.name = decoded.name; + result.session_secrets = decoded.session_secrets; + result.default_path = decoded.default_path; + return true; + } + + return false; +} + +// The reference check treats two GitHub policies as the same scope only when +// they admit the same people, exactly as it does for the interactive policies +// below, so the deployment, the client identifier and every rule narrowing who +// is let in count as one indivisible identity +inline auto +collect_github_identifiers(const std::span metadata, + std::unordered_set &keys) -> void { + std::size_t cursor{0}; + std::string_view host; + std::string_view client_id; + std::span users; + std::span organizations; + std::span teams; + std::span email_domains; + if (!read_string(metadata, cursor, host) || + !read_string(metadata, cursor, client_id) || + !read_counted_strings(metadata, cursor, users) || + !read_counted_strings(metadata, cursor, organizations) || + !read_counted_strings(metadata, cursor, teams) || + !read_counted_strings(metadata, cursor, email_domains)) { + return; + } + + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + keys.emplace(reinterpret_cast(metadata.data()), cursor); +} + // The reference check treats two interactive policies as the same scope only // when they admit the same people, so the issuer, client identifier, claim // rules and email domains count as one indivisible identity, never as separate diff --git a/enterprise/authentication/authentication_github.h b/enterprise/authentication/authentication_github.h new file mode 100644 index 000000000..722bfbaab --- /dev/null +++ b/enterprise/authentication/authentication_github.h @@ -0,0 +1,481 @@ +#ifndef SOURCEMETA_ONE_ENTERPRISE_AUTHENTICATION_GITHUB_H_ +#define SOURCEMETA_ONE_ENTERPRISE_AUTHENTICATION_GITHUB_H_ + +#include + +#include +#include +#include +#include + +#include "authentication_claims.h" +#include "authentication_format.h" + +#include // std::ranges::find +#include // std::invocable +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::string, std::to_string +#include // std::string_view +#include // std::move, std::pair +#include // std::vector + +// What this knows about GitHub. It is a product rather than a standard, so +// every departure from what a standard would have is settled here, and nothing +// about who governs what or about the session that follows belongs in this file +namespace sourcemeta::one { + +// Where the public deployment is served, and where it answers its API. Every +// other deployment answers below its own origin instead, which is the whole of +// what parts them as far as this is concerned +inline constexpr std::string_view GITHUB_HOST{"https://github.com"}; +inline constexpr std::string_view GITHUB_PUBLIC_API{"https://api.github.com"}; +inline constexpr std::string_view GITHUB_PRIVATE_API{"/api/v3"}; + +// The API refuses a request naming no user agent outright, and answers in +// whichever representation a request pins, so both travel on every call this +// makes +inline constexpr std::string_view GITHUB_USER_AGENT{"sourcemeta-one"}; +inline constexpr std::string_view GITHUB_API_VERSION{"2022-11-28"}; + +// How much of a listing this reads before it stops. A listing is read inside a +// request handler, so one that never ends would leave a login making outbound +// calls without end. This sits far above what an account can plausibly belong +// to and far below where reading becomes a burden +inline constexpr std::size_t GITHUB_MAXIMUM_PAGES{10}; +inline constexpr std::size_t GITHUB_PAGE_SIZE{100}; + +// What a GitHub policy declares, lifted out of the bytes it is stored as. The +// views point into the artifact and remain valid for as long as it is mapped +struct GitHubPolicy { + std::string_view host{}; + std::string_view client_id{}; + // The first registry path the policy governs + std::string_view default_path{}; + std::vector users{}; + std::vector organizations{}; + std::vector teams{}; + std::vector email_domains{}; +}; + +// Who the API says a token was issued for. The account identifier is the +// subject, since an account may be renamed and its handle taken by somebody +// else, while the identifier is the account +struct GitHubIdentity { + std::string subject{}; + std::string login{}; +}; + +inline auto github_policy(const GitHubPolicyMetadata &decoded) -> GitHubPolicy { + GitHubPolicy result; + result.host = decoded.host; + result.client_id = decoded.client_id; + result.default_path = decoded.default_path; + // A rule this cannot read admits nobody, so a run that cannot be read to its + // end leaves the rule it belongs to empty rather than partly answered + if (!each_counted_string(decoded.users, [&result](const auto handle) -> void { + result.users.push_back(handle); + })) { + result.users.clear(); + } + + if (!each_counted_string(decoded.organizations, + [&result](const auto handle) -> void { + result.organizations.push_back(handle); + })) { + result.organizations.clear(); + } + + if (!each_counted_string(decoded.teams, [&result](const auto handle) -> void { + result.teams.push_back(handle); + })) { + result.teams.clear(); + } + + if (!each_counted_string(decoded.email_domains, + [&result](const auto domain) -> void { + result.email_domains.push_back(domain); + })) { + result.email_domains.clear(); + } + + return result; +} + +inline auto github_authorization_endpoint(const std::string_view host) + -> std::string { + std::string result{host}; + result += "/login/oauth/authorize"; + return result; +} + +inline auto github_token_endpoint(const std::string_view host) -> std::string { + std::string result{host}; + result += "/login/oauth/access_token"; + return result; +} + +// The public deployment answers its API under a host of its own, while every +// other answers below the origin it is served at +inline auto github_api_endpoint(const std::string_view host, + const std::string_view path) -> std::string { + std::string result; + if (host == GITHUB_HOST) { + result = GITHUB_PUBLIC_API; + } else { + result = host; + result += GITHUB_PRIVATE_API; + } + + result += path; + return result; +} + +// The least a login can ask for and still answer the rules a policy names. A +// policy naming only accounts needs nothing beyond signing in, since the +// account is what a token is issued for +inline auto github_scope(const GitHubPolicy &policy) -> std::string { + std::string result; + if (!policy.organizations.empty() || !policy.teams.empty()) { + result = "read:org"; + } + + if (!policy.email_domains.empty()) { + if (!result.empty()) { + result += " "; + } + + result += "user:email"; + } + + return result; +} + +// What every call to the API carries beyond the credential +inline constexpr std::pair + GITHUB_API_HEADERS[]{{"user-agent", GITHUB_USER_AGENT}, + {"accept", "application/vnd.github+json"}, + {"x-github-api-version", GITHUB_API_VERSION}}; + +// What redeeming an authorization code carries. The token endpoint answers in a +// form encoding unless a request pins one, so the representation is asked for +// here rather than assumed to be the one RFC 6749 Section 5.1 mandates +inline constexpr std::pair + GITHUB_TOKEN_HEADERS[]{{"user-agent", GITHUB_USER_AGENT}, + {"accept", "application/json"}}; + +// Redeem an authorization code for an access token. +// +// The token endpoint answers a failure with a 200 and a body naming the error, +// where RFC 6749 Section 5.2 has a failure carry a 400. So the body decides +// this rather than the status, and reading it the other way round would take a +// refused code for a grant and fail further along for a reason nobody could +// place +inline auto github_exchange(const Authentication::Fetcher &fetcher, + const std::string_view token_endpoint, + const std::string_view client_id, + const sourcemeta::core::SecureString &client_secret, + const std::string_view redirect_uri, + const std::string_view code, + const std::string_view code_verifier, + std::vector &log) + -> std::optional { + if (!fetcher) { + return std::nullopt; + } + + Authentication::ProviderRequest request{.url = token_endpoint}; + sourcemeta::core::oauth_build_token_request_code( + code, redirect_uri, code_verifier, {}, request.body); + // The body rather than an authorization header, which is what the deployment + // documents and what every other client of it sends. There is nothing to + // discover here that would say whether the header is taken, so the form known + // to work is the one that is used + sourcemeta::core::oauth_client_secret_post(client_id, client_secret, + request.body); + request.headers = GITHUB_TOKEN_HEADERS; + + const auto result{fetcher(std::move(request))}; + if (!result.has_value()) { + return std::nullopt; + } + + const auto document{sourcemeta::core::try_parse_json(result.value().body)}; + if (!document.has_value() || !document.value().is_object()) { + return std::nullopt; + } + + const auto *error{document.value().try_at("error")}; + if (error != nullptr) { + std::string message{"The token endpoint refused the authorization code, " + "naming "}; + message += error->is_string() ? error->to_string() : "no reason"; + log.push_back(std::move(message)); + return std::nullopt; + } + + const sourcemeta::core::OAuthTokenResponse response{document.value()}; + if (!response.access_token().has_value()) { + return std::nullopt; + } + + sourcemeta::core::SecureString token; + token.append(response.access_token().value()); + return token; +} + +// One call to the API, which is the only way anything is learned about who +// signed in. A redirect is not followed, so a membership the caller may not see +// reads as the absence it is rather than as somewhere else to look +inline auto github_get(const Authentication::Fetcher &fetcher, + const std::string_view url, + const sourcemeta::core::SecureString &access_token) + -> std::optional { + if (!fetcher || access_token.empty()) { + return std::nullopt; + } + + std::string authorization; + if (!sourcemeta::core::oauth_bearer_header(access_token, authorization)) { + return std::nullopt; + } + + Authentication::ProviderRequest request{.url = url}; + request.authorization.append(authorization); + request.headers = GITHUB_API_HEADERS; + + const auto result{fetcher(std::move(request))}; + if (!result.has_value() || result.value().status < 200 || + result.value().status >= 300) { + return std::nullopt; + } + + return sourcemeta::core::try_parse_json(result.value().body); +} + +inline auto github_identity(const Authentication::Fetcher &fetcher, + const std::string_view host, + const sourcemeta::core::SecureString &access_token) + -> std::optional { + const auto document{ + github_get(fetcher, github_api_endpoint(host, "/user"), access_token)}; + if (!document.has_value() || !document.value().is_object()) { + return std::nullopt; + } + + const auto *identifier{document.value().try_at("id")}; + const auto *login{document.value().try_at("login")}; + if (identifier == nullptr || !identifier->is_integer() || login == nullptr || + !login->is_string() || login->to_string().empty()) { + return std::nullopt; + } + + GitHubIdentity result; + result.subject = std::to_string(identifier->to_integer()); + result.login = login->to_string(); + return result; +} + +// How an entry of a listing is spelled for comparison against what a policy +// names. An organisation is named by its handle, and a team by the handle of +// the organisation holding it alongside its own +inline auto github_organization_handle(const sourcemeta::core::JSON &entry) + -> std::string { + const auto *login{entry.try_at("login")}; + if (login == nullptr || !login->is_string()) { + return {}; + } + + std::string result{login->to_string()}; + sourcemeta::core::to_lowercase(result); + return result; +} + +inline auto github_team_handle(const sourcemeta::core::JSON &entry) + -> std::string { + const auto *organization{entry.try_at("organization")}; + const auto *slug{entry.try_at("slug")}; + if (organization == nullptr || !organization->is_object() || + slug == nullptr || !slug->is_string()) { + return {}; + } + + auto result{github_organization_handle(*organization)}; + if (result.empty()) { + return {}; + } + + result += "/"; + result += slug->to_string(); + sourcemeta::core::to_lowercase(result); + return result; +} + +// Whether a listing carries an entry a policy names. +// +// The listing is walked a page at a time until one comes back shorter than the +// page asked for, which is the last one. Reading the `Link` header instead +// would follow wherever the answer pointed, for as long as it kept pointing +// somewhere, so the number of pages is bounded here and exceeding the bound is +// a refusal rather than a further call +template + requires std::invocable +[[nodiscard]] auto +github_admits_listing(const Authentication::Fetcher &fetcher, + const std::string_view host, const std::string_view path, + const sourcemeta::core::SecureString &access_token, + const std::span admitted, + Speller speller, std::vector &log) + -> std::optional { + for (std::size_t page{1}; page <= GITHUB_MAXIMUM_PAGES; page += 1) { + std::string url{github_api_endpoint(host, path)}; + url += "?per_page="; + url += std::to_string(GITHUB_PAGE_SIZE); + url += "&page="; + url += std::to_string(page); + + const auto document{github_get(fetcher, url, access_token)}; + if (!document.has_value() || !document.value().is_array()) { + return std::nullopt; + } + + for (const auto &entry : document.value().as_array()) { + if (!entry.is_object()) { + continue; + } + + const auto handle{speller(entry)}; + if (!handle.empty() && + std::ranges::find(admitted, handle) != admitted.end()) { + return true; + } + } + + if (document.value().size() < GITHUB_PAGE_SIZE) { + return false; + } + } + + log.emplace_back("A listing of what an account belongs to did not end within " + "the pages this reads, for the policy"); + return std::nullopt; +} + +// The address a policy answers a domain rule against, shaped as the pair +// OpenID Connect Core Section 5.1 defines for one, so that the rule itself is +// read exactly where every other one is. +// +// The address on the account is the public one and is frequently absent, so the +// addresses the account holder keeps are asked for instead, and only one that +// is both primary and verified stands for the person +inline auto github_email(const Authentication::Fetcher &fetcher, + const std::string_view host, + const sourcemeta::core::SecureString &access_token) + -> std::optional { + const auto document{github_get( + fetcher, github_api_endpoint(host, "/user/emails"), access_token)}; + if (!document.has_value() || !document.value().is_array()) { + return std::nullopt; + } + + for (const auto &entry : document.value().as_array()) { + if (!entry.is_object()) { + continue; + } + + const auto *address{entry.try_at("email")}; + const auto *primary{entry.try_at("primary")}; + const auto *verified{entry.try_at("verified")}; + if (address == nullptr || !address->is_string() || primary == nullptr || + !primary->is_boolean() || !primary->to_boolean()) { + continue; + } + + auto result{sourcemeta::core::JSON::make_object()}; + result.assign_assume_new("email", sourcemeta::core::JSON{*address}); + result.assign_assume_new("email_verified", + sourcemeta::core::JSON{verified != nullptr && + verified->is_boolean() && + verified->to_boolean()}); + return result; + } + + return std::nullopt; +} + +// Whether a policy admits the person an access token was issued for. +// +// The values within one rule are alternatives and the rules themselves are +// cumulative, which is the rule an interactive policy's claims already follow. +// A rule is answered with the fewest calls that can settle it, in the order +// that puts the cheapest first, and the first refusal ends the questioning +inline auto github_admits(const Authentication::Fetcher &fetcher, + const GitHubPolicy &policy, + const GitHubIdentity &identity, + const sourcemeta::core::SecureString &access_token, + std::vector &log) -> Admission { + if (!policy.users.empty()) { + std::string login{identity.login}; + sourcemeta::core::to_lowercase(login); + if (std::ranges::find(policy.users, login) == policy.users.cend()) { + return Admission::Refused; + } + } + + if (!policy.organizations.empty()) { + const auto admitted{github_admits_listing( + fetcher, policy.host, "/user/orgs", access_token, policy.organizations, + github_organization_handle, log)}; + // An organisation can be made to refuse an application its own data, under + // which a member reads as a stranger and nothing in the exchange says why. + // That is a rule which can only ever deny, so it is reported where an + // operator looks rather than only refused + if (!admitted.has_value()) { + log.emplace_back("The organisations an account belongs to could not be " + "read, which is also how an organisation refusing this " + "application its own data reads, for the policy"); + return Admission::Refused; + } + + if (!admitted.value()) { + return Admission::Refused; + } + } + + if (!policy.teams.empty()) { + const auto admitted{ + github_admits_listing(fetcher, policy.host, "/user/teams", access_token, + policy.teams, github_team_handle, log)}; + if (!admitted.has_value()) { + log.emplace_back("The teams an account belongs to could not be read, " + "which is also how an organisation refusing this " + "application its own data reads, for the policy"); + return Admission::Refused; + } + + if (!admitted.value()) { + return Admission::Refused; + } + } + + if (!policy.email_domains.empty()) { + const auto address{github_email(fetcher, policy.host, access_token)}; + if (!address.has_value()) { + log.emplace_back("The account holds no primary address, or none could be " + "read, for the policy"); + return Admission::Refused; + } + + if (admits_email_domain(address.value(), policy.email_domains) != + Admission::Admitted) { + return Admission::Refused; + } + } + + return Admission::Admitted; +} + +} // namespace sourcemeta::one + +#endif diff --git a/enterprise/authentication/authentication_save.cc b/enterprise/authentication/authentication_save.cc index 4d1426547..0ea4c8ef7 100644 --- a/enterprise/authentication/authentication_save.cc +++ b/enterprise/authentication/authentication_save.cc @@ -48,6 +48,8 @@ auto policy_type(const sourcemeta::one::Authentication::Policy &policy) return sourcemeta::one::AuthenticationPolicyType::JWT; case 2: return sourcemeta::one::AuthenticationPolicyType::OIDC; + case 3: + return sourcemeta::one::AuthenticationPolicyType::GitHub; default: return sourcemeta::one::AuthenticationPolicyType::ApiKey; } @@ -134,6 +136,60 @@ auto encode_oidc_metadata( return result; } +// A handle names an account rather than a phrase, so it is compared without +// regard to case, and the order rules were written in says nothing about who +// they admit. Both are reduced here to the single spelling the artifact +// carries, so that two policies admitting the same people serialise identically +auto append_handles(std::vector &output, + const std::span handles) -> void { + std::vector canonical; + canonical.reserve(handles.size()); + for (const auto handle : handles) { + canonical.emplace_back(handle); + sourcemeta::core::to_lowercase(canonical.back()); + } + + std::ranges::sort(canonical); + const auto repeated{std::ranges::unique(canonical)}; + canonical.erase(repeated.begin(), repeated.end()); + append_u32(output, static_cast(canonical.size())); + for (const auto &handle : canonical) { + append_string(output, handle); + } +} + +// The same layout rule the interactive metadata above follows: everything +// deciding who a policy admits leads, so those bytes keep spanning exactly the +// audience it denotes, and every run is counted so the field after it is found +// whatever its number +auto encode_github_metadata( + const std::string_view host, const std::string_view client_id, + const std::span users, + const std::span organizations, + const std::span teams, + const std::span email_domains, + const std::string_view client_secret_variable, const std::string_view name, + const std::span session_secret_variables, + const std::string_view default_path) -> std::vector { + std::vector result; + append_string(result, host); + append_string(result, client_id); + append_handles(result, users); + append_handles(result, organizations); + append_handles(result, teams); + append_handles(result, email_domains); + append_string(result, client_secret_variable); + append_string(result, name); + append_u32(result, + static_cast(session_secret_variables.size())); + for (const auto variable : session_secret_variables) { + append_string(result, variable); + } + + append_string(result, default_path); + return result; +} + // A media type is compared case-insensitively and with the `application/` // prefix optional, so two spellings that admit exactly the same tokens are // reduced to one here. The prefix only comes off a bare subtype, matching how @@ -396,6 +452,20 @@ auto Authentication::Table::compile( interactive->email_domains, interactive->client_secret_variable, policy.name, interactive->session_secrets, policy.paths.empty() ? std::string_view{} : policy.paths.front()); + } else if (const auto *github{std::get_if( + &policy.credential)}) { + // The same requirement an interactive policy carries, for the same + // reason: one without a session secret could never mint or verify one + if (github->session_secrets.empty()) { + throw AuthenticationMissingSecretError(configuration, + std::string{policy.name}); + } + + policy_metadata = encode_github_metadata( + github->host, github->client_id, github->users, github->organizations, + github->teams, github->email_domains, github->client_secret_variable, + policy.name, github->session_secrets, + policy.paths.empty() ? std::string_view{} : policy.paths.front()); } else { const auto &key{ std::get(policy.credential)}; diff --git a/enterprise/authentication/authentication_table.h b/enterprise/authentication/authentication_table.h index 0a6bee0bf..df4c28662 100644 --- a/enterprise/authentication/authentication_table.h +++ b/enterprise/authentication/authentication_table.h @@ -12,6 +12,7 @@ #include "authentication_claims.h" #include "authentication_format.h" +#include "authentication_github.h" #include "authentication_provider.h" #include "authentication_session.h" @@ -120,6 +121,19 @@ struct Authentication::Table::Impl { continue; } + // A GitHub policy asserts no claims, since its provider publishes none, + // so what is read here is only that the policy can be read at all. One + // that cannot leaves the whole artifact denying everything, exactly as a + // malformed header does + if (type == AuthenticationPolicyType::GitHub) { + GitHubPolicyMetadata decoded; + if (!decode_github_metadata(metadata, decoded)) { + return false; + } + + continue; + } + std::string_view serialized; if (type == AuthenticationPolicyType::JWT) { if (!read_jwt_claims(metadata, serialized)) { @@ -209,17 +223,17 @@ struct Authentication::Table::Impl { const auto *sealed_policy{document.value().try_at("policy")}; const auto *sealed_state{document.value().try_at("state")}; const auto *sealed_redirect{document.value().try_at("redirect_uri")}; - const auto *nonce{document.value().try_at("nonce")}; const auto *verifier{document.value().try_at("verifier")}; + // A nonce is not asked for here, since only a login that will receive an + // identity token seals one. Whoever completes such a login requires it, + // where this requires what every login carries if (sealed_policy == nullptr || !sealed_policy->is_string() || sealed_policy->to_string() != policy_name || sealed_state == nullptr || !sealed_state->is_string() || sealed_state->to_string() != state || sealed_redirect == nullptr || !sealed_redirect->is_string() || - sealed_redirect->to_string() != redirect_uri || nonce == nullptr || - !nonce->is_string() || nonce->to_string().empty() || - verifier == nullptr || !verifier->is_string() || - verifier->to_string().empty()) { + sealed_redirect->to_string() != redirect_uri || verifier == nullptr || + !verifier->is_string() || verifier->to_string().empty()) { continue; } @@ -409,8 +423,9 @@ struct Authentication::Table::Impl { // browser login established, never a presented credential. A request that // presented one is asking to be read as that credential, so its session is // not consulted at all rather than quietly widening what it reaches - if (type == AuthenticationPolicyType::OIDC) { - return credential.empty() && this->admits_session(metadata, cookies); + if (is_interactive_type(type)) { + return credential.empty() && + this->admits_session(type, metadata, cookies); } return admits_apikey( @@ -533,15 +548,17 @@ struct Authentication::Table::Impl { } [[nodiscard]] auto - admits_session(const std::span metadata, + admits_session(const AuthenticationPolicyType type, + const std::span metadata, const std::span cookies) const -> bool { if (cookies.empty()) { return false; } - OIDCPolicyMetadata decoded; - if (!decode_oidc_metadata(metadata, decoded) || decoded.name.empty()) { + SessionPolicyMetadata decoded; + if (!decode_session_metadata(type, metadata, decoded) || + decoded.name.empty()) { return false; } const auto policy_name{decoded.name}; @@ -585,17 +602,17 @@ struct Authentication::Table::Impl { static_cast(this->policies_)}; for (std::uint32_t index{0}; index < this->policy_count_; index += 1) { const auto &entry{policies[index]}; - if (static_cast(entry.type) != - AuthenticationPolicyType::OIDC || - entry.metadata_length == 0) { + const auto type{static_cast(entry.type)}; + if (!is_interactive_type(type) || entry.metadata_length == 0) { continue; } const std::span metadata{ at_offset(this->bytes_, entry.metadata_offset), entry.metadata_length}; - OIDCPolicyMetadata decoded; - if (!decode_oidc_metadata(metadata, decoded) || decoded.name.empty()) { + SessionPolicyMetadata decoded; + if (!decode_session_metadata(type, metadata, decoded) || + decoded.name.empty()) { continue; } @@ -652,6 +669,75 @@ struct Authentication::Table::Impl { return false; } + // What the policy declared under a name carries about the session it holds, + // whichever kind establishes it. Sealing, opening and reading a client secret + // ask this rather than each kind in turn + [[nodiscard]] auto find_session(const std::string_view name, + SessionPolicyMetadata &result) const -> bool { + if (this->policy_count_ == 0 || name.empty()) { + return false; + } + + const auto *policies{ + static_cast(this->policies_)}; + for (std::uint32_t index{0}; index < this->policy_count_; index += 1) { + const auto &entry{policies[index]}; + const auto type{static_cast(entry.type)}; + if (!is_interactive_type(type) || entry.metadata_length == 0) { + continue; + } + + const std::span metadata{ + at_offset(this->bytes_, entry.metadata_offset), + entry.metadata_length}; + if (decode_session_metadata(type, metadata, result) && + result.name == name) { + return true; + } + } + + return false; + } + + // The decoded metadata of the GitHub policy declared under the given name, + // scanned out of the artifact + [[nodiscard]] auto find_github(const std::string_view name, + GitHubPolicyMetadata &result) const -> bool { + if (this->policy_count_ == 0 || name.empty()) { + return false; + } + + const auto *policies{ + static_cast(this->policies_)}; + for (std::uint32_t index{0}; index < this->policy_count_; index += 1) { + const auto &entry{policies[index]}; + if (static_cast(entry.type) != + AuthenticationPolicyType::GitHub || + entry.metadata_length == 0) { + continue; + } + + const std::span metadata{ + at_offset(this->bytes_, entry.metadata_offset), + entry.metadata_length}; + if (decode_github_metadata(metadata, result) && result.name == name) { + return true; + } + } + + return false; + } + + [[nodiscard]] auto github(const std::string_view name) const + -> std::optional { + GitHubPolicyMetadata decoded; + if (!this->find_github(name, decoded)) { + return std::nullopt; + } + + return github_policy(decoded); + } + [[nodiscard]] auto interactive(const std::string_view name) const -> std::optional { OIDCPolicyMetadata decoded; @@ -782,6 +868,10 @@ struct Authentication::Table::Impl { continue; } + // Only a policy whose provider can be asked whether a sign-in still + // stands without showing the person anything is named here, which a + // GitHub deployment cannot be, so a browser signed in through one is + // never sent back to it on its own const auto &entry{policies[index]}; if (static_cast(entry.type) != AuthenticationPolicyType::OIDC || @@ -842,8 +932,8 @@ struct Authentication::Table::Impl { [[nodiscard]] auto client_secret(const std::string_view policy) const -> std::optional { - OIDCPolicyMetadata decoded; - if (!this->find_interactive(policy, decoded)) { + SessionPolicyMetadata decoded; + if (!this->find_session(policy, decoded)) { return std::nullopt; } @@ -938,8 +1028,8 @@ struct Authentication::Table::Impl { const std::string_view payload, const std::chrono::sys_seconds expiry) const -> std::optional { - OIDCPolicyMetadata decoded; - if (!this->find_interactive(policy, decoded)) { + SessionPolicyMetadata decoded; + if (!this->find_session(policy, decoded)) { return std::nullopt; } @@ -951,8 +1041,8 @@ struct Authentication::Table::Impl { const SealPurpose purpose, const std::string_view value) const -> std::optional { - OIDCPolicyMetadata decoded; - if (!this->find_interactive(policy, decoded)) { + SessionPolicyMetadata decoded; + if (!this->find_session(policy, decoded)) { return std::nullopt; } @@ -1171,6 +1261,8 @@ struct Authentication::Table::Impl { collect_jwt_identifiers(metadata, result.keys); } else if (type == AuthenticationPolicyType::OIDC) { collect_oidc_identifiers(metadata, result.keys); + } else if (type == AuthenticationPolicyType::GitHub) { + collect_github_identifiers(metadata, result.keys); } else { collect_keys(metadata, result.keys); } diff --git a/enterprise/e2e/auth-github/Dockerfile b/enterprise/e2e/auth-github/Dockerfile new file mode 100644 index 000000000..9323b5954 --- /dev/null +++ b/enterprise/e2e/auth-github/Dockerfile @@ -0,0 +1,9 @@ +FROM one +COPY tls/ca.crt /usr/local/share/ca-certificates/sandbox-authority.crt +RUN update-ca-certificates +COPY one.json . +COPY schemas schemas +RUN sourcemeta one.json --profile +RUN set -e && test -d "$SOURCEMETA_ONE_WORKDIR" && \ + test -z "$(ls -A "$SOURCEMETA_ONE_WORKDIR")" +RUN rm -rf "$SOURCEMETA_ONE_WORKDIR" diff --git a/enterprise/e2e/auth-github/Makefile b/enterprise/e2e/auth-github/Makefile new file mode 100644 index 000000000..960efcf16 --- /dev/null +++ b/enterprise/e2e/auth-github/Makefile @@ -0,0 +1,6 @@ +include ../../../test/e2e/common.mk + +# The deployment serves real TLS under this sandbox's committed authority, so +# the client trusts that authority and dials the certificate's name through the +# locally mapped port +HURL_FLAGS = --cacert tls/ca.crt --connect-to github:9443:localhost:9443 diff --git a/enterprise/e2e/auth-github/compose.yml b/enterprise/e2e/auth-github/compose.yml new file mode 100644 index 000000000..c1d492ec7 --- /dev/null +++ b/enterprise/e2e/auth-github/compose.yml @@ -0,0 +1,58 @@ +services: + # The identity provider the deployment in front of it authenticates people + # against. It is never reached directly: it advertises itself under the + # deployment's name, so every page a person sees comes from there + keycloak: + image: quay.io/keycloak/keycloak:26.6 + command: start-dev --import-realm + environment: + - KC_HEALTH_ENABLED=true + - KC_HOSTNAME=https://github:9443 + - KC_PROXY_HEADERS=xforwarded + volumes: + - ./realm.json:/opt/keycloak/data/import/realm.json:ro + healthcheck: + test: [ "CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\r\nHost: 127.0.0.1:9000\r\nConnection: close\r\n\r\n' >&3 && grep -q '200 OK' <&3" ] + interval: 2s + timeout: 5s + retries: 60 + + # The deployment itself, serving real TLS under this sandbox's committed + # authority so that the registry reaches it exactly as it would reach the + # real one + github: + build: + context: ./github + dockerfile: Dockerfile + environment: + - GITHUB_PORT=9443 + - GITHUB_PROVIDER=http://keycloak:8080 + - GITHUB_PUBLIC_ORIGIN=https://github:9443 + volumes: + - ./tls:/etc/github/tls:ro + ports: + - "9443:9443" + depends_on: + keycloak: + condition: service_healthy + healthcheck: + test: [ "CMD", "node", "-e", "require('node:https').get({host:'127.0.0.1',port:9443,path:'/api/v3/user',rejectUnauthorized:false},()=>process.exit(0)).on('error',()=>process.exit(1))" ] + interval: 2s + timeout: 5s + retries: 60 + + sandbox: + build: + context: . + dockerfile: Dockerfile + args: + SOURCEMETA_ONE_SANDBOX_EDITION: ${EDITION} + environment: + - SOURCEMETA_ONE_PORT=8001 + env_file: + - environment + depends_on: + github: + condition: service_healthy + ports: + - "${PORT}:8001" diff --git a/enterprise/e2e/auth-github/environment b/enterprise/e2e/auth-github/environment new file mode 100644 index 000000000..e1ad07e66 --- /dev/null +++ b/enterprise/e2e/auth-github/environment @@ -0,0 +1,4 @@ +ONE_E2E_GITHUB_CLIENT_SECRET=registry-client-secret +ONE_E2E_GITHUB_SESSION_SECRET=a-session-signing-secret-for-the-github-sandbox +# The policy gating /unavailable names a variable nothing sets, so a login it +# could never complete is refused before the browser is sent anywhere diff --git a/enterprise/e2e/auth-github/github/Dockerfile b/enterprise/e2e/auth-github/github/Dockerfile new file mode 100644 index 000000000..b6d6de128 --- /dev/null +++ b/enterprise/e2e/auth-github/github/Dockerfile @@ -0,0 +1,3 @@ +FROM node:22-alpine +COPY server.mjs /usr/local/lib/github/server.mjs +CMD [ "node", "/usr/local/lib/github/server.mjs" ] diff --git a/enterprise/e2e/auth-github/github/server.mjs b/enterprise/e2e/auth-github/github/server.mjs new file mode 100644 index 000000000..8633f7e4c --- /dev/null +++ b/enterprise/e2e/auth-github/github/server.mjs @@ -0,0 +1,328 @@ +// A GitHub deployment, as far as a registry signing people in against one can +// tell. It is served under one name over TLS, exactly as the real thing is, and +// it is the only host the browser or the registry ever reaches. +// +// Everything about authenticating a person is a real OAuth 2.0 authorization +// code exchange against the identity provider behind this, sign-in page and +// PKCE included. What is emulated is the surface GitHub puts in front of that: +// the two endpoint paths, the REST API the identity is assembled from, and the +// three places where GitHub departs from what a standard would have. +// +// Those three departures are the point of this file, since they are what an +// implementation is most likely to get wrong: +// +// 1. The API refuses a request that names no user agent, with a 403. +// 2. The token endpoint answers in a form encoding unless a request asks for +// JSON, where RFC 6749 Section 5.1 mandates JSON. +// 3. The token endpoint answers a failure with a 200 carrying an `error` +// member, where RFC 6749 Section 5.2 has a failure carry a 400. +// +// An organisation is a group at the provider, and a team is a group below one, +// so who belongs to what is declared in the realm rather than here. + +import { createServer } from "node:https"; +import { request as httpRequest } from "node:http"; +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; + +const PORT = Number(process.env.GITHUB_PORT ?? 9443); +const PROVIDER = process.env.GITHUB_PROVIDER ?? "http://keycloak:8080"; +const REALM = process.env.GITHUB_REALM ?? "main"; +const PUBLIC_ORIGIN = process.env.GITHUB_PUBLIC_ORIGIN ?? `https://github:${PORT}`; + +const AUTHORIZATION_ENDPOINT = `/realms/${REALM}/protocol/openid-connect/auth`; +const TOKEN_ENDPOINT = `/realms/${REALM}/protocol/openid-connect/token`; +const USERINFO_ENDPOINT = `/realms/${REALM}/protocol/openid-connect/userinfo`; + +// One call to the identity provider behind this, which is the only thing here +// that is not emulated +function upstream(path, options, body) { + const target = new URL(path, PROVIDER); + return new Promise((resolve) => { + const outgoing = httpRequest( + { + hostname: target.hostname, + port: target.port, + path: target.pathname + target.search, + method: options.method ?? "GET", + headers: { + ...options.headers, + host: new URL(PUBLIC_ORIGIN).host, + "x-forwarded-proto": "https", + "x-forwarded-host": new URL(PUBLIC_ORIGIN).host + } + }, + (answer) => { + const chunks = []; + answer.on("data", (chunk) => chunks.push(chunk)); + answer.on("end", () => + resolve({ + status: answer.statusCode, + headers: answer.headers, + body: Buffer.concat(chunks) + }) + ); + } + ); + + outgoing.on("error", () => resolve(null)); + if (body !== undefined) { + outgoing.write(body); + } + + outgoing.end(); + }); +} + +function readBody(incoming) { + return new Promise((resolve) => { + const chunks = []; + incoming.on("data", (chunk) => chunks.push(chunk)); + incoming.on("end", () => resolve(Buffer.concat(chunks))); + }); +} + +// An account identifier is a number that outlives a rename, so one is derived +// from the identifier the provider assigns rather than from the handle +function accountIdentifier(subject) { + return parseInt(createHash("sha256").update(subject).digest("hex").slice(0, 8), 16); +} + +// A group at the provider names an organisation, and a group below one names a +// team within it, which is the whole of the mapping between the two models +function membership(claims) { + const groups = Array.isArray(claims.groups) ? claims.groups : []; + const organizations = []; + const teams = []; + for (const group of groups) { + const segments = group.split("/").filter((segment) => segment.length > 0); + if (segments.length === 1) { + organizations.push({ id: accountIdentifier(segments[0]), login: segments[0] }); + } else if (segments.length === 2) { + teams.push({ + id: accountIdentifier(group), + name: segments[1], + slug: segments[1], + organization: { id: accountIdentifier(segments[0]), login: segments[0] } + }); + } + } + + return { organizations, teams }; +} + +function send(response, status, headers, body) { + response.writeHead(status, headers); + response.end(body); +} + +function sendJSON(response, status, document) { + send(response, status, { "content-type": "application/json; charset=utf-8" }, + JSON.stringify(document)); +} + +// A listing is answered a page at a time, honouring what a request asked for, +// so that whoever reads one has to walk it rather than assume it arrives whole +function paginate(entries, url) { + const size = Math.min(Number(url.searchParams.get("per_page") ?? 30) || 30, 100); + const page = Math.max(Number(url.searchParams.get("page") ?? 1) || 1, 1); + return entries.slice((page - 1) * size, page * size); +} + +async function claimsFor(authorization) { + const answer = await upstream(USERINFO_ENDPOINT, { + headers: { authorization, accept: "application/json" } + }); + if (answer === null || answer.status < 200 || answer.status >= 300) { + return null; + } + + try { + return JSON.parse(answer.body.toString("utf-8")); + } catch { + return null; + } +} + +async function api(incoming, response, url) { + // GitHub refuses a request that names no user agent outright, and says so in + // prose rather than in the representation the rest of the API answers with + if (!incoming.headers["user-agent"]) { + send(response, 403, { "content-type": "text/plain; charset=utf-8" }, + "Request forbidden by administrative rules. Please make sure your " + + "request has a User-Agent header."); + return; + } + + const authorization = incoming.headers.authorization; + if (!authorization) { + sendJSON(response, 401, { message: "Requires authentication" }); + return; + } + + const claims = await claimsFor(authorization); + if (claims === null) { + sendJSON(response, 401, { message: "Bad credentials" }); + return; + } + + const { organizations, teams } = membership(claims); + const path = url.pathname.replace(/^\/api\/v3/, ""); + + if (path === "/user") { + sendJSON(response, 200, { + login: claims.preferred_username, + id: accountIdentifier(claims.sub), + type: "User", + name: claims.name ?? null, + // The address on an account is the public one, which is unset far more + // often than not, so a policy asking about one is made to go and look + email: null + }); + return; + } + + if (path === "/user/emails") { + const address = claims.email; + sendJSON(response, 200, + address === undefined + ? [] + : paginate([{ + email: address, + primary: true, + verified: claims.email_verified === true, + visibility: "private" + }], url)); + return; + } + + if (path === "/user/orgs") { + sendJSON(response, 200, paginate(organizations, url)); + return; + } + + if (path === "/user/teams") { + sendJSON(response, 200, paginate(teams, url)); + return; + } + + sendJSON(response, 404, { message: "Not Found" }); +} + +async function token(incoming, response) { + const body = await readBody(incoming); + const answer = await upstream(TOKEN_ENDPOINT, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "content-length": Buffer.byteLength(body), + accept: "application/json" + } + }, body); + + // What GitHub answers with, rather than what the provider behind this + // answered with: every outcome carries a 200, and a failure names itself in + // the body + let payload; + if (answer === null) { + payload = { error: "server_error" }; + } else { + let document; + try { + document = JSON.parse(answer.body.toString("utf-8")); + } catch { + document = { error: "server_error" }; + } + + payload = answer.status >= 200 && answer.status < 300 + ? { + access_token: document.access_token, + token_type: "bearer", + scope: document.scope ?? "" + } + : { + error: document.error ?? "bad_verification_code", + error_description: document.error_description ?? "", + error_uri: "https://docs.github.com/apps/oauth" + }; + } + + // The default representation is a form encoding, which is what a client that + // did not ask for JSON is answered with + const wants = String(incoming.headers.accept ?? ""); + if (wants.includes("application/json")) { + send(response, 200, { "content-type": "application/json; charset=utf-8" }, + JSON.stringify(payload)); + return; + } + + const encoded = new URLSearchParams(); + for (const [name, value] of Object.entries(payload)) { + encoded.set(name, String(value ?? "")); + } + + send(response, 200, + { "content-type": "application/x-www-form-urlencoded; charset=utf-8" }, + encoded.toString()); +} + +// Everything else is the identity provider itself, served under this name so +// that the browser only ever sees one deployment +async function proxy(incoming, response, url) { + const body = incoming.method === "POST" ? await readBody(incoming) : undefined; + const headers = { ...incoming.headers }; + delete headers.host; + delete headers["accept-encoding"]; + if (body !== undefined) { + headers["content-length"] = Buffer.byteLength(body); + } + + const answer = await upstream(url.pathname + url.search, + { method: incoming.method, headers }, body); + if (answer === null) { + send(response, 502, { "content-type": "text/plain" }, "Bad Gateway"); + return; + } + + send(response, answer.status, answer.headers, answer.body); +} + +const server = createServer( + { + cert: readFileSync("/etc/github/tls/github.crt"), + key: readFileSync("/etc/github/tls/github.key") + }, + (incoming, response) => { + const url = new URL(incoming.url, PUBLIC_ORIGIN); + + // The two endpoint paths GitHub serves, which are the only part of the + // protocol surface that differs from where the provider behind this + // serves them + if (url.pathname === "/login/oauth/authorize") { + const target = new URL(AUTHORIZATION_ENDPOINT, PUBLIC_ORIGIN); + target.search = url.search; + // What the deployment needs of the provider behind it to answer for who + // signed in, which is its own business rather than anything its client + // asked for + const scope = (url.searchParams.get("scope") ?? "").split(" ") + .filter((entry) => entry.length > 0); + target.searchParams.set("scope", ["openid", ...scope].join(" ")); + send(response, 302, { location: target.toString() }, ""); + return; + } + + if (url.pathname === "/login/oauth/access_token") { + token(incoming, response); + return; + } + + if (url.pathname.startsWith("/api/v3/")) { + api(incoming, response, url); + return; + } + + proxy(incoming, response, url); + } +); + +server.listen(PORT, "0.0.0.0"); diff --git a/enterprise/e2e/auth-github/hurl/denial.all.hurl b/enterprise/e2e/auth-github/hurl/denial.all.hurl new file mode 100644 index 000000000..079558aa4 --- /dev/null +++ b/enterprise/e2e/auth-github/hurl/denial.all.hurl @@ -0,0 +1,146 @@ +# An account the deployment authenticated, whom the policy does not admit. +# +# This account belongs to another organisation entirely, so the deployment +# vouches for it and the policy still refuses. That is the end of the road +# rather than something to try again, so no session is minted and the catalog +# stays where it was +GET {{base}}/self/v1/auth/login/engineering +HTTP 303 +[Captures] +authorize_url: header "Location" + +GET {{authorize_url}} +HTTP 302 +[Captures] +provider_url: header "Location" + +GET {{provider_url}} +HTTP 200 +[Captures] +login_action: xpath "string(//form[@id='kc-form-login']/@action)" + +POST {{login_action}} +[FormParams] +username: hubot +password: hubot-password +HTTP 302 +[Captures] +callback_url: header "Location" + +GET {{callback_url}} +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:auth-not-admitted", + "title": "Forbidden", + "status": 403, + "detail": "This account is not admitted here" +} + +GET {{base}}/private/secret.json +HTTP 404 +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:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} + +# A callback carrying no transaction belongs to no login this instance started, +# and one carrying a state nothing echoed is the same. Both are refused before +# anything the deployment said is honoured +GET {{base}}/self/v1/auth/callback/engineering?code=invented&state=invented +HTTP 400 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Captures] +invalid_body: body +invalid_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "Set-Cookie" not exists +{ + "type": "urn:sourcemeta:one:auth-invalid-callback", + "title": "Bad Request", + "status": 400, + "detail": "The login could not be completed" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{invalid_schema}} +``` +{{invalid_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +# A name no policy answers to reveals nothing beyond that +GET {{base}}/self/v1/auth/login/invented +HTTP 404 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +{ + "type": "urn:sourcemeta:one:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} + +# A login that cannot be completed only strands the person at the deployment, +# so a policy whose client secret is unset is refused before the browser is +# sent anywhere, and every reason a login cannot start reads alike +GET {{base}}/self/v1/auth/login/no-client-secret +HTTP 500 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Captures] +unavailable_body: body +[Asserts] +header "Set-Cookie" not exists +{ + "type": "urn:sourcemeta:one:auth-unavailable", + "title": "Internal Server Error", + "status": 500, + "detail": "This login cannot be started" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{invalid_schema}} +``` +{{unavailable_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +GET {{base}}/unavailable/thing.json +HTTP 404 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +{ + "type": "urn:sourcemeta:one:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} diff --git a/enterprise/e2e/auth-github/hurl/deployment.all.hurl b/enterprise/e2e/auth-github/hurl/deployment.all.hurl new file mode 100644 index 000000000..97dc6e370 --- /dev/null +++ b/enterprise/e2e/auth-github/hurl/deployment.all.hurl @@ -0,0 +1,62 @@ +# The three places a GitHub deployment departs from what a standard would have, +# asked of the deployment directly. +# +# These are not assertions about the registry. They are what pins the sandbox to +# the behaviour it is standing in for, so that the flow tests beside them prove +# the registry copes with a deployment that really does behave this way rather +# than with one that was quietly made easier to talk to + +# The API refuses a request that names no user agent, and says so in prose +# rather than in the representation the rest of the API answers with +GET https://github:9443/api/v3/user +User-Agent: +HTTP 403 +Content-Type: text/plain; charset=utf-8 +[Asserts] +body contains "User-Agent" + +# Naming one gets past that, and the credential is then what is missing +GET https://github:9443/api/v3/user +User-Agent: sourcemeta-one +HTTP 401 +Content-Type: application/json; charset=utf-8 +[Asserts] +jsonpath "$.message" == "Requires authentication" + +# The token endpoint answers every outcome with a 200 and names a failure in +# the body, where RFC 6749 Section 5.2 has a failure carry a 400 +POST https://github:9443/login/oauth/access_token +Accept: application/json +[FormParams] +grant_type: authorization_code +code: invented +redirect_uri: http://localhost:8000/self/v1/auth/callback/engineering +client_id: registry +client_secret: registry-client-secret +HTTP 200 +Content-Type: application/json; charset=utf-8 +[Asserts] +jsonpath "$.error" exists +jsonpath "$.access_token" not exists + +# And it answers in a form encoding unless a request asks for JSON, where +# RFC 6749 Section 5.1 mandates JSON +POST https://github:9443/login/oauth/access_token +[FormParams] +grant_type: authorization_code +code: invented +redirect_uri: http://localhost:8000/self/v1/auth/callback/engineering +client_id: registry +client_secret: registry-client-secret +HTTP 200 +Content-Type: application/x-www-form-urlencoded; charset=utf-8 +[Asserts] +body contains "error=" + +# There is nothing to discover, which is why a policy names an origin rather +# than an issuer and composes both endpoints from it +GET https://github:9443/.well-known/openid-configuration +HTTP 404 + +GET https://github:9443/.well-known/oauth-authorization-server +HTTP 404 diff --git a/enterprise/e2e/auth-github/hurl/login.all.hurl b/enterprise/e2e/auth-github/hurl/login.all.hurl new file mode 100644 index 000000000..ca11e4e78 --- /dev/null +++ b/enterprise/e2e/auth-github/hurl/login.all.hurl @@ -0,0 +1,274 @@ +# The public catalog is served to everyone, no login required +GET {{base}}/public/string.json +HTTP 200 +Cache-Control: public, max-age=0, must-revalidate +Content-Type: application/schema+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +[Asserts] +header "Vary" == "User-Agent, Accept-Encoding" +header "ETag" exists +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "{{base}}/public/string", + "type": "string" +} + +# A policy signing people in through a deployment is an interactive policy like +# any other, so it grows the way in and appears on the login page +GET {{base}}/public/ +Accept: text/html +HTTP 200 +Cache-Control: public, max-age=0, must-revalidate +Content-Type: text/html; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: frame-ancestors 'none' +X-Frame-Options: DENY +[Asserts] +header "Vary" == "Accept, Accept-Encoding" +header "Access-Control-Allow-Origin" not exists +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}}/self/v1/auth/login +Accept: application/json +HTTP 200 +Cache-Control: public, max-age=0, must-revalidate +Content-Type: application/json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +[Captures] +login_body: body +login_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "Vary" == "Accept, Accept-Encoding" +header "Set-Cookie" not exists +jsonpath "$.title" == "GitHub Sandbox" +jsonpath "$.providers" count == 5 +jsonpath "$.providers[0].name" == "engineering" +jsonpath "$.providers[0].title" == "GitHub" +jsonpath "$.providers[0].path" == "/self/v1/auth/login/engineering" +jsonpath "$.providers[1].name" == "platform" +jsonpath "$.providers[2].name" == "personal" +jsonpath "$.providers[3].name" == "corp" +jsonpath "$.providers[4].name" == "no-client-secret" + +POST {{base}}/self/v1/api/schemas/evaluate{{login_schema}} +``` +{{login_body}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +# The private catalog is not there for an anonymous caller +GET {{base}}/private/secret.json +HTTP 404 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Captures] +error_schema: header "Link" regex "<([^>]+)>" +anonymous_denied: body +[Asserts] +header "Set-Cookie" not exists +{ + "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{{error_schema}} +``` +{{anonymous_denied}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +# A deployment publishes nothing to discover, so where a login begins is +# composed from the origin the policy names rather than fetched. It carries no +# nonce, since nothing in this flow issues a token to bind one to, and it asks +# for exactly the scope the policy's one rule needs +GET {{base}}/self/v1/auth/login/engineering +HTTP 303 +Cache-Control: no-store +[Captures] +authorize_url: header "Location" +[Asserts] +header "Location" startsWith "https://github:9443/login/oauth/authorize?" +header "Location" contains "response_type=code" +header "Location" contains "client_id=registry" +header "Location" contains "scope=read%3Aorg" +header "Location" contains "code_challenge_method=S256" +header "Location" contains "state=" +header "Location" not contains "nonce=" +header "Location" not contains "prompt=" +header "Set-Cookie" startsWith "sourcemeta_one_transaction=" +cookie "sourcemeta_one_session" not exists +cookie "sourcemeta_one_renewal" not exists +cookie "sourcemeta_one_transaction[Max-Age]" == 600 +cookie "sourcemeta_one_transaction[HttpOnly]" exists +cookie "sourcemeta_one_transaction[SameSite]" == "Lax" + +# The deployment serves the authorization endpoint under the two paths GitHub +# serves it at, and takes the browser on to where it authenticates people +GET {{authorize_url}} +HTTP 302 +[Captures] +provider_url: header "Location" +[Asserts] +header "Location" startsWith "https://github:9443/realms/main/protocol/openid-connect/auth?" + +GET {{provider_url}} +HTTP 200 +[Captures] +login_action: xpath "string(//form[@id='kc-form-login']/@action)" + +# Submitting the account's credentials sends the browser back to the callback +# with an authorization code +POST {{login_action}} +[FormParams] +username: octocat +password: octocat-password +HTTP 302 +[Captures] +callback_url: header "Location" +[Asserts] +header "Location" startsWith "http://localhost:8000/self/v1/auth/callback/engineering?" +header "Location" contains "code=" +header "Location" contains "state=" + +# The callback redeems the code, asks the deployment who the token was issued +# for and what they belong to, and establishes the session. +# +# Two cookies rather than three: the session and the spent transaction. A +# deployment cannot be asked whether a sign-in still stands without showing the +# person its own pages, so nothing here earns a browser a silent renewal +GET {{callback_url}} +HTTP 303 +Location: /private +Cache-Control: no-store +[Asserts] +header "Set-Cookie" count == 2 +cookie "sourcemeta_one_session" exists +cookie "sourcemeta_one_session[Max-Age]" == 3600 +cookie "sourcemeta_one_session[HttpOnly]" exists +cookie "sourcemeta_one_session[SameSite]" == "Lax" +cookie "sourcemeta_one_transaction[Max-Age]" == 0 +cookie "sourcemeta_one_renewal" not exists + +# 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 +HTTP 200 +Cache-Control: private, max-age=0, must-revalidate +Content-Type: application/schema+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +[Asserts] +header "Vary" == "User-Agent, Accept-Encoding" +header "ETag" exists +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "{{base}}/private/secret", + "type": "object", + "properties": { + "label": { + "$ref": "../public/string" + } + } +} + +# The one session opens every collection the policy governs, not only the one +# the login landed on +GET {{base}}/archive/record.json +HTTP 200 +Cache-Control: private, max-age=0, must-revalidate +Content-Type: application/schema+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +[Asserts] +header "ETag" exists +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "{{base}}/archive/record", + "type": "array", + "items": { + "$ref": "../private/secret" + } +} + +# The root listing under the session marks what the session opens as private, +# and what it does not open is absent rather than locked +GET {{base}}/ +Accept: text/html +HTTP 200 +Cache-Control: private, max-age=0, must-revalidate +Content-Type: text/html; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: frame-ancestors 'none' +X-Frame-Options: DENY +[Asserts] +header "Vary" == "Accept, Accept-Encoding" +xpath "count(//i[contains(@class, 'bi-lock-fill')])" == 2 +xpath "count(//tr[.//a[normalize-space(.) = 'private']]//i[contains(@class, 'bi-lock-fill')])" == 1 +xpath "count(//tr[.//a[normalize-space(.) = 'archive']]//i[contains(@class, 'bi-lock-fill')])" == 1 +xpath "count(//tr[.//a[normalize-space(.) = 'public']]//i[contains(@class, 'bi-folder-fill')])" == 1 +xpath "count(//tr[.//a[normalize-space(.) = 'team']])" == 0 +xpath "count(//tr[.//a[normalize-space(.) = 'corp']])" == 0 +xpath "count(//a[@data-sourcemeta-ui-signin])" == 0 +xpath "count(//button[@data-sourcemeta-ui-signout])" == 1 + +# A deployment offers nowhere to end its own session, so signing out clears +# what this instance minted and leaves the browser here rather than sending it +# on. The renewal cookie is expired alongside the rest, though no login through +# a deployment ever set one +POST {{base}}/self/v1/auth/logout +HTTP 303 +Cache-Control: no-store +Location: / +[Asserts] +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 + +# And the private catalog is gone again, byte-identical to the first time +GET {{base}}/private/secret.json +HTTP 404 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Captures] +final_denied: body +[Asserts] +header "Set-Cookie" not exists +{ + "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{{error_schema}} +``` +{{final_denied}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true diff --git a/enterprise/e2e/auth-github/hurl/rule-account-denied.all.hurl b/enterprise/e2e/auth-github/hurl/rule-account-denied.all.hurl new file mode 100644 index 000000000..1f9bc6ce2 --- /dev/null +++ b/enterprise/e2e/auth-github/hurl/rule-account-denied.all.hurl @@ -0,0 +1,71 @@ +# A policy naming accounts admits the accounts it names and no other, whatever +# else the deployment vouches for about them + +GET {{base}}/self/v1/auth/login/personal +HTTP 303 +Cache-Control: no-store +[Captures] +authorize_url: header "Location" +[Asserts] +header "Location" not contains "scope=" + +GET {{authorize_url}} +HTTP 302 +[Captures] +provider_url: header "Location" + +GET {{provider_url}} +HTTP 200 +[Captures] +login_action: xpath "string(//form[@id='kc-form-login']/@action)" + +POST {{login_action}} +[FormParams] +username: mona +password: mona-password +HTTP 302 +[Captures] +callback_url: header "Location" + +GET {{callback_url}} +HTTP 403 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Captures] +not_admitted: body +admission_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "Set-Cookie" not exists +{ + "type": "urn:sourcemeta:one:auth-not-admitted", + "title": "Forbidden", + "status": 403, + "detail": "This account is not admitted here" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{admission_schema}} +``` +{{not_admitted}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +GET {{base}}/desk/ticket.json +HTTP 404 +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:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} diff --git a/enterprise/e2e/auth-github/hurl/rule-domain-denied.all.hurl b/enterprise/e2e/auth-github/hurl/rule-domain-denied.all.hurl new file mode 100644 index 000000000..a15b7115b --- /dev/null +++ b/enterprise/e2e/auth-github/hurl/rule-domain-denied.all.hurl @@ -0,0 +1,71 @@ +# An address at another domain is refused, which is the same answer whatever +# organisation the account belongs to + +GET {{base}}/self/v1/auth/login/corp +HTTP 303 +Cache-Control: no-store +[Captures] +authorize_url: header "Location" +[Asserts] +header "Location" contains "scope=user%3Aemail" + +GET {{authorize_url}} +HTTP 302 +[Captures] +provider_url: header "Location" + +GET {{provider_url}} +HTTP 200 +[Captures] +login_action: xpath "string(//form[@id='kc-form-login']/@action)" + +POST {{login_action}} +[FormParams] +username: hubot +password: hubot-password +HTTP 302 +[Captures] +callback_url: header "Location" + +GET {{callback_url}} +HTTP 403 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Captures] +not_admitted: body +admission_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "Set-Cookie" not exists +{ + "type": "urn:sourcemeta:one:auth-not-admitted", + "title": "Forbidden", + "status": 403, + "detail": "This account is not admitted here" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{admission_schema}} +``` +{{not_admitted}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +GET {{base}}/corp/policy.json +HTTP 404 +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:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} diff --git a/enterprise/e2e/auth-github/hurl/rule-domain.all.hurl b/enterprise/e2e/auth-github/hurl/rule-domain.all.hurl new file mode 100644 index 000000000..3c41a5525 --- /dev/null +++ b/enterprise/e2e/auth-github/hurl/rule-domain.all.hurl @@ -0,0 +1,55 @@ +# A policy naming a domain is answered against the addresses an account holds, +# since the address on the account itself is the public one and is frequently +# unset + +GET {{base}}/self/v1/auth/login/corp +HTTP 303 +Cache-Control: no-store +[Captures] +authorize_url: header "Location" +[Asserts] +header "Location" contains "scope=user%3Aemail" + +GET {{authorize_url}} +HTTP 302 +[Captures] +provider_url: header "Location" + +GET {{provider_url}} +HTTP 200 +[Captures] +login_action: xpath "string(//form[@id='kc-form-login']/@action)" + +POST {{login_action}} +[FormParams] +username: octocat +password: octocat-password +HTTP 302 +[Captures] +callback_url: header "Location" + +GET {{callback_url}} +HTTP 303 +Location: /corp +Cache-Control: no-store +[Asserts] +cookie "sourcemeta_one_session" exists +cookie "sourcemeta_one_renewal" not exists + +GET {{base}}/corp/policy.json +HTTP 200 +Cache-Control: private, max-age=0, must-revalidate +Content-Type: application/schema+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "{{base}}/corp/policy", + "type": "object", + "properties": { + "name": { + "$ref": "../public/string" + } + } +} diff --git a/enterprise/e2e/auth-github/hurl/rule-team-denied.all.hurl b/enterprise/e2e/auth-github/hurl/rule-team-denied.all.hurl new file mode 100644 index 000000000..2d6f1ecbe --- /dev/null +++ b/enterprise/e2e/auth-github/hurl/rule-team-denied.all.hurl @@ -0,0 +1,71 @@ +# Belonging to the organisation that holds a team is not belonging to the team, +# so an account in the organisation and nothing below it is refused + +GET {{base}}/self/v1/auth/login/platform +HTTP 303 +Cache-Control: no-store +[Captures] +authorize_url: header "Location" +[Asserts] +header "Location" contains "scope=read%3Aorg" + +GET {{authorize_url}} +HTTP 302 +[Captures] +provider_url: header "Location" + +GET {{provider_url}} +HTTP 200 +[Captures] +login_action: xpath "string(//form[@id='kc-form-login']/@action)" + +POST {{login_action}} +[FormParams] +username: mona +password: mona-password +HTTP 302 +[Captures] +callback_url: header "Location" + +GET {{callback_url}} +HTTP 403 +Cache-Control: no-store +Content-Type: application/problem+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +[Captures] +not_admitted: body +admission_schema: header "Link" regex "<([^>]+)>" +[Asserts] +header "Set-Cookie" not exists +{ + "type": "urn:sourcemeta:one:auth-not-admitted", + "title": "Forbidden", + "status": 403, + "detail": "This account is not admitted here" +} + +POST {{base}}/self/v1/api/schemas/evaluate{{admission_schema}} +``` +{{not_admitted}} +``` +HTTP 200 +Cache-Control: no-store +Link: ; rel="describedby" +[Asserts] +jsonpath "$.valid" == true + +GET {{base}}/team/roster.json +HTTP 404 +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:not-found", + "title": "Not Found", + "status": 404, + "detail": "There is nothing at this URL" +} diff --git a/enterprise/e2e/auth-github/hurl/rule-team.all.hurl b/enterprise/e2e/auth-github/hurl/rule-team.all.hurl new file mode 100644 index 000000000..45ff55c55 --- /dev/null +++ b/enterprise/e2e/auth-github/hurl/rule-team.all.hurl @@ -0,0 +1,55 @@ +# A policy naming a team admits a member of it, and nobody else. +# +# Each of these files stands for a fresh browser: a deployment offers nowhere to +# end its own session, so signing in as a second account within one of them +# would be answered with the first account's sign-in rather than a form + +GET {{base}}/self/v1/auth/login/platform +HTTP 303 +Cache-Control: no-store +[Captures] +authorize_url: header "Location" +[Asserts] +header "Location" contains "scope=read%3Aorg" + +GET {{authorize_url}} +HTTP 302 +[Captures] +provider_url: header "Location" + +GET {{provider_url}} +HTTP 200 +[Captures] +login_action: xpath "string(//form[@id='kc-form-login']/@action)" + +POST {{login_action}} +[FormParams] +username: octocat +password: octocat-password +HTTP 302 +[Captures] +callback_url: header "Location" + +GET {{callback_url}} +HTTP 303 +Location: /team +Cache-Control: no-store +[Asserts] +cookie "sourcemeta_one_session" exists +cookie "sourcemeta_one_renewal" not exists + +GET {{base}}/team/roster.json +HTTP 200 +Cache-Control: private, max-age=0, must-revalidate +Content-Type: application/schema+json +Link: ; rel="describedby" +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Link, ETag +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "{{base}}/team/roster", + "type": "array", + "items": { + "$ref": "../public/string" + } +} diff --git a/enterprise/e2e/auth-github/one.json b/enterprise/e2e/auth-github/one.json new file mode 100644 index 000000000..3a3d9be34 --- /dev/null +++ b/enterprise/e2e/auth-github/one.json @@ -0,0 +1,134 @@ +{ + "url": "http://localhost:8000", + "html": { + "name": "GitHub Sandbox", + "description": "A registry whose private catalogs are gated by policies that sign people in through a GitHub deployment" + }, + "authentication": [ + { + "type": "github", + "name": "engineering", + "title": "GitHub", + "paths": [ + "/private", + "/archive" + ], + "host": "https://github:9443", + "clientId": "registry", + "clientSecret": { + "environmentVariable": "ONE_E2E_GITHUB_CLIENT_SECRET" + }, + "sessionSecrets": [ + { + "environmentVariable": "ONE_E2E_GITHUB_SESSION_SECRET" + } + ], + "organizations": [ + "acme" + ] + }, + { + "type": "github", + "name": "platform", + "paths": [ + "/team" + ], + "host": "https://github:9443", + "clientId": "registry", + "clientSecret": { + "environmentVariable": "ONE_E2E_GITHUB_CLIENT_SECRET" + }, + "sessionSecrets": [ + { + "environmentVariable": "ONE_E2E_GITHUB_SESSION_SECRET" + } + ], + "teams": [ + "acme/platform" + ] + }, + { + "type": "github", + "name": "personal", + "paths": [ + "/desk" + ], + "host": "https://github:9443", + "clientId": "registry", + "clientSecret": { + "environmentVariable": "ONE_E2E_GITHUB_CLIENT_SECRET" + }, + "sessionSecrets": [ + { + "environmentVariable": "ONE_E2E_GITHUB_SESSION_SECRET" + } + ], + "users": [ + "octocat" + ] + }, + { + "type": "github", + "name": "corp", + "paths": [ + "/corp" + ], + "host": "https://github:9443", + "clientId": "registry", + "clientSecret": { + "environmentVariable": "ONE_E2E_GITHUB_CLIENT_SECRET" + }, + "sessionSecrets": [ + { + "environmentVariable": "ONE_E2E_GITHUB_SESSION_SECRET" + } + ], + "emailDomains": [ + "acme.test" + ] + }, + { + "type": "github", + "name": "no-client-secret", + "paths": [ + "/unavailable" + ], + "host": "https://github:9443", + "clientId": "registry", + "clientSecret": { + "environmentVariable": "ONE_E2E_GITHUB_ABSENT_CLIENT_SECRET" + }, + "sessionSecrets": [ + { + "environmentVariable": "ONE_E2E_GITHUB_SESSION_SECRET" + } + ], + "users": [ + "octocat" + ] + } + ], + "contents": { + "public": { + "path": "./schemas/public" + }, + "private": { + "path": "./schemas/private" + }, + "archive": { + "path": "./schemas/archive" + }, + "team": { + "path": "./schemas/team" + }, + "desk": { + "path": "./schemas/desk" + }, + "corp": { + "path": "./schemas/corp" + }, + "unavailable": { + "path": "./schemas/unavailable" + } + } +} diff --git a/enterprise/e2e/auth-github/playwright/login.spec.js b/enterprise/e2e/auth-github/playwright/login.spec.js new file mode 100644 index 000000000..fe82808ca --- /dev/null +++ b/enterprise/e2e/auth-github/playwright/login.spec.js @@ -0,0 +1,140 @@ +import { test, expect } from '@playwright/test'; + +// Signing in through a GitHub deployment, from a browser. +// +// The four policies each gate one collection and each name one kind of rule, so +// what a given account reaches says which rule admitted them. The deployment +// holds three accounts: one in the acme organisation and its platform team, one +// in acme alone, and one in another organisation entirely. + +async function signIn(page, policy, account, password) { + await page.goto('/self/v1/auth/login'); + await page.locator(`a[data-sourcemeta-ui-login="${policy}"]`).click(); + await page.locator('#username').fill(account); + await page.locator('#password').fill(password); + await page.locator('#kc-login').click(); +} + +test.describe('Signing in through a GitHub deployment', () => { + test('open areas are browsable without any login', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/Schemas/); + await page.locator('table a', { hasText: 'public' }).first().click(); + await expect(page).toHaveURL(/\/public\/$/); + }); + + test('the login page names every policy that signs a person in', async ({ + page + }) => { + await page.goto('/self/v1/auth/login'); + await expect(page).toHaveTitle('Sign In'); + await expect(page.locator('a[data-sourcemeta-ui-login]')).toHaveCount(5); + const first = page.locator('a[data-sourcemeta-ui-login="engineering"]'); + await expect(first).toBeVisible(); + await expect(first).toHaveText('GitHub'); + }); + + test('a gated collection is not there for a stranger', async ({ page }) => { + const response = await page.goto('/private/'); + expect(response.status()).toBe(404); + await expect(page).toHaveTitle('Not Found'); + expect(response.headers()['www-authenticate']).toBeUndefined(); + }); + + test('the browser never leaves the deployment while signing in', async ({ + page + }) => { + const hosts = []; + page.on('request', (request) => { + if (request.isNavigationRequest()) { + hosts.push(new URL(request.url()).host); + } + }); + + await signIn(page, 'engineering', 'octocat', 'octocat-password'); + await expect(page).toHaveURL(/\/private$/); + + // Every navigation went either to this instance or to the deployment, and + // to nothing behind it + expect([...new Set(hosts)].sort()).toEqual([ + 'github:9443', + new URL(process.env.PLAYWRIGHT_BASE_URL).host + ]); + }); + + test('an account in the organisation reaches what the policy governs', async ({ + page + }) => { + await signIn(page, 'engineering', 'octocat', 'octocat-password'); + await expect(page).toHaveURL(/\/private$/); + await expect(page.locator('table tbody tr').first()).toBeVisible(); + + // The policy governs two collections, and the session reaches both + await page.goto('/archive/'); + await expect(page.locator('table tbody tr').first()).toBeVisible(); + const deep = await page.goto('/private/secret'); + expect(deep.status()).toBe(200); + }); + + test('an account in another organisation is refused, and is told so', async ({ + page + }) => { + await signIn(page, 'engineering', 'hubot', 'hubot-password'); + await expect(page).toHaveURL(/\/self\/v1\/auth\/callback\/engineering/); + await expect(page.locator('body')).toContainText( + 'This account is not admitted here' + ); + + // And nothing was established by being told so + const denied = await page.goto('/private/'); + expect(denied.status()).toBe(404); + }); + + test('a team rule admits a member of the team and nobody above it', async ({ + page + }) => { + await signIn(page, 'platform', 'octocat', 'octocat-password'); + await expect(page).toHaveURL(/\/team$/); + await expect(page.locator('table tbody tr').first()).toBeVisible(); + }); + + test('the signed-in listing names what the session opens', async ({ + page + }) => { + await signIn(page, 'engineering', 'octocat', 'octocat-password'); + await page.goto('/'); + + await expect( + page.locator('table a', { hasText: 'private' }).first() + ).toBeVisible(); + await expect( + page.locator('table a', { hasText: 'archive' }).first() + ).toBeVisible(); + // What this session does not open is absent rather than locked + await expect(page.locator('table a', { hasText: 'team' })).toHaveCount(0); + await expect(page.locator('table a', { hasText: 'corp' })).toHaveCount(0); + }); + + test('the bar offers the way out once signed in, and it works', async ({ + page + }) => { + await signIn(page, 'engineering', 'octocat', 'octocat-password'); + await expect(page.locator('table tbody tr').first()).toBeVisible(); + 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 control.click(); + + // A deployment offers nowhere to end its own session, so signing out lands + // back here rather than going on anywhere + await page.waitForURL((url) => !url.pathname.startsWith('/private')); + expect(new URL(page.url()).host).toBe( + new URL(process.env.PLAYWRIGHT_BASE_URL).host + ); + + const response = await page.goto('/private/'); + expect(response.status()).toBe(404); + await expect(page.locator('a[data-sourcemeta-ui-signin]')).toHaveCount(1); + }); +}); diff --git a/enterprise/e2e/auth-github/playwright/playwright.config.js b/enterprise/e2e/auth-github/playwright/playwright.config.js new file mode 100644 index 000000000..3e9574c13 --- /dev/null +++ b/enterprise/e2e/auth-github/playwright/playwright.config.js @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; + +// See https://playwright.dev/docs/test-configuration +export default defineConfig({ + testDir: '.', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'list', + outputDir: '../../../../build/test-results', + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL, + trace: 'on-first-retry', + // The deployment's certificate chains to a sandbox-local authority the + // browser does not know, so certificate errors are tolerated here while the + // registry container verifies the chain for real + ignoreHTTPSErrors: true + }, + // Chromium only: the redirect chain relies on a Chromium-specific host + // resolver rule, so the suite never runs under Firefox or WebKit + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // The deployment is served under `github:9443`, so the browser must + // resolve that container name to the mapped local port to follow the + // redirect, exactly as a developer would via /etc/hosts + launchOptions: { + args: ['--host-resolver-rules=MAP github 127.0.0.1'] + } + } + } + ] +}); diff --git a/enterprise/e2e/auth-github/playwright/session.spec.js b/enterprise/e2e/auth-github/playwright/session.spec.js new file mode 100644 index 000000000..b454d547f --- /dev/null +++ b/enterprise/e2e/auth-github/playwright/session.spec.js @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +// What the browser is left holding after signing in through a GitHub +// deployment, which is where this differs visibly from an OpenID Connect +// provider. + +test.describe('What a session through a deployment holds', () => { + test('the session carries the account and never the credential', async ({ + page, + context + }) => { + await page.goto('/self/v1/auth/login'); + await page.locator('a[data-sourcemeta-ui-login="engineering"]').click(); + await page.locator('#username').fill('octocat'); + await page.locator('#password').fill('octocat-password'); + await page.locator('#kc-login').click(); + await expect(page).toHaveURL(/\/private$/); + + const cookies = await context.cookies(); + const names = cookies + .filter((cookie) => cookie.name.startsWith('sourcemeta_one_')) + .map((cookie) => cookie.name); + + // A deployment cannot be asked whether a sign-in still stands without + // showing the person its own pages, so nothing here earns a browser the + // marker that would send it back there on its own + expect(names).toEqual(['sourcemeta_one_session']); + + const session = cookies.find( + (cookie) => cookie.name === 'sourcemeta_one_session' + ); + expect(session.httpOnly).toBe(true); + + // The sealed payload names the policy and the account identifier, and + // carries no access token: that credential reaches the person's own + // repositories and is spent during the callback rather than stored + const payload = JSON.parse( + Buffer.from(session.value.split('.')[3], 'base64url').toString('utf-8') + ); + expect(Object.keys(payload).sort()).toEqual(['policy', 'subject']); + expect(payload.policy).toBe('engineering'); + expect(payload.subject).toMatch(/^[0-9]+$/); + }); +}); diff --git a/enterprise/e2e/auth-github/realm.json b/enterprise/e2e/auth-github/realm.json new file mode 100644 index 000000000..3bb10a207 --- /dev/null +++ b/enterprise/e2e/auth-github/realm.json @@ -0,0 +1,197 @@ +{ + "realm": "main", + "enabled": true, + "sslRequired": "none", + "groups": [ + { + "name": "acme", + "subGroups": [ + { + "name": "platform" + } + ] + }, + { + "name": "contoso" + } + ], + "clientScopes": [ + { + "name": "account", + "description": "The handle the deployment knows an account by", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "name": "read:org", + "description": "Read the organisations and teams an account belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "claim.name": "groups", + "full.path": "true", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "name": "user:email", + "description": "Read the addresses an account holds", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "email", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "emailVerified", + "claim.name": "email_verified", + "jsonType.label": "boolean", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "clients": [ + { + "clientId": "registry", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "registry-client-secret", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8000/self/v1/auth/callback/engineering", + "http://localhost:8000/self/v1/auth/callback/platform", + "http://localhost:8000/self/v1/auth/callback/personal", + "http://localhost:8000/self/v1/auth/callback/corp" + ], + "fullScopeAllowed": false, + "defaultClientScopes": [ + "basic", + "account" + ], + "optionalClientScopes": [ + "read:org", + "user:email" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + } + } + ], + "users": [ + { + "username": "octocat", + "enabled": true, + "emailVerified": true, + "email": "octocat@acme.test", + "firstName": "Octo", + "lastName": "Cat", + "credentials": [ + { + "type": "password", + "value": "octocat-password", + "temporary": false + } + ], + "groups": [ + "/acme", + "/acme/platform" + ] + }, + { + "username": "mona", + "enabled": true, + "emailVerified": true, + "email": "mona@acme.test", + "firstName": "Mona", + "lastName": "Lisa", + "credentials": [ + { + "type": "password", + "value": "mona-password", + "temporary": false + } + ], + "groups": [ + "/acme" + ] + }, + { + "username": "hubot", + "enabled": true, + "emailVerified": true, + "email": "hubot@other.test", + "firstName": "Hu", + "lastName": "Bot", + "credentials": [ + { + "type": "password", + "value": "hubot-password", + "temporary": false + } + ], + "groups": [ + "/contoso" + ] + } + ] +} diff --git a/enterprise/e2e/auth-github/schemas/archive/record.json b/enterprise/e2e/auth-github/schemas/archive/record.json new file mode 100644 index 000000000..5add26761 --- /dev/null +++ b/enterprise/e2e/auth-github/schemas/archive/record.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "array", + "items": { + "$ref": "../private/secret" + } +} diff --git a/enterprise/e2e/auth-github/schemas/corp/policy.json b/enterprise/e2e/auth-github/schemas/corp/policy.json new file mode 100644 index 000000000..a189004ed --- /dev/null +++ b/enterprise/e2e/auth-github/schemas/corp/policy.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "$ref": "../public/string" + } + } +} diff --git a/enterprise/e2e/auth-github/schemas/desk/ticket.json b/enterprise/e2e/auth-github/schemas/desk/ticket.json new file mode 100644 index 000000000..2bad05129 --- /dev/null +++ b/enterprise/e2e/auth-github/schemas/desk/ticket.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "summary": { + "$ref": "../public/string" + } + } +} diff --git a/enterprise/e2e/auth-github/schemas/private/secret.json b/enterprise/e2e/auth-github/schemas/private/secret.json new file mode 100644 index 000000000..eab093523 --- /dev/null +++ b/enterprise/e2e/auth-github/schemas/private/secret.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "label": { + "$ref": "../public/string" + } + } +} diff --git a/enterprise/e2e/auth-github/schemas/public/string.json b/enterprise/e2e/auth-github/schemas/public/string.json new file mode 100644 index 000000000..d423e9e6c --- /dev/null +++ b/enterprise/e2e/auth-github/schemas/public/string.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string" +} diff --git a/enterprise/e2e/auth-github/schemas/team/roster.json b/enterprise/e2e/auth-github/schemas/team/roster.json new file mode 100644 index 000000000..6e5644af3 --- /dev/null +++ b/enterprise/e2e/auth-github/schemas/team/roster.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "array", + "items": { + "$ref": "../public/string" + } +} diff --git a/enterprise/e2e/auth-github/schemas/unavailable/thing.json b/enterprise/e2e/auth-github/schemas/unavailable/thing.json new file mode 100644 index 000000000..695daf121 --- /dev/null +++ b/enterprise/e2e/auth-github/schemas/unavailable/thing.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "boolean" +} diff --git a/enterprise/e2e/auth-github/tls/ca.crt b/enterprise/e2e/auth-github/tls/ca.crt new file mode 100644 index 000000000..80cd1998e --- /dev/null +++ b/enterprise/e2e/auth-github/tls/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTTCCAjWgAwIBAgIUJvG/4so+g2cMmTDPbmEPoYhvwPgwDQYJKoZIhvcNAQEL +BQAwLjEsMCoGA1UEAwwjU291cmNlbWV0YSBPbmUgRW5kLXRvLUVuZCBBdXRob3Jp +dHkwHhcNMjYwODI0MTY1MjQxWhcNMzYwODIxMTY1MjQxWjAuMSwwKgYDVQQDDCNT +b3VyY2VtZXRhIE9uZSBFbmQtdG8tRW5kIEF1dGhvcml0eTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAL5w3xEL60O0l56UKIilmTVEqLT2dh3JLeSUhWGF +5ZZfS8aPydnvI0dGU4gsdTwYQCod3uymB6+bNOw7TAxBKYxz9ygxvNVOrhYzNofs +0zrAa75R+TcAiAV7oNV0avwGtQFbzrlJCL40CfOes1OAYTxwX8OvWbAnEzwZ43tv +B/6n4b2FkYjqoS0g+++DZXotIoPF9KSTSpaV0w6V7VI/hopWinN5Ct3u3xuuFL04 +2x79ECGc6kciKOwOas3ejbKo3CaG77SwjxQ+/qHWOmpvVmEss10AW8iErCi4hBx8 +Ne2ryYj2FbnMqNt6ncvEfm7/JBNeSt+OhZjpJ2lJ9bJqDMUCAwEAAaNjMGEwHQYD +VR0OBBYEFJ5z0Tx0u02pB52/vYs8eTgqyaNRMB8GA1UdIwQYMBaAFJ5z0Tx0u02p +B52/vYs8eTgqyaNRMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0G +CSqGSIb3DQEBCwUAA4IBAQAr1mDZ3MmUC4BOyD5H3rT/EOCkwAvqD1IlNoTskE+K +2ls0uUfLmDa77dN7ChJFqemVb72i3dR7tasPNJbFEmN6QGAejVss7MWuvNV+gJmj +D5w8A34bW5jZLfH+b+792CTGykfxvDrGGVqz/zBuEWFYTSUux+JL6v9ADnOUtPis +mt7K+uPCkeDk2QVsn92ik4rqMs+d14YWt+kYnWFvlgbcJbUFDJeuadIkj2kYNJsU +eCN0mneYjpaojV7cWjtl9+a/Hd3tvUBPSpiRnZJiEYfnJof8Qd4Z6jLZK+ZfpQwr +N70tlaxsFMCtJt194F2NM5LTh681ayYwNYP931dADwjW +-----END CERTIFICATE----- diff --git a/enterprise/e2e/auth-github/tls/github.crt b/enterprise/e2e/auth-github/tls/github.crt new file mode 100644 index 000000000..804a95c11 --- /dev/null +++ b/enterprise/e2e/auth-github/tls/github.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDRzCCAi+gAwIBAgIUVWJxO3YnpKX0T5EQ5DzTReGg0D8wDQYJKoZIhvcNAQEL +BQAwLjEsMCoGA1UEAwwjU291cmNlbWV0YSBPbmUgRW5kLXRvLUVuZCBBdXRob3Jp +dHkwHhcNMjYwODI0MTY1MjQxWhcNMzYwODIxMTY1MjQxWjARMQ8wDQYDVQQDDAZn +aXRodWIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDjOeb3jqjy9EIG +S4mecArmkYqp7cxopYWU8/twP/rVOipB0S4oHaZVlAddQ8shh05qVPSvuCY8mXgp +hWfTkY6Yzg0VitR6pbziljb83EVWJnUvr6zM7YilhYNz8sNIFqhZJiZEGSFNIr+0 +Km3gkgvN57ucHAu0UASAvE9rrnqKK+s0jLehGZnK+R6cuE7XCXlkSx1dQin+bXxN +nqP+/ZVwqecJIwuKtKvMgPZbJptD13RTaxHQVTaZOmOwe5AeV8laSL5cTiPZaEyu +UYj2y5dpCzm1A47Me96qUJ/VkTheKRj4J496aRaIIjIjLGcRn7SegxMKHY5bZyF2 +lPyxcvj3AgMBAAGjejB4MBEGA1UdEQQKMAiCBmdpdGh1YjAOBgNVHQ8BAf8EBAMC +BaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFLTpyGGXI+Ougylmgffn +YYv9CDtJMB8GA1UdIwQYMBaAFJ5z0Tx0u02pB52/vYs8eTgqyaNRMA0GCSqGSIb3 +DQEBCwUAA4IBAQBca14Rrw1qovOSVDRdisFvsG6ZCuEfmbion8EcLhPLnoAqR4yw +dQGmS+1AEGnZwKnV28wXYefgx9zsAWNcTcRh8mRrcSciSglVjG77B0NQ+iecTj9s +kFHoP4IELVAZ7BsQdsII+xuvZwEQTkbVlg4YeUB+hoyEmmaJmKsGGga7utzuutQ8 +Y/zqIHsLVbuIyE9u4KIKhiVY2V1oVjC7/L4/qhPThwya4GknfUKWrz+5a4FRx+ey +QSpClHLwIAMjOWMT618HFA0Rag2jnklmnIN1ZMiqbpohblnhKdbutoVe6NFtqf1s +TSpIpDllK87fYtGb2rVqGyeHRD2Zn1y4TSy9 +-----END CERTIFICATE----- diff --git a/enterprise/e2e/auth-github/tls/github.key b/enterprise/e2e/auth-github/tls/github.key new file mode 100644 index 000000000..62900a720 --- /dev/null +++ b/enterprise/e2e/auth-github/tls/github.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDjOeb3jqjy9EIG +S4mecArmkYqp7cxopYWU8/twP/rVOipB0S4oHaZVlAddQ8shh05qVPSvuCY8mXgp +hWfTkY6Yzg0VitR6pbziljb83EVWJnUvr6zM7YilhYNz8sNIFqhZJiZEGSFNIr+0 +Km3gkgvN57ucHAu0UASAvE9rrnqKK+s0jLehGZnK+R6cuE7XCXlkSx1dQin+bXxN +nqP+/ZVwqecJIwuKtKvMgPZbJptD13RTaxHQVTaZOmOwe5AeV8laSL5cTiPZaEyu +UYj2y5dpCzm1A47Me96qUJ/VkTheKRj4J496aRaIIjIjLGcRn7SegxMKHY5bZyF2 +lPyxcvj3AgMBAAECggEAPcjUwKON1OINweBwPyCAFkmkxqfeWOYI+IOO7uq2rZvv +OY5DWq0VOVfS2M1CQo+kBs2q6szLuNaSEdgwbxq5B1ufuyfQtriyL3pg4UiToeU7 +IAhImEBOU5OGNtTfvI5MoFD7PrEGmQ7FyZtv3QxZIVfNb5lApXrMfqS1g7Yho8fi +Bo9y9Za2JWwDEUCJ7xzaryrretAVwd33ELx+RLzKV/cXrK1oT6LTqRHrDchHuQqt +aUsJwrhki3djC+U8sb5nRCTuND2fbnTR6wUVIY0tWG4i92HsF2RoVE04Klryw3RY +mWrlGw5KCzoJC704eFnBcL/nfLWKhsL/GvyCbMHW4QKBgQD+hfxSOYZlTfZPOSXJ +dmKtSVcDqvQz+fN1w+3LEvB2fRCA/hfYeRUxRjAslwaJ63V6EvwJSQeshm03VEVk +KRpufOBPMVu9TiLY7tEDnbuESNQkHOZEpSNQ2CujoIfYO5Deg/r7EPHnfhuJhJ5U +yfNampAGEkUuONiuGHgPUOcf1QKBgQDki2AMJhmds74iWR6g9w0OebAVPzYa/vbS +qr0Z8/6nPoLSE+9feSCNQl762CHT1xzfA7A15kJYMnb/1hZxgOOVGKZnVf1SxoM4 +VUQumPyNjrmXui1xyN5j+iyfAeglQgo8NbfzMuJHq1MyOBCgIoUUC9wtQPtEUXt1 ++4LY5uRnmwKBgQDMECB8zCI0lo0kd20UWRZEWMiq6CVihsPrZ2r/pe+lECBrS01T +AcE8AEofdfaIHX/Sn+Xyi9rbN+vYsHfyFgJbE0PEOo6S+FJ4GwD5JT1ykfGEAqeb +4cquxqI2Tj6b3yYHQUm4gZ1xPGpXlzxaPpAd1E4kkAFnTaxr6LJZlUO5AQKBgQCd +rgt2VlMWGwzzZclcBRddBVgXUKVjusVQU7xkS5NGkDpx9o8Qr+FllIUzTCsKnT+u +Hj1U8qiTcT3pBSw241YhaABnC0zb71pZY3rHK4YTpIUnyavQ9WV6VQC0M/yWuBmX +sPhZMqIsEGg2Hbhaw3ZNfmFKV6sEV7N2kzCTtbmgQQKBgQDanok07h64ujCyrpeh +eL7rZt26L3jsopDYYwRGEs4zDRtp5AzS1JyHjMPSgD022g/NFVn0G0k8MRAdCBv+ +3p8hPJYdLDyQEtY252cmMouSDwz5n1wKrbLuNaS32GZ7QQauZc79lrgiWLVe458Q +wsq14gdCV/x0yb0f9xp+pyYUTw== +-----END PRIVATE KEY----- diff --git a/enterprise/e2e/auth/hurl/mcp-resources.all.hurl b/enterprise/e2e/auth/hurl/mcp-resources.all.hurl index fa76d0e8a..ecc432bfe 100644 --- a/enterprise/e2e/auth/hurl/mcp-resources.all.hurl +++ b/enterprise/e2e/auth/hurl/mcp-resources.all.hurl @@ -50,7 +50,7 @@ jsonpath "$.result.resources[3].uri" == "{{base}}/self/v1/schemas/api/error" jsonpath "$.result.resources[3].name" == "Sourcemeta One API Error" jsonpath "$.result.resources[3].description" == "The error response format returned by the Sourcemeta One API endpoints" jsonpath "$.result.resources[3].mimeType" == "application/schema+json" -jsonpath "$.result.resources[3].size" == 9525 +jsonpath "$.result.resources[3].size" == 9726 jsonpath "$.result.resources[3].annotations.priority" == 0 jsonpath "$.result.resources[4].uri" == "{{base}}/self/v1/schemas/api/list/response" jsonpath "$.result.resources[4].name" == "Sourcemeta One List API Response" @@ -498,7 +498,7 @@ jsonpath "$.result.resources[6].uri" == "{{base}}/self/v1/schemas/api/error" jsonpath "$.result.resources[6].name" == "Sourcemeta One API Error" jsonpath "$.result.resources[6].description" == "The error response format returned by the Sourcemeta One API endpoints" jsonpath "$.result.resources[6].mimeType" == "application/schema+json" -jsonpath "$.result.resources[6].size" == 9525 +jsonpath "$.result.resources[6].size" == 9726 jsonpath "$.result.resources[6].annotations.priority" == 0 jsonpath "$.result.resources[7].uri" == "{{base}}/self/v1/schemas/api/list/response" jsonpath "$.result.resources[7].name" == "Sourcemeta One List API Response" @@ -929,7 +929,7 @@ jsonpath "$.result.resources[4].uri" == "{{base}}/self/v1/schemas/api/error" jsonpath "$.result.resources[4].name" == "Sourcemeta One API Error" jsonpath "$.result.resources[4].description" == "The error response format returned by the Sourcemeta One API endpoints" jsonpath "$.result.resources[4].mimeType" == "application/schema+json" -jsonpath "$.result.resources[4].size" == 9525 +jsonpath "$.result.resources[4].size" == 9726 jsonpath "$.result.resources[4].annotations.priority" == 0 jsonpath "$.result.resources[5].uri" == "{{base}}/self/v1/schemas/api/list/response" jsonpath "$.result.resources[5].name" == "Sourcemeta One List API Response" diff --git a/enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl b/enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl index ab49ba901..8271a016a 100644 --- a/enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl +++ b/enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl @@ -490,7 +490,7 @@ jsonpath "$.result.resources[28].uri" == "{{base}}/self/v1/schemas/api/error" jsonpath "$.result.resources[28].name" == "Sourcemeta One API Error" jsonpath "$.result.resources[28].description" == "The error response format returned by the Sourcemeta One API endpoints" jsonpath "$.result.resources[28].mimeType" == "application/schema+json" -jsonpath "$.result.resources[28].size" == 9525 +jsonpath "$.result.resources[28].size" == 9726 jsonpath "$.result.resources[28].annotations.priority" == 0 jsonpath "$.result.resources[29].uri" == "{{base}}/self/v1/schemas/api/list/response" jsonpath "$.result.resources[29].name" == "Sourcemeta One List API Response" @@ -1257,7 +1257,7 @@ jsonpath "$.result.resources[28].uri" == "{{base}}/self/v1/schemas/api/error" jsonpath "$.result.resources[28].name" == "Sourcemeta One API Error" jsonpath "$.result.resources[28].description" == "The error response format returned by the Sourcemeta One API endpoints" jsonpath "$.result.resources[28].mimeType" == "application/schema+json" -jsonpath "$.result.resources[28].size" == 9525 +jsonpath "$.result.resources[28].size" == 9726 jsonpath "$.result.resources[28].annotations.priority" == 0 jsonpath "$.result.resources[29].uri" == "{{base}}/self/v1/schemas/api/list/response" jsonpath "$.result.resources[29].name" == "Sourcemeta One List API Response" diff --git a/enterprise/scripts/e2e-tls.sh b/enterprise/scripts/e2e-tls.sh index 15d5b3f2f..f3b305a08 100755 --- a/enterprise/scripts/e2e-tls.sh +++ b/enterprise/scripts/e2e-tls.sh @@ -40,6 +40,7 @@ issue() { issue keycloak issue registry +issue github install_into() { SANDBOX="$1" @@ -60,3 +61,6 @@ install_into() { install_into auth keycloak install_into auth-sso keycloak install_into auth-closed keycloak registry +# The GitHub sandbox serves one deployment under one name, which fronts the +# identity provider beside it as well as the API it emulates +install_into auth-github github diff --git a/enterprise/unit/authentication/CMakeLists.txt b/enterprise/unit/authentication/CMakeLists.txt index 901816f80..47520e9da 100644 --- a/enterprise/unit/authentication/CMakeLists.txt +++ b/enterprise/unit/authentication/CMakeLists.txt @@ -2,6 +2,7 @@ sourcemeta_test(NAMESPACE sourcemeta PROJECT one NAME enterprise_authentication SOURCES authentication_caller_test.cc authentication_compile_test.cc + authentication_github_test.cc authentication_governing_test.cc authentication_login_test.cc authentication_logout_test.cc diff --git a/enterprise/unit/authentication/authentication_github_test.cc b/enterprise/unit/authentication/authentication_github_test.cc new file mode 100644 index 000000000..33d548554 --- /dev/null +++ b/enterprise/unit/authentication/authentication_github_test.cc @@ -0,0 +1,521 @@ +#include "authentication_helpers.h" + +// Signing in through a GitHub deployment end to end. Every case drives a login +// and its callback against a deployment it controls, and reads the outcome + +static constexpr std::string_view GITHUB_REDIRECT_URI{ + "https://registry.test/self/v1/auth/callback/github"}; + +// A deployment answers a request for a page it does not serve with nothing at +// all, so a case that wants a policy admitting nobody says so through its rules +static auto +GITHUB_POLICY(const TestGitHub &deployment, + const std::span paths, + const std::span users = {}, + const std::span organizations = {}, + const std::span teams = {}, + const std::span email_domains = {}) + -> sourcemeta::one::Authentication::Policy { + return {.paths = paths, + .name = "github", + .credential = sourcemeta::one::Authentication::Policy::GitHub{ + .host = deployment.host, + .client_id = "Iv1.0123456789abcdef", + .client_secret_variable = "ONE_TEST_GITHUB_CLIENT_SECRET", + .users = users, + .organizations = organizations, + .teams = teams, + .email_domains = email_domains, + .session_secrets = SESSION_SECRETS}}; +} + +// Start a login and complete it, which is the whole of what a case about +// admission drives. A login that does not get as far as a redirect is returned +// as it is, since that is the answer a case asking about one wants +static auto +SIGN_IN_GITHUB(const sourcemeta::one::Authentication &authentication) + -> sourcemeta::one::Authentication::Outcome { + auto started{authentication.login("github", INSTANCE_URL, GITHUB_REDIRECT_URI, + false, "")}; + if (started.result != + sourcemeta::one::Authentication::Outcome::Result::Redirect) { + return started; + } + + const auto carried{"sourcemeta_one_transaction=" + + COOKIE_VALUE(started.cookies.front())}; + const std::array presented{{carried}}; + return authentication.callback( + "github", INSTANCE_URL, GITHUB_REDIRECT_URI, + {.state = QUERY_OF(started.location, "state"), .code = "a-code"}, + {.cookies = presented}); +} + +static auto INSTANCE( + const TestGitHub &deployment, + const std::span policies, + const std::string &name) -> sourcemeta::one::Authentication { + return sourcemeta::one::Authentication{ + sourcemeta::one::Authentication::Table{ + sourcemeta::one::Authentication::Table::compile( + policies, TEST_PATH(name), ANYWHERE)}, + deployment.fetcher()}; +} + +// A deployment publishes nothing to discover and issues no identity token, so +// a login against one is composed rather than fetched, binds no nonce, and asks +// for exactly the scopes the policy's rules need +TEST(a_login_asks_for_the_least_the_rules_of_a_policy_need) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const TestGitHub deployment; + const std::array paths{{"/portal"}}; + const std::array organizations{{"acme"}}; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, {}, organizations)}}; + const auto authentication{INSTANCE(deployment, policies, "one_github_scope")}; + + const auto started{authentication.login("github", INSTANCE_URL, + GITHUB_REDIRECT_URI, false, "")}; + EXPECT_EQ(started.result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + EXPECT_TRUE(started.location.starts_with( + "https://github.test/login/oauth/authorize?")); + EXPECT_EQ(QUERY_OF(started.location, "client_id"), "Iv1.0123456789abcdef"); + EXPECT_EQ(QUERY_OF(started.location, "response_type"), "code"); + EXPECT_EQ( + sourcemeta::core::URI::unescape(QUERY_OF(started.location, "scope")), + "read:org"); + EXPECT_EQ(QUERY_OF(started.location, "code_challenge_method"), "S256"); + EXPECT_EQ(sourcemeta::core::URI{started.location}.query().value().at("nonce"), + std::nullopt); + // Starting a login reaches the deployment for nothing at all + EXPECT_TRUE(deployment.asked->empty()); +} + +TEST(a_login_asks_for_nothing_where_a_policy_names_only_accounts) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const TestGitHub deployment; + const std::array paths{{"/portal"}}; + const std::array users{{"octocat"}}; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, users)}}; + const auto authentication{ + INSTANCE(deployment, policies, "one_github_no_scope")}; + + const auto started{authentication.login("github", INSTANCE_URL, + GITHUB_REDIRECT_URI, false, "")}; + EXPECT_EQ(started.result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + EXPECT_EQ(sourcemeta::core::URI{started.location}.query().value().at("scope"), + std::nullopt); +} + +TEST(a_login_asks_for_both_scopes_where_a_policy_names_both_kinds_of_rule) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const TestGitHub deployment; + const std::array paths{{"/portal"}}; + const std::array teams{{"acme/platform"}}; + const std::array domains{{"acme.test"}}; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, {}, {}, teams, domains)}}; + const auto authentication{ + INSTANCE(deployment, policies, "one_github_both_scopes")}; + + const auto started{authentication.login("github", INSTANCE_URL, + GITHUB_REDIRECT_URI, false, "")}; + EXPECT_EQ( + sourcemeta::core::URI::unescape(QUERY_OF(started.location, "scope")), + "read:org user:email"); +} + +// A policy naming accounts is answered against the handle the deployment says +// the token was issued for, which costs no call beyond the one that asks +TEST(a_policy_naming_an_account_admits_only_that_account) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array users{{"octocat"}}; + + const TestGitHub admitted; + const std::array first{ + {GITHUB_POLICY(admitted, paths, users)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(admitted, first, "one_github_user_yes")).result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + + TestGitHub refused; + refused.user = R"JSON({ "login": "hubot", "id": 99, "email": null })JSON"; + const std::array second{ + {GITHUB_POLICY(refused, paths, users)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(refused, second, "one_github_user_no")).result, + sourcemeta::one::Authentication::Outcome::Result::NotAdmitted); +} + +// A handle names an account rather than a phrase, so it is compared without +// regard to case on both sides +TEST(a_handle_is_compared_without_regard_to_case) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array users{{"OctoCat"}}; + TestGitHub deployment; + deployment.user = R"JSON({ "login": "OCTOCAT", "id": 583231 })JSON"; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, users)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(deployment, policies, "one_github_user_case")) + .result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); +} + +TEST(a_policy_naming_an_organisation_admits_a_member) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array organizations{{"acme"}}; + + TestGitHub admitted; + admitted.organizations = R"JSON([ { "login": "acme", "id": 1 } ])JSON"; + const std::array first{ + {GITHUB_POLICY(admitted, paths, {}, organizations)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(admitted, first, "one_github_org_yes")).result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + + TestGitHub refused; + refused.organizations = R"JSON([ { "login": "contoso", "id": 2 } ])JSON"; + const std::array second{ + {GITHUB_POLICY(refused, paths, {}, organizations)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(refused, second, "one_github_org_no")).result, + sourcemeta::one::Authentication::Outcome::Result::NotAdmitted); +} + +// A team is named by the organisation holding it alongside its slug, so a team +// of the same name in another organisation is a different team +TEST(a_policy_naming_a_team_admits_a_member_of_that_team_alone) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array teams{{"acme/platform"}}; + + TestGitHub admitted; + admitted.teams = + R"JSON([ { "slug": "platform", "organization": { "login": "acme" } } ])JSON"; + const std::array first{ + {GITHUB_POLICY(admitted, paths, {}, {}, teams)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(admitted, first, "one_github_team_yes")).result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + + TestGitHub refused; + refused.teams = + R"JSON([ { "slug": "platform", "organization": { "login": "contoso" } } ])JSON"; + const std::array second{ + {GITHUB_POLICY(refused, paths, {}, {}, teams)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(refused, second, "one_github_team_no")).result, + sourcemeta::one::Authentication::Outcome::Result::NotAdmitted); +} + +// The address on an account is the public one and is frequently unset, so a +// domain rule is answered against the primary address the account holds, and +// only where the deployment vouches for it +TEST(a_domain_rule_is_answered_against_a_verified_primary_address) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array domains{{"acme.test"}}; + + TestGitHub admitted; + admitted.emails = + R"JSON([ { "email": "spare@other.test", "primary": false, "verified": true }, + { "email": "octocat@acme.test", "primary": true, "verified": true } ])JSON"; + const std::array first{ + {GITHUB_POLICY(admitted, paths, {}, {}, {}, domains)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(admitted, first, "one_github_email_yes")).result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + + TestGitHub elsewhere; + elsewhere.emails = + R"JSON([ { "email": "octocat@other.test", "primary": true, "verified": true } ])JSON"; + const std::array second{ + {GITHUB_POLICY(elsewhere, paths, {}, {}, {}, domains)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(elsewhere, second, "one_github_email_no")).result, + sourcemeta::one::Authentication::Outcome::Result::NotAdmitted); + + TestGitHub unverified; + unverified.emails = + R"JSON([ { "email": "octocat@acme.test", "primary": true, "verified": false } ])JSON"; + const std::array third{ + {GITHUB_POLICY(unverified, paths, {}, {}, {}, domains)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(unverified, third, "one_github_email_unverified")) + .result, + sourcemeta::one::Authentication::Outcome::Result::NotAdmitted); +} + +// Rules of different kinds are cumulative, exactly as the claim rules of an +// interactive policy are, so satisfying one of two is satisfying neither +TEST(rules_of_different_kinds_are_cumulative) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array users{{"octocat"}}; + const std::array organizations{{"acme"}}; + + TestGitHub deployment; + deployment.organizations = R"JSON([ { "login": "contoso", "id": 2 } ])JSON"; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, users, organizations)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(deployment, policies, "one_github_cumulative")) + .result, + sourcemeta::one::Authentication::Outcome::Result::NotAdmitted); +} + +// The token endpoint answers a failure with a 200 carrying an `error` member, +// so a callback that read the status alone would take a refused code for a +// grant and fail further along for a reason nobody could place +TEST(a_token_endpoint_naming_an_error_in_a_success_is_no_grant) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array users{{"octocat"}}; + TestGitHub deployment; + deployment.token = + R"JSON({ "error": "bad_verification_code", "error_description": "The code passed is incorrect or expired." })JSON"; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, users)}}; + const auto outcome{ + SIGN_IN_GITHUB(INSTANCE(deployment, policies, "one_github_token_error"))}; + EXPECT_EQ(outcome.result, + sourcemeta::one::Authentication::Outcome::Result::Incomplete); + EXPECT_TRUE(REPORTED(outcome, "bad_verification_code")); + // The account was never asked for, since there was nothing to ask with + EXPECT_EQ(deployment.asked->size(), 1); +} + +// The token endpoint answers in a form encoding unless a request asks for JSON, +// and the API refuses a request that names no user agent at all, so both are +// asked for on every call this makes +TEST(every_call_carries_what_a_deployment_requires_of_one) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array organizations{{"acme"}}; + TestGitHub deployment; + deployment.organizations = R"JSON([ { "login": "acme", "id": 1 } ])JSON"; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, {}, organizations)}}; + EXPECT_EQ(SIGN_IN_GITHUB(INSTANCE(deployment, policies, "one_github_headers")) + .result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + EXPECT_TRUE(*deployment.asked_for_json); + EXPECT_TRUE(*deployment.named_agent); + EXPECT_EQ(deployment.asked->size(), 3); + EXPECT_EQ(deployment.asked->at(0), + "https://github.test/login/oauth/access_token"); + EXPECT_EQ(deployment.asked->at(1), "https://github.test/api/v3/user"); + EXPECT_EQ(deployment.asked->at(2), + "https://github.test/api/v3/user/orgs?per_page=100&page=1"); +} + +// The public deployment answers its API under a host of its own, while every +// other answers below the origin it is served at +TEST(the_public_deployment_answers_its_api_under_its_own_host) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array users{{"octocat"}}; + TestGitHub deployment; + deployment.host = "https://github.com"; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, users)}}; + EXPECT_EQ( + SIGN_IN_GITHUB(INSTANCE(deployment, policies, "one_github_public_host")) + .result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + EXPECT_EQ(deployment.asked->size(), 2); + EXPECT_EQ(deployment.asked->at(0), + "https://github.com/login/oauth/access_token"); + EXPECT_EQ(deployment.asked->at(1), "https://api.github.com/user"); +} + +// A listing is read inside a request handler, so one that never comes back +// shorter than the page asked for is refused rather than followed for as long +// as it keeps pointing somewhere +TEST(a_listing_that_never_shortens_is_refused_rather_than_followed) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const std::array paths{{"/portal"}}; + const std::array organizations{{"acme"}}; + + // A page as long as the one that was asked for is one more page to read, and + // this deployment answers every page with one + std::string endless{"["}; + for (std::size_t index{0}; index < 100; index += 1) { + if (index > 0) { + endless += ","; + } + + endless += R"JSON({ "login": "contoso", "id": 2 })JSON"; + } + + endless += "]"; + + TestGitHub deployment; + deployment.organizations = endless; + const auto fetcher{[endless](sourcemeta::one::Authentication::ProviderRequest + &&request) + -> std::optional { + if (request.url == "https://github.test/login/oauth/access_token") { + return sourcemeta::one::Authentication::ProviderResponse{ + .status = 200, + .body = + R"JSON({ "access_token": "an-access-token", "token_type": "bearer" })JSON"}; + } + + if (request.url == "https://github.test/api/v3/user") { + return sourcemeta::one::Authentication::ProviderResponse{ + .status = 200, + .body = R"JSON({ "login": "octocat", "id": 583231 })JSON"}; + } + + return sourcemeta::one::Authentication::ProviderResponse{.status = 200, + .body = endless}; + }}; + + const std::array policies{ + {GITHUB_POLICY(deployment, paths, {}, organizations)}}; + const sourcemeta::one::Authentication authentication{ + sourcemeta::one::Authentication::Table{ + sourcemeta::one::Authentication::Table::compile( + policies, TEST_PATH("one_github_endless"), ANYWHERE)}, + fetcher}; + + const auto outcome{SIGN_IN_GITHUB(authentication)}; + EXPECT_EQ(outcome.result, + sourcemeta::one::Authentication::Outcome::Result::NotAdmitted); + EXPECT_TRUE(REPORTED(outcome, "did not end within the pages this reads")); +} + +// A deployment cannot be asked whether a sign-in still stands without showing +// the person its own pages, so a browser signed in through one is left no +// marker that would send it back there on its own +TEST(signing_in_leaves_no_marker_for_a_silent_renewal) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const TestGitHub deployment; + const std::array paths{{"/portal"}}; + const std::array users{{"octocat"}}; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, users)}}; + const auto authentication{ + INSTANCE(deployment, policies, "one_github_no_renewal")}; + + const auto outcome{SIGN_IN_GITHUB(authentication)}; + EXPECT_EQ(outcome.result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + // The session and the spent transaction, and nothing else + EXPECT_EQ(outcome.cookies.size(), 2); + EXPECT_TRUE(outcome.cookies.at(0).starts_with("sourcemeta_one_session=")); + EXPECT_TRUE(outcome.cookies.at(1).starts_with("sourcemeta_one_transaction=")); + + const auto carried{"sourcemeta_one_session=" + + COOKIE_VALUE(outcome.cookies.front())}; + const std::array presented{{carried}}; + EXPECT_EQ(authentication.renewal(AT("/portal"), {.cookies = presented}), + std::nullopt); +} + +// A silent attempt is a navigation rather than something hidden, so one against +// a deployment that cannot answer it would land somebody on its sign-in page in +// the middle of browsing here. It is not made at all +TEST(a_silent_attempt_against_a_deployment_is_an_ordinary_login) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const TestGitHub deployment; + const std::array paths{{"/portal"}}; + const std::array users{{"octocat"}}; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, users)}}; + const auto authentication{ + INSTANCE(deployment, policies, "one_github_silent")}; + + const auto started{authentication.login("github", INSTANCE_URL, + GITHUB_REDIRECT_URI, true, "")}; + EXPECT_EQ(started.result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + EXPECT_EQ( + sourcemeta::core::URI{started.location}.query().value().at("prompt"), + std::nullopt); +} + +// A login that cannot be completed only strands the person at the deployment, +// so the secret the exchange will need is required before the browser is sent +// anywhere +TEST(a_login_without_a_client_secret_is_refused_before_the_redirect) { + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + unsetenv("ONE_TEST_GITHUB_ABSENT"); + const TestGitHub deployment; + const std::array paths{{"/portal"}}; + const std::array users{{"octocat"}}; + const std::array policies{ + {{.paths = paths, + .name = "github", + .credential = sourcemeta::one::Authentication::Policy::GitHub{ + .host = deployment.host, + .client_id = "Iv1.0123456789abcdef", + .client_secret_variable = "ONE_TEST_GITHUB_ABSENT", + .users = users, + .session_secrets = SESSION_SECRETS}}}}; + const auto authentication{ + INSTANCE(deployment, policies, "one_github_no_secret")}; + + const auto started{authentication.login("github", INSTANCE_URL, + GITHUB_REDIRECT_URI, false, "")}; + EXPECT_EQ(started.result, + sourcemeta::one::Authentication::Outcome::Result::Unavailable); + EXPECT_TRUE(REPORTED(started, "No client secret is set for the policy")); + EXPECT_TRUE(deployment.asked->empty()); +} + +// The session a login ends in reaches every path the policy governs, and a +// caller holding none reaches none of them +TEST(a_session_a_deployment_established_reaches_what_the_policy_governs) { + setenv("ONE_TEST_GITHUB_CLIENT_SECRET", "confidential", 1); + setenv(SESSION_SECRET_VARIABLE, "session-secret", 1); + const TestGitHub deployment; + const std::array paths{{"/portal", "/archive"}}; + const std::array users{{"octocat"}}; + const std::array policies{ + {GITHUB_POLICY(deployment, paths, users)}}; + const auto authentication{ + INSTANCE(deployment, policies, "one_github_session")}; + + const auto outcome{SIGN_IN_GITHUB(authentication)}; + EXPECT_EQ(outcome.result, + sourcemeta::one::Authentication::Outcome::Result::Redirect); + EXPECT_EQ(outcome.location, "/portal"); + + const auto carried{"sourcemeta_one_session=" + + COOKIE_VALUE(outcome.cookies.front())}; + const std::array presented{{carried}}; + const auto caller{authentication.caller({.cookies = presented})}; + EXPECT_EQ(caller.view(), "github"); + EXPECT_TRUE(authentication.permits(AT("/portal"), caller)); + EXPECT_TRUE(authentication.permits(AT("/archive"), caller)); + + const auto stranger{authentication.caller({})}; + EXPECT_EQ(stranger.view(), "public"); + EXPECT_FALSE(authentication.permits(AT("/portal"), stranger)); + EXPECT_FALSE(authentication.permits(AT("/archive"), stranger)); +} diff --git a/enterprise/unit/authentication/authentication_helpers.h b/enterprise/unit/authentication/authentication_helpers.h index 8826ce16b..159de3b68 100644 --- a/enterprise/unit/authentication/authentication_helpers.h +++ b/enterprise/unit/authentication/authentication_helpers.h @@ -290,6 +290,97 @@ struct TestProvider { } }; +// A GitHub deployment a case has control of. It publishes nothing to discover +// and asserts nothing in a token, so what it answers at each of its endpoints +// is what a case sets, and what a case reads back is what was made of it. +// Nothing here reaches a network +struct TestGitHub { + std::string_view host{"https://github.test"}; + // What redeeming an authorization code answers with. A deployment answers + // every outcome with a 200, so a case that is about a failure says so in the + // body rather than in a status + std::string token{ + R"JSON({ "access_token": "an-access-token", "token_type": "bearer" })JSON"}; + std::string user{ + R"JSON({ "login": "octocat", "id": 583231, "email": null })JSON"}; + std::string emails{"[]"}; + std::string organizations{"[]"}; + std::string teams{"[]"}; + // Every URL that was asked for, in order, which is how a case reads both what + // was called and how often + std::shared_ptr> asked{ + std::make_shared>()}; + // Whether every call carried what the deployment requires of one, which is a + // user agent on all of them and a request for JSON on the token endpoint + std::shared_ptr named_agent{std::make_shared(true)}; + std::shared_ptr asked_for_json{std::make_shared(false)}; + + [[nodiscard]] auto api() const -> std::string { + return this->host == "https://github.com" + ? "https://api.github.com" + : std::string{this->host} + "/api/v3"; + } + + [[nodiscard]] auto fetcher() const + -> sourcemeta::one::Authentication::Fetcher { + return [host = std::string{this->host}, api = this->api(), + token = this->token, user = this->user, emails = this->emails, + organizations = this->organizations, teams = this->teams, + asked = this->asked, named_agent = this->named_agent, + asked_for_json = this->asked_for_json]( + sourcemeta::one::Authentication::ProviderRequest &&request) + -> std::optional< + sourcemeta::one::Authentication::ProviderResponse> { + asked->emplace_back(request.url); + const auto header{ + [&request](const std::string_view name) -> std::string_view { + for (const auto &entry : request.headers) { + if (entry.first == name) { + return entry.second; + } + } + + return {}; + }}; + + if (request.url == host + "/login/oauth/access_token") { + *asked_for_json = header("accept") == "application/json"; + return sourcemeta::one::Authentication::ProviderResponse{.status = 200, + .body = token}; + } + + *named_agent = *named_agent && !header("user-agent").empty(); + // A listing is asked for one page at a time, so what a case declared is + // answered on the first and nothing is answered after it + const auto question{request.url.find('?')}; + const std::string_view location{request.url.substr(0, question)}; + const auto first{question == std::string_view::npos || + request.url.ends_with("&page=1")}; + if (location == api + "/user") { + return sourcemeta::one::Authentication::ProviderResponse{.status = 200, + .body = user}; + } + + if (location == api + "/user/emails") { + return sourcemeta::one::Authentication::ProviderResponse{ + .status = 200, .body = first ? emails : "[]"}; + } + + if (location == api + "/user/orgs") { + return sourcemeta::one::Authentication::ProviderResponse{ + .status = 200, .body = first ? organizations : "[]"}; + } + + if (location == api + "/user/teams") { + return sourcemeta::one::Authentication::ProviderResponse{ + .status = 200, .body = first ? teams : "[]"}; + } + + return std::nullopt; + }; + } +}; + inline constexpr std::string_view INSTANCE_URL{"https://registry.test"}; inline constexpr std::string_view REDIRECT_URI{ "https://registry.test/self/v1/auth/callback/okta"}; diff --git a/src/authentication/include/sourcemeta/one/authentication.h b/src/authentication/include/sourcemeta/one/authentication.h index a7dcadea1..38922e62a 100644 --- a/src/authentication/include/sourcemeta/one/authentication.h +++ b/src/authentication/include/sourcemeta/one/authentication.h @@ -20,7 +20,7 @@ #include // std::span #include // std::string #include // std::string_view -#include // std::move +#include // std::move, std::pair #include // std::variant, std::get_if, std::holds_alternative #include // std::vector @@ -157,12 +157,36 @@ class SOURCEMETA_ONE_AUTHENTICATION_EXPORT Authentication { std::span session_secrets{}; }; + // A person who signs in through a GitHub deployment. It is an OAuth 2.0 + // authorization server rather than an OpenID Connect provider, so it + // publishes nothing to discover, returns no identity token, and asserts no + // claims. Who somebody is and what they belong to is asked of its API + // instead, which is why nothing here is shaped like the above + struct GitHub { + // Where the deployment is served, as an origin + std::string_view host{}; + std::string_view client_id{}; + // The environment variable name holding the client secret + std::string_view client_secret_variable{}; + // The accounts that admit a person + std::span users{}; + // The organisations whose members this admits + std::span organizations{}; + // The teams whose members this admits, each named alongside the + // organisation that holds it + std::span teams{}; + std::span email_domains{}; + // The environment variable names holding the secrets that sign this + // policy's session and transaction cookies, newest first + std::span session_secrets{}; + }; + std::span paths{}; // The policy name, which interactive policies carry so their session // cookies can be recognised at the gate, and which every policy carries so // that a view can be named after what it comprises std::string_view name{}; - std::variant credential{ApiKey{}}; + std::variant credential{ApiKey{}}; }; // What this asks a provider for. Every outbound call goes through one of @@ -175,6 +199,11 @@ class SOURCEMETA_ONE_AUTHENTICATION_EXPORT Authentication { sourcemeta::core::SecureString body{}; // Empty where the request authenticates by other means, or not at all sourcemeta::core::SecureString authorization{}; + // Whatever else a provider requires of a request beyond what the protocol + // implies. None of it is a secret, so unlike the authorization above these + // are borrowed rather than held in wiping storage, and must outlive the + // request + std::span> headers{}; }; struct ProviderResponse { diff --git a/src/configuration/include/sourcemeta/one/configuration.h b/src/configuration/include/sourcemeta/one/configuration.h index 5b5e6a64b..fbc47aa20 100644 --- a/src/configuration/include/sourcemeta/one/configuration.h +++ b/src/configuration/include/sourcemeta/one/configuration.h @@ -59,7 +59,7 @@ struct Configuration { struct AuthenticationEntry { // What a policy authenticates against - enum class Type : std::uint8_t { ApiKey, JWT, OIDC }; + enum class Type : std::uint8_t { ApiKey, JWT, OIDC, GitHub }; // How a presented credential is compared against the keys enum class Algorithm : std::uint8_t { Identity, Sha256 }; @@ -93,6 +93,18 @@ struct Configuration { // newest first. Several coexist so that a secret can be replaced while // values signed under the one it replaces are still honoured std::vector session_secret_variables; + // Where the deployment a policy signs people in against is served, as an + // origin + sourcemeta::core::JSON::String host; + // The accounts that admit a person, lowercased and sorted, since an + // account names a handle and the order says nothing about who is admitted + std::vector users; + // The organisations whose members this admits, under the same spelling + // rule as the accounts above + std::vector organizations; + // The teams whose members this admits, each named alongside the + // organisation that holds it, under the same spelling rule as above + std::vector teams; }; std::vector authentication; @@ -149,7 +161,8 @@ struct Configuration { // both what the login endpoint offers and whether the bar offers a way to it [[nodiscard]] inline auto is_interactive(const Configuration::AuthenticationEntry &policy) -> bool { - return policy.type == Configuration::AuthenticationEntry::Type::OIDC; + return policy.type == Configuration::AuthenticationEntry::Type::OIDC || + policy.type == Configuration::AuthenticationEntry::Type::GitHub; } } // namespace sourcemeta::one diff --git a/src/configuration/parse.cc b/src/configuration/parse.cc index 94d5d5e6a..a837509ce 100644 --- a/src/configuration/parse.cc +++ b/src/configuration/parse.cc @@ -72,6 +72,41 @@ auto claims_from_json(const sourcemeta::core::JSON &input) return result; } +// Where the public GitHub service is served, which every policy that does not +// name an Enterprise Server of its own signs people in against +constexpr std::string_view GITHUB_HOST{"https://github.com"}; + +// The origin a URL names, which is all a policy pointing at a deployment can +// mean. Anything below it would compose endpoints that name no resource +auto origin_of(const std::string_view url) -> std::string { + sourcemeta::core::URI parsed{std::string{url}}; + parsed.canonicalize(); + parsed.path(""); + parsed.userinfo(""); + parsed.query(""); + parsed.fragment(""); + parsed.canonicalize(); + return parsed.recompose(); +} + +// A handle names an account rather than a phrase, so it is compared without +// regard to case, and the order a policy was written in says nothing about who +// it admits +auto handles_from_json(const sourcemeta::core::JSON *input, + std::vector &sink) + -> void { + if (input == nullptr) { + return; + } + + for (const auto &handle : input->as_array()) { + sink.push_back(handle.to_string()); + sourcemeta::core::to_lowercase(sink.back()); + } + + std::ranges::sort(sink); +} + auto page_from_json(const sourcemeta::core::JSON &input) -> sourcemeta::one::Configuration::Page { sourcemeta::one::Configuration::Page result; @@ -274,6 +309,38 @@ auto Configuration::parse(const sourcemeta::core::JSON &data, std::ranges::sort(parsed.email_domains); } + } else if (entry.at("type").to_string() == "github") { + parsed.type = Configuration::AuthenticationEntry::Type::GitHub; + parsed.client_id = entry.at("clientId").to_string(); + parsed.client_secret_variable = + entry.at("clientSecret").at("environmentVariable").to_string(); + for (const auto &secret : entry.at("sessionSecrets").as_array()) { + parsed.session_secret_variables.push_back( + secret.at("environmentVariable").to_string()); + } + + const auto *title{entry.try_at("title")}; + if (title != nullptr) { + parsed.title = title->to_string(); + } + + const auto *host{entry.try_at("host")}; + parsed.host = + origin_of(host == nullptr ? GITHUB_HOST : host->to_string()); + + const auto *domains{entry.try_at("emailDomains")}; + if (domains != nullptr) { + for (const auto &domain : domains->as_array()) { + parsed.email_domains.push_back(domain.to_string()); + sourcemeta::core::to_lowercase(parsed.email_domains.back()); + } + + std::ranges::sort(parsed.email_domains); + } + + handles_from_json(entry.try_at("users"), parsed.users); + handles_from_json(entry.try_at("organizations"), parsed.organizations); + handles_from_json(entry.try_at("teams"), parsed.teams); } else { parsed.type = Configuration::AuthenticationEntry::Type::ApiKey; parsed.algorithm = @@ -316,9 +383,13 @@ auto Configuration::parse(const sourcemeta::core::JSON &data, throw ConfigurationInvalidAuthenticationIssuerError( configuration_path, entry.name, entry.issuer); } + } - if (entry.type == Configuration::AuthenticationEntry::Type::OIDC && - !serves_securely(result.url)) { + // Every interactive policy mints the same session cookie, whether or not it + // ever fetches a discovery document, so the requirement on where the instance + // is served belongs to all of them rather than to the ones that discover + for (const auto &entry : result.authentication) { + if (is_interactive(entry) && !serves_securely(result.url)) { throw ConfigurationInsecureAuthenticationURLError(configuration_path, entry.name, result.url); } diff --git a/src/configuration/schema/configuration.json b/src/configuration/schema/configuration.json index 5abb9bb9c..8608fd9ed 100644 --- a/src/configuration/schema/configuration.json +++ b/src/configuration/schema/configuration.json @@ -320,6 +320,114 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "name", + "paths", + "clientId", + "clientSecret", + "sessionSecrets" + ], + "properties": { + "type": { + "const": "github" + }, + "name": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "paths": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^/", + "minLength": 1 + } + }, + "host": { + "x-format-assertion": true, + "type": "string", + "format": "uri" + }, + "clientId": { + "type": "string", + "minLength": 1 + }, + "clientSecret": { + "type": "object", + "required": [ "environmentVariable" ], + "properties": { + "environmentVariable": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "users": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9-]{1,39}$" + } + }, + "organizations": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9-]{1,39}$" + } + }, + "teams": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9-]{1,39}/[A-Za-z0-9._-]{1,100}$" + } + }, + "emailDomains": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "x-format-assertion": true, + "type": "string", + "format": "idn-hostname" + } + }, + "sessionSecrets": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "required": [ "environmentVariable" ], + "properties": { + "environmentVariable": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false } ] } diff --git a/src/index/generators.h b/src/index/generators.h index 28f309a74..f4bcac497 100644 --- a/src/index/generators.h +++ b/src/index/generators.h @@ -988,26 +988,35 @@ struct GENERATE_URITEMPLATE_ROUTES { }; struct GENERATE_AUTHENTICATION { - // The policies borrow the paths, keys and session secrets they are declared - // with, so the vectors that hold those must outlive the policies that point - // into them - static auto make_policies( - const sourcemeta::one::Configuration &configuration, - std::vector> &policy_paths, - std::vector> &policy_keys, - std::vector> &policy_session_secrets, - std::vector &policy_claims, - std::vector> &policy_email_domains) + // What the policies borrow from, since every one of them points at a run + // declared elsewhere rather than holding it. Whatever this is is handed to + // must outlive the policies made against it + struct PolicyStorage { + std::vector> paths; + std::vector> keys; + std::vector> session_secrets; + std::vector claims; + std::vector> email_domains; + std::vector> users; + std::vector> organizations; + std::vector> teams; + }; + + static auto make_policies(const sourcemeta::one::Configuration &configuration, + PolicyStorage &storage) -> std::vector { std::vector policies; policies.reserve(configuration.authentication.size()); - policy_paths.reserve(configuration.authentication.size()); - policy_keys.reserve(configuration.authentication.size()); - policy_session_secrets.reserve(configuration.authentication.size()); - // The views a policy carries point into here, so this must not grow while - // one is held - policy_claims.reserve(configuration.authentication.size()); - policy_email_domains.reserve(configuration.authentication.size()); + // The views a policy carries point into these, so none of them may grow + // while one is held + storage.paths.reserve(configuration.authentication.size()); + storage.keys.reserve(configuration.authentication.size()); + storage.session_secrets.reserve(configuration.authentication.size()); + storage.claims.reserve(configuration.authentication.size()); + storage.email_domains.reserve(configuration.authentication.size()); + storage.users.reserve(configuration.authentication.size()); + storage.organizations.reserve(configuration.authentication.size()); + storage.teams.reserve(configuration.authentication.size()); for (const auto &entry : configuration.authentication) { std::vector paths; paths.reserve(entry.paths.size()); @@ -1015,7 +1024,7 @@ struct GENERATE_AUTHENTICATION { paths.push_back(path); } - policy_paths.push_back(std::move(paths)); + storage.paths.push_back(std::move(paths)); using Entry = sourcemeta::one::Configuration::AuthenticationEntry; if (entry.type == Entry::Type::JWT) { @@ -1026,9 +1035,9 @@ struct GENERATE_AUTHENTICATION { sourcemeta::core::stringify(entry.claims, claims); } - policy_claims.push_back(claims.str()); + storage.claims.push_back(claims.str()); policies.push_back( - {.paths = policy_paths.back(), + {.paths = storage.paths.back(), .name = entry.name, .credential = sourcemeta::one::Authentication::Policy::Token{ .issuer = entry.issuer, @@ -1038,7 +1047,7 @@ struct GENERATE_AUTHENTICATION { : std::string_view{}, .algorithms = entry.algorithms, .token_type = entry.token_type, - .claims = policy_claims.back()}}); + .claims = storage.claims.back()}}); } else if (entry.type == Entry::Type::OIDC) { std::vector session_secrets; session_secrets.reserve(entry.session_secret_variables.size()); @@ -1046,14 +1055,14 @@ struct GENERATE_AUTHENTICATION { session_secrets.push_back(variable); } - policy_session_secrets.push_back(std::move(session_secrets)); + storage.session_secrets.push_back(std::move(session_secrets)); std::ostringstream claims; if (!entry.claims.is_null()) { sourcemeta::core::stringify(entry.claims, claims); } - policy_claims.push_back(claims.str()); + storage.claims.push_back(claims.str()); std::vector domains; domains.reserve(entry.email_domains.size()); @@ -1061,17 +1070,69 @@ struct GENERATE_AUTHENTICATION { domains.push_back(domain); } - policy_email_domains.push_back(std::move(domains)); + storage.email_domains.push_back(std::move(domains)); policies.push_back( - {.paths = policy_paths.back(), + {.paths = storage.paths.back(), .name = entry.name, .credential = sourcemeta::one::Authentication::Policy::Interactive{ .issuer = entry.issuer, .client_id = entry.client_id, .client_secret_variable = entry.client_secret_variable, - .claims = policy_claims.back(), - .email_domains = policy_email_domains.back(), - .session_secrets = policy_session_secrets.back()}}); + .claims = storage.claims.back(), + .email_domains = storage.email_domains.back(), + .session_secrets = storage.session_secrets.back()}}); + } else if (entry.type == Entry::Type::GitHub) { + std::vector session_secrets; + session_secrets.reserve(entry.session_secret_variables.size()); + for (const auto &variable : entry.session_secret_variables) { + session_secrets.push_back(variable); + } + + storage.session_secrets.push_back(std::move(session_secrets)); + + std::vector domains; + domains.reserve(entry.email_domains.size()); + for (const auto &domain : entry.email_domains) { + domains.push_back(domain); + } + + storage.email_domains.push_back(std::move(domains)); + + std::vector users; + users.reserve(entry.users.size()); + for (const auto &user : entry.users) { + users.push_back(user); + } + + storage.users.push_back(std::move(users)); + + std::vector organizations; + organizations.reserve(entry.organizations.size()); + for (const auto &organization : entry.organizations) { + organizations.push_back(organization); + } + + storage.organizations.push_back(std::move(organizations)); + + std::vector teams; + teams.reserve(entry.teams.size()); + for (const auto &team : entry.teams) { + teams.push_back(team); + } + + storage.teams.push_back(std::move(teams)); + policies.push_back( + {.paths = storage.paths.back(), + .name = entry.name, + .credential = sourcemeta::one::Authentication::Policy::GitHub{ + .host = entry.host, + .client_id = entry.client_id, + .client_secret_variable = entry.client_secret_variable, + .users = storage.users.back(), + .organizations = storage.organizations.back(), + .teams = storage.teams.back(), + .email_domains = storage.email_domains.back(), + .session_secrets = storage.session_secrets.back()}}); } else { std::vector keys; keys.reserve(entry.keys.size()); @@ -1079,16 +1140,16 @@ struct GENERATE_AUTHENTICATION { keys.push_back(key); } - policy_keys.push_back(std::move(keys)); + storage.keys.push_back(std::move(keys)); const auto algorithm{ entry.algorithm == Entry::Algorithm::Sha256 ? sourcemeta::one::Authentication::Algorithm::Sha256 : sourcemeta::one::Authentication::Algorithm::Identity}; policies.push_back( - {.paths = policy_paths.back(), + {.paths = storage.paths.back(), .name = entry.name, .credential = sourcemeta::one::Authentication::Policy::ApiKey{ - .keys = policy_keys.back(), .algorithm = algorithm}}); + .keys = storage.keys.back(), .algorithm = algorithm}}); } } @@ -1103,14 +1164,8 @@ struct GENERATE_AUTHENTICATION { const sourcemeta::core::JSON &) -> void { const sourcemeta::core::URITemplateRouterView routes{ action.dependencies.at(0)}; - std::vector> policy_paths; - std::vector> policy_keys; - std::vector> policy_session_secrets; - std::vector policy_claims; - std::vector> policy_email_domains; - const auto policies{make_policies(configuration, policy_paths, policy_keys, - policy_session_secrets, policy_claims, - policy_email_domains)}; + PolicyStorage storage; + const auto policies{make_policies(configuration, storage)}; // A policy gates a route or a declared collection or page (or a namespace // above one), never a path inside a collection. A route is named where it diff --git a/src/index/index.cc b/src/index/index.cc index fb524a112..a67f81b74 100644 --- a/src/index/index.cc +++ b/src/index/index.cc @@ -642,16 +642,10 @@ static auto index_main(const std::string_view &program, // The views this build writes for, read below from the table it compiles, so // that the naming rule is applied once and a build and the server it feeds // cannot come to different answers about what the views are - std::vector> view_policy_paths; - std::vector> view_policy_keys; - std::vector> view_policy_session_secrets; - std::vector view_policy_claims; - std::vector> view_policy_email_domains; + sourcemeta::one::GENERATE_AUTHENTICATION::PolicyStorage view_policy_storage; const auto view_policies{ sourcemeta::one::GENERATE_AUTHENTICATION::make_policies( - configuration, view_policy_paths, view_policy_keys, - view_policy_session_secrets, view_policy_claims, - view_policy_email_domains)}; + configuration, view_policy_storage)}; const auto authentication_path{canonical_output / "authentication.bin"}; // The table this build just compiled is what the plan is filtered against, so // it is read from memory rather than through the file it is also written to diff --git a/src/router/router.cc b/src/router/router.cc index 88f0c4082..f292c474c 100644 --- a/src/router/router.cc +++ b/src/router/router.cc @@ -35,6 +35,10 @@ auto provider_fetcher() -> Authentication::Fetcher { request.header("authorization", std::move(incoming.authorization)); } + for (const auto &header : incoming.headers) { + request.header(std::string{header.first}, std::string{header.second}); + } + if (posting) { request.body(std::move(incoming.body), "application/x-www-form-urlencoded"); diff --git a/src/self/v1/schemas/api/error.json b/src/self/v1/schemas/api/error.json index fc7287bca..448320ccb 100644 --- a/src/self/v1/schemas/api/error.json +++ b/src/self/v1/schemas/api/error.json @@ -201,6 +201,14 @@ "detail": "The identity provider declined the login" } }, + { + "const": { + "type": "urn:sourcemeta:one:auth-not-admitted", + "title": "Forbidden", + "status": 403, + "detail": "This account is not admitted here" + } + }, { "const": { "type": "urn:sourcemeta:one:auth-invalid-callback", diff --git a/test/cli/index/enterprise/fail-authentication-apikey-without-keys.clitest b/test/cli/index/enterprise/fail-authentication-apikey-without-keys.clitest index f6a67fe1b..b0a79a95c 100644 --- a/test/cli/index/enterprise/fail-authentication-apikey-without-keys.clitest +++ b/test/cli/index/enterprise/fail-authentication-apikey-without-keys.clitest @@ -27,7 +27,10 @@ WRITE expected.txt UNTIL EOF 2> The value was expected to be an object that defines properties "clientId", "clientSecret", "issuer", "name", "paths", "sessionSecrets", and "type" 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf/2/required" -2> The object value was expected to validate against at least one of the 3 given subschemas +2> The value was expected to be an object that defines properties "clientId", "clientSecret", "name", "paths", "sessionSecrets", and "type" +2> at instance location "/authentication/0" +2> at evaluate path "/properties/authentication/items/anyOf/3/required" +2> The object value was expected to validate against at least one of the 4 given subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf" 2> Every item in the array value was expected to validate against the given subschema diff --git a/test/cli/index/enterprise/fail-authentication-name-empty.clitest b/test/cli/index/enterprise/fail-authentication-name-empty.clitest index 1a4a71bf4..7c2eb6ed2 100644 --- a/test/cli/index/enterprise/fail-authentication-name-empty.clitest +++ b/test/cli/index/enterprise/fail-authentication-name-empty.clitest @@ -44,7 +44,10 @@ WRITE expected.txt UNTIL EOF 2> The value was expected to be an object that defines properties "clientId", "clientSecret", "issuer", "name", "paths", "sessionSecrets", and "type" 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf/2/required" -2> The object value was expected to validate against at least one of the 3 given subschemas +2> The value was expected to be an object that defines properties "clientId", "clientSecret", "name", "paths", "sessionSecrets", and "type" +2> at instance location "/authentication/0" +2> at evaluate path "/properties/authentication/items/anyOf/3/required" +2> The object value was expected to validate against at least one of the 4 given subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf" 2> Every item in the array value was expected to validate against the given subschema diff --git a/test/cli/index/enterprise/fail-authentication-name-invalid.clitest b/test/cli/index/enterprise/fail-authentication-name-invalid.clitest index 240487ab7..b1a395f17 100644 --- a/test/cli/index/enterprise/fail-authentication-name-invalid.clitest +++ b/test/cli/index/enterprise/fail-authentication-name-invalid.clitest @@ -44,7 +44,10 @@ WRITE expected.txt UNTIL EOF 2> The value was expected to be an object that defines properties "clientId", "clientSecret", "issuer", "name", "paths", "sessionSecrets", and "type" 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf/2/required" -2> The object value was expected to validate against at least one of the 3 given subschemas +2> The value was expected to be an object that defines properties "clientId", "clientSecret", "name", "paths", "sessionSecrets", and "type" +2> at instance location "/authentication/0" +2> at evaluate path "/properties/authentication/items/anyOf/3/required" +2> The object value was expected to validate against at least one of the 4 given subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf" 2> Every item in the array value was expected to validate against the given subschema diff --git a/test/cli/index/enterprise/fail-authentication-name-missing.clitest b/test/cli/index/enterprise/fail-authentication-name-missing.clitest index 23392e783..0e6805902 100644 --- a/test/cli/index/enterprise/fail-authentication-name-missing.clitest +++ b/test/cli/index/enterprise/fail-authentication-name-missing.clitest @@ -40,7 +40,10 @@ WRITE expected.txt UNTIL EOF 2> The value was expected to be an object that defines properties "clientId", "clientSecret", "issuer", "name", "paths", "sessionSecrets", and "type" 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf/2/required" -2> The object value was expected to validate against at least one of the 3 given subschemas +2> The value was expected to be an object that defines properties "clientId", "clientSecret", "name", "paths", "sessionSecrets", and "type" +2> at instance location "/authentication/0" +2> at evaluate path "/properties/authentication/items/anyOf/3/required" +2> The object value was expected to validate against at least one of the 4 given subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf" 2> Every item in the array value was expected to validate against the given subschema diff --git a/test/cli/index/enterprise/fail-authentication-public-type.clitest b/test/cli/index/enterprise/fail-authentication-public-type.clitest index a3378ebdf..cadd0727b 100644 --- a/test/cli/index/enterprise/fail-authentication-public-type.clitest +++ b/test/cli/index/enterprise/fail-authentication-public-type.clitest @@ -44,7 +44,10 @@ WRITE expected.txt UNTIL EOF 2> The value was expected to be an object that defines properties "clientId", "clientSecret", "issuer", "name", "paths", "sessionSecrets", and "type" 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf/2/required" -2> The object value was expected to validate against at least one of the 3 given subschemas +2> The value was expected to be an object that defines properties "clientId", "clientSecret", "name", "paths", "sessionSecrets", and "type" +2> at instance location "/authentication/0" +2> at evaluate path "/properties/authentication/items/anyOf/3/required" +2> The object value was expected to validate against at least one of the 4 given subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf" 2> Every item in the array value was expected to validate against the given subschema diff --git a/test/cli/index/enterprise/fail-authentication-session-secret-duplicate.clitest b/test/cli/index/enterprise/fail-authentication-session-secret-duplicate.clitest index d648b412b..eb2b52994 100644 --- a/test/cli/index/enterprise/fail-authentication-session-secret-duplicate.clitest +++ b/test/cli/index/enterprise/fail-authentication-session-secret-duplicate.clitest @@ -54,7 +54,13 @@ WRITE expected.txt UNTIL EOF 2> The object value was expected to validate against the defined properties subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf/2/properties" -2> The object value was expected to validate against at least one of the 3 given subschemas +2> The string value "oidc" was expected to equal the string constant "github" +2> at instance location "/authentication/0/type" +2> at evaluate path "/properties/authentication/items/anyOf/3/properties/type/const" +2> The object value was expected to validate against the defined properties subschemas +2> at instance location "/authentication/0" +2> at evaluate path "/properties/authentication/items/anyOf/3/properties" +2> The object value was expected to validate against at least one of the 4 given subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf" 2> Every item in the array value was expected to validate against the given subschema diff --git a/test/cli/index/enterprise/fail-authentication-session-secret-empty.clitest b/test/cli/index/enterprise/fail-authentication-session-secret-empty.clitest index 9990fae35..30710b071 100644 --- a/test/cli/index/enterprise/fail-authentication-session-secret-empty.clitest +++ b/test/cli/index/enterprise/fail-authentication-session-secret-empty.clitest @@ -51,7 +51,13 @@ WRITE expected.txt UNTIL EOF 2> The object value was expected to validate against the defined properties subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf/2/properties" -2> The object value was expected to validate against at least one of the 3 given subschemas +2> The string value "oidc" was expected to equal the string constant "github" +2> at instance location "/authentication/0/type" +2> at evaluate path "/properties/authentication/items/anyOf/3/properties/type/const" +2> The object value was expected to validate against the defined properties subschemas +2> at instance location "/authentication/0" +2> at evaluate path "/properties/authentication/items/anyOf/3/properties" +2> The object value was expected to validate against at least one of the 4 given subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf" 2> Every item in the array value was expected to validate against the given subschema diff --git a/test/cli/index/enterprise/fail-authentication-unknown-algorithm.clitest b/test/cli/index/enterprise/fail-authentication-unknown-algorithm.clitest index 102fc5ba0..b37dd525b 100644 --- a/test/cli/index/enterprise/fail-authentication-unknown-algorithm.clitest +++ b/test/cli/index/enterprise/fail-authentication-unknown-algorithm.clitest @@ -44,7 +44,10 @@ WRITE expected.txt UNTIL EOF 2> The value was expected to be an object that defines properties "clientId", "clientSecret", "issuer", "name", "paths", "sessionSecrets", and "type" 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf/2/required" -2> The object value was expected to validate against at least one of the 3 given subschemas +2> The value was expected to be an object that defines properties "clientId", "clientSecret", "name", "paths", "sessionSecrets", and "type" +2> at instance location "/authentication/0" +2> at evaluate path "/properties/authentication/items/anyOf/3/required" +2> The object value was expected to validate against at least one of the 4 given subschemas 2> at instance location "/authentication/0" 2> at evaluate path "/properties/authentication/items/anyOf" 2> Every item in the array value was expected to validate against the given subschema