1use rand::RngCore;
16use serde::{Deserialize, Serialize};
17use url::Url;
18
19use crate::client::Client;
20use crate::error::{Error, Result};
21use crate::request;
22
23const TOKEN_ENDPOINT: &str = "https://openrouter.ai/api/v1/auth/keys";
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27pub enum CodeChallengeMethod {
28 #[serde(rename = "S256")]
30 S256,
31 #[serde(rename = "plain")]
33 Plain,
34}
35
36pub fn generate_code_verifier() -> String {
40 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
41 use base64::Engine;
42 let mut buf = [0u8; 32];
43 rand::thread_rng().fill_bytes(&mut buf);
44 URL_SAFE_NO_PAD.encode(buf)
45}
46
47pub fn create_s256_code_challenge(verifier: &str) -> String {
50 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
51 use base64::Engine;
52 use sha2::Digest;
53 let digest = sha2::Sha256::digest(verifier.as_bytes());
54 URL_SAFE_NO_PAD.encode(digest)
55}
56
57#[derive(Clone, Debug, Default, PartialEq, Eq)]
59pub struct AuthUrlParams<'a> {
60 pub callback_url: &'a str,
63 pub code_challenge: Option<&'a str>,
65 pub code_challenge_method: Option<CodeChallengeMethod>,
67}
68
69pub fn build_auth_url(base_url: &str, params: AuthUrlParams<'_>) -> Result<String> {
72 if params.callback_url.is_empty() {
73 return Err(Error::InvalidInput("callback_url is required"));
74 }
75 let mut u =
76 Url::parse(base_url).map_err(|_| Error::InvalidInput("base_url is not a valid URL"))?;
77 {
78 let mut q = u.query_pairs_mut();
79 q.append_pair("callback_url", params.callback_url);
80 if let Some(c) = params.code_challenge {
81 q.append_pair("code_challenge", c);
82 }
83 if let Some(m) = params.code_challenge_method {
84 let v = match m {
85 CodeChallengeMethod::S256 => "S256",
86 CodeChallengeMethod::Plain => "plain",
87 };
88 q.append_pair("code_challenge_method", v);
89 }
90 }
91 Ok(u.into())
92}
93
94#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
96pub struct ExchangeAuthCodeRequest {
97 pub code: String,
99 #[serde(skip_serializing_if = "Option::is_none", default)]
102 pub code_verifier: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none", default)]
105 pub code_challenge_method: Option<CodeChallengeMethod>,
106}
107
108#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
110pub struct ExchangeAuthCodeResponse {
111 #[serde(default)]
114 pub key: String,
115 #[serde(default)]
117 pub user_id: Option<String>,
118}
119
120pub async fn exchange_auth_code(req: &ExchangeAuthCodeRequest) -> Result<ExchangeAuthCodeResponse> {
125 exchange_auth_code_at(TOKEN_ENDPOINT, req).await
126}
127
128async fn exchange_auth_code_at(
129 endpoint: &str,
130 req: &ExchangeAuthCodeRequest,
131) -> Result<ExchangeAuthCodeResponse> {
132 if req.code.is_empty() {
133 return Err(Error::InvalidInput("code is required"));
134 }
135 let response = reqwest::Client::new()
136 .post(endpoint)
137 .json(req)
138 .send()
139 .await?;
140 let status = response.status();
141 let body = response.bytes().await?;
142 if status.is_success() {
143 Ok(serde_json::from_slice(&body)?)
144 } else {
145 Err(Error::from_response_body(status.as_u16(), &body, None))
146 }
147}
148
149impl Client {
150 pub async fn exchange_auth_code(
158 &self,
159 req: &ExchangeAuthCodeRequest,
160 ) -> Result<ExchangeAuthCodeResponse> {
161 if req.code.is_empty() {
162 return Err(Error::InvalidInput("code is required"));
163 }
164 request::execute_json(self, "auth/keys", req).await
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use wiremock::matchers::{body_json, method, path};
172 use wiremock::{Mock, MockServer, ResponseTemplate};
173
174 #[test]
175 fn verifier_is_43_chars_and_url_safe() {
176 let v = generate_code_verifier();
177 assert_eq!(v.len(), 43);
178 for c in v.chars() {
179 assert!(
180 c.is_ascii_alphanumeric() || c == '-' || c == '_',
181 "non-urlsafe char {c}"
182 );
183 }
184 }
185
186 #[test]
187 fn s256_challenge_known_vector() {
188 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
190 let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
191 assert_eq!(create_s256_code_challenge(verifier), expected);
192 }
193
194 #[test]
195 fn build_auth_url_requires_callback() {
196 let err = build_auth_url(
197 "https://openrouter.ai/auth",
198 AuthUrlParams {
199 callback_url: "",
200 ..Default::default()
201 },
202 )
203 .unwrap_err();
204 assert!(matches!(err, Error::InvalidInput(_)));
205 }
206
207 #[test]
208 fn build_auth_url_appends_params() {
209 let url = build_auth_url(
210 "https://openrouter.ai/auth",
211 AuthUrlParams {
212 callback_url: "https://app.example/cb",
213 code_challenge: Some("CHAL"),
214 code_challenge_method: Some(CodeChallengeMethod::S256),
215 },
216 )
217 .unwrap();
218 assert!(url.contains("callback_url=https%3A%2F%2Fapp.example%2Fcb"));
219 assert!(url.contains("code_challenge=CHAL"));
220 assert!(url.contains("code_challenge_method=S256"));
221 }
222
223 #[tokio::test]
224 async fn public_exchange_does_not_need_an_api_key() {
225 let server = MockServer::start().await;
226 let request = ExchangeAuthCodeRequest {
227 code: "oauth-code".into(),
228 code_verifier: Some("verifier".into()),
229 code_challenge_method: Some(CodeChallengeMethod::S256),
230 };
231 Mock::given(method("POST"))
232 .and(path("/auth/keys"))
233 .and(body_json(&request))
234 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
235 "key": "sk-user",
236 "user_id": "user-1"
237 })))
238 .expect(1)
239 .mount(&server)
240 .await;
241
242 let endpoint = format!("{}/auth/keys", server.uri());
243 let response = exchange_auth_code_at(&endpoint, &request).await.unwrap();
244 assert_eq!(response.key, "sk-user");
245 assert_eq!(response.user_id.as_deref(), Some("user-1"));
246 }
247}