Skip to main content

rws_server/sso/
discovery.rs

1//! OIDC provider discovery and hardcoded presets.
2//!
3//! [`OidcProvider`] holds the endpoint URLs for an identity provider.
4//! Use one of the preset constructors (`google()`, `microsoft()`, etc.) or call
5//! [`OidcProvider::discover`] to fetch endpoints from the standard
6//! `{issuer}/.well-known/openid-configuration` URL.
7
8use crate::http_client::Client;
9use super::SsoError;
10
11/// Endpoint URLs for an OIDC / OAuth 2.0 identity provider.
12#[derive(Debug, Clone)]
13pub struct OidcProvider {
14    pub issuer:                 String,
15    pub authorization_endpoint: String,
16    pub token_endpoint:         String,
17    pub jwks_uri:               String,
18    pub userinfo_endpoint:      Option<String>,
19    pub end_session_endpoint:   Option<String>,
20}
21
22impl OidcProvider {
23    /// Fetch and parse `{issuer}/.well-known/openid-configuration`.
24    pub fn discover(issuer: &str) -> Result<Self, SsoError> {
25        let url = format!(
26            "{}/.well-known/openid-configuration",
27            issuer.trim_end_matches('/')
28        );
29        let resp = Client::new()
30            .get(&url)
31            .timeout_ms(10_000)
32            .send()
33            .map_err(|e| SsoError(format!("discovery fetch failed: {e}")))?;
34        if !resp.is_success() {
35            return Err(SsoError(format!("discovery returned {}", resp.status())));
36        }
37        let body = resp.text().map_err(|e| SsoError(e.to_string()))?;
38        parse_discovery_json(&body)
39    }
40
41    /// Google OIDC preset.
42    pub fn google() -> Self {
43        OidcProvider {
44            issuer:                 "https://accounts.google.com".into(),
45            authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth".into(),
46            token_endpoint:         "https://oauth2.googleapis.com/token".into(),
47            jwks_uri:               "https://www.googleapis.com/oauth2/v3/certs".into(),
48            userinfo_endpoint:      Some("https://openidconnect.googleapis.com/v1/userinfo".into()),
49            end_session_endpoint:   None,
50        }
51    }
52
53    /// Microsoft Azure AD / Entra ID preset.
54    ///
55    /// `tenant_id` can be a GUID, `"common"`, `"organizations"`, or `"consumers"`.
56    pub fn microsoft(tenant_id: &str) -> Self {
57        let base = format!("https://login.microsoftonline.com/{tenant_id}");
58        OidcProvider {
59            issuer:                 format!("{base}/v2.0"),
60            authorization_endpoint: format!("{base}/oauth2/v2.0/authorize"),
61            token_endpoint:         format!("{base}/oauth2/v2.0/token"),
62            jwks_uri:               format!("{base}/discovery/v2.0/keys"),
63            userinfo_endpoint:      Some("https://graph.microsoft.com/oidc/userinfo".into()),
64            end_session_endpoint:   Some(format!("{base}/oauth2/v2.0/logout")),
65        }
66    }
67
68    /// GitHub OAuth 2.0 preset.
69    ///
70    /// GitHub is not a full OIDC provider — it does not issue JWTs, so
71    /// `jwks_uri` is empty and user info is fetched from the `/user` API.
72    pub fn github() -> Self {
73        OidcProvider {
74            issuer:                 "https://github.com".into(),
75            authorization_endpoint: "https://github.com/login/oauth/authorize".into(),
76            token_endpoint:         "https://github.com/login/oauth/access_token".into(),
77            jwks_uri:               String::new(),
78            userinfo_endpoint:      Some("https://api.github.com/user".into()),
79            end_session_endpoint:   None,
80        }
81    }
82
83    /// Okta preset.
84    ///
85    /// `domain` is your Okta org URL, e.g. `"dev-12345.okta.com"`.
86    pub fn okta(domain: &str) -> Self {
87        let base = format!("https://{}", domain.trim_start_matches("https://"));
88        OidcProvider {
89            issuer:                 format!("{base}/oauth2/default"),
90            authorization_endpoint: format!("{base}/oauth2/default/v1/authorize"),
91            token_endpoint:         format!("{base}/oauth2/default/v1/token"),
92            jwks_uri:               format!("{base}/oauth2/default/v1/keys"),
93            userinfo_endpoint:      Some(format!("{base}/oauth2/default/v1/userinfo")),
94            end_session_endpoint:   Some(format!("{base}/oauth2/default/v1/logout")),
95        }
96    }
97
98    /// Auth0 preset.
99    ///
100    /// `domain` is your Auth0 domain, e.g. `"myapp.us.auth0.com"`.
101    pub fn auth0(domain: &str) -> Self {
102        let base = format!("https://{}", domain.trim_start_matches("https://"));
103        OidcProvider {
104            issuer:                 format!("{base}/"),
105            authorization_endpoint: format!("{base}/authorize"),
106            token_endpoint:         format!("{base}/oauth/token"),
107            jwks_uri:               format!("{base}/.well-known/jwks.json"),
108            userinfo_endpoint:      Some(format!("{base}/userinfo")),
109            end_session_endpoint:   Some(format!("{base}/v2/logout")),
110        }
111    }
112
113    /// Keycloak preset.
114    ///
115    /// `base_url` is the root URL of your Keycloak instance, e.g.
116    /// `"https://keycloak.example.com"`.  `realm` is the realm name.
117    pub fn keycloak(base_url: &str, realm: &str) -> Self {
118        let base = format!("{}/realms/{}", base_url.trim_end_matches('/'), realm);
119        OidcProvider {
120            issuer:                 base.clone(),
121            authorization_endpoint: format!("{base}/protocol/openid-connect/auth"),
122            token_endpoint:         format!("{base}/protocol/openid-connect/token"),
123            jwks_uri:               format!("{base}/protocol/openid-connect/certs"),
124            userinfo_endpoint:      Some(format!("{base}/protocol/openid-connect/userinfo")),
125            end_session_endpoint:   Some(format!("{base}/protocol/openid-connect/logout")),
126        }
127    }
128}
129
130// ── internal JSON parser ──────────────────────────────────────────────────────
131
132fn parse_discovery_json(json: &str) -> Result<OidcProvider, SsoError> {
133    fn extract(json: &str, key: &str) -> Option<String> {
134        let needle = format!("\"{key}\"");
135        let start = json.find(&needle)? + needle.len();
136        let rest = json[start..].trim_start_matches(|c: char| c.is_whitespace() || c == ':');
137        if rest.starts_with('"') {
138            let inner = &rest[1..];
139            let end = inner.find('"')?;
140            Some(inner[..end].to_string())
141        } else {
142            None
143        }
144    }
145
146    Ok(OidcProvider {
147        issuer: extract(json, "issuer").unwrap_or_default(),
148        authorization_endpoint: extract(json, "authorization_endpoint")
149            .ok_or_else(|| SsoError("missing authorization_endpoint".into()))?,
150        token_endpoint: extract(json, "token_endpoint")
151            .ok_or_else(|| SsoError("missing token_endpoint".into()))?,
152        jwks_uri: extract(json, "jwks_uri")
153            .ok_or_else(|| SsoError("missing jwks_uri".into()))?,
154        userinfo_endpoint: extract(json, "userinfo_endpoint"),
155        end_session_endpoint: extract(json, "end_session_endpoint"),
156    })
157}