1use crate::http_client::Client;
5
6use super::{
7 config::OidcConfig,
8 jwks::{json_str, OidcClaims},
9 pkce::PkceVerifier,
10 SsoError,
11};
12
13pub struct TokenResponse {
15 pub access_token: String,
17 pub token_type: String,
19 pub expires_in: Option<u64>,
21 pub refresh_token: Option<String>,
23 pub id_token: Option<String>,
25 pub scope: Option<String>,
27}
28
29#[derive(Clone)]
31pub struct OidcClient {
32 config: OidcConfig,
33}
34
35impl OidcClient {
36 pub fn new(config: OidcConfig) -> Self {
38 OidcClient { config }
39 }
40
41 pub fn authorization_url(&self, pkce: &PkceVerifier, state: &str, nonce: &str) -> String {
50 let scopes = self.config.scopes.join(" ");
51 let mut url = format!(
52 "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}",
53 self.config.provider.authorization_endpoint,
54 url_encode(&self.config.client_id),
55 url_encode(&self.config.redirect_uri),
56 url_encode(&scopes),
57 url_encode(state),
58 url_encode(nonce),
59 );
60 if !self.config.provider.jwks_uri.is_empty() {
63 url.push_str(&format!(
64 "&code_challenge={}&code_challenge_method=S256",
65 url_encode(pkce.challenge().as_str())
66 ));
67 }
68 url
69 }
70
71 pub fn exchange_code(
73 &self,
74 code: &str,
75 pkce_verifier: &str,
76 ) -> Result<TokenResponse, SsoError> {
77 let mut form: Vec<(&str, &str)> = vec![
78 ("grant_type", "authorization_code"),
79 ("code", code),
80 ("redirect_uri", &self.config.redirect_uri),
81 ("client_id", &self.config.client_id),
82 ("client_secret", &self.config.client_secret),
83 ];
84 if !self.config.provider.jwks_uri.is_empty() {
85 form.push(("code_verifier", pkce_verifier));
86 }
87
88 let resp = Client::new()
89 .post(&self.config.provider.token_endpoint)
90 .header("Accept", "application/json")
91 .form(&form)
92 .send()
93 .map_err(|e| SsoError(format!("token exchange failed: {e}")))?;
94
95 if !resp.is_success() {
96 let body_text = resp.text().unwrap_or_default();
97 return Err(SsoError(format!(
98 "token endpoint returned {}: {}",
99 resp.status(),
100 body_text
101 )));
102 }
103 let json = resp.text().map_err(|e| SsoError(e.to_string()))?;
104 parse_token_response(&json)
105 }
106
107 pub fn fetch_user_info(&self, access_token: &str) -> Result<OidcClaims, SsoError> {
109 let endpoint = self
110 .config
111 .provider
112 .userinfo_endpoint
113 .as_ref()
114 .ok_or_else(|| SsoError("provider has no userinfo_endpoint".into()))?;
115
116 let resp = Client::new()
117 .get(endpoint)
118 .header("Authorization", &format!("Bearer {access_token}"))
119 .header("Accept", "application/json")
120 .send()
121 .map_err(|e| SsoError(format!("userinfo fetch failed: {e}")))?;
122
123 if !resp.is_success() {
124 return Err(SsoError(format!("userinfo returned {}", resp.status())));
125 }
126 let json = resp.text().map_err(|e| SsoError(e.to_string()))?;
127 parse_userinfo_json(&json)
128 }
129}
130
131fn parse_token_response(json: &str) -> Result<TokenResponse, SsoError> {
134 let access_token = json_str(json, "access_token")
135 .ok_or_else(|| SsoError("token response missing access_token".into()))?;
136 Ok(TokenResponse {
137 access_token,
138 token_type: json_str(json, "token_type").unwrap_or_else(|| "Bearer".into()),
139 expires_in: json_u64(json, "expires_in"),
140 refresh_token: json_str(json, "refresh_token"),
141 id_token: json_str(json, "id_token"),
142 scope: json_str(json, "scope"),
143 })
144}
145
146fn parse_userinfo_json(json: &str) -> Result<OidcClaims, SsoError> {
147 let sub = json_str(json, "sub")
150 .or_else(|| json_int_as_string(json, "id").map(|id| format!("github:{id}")))
151 .unwrap_or_else(|| "unknown".into());
152
153 Ok(OidcClaims {
154 sub,
155 iss: json_str(json, "iss").unwrap_or_default(),
156 aud: vec![],
157 exp: 0,
158 iat: 0,
159 nonce: None,
160 email: json_str(json, "email"),
161 email_verified: json_bool(json, "email_verified"),
162 name: json_str(json, "name"),
163 given_name: json_str(json, "given_name"),
164 family_name: json_str(json, "family_name"),
165 picture: json_str(json, "picture").or_else(|| json_str(json, "avatar_url")),
166 locale: json_str(json, "locale"),
167 })
168}
169
170fn json_u64(json: &str, key: &str) -> Option<u64> {
171 let needle = format!("\"{key}\"");
172 let start = json.find(&needle)? + needle.len();
173 let rest = json[start..].trim_start_matches(|c: char| c.is_whitespace() || c == ':');
174 let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
175 rest[..end].parse().ok()
176}
177
178fn json_int_as_string(json: &str, key: &str) -> Option<String> {
180 let needle = format!("\"{key}\"");
181 let start = json.find(&needle)? + needle.len();
182 let rest = json[start..].trim_start_matches(|c: char| c.is_whitespace() || c == ':');
183 if rest.starts_with(|c: char| c.is_ascii_digit()) {
184 let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
185 Some(rest[..end].to_string())
186 } else {
187 None
188 }
189}
190
191fn json_bool(json: &str, key: &str) -> Option<bool> {
192 let needle = format!("\"{key}\"");
193 let start = json.find(&needle)? + needle.len();
194 let rest = json[start..].trim_start_matches(|c: char| c.is_whitespace() || c == ':');
195 if rest.starts_with("true") {
196 Some(true)
197 } else if rest.starts_with("false") {
198 Some(false)
199 } else {
200 None
201 }
202}
203
204#[doc(hidden)]
206pub fn url_encode(s: &str) -> String {
207 let mut out = String::with_capacity(s.len());
208 for b in s.bytes() {
209 match b {
210 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
211 out.push(b as char)
212 }
213 _ => out.push_str(&format!("%{:02X}", b)),
214 }
215 }
216 out
217}