mod client;
mod code;
pub use client::{ClientAuth, OidcClient, OidcClientRegistry};
pub use code::{AuthCode, AuthCodeStore, MemoryAuthCodeStore};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use arcly_http_core::auth::JwtService;
use crate::error::{IdentityError, Result};
use crate::identity::{new_id, IdentityService};
use crate::store::UserStore;
#[derive(Debug, Clone)]
pub struct OidcConfig {
pub issuer: String,
pub jwks: serde_json::Value,
pub id_token_ttl_secs: u64,
pub code_ttl_secs: u64,
}
pub struct OidcProvider {
config: OidcConfig,
clients: Arc<OidcClientRegistry>,
codes: Arc<dyn AuthCodeStore>,
jwt: Arc<JwtService>,
users: Arc<dyn UserStore>,
identity: IdentityService,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthorizeRequest {
pub client_id: String,
pub redirect_uri: String,
pub response_type: String,
pub scope: String,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub nonce: Option<String>,
#[serde(default)]
pub code_challenge: Option<String>,
#[serde(default)]
pub code_challenge_method: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OidcTokenResponse {
pub access_token: String,
pub id_token: String,
pub refresh_token: String,
pub token_type: &'static str,
pub expires_in: u64,
pub scope: String,
}
impl OidcProvider {
pub fn new(
config: OidcConfig,
clients: Arc<OidcClientRegistry>,
codes: Arc<dyn AuthCodeStore>,
jwt: Arc<JwtService>,
users: Arc<dyn UserStore>,
identity: IdentityService,
) -> Self {
Self {
config,
clients,
codes,
jwt,
users,
identity,
}
}
pub fn discovery_document(&self) -> serde_json::Value {
let iss = &self.config.issuer;
serde_json::json!({
"issuer": iss,
"authorization_endpoint": format!("{iss}/oauth2/authorize"),
"token_endpoint": format!("{iss}/oauth2/token"),
"userinfo_endpoint": format!("{iss}/oauth2/userinfo"),
"jwks_uri": format!("{iss}/.well-known/jwks.json"),
"introspection_endpoint": format!("{iss}/oauth2/introspect"),
"revocation_endpoint": format!("{iss}/oauth2/revoke"),
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": [self.jwt.algorithm_name()],
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
"code_challenge_methods_supported": ["S256"],
})
}
pub fn jwks(&self) -> serde_json::Value {
self.config.jwks.clone()
}
pub async fn authorize(&self, req: &AuthorizeRequest, user_id: &str) -> Result<String> {
if req.response_type != "code" {
return Err(IdentityError::Forbidden);
}
let client = self
.clients
.get(&req.client_id)
.ok_or(IdentityError::Forbidden)?;
if !client.allows_redirect(&req.redirect_uri) {
return Err(IdentityError::Forbidden);
}
if client.requires_pkce()
&& (req.code_challenge.is_none()
|| req.code_challenge_method.as_deref() != Some("S256"))
{
return Err(IdentityError::Forbidden);
}
let code = new_id();
self.codes
.put(AuthCode {
code: code.clone(),
client_id: req.client_id.clone(),
user_id: user_id.to_owned(),
redirect_uri: req.redirect_uri.clone(),
scope: req.scope.clone(),
nonce: req.nonce.clone(),
code_challenge: req.code_challenge.clone(),
expires_at: JwtService::unix_now() + self.config.code_ttl_secs,
})
.await?;
let mut url = format!("{}?code={}", req.redirect_uri, code);
if let Some(state) = &req.state {
url.push_str(&format!("&state={state}"));
}
Ok(url)
}
pub async fn token(
&self,
code: &str,
redirect_uri: &str,
client_auth: &ClientAuth,
code_verifier: Option<&str>,
) -> Result<OidcTokenResponse> {
let client = self
.clients
.authenticate(client_auth)
.ok_or(IdentityError::Forbidden)?;
let record = self
.codes
.take(code)
.await?
.ok_or(IdentityError::InvalidToken)?;
if record.client_id != client.client_id || record.redirect_uri != redirect_uri {
return Err(IdentityError::InvalidToken);
}
if JwtService::unix_now() >= record.expires_at {
return Err(IdentityError::InvalidToken);
}
if let Some(challenge) = &record.code_challenge {
let verifier = code_verifier.ok_or(IdentityError::InvalidToken)?;
if !pkce_matches(verifier, challenge) {
return Err(IdentityError::InvalidToken);
}
}
let user = self
.users
.find_by_id(&record.user_id)
.await?
.ok_or(IdentityError::NotFound)?;
let pair = self.identity.mint(&user).await?;
let id_token = self.mint_id_token(&user, &client.client_id, record.nonce.as_deref())?;
Ok(OidcTokenResponse {
access_token: pair.access_token,
id_token,
refresh_token: pair.refresh_token,
token_type: "Bearer",
expires_in: pair.expires_in,
scope: record.scope,
})
}
pub async fn userinfo(&self, access_token: &str) -> Result<serde_json::Value> {
let claims = self
.jwt
.decode_access(access_token)
.ok_or(IdentityError::InvalidToken)?;
let sub = claims
.get("sub")
.and_then(|v| v.as_str())
.ok_or(IdentityError::InvalidToken)?;
let user = self
.users
.find_by_id(sub)
.await?
.ok_or(IdentityError::NotFound)?;
Ok(serde_json::json!({
"sub": user.id,
"email": user.email,
"email_verified": user.email_verified,
"roles": user.roles,
}))
}
pub fn introspect(&self, token: &str) -> serde_json::Value {
match self.jwt.decode(token) {
Some(claims) => {
let mut obj = serde_json::Map::new();
obj.insert("active".into(), true.into());
for (k, v) in claims.iter() {
obj.insert(k.clone(), v.clone());
}
serde_json::Value::Object(obj)
}
None => serde_json::json!({ "active": false }),
}
}
pub async fn revoke(&self, token: &str) -> Result<()> {
self.identity.logout(token).await
}
fn mint_id_token(
&self,
user: &crate::model::Identity,
audience: &str,
nonce: Option<&str>,
) -> Result<String> {
let now = JwtService::unix_now();
let mut claims = serde_json::Map::new();
claims.insert("iss".into(), self.config.issuer.clone().into());
claims.insert("sub".into(), user.id.clone().into());
claims.insert("aud".into(), audience.to_owned().into());
claims.insert("iat".into(), now.into());
claims.insert("exp".into(), (now + self.config.id_token_ttl_secs).into());
if let Some(email) = &user.email {
claims.insert("email".into(), email.clone().into());
claims.insert("email_verified".into(), user.email_verified.into());
}
if let Some(nonce) = nonce {
claims.insert("nonce".into(), nonce.to_owned().into());
}
self.jwt
.sign(&serde_json::Value::Object(claims))
.map_err(|e| IdentityError::Internal(e.to_string()))
}
}
fn pkce_matches(verifier: &str, challenge: &str) -> bool {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let mut h = Sha256::new();
h.update(verifier.as_bytes());
let computed = URL_SAFE_NO_PAD.encode(h.finalize());
computed.as_bytes().ct_eq(challenge.as_bytes()).into()
}