Skip to main content

rws_server/sso/
client.rs

1//! OIDC / OAuth 2.0 client — authorization URL builder, token exchange,
2//! and UserInfo fetch.
3
4use crate::http_client::Client;
5
6use super::{
7    config::OidcConfig,
8    jwks::{json_str, OidcClaims},
9    pkce::PkceVerifier,
10    SsoError,
11};
12
13/// Result of a successful token-endpoint exchange.
14pub struct TokenResponse {
15    /// The access token returned by the provider.
16    pub access_token:  String,
17    /// Token type (typically `"Bearer"`).
18    pub token_type:    String,
19    /// Lifetime of the access token in seconds.
20    pub expires_in:    Option<u64>,
21    /// Refresh token, if the provider returned one.
22    pub refresh_token: Option<String>,
23    /// OIDC id_token (a signed JWT), if the provider returned one.
24    pub id_token:      Option<String>,
25    /// Granted scopes.
26    pub scope:         Option<String>,
27}
28
29/// A thin OIDC / OAuth 2.0 client that performs the authorization code flow.
30#[derive(Clone)]
31pub struct OidcClient {
32    config: OidcConfig,
33}
34
35impl OidcClient {
36    /// Create a new client for the given configuration.
37    pub fn new(config: OidcConfig) -> Self {
38        OidcClient { config }
39    }
40
41    /// Build the URL to redirect the user to for authorization.
42    ///
43    /// Returns the full URL including `response_type`, `client_id`,
44    /// `redirect_uri`, `scope`, `state`, `nonce`, and (for OIDC providers)
45    /// PKCE `code_challenge` / `code_challenge_method`.
46    ///
47    /// Store `state`, `nonce`, and `pkce.as_str()` in the pre-auth session
48    /// before issuing the redirect.
49    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        // Only add PKCE for providers that issue JWTs (i.e., have a JWKS URI).
61        // GitHub does not support PKCE on its token endpoint.
62        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    /// Exchange an authorization code for tokens at the token endpoint.
72    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    /// Fetch user info from the provider's UserInfo endpoint using `access_token`.
108    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
131// ── internal parsers ──────────────────────────────────────────────────────────
132
133fn 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    // GitHub returns { "id": 12345, "login": "user", "email": "...", "name": "..." }
148    // OIDC providers return { "sub": "...", "email": "...", ... }
149    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
178/// Extract a bare integer field (not quoted) and return it as a String.
179fn 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/// Minimal percent-encoding for URL query parameter values.
205#[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}