#![cfg(all(feature = "oidc", feature = "dpop-replay-cache"))]
use std::time::Duration;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL;
use base64::Engine;
use hmac::{Hmac, Mac};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use solid_pod_rs::oidc::{
discovery_for, register_client, replay::DpopReplayCache, verify_access_token,
verify_dpop_proof, ClientRegistrationRequest, CnfClaim, DpopClaims, Jwk, TokenVerifyKey,
};
use solid_pod_rs::wac::{
evaluate_access, parse_turtle_acl, serialize_turtle_acl, AccessMode, AclAuthorization,
AclDocument, IdOrIds, IdRef,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TokenClaims {
iss: String,
sub: String,
aud: serde_json::Value,
exp: u64,
iat: u64,
webid: Option<String>,
client_id: Option<String>,
cnf: Option<CnfClaim>,
scope: Option<String>,
}
fn issue_hs256_token(
secret: &[u8],
issuer: &str,
webid: &str,
client_id: &str,
jkt: &str,
exp: u64,
) -> String {
let claims = TokenClaims {
iss: issuer.into(),
sub: webid.into(),
aud: serde_json::json!("solid"),
exp,
iat: exp.saturating_sub(3600),
webid: Some(webid.into()),
client_id: Some(client_id.into()),
cnf: Some(CnfClaim { jkt: jkt.into() }),
scope: Some("openid webid".into()),
};
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret),
)
.expect("HS256 token encodes")
}
fn oct_jwk(secret: &[u8]) -> Jwk {
Jwk {
kty: "oct".into(),
alg: Some("HS256".into()),
kid: None,
use_: None,
crv: None,
x: None,
y: None,
n: None,
e: None,
k: Some(BASE64_URL.encode(secret)),
}
}
fn build_dpop_proof(
secret: &[u8],
jwk: &Jwk,
htu: &str,
htm: &str,
iat: u64,
jti: &str,
) -> String {
let header_json = serde_json::json!({
"typ": "dpop+jwt",
"alg": "HS256",
"jwk": jwk,
});
let header_b64 = BASE64_URL.encode(serde_json::to_string(&header_json).unwrap());
let claims = DpopClaims {
htu: htu.into(),
htm: htm.into(),
iat,
jti: jti.into(),
ath: None,
};
let body_b64 = BASE64_URL.encode(serde_json::to_string(&claims).unwrap());
let signing_input = format!("{header_b64}.{body_b64}");
let mut mac = <Hmac<Sha256>>::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(signing_input.as_bytes());
let sig_b64 = BASE64_URL.encode(mac.finalize().into_bytes());
format!("{signing_input}.{sig_b64}")
}
fn id(s: &str) -> IdOrIds {
IdOrIds::Single(IdRef { id: s.to_string() })
}
fn acl_single(
agent_uri: Option<&str>,
agent_class: Option<&str>,
access_to: &str,
mode: &str,
) -> AclDocument {
AclDocument {
context: None,
graph: Some(vec![AclAuthorization {
id: Some("#rule".into()),
r#type: Some("acl:Authorization".into()),
agent: agent_uri.map(id),
agent_class: agent_class.map(id),
agent_group: None,
origin: None,
access_to: Some(id(access_to)),
default: None,
mode: Some(id(mode)),
condition: None,
}]),
}
}
#[test]
fn oidc_e2e_dynamic_registration_round_trip() {
let req = ClientRegistrationRequest {
redirect_uris: vec!["https://app.example/cb".into()],
client_name: Some("Round Trip App".into()),
client_uri: None,
grant_types: vec!["authorization_code".into()],
response_types: vec!["code".into()],
scope: Some("openid webid".into()),
token_endpoint_auth_method: Some("client_secret_basic".into()),
application_type: Some("web".into()),
};
let now = 1_700_000_000u64;
let client = register_client(&req, now);
assert!(client.client_id.starts_with("client-"));
let secret = b"e2e-registration-secret";
let issuer = "https://op.example";
let webid = "https://me.example/profile#me";
let jkt = "JKT-E2E-1";
let token = issue_hs256_token(secret, issuer, webid, &client.client_id, jkt, now + 3600);
let ks = TokenVerifyKey::Symmetric(secret.to_vec());
let verified = verify_access_token(&token, &ks, issuer, jkt, now)
.expect("HS256 token verifies against symmetric key");
assert_eq!(verified.client_id.as_deref(), Some(client.client_id.as_str()));
assert_eq!(verified.webid, webid);
assert_eq!(verified.jkt, jkt);
assert_eq!(verified.iss, issuer);
}
#[test]
fn oidc_e2e_discovery_to_evaluate() {
let issuer = "https://op.example";
let disc = discovery_for(issuer);
assert_eq!(disc.issuer, issuer);
let secret = b"e2e-discovery-secret";
let ks = TokenVerifyKey::Symmetric(secret.to_vec());
let webid = "https://me.example/profile#me";
let jkt = "JKT-E2E-2";
let now = 1_700_000_000u64;
let token = issue_hs256_token(secret, &disc.issuer, webid, "client-a", jkt, now + 3600);
let verified = verify_access_token(&token, &ks, &disc.issuer, jkt, now)
.expect("discovery-wired verify succeeds");
let acl = acl_single(Some(webid), None, "/n", "acl:Read");
let allowed = evaluate_access(
Some(&acl),
Some(verified.webid.as_str()),
"/n",
AccessMode::Read,
None,
);
assert!(allowed, "the full 3-hop pipeline grants Read to the WebID");
let denied = evaluate_access(
Some(&acl),
Some("https://mallory.example/profile#me"),
"/n",
AccessMode::Read,
None,
);
assert!(!denied);
}
#[cfg(feature = "nip98-schnorr")]
#[test]
fn nip98_to_wac_bridge() {
use k256::schnorr::signature::Signer;
use solid_pod_rs::auth::nip98::{compute_event_id, verify_at, Nip98Event};
let sk = k256::schnorr::SigningKey::from_bytes(&[0x42u8; 32]).unwrap();
let pubkey = hex::encode(sk.verifying_key().to_bytes());
let ts = 1_700_000_000u64;
let url = "https://pod.example/note";
let tags = vec![
vec!["u".to_string(), url.to_string()],
vec!["method".to_string(), "GET".to_string()],
];
let skeleton = Nip98Event {
id: String::new(),
pubkey: pubkey.clone(),
created_at: ts,
kind: 27235,
tags: tags.clone(),
content: String::new(),
sig: String::new(),
};
let id = compute_event_id(&skeleton);
let id_bytes: Vec<u8> = hex::decode(&id).unwrap();
let sig: k256::schnorr::Signature = sk.sign(&id_bytes);
let ev = serde_json::json!({
"id": id,
"pubkey": pubkey,
"created_at": ts,
"kind": 27235,
"tags": tags,
"content": "",
"sig": hex::encode(sig.to_bytes()),
});
let hdr = format!(
"Nostr {}",
BASE64_STANDARD.encode(serde_json::to_string(&ev).unwrap().as_bytes())
);
let verified = verify_at(&hdr, url, "GET", None, ts).expect("NIP-98 verifies");
assert_eq!(verified.pubkey, pubkey);
let agent = format!("did:nostr:{pubkey}");
let acl = acl_single(Some(&agent), None, "/note", "acl:Read");
assert!(evaluate_access(Some(&acl), Some(&agent), "/note", AccessMode::Read, None));
assert!(!evaluate_access(
Some(&acl), Some("did:nostr:deadbeef"), "/note", AccessMode::Read, None,
));
}
#[test]
fn acl_serialise_round_trip_evaluates_identically() {
let webid = "https://me.example/profile#me";
let original = acl_single(Some(webid), None, "/r", "acl:Read");
let turtle = serialize_turtle_acl(&original);
assert!(turtle.contains("@prefix acl:"));
assert!(turtle.contains("acl:Authorization"));
assert!(turtle.contains("acl:accessTo"));
let parsed = parse_turtle_acl(&turtle).expect("Turtle re-parses");
for mode in [AccessMode::Read, AccessMode::Write, AccessMode::Append, AccessMode::Control] {
let a = evaluate_access(Some(&original), Some(webid), "/r", mode, None);
let b = evaluate_access(Some(&parsed), Some(webid), "/r", mode, None);
assert_eq!(a, b, "verdict drift for mode {mode:?}");
}
assert!(evaluate_access(Some(&parsed), Some(webid), "/r", AccessMode::Read, None));
}
#[tokio::test]
async fn oidc_dpop_proof_replay_cache_blocks_second_use() {
let secret = b"e2e-replay-secret";
let jwk = oct_jwk(secret);
let htu = "https://pod.example/r";
let now = 1_700_000_000u64;
let proof = build_dpop_proof(secret, &jwk, htu, "GET", now, "jti-e2e-replay");
let cache = DpopReplayCache::with_config(Duration::from_secs(60), 64);
let first = verify_dpop_proof(&proof, htu, "GET", now, 60, Some(&cache))
.await
.expect("first submission accepted");
assert_eq!(first.jti, "jti-e2e-replay");
let err = verify_dpop_proof(&proof, htu, "GET", now, 60, Some(&cache))
.await
.expect_err("replay must be blocked");
assert!(format!("{err}").to_lowercase().contains("replay"));
verify_dpop_proof(&proof, htu, "GET", now, 60, None)
.await
.expect("None cache disables replay detection");
}