1use 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);
20const NEVER: u64 = 9_007_199_254_740_991;
22
23#[derive(Clone, Debug)]
25pub struct OpenRouterOAuth {
26 pub authorize_url: String,
28 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
41fn 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 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 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}