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");
}
}