Skip to main content

oauth_toolkit/
oidc.rs

1//! OIDC discovery, token exchange, and user info.
2
3use serde::{Deserialize, Serialize};
4
5/// OIDC provider configuration.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct OidcConfig {
8    /// OIDC issuer URL (e.g., <https://accounts.google.com>).
9    pub issuer: String,
10    /// OAuth2 client ID.
11    pub client_id: String,
12    /// OAuth2 client secret.
13    pub client_secret: String,
14    /// Redirect URI after authorization.
15    pub redirect_uri: String,
16    /// Scopes to request.
17    pub scope: Vec<String>,
18}
19
20/// OIDC discovery document.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct OidcDiscovery {
23    /// Issuer URL.
24    pub issuer: String,
25    /// Authorization endpoint URL.
26    pub authorization_endpoint: String,
27    /// Token endpoint URL.
28    pub token_endpoint: String,
29    /// Userinfo endpoint URL.
30    pub userinfo_endpoint: String,
31    /// JWKS URI for key discovery.
32    pub jwks_uri: String,
33}
34
35/// OIDC token response.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct OidcTokenResponse {
38    /// Access token.
39    pub access_token: String,
40    /// Token type (usually "Bearer").
41    pub token_type: String,
42    /// Expires in seconds.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub expires_in: Option<u64>,
45    /// Refresh token (optional).
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub refresh_token: Option<String>,
48    /// ID token (optional, for OIDC).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub id_token: Option<String>,
51}
52
53/// OIDC user info response.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct OidcUserInfo {
56    /// Subject (user ID).
57    pub sub: String,
58    /// Email address.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub email: Option<String>,
61    /// Display name.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub name: Option<String>,
64    /// Given name.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub given_name: Option<String>,
67    /// Family name.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub family_name: Option<String>,
70    /// Profile picture URL.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub picture: Option<String>,
73}
74
75/// Errors for OIDC operations.
76#[derive(Debug, thiserror::Error)]
77pub enum OidcError {
78    /// OIDC discovery document fetch failed.
79    #[error("discovery failed: {0}")]
80    DiscoveryFailed(String),
81
82    /// Token exchange with the provider failed.
83    #[error("token exchange failed: {0}")]
84    TokenExchangeFailed(String),
85
86    /// User info fetch failed.
87    #[error("userinfo fetch failed: {0}")]
88    UserInfoFailed(String),
89
90    /// HTTP request error.
91    #[error("HTTP error: {0}")]
92    HttpError(String),
93}
94
95/// Fetch and parse the OIDC discovery document from an issuer URL.
96pub async fn fetch_discovery(
97    client: &reqwest::Client,
98    issuer: &str,
99) -> Result<OidcDiscovery, OidcError> {
100    let url = format!(
101        "{}/.well-known/openid-configuration",
102        issuer.trim_end_matches('/')
103    );
104
105    let resp = client
106        .get(&url)
107        .send()
108        .await
109        .map_err(|e| OidcError::DiscoveryFailed(e.to_string()))?;
110
111    let discovery: OidcDiscovery = resp
112        .json()
113        .await
114        .map_err(|e| OidcError::DiscoveryFailed(e.to_string()))?;
115
116    Ok(discovery)
117}
118
119/// Exchange an authorization code for tokens.
120///
121/// If `code_verifier` is `Some`, includes it for PKCE.
122pub async fn exchange_code(
123    client: &reqwest::Client,
124    token_endpoint: &str,
125    config: &OidcConfig,
126    code: &str,
127    code_verifier: Option<&str>,
128) -> Result<OidcTokenResponse, OidcError> {
129    let mut params = vec![
130        ("grant_type", "authorization_code".to_string()),
131        ("code", code.to_string()),
132        ("redirect_uri", config.redirect_uri.clone()),
133        ("client_id", config.client_id.clone()),
134        ("client_secret", config.client_secret.clone()),
135    ];
136
137    if let Some(verifier) = code_verifier {
138        params.push(("code_verifier", verifier.to_string()));
139    }
140
141    let resp = client
142        .post(token_endpoint)
143        .form(&params)
144        .send()
145        .await
146        .map_err(|e| OidcError::HttpError(e.to_string()))?;
147
148    let token_resp: OidcTokenResponse = resp
149        .json()
150        .await
151        .map_err(|e| OidcError::TokenExchangeFailed(e.to_string()))?;
152
153    Ok(token_resp)
154}
155
156/// Fetch user info from the userinfo endpoint.
157pub async fn fetch_user_info(
158    client: &reqwest::Client,
159    userinfo_endpoint: &str,
160    access_token: &str,
161) -> Result<OidcUserInfo, OidcError> {
162    let resp = client
163        .get(userinfo_endpoint)
164        .bearer_auth(access_token)
165        .send()
166        .await
167        .map_err(|e| OidcError::HttpError(e.to_string()))?;
168
169    let user_info: OidcUserInfo = resp
170        .json()
171        .await
172        .map_err(|e| OidcError::UserInfoFailed(e.to_string()))?;
173
174    Ok(user_info)
175}
176
177/// Refresh an access token using a refresh token.
178pub async fn refresh_token(
179    client: &reqwest::Client,
180    token_endpoint: &str,
181    client_id: &str,
182    client_secret: &str,
183    refresh_token: &str,
184) -> Result<OidcTokenResponse, OidcError> {
185    let resp = client
186        .post(token_endpoint)
187        .form(&[
188            ("grant_type", "refresh_token"),
189            ("client_id", client_id),
190            ("client_secret", client_secret),
191            ("refresh_token", refresh_token),
192        ])
193        .send()
194        .await
195        .map_err(|e| OidcError::HttpError(e.to_string()))?;
196
197    let token_resp: OidcTokenResponse = resp
198        .json()
199        .await
200        .map_err(|e| OidcError::TokenExchangeFailed(e.to_string()))?;
201
202    Ok(token_resp)
203}
204
205/// Build an OIDC end-session URL for front-channel logout.
206pub fn end_session_url(issuer: &str, client_id: &str, post_logout_redirect: &str) -> String {
207    format!(
208        "{}/logout?client_id={}&post_logout_redirect_uri={}",
209        issuer.trim_end_matches('/'),
210        urlencoding::encode(client_id),
211        urlencoding::encode(post_logout_redirect),
212    )
213}