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//!    [`Client::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
23/// PKCE code-challenge method.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub enum CodeChallengeMethod {
26    /// SHA-256 hashing per RFC 7636.
27    #[serde(rename = "S256")]
28    S256,
29    /// Plain text — verifier is the challenge.
30    #[serde(rename = "plain")]
31    Plain,
32}
33
34/// Generate a cryptographically random PKCE code verifier: 32 random
35/// bytes encoded as base64url without padding, yielding a 43-character
36/// string per RFC 7636.
37pub fn generate_code_verifier() -> String {
38    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
39    use base64::Engine;
40    let mut buf = [0u8; 32];
41    rand::thread_rng().fill_bytes(&mut buf);
42    URL_SAFE_NO_PAD.encode(buf)
43}
44
45/// Create a PKCE code challenge from a verifier using the S256 method:
46/// `BASE64URL(SHA256(verifier))`.
47pub fn create_s256_code_challenge(verifier: &str) -> String {
48    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
49    use base64::Engine;
50    use sha2::Digest;
51    let digest = sha2::Sha256::digest(verifier.as_bytes());
52    URL_SAFE_NO_PAD.encode(digest)
53}
54
55/// Parameters for [`build_auth_url`].
56#[derive(Clone, Debug, Default, PartialEq, Eq)]
57pub struct AuthUrlParams<'a> {
58    /// HTTPS URL OpenRouter will redirect to after authorization
59    /// (required).
60    pub callback_url: &'a str,
61    /// PKCE code challenge (optional but recommended).
62    pub code_challenge: Option<&'a str>,
63    /// Method used to compute `code_challenge` (optional).
64    pub code_challenge_method: Option<CodeChallengeMethod>,
65}
66
67/// Build the user-facing authorization URL. `base_url` is typically
68/// `https://openrouter.ai/auth`.
69pub fn build_auth_url(base_url: &str, params: AuthUrlParams<'_>) -> Result<String> {
70    if params.callback_url.is_empty() {
71        return Err(Error::InvalidInput("callback_url is required"));
72    }
73    let mut u =
74        Url::parse(base_url).map_err(|_| Error::InvalidInput("base_url is not a valid URL"))?;
75    {
76        let mut q = u.query_pairs_mut();
77        q.append_pair("callback_url", params.callback_url);
78        if let Some(c) = params.code_challenge {
79            q.append_pair("code_challenge", c);
80        }
81        if let Some(m) = params.code_challenge_method {
82            let v = match m {
83                CodeChallengeMethod::S256 => "S256",
84                CodeChallengeMethod::Plain => "plain",
85            };
86            q.append_pair("code_challenge_method", v);
87        }
88    }
89    Ok(u.into())
90}
91
92/// Request body for [`Client::exchange_auth_code`].
93#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
94pub struct ExchangeAuthCodeRequest {
95    /// Authorization code received on the callback URL.
96    pub code: String,
97    /// PKCE verifier matching the challenge used at auth-URL build time.
98    /// Required when PKCE was used.
99    #[serde(skip_serializing_if = "Option::is_none", default)]
100    pub code_verifier: Option<String>,
101    /// Method used to derive the challenge.
102    #[serde(skip_serializing_if = "Option::is_none", default)]
103    pub code_challenge_method: Option<CodeChallengeMethod>,
104}
105
106/// Response from `POST /auth/keys`.
107#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
108pub struct ExchangeAuthCodeResponse {
109    /// The newly-issued API key. **Stored only once** — capture it
110    /// immediately.
111    #[serde(default)]
112    pub key: String,
113    /// Owning user id, when returned.
114    #[serde(default)]
115    pub user_id: Option<String>,
116}
117
118impl Client {
119    /// Exchange an authorization code for an API key (`POST /auth/keys`).
120    ///
121    /// This is the second step of the OAuth PKCE flow, called after the
122    /// user has authorized the application at OpenRouter and been
123    /// redirected back with a `?code=…` query parameter. When PKCE was
124    /// used to build the auth URL, [`ExchangeAuthCodeRequest::code_verifier`]
125    /// must be the verifier that produced the challenge.
126    pub async fn exchange_auth_code(
127        &self,
128        req: &ExchangeAuthCodeRequest,
129    ) -> Result<ExchangeAuthCodeResponse> {
130        if req.code.is_empty() {
131            return Err(Error::InvalidInput("code is required"));
132        }
133        request::execute_json(self, "auth/keys", req).await
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn verifier_is_43_chars_and_url_safe() {
143        let v = generate_code_verifier();
144        assert_eq!(v.len(), 43);
145        for c in v.chars() {
146            assert!(
147                c.is_ascii_alphanumeric() || c == '-' || c == '_',
148                "non-urlsafe char {c}"
149            );
150        }
151    }
152
153    #[test]
154    fn s256_challenge_known_vector() {
155        // RFC 7636 §B test vector
156        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
157        let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
158        assert_eq!(create_s256_code_challenge(verifier), expected);
159    }
160
161    #[test]
162    fn build_auth_url_requires_callback() {
163        let err = build_auth_url(
164            "https://openrouter.ai/auth",
165            AuthUrlParams {
166                callback_url: "",
167                ..Default::default()
168            },
169        )
170        .unwrap_err();
171        assert!(matches!(err, Error::InvalidInput(_)));
172    }
173
174    #[test]
175    fn build_auth_url_appends_params() {
176        let url = build_auth_url(
177            "https://openrouter.ai/auth",
178            AuthUrlParams {
179                callback_url: "https://app.example/cb",
180                code_challenge: Some("CHAL"),
181                code_challenge_method: Some(CodeChallengeMethod::S256),
182            },
183        )
184        .unwrap();
185        assert!(url.contains("callback_url=https%3A%2F%2Fapp.example%2Fcb"));
186        assert!(url.contains("code_challenge=CHAL"));
187        assert!(url.contains("code_challenge_method=S256"));
188    }
189}