Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,53 @@ jobs:

- name: Test
run: nix develop -c cargo test

# A separate job on purpose. An advisory is published on the RustSec
# timetable, not on this repository's, so a dependency going bad must not
# turn the clippy/test signal red on an unrelated pull request: the two
# answer different questions and are read at different times.
#
# It reads Cargo.lock and the advisory database, and compiles nothing, so
# it needs neither the toolchain cache nor the dev shell's compiler to
# match anything. It does need the network, to fetch the database.
#
# Blocking rather than `continue-on-error`, because an advisory nobody has
# to answer is an advisory nobody reads.
#
# Each `--ignore` below is an advisory in a crate this pod links but does
# not reach, with the path that brings it in and the thing that would
# remove it. They are listed rather than silenced wholesale so that adding
# one is a diff someone reviews, and so a NEW advisory still turns this
# red. Re-check them whenever `oxigraph` or `rudof_lib` moves:
#
# RUSTSEC-2026-0194, RUSTSEC-2026-0195 (quick-xml, both DoS, 7.5)
# Two paths, neither reachable from a request.
# * quick-xml 0.37.5 <- oxrdfxml <- oxrdfio <- oxigraph. This is
# oxigraph's RDF/XML parser, and `rdf::Format::ALL` is closed over
# five media types (Turtle, N-Triples, JSON-LD, TriG, N-Quads) with
# no RDF/XML arm, so nothing in this pod ever constructs one. Adding
# RDF/XML as a supported format makes this advisory live, and the
# `unreachable!()` in `Format::media_type` is what would have to
# change first.
# * quick-xml 0.38.4 <- calamine <- dctap <- rudof_lib. calamine is a
# spreadsheet reader, pulled in because rudof_lib carries DCTAP.
# This pod uses rudof for SHACL validation only, over Turtle.
# Cleared by oxigraph updating oxrdfxml, and by depending on the
# narrower `shacl` crate instead of `rudof_lib`.
#
# RUSTSEC-2026-0235 (rkyv, out-of-bounds read on archive validation)
# rkyv 0.7.46 <- rust_decimal <- rudof_rdf <- rudof_lib. Nothing here
# deserializes an rkyv archive, and no attacker-supplied bytes reach
# one. Cleared by the same narrowing.
audit:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- name: Audit dependencies
run: |
nix develop -c cargo audit \
--ignore RUSTSEC-2026-0194 \
--ignore RUSTSEC-2026-0195 \
--ignore RUSTSEC-2026-0235
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,13 @@ provider, because this pod has no token endpoint yet.
later. The nearest ACL wins outright and does not merge with its ancestors'. The WAC
specification requires that, and implementations most often get it wrong.
- **Solid-OIDC authentication**: access tokens bound to a key with DPoP (Demonstration of
Proof-of-Possession, RFC 9449), ES256 and RS256 proofs, and the token issuer cross-checked
against the `solid:oidcIssuer` in the WebID profile it claims. A plain `Bearer` credential
is refused: this pod requires the stronger binding, and the cost is that an issuer
configured to hand out non-DPoP tokens will not work against it.
Proof-of-Possession, RFC 9449), ES256 and RS256 for both the proof and the access token's
own signature, and the token issuer cross-checked against the `solid:oidcIssuer` in the
WebID profile it claims. Which algorithm a token is verified under follows the key the
issuer published, never the token's own header, so widening past one algorithm gives a
token no say in how it is checked. A plain `Bearer` credential is refused: this pod
requires the stronger binding, and the cost is that an issuer configured to hand out
non-DPoP tokens will not work against it.
- **An SSRF control** on the fetches that happen while a request is still unauthenticated.
The token names the URLs, so they are attacker-chosen. The address filter runs inside the
DNS resolver, so a name cannot answer public for the check and private for the connection.
Expand Down
24 changes: 24 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,30 @@ match a name the client never typed.
With `--op-signing-keys` set it must also be an origin root, since the discovery document it
implies hangs off `/.well-known/`.

## Running it under a supervisor

**Liveness is `GET /.well-known/health`**, unauthenticated, always served, whether or not
the OP is on. It answers `200` with `{"status":"pass"}` as `application/health+json`.

