Skip to main content

yapi_ai/auth/
openrouter.rs

1//! OpenRouter OAuth: a Proof Key for Code Exchange (PKCE) sign-in that
2//! trades the authorization code for a permanent, user-controlled API key.
3//! Port of `oauth/openrouter.ts`.
4
5use std::time::Duration;
6
7use serde_json::{Map, Value, json};
8use tokio_util::sync::CancellationToken;
9use url::Url;
10use yapi_types::auth::OAuthCredential;
11
12use super::callback::{Received, callback_or_manual, query, start_code_server};
13use super::{
14    AuthError, AuthEvent, BoxFuture, Interaction, LoginOptions, OAuthProvider, callback_host, form,
15    pkce, send,
16};
17
18const LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
19const TOKEN_EXCHANGE_TIMEOUT: Duration = Duration::from_secs(30);
20/// pi's credentials for OpenRouter never expire: `Number.MAX_SAFE_INTEGER`.
21const NEVER: u64 = 9_007_199_254_740_991;
22
23/// The OpenRouter sign-in and its endpoints.
24#[derive(Clone, Debug)]
25pub struct OpenRouterOAuth {
26    /// Authorization page.
27    pub authorize_url: String,
28    /// Key exchange endpoint.
29    pub token_url: String,
30}
31
32impl Default for OpenRouterOAuth {
33    fn default() -> OpenRouterOAuth {
34        OpenRouterOAuth {
35            authorize_url: "https://openrouter.ai/auth".into(),
36            token_url: "https://openrouter.ai/api/v1/auth/keys".into(),
37        }
38    }
39}
40
41/// The code in pasted input: a redirect URL, a `code=` query, or the code.
42fn parse_input(input: &str) -> Option<String> {
43    let value = input.trim();
44    if value.is_empty() {
45        return None;
46    }
47    if let Ok(url) = Url::parse(value) {
48        return query(&url, "code");
49    }
50    if value.contains("code=") {
51        return url::form_urlencoded::parse(value.as_bytes())
52            .find(|(key, _)| key == "code")
53            .map(|(_, code)| code.into_owned());
54    }
55    Some(value.to_owned())
56}
57
58fn error_detail(body: &Value) -> Option<String> {
59    ["error_description", "message", "error"]
60        .iter()
61        .find_map(|key| body[*key].as_str().map(str::to_owned))
62        .or_else(|| body["error"]["message"].as_str().map(str::to_owned))
63}
64
65impl OpenRouterOAuth {
66    async fn exchange(
67        &self,
68        code: &str,
69        verifier: &str,
70        cancel: &CancellationToken,
71    ) -> Result<OAuthCredential, AuthError> {
72        let request = crate::http::client()
73            .post(&self.token_url)
74            .header("accept", "application/json")
75            .header("content-type", "application/json")
76            .body(yapi_types::json::stringify(&json!({
77                "code": code,
78                "code_verifier": verifier,
79                "code_challenge_method": "S256",
80            })));
81        let response =
82            match tokio::time::timeout(TOKEN_EXCHANGE_TIMEOUT, send(request, cancel)).await {
83                Ok(response) => response?,
84                Err(_) => {
85                    return Err(AuthError::failed(
86                        "OpenRouter OAuth token exchange timed out",
87                    ));
88                }
89            };
90        let status = response.status();
91        let bytes = response.bytes().await.unwrap_or_default();
92        let body = match serde_json::from_slice::<Value>(&bytes) {
93            Ok(object @ Value::Object(_)) => object,
94            Ok(_) => Value::Object(Map::new()),
95            Err(_) if status.is_success() => {
96                return Err(AuthError::failed("OpenRouter OAuth returned invalid JSON"));
97            }
98            Err(_) => Value::Object(Map::new()),
99        };
100        if !status.is_success() {
101            let detail = error_detail(&body)
102                .map(|detail| format!(": {detail}"))
103                .unwrap_or_default();
104            return Err(AuthError::Failed(format!(
105                "OpenRouter OAuth key exchange failed (HTTP {}){detail}",
106                status.as_u16()
107            )));
108        }
109        let key = body["key"]
110            .as_str()
111            .filter(|key| !key.is_empty())
112            .ok_or_else(|| AuthError::failed("OpenRouter OAuth response carries no \"key\""))?;
113        Ok(OAuthCredential {
114            access: key.to_owned(),
115            refresh: String::new(),
116            expires: NEVER as f64,
117            extra: Map::new(),
118        })
119    }
120
121    async fn login_openrouter(
122        &self,
123        interaction: &Interaction,
124    ) -> Result<OAuthCredential, AuthError> {
125        let pkce = pkce::generate()?;
126        // OpenRouter sends no `state`. The random path keeps stray requests
127        // from completing the sign-in.
128        let uuid = yapi_types::time::try_uuid_v4().map_err(AuthError::random)?;
129        let path = format!("/oauth/callback/{uuid}");
130        let mut server = start_code_server(
131            "OpenRouter",
132            &callback_host(),
133            0,
134            &path,
135            None,
136            true,
137            interaction.cancel(),
138        )
139        .await
140        .map_err(|err| AuthError::Failed(err.to_string()))?;
141        let redirect_uri = server.redirect_uri().to_owned();
142        let query = form(&[
143            ("callback_url", &redirect_uri),
144            ("code_challenge", &pkce.challenge),
145            ("code_challenge_method", "S256"),
146        ]);
147        interaction.notify(AuthEvent::Progress {
148            message: format!("Listening for OpenRouter OAuth callback on {redirect_uri}"),
149        });
150        interaction.notify(AuthEvent::AuthUrl {
151            url: format!("{}?{query}", self.authorize_url),
152            instructions: Some("Complete sign-in in your browser. If the browser is on another machine, paste the final redirect URL here.".into()),
153        });
154        let received = callback_or_manual(
155            interaction,
156            Some(&mut server),
157            "Complete sign-in in your browser, or paste the authorization code / redirect URL here:",
158            &redirect_uri,
159        );
160        let received = tokio::time::timeout(LOGIN_TIMEOUT, received)
161            .await
162            .unwrap_or_else(|_| Err(AuthError::failed("OpenRouter sign-in timed out")))?;
163        let (code, page) = match received {
164            Received::Callback(callback) => callback,
165            Received::Manual(input) => {
166                let code = parse_input(&input)
167                    .ok_or_else(|| AuthError::failed("Missing authorization code"))?;
168                interaction.notify(AuthEvent::Progress {
169                    message: "Exchanging authorization code for an API key...".into(),
170                });
171                (code, None)
172            }
173        };
174        // pi exchanges the code before the browser's page, which then shows the result.
175        let result = self
176            .exchange(&code, &pkce.verifier, interaction.cancel())
177            .await;
178        if let Some(page) = page {
179            page.finish("OpenRouter", &result).await;
180        }
181        result
182    }
183}
184
185impl OAuthProvider for OpenRouterOAuth {
186    fn name(&self) -> &str {
187        "OpenRouter OAuth"
188    }
189
190    fn login_label(&self) -> Option<&str> {
191        Some("Sign in with OpenRouter")
192    }
193
194    fn is_subscription(&self) -> bool {
195        false
196    }
197
198    fn login<'a>(
199        &'a self,
200        interaction: &'a Interaction,
201        _options: &'a LoginOptions,
202    ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
203        Box::pin(self.login_openrouter(interaction))
204    }
205
206    fn refresh<'a>(
207        &'a self,
208        credential: &'a OAuthCredential,
209        _cancel: &'a CancellationToken,
210    ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
211        Box::pin(async move { Ok(credential.clone()) })
212    }
213}