use rand::RngCore;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::client::Client;
use crate::error::{Error, Result};
use crate::request;
const TOKEN_ENDPOINT: &str = "https://openrouter.ai/api/v1/auth/keys";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CodeChallengeMethod {
#[serde(rename = "S256")]
S256,
#[serde(rename = "plain")]
Plain,
}
pub fn generate_code_verifier() -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
URL_SAFE_NO_PAD.encode(buf)
}
pub fn create_s256_code_challenge(verifier: &str) -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use sha2::Digest;
let digest = sha2::Sha256::digest(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AuthUrlParams<'a> {
pub callback_url: &'a str,
pub code_challenge: Option<&'a str>,
pub code_challenge_method: Option<CodeChallengeMethod>,
}
pub fn build_auth_url(base_url: &str, params: AuthUrlParams<'_>) -> Result<String> {
if params.callback_url.is_empty() {
return Err(Error::InvalidInput("callback_url is required"));
}
let mut u =
Url::parse(base_url).map_err(|_| Error::InvalidInput("base_url is not a valid URL"))?;
{
let mut q = u.query_pairs_mut();
q.append_pair("callback_url", params.callback_url);
if let Some(c) = params.code_challenge {
q.append_pair("code_challenge", c);
}
if let Some(m) = params.code_challenge_method {
let v = match m {
CodeChallengeMethod::S256 => "S256",
CodeChallengeMethod::Plain => "plain",
};
q.append_pair("code_challenge_method", v);
}
}
Ok(u.into())
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ExchangeAuthCodeRequest {
pub code: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub code_verifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub code_challenge_method: Option<CodeChallengeMethod>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ExchangeAuthCodeResponse {
#[serde(default)]
pub key: String,
#[serde(default)]
pub user_id: Option<String>,
}
pub async fn exchange_auth_code(req: &ExchangeAuthCodeRequest) -> Result<ExchangeAuthCodeResponse> {
exchange_auth_code_at(TOKEN_ENDPOINT, req).await
}
async fn exchange_auth_code_at(
endpoint: &str,
req: &ExchangeAuthCodeRequest,
) -> Result<ExchangeAuthCodeResponse> {
if req.code.is_empty() {
return Err(Error::InvalidInput("code is required"));
}
let response = reqwest::Client::new()
.post(endpoint)
.json(req)
.send()
.await?;
let status = response.status();
let body = response.bytes().await?;
if status.is_success() {
Ok(serde_json::from_slice(&body)?)
} else {
Err(Error::from_response_body(status.as_u16(), &body, None))
}
}
impl Client {
pub async fn exchange_auth_code(
&self,
req: &ExchangeAuthCodeRequest,
) -> Result<ExchangeAuthCodeResponse> {
if req.code.is_empty() {
return Err(Error::InvalidInput("code is required"));
}
request::execute_json(self, "auth/keys", req).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn verifier_is_43_chars_and_url_safe() {
let v = generate_code_verifier();
assert_eq!(v.len(), 43);
for c in v.chars() {
assert!(
c.is_ascii_alphanumeric() || c == '-' || c == '_',
"non-urlsafe char {c}"
);
}
}
#[test]
fn s256_challenge_known_vector() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
assert_eq!(create_s256_code_challenge(verifier), expected);
}
#[test]
fn build_auth_url_requires_callback() {
let err = build_auth_url(
"https://openrouter.ai/auth",
AuthUrlParams {
callback_url: "",
..Default::default()
},
)
.unwrap_err();
assert!(matches!(err, Error::InvalidInput(_)));
}
#[test]
fn build_auth_url_appends_params() {
let url = build_auth_url(
"https://openrouter.ai/auth",
AuthUrlParams {
callback_url: "https://app.example/cb",
code_challenge: Some("CHAL"),
code_challenge_method: Some(CodeChallengeMethod::S256),
},
)
.unwrap();
assert!(url.contains("callback_url=https%3A%2F%2Fapp.example%2Fcb"));
assert!(url.contains("code_challenge=CHAL"));
assert!(url.contains("code_challenge_method=S256"));
}
#[tokio::test]
async fn public_exchange_does_not_need_an_api_key() {
let server = MockServer::start().await;
let request = ExchangeAuthCodeRequest {
code: "oauth-code".into(),
code_verifier: Some("verifier".into()),
code_challenge_method: Some(CodeChallengeMethod::S256),
};
Mock::given(method("POST"))
.and(path("/auth/keys"))
.and(body_json(&request))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "sk-user",
"user_id": "user-1"
})))
.expect(1)
.mount(&server)
.await;
let endpoint = format!("{}/auth/keys", server.uri());
let response = exchange_auth_code_at(&endpoint, &request).await.unwrap();
assert_eq!(response.key, "sk-user");
assert_eq!(response.user_id.as_deref(), Some("user-1"));
}
}