Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-auth-oidc/src/fake_op.rs

//! **A fake OpenID provider, in process** (feature `test-support`) — the
//! relying party's test double: discovery, JWKS, `/authorize` (which signs in
//! whoever the test says, and answers with a 302 to the redirect URI) and
//! `/token` (client_secret_basic, single-use codes, PKCE S256 checked), over
//! plain HTTP on `127.0.0.1:0` — or https under a throwaway CA ([`FakeOp::start_https`]). ID tokens are RS256 with the crate's
//! throwaway test key ([`crate::test_support`]).
//!
//! [`Tweak`] breaks exactly one thing in the NEXT token: the audience, the
//! expiry, the nonce, the key, the issuer, the algorithm — so a relying party
//! can be shown refusing each.
//!
//! Never in a release build: the module exists only behind `test-support`.

use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde_json::json;

use crate::test_support::{jwks_json, TEST_KID, TEST_RSA_PEM};

/// A second throwaway RSA key the fake signs with under [`Tweak::ForeignKey`]
/// (same `kid`, another key: the signature cannot verify). Test-only.
pub const FOREIGN_RSA_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDEdcfUu3tmP1U6\nHKaF89FQ2oWtzQPeZtNzxhNWj8tnscoMM95wJ1cUQ9hzUciFejlXw57CB6ORBGSg\nas0GZk0bVhZGJzMzNEbsPYCRxOoNZnpF/MK2875ZG6Fdc5ulYGrmo9C+03mNr9U+\nm1J06k9C9omt3yE7KBCcwD2lqyu0bDUqyf8tsyBe0z96g63RCTYLkmpCi5rM5eDJ\nLot8vKSRzeZmHfaIuHV8ff4sEjAYPF45BusLSgWCmJyNlXRhAS/z94KBeRt2wWR/\nHgs5/AmjC9kaOvJQ77U2f3QkAHh5/AQh4LXt3E5px+ZQWskuA2rbPhgus4HEjxuc\ngNSh7PkJAgMBAAECggEAQdJqZOJ5uiyu+9zciBXEBBBq6Gu/h5Sw9OGg2ggscK4X\nEVaRiMcNfFss2ppEkj/8lWSTvNN9YFl1829FCQdefzGtHc0FcPrrZjxG5s/wURQM\nFIXWoSByHn3M5zgUJmjy+BpblovY9nk6e5LjogtdCgT65PfUtPK7+iDIpsh+SKC/\n0oHz/uBEVtUukN6g937u7LnHO31P2S0HnjxuOXvcx5Uqj+g/XGdjdnZEla8Zh4mJ\nhedBz83qSQjL50FlQSbp0Xf7elZNyaxlVNdWf+K+Ju+owRfNQX0fdm0C3AGKhqyZ\nXKLW+gslISU+fNNlMu5XKsOWXG6Q4JZZXgmzLcFoFwKBgQDxZK9TPXdGIeY3AU4K\nUdIlxBUuaZH9iUvDFpdVK+zpXOp4UbGLvbDLgSKp+ZtEsZhEsvAwI40HeC8CQ+LY\nfvT36Wx812Zz9d2xzEfd2l4TSe3Ct9v9ZNE7RojLA2zVyF88vSMircNLyfGxIZmj\n15F7fRwCNOkiRem2Lld6I1tZAwKBgQDQWQ4vV4XL5Xpqb8lwtZlgya/8Q0BJWu7Q\ne8maMsby9cSPuHvWt7bOKr3aDak+HQIvq6gCTDY+ZHn49Rpoag7vjyvJgbBkSk5k\n79b7yp6V6lZE9uMSN71wogwoBDwwCysvmRAx0AZ1fXq/gLq+rWiB9x+5NKyxFPGo\nUxJCsvr6AwKBgGbbRQ2N8kWy6hpj9vHUvVuUOoZFO4yxtNOSy7bh+ZfSHKJaBPIL\nSMrd4nxUvEE//ya+rDOG4m/dWQJ5FK61IgKEttJXIfOlPuqkpba1dkJ3zNScbSOC\nktjUDDOc6QIK1/HUkaUUL1Qa315fd8zlbLsWqxmRnAkbiyMyidmkOScrAoGAV0W2\nh2YmCW6qhIMm4itvf/d73DNfblrExlfT7t9uLr98AqfIV/zEej+1Hu2yAu6rx8Qj\nsDK0VhZIcVxaH3qtnvNd6B6HALuPcwB0ESyimTF7yY+RNkRa+h8m8Gw1ufozsNBw\n1CzLR9Oz4xvNHaoNqMMXasEBxXt8boG9mGrYbnkCgYEAo/3QjoOIV4RoqBhxrTU5\nyi4wn1t36q7jlkY+VFM93NgTQHp4L/zb0FEOjYQYjehWZinmSNDdkB/esj3rSWC2\nmlJp40Q7vpvrmni+zcKBdG7A9WxxYLgXNaVWou0BNHQUnrysklj8HH3NAFljuhTM\nlw++ck6M4vmdEoA9M2hLcao=\n-----END PRIVATE KEY-----\n";

