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 verifications outcome <- authenticate fetchKeySet 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 replaces the duplicate-issuer check NixCI currently does itself. - **`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. ## 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 Audience` 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.