use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tracing::debug;
use solid_pod_rs::oidc::verify_dpop_proof;
use crate::error::ProviderError;
use crate::jwks::Jwks;
use crate::registration::ClientStore;
use crate::session::{AuthCodeRecord, SessionStore};
use crate::tokens::{issue_access_token, AccessToken};
use crate::user_store::UserStore;
#[derive(Debug, Clone)]
pub struct ProviderConfig {
pub issuer: String,
pub access_token_ttl_secs: u64,
pub dpop_skew_secs: u64,
}
impl ProviderConfig {
pub fn new(issuer: impl Into<String>) -> Self {
Self {
issuer: issuer.into(),
access_token_ttl_secs: 3600,
dpop_skew_secs: 60,
}
}
}
#[derive(Clone)]
pub struct Provider {
config: ProviderConfig,
client_store: ClientStore,
session_store: SessionStore,
user_store: Arc<dyn UserStore>,
jwks: Jwks,
}
impl Provider {
pub fn new(
config: ProviderConfig,
client_store: ClientStore,
session_store: SessionStore,
user_store: Arc<dyn UserStore>,
jwks: Jwks,
) -> Self {
Self {
config,
client_store,
session_store,
user_store,
jwks,
}
}
pub fn jwks(&self) -> &Jwks {
&self.jwks
}
pub fn config(&self) -> &ProviderConfig {
&self.config
}
pub fn client_store(&self) -> &ClientStore {
&self.client_store
}
pub fn session_store(&self) -> &SessionStore {
&self.session_store
}
pub fn user_store_trait_object(&self) -> &dyn UserStore {
self.user_store.as_ref()
}
pub fn discovery_document(&self) -> crate::discovery::DiscoveryDocument {
crate::discovery::build_discovery(&self.config.issuer)
}
pub async fn authorize(
&self,
req: AuthorizeRequest,
) -> Result<AuthorizeResponse, ProviderError> {
let client = self
.client_store
.find(&req.client_id)
.await
.map_err(|e| ProviderError::ClientDocument(e.to_string()))?
.ok_or_else(|| ProviderError::InvalidClient(format!("unknown: {}", req.client_id)))?;
if req.response_type != "code" {
return Err(ProviderError::InvalidRequest(format!(
"response_type must be 'code', got '{}'",
req.response_type
)));
}
if !client.redirect_uris.iter().any(|r| r == &req.redirect_uri) {
return Err(ProviderError::InvalidRequest(format!(
"redirect_uri not registered: {}",
req.redirect_uri
)));
}
if req.code_challenge_method.as_deref() != Some("S256") {
return Err(ProviderError::InvalidRequest(
"PKCE S256 is required (code_challenge_method)".into(),
));
}
if req.code_challenge.is_none() {
return Err(ProviderError::InvalidRequest(
"code_challenge is required".into(),
));
}
match req.session_account_id {
None => Ok(AuthorizeResponse::NeedsLogin {
client_id: req.client_id,
redirect_uri: req.redirect_uri,
state: req.state,
code_challenge: req.code_challenge,
scope: req.scope,
}),
Some(account_id) => {
let code = self.session_store.issue_code(
&client.client_id,
account_id,
&req.redirect_uri,
req.code_challenge.clone(),
req.scope.clone(),
);
Ok(AuthorizeResponse::Redirect {
redirect_uri: req.redirect_uri,
code: code.code,
state: req.state,
iss: self.config.issuer.clone(),
})
}
}
}
pub async fn token(&self, req: TokenRequest<'_>) -> Result<TokenResponse, ProviderError> {
if req.grant_type != "authorization_code" {
return Err(ProviderError::InvalidRequest(format!(
"grant_type must be 'authorization_code', got '{}'",
req.grant_type
)));
}
let dpop_proof = req
.dpop_proof
.ok_or_else(|| ProviderError::InvalidDpop("missing DPoP header".into()))?;
let expected_htu = format!(
"{}/idp/token",
self.config.issuer.trim_end_matches('/')
);
let verified = verify_dpop_proof(
dpop_proof,
&expected_htu,
"POST",
req.now_unix,
self.config.dpop_skew_secs,
None, )
.await
.map_err(|e| ProviderError::InvalidDpop(e.to_string()))?;
let jkt = verified.jkt;
let code: AuthCodeRecord = self
.session_store
.take_code(req.code)
.ok_or_else(|| ProviderError::InvalidGrant("code expired or unknown".into()))?;
if code.client_id != req.client_id {
return Err(ProviderError::InvalidGrant(
"code issued to different client_id".into(),
));
}
if code.redirect_uri != req.redirect_uri {
return Err(ProviderError::InvalidGrant(
"redirect_uri mismatch".into(),
));
}
if let Some(challenge) = &code.code_challenge {
let verifier = req.code_verifier.ok_or_else(|| {
ProviderError::InvalidRequest("code_verifier required for PKCE".into())
})?;
let computed = pkce_s256(verifier);
if &computed != challenge {
return Err(ProviderError::InvalidGrant(
"PKCE verifier mismatch".into(),
));
}
}
let user = self
.user_store
.find_by_id(&code.account_id)
.await
.map_err(|e| ProviderError::UserStore(e.to_string()))?
.ok_or_else(|| ProviderError::InvalidGrant("account not found".into()))?;
let key = self.jwks.active_key();
let token: AccessToken = issue_access_token(
&key,
&self.config.issuer,
&user.webid,
&user.id,
&code.client_id,
code.requested_scope.as_deref().unwrap_or("openid webid"),
Some(&jkt),
req.now_unix,
self.config.access_token_ttl_secs,
)
.map_err(|e| ProviderError::Crypto(e.to_string()))?;
debug!(client_id = %code.client_id, webid = %user.webid, "issued DPoP-bound access token");
Ok(TokenResponse {
access_token: token.jwt,
token_type: "DPoP".into(),
expires_in: self.config.access_token_ttl_secs,
scope: token.payload.scope,
webid: Some(user.webid),
})
}
pub async fn userinfo(
&self,
access_token: &str,
dpop_jkt: &str,
now_unix: u64,
) -> Result<UserInfo, ProviderError> {
let keyset = build_jwk_set(&self.jwks);
let v = solid_pod_rs::oidc::verify_access_token(
access_token,
&solid_pod_rs::oidc::TokenVerifyKey::Asymmetric(keyset),
&self.config.issuer,
dpop_jkt,
now_unix,
)
.map_err(|e| ProviderError::InvalidDpop(e.to_string()))?;
Ok(UserInfo {
webid: v.webid.clone(),
sub: v.webid,
client_id: v.client_id,
scope: v.scope,
})
}
}
fn build_jwk_set(jwks: &Jwks) -> jsonwebtoken::jwk::JwkSet {
let doc = jwks.public_document();
let keys: Vec<jsonwebtoken::jwk::Jwk> = doc
.keys
.iter()
.filter_map(|k| serde_json::to_value(k).ok())
.filter_map(|v| serde_json::from_value(v).ok())
.collect();
jsonwebtoken::jwk::JwkSet { keys }
}
fn pkce_s256(verifier: &str) -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use base64::Engine;
use sha2::{Digest, Sha256};
let hash = Sha256::digest(verifier.as_bytes());
B64.encode(hash)
}
#[derive(Debug, Clone)]
pub struct AuthorizeRequest {
pub client_id: String,
pub response_type: String,
pub redirect_uri: String,
pub state: Option<String>,
pub code_challenge: Option<String>,
pub code_challenge_method: Option<String>,
pub scope: Option<String>,
pub session_account_id: Option<String>,
}
#[derive(Debug, Clone)]
pub enum AuthorizeResponse {
Redirect {
redirect_uri: String,
code: String,
state: Option<String>,
iss: String,
},
NeedsLogin {
client_id: String,
redirect_uri: String,
state: Option<String>,
code_challenge: Option<String>,
scope: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct TokenRequest<'a> {
pub grant_type: String,
pub code: &'a str,
pub redirect_uri: String,
pub client_id: String,
pub code_verifier: Option<&'a str>,
pub dpop_proof: Option<&'a str>,
pub now_unix: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
pub scope: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub webid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub sub: String,
pub webid: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use base64::Engine;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use rand::Rng;
use serde_json::json;
use crate::registration::{register_client, ClientDocument, RegistrationRequest};
use crate::user_store::InMemoryUserStore;
async fn seed_provider() -> (Provider, InMemoryUserStore, ClientDocument, String) {
let store = Arc::new(InMemoryUserStore::new());
store
.insert_user(
"acct-1",
"alice@example.com",
"https://alice.example/profile#me",
None,
"hunter2!",
)
.unwrap();
let jwks = Jwks::generate_es256().unwrap();
let clients = ClientStore::new();
let client = register_client(
&clients,
RegistrationRequest {
redirect_uris: vec!["https://app.example/cb".into()],
client_name: Some("TestApp".into()),
..Default::default()
},
)
.await
.unwrap();
let sessions = SessionStore::new();
let cfg = ProviderConfig::new("https://pod.example/");
let provider = Provider::new(cfg, clients, sessions, store.clone() as Arc<dyn UserStore>, jwks);
let verifier: String = (0..43)
.map(|_| {
let c = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
c[rand::thread_rng().gen_range(0..c.len())] as char
})
.collect();
(provider, InMemoryUserStore::new(), client, verifier)
}
fn s256(s: &str) -> String {
use sha2::{Digest, Sha256};
B64.encode(Sha256::digest(s.as_bytes()))
}
fn test_dpop_proof(htu: &str, htm: &str, iat: u64) -> String {
let mut sec = [0u8; 32];
rand::thread_rng().fill(&mut sec);
let k = B64.encode(sec);
let jwk = json!({
"kty": "oct",
"k": k,
});
let mut header = Header::new(Algorithm::HS256);
header.typ = Some("dpop+jwt".into());
header.jwk = Some(serde_json::from_value(jwk).unwrap());
let claims = json!({
"htu": htu,
"htm": htm,
"iat": iat,
"jti": uuid::Uuid::new_v4().to_string(),
});
encode(&header, &claims, &EncodingKey::from_secret(&sec)).unwrap()
}
#[tokio::test]
async fn authorize_needs_login_without_session() {
let (p, _, client, _) = seed_provider().await;
let req = AuthorizeRequest {
client_id: client.client_id.clone(),
response_type: "code".into(),
redirect_uri: "https://app.example/cb".into(),
state: Some("xyz".into()),
code_challenge: Some(s256("verifier-1")),
code_challenge_method: Some("S256".into()),
scope: Some("openid webid".into()),
session_account_id: None,
};
match p.authorize(req).await.unwrap() {
AuthorizeResponse::NeedsLogin { client_id, .. } => {
assert_eq!(client_id, client.client_id);
}
other => panic!("expected NeedsLogin, got {other:?}"),
}
}
#[tokio::test]
async fn authorize_issues_code_when_logged_in() {
let (p, _, client, verifier) = seed_provider().await;
let challenge = s256(&verifier);
let req = AuthorizeRequest {
client_id: client.client_id.clone(),
response_type: "code".into(),
redirect_uri: "https://app.example/cb".into(),
state: Some("state-1".into()),
code_challenge: Some(challenge),
code_challenge_method: Some("S256".into()),
scope: Some("openid webid".into()),
session_account_id: Some("acct-1".into()),
};
match p.authorize(req).await.unwrap() {
AuthorizeResponse::Redirect {
redirect_uri,
code,
state,
iss,
} => {
assert_eq!(redirect_uri, "https://app.example/cb");
assert!(!code.is_empty());
assert_eq!(state.as_deref(), Some("state-1"));
assert!(iss.contains("pod.example"));
}
other => panic!("expected Redirect, got {other:?}"),
}
}
#[tokio::test]
async fn authorize_rejects_unregistered_redirect_uri() {
let (p, _, client, verifier) = seed_provider().await;
let req = AuthorizeRequest {
client_id: client.client_id.clone(),
response_type: "code".into(),
redirect_uri: "https://evil.example/steal".into(),
state: None,
code_challenge: Some(s256(&verifier)),
code_challenge_method: Some("S256".into()),
scope: Some("openid".into()),
session_account_id: Some("acct-1".into()),
};
let err = p.authorize(req).await.unwrap_err();
assert!(matches!(err, ProviderError::InvalidRequest(_)));
}
#[tokio::test]
async fn authorize_rejects_without_pkce() {
let (p, _, client, _) = seed_provider().await;
let req = AuthorizeRequest {
client_id: client.client_id.clone(),
response_type: "code".into(),
redirect_uri: "https://app.example/cb".into(),
state: None,
code_challenge: None,
code_challenge_method: None,
scope: Some("openid".into()),
session_account_id: Some("acct-1".into()),
};
let err = p.authorize(req).await.unwrap_err();
assert!(matches!(err, ProviderError::InvalidRequest(_)));
}
#[tokio::test]
async fn token_endpoint_rejects_without_dpop() {
let (p, _, client, verifier) = seed_provider().await;
let auth = p
.authorize(AuthorizeRequest {
client_id: client.client_id.clone(),
response_type: "code".into(),
redirect_uri: "https://app.example/cb".into(),
state: None,
code_challenge: Some(s256(&verifier)),
code_challenge_method: Some("S256".into()),
scope: Some("openid webid".into()),
session_account_id: Some("acct-1".into()),
})
.await
.unwrap();
let code = match auth {
AuthorizeResponse::Redirect { code, .. } => code,
_ => panic!(),
};
let err = p
.token(TokenRequest {
grant_type: "authorization_code".into(),
code: &code,
redirect_uri: "https://app.example/cb".into(),
client_id: client.client_id.clone(),
code_verifier: Some(&verifier),
dpop_proof: None,
now_unix: 1_700_000_000,
})
.await
.unwrap_err();
assert!(matches!(err, ProviderError::InvalidDpop(_)));
}
#[tokio::test]
async fn token_endpoint_rejects_dpop_with_wrong_htu() {
let (p, _, client, verifier) = seed_provider().await;
let auth = p
.authorize(AuthorizeRequest {
client_id: client.client_id.clone(),
response_type: "code".into(),
redirect_uri: "https://app.example/cb".into(),
state: None,
code_challenge: Some(s256(&verifier)),
code_challenge_method: Some("S256".into()),
scope: Some("openid webid".into()),
session_account_id: Some("acct-1".into()),
})
.await
.unwrap();
let code = match auth {
AuthorizeResponse::Redirect { code, .. } => code,
_ => panic!(),
};
let wrong_htu = "https://evil.example/idp/token";
let proof = test_dpop_proof(wrong_htu, "POST", 1_700_000_000);
let err = p
.token(TokenRequest {
grant_type: "authorization_code".into(),
code: &code,
redirect_uri: "https://app.example/cb".into(),
client_id: client.client_id.clone(),
code_verifier: Some(&verifier),
dpop_proof: Some(&proof),
now_unix: 1_700_000_000,
})
.await
.unwrap_err();
assert!(matches!(err, ProviderError::InvalidDpop(_)));
}
#[tokio::test]
async fn authorization_code_flow_end_to_end() {
let (p, _, client, verifier) = seed_provider().await;
let auth = p
.authorize(AuthorizeRequest {
client_id: client.client_id.clone(),
response_type: "code".into(),
redirect_uri: "https://app.example/cb".into(),
state: Some("s-1".into()),
code_challenge: Some(s256(&verifier)),
code_challenge_method: Some("S256".into()),
scope: Some("openid webid".into()),
session_account_id: Some("acct-1".into()),
})
.await
.unwrap();
let code = match auth {
AuthorizeResponse::Redirect { code, .. } => code,
_ => panic!(),
};
let proof = test_dpop_proof("https://pod.example/idp/token", "POST", 1_700_000_000);
let tok = p
.token(TokenRequest {
grant_type: "authorization_code".into(),
code: &code,
redirect_uri: "https://app.example/cb".into(),
client_id: client.client_id.clone(),
code_verifier: Some(&verifier),
dpop_proof: Some(&proof),
now_unix: 1_700_000_000,
})
.await
.unwrap();
assert_eq!(tok.token_type, "DPoP");
assert!(tok.access_token.contains('.'));
assert_eq!(tok.expires_in, 3600);
assert_eq!(tok.webid.as_deref(), Some("https://alice.example/profile#me"));
let proof2 = test_dpop_proof("https://pod.example/idp/token", "POST", 1_700_000_000);
let err = p
.token(TokenRequest {
grant_type: "authorization_code".into(),
code: &code,
redirect_uri: "https://app.example/cb".into(),
client_id: client.client_id.clone(),
code_verifier: Some(&verifier),
dpop_proof: Some(&proof2),
now_unix: 1_700_000_000,
})
.await
.unwrap_err();
assert!(matches!(err, ProviderError::InvalidGrant(_)));
}
#[tokio::test]
async fn token_endpoint_rejects_pkce_verifier_mismatch() {
let (p, _, client, verifier) = seed_provider().await;
let auth = p
.authorize(AuthorizeRequest {
client_id: client.client_id.clone(),
response_type: "code".into(),
redirect_uri: "https://app.example/cb".into(),
state: None,
code_challenge: Some(s256(&verifier)),
code_challenge_method: Some("S256".into()),
scope: Some("openid webid".into()),
session_account_id: Some("acct-1".into()),
})
.await
.unwrap();
let code = match auth {
AuthorizeResponse::Redirect { code, .. } => code,
_ => panic!(),
};
let proof = test_dpop_proof("https://pod.example/idp/token", "POST", 1_700_000_000);
let err = p
.token(TokenRequest {
grant_type: "authorization_code".into(),
code: &code,
redirect_uri: "https://app.example/cb".into(),
client_id: client.client_id.clone(),
code_verifier: Some("totally-wrong-verifier"),
dpop_proof: Some(&proof),
now_unix: 1_700_000_000,
})
.await
.unwrap_err();
assert!(matches!(err, ProviderError::InvalidGrant(_)));
}
}