use anyhow::{Context, Result};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::Rng;
use sha2::{Digest, Sha256};
use super::server::{CALLBACK_PORTS, start_callback_server};
use super::token::{OAuthTokens, TokenStore};
const AUTHORIZE_URL: &str = "https://linear.app/oauth/authorize";
const TOKEN_URL: &str = "https://api.linear.app/oauth/token";
const DEFAULT_CLIENT_ID: &str = "10a4dc40b91ad9015b7b95cf703a54e3";
const SCOPES: &str = "read,write";
fn client_id() -> Result<String> {
if let Ok(id) = std::env::var("LINEAR_CLIENT_ID") {
return Ok(id);
}
let config = crate::config::Config::load()?;
Ok(config
.auth
.oauth_client_id
.unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string()))
}
fn client_secret() -> Result<Option<String>> {
if let Ok(secret) = std::env::var("LINEAR_CLIENT_SECRET") {
return Ok(Some(secret));
}
Ok(crate::config::Config::load()?.auth.oauth_client_secret)
}
fn generate_code_verifier() -> String {
let random_bytes: Vec<u8> = (0..32).map(|_| rand::rng().random::<u8>()).collect();
URL_SAFE_NO_PAD.encode(&random_bytes)
}
fn generate_code_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
}
fn generate_state() -> String {
let random_bytes: Vec<u8> = (0..16).map(|_| rand::rng().random::<u8>()).collect();
URL_SAFE_NO_PAD.encode(&random_bytes)
}
fn percent_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
pub async fn login(token_store: &TokenStore) -> Result<()> {
let client_id = client_id()?;
let code_verifier = generate_code_verifier();
let code_challenge = generate_code_challenge(&code_verifier);
let state = generate_state();
let (port, code_rx) = start_callback_server(state.clone()).await?;
let redirect_uri = format!("http://localhost:{port}/callback");
let auth_url = format!(
"{AUTHORIZE_URL}?client_id={client_id}&response_type=code&redirect_uri={}&scope={SCOPES}&state={state}&code_challenge={code_challenge}&code_challenge_method=S256",
percent_encode(&redirect_uri),
);
tracing::info!(redirect_uri = %redirect_uri, "starting OAuth login flow");
match open::that(&auth_url) {
Ok(()) => println!("Opening your browser to authorize linear-tui..."),
Err(e) => {
tracing::warn!(error = %e, "could not open a browser");
println!("Could not open a browser. Visit this URL to authorize:\n\n{auth_url}\n");
}
}
println!("Waiting for the response on {redirect_uri}");
let code = code_rx
.await
.context("Authorization finished without returning a code")?;
tracing::debug!("exchanging authorization code for tokens");
let tokens = exchange_code(&code, &code_verifier, &redirect_uri).await?;
token_store.save(&tokens)?;
tracing::info!("OAuth login successful");
Ok(())
}
async fn exchange_code(code: &str, code_verifier: &str, redirect_uri: &str) -> Result<OAuthTokens> {
let mut form = vec![
("grant_type", "authorization_code".to_string()),
("client_id", client_id()?),
("code", code.to_string()),
("redirect_uri", redirect_uri.to_string()),
("code_verifier", code_verifier.to_string()),
];
if let Some(secret) = client_secret()? {
form.push(("client_secret", secret));
}
post_token(&form).await
}
pub async fn refresh_token(refresh_token: &str) -> Result<OAuthTokens> {
tracing::debug!("refreshing OAuth token");
let mut form = vec![
("grant_type", "refresh_token".to_string()),
("client_id", client_id()?),
("refresh_token", refresh_token.to_string()),
];
if let Some(secret) = client_secret()? {
form.push(("client_secret", secret));
}
let mut tokens = post_token(&form).await?;
if tokens.refresh_token.is_empty() {
tokens.refresh_token = refresh_token.to_string();
}
Ok(tokens)
}
async fn post_token(form: &[(&str, String)]) -> Result<OAuthTokens> {
let client = reqwest::Client::new();
let resp = client
.post(TOKEN_URL)
.form(form)
.send()
.await
.context("Could not reach Linear's token endpoint")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::error!(%status, body = %body, "token request failed");
anyhow::bail!("Linear rejected the token request ({status}): {body}");
}
let token_resp: TokenResponse = resp
.json()
.await
.context("Linear returned a token response that could not be parsed")?;
Ok(OAuthTokens::from_response(token_resp))
}
pub fn application_summary() -> Result<String> {
let id = client_id()?;
let origin = if std::env::var("LINEAR_CLIENT_ID").is_ok() {
"from LINEAR_CLIENT_ID"
} else if id == DEFAULT_CLIENT_ID {
"k1-c/tui, bundled"
} else {
"from config.toml"
};
Ok(format!("{id} ({origin})"))
}
pub fn callback_ports() -> &'static [u16] {
&CALLBACK_PORTS
}
#[derive(serde::Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_encode_escapes_a_redirect_uri() {
assert_eq!(
percent_encode("http://localhost:53681/callback"),
"http%3A%2F%2Flocalhost%3A53681%2Fcallback"
);
}
#[test]
fn percent_encode_leaves_unreserved_characters_alone() {
assert_eq!(percent_encode("aZ0-._~"), "aZ0-._~");
}
#[test]
fn code_challenge_matches_the_rfc_7636_example() {
assert_eq!(
generate_code_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn verifier_and_state_are_url_safe() {
for value in [generate_code_verifier(), generate_state()] {
assert!(
value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"not URL-safe: {value}"
);
}
}
}