/// Who the fake signs in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Person {
    pub sub: String,
    pub preferred_username: String,
    pub email: String,
    pub name: String,
    /// The `email_verified` claim: `None` sends none (an IdP that does not
    /// say, a self-service sign-up), `Some(b)` sends `b`.
    pub email_verified: Option<bool>,
}

impl Person {
    /// A person whose `sub` is `sub` and whose user name and email are `email`.
    pub fn new(sub: &str, email: &str, name: &str) -> Person {
        Person {
            sub: sub.to_owned(),
            preferred_username: email.to_owned(),
            email: email.to_owned(),
            name: name.to_owned(),
            email_verified: None,
        }
    }
}

/// One thing wrong with the NEXT token the fake issues.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tweak {
    None,
    /// `aud` is another client.
    WrongAudience,
    /// Expired ten minutes ago.
    Expired,
    /// The nonce is another login's.
    WrongNonce,
    /// Signed with another RSA key under the same `kid`.
    ForeignKey,
    /// `iss` is another issuer.
    WrongIssuer,
    /// HS256, keyed with the public modulus (the algorithm-confusion forgery).
    HmacConfusion,
}

struct Pending {
    person: Person,
    nonce: String,
    challenge: String,
    redirect_uri: String,
    client_id: String,
}

struct Inner {
    person: Person,
    tweak: Tweak,
    codes: HashMap<String, Pending>,
    tokens_issued: usize,
    seq: u64,
}

/// A running fake OpenID provider. Dropped: it stops answering.
pub struct FakeOp {
    issuer: String,
    client_id: String,
    client_secret: String,
    inner: Arc<Mutex<Inner>>,
    stop: Arc<std::sync::atomic::AtomicBool>,
    /// The throwaway CA the fake's https certificate is issued under (PEM), when it serves
    /// https ([`FakeOp::start_https`]).
    ca_pem: Option<String>,
}

fn now() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

/// A throwaway CA and a server certificate for the address `at` under it: the server's TLS
/// configuration and the CA's PEM. Test-only keys, minted per fake, never kept.
fn throwaway_tls(at: std::net::IpAddr) -> Result<(Arc<rustls::ServerConfig>, String), String> {
    use rcgen::{BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType};
    let e = |w: &'static str| move |x: rcgen::Error| format!("the fake's {w}: {x}");
    let ca_key = KeyPair::generate().map_err(e("CA key"))?;
    let mut ca = CertificateParams::new(Vec::<String>::new()).map_err(e("CA"))?;
    ca.distinguished_name.push(DnType::CommonName, "nornir-auth-oidc fake_op throwaway CA");
    ca.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
    ca.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::DigitalSignature];
    let ca_cert = ca.self_signed(&ca_key).map_err(e("CA"))?;
    let ca = Issuer::new(ca, ca_key);
    let key = KeyPair::generate().map_err(e("key"))?;
    let mut leaf = CertificateParams::new(Vec::<String>::new()).map_err(e("certificate"))?;
    leaf.distinguished_name.push(DnType::CommonName, at.to_string());
    leaf.subject_alt_names = vec![SanType::IpAddress(at)];
    leaf.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
    let leaf = leaf.signed_by(&key, &ca).map_err(e("certificate"))?;
    let chain = vec![leaf.der().clone()];
    let key = rustls::pki_types::PrivateKeyDer::try_from(key.serialize_der()).map_err(|x| format!("the fake's key: {x}"))?;
    let config = rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
        .with_safe_default_protocol_versions()
        .map_err(|x| x.to_string())?
        .with_no_client_auth()
        .with_single_cert(chain, key)
        .map_err(|x| x.to_string())?;
    Ok((Arc::new(config), ca_cert.pem()))
}