It reports one thing: this process is up and serving requests. It is deliberately not a
readiness check and touches neither the triple store nor the blob store, because an
unauthenticated probe that queried the store would be a store round trip anyone on the
network could ask for, without a credential and without a rate limit in front of it. A pod
whose store has gone away still answers this route, and fails the requests that need the
store. If you want to alert on the store, alert on those.

**`SIGTERM` drains.** The pod stops accepting connections and waits for the requests already
in flight before the process exits; `SIGINT` (Ctrl-C) does the same. This matters more here
than it would elsewhere, because a `rocksdb:` directory belongs to one process (below), so
every deployment is a stop and a start with no second replica to cover the gap. Nothing is
corrupted without it, each update sequence is atomic, but a caller learns the outcome of its
own write by having the connection dropped, and a `POST` is not safe to retry blind.

Give the supervisor a stop timeout longer than your slowest request rather than the default
it ships with (systemd's `TimeoutStopSec` is 90s, Kubernetes' `terminationGracePeriodSeconds`
is 30s), or it will send `SIGKILL` mid-drain and you are back where you started. A second
`SIGTERM` is not special-cased; `SIGKILL` is how you stop waiting.

## Where the data lives

--rdf-store memory (default) triples in this process, gone on restart
Expand Down
24 changes: 17 additions & 7 deletions docs/uri-space.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,23 @@ this one is yours:
refused by the router: no handler runs, no WAC decision is taken, and a valid credential
does not change the answer, the owner's included. It holds whether or not the pod is
running as an identity provider.
- **`GET` serves the names the pod implements, and `404`s the rest.** Two names are
implemented, and only while the OP is on (`--op-signing-keys`):

| Path | Answer |
|---|---|
| `/.well-known/openid-configuration` | the OIDC discovery document, `application/json` |
| `/.well-known/jwks.json` | the public key set, `application/jwk-set+json` |
- **`GET` serves the names the pod implements, and `404`s the rest.** Three names are
implemented. One is served always; the other two only while the OP is on
(`--op-signing-keys`):

| Path | Answer | When |
|---|---|---|
| `/.well-known/health` | liveness, `{"status":"pass"}` as `application/health+json` | always |
| `/.well-known/openid-configuration` | the OIDC discovery document, `application/json` | OP on |
| `/.well-known/jwks.json` | the public key set, `application/jwk-set+json` | OP on |

`health` is here rather than at `/health` because `/health` is a name you are entitled to
store a resource at, and a route there would shadow it: the probe would be answered, the
graph would stay, and no write method would reach it. It is not an IANA-registered
well-known name (RFC 8615 §3 asks that names be registered); its shape follows
`draft-inadarei-api-health-check`. It reports that the process is serving and reads
nothing: a pod whose store is unreachable still answers it, and fails the requests that
need the store.

Both are served to a request carrying **no credentials at all**, because a verifier reads
issuer metadata before it holds anything to present. A request carrying *invalid* credentials
Expand Down
4 changes: 4 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
# the whole tree with E0514.
clippy
rustfmt
# The advisory check CI runs. Here rather than installed in the
# workflow so the version is pinned by flake.lock like every other
# tool, and so `cargo audit` locally is the same command CI runs.
cargo-audit
clang
libclang
pkg-config
Expand Down
156 changes: 146 additions & 10 deletions src/auth/access_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use josekit::jwk::Jwk;
use josekit::jws::ES256;
use josekit::jws::{JwsVerifier, ES256, RS256};
use josekit::jwt;
use serde_json::Value;

