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. **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. ## CI `nix flake check` passes: library, tests, shell, hlint, hopinion, weeder, statix, deadnix, pre-commit, and the mutation check at **436 mutations, 0 survived, 0 uncovered, 0 skipped** across 92 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.
Total
9s
Created Started Finished
| Time to Start | Worker time | Duration | Time to finish | Idle | |
|---|---|---|---|---|---|
| Config | 0s | 0s | 0s | 1s | 0s |
| Eval | 1s | 8s | 8s | 9s | 0s |
| Build | 9s | 0s | 0s | 9s | 0s |
| Suite | 0s | 9s | 9s | 9s | 0s |