fn lock(m: &Mutex<Inner>) -> std::sync::MutexGuard<'_, Inner> {
    m.lock().unwrap_or_else(|p| p.into_inner())
}

fn decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'+' => out.push(b' '),
            b'%' if i + 2 < b.len() => {
                if let Some(v) = std::str::from_utf8(&b[i + 1..i + 3])
                    .ok()
                    .and_then(|h| u8::from_str_radix(h, 16).ok())
                {
                    out.push(v);
                    i += 2;
                } else {
                    out.push(b'%');
                }
            }
            c => out.push(c),
        }
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

fn query_of(url: &str) -> HashMap<String, String> {
    url.split_once('?')
        .map(|(_, q)| q)
        .unwrap_or("")
        .split('&')
        .filter_map(|p| p.split_once('='))
        .map(|(k, v)| (decode(k), decode(v)))
        .collect()
}

impl FakeOp {
    /// Bind `127.0.0.1:0` and serve. The issuer is `http://127.0.0.1:<port>`.
    pub fn start(client_id: &str, client_secret: &str) -> std::io::Result<FakeOp> {
        FakeOp::start_with(std::net::IpAddr::from([127, 0, 0, 1]), client_id, client_secret, None)
    }

    fn start_with(at: std::net::IpAddr, client_id: &str, client_secret: &str, tls: Option<(Arc<rustls::ServerConfig>, String)>) -> std::io::Result<FakeOp> {
        let listener = TcpListener::bind((at, 0))?;
        let scheme = if tls.is_some() { "https" } else { "http" };
        let issuer = format!("{scheme}://{}", listener.local_addr()?);
        let inner = Arc::new(Mutex::new(Inner {
            person: Person::new("00u-bob", "bob@acme.example", "Bob Example"),
            tweak: Tweak::None,
            codes: HashMap::new(),
            tokens_issued: 0,
            seq: 0,
        }));
        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
        let (config, ca_pem) = match tls {
            Some((c, ca)) => (Some(c), Some(ca)),
            None => (None, None),
        };
        let op = FakeOp {
            issuer: issuer.clone(),
            client_id: client_id.to_owned(),
            client_secret: client_secret.to_owned(),
            inner: Arc::clone(&inner),
            stop: Arc::clone(&stop),
            ca_pem,
        };
        let (cid, secret) = (client_id.to_owned(), client_secret.to_owned());
        listener.set_nonblocking(false)?;
        // A32-14, stays a thread: the fake OP's accept loop, a test double (feature test-support), never in a box.
        std::thread::spawn(move || {
            for stream in listener.incoming() {
                if stop.load(std::sync::atomic::Ordering::SeqCst) {
                    break;
                }
                let Ok(mut stream) = stream else { continue };
                let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
                match &config {
                    None => {
                        let _ = serve(&mut stream, &issuer, &cid, &secret, &inner);
                    }
                    Some(c) => {
                        let Ok(conn) = rustls::ServerConnection::new(Arc::clone(c)) else { continue };
                        let mut tls = rustls::StreamOwned::new(conn, stream);
                        if serve(&mut tls, &issuer, &cid, &secret, &inner).is_ok() {
                            tls.conn.send_close_notify();
                            let _ = tls.flush();
                        }
                    }
                }
            }
        });
        Ok(op)
    }