Expand All @@ -28,9 +28,24 @@ pub struct AccessClaims {
/// or verification error returns the matching [`AuthError`] rather than a
/// silent pass.
///
/// The verification algorithm is pinned to ES256 regardless of what the
/// token's own header claims (its `alg` is never read for this purpose),
/// which forecloses `alg: none` and algorithm-confusion attacks.
/// The verification algorithm comes from the RESOLVED KEY, never from the
/// token's own header, which is what forecloses `alg: none` and
/// algorithm-confusion attacks: a header claiming `none` or `HS256` cannot
/// nominate the verifier it would like to be checked under, and josekit's
/// `decode_with_verifier` separately refuses a token whose header algorithm
/// disagrees with the verifier it was handed.
///
/// [`verifier_for`] is what maps the key to that algorithm, RS256 for RSA
/// and ES256 for EC P-256, the same two `op::keys::signer_for` signs with.
/// Before that pairing existed this function pinned ES256 outright, which
/// made an RS256 issuer indistinguishable from a forgery and, worse, made
/// this pod reject the tokens its own OP mints from an RSA key: the key set
/// signs RS256 for a key that declares it, publishes `RS256` in the JWKS,
/// and advertises it in `id_token_signing_alg_values_supported`, so the
/// verify side pinning ES256 was a pod that could not read its own
/// signature. ADR-3 settled the identical question one layer down for DPoP
/// proofs ("accepting only ES256 was stricter than the specification
/// without a reason"); this is that decision applied to the access token.
pub async fn verify_access_token(
token: &str,
resolver: &dyn JwksResolver,
Expand All @@ -56,13 +71,10 @@ pub async fn verify_access_token(
let jwk = select_key(&jwks.keys, kid)?;

// Verify the JWS signature against the resolved PUBLIC key, with the
// verifier built for the pinned ES256 algorithm, not whatever `alg`
// the header claims.
let verifier = ES256
.verifier_from_jwk(jwk)
.map_err(|_| AuthError::BadSignature)?;
// verifier the KEY selects, not whatever `alg` the header claims.
let verifier = verifier_for(jwk)?;
let (verified_payload, _verified_header) =
jwt::decode_with_verifier(token, &verifier).map_err(|_| AuthError::BadSignature)?;
jwt::decode_with_verifier(token, &*verifier).map_err(|_| AuthError::BadSignature)?;

// From here on, every claim comes from `verified_payload`, its
// signature has been checked against the issuer's key.
Expand Down Expand Up @@ -148,9 +160,50 @@ fn decode_segment(segment: &str) -> Result<Value, AuthError> {
.map_err(|_| AuthError::Malformed("invalid JSON segment".to_string()))
}

/// The verifier for `jwk`, chosen by the key's own type: RS256 for an RSA
/// key, ES256 for an EC P-256 one. The mirror of `op::keys::signer_for`, and
/// the two have to stay in step: a key this pod signs with and cannot verify
/// is a pod that rejects its own tokens.
///
/// Chosen from the KEY, never from the token header. That is the whole
/// safety argument for widening past one algorithm: the algorithm is a
/// property of the key the issuer published under its own `jwks_uri`, so a
/// token can no more choose it than it can choose the key. A header claiming
/// `none`, `HS256`, or anything else the resolved key does not support is
/// refused by `decode_with_verifier` before a signature is inspected.
///
/// A key of any other type is [`AuthError::UnsupportedKeyType`] rather than
/// [`AuthError::BadSignature`]: an issuer whose keys this pod cannot handle
/// is a configuration fact an operator can act on, and reporting it as a
/// forgery is exactly what makes it undiagnosable from the log.
fn verifier_for(jwk: &Jwk) -> Result<Box<dyn JwsVerifier>, AuthError> {
let built: Result<Box<dyn JwsVerifier>, _> = match (jwk.key_type(), jwk.curve()) {
("RSA", _) => RS256
.verifier_from_jwk(jwk)
.map(|v| Box::new(v) as Box<dyn JwsVerifier>),
("EC", Some("P-256")) => ES256
.verifier_from_jwk(jwk)
.map(|v| Box::new(v) as Box<dyn JwsVerifier>),
_ => return Err(AuthError::UnsupportedKeyType),
};
// A key of a type this pod handles that still will not build a verifier
// (an RSA modulus under josekit's 2048-bit floor, a malformed member) is
// a key nothing can be verified against, which is a refusal, not a
// capability gap.
built.map_err(|_| AuthError::BadSignature)
}

/// Select the JWK to verify against: by `kid` if the header names one,
/// else the first signing-capable key (no `use`/`key_ops` restriction, or
/// one that explicitly allows verification).
///
/// Whichever key this returns is also what picks the algorithm
/// ([`verifier_for`]), so a JWKS mixing key types stays coherent: the token
/// is checked under the algorithm of the key it selected, never under one it
/// asked for. The `kid`-less arm is best-effort by nature, and it is the
/// issuer's own JWKS that decides whether it can be: a set holding more than
/// one usable key and a token that names none of them is an issuer not
/// saying which key signed. Every issuer in practice sends `kid`.
fn select_key<'a>(keys: &'a [Jwk], kid: Option<&str>) -> Result<&'a Jwk, AuthError> {
if let Some(kid) = kid {
return keys
Expand Down Expand Up @@ -295,6 +348,89 @@ mod tests {
assert!(verify_access_token(&forged, &resolver, 1_000).await.is_err());
}

/// The regression this pairing exists for. RS256 is the algorithm OIDC
/// Core requires every provider to support, and pinning ES256 rejected
/// every issuer that uses it, as a forged signature.
#[tokio::test]
async fn an_rs256_token_from_an_rsa_issuer_verifies() {
let idp = TestIdp::new_rsa();
let client = TestClient::new();
let resolver = StaticJwksResolver::new("https://idp.example/", idp.jwks());
let jkt = client.jkt();
let at = idp.mint_access_token("https://alice.example/card#me", &jkt, 9_999_999_999);

let claims = verify_access_token(&at, &resolver, 1_000).await.unwrap();
assert_eq!(claims.webid, "https://alice.example/card#me");
assert_eq!(claims.jkt, jkt);
assert_eq!(claims.issuer, "https://idp.example/");
}

/// Widening to a second algorithm must not let a token pick which one it
/// is checked under. The key the resolver hands back decides, so a token
/// signed by the wrong key type is refused rather than routed to a
/// verifier that happens to match its header.
#[tokio::test]
async fn an_es256_token_does_not_verify_against_an_rsa_jwks() {
let ec_idp = TestIdp::new();
let rsa_idp = TestIdp::new_rsa();
let client = TestClient::new();
// The resolver publishes the RSA issuer's key; the token is ES256.
let resolver = StaticJwksResolver::new("https://idp.example/", rsa_idp.jwks());
let at = ec_idp.mint_access_token("https://alice.example/card#me", &client.jkt(), 9_999_999_999);

assert!(matches!(
verify_access_token(&at, &resolver, 1_000).await,
Err(AuthError::BadSignature)
));
}

/// And the reverse direction, so neither arm is the one carrying the
/// test on its own.
#[tokio::test]
async fn an_rs256_token_does_not_verify_against_an_ec_jwks() {
let ec_idp = TestIdp::new();
let rsa_idp = TestIdp::new_rsa();
let client = TestClient::new();
let resolver = StaticJwksResolver::new("https://idp.example/", ec_idp.jwks());
let at = rsa_idp.mint_access_token("https://alice.example/card#me", &client.jkt(), 9_999_999_999);

assert!(matches!(
verify_access_token(&at, &resolver, 1_000).await,
Err(AuthError::BadSignature)
));
}

/// A key type this pod cannot verify is a fact about the issuer's
/// configuration, and it must not be reported as a forged signature:
/// that is what makes an operator chase an attack that is not happening.
#[test]
fn an_unsupported_key_type_is_distinguished_from_a_bad_signature() {
// OKP/Ed25519 is a real JWKS key type, and one `verifier_for` has no
// arm for.
let okp = Jwk::generate_ed_key(josekit::jwk::alg::ed::EdCurve::Ed25519).unwrap();
assert!(matches!(
verifier_for(&okp),
Err(AuthError::UnsupportedKeyType)
));

// An EC key on a curve other than P-256 is the same class of answer,
// not an ES256 verification that fails later.
let p384 = Jwk::generate_ec_key(josekit::jwk::alg::ec::EcCurve::P384).unwrap();
assert!(matches!(
verifier_for(&p384),
Err(AuthError::UnsupportedKeyType)
));
}

/// An RSA key below josekit's 2048-bit floor is a key nothing can be
/// verified against, which is a refusal rather than a capability gap:
/// the pod handles RSA, this particular key is unusable.
#[test]
fn an_undersized_rsa_key_is_a_refusal_not_an_unsupported_type() {
let weak = Jwk::generate_rsa_key(1024).unwrap();
assert!(matches!(verifier_for(&weak), Err(AuthError::BadSignature)));
}

#[test]
fn parse_audience_accepts_string_array_or_missing() {
assert_eq!(
Expand Down
Loading
Loading