use crate::http_client::Client;
use super::{
config::OidcConfig,
jwks::{json_str, OidcClaims},
pkce::PkceVerifier,
SsoError,
};
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: Option<u64>,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub scope: Option<String>,
}
pub struct OidcClient {
config: OidcConfig,
}
impl OidcClient {
pub fn new(config: OidcConfig) -> Self {
OidcClient { config }
}
pub fn authorization_url(&self, pkce: &PkceVerifier, state: &str, nonce: &str) -> String {
let scopes = self.config.scopes.join(" ");
let mut url = format!(
"{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}",
self.config.provider.authorization_endpoint,
url_encode(&self.config.client_id),
url_encode(&self.config.redirect_uri),
url_encode(&scopes),
url_encode(state),
url_encode(nonce),
);
if !self.config.provider.jwks_uri.is_empty() {
url.push_str(&format!(
"&code_challenge={}&code_challenge_method=S256",
url_encode(pkce.challenge().as_str())
));
}
url
}
pub fn exchange_code(
&self,
code: &str,
pkce_verifier: &str,
) -> Result<TokenResponse, SsoError> {
let mut body = format!(
"grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&client_secret={}",
url_encode(code),
url_encode(&self.config.redirect_uri),
url_encode(&self.config.client_id),
url_encode(&self.config.client_secret),
);
if !self.config.provider.jwks_uri.is_empty() {
body.push_str(&format!("&code_verifier={}", url_encode(pkce_verifier)));
}
let resp = Client::new()
.post(&self.config.provider.token_endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.body(body.into_bytes())
.send()
.map_err(|e| SsoError(format!("token exchange failed: {e}")))?;
if !resp.is_success() {
let body_text = resp.text().unwrap_or_default();
return Err(SsoError(format!(
"token endpoint returned {}: {}",
resp.status(),
body_text
)));
}
let json = resp.text().map_err(|e| SsoError(e.to_string()))?;
parse_token_response(&json)
}
pub fn fetch_user_info(&self, access_token: &str) -> Result<OidcClaims, SsoError> {
let endpoint = self
.config
.provider
.userinfo_endpoint
.as_ref()
.ok_or_else(|| SsoError("provider has no userinfo_endpoint".into()))?;
let resp = Client::new()
.get(endpoint)
.header("Authorization", &format!("Bearer {access_token}"))
.header("Accept", "application/json")
.send()
.map_err(|e| SsoError(format!("userinfo fetch failed: {e}")))?;
if !resp.is_success() {
return Err(SsoError(format!("userinfo returned {}", resp.status())));
}
let json = resp.text().map_err(|e| SsoError(e.to_string()))?;
parse_userinfo_json(&json)
}
}
fn parse_token_response(json: &str) -> Result<TokenResponse, SsoError> {
let access_token = json_str(json, "access_token")
.ok_or_else(|| SsoError("token response missing access_token".into()))?;
Ok(TokenResponse {
access_token,
token_type: json_str(json, "token_type").unwrap_or_else(|| "Bearer".into()),
expires_in: json_u64(json, "expires_in"),
refresh_token: json_str(json, "refresh_token"),
id_token: json_str(json, "id_token"),
scope: json_str(json, "scope"),
})
}
fn parse_userinfo_json(json: &str) -> Result<OidcClaims, SsoError> {
let sub = json_str(json, "sub")
.or_else(|| json_int_as_string(json, "id").map(|id| format!("github:{id}")))
.unwrap_or_else(|| "unknown".into());
Ok(OidcClaims {
sub,
iss: json_str(json, "iss").unwrap_or_default(),
aud: vec![],
exp: 0,
iat: 0,
nonce: None,
email: json_str(json, "email"),
email_verified: json_bool(json, "email_verified"),
name: json_str(json, "name"),
given_name: json_str(json, "given_name"),
family_name: json_str(json, "family_name"),
picture: json_str(json, "picture").or_else(|| json_str(json, "avatar_url")),
locale: json_str(json, "locale"),
})
}
fn json_u64(json: &str, key: &str) -> Option<u64> {
let needle = format!("\"{key}\"");
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start_matches(|c: char| c.is_whitespace() || c == ':');
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
rest[..end].parse().ok()
}
fn json_int_as_string(json: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start_matches(|c: char| c.is_whitespace() || c == ':');
if rest.starts_with(|c: char| c.is_ascii_digit()) {
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
Some(rest[..end].to_string())
} else {
None
}
}
fn json_bool(json: &str, key: &str) -> Option<bool> {
let needle = format!("\"{key}\"");
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start_matches(|c: char| c.is_whitespace() || c == ':');
if rest.starts_with("true") {
Some(true)
} else if rest.starts_with("false") {
Some(false)
} else {
None
}
}
pub(crate) fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}