    /// **The fake over https** — `https://127.0.0.1:<port>`, its certificate issued for
    /// `127.0.0.1` under a throwaway CA minted here ([`FakeOp::ca_pem`]): for a relying
    /// party that sends people only to an https provider and is handed the provider's CA.
    pub fn start_https(client_id: &str, client_secret: &str) -> std::io::Result<FakeOp> {
        FakeOp::start_https_on(std::net::IpAddr::from([127, 0, 0, 1]), client_id, client_secret)
    }

    /// [`FakeOp::start_https`] bound on `at` (port 0), its certificate issued for `at`: an IdP
    /// on a network of its own (a bench's hub address that the relying party admits as the
    /// operator's internal IdP network), not on the relying party's loopback.
    pub fn start_https_on(at: std::net::IpAddr, client_id: &str, client_secret: &str) -> std::io::Result<FakeOp> {
        let (config, ca_pem) = throwaway_tls(at).map_err(std::io::Error::other)?;
        FakeOp::start_with(at, client_id, client_secret, Some((config, ca_pem)))
    }

    /// The throwaway CA (PEM) of an https fake; `None` over plain http.
    pub fn ca_pem(&self) -> Option<&str> {
        self.ca_pem.as_deref()
    }

    pub fn issuer(&self) -> &str {
        &self.issuer
    }

    pub fn client_id(&self) -> &str {
        &self.client_id
    }

    pub fn client_secret(&self) -> &str {
        &self.client_secret
    }

    /// Sign in this person from now on.
    pub fn set_person(&self, person: Person) {
        lock(&self.inner).person = person;
    }

    /// Break one thing in the next token.
    pub fn tweak_next(&self, tweak: Tweak) {
        lock(&self.inner).tweak = tweak;
    }

    /// How many tokens the fake has handed out.
    pub fn tokens_issued(&self) -> usize {
        lock(&self.inner).tokens_issued
    }

    /// **`/authorize`, without a browser**: the redirect the fake would answer
    /// a browser with (the redirect URI + `code` + `state`).
    pub fn authorize(&self, authorize_url: &str) -> Result<String, String> {
        authorize(&self.inner, &self.client_id, authorize_url)
    }
}

impl Drop for FakeOp {
    fn drop(&mut self) {
        self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
        // Wake the accept loop so it sees the flag.
        let _ = TcpStream::connect(self.issuer.trim_start_matches("https://").trim_start_matches("http://"));
    }
}

fn authorize(inner: &Mutex<Inner>, client_id: &str, url: &str) -> Result<String, String> {
    let q = query_of(url);
    let get = |k: &str| q.get(k).cloned().unwrap_or_default();
    if get("response_type") != "code" {
        return Err("response_type must be code".into());
    }
    if get("client_id") != client_id {
        return Err("unknown client".into());
    }
    if get("code_challenge_method") != "S256" || get("code_challenge").is_empty() {
        return Err("PKCE S256 is required".into());
    }
    let redirect_uri = get("redirect_uri");
    if redirect_uri.is_empty() {
        return Err("redirect_uri is required".into());
    }
    let mut inner = lock(inner);
    inner.seq += 1;
    let code = {
        use sha2::Digest;
        crate::rp::random_token(&sha2::Sha256::digest(format!(
            "{}-{}-{}",
            inner.seq,
            now(),
            get("state")
        )))
    };
    let person = inner.person.clone();
    inner.codes.insert(
        code.clone(),
        Pending {
            person,
            nonce: get("nonce"),
            challenge: get("code_challenge"),
            redirect_uri: redirect_uri.clone(),
            client_id: client_id.to_owned(),
        },
    );
    let sep = if redirect_uri.contains('?') { '&' } else { '?' };
    Ok(format!(
        "{redirect_uri}{sep}code={}&state={}",
        crate::rp::percent_encode(&code),
        crate::rp::percent_encode(&get("state"))
    ))
}

