3dd41392

The resource server's half of OpenID Connect

The resource server's half of OpenID Connect, which no Haskell package covers.

`oidc-client`, `openid-connect` and `yesod-auth-oidc` are all relying-party
libraries: they log a user in through a browser, with discovery, the
authorization code flow and a token exchange. None of them answers the other
question, which is the one NixCI has: something presents a token it got from
an issuer elsewhere, and we decide whether to believe it. `jose` is the right
primitive for the signature and the registered claims, and this uses it, but
the layer above it was ours to write.

First consumer is workload identity federation on the leader API
(NorfairKing/nix-ci#748), and then the cache (NorfairKing/nix-ci#749).

## What a caller writes

```haskell
federation <- newFederation issuers   -- each with how to fetch its keys
outcome <- authenticate federation now bearerToken
```

`Outcome` is `NotFederated`, `Refused refusal` or `Accepted claims`.

`OIDC.Federation.authenticate` does all four steps: which issuer the token
names, that issuer's settings and keys, the key its header names, and then the
token itself. A caller that wants the pieces separately still has them.

## What it will not do

**Verify an unsigned or symmetrically signed token.** `Algorithm` has no
`none` constructor and no `HS*` constructor, so neither is something a caller
can ask for. A symmetric key signs as well as checks, so an issuer that
published one in a key set would have published the power to mint tokens.

**Try every key the issuer publishes.** The key is the one the token's `kid`
names, so a token whose header names one key and whose signature was made with
another is refused rather than accepted on the strength of the second.

**Make an HTTP request.** Fetching the key set and the discovery document is
the caller's, which keeps an HTTP client, a retry policy and a logger out of
the library, and lets NixCI keep its own circuit breaker. `KeySet` holds the
part that is easy to get wrong: one fetch at a time under a lock, a one-minute
floor under how often an issuer is asked, and keeping the keys it has when a
fetch fails. `parseJWKSet` reads the answer, so fetching one does not mean
depending on jose either.

**Say which issuers it trusts.** A token from an unknown issuer and something
that is not a token at all are the same answer, `NotFederated`. A test pins
that an unknown issuer costs no fetch, so it cannot be made to generate
traffic either.

## Things worth your eye

- **`verificationsByIssuer` returns `Left issuer`** for an issuer configured
  twice, separately from `newFederation`, so a service can refuse its settings
  before it starts rather than silently reaching only one of the two. This
  replaced the duplicate-issuer check NixCI did itself, and let NixCI make its
  setting a `Map Issuer ProviderSettings` so the case exists in exactly one
  place.
- **`verifyToken` hands back a plain `Claims` record**, with `claimsAll`
  carrying every top-level claim beside the registered ones. That is what
  keeps lenses out of callers: jose exposes a token's claims only as
  van-Laarhoven lenses, and four NixCI modules currently have an hlint
  exception for it that this removes.
- **jose does not check a claim a token leaves out.** Its predicates are only
  asked about claims that are present, so a token carrying no `aud` passes an
  audience check that was never made. The issuer, the audience, and both ends
  of the lifetime are asserted separately afterwards.
- **`maxTokenLifetime` is `Maybe`.** Nothing accepts whatever the issuer
  chose. A bound is the only thing that makes "short-lived" mean anything: an
  issuer that mints a year-long token has minted a credential.
- **Issuers and audiences are compared as text**, not as parsed urls. That is
  how an issuer documents itself and how a token carries it, and it is the
  safe direction to be wrong in. Comparing parsed urls would be a second
  answer to the same question, and a configured value that is no url would
  parse to nothing and quietly match nothing.
- **All rights reserved.** Nothing is decided about who may use this, and a
  permissive licence is the one decision that cannot be taken back. cabal2nix
  reads that as unfree, hence `config.allowUnfree` in the flake.

## Four things integrating it changed

Wiring NixCI onto this
([NorfairKing/nix-ci#748](https://github.com/NorfairKing/nix-ci/pull/748))
found four, which is what the exercise was for.

- **Each issuer carries its own fetch**, handed in once at `newFederation`,
  and `authenticate` lost an argument. Before, the fetcher was given only an
  `Issuer` on every request, so the caller looked that issuer up a second time
  to find its key set url and then had a `Nothing` to answer that could not
  happen.
- **`Audience` is `TokenAudience`.** It collided with jose's in every module
  that both signs a token and configures this, which was three of them during
  the integration. The algorithm constructors keep their registry names, since
  those only collide when jose's `Alg(..)` is imported unqualified.
- **`OIDC.KeySet.Internal`** holds `KeySetState`, `readKeySetState`,
  `refetchAllowed`, `minimumRefetchInterval` and `keyWithKid`. They are worth
  testing for themselves, but a published module should not have a surface
  shaped by its own test suite, so `OIDC.KeySet` now offers four things.
- **A federation is built somewhere other than where it is used.**
  `newFederation :: MonadIO n => Map Issuer (Verification, m (Maybe [JWK])) -> n (Federation m)`.
  Tying the two monads meant NixCI could not build an env at all: its test
  setup runs in `SetupFunc` and its handlers in `LoggingT IO`.

The cost is that `Federation` is parameterised in the monad its fetches run
in, which NixCI's `App` carries as `OIDCEnv (LoggingT IO)`.

## Merging this is the release trigger

`nix-ci.nix` deploys `packages.x86_64-linux.release-to-hackage` from `master`,
and the script uploads any package whose version is not on Hackage yet. So
merging this would publish `oidc-0.0.0.0` and `oidc-gen-0.0.0.0` as soon as
`HACKAGE_API_KEY` exists in the repository's NixCI secrets. It does not exist,
which is the thing standing between this and a public release.

Review it here and leave it unmerged until NixCI runs it in production.

## Tested against an issuer that really exists

Everything else in here signs its own tokens with keys it generated, which
says nothing about whether a real issuer's documents, keys and tokens are
shaped the way the library expects. `checks.x86_64-linux.e2e-test` is a NixOS
test that runs **dex and Keycloak** on the network. Each holds the workload's
password, signs with a key of its own, publishes that key where the library
goes looking, and describes itself at the well-known url. Nothing in that path
is a stand-in, and each issuer logs a real sign-in for every token the suite
asks for.

Two issuers rather than one, because one issuer only ever says that the
library agrees with that issuer. They are different implementations and they
disagree about which grants they still offer, so the driver takes the one each
of them has: dex only signs in at its own login page, Keycloak still takes the
credentials straight. What the library sees is the same either way.

Nine tests against each, in `oidc-e2e`: the discovery document parses and
names a fetchable key set; a token it signed is accepted, with its issuer,
audience and lifetime asserted; the keys are fetched from the issuer exactly
once across several tokens; a token the same issuer minted for a second client
is refused on the audience; a token from an issuer this does not federate with
is `NotFederated` and costs no fetch; a tampered signature, an expired token
and an algorithm the issuer may not use are each refused for the right reason.

Writing that driver found a gap, which is what an end-to-end test is for:
fetching a key set meant decoding it, which meant depending on jose for the
one type the library hands back. Keycloak found a second one, in the driver
rather than the library: three different realm misconfigurations all looked
like "holds no id token" until it said what the issuer actually answered.

## CI

`nix flake check` passes: library, tests, e2e-test against both issuers,
shell, hlint, hopinion, weeder, statix, deadnix, pre-commit, and the mutation
check at **438 mutations, 0 survived, 0 uncovered, 0 skipped** across 97 unit
tests.

Three of those mutations were the code's fault rather than the tests'. A
`Validity` instance written as `mconcat` over a list, and a `decorate` that
only prefixes a message, are both shapes whose mutants no test can tell from
the original; written as a chain of `declare`s there is nothing left to mutate
that is not worth a test. The third was a branch no test reached, for
something that names a known issuer but is not a signed JWT.

## To look at by hand

- `oidc/src/OIDC/Token.hs` is where the judgement is. The rest is plumbing
  around it.
- `weeder.toml` has two `root-instances`. `Ord Issuer` is used here by the
  federation's map; `Ord TokenAudience` and `Ord Algorithm` are the same promise to
  a caller who wants a `Set` of either, and weeder cannot see a use outside
  this repo.
- The `.hlint.yaml` `Control.Lens` exception names four modules. Those are the
  ones that talk to jose; nothing the library exports asks a caller for a lens.



deadnix-checke2e-testhlint-checkhopinionlibrarymutationpre-commitshellstatix-checktestsweeder-checkconfiguredevShelldefaultrelease-to-hackagex86_64-linux