Workspaces, group mappings and sign-in methods now come from a configuration file synchronized at startup; workspaces dropped from it are disabled, never deleted. Instance administrators (a new role) see and help every workspace and may delete disabled ones; workspace administrators and employees are derived from the user's groups at every SSO sign-in or administrator edit, and the employee permission set belongs to the workspace. Self-registration is gone: users come from SSO (only when their groups map) or from the administrator. Break-glass bootstrap accounts get an HMAC-derived hourly password that all replicas agree on. First SSO sign-in links a matching email contact to the user. The demo runs on the same model with a generated per-start password. Co-Authored-By: Claude <noreply@anthropic.com>
294 lines
12 KiB
Rust
294 lines
12 KiB
Rust
//! A minimal OpenID provider for tests: discovery, JWKS, token and userinfo endpoints with
|
|
//! RS256 tokens. The private key is a throwaway test fixture and protects nothing.
|
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
|
use serde_json::{Value, json};
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
collections::HashMap,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
use tokio::{
|
|
io::{AsyncReadExt, AsyncWriteExt},
|
|
net::TcpListener,
|
|
};
|
|
|
|
const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----
|
|
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDI4xpMobGZ91uK
|
|
+uV7YFtGg0KorieswaP4ZhIqg3LkxIjpaMggyw0Fc4jliFPxn+uZAzKEF5lBrrdt
|
|
XBllxKayC6dLEHFBvrglZMwyzsj6fgssne/N1YRWsQsQ48d2KANzfGpkBB8/29g7
|
|
YhpNK6lWZo54yGFcFa2g68/yqbpQ6dSo191/CSqOnC+R2pdMe9NzI8kQBtZPkcft
|
|
hmQ05IQJoyNHgBgwUQO2vB7J23haATlvQUrZlv0ASFYgxJDKeLZOPvdLGEmezIvc
|
|
WZYdI5qEqiNDr9KRWLS/sDYxxOQkVxRP4OoWy5svru1ptJ3QEG50uUnPZkdpX/Ny
|
|
UaMFOieJAgMBAAECgf8UyqFa5XWp/ODExbcM7nE2ydLmiAuRJgvXmHfdLY72qqne
|
|
ajvq17OcvVVs3g3B3wE5PaKWPCAdhJdEtUCCB/HXTO25hOTOYAhTrdLuh6cvIUxF
|
|
wqlJGVNM7tM3ZS8mW8b7maLjMxQtep605CxYxjU5c+wD54yuIMkl58Q3bLENuM5G
|
|
Y5GeXwmAv4yrJXuFIe/h2F6A+p4IEQ64QF6VQ/rDeG8kniLXWNZjAndqGxPdLDjC
|
|
4YdXpSWeYM/dTGKUyt0SwE64vxytMNyoRWAO4i4v3JJbrST0SmAM2RwM803Ks0Ys
|
|
H9VvFoYt5kGhETOz4OKa3m6HrMwdUy7vnUYd870CgYEA59CLNpjU3Dl57RJRlHRw
|
|
2FAPM0uM9qBWiahDyhQAeHeypUFGeu1yZHZCH9Z5oOo28xRyAB27R8ozFX0JdPYl
|
|
HBuJH63boff4/DuzjmnAK92889m+cbyPcAd6BjOo545cMRiAXvu3b8cz2Mlks1bj
|
|
uWS5RIVbQvuiPqNEbHNHc5UCgYEA3diG58Ziug75V6k8U9wHyqWWRyvQdwdJQmey
|
|
FAB0LQdSsHUkPteGl2U6Ml7EL2AuqoZK5kVRK+8aGhBHyOlsjwj/sCfQfiTNDgve
|
|
MP7pWfUUjctO8ZvKHG3SG87BG2kLAW6l/vuhDfLwQe/PV8hC0yDegFfF1R4nQVYf
|
|
7XFv5yUCgYBftygEHw6k78Z8MLgmh/2gE05za5qEqqeQpz12TtPy6dmFinJDh49Y
|
|
E8wxwSjvXEXcEHERwzT063Q0RzCEbq3b8TKpNewd8HmKDEyTXO7QLfo65YlRBP5k
|
|
z3F+5g2zroA6dVdr4JtZN/Mca+t8cUAN6Jzmwflwqt3O5zL1ZJP4EQKBgQCt50Sp
|
|
f9x4mbBgRWvjGQKKmo9ZL0F1bSLCiWMRRuHLQohRhyJomK8LEvEx9xO3R6CNUER6
|
|
ii/9TquZbtW26zSMU7OLtUo8+EA+GJOOuOTX/cSk+pDh2QvwiS5BWiwKfqd/8ery
|
|
HPK9aoloRsK0v2mS85s35JZSYBbJoufYTCo6eQKBgGW1vIlLzHRMeboj82uW0yw2
|
|
Jkg6Ig45qtVhY89267E8cI7gdC92Y715nFR4v+0eiWo08k+FIaHyBsah0vklj1Q6
|
|
fj6ir9inIzkkrFmjs6u/g4h2GNGUXOJJwJzOU78C6oeB3G5OUM/pWFx0zypRGH/q
|
|
HpM1TJ5Fkyog20WBQS9+
|
|
-----END PRIVATE KEY-----";
|
|
const MODULUS: &str = "yOMaTKGxmfdbivrle2BbRoNCqK4nrMGj-GYSKoNy5MSI6WjIIMsNBXOI5YhT8Z_rmQMyhBeZQa63bVwZZcSmsgunSxBxQb64JWTMMs7I-n4LLJ3vzdWEVrELEOPHdigDc3xqZAQfP9vYO2IaTSupVmaOeMhhXBWtoOvP8qm6UOnUqNfdfwkqjpwvkdqXTHvTcyPJEAbWT5HH7YZkNOSECaMjR4AYMFEDtrweydt4WgE5b0FK2Zb9AEhWIMSQyni2Tj73SxhJnsyL3FmWHSOahKojQ6_SkVi0v7A2McTkJFcUT-DqFsubL67tabSd0BBudLlJz2ZHaV_zclGjBToniQ";
|
|
pub const CLIENT_ID: &str = "meguri-test";
|
|
pub const CLIENT_SECRET: &str = "test-client-secret";
|
|
|
|
/// What a single sign-in attempt looks like to the relying party.
|
|
#[derive(Clone)]
|
|
pub struct Scenario {
|
|
pub claims: Value,
|
|
pub userinfo: Option<Value>,
|
|
pub issuer: Option<String>,
|
|
pub audience: Option<Value>,
|
|
pub expires_in: i64,
|
|
pub nonce: Option<String>,
|
|
pub corrupt_signature: bool,
|
|
pub token_status: u16,
|
|
}
|
|
impl Scenario {
|
|
pub fn new(claims: Value) -> Self {
|
|
Self {
|
|
claims,
|
|
userinfo: None,
|
|
issuer: None,
|
|
audience: None,
|
|
expires_in: 300,
|
|
nonce: None,
|
|
corrupt_signature: false,
|
|
token_status: 200,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Grant {
|
|
scenario: Scenario,
|
|
nonce: String,
|
|
challenge: String,
|
|
}
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
codes: HashMap<String, Grant>,
|
|
tokens: HashMap<String, Value>,
|
|
pub token_requests: usize,
|
|
pub userinfo_requests: usize,
|
|
}
|
|
pub struct Server {
|
|
pub base: String,
|
|
pub state: Arc<Mutex<State>>,
|
|
task: tokio::task::JoinHandle<()>,
|
|
}
|
|
impl Drop for Server {
|
|
fn drop(&mut self) {
|
|
self.task.abort();
|
|
}
|
|
}
|
|
impl Server {
|
|
pub fn issuer(&self) -> String {
|
|
format!("{}/realms/corp", self.base)
|
|
}
|
|
/// Registers an authorization the way a browser visit to the provider would.
|
|
pub fn authorize(&self, authorization_url: &str, scenario: Scenario) -> String {
|
|
let url = reqwest::Url::parse(authorization_url).unwrap();
|
|
let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
|
|
assert_eq!(query["response_type"], "code");
|
|
assert_eq!(query["client_id"], CLIENT_ID);
|
|
assert_eq!(query["code_challenge_method"], "S256");
|
|
assert!(query["scope"].split(' ').any(|s| s == "openid"));
|
|
let code = format!("code-{}", uuid::Uuid::new_v4().simple());
|
|
self.state.lock().unwrap().codes.insert(
|
|
code.clone(),
|
|
Grant {
|
|
scenario,
|
|
nonce: query["nonce"].clone(),
|
|
challenge: query["code_challenge"].clone(),
|
|
},
|
|
);
|
|
code
|
|
}
|
|
pub async fn start() -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
let state = Arc::new(Mutex::new(State::default()));
|
|
let (shared, root) = (state.clone(), base.clone());
|
|
let task = tokio::spawn(async move {
|
|
loop {
|
|
let (mut socket, _) = listener.accept().await.unwrap();
|
|
let (shared, root) = (shared.clone(), root.clone());
|
|
tokio::spawn(async move {
|
|
let mut buffer = vec![];
|
|
let (head_end, length) = loop {
|
|
let mut chunk = [0; 4096];
|
|
let n = socket.read(&mut chunk).await.unwrap();
|
|
if n == 0 {
|
|
return;
|
|
}
|
|
buffer.extend_from_slice(&chunk[..n]);
|
|
if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
|
|
let head = String::from_utf8_lossy(&buffer[..end]);
|
|
let length = head
|
|
.lines()
|
|
.find_map(|l| {
|
|
l.to_lowercase()
|
|
.strip_prefix("content-length: ")
|
|
.and_then(|v| v.parse::<usize>().ok())
|
|
})
|
|
.unwrap_or(0);
|
|
break (end + 4, length);
|
|
}
|
|
};
|
|
while buffer.len() < head_end + length {
|
|
let mut chunk = [0; 4096];
|
|
let n = socket.read(&mut chunk).await.unwrap();
|
|
if n == 0 {
|
|
return;
|
|
}
|
|
buffer.extend_from_slice(&chunk[..n]);
|
|
}
|
|
let head = String::from_utf8_lossy(&buffer[..head_end]).to_string();
|
|
let body =
|
|
String::from_utf8_lossy(&buffer[head_end..head_end + length]).to_string();
|
|
let mut first = head.lines().next().unwrap().split_whitespace();
|
|
let method = first.next().unwrap().to_owned();
|
|
let path = first.next().unwrap().to_owned();
|
|
let (status, result) = handle(
|
|
&mut shared.lock().unwrap(),
|
|
&root,
|
|
&method,
|
|
&path,
|
|
&head,
|
|
&body,
|
|
);
|
|
let body = result.to_string();
|
|
let response = format!(
|
|
"HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
let _ = socket.write_all(response.as_bytes()).await;
|
|
});
|
|
}
|
|
});
|
|
Self { base, state, task }
|
|
}
|
|
}
|
|
|
|
fn header<'a>(head: &'a str, name: &str) -> Option<&'a str> {
|
|
head.lines().find_map(|l| {
|
|
let (key, value) = l.split_once(':')?;
|
|
key.eq_ignore_ascii_case(name).then(|| value.trim())
|
|
})
|
|
}
|
|
|
|
fn sign(claims: &Value, corrupt: bool) -> String {
|
|
let mut header = Header::new(Algorithm::RS256);
|
|
header.kid = Some("test-key".into());
|
|
let mut token = jsonwebtoken::encode(
|
|
&header,
|
|
claims,
|
|
&EncodingKey::from_rsa_pem(PRIVATE_KEY.as_bytes()).unwrap(),
|
|
)
|
|
.unwrap();
|
|
if corrupt {
|
|
let last = token.pop().unwrap();
|
|
token.push(if last == 'A' { 'B' } else { 'A' });
|
|
}
|
|
token
|
|
}
|
|
|
|
fn handle(
|
|
s: &mut State,
|
|
root: &str,
|
|
method: &str,
|
|
path: &str,
|
|
head: &str,
|
|
body: &str,
|
|
) -> (u16, Value) {
|
|
let issuer = format!("{root}/realms/corp");
|
|
let path = path.split('?').next().unwrap();
|
|
match (method, path) {
|
|
("GET", "/realms/corp/.well-known/openid-configuration") => (
|
|
200,
|
|
json!({
|
|
"issuer":issuer,
|
|
"authorization_endpoint":format!("{issuer}/protocol/openid-connect/auth"),
|
|
"token_endpoint":format!("{issuer}/protocol/openid-connect/token"),
|
|
"jwks_uri":format!("{issuer}/protocol/openid-connect/certs"),
|
|
"userinfo_endpoint":format!("{issuer}/protocol/openid-connect/userinfo"),
|
|
}),
|
|
),
|
|
("GET", "/realms/corp/protocol/openid-connect/certs") => (
|
|
200,
|
|
json!({"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":MODULUS,"e":"AQAB"}]}),
|
|
),
|
|
("POST", "/realms/corp/protocol/openid-connect/token") => {
|
|
s.token_requests += 1;
|
|
let expected = format!(
|
|
"Basic {}",
|
|
base64::engine::general_purpose::STANDARD
|
|
.encode(format!("{CLIENT_ID}:{CLIENT_SECRET}"))
|
|
);
|
|
if header(head, "authorization") != Some(expected.as_str()) {
|
|
return (401, json!({"error":"invalid_client"}));
|
|
}
|
|
let form: HashMap<_, _> = reqwest::Url::parse(&format!("http://x/?{body}"))
|
|
.unwrap()
|
|
.query_pairs()
|
|
.into_owned()
|
|
.collect();
|
|
let Some(grant) = form.get("code").and_then(|c| s.codes.remove(c)) else {
|
|
return (400, json!({"error":"invalid_grant"}));
|
|
};
|
|
let verifier = form.get("code_verifier").cloned().unwrap_or_default();
|
|
if form.get("grant_type").map(String::as_str) != Some("authorization_code")
|
|
|| URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) != grant.challenge
|
|
|| !form
|
|
.get("redirect_uri")
|
|
.is_some_and(|u| u.ends_with("/api/oidc/callback"))
|
|
{
|
|
return (400, json!({"error":"invalid_grant"}));
|
|
}
|
|
let scenario = grant.scenario;
|
|
if scenario.token_status != 200 {
|
|
return (scenario.token_status, json!({"error":"server_error"}));
|
|
}
|
|
let now = chrono::Utc::now().timestamp();
|
|
let mut claims = scenario.claims.clone();
|
|
claims["iss"] = json!(scenario.issuer.unwrap_or(issuer));
|
|
claims["aud"] = scenario.audience.unwrap_or(json!(CLIENT_ID));
|
|
claims["iat"] = json!(now);
|
|
claims["exp"] = json!(now + scenario.expires_in);
|
|
claims["nonce"] = json!(scenario.nonce.unwrap_or(grant.nonce));
|
|
let access = format!("access-{}", uuid::Uuid::new_v4().simple());
|
|
if let Some(info) = scenario.userinfo {
|
|
s.tokens.insert(access.clone(), info);
|
|
}
|
|
(
|
|
200,
|
|
json!({"id_token":sign(&claims, scenario.corrupt_signature),"access_token":access,"token_type":"Bearer"}),
|
|
)
|
|
}
|
|
("GET", "/realms/corp/protocol/openid-connect/userinfo") => {
|
|
s.userinfo_requests += 1;
|
|
let token = header(head, "authorization")
|
|
.and_then(|h| h.strip_prefix("Bearer "))
|
|
.unwrap_or_default();
|
|
match s.tokens.get(token) {
|
|
Some(info) => (200, info.clone()),
|
|
None => (401, json!({"error":"invalid_token"})),
|
|
}
|
|
}
|
|
_ => (404, json!({})),
|
|
}
|
|
}
|