1#![allow(dead_code)]
4
5#[derive(Clone, Debug)]
9pub struct PkceChallenge {
10 pub code_verifier: String,
11 pub code_challenge: String,
12}
13
14#[derive(Clone, Debug)]
16pub struct OAuth2Token {
17 pub access_token: String,
18 pub token_type: String,
19 pub expires_in_secs: u64,
20 pub refresh_token: Option<String>,
21 pub scope: Option<String>,
22}
23
24#[derive(Clone, Debug)]
26pub struct OAuth2Config {
27 pub client_id: String,
28 pub redirect_uri: String,
29 pub auth_endpoint: String,
30 pub token_endpoint: String,
31}
32
33pub struct OAuth2Client {
35 pub config: OAuth2Config,
36 pending_verifier: Option<String>,
37}
38
39pub fn generate_pkce_challenge(verifier: &str) -> PkceChallenge {
41 let challenge = format!("sha256:{}", &verifier[..verifier.len().min(32)]);
42 PkceChallenge {
43 code_verifier: verifier.to_owned(),
44 code_challenge: challenge,
45 }
46}
47
48pub fn build_authorization_url(
50 cfg: &OAuth2Config,
51 challenge: &PkceChallenge,
52 state: &str,
53) -> String {
54 format!(
55 "{}?client_id={}&redirect_uri={}&code_challenge={}&state={}",
56 cfg.auth_endpoint, cfg.client_id, cfg.redirect_uri, challenge.code_challenge, state
57 )
58}
59
60pub fn exchange_code_for_token(
62 cfg: &OAuth2Config,
63 code: &str,
64 verifier: &str,
65) -> Result<OAuth2Token, String> {
66 if code.is_empty() {
67 return Err("empty authorization code".into());
68 }
69 if verifier.is_empty() {
70 return Err("empty code verifier".into());
71 }
72 Ok(OAuth2Token {
73 access_token: format!("at_{}", code),
74 token_type: "Bearer".into(),
75 expires_in_secs: 3600,
76 refresh_token: Some(format!("rt_{}", cfg.client_id)),
77 scope: Some("openid profile".into()),
78 })
79}
80
81pub fn refresh_token(cfg: &OAuth2Config, refresh: &str) -> Result<OAuth2Token, String> {
83 if refresh.is_empty() {
84 return Err("empty refresh token".into());
85 }
86 Ok(OAuth2Token {
87 access_token: format!("at_refreshed_{}", cfg.client_id),
88 token_type: "Bearer".into(),
89 expires_in_secs: 3600,
90 refresh_token: Some(refresh.to_owned()),
91 scope: None,
92 })
93}
94
95impl OAuth2Client {
96 pub fn new(config: OAuth2Config) -> Self {
98 Self {
99 config,
100 pending_verifier: None,
101 }
102 }
103
104 pub fn start_pkce_flow(&mut self, verifier: &str, state: &str) -> String {
106 let challenge = generate_pkce_challenge(verifier);
107 self.pending_verifier = Some(verifier.to_owned());
108 build_authorization_url(&self.config, &challenge, state)
109 }
110
111 pub fn complete_flow(&mut self, code: &str) -> Result<OAuth2Token, String> {
113 let verifier = self.pending_verifier.take().ok_or("no pending flow")?;
114 exchange_code_for_token(&self.config, code, &verifier)
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn test_pkce_challenge_contains_verifier_prefix() {
124 let ch = generate_pkce_challenge("my-verifier-string");
125 assert!(ch.code_challenge.contains("sha256:"));
126 }
127
128 #[test]
129 fn test_pkce_verifier_preserved() {
130 let verifier = "verifier-abc";
131 let ch = generate_pkce_challenge(verifier);
132 assert_eq!(ch.code_verifier, verifier);
133 }
134
135 #[test]
136 fn test_build_authorization_url_contains_client_id() {
137 let cfg = OAuth2Config {
138 client_id: "cid".into(),
139 redirect_uri: "http://localhost".into(),
140 auth_endpoint: "https://auth.example.com/auth".into(),
141 token_endpoint: "https://auth.example.com/token".into(),
142 };
143 let ch = generate_pkce_challenge("ver");
144 let url = build_authorization_url(&cfg, &ch, "state1");
145 assert!(url.contains("cid"));
146 }
147
148 #[test]
149 fn test_exchange_empty_code_returns_error() {
150 let cfg = OAuth2Config {
151 client_id: "cid".into(),
152 redirect_uri: "".into(),
153 auth_endpoint: "".into(),
154 token_endpoint: "".into(),
155 };
156 assert!(exchange_code_for_token(&cfg, "", "verifier").is_err());
157 }
158
159 #[test]
160 fn test_exchange_valid_code_returns_token() {
161 let cfg = OAuth2Config {
162 client_id: "cid".into(),
163 redirect_uri: "".into(),
164 auth_endpoint: "".into(),
165 token_endpoint: "".into(),
166 };
167 let tok = exchange_code_for_token(&cfg, "code123", "verifier").expect("should succeed");
168 assert!(tok.access_token.contains("code123"));
169 }
170
171 #[test]
172 fn test_refresh_empty_token_returns_error() {
173 let cfg = OAuth2Config {
174 client_id: "cid".into(),
175 redirect_uri: "".into(),
176 auth_endpoint: "".into(),
177 token_endpoint: "".into(),
178 };
179 assert!(refresh_token(&cfg, "").is_err());
180 }
181
182 #[test]
183 fn test_refresh_valid_token_returns_new_token() {
184 let cfg = OAuth2Config {
185 client_id: "myclient".into(),
186 redirect_uri: "".into(),
187 auth_endpoint: "".into(),
188 token_endpoint: "".into(),
189 };
190 let tok = refresh_token(&cfg, "rt_old").expect("should succeed");
191 assert!(tok.access_token.contains("myclient"));
192 }
193
194 #[test]
195 fn test_client_start_and_complete_flow() {
196 let cfg = OAuth2Config {
197 client_id: "c1".into(),
198 redirect_uri: "http://localhost".into(),
199 auth_endpoint: "https://auth.test/auth".into(),
200 token_endpoint: "https://auth.test/token".into(),
201 };
202 let mut client = OAuth2Client::new(cfg);
203 let url = client.start_pkce_flow("verifier-xyz", "s1");
204 assert!(url.contains("c1"));
205 let tok = client.complete_flow("authcode").expect("should succeed");
206 assert!(!tok.access_token.is_empty());
207 }
208
209 #[test]
210 fn test_complete_flow_without_start_errors() {
211 let cfg = OAuth2Config {
212 client_id: "c2".into(),
213 redirect_uri: "".into(),
214 auth_endpoint: "".into(),
215 token_endpoint: "".into(),
216 };
217 let mut client = OAuth2Client::new(cfg);
218 assert!(client.complete_flow("code").is_err());
219 }
220}