fn respond<S: Write>(
    stream: &mut S,
    status: &str,
    content_type: &str,
    extra: &str,
    body: &str,
) -> std::io::Result<()> {
    write!(
        stream,
        "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n{extra}\r\n{body}",
        body.len()
    )?;
    stream.flush()
}

fn serve<S: Read + Write>(
    stream: &mut S,
    issuer: &str,
    client_id: &str,
    secret: &str,
    inner: &Mutex<Inner>,
) -> std::io::Result<()> {
    let mut reader = BufReader::new(&mut *stream);
    let mut line = String::new();
    reader.read_line(&mut line)?;
    let mut parts = line.split_whitespace();
    let method = parts.next().unwrap_or_default().to_owned();
    let target = parts.next().unwrap_or_default().to_owned();
    let mut length = 0usize;
    let mut authorization = String::new();
    loop {
        let mut h = String::new();
        if reader.read_line(&mut h)? == 0 || h == "\r\n" || h == "\n" {
            break;
        }
        if let Some((k, v)) = h.split_once(':') {
            match k.trim().to_ascii_lowercase().as_str() {
                "content-length" => length = v.trim().parse().unwrap_or(0),
                "authorization" => authorization = v.trim().to_owned(),
                _ => {}
            }
        }
    }
    let mut body = vec![0u8; length.min(1 << 20)];
    reader.read_exact(&mut body)?;
    drop(reader);
    let path = target.split('?').next().unwrap_or_default();
    match (method.as_str(), path) {
        ("GET", "/.well-known/openid-configuration") => {
            let doc = json!({
                "issuer": issuer,
                "authorization_endpoint": format!("{issuer}/authorize"),
                "token_endpoint": format!("{issuer}/token"),
                "jwks_uri": format!("{issuer}/jwks"),
                "response_types_supported": ["code"],
                "subject_types_supported": ["public"],
                "id_token_signing_alg_values_supported": ["RS256"],
                "code_challenge_methods_supported": ["S256"],
            });
            respond(
                stream,
                "200 OK",
                "application/json",
                "",
                &doc.to_string(),
            )
        }
        ("GET", "/jwks") => respond(stream, "200 OK", "application/json", "", &jwks_json()),
        ("GET", "/authorize") => match authorize(inner, client_id, &target) {
            Ok(back) => respond(
                stream,
                "302 Found",
                "text/plain",
                &format!("Location: {back}\r\n"),
                "signed in",
            ),
            Err(why) => respond(stream, "400 Bad Request", "text/plain", "", &why),
        },
        ("POST", "/token") => {
            let (status, answer) = token(
                issuer,
                client_id,
                secret,
                &authorization,
                &String::from_utf8_lossy(&body),
                inner,
            );
            respond(stream, status, "application/json", "", &answer)
        }
        _ => respond(
            stream,
            "404 Not Found",
            "text/plain",
            "",
            "no such endpoint",
        ),
    }
}

fn oauth_error(error: &str, description: &str) -> (&'static str, String) {
    (
        "400 Bad Request",
        json!({ "error": error, "error_description": description }).to_string(),
    )
}

fn token(
    issuer: &str,
    client_id: &str,
    secret: &str,
    authorization: &str,
    body: &str,
    inner: &Mutex<Inner>,
) -> (&'static str, String) {
    if authorization != crate::rp::basic_auth_header(client_id, secret) {
        return (
            "401 Unauthorized",
            json!({ "error": "invalid_client" }).to_string(),
        );
    }
    let form: HashMap<String, String> = body
        .split('&')
        .filter_map(|p| p.split_once('='))
        .map(|(k, v)| (decode(k), decode(v)))
        .collect();
    let get = |k: &str| form.get(k).cloned().unwrap_or_default();
    if get("grant_type") != "authorization_code" {
        return oauth_error("unsupported_grant_type", "authorization_code only");
    }
    let mut inner = lock(inner);
    let Some(pending) = inner.codes.remove(&get("code")) else {
        return oauth_error("invalid_grant", "the code is unknown or was already used");
    };
    if pending.redirect_uri != get("redirect_uri") || pending.client_id != client_id {
        return oauth_error("invalid_grant", "the redirect_uri differs");
    }
    match crate::rp::Pkce::from_verifier(&get("code_verifier")) {
        Ok(p) if p.challenge == pending.challenge => {}
        _ => return oauth_error("invalid_grant", "the PKCE verifier does not match"),
    }
    let tweak = std::mem::replace(&mut inner.tweak, Tweak::None);
    inner.tokens_issued += 1;
    drop(inner);
    let t = now();
    let p = &pending.person;
    let mut claims = json!({
        "iss": issuer, "sub": p.sub, "aud": client_id, "exp": t + 300, "iat": t,
        "nonce": pending.nonce, "preferred_username": p.preferred_username, "email": p.email, "name": p.name,
    });
    if let Some(verified) = p.email_verified {
        claims["email_verified"] = json!(verified);
    }
    match tweak {
        Tweak::WrongAudience => claims["aud"] = json!("another-client"),
        Tweak::Expired => {
            claims["exp"] = json!(t - 600);
            claims["iat"] = json!(t - 900);
        }
        Tweak::WrongNonce => claims["nonce"] = json!("another-login"),
        Tweak::WrongIssuer => claims["iss"] = json!("http://127.0.0.1:1/another-issuer"),
        _ => {}
    }
    let id_token = match tweak {
        Tweak::HmacConfusion => {
            let mut header = Header::new(Algorithm::HS256);
            header.kid = Some(TEST_KID.to_owned());
            encode(
                &header,
                &claims,
                &EncodingKey::from_secret(crate::test_support::TEST_N.as_bytes()),
            )
        }
        other => {
            let mut header = Header::new(Algorithm::RS256);
            header.kid = Some(TEST_KID.to_owned());
            let pem = if other == Tweak::ForeignKey {
                FOREIGN_RSA_PEM
            } else {
                TEST_RSA_PEM
            };
            encode(
                &header,
                &claims,
                &EncodingKey::from_rsa_pem(pem.as_bytes()).expect("a test key"),
            )
        }
    }
    .expect("the fake signs");
    ("200 OK", json!({ "access_token": "fake-access", "token_type": "Bearer", "expires_in": 300, "id_token": id_token }).to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// One GET over TLS, the CA as the only root and `127.0.0.1` as the name: (status line, body).
    fn get_https(issuer: &str, ca_pem: &str, path: &str) -> Result<(String, String), String> {
        let addr = issuer.strip_prefix("https://").ok_or("not an https issuer")?;
        let mut roots = rustls::RootCertStore::empty();
        let der = pem_der(ca_pem).ok_or("no CERTIFICATE block")?;
        roots.add(rustls::pki_types::CertificateDer::from(der)).map_err(|e| e.to_string())?;
        let cfg = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
            .with_safe_default_protocol_versions()
            .map_err(|e| e.to_string())?
            .with_root_certificates(roots)
            .with_no_client_auth();
        let name = rustls::pki_types::ServerName::try_from("127.0.0.1".to_owned()).map_err(|e| e.to_string())?;
        let conn = rustls::ClientConnection::new(Arc::new(cfg), name).map_err(|e| e.to_string())?;
        let sock = TcpStream::connect(addr).map_err(|e| e.to_string())?;
        let mut tls = rustls::StreamOwned::new(conn, sock);
        write!(tls, "GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n").map_err(|e| e.to_string())?;
        let mut raw = Vec::new();
        let _ = tls.read_to_end(&mut raw);
        let text = String::from_utf8_lossy(&raw).to_string();
        let (head, body) = text.split_once("\r\n\r\n").ok_or_else(|| format!("no HTTP answer: {text:?}"))?;
        Ok((head.lines().next().unwrap_or_default().to_owned(), body.to_owned()))
    }

    fn pem_der(pem: &str) -> Option<Vec<u8>> {
        use base64::Engine as _;
        let b64: String = pem.lines().skip_while(|l| !l.starts_with("-----BEGIN CERTIFICATE")).skip(1).take_while(|l| !l.starts_with("-----END")).collect();
        base64::engine::general_purpose::STANDARD.decode(b64).ok()
    }

    /// ★ **The fake serves https under its own throwaway CA** (gunnar-ui 41a04a4: the console
    /// sends a person only to an https IdP, so the bench's http fake was refused,
    /// `sso_error=unsafe-provider`): the issuer is https, discovery answers over TLS verified
    /// against the CA the fake hands out (a CA, as gunnar's `idp_ca` takes), and names https
    /// endpoints; a plain-http fake has no CA.
    #[test]
    fn the_fake_serves_https_under_its_own_ca() {
        let op = FakeOp::start_https("client-1", "s3cret").expect("an https fake");
        assert!(op.issuer().starts_with("https://127.0.0.1:"), "{}", op.issuer());
        let ca = op.ca_pem().expect("the CA it is issued under");
        assert!(ca.contains("BEGIN CERTIFICATE") && !ca.contains("PRIVATE KEY"));
        let (status, body) = get_https(op.issuer(), ca, "/.well-known/openid-configuration").expect("discovery over TLS");
        assert!(status.contains("200"), "{status}");
        let doc: serde_json::Value = serde_json::from_str(&body).unwrap();
        assert_eq!(doc["issuer"], op.issuer());
        assert!(doc["token_endpoint"].as_str().unwrap().starts_with("https://"));
        let (status, _) = get_https(op.issuer(), ca, "/jwks").expect("the keys over TLS");
        assert!(status.contains("200"), "{status}");
        assert!(FakeOp::start("c", "s").unwrap().ca_pem().is_none());
    }

    #[test]
    fn the_fake_signs_in_and_the_relying_party_verifies() {
        let op = FakeOp::start("client-1", "s3cret").unwrap();
        let pkce = crate::rp::Pkce::from_verifier(&crate::rp::random_token(&[7u8; 32])).unwrap();
        let url = crate::rp::authorize_url(&crate::rp::AuthorizeRequest {
            authorization_endpoint: &format!("{}/authorize", op.issuer()),
            client_id: "client-1",
            redirect_uri: "http://127.0.0.1:9/cb",
            scopes: &[],
            state: "st-1",
            nonce: "n-1",
            code_challenge: &pkce.challenge,
            login_hint: None,
        })
        .unwrap();
        let back = op.authorize(&url).unwrap();
        let q = query_of(&back);
        assert_eq!(q.get("state").map(String::as_str), Some("st-1"));
        let code = q.get("code").cloned().unwrap();
        let body = crate::rp::form_body(&crate::rp::token_request_form(
            &code,
            "http://127.0.0.1:9/cb",
            &pkce.verifier,
            "client-1",
        ));
        let (status, answer) = token(
            op.issuer(),
            "client-1",
            "s3cret",
            &crate::rp::basic_auth_header("client-1", "s3cret"),
            &body,
            &op.inner,
        );
        assert_eq!(status, "200 OK", "{answer}");
        let t = crate::rp::parse_token_response(&answer).unwrap();
        let claims = crate::rp::verify_id_token(
            &t.id_token,
            &jwks_json(),
            &crate::rp::IdTokenExpect {
                issuer: op.issuer(),
                client_id: "client-1",
                nonce: "n-1",
                now_unix: now(),
                skew_secs: 60,
                algorithms: crate::rp::DEFAULT_ALGORITHMS,
            },
        )
        .unwrap();
        assert_eq!(claims.sub, "00u-bob");
        // The code is single use.
        let (status, _) = token(
            op.issuer(),
            "client-1",
            "s3cret",
            &crate::rp::basic_auth_header("client-1", "s3cret"),
            &body,
            &op.inner,
        );
        assert_eq!(status, "400 Bad Request");
    }
}