Skip to main content

openrouter/
oauth.rs

1//! OAuth PKCE helpers for the OpenRouter authorization flow.
2//!
3//! The flow:
4//!
5//! 1. Generate a verifier with [`generate_code_verifier`] (random, 43-char,
6//!    base64url-no-padding per RFC 7636).
7//! 2. Derive a challenge with [`create_s256_code_challenge`].
8//! 3. Build the user-facing authorization URL with [`build_auth_url`] and
9//!    redirect the user to it.
10//! 4. When OpenRouter redirects back with `?code=…`, call
11//!    [`exchange_auth_code`] to swap the code for an API key.
12//!
13//! Shapes mirror the Go SDK (`oauth.go`, `oauth_endpoint.go`).
14
15use 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/// PKCE code-challenge method.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27pub enum CodeChallengeMethod {
28    /// SHA-256 hashing per RFC 7636.
29    #[serde(rename = "S256")]
30    S256,
31    /// Plain text — verifier is the challenge.
32    #[serde(rename = "plain")]
33    Plain,
34}
35
36/// Generate a cryptographically random PKCE code verifier: 32 random
37/// bytes encoded as base64url without padding, yielding a 43-character
38/// string per RFC 7636.
39pub 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
47/// Create a PKCE code challenge from a verifier using the S256 method:
48/// `BASE64URL(SHA256(verifier))`.
49pub 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/// Parameters for [`build_auth_url`].
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
59pub struct AuthUrlParams<'a> {
60    /// HTTPS URL OpenRouter will redirect to after authorization
61    /// (required).
62    pub callback_url: &'a str,
63    /// PKCE code challenge (optional but recommended).
64    pub code_challenge: Option<&'a str>,
65    /// Method used to compute `code_challenge` (optional).
66    pub code_challenge_method: Option<CodeChallengeMethod>,
67}
68
69/// Build the user-facing authorization URL. `base_url` is typically
70/// `https://openrouter.ai/auth`.
71pub 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/// Request body for [`Client::exchange_auth_code`].
95#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
96pub struct ExchangeAuthCodeRequest {
97    /// Authorization code received on the callback URL.
98    pub code: String,
99    /// PKCE verifier matching the challenge used at auth-URL build time.
100    /// Required when PKCE was used.
101    #[serde(skip_serializing_if = "Option::is_none", default)]
102    pub code_verifier: Option<String>,
103    /// Method used to derive the challenge.
104    #[serde(skip_serializing_if = "Option::is_none", default)]
105    pub code_challenge_method: Option<CodeChallengeMethod>,
106}
107
108/// Response from `POST /auth/keys`.
109#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
110pub struct ExchangeAuthCodeResponse {
111    /// The newly-issued API key. **Stored only once** — capture it
112    /// immediately.
113    #[serde(default)]
114    pub key: String,
115    /// Owning user id, when returned.
116    #[serde(default)]
117    pub user_id: Option<String>,
118}
119
120/// Exchange an OAuth authorization code without requiring an existing API key.
121///
122/// This is the browser-friendly entry point for the second half of the PKCE
123/// flow. The authorization code is single-use, so the exchange is not retried.
124pub 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    /// Exchange an authorization code for an API key (`POST /auth/keys`).
151    ///
152    /// This is the second step of the OAuth PKCE flow, called after the
153    /// user has authorized the application at OpenRouter and been
154    /// redirected back with a `?code=…` query parameter. When PKCE was
155    /// used to build the auth URL, [`ExchangeAuthCodeRequest::code_verifier`]
156    /// must be the verifier that produced the challenge.
157    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        // RFC 7636 §B test vector
189        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}