use std::time::{Duration, Instant};
use serde::Deserialize;
use crate::error::{Result, ToriiError};
struct DeviceFlowProvider {
device_authz_url: &'static str,
token_url: &'static str,
scopes: &'static str,
client_id_env: &'static str,
bundled_client_id: Option<&'static str>,
}
fn device_flow_provider(provider: &str) -> Option<DeviceFlowProvider> {
match provider {
"github" => Some(DeviceFlowProvider {
device_authz_url: "https://github.com/login/device/code",
token_url: "https://github.com/login/oauth/access_token",
scopes: "repo read:org workflow",
client_id_env: "TORII_GITHUB_APP_ID",
bundled_client_id: Some("Ov23liDcA2Njn7eRWnYV"),
}),
"gitlab" => Some(DeviceFlowProvider {
device_authz_url: "https://gitlab.com/oauth/authorize_device",
token_url: "https://gitlab.com/oauth/token",
scopes: "api",
client_id_env: "TORII_GITLAB_APP_ID",
bundled_client_id: Some("b72a85262c309587f67591da8fed4f8e8f4ee7349e9ed06f6a2a99ee7caec4fe"),
}),
"codeberg" => Some(DeviceFlowProvider {
device_authz_url: "https://codeberg.org/login/oauth/device/code",
token_url: "https://codeberg.org/login/oauth/access_token",
scopes: "",
client_id_env: "TORII_CODEBERG_APP_ID",
bundled_client_id: Some("d114c8aa-227d-453e-8f25-cdd727f49d42"),
}),
_ => None,
}
}
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
verification_uri_complete: Option<String>,
#[serde(default)]
expires_in: u64,
#[serde(default = "default_interval")]
interval: u64,
}
fn default_interval() -> u64 { 5 }
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum TokenResponse {
Success {
access_token: String,
#[serde(default)] #[allow(dead_code)] token_type: Option<String>,
#[serde(default)] refresh_token: Option<String>,
#[serde(default)] expires_in: Option<u64>,
},
Error { error: String, #[serde(default)] error_description: Option<String> },
}
pub fn run_device_flow(provider: &str) -> Result<String> {
let cfg = device_flow_provider(provider).ok_or_else(|| ToriiError::InvalidConfig(format!(
"OAuth device flow not configured for `{}`. Supported: github, gitlab, codeberg. \
Bitbucket needs the (separate) Authorization Code flow.", provider
)))?;
let client_id = std::env::var(cfg.client_id_env).ok()
.or_else(|| cfg.bundled_client_id.map(String::from))
.ok_or_else(|| ToriiError::InvalidConfig(format!(
"No OAuth client_id available for `{}`. Set the {} env var, or wait until the \
bundled client_id ships in a future torii release. As a workaround, create a \
Personal Access Token in the platform's web UI and run: \
torii auth set {} YOUR_TOKEN",
provider, cfg.client_id_env, provider
)))?;
let client = crate::http::make_client();
let init_req = client.post(cfg.device_authz_url)
.header("Accept", "application/json")
.form(&[
("client_id", client_id.as_str()),
("scope", cfg.scopes),
]);
let init: DeviceCodeResponse = crate::http::send_json(init_req, "OAuth device init")
.and_then(|v| serde_json::from_value(v).map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth device init: cannot parse response: {}", e) }))?;
let display_uri = init.verification_uri_complete.as_deref().unwrap_or(&init.verification_uri);
println!();
println!("⛩ Open this URL in your browser:");
println!(" {}", display_uri);
if init.verification_uri_complete.is_none() {
println!();
println!(" And enter the code: {}", init.user_code);
}
println!();
println!("Waiting for authorisation… (Ctrl-C to abort)");
let mut interval = Duration::from_secs(init.interval.max(1));
let deadline = Instant::now() + Duration::from_secs(init.expires_in.max(60));
loop {
std::thread::sleep(interval);
if Instant::now() >= deadline {
return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: code expired before authorisation. Run the command again.".to_string() });
}
let poll_req = client.post(cfg.token_url)
.header("Accept", "application/json")
.form(&[
("client_id", client_id.as_str()),
("device_code", init.device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
]);
let resp = poll_req.send()
.map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth poll: {}", e) })?;
let body: TokenResponse = resp.json()
.map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth poll: malformed JSON: {}", e) })?;
match body {
TokenResponse::Success { access_token, .. } => {
println!("✅ Authorised. Token saved.");
return Ok(access_token);
}
TokenResponse::Error { error, error_description } => match error.as_str() {
"authorization_pending" => continue,
"slow_down" => {
interval += Duration::from_secs(5);
continue;
}
"expired_token" => return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: code expired. Run the command again.".to_string() }),
"access_denied" => return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: user denied authorisation.".to_string() }),
other => return Err(ToriiError::Auth { provider: "oauth".into(), message: format!(
"OAuth device flow error '{}': {}",
other, error_description.unwrap_or_default()
) }),
}
}
}
}
pub fn device_flow_supported(provider: &str) -> bool {
device_flow_provider(provider).is_some()
}
pub struct DeviceFlowSession {
pub provider: String,
pub verification_uri: String,
pub verification_uri_complete: Option<String>,
pub user_code: String,
pub display_uri: String,
device_code: String,
client_id: String,
token_url: String,
interval: Duration,
deadline: Instant,
}
#[derive(Debug)]
pub enum DeviceFlowStep {
Pending,
SlowDown,
Done {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<u64>,
},
}
pub fn start_device_flow(provider: &str) -> Result<DeviceFlowSession> {
let cfg = device_flow_provider(provider).ok_or_else(|| ToriiError::InvalidConfig(format!(
"OAuth device flow not configured for `{}`. Supported: github, gitlab, codeberg.",
provider
)))?;
let client_id = std::env::var(cfg.client_id_env).ok()
.or_else(|| cfg.bundled_client_id.map(String::from))
.ok_or_else(|| ToriiError::InvalidConfig(format!(
"No OAuth client_id available for `{}`. Set the {} env var, or wait until \
the bundled client_id ships. As a workaround, create a Personal Access \
Token in the platform's web UI and run: torii auth set {} YOUR_TOKEN",
provider, cfg.client_id_env, provider
)))?;
let client = crate::http::make_client();
let init_req = client.post(cfg.device_authz_url)
.header("Accept", "application/json")
.form(&[
("client_id", client_id.as_str()),
("scope", cfg.scopes),
]);
let init: DeviceCodeResponse = crate::http::send_json(init_req, "OAuth device init")
.and_then(|v| serde_json::from_value(v).map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth device init: cannot parse response: {}", e) }))?;
let display_uri = init.verification_uri_complete
.clone()
.unwrap_or_else(|| init.verification_uri.clone());
let interval = Duration::from_secs(init.interval.max(1));
let deadline = Instant::now() + Duration::from_secs(init.expires_in.max(60));
Ok(DeviceFlowSession {
provider: provider.to_string(),
verification_uri: init.verification_uri,
verification_uri_complete: init.verification_uri_complete,
user_code: init.user_code,
display_uri,
device_code: init.device_code,
client_id,
token_url: cfg.token_url.to_string(),
interval,
deadline,
})
}
pub fn poll_device_flow(session: &mut DeviceFlowSession) -> Result<DeviceFlowStep> {
if Instant::now() >= session.deadline {
return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: code expired before authorisation.".to_string() });
}
let client = crate::http::make_client();
let poll_req = client.post(&session.token_url)
.header("Accept", "application/json")
.form(&[
("client_id", session.client_id.as_str()),
("device_code", session.device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
]);
let resp = poll_req.send()
.map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth poll: {}", e) })?;
let body: TokenResponse = resp.json()
.map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth poll: malformed JSON: {}", e) })?;
match body {
TokenResponse::Success { access_token, refresh_token, expires_in, .. } =>
Ok(DeviceFlowStep::Done { access_token, refresh_token, expires_in }),
TokenResponse::Error { error, error_description } => match error.as_str() {
"authorization_pending" => Ok(DeviceFlowStep::Pending),
"slow_down" => {
session.interval += Duration::from_secs(5);
Ok(DeviceFlowStep::SlowDown)
}
"expired_token" => Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: code expired. Start again.".to_string() }),
"access_denied" => Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: user denied authorisation.".to_string() }),
other => Err(ToriiError::Auth { provider: "oauth".into(), message: format!(
"OAuth device flow error '{}': {}",
other, error_description.unwrap_or_default()
) }),
}
}
}
pub fn refresh_access_token(provider: &str, refresh_token: &str)
-> Result<(String, Option<String>, Option<u64>)>
{
let cfg = device_flow_provider(provider).ok_or_else(|| ToriiError::InvalidConfig(format!(
"OAuth refresh not configured for `{}`. Re-auth manually with `torii auth oauth {}`.",
provider, provider
)))?;
let client_id = std::env::var(cfg.client_id_env).ok()
.or_else(|| cfg.bundled_client_id.map(String::from))
.ok_or_else(|| ToriiError::InvalidConfig(format!(
"No OAuth client_id for `{}` refresh.", provider
)))?;
let client = crate::http::make_client();
let req = client.post(cfg.token_url)
.header("Accept", "application/json")
.form(&[
("client_id", client_id.as_str()),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
]);
let body: TokenResponse = req.send()
.map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth refresh: {}", e) })?
.json()
.map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth refresh: malformed JSON: {}", e) })?;
match body {
TokenResponse::Success { access_token, refresh_token, expires_in, .. } => {
Ok((access_token, refresh_token, expires_in))
}
TokenResponse::Error { error, error_description } => {
Err(ToriiError::Auth { provider: "oauth".into(), message: format!(
"OAuth refresh error '{}': {}",
error, error_description.unwrap_or_default()
) })
}
}
}
pub fn device_flow_interval(session: &DeviceFlowSession) -> Duration {
session.interval
}
pub fn revoke_token(provider: &str, token: &str) -> Result<bool> {
match provider {
"gitlab" => revoke_gitlab(token),
"github" => revoke_github(token),
_ => Ok(false),
}
}
fn revoke_gitlab(token: &str) -> Result<bool> {
let client_id = std::env::var("TORII_GITLAB_APP_ID").ok()
.unwrap_or_else(|| "b72a85262c309587f67591da8fed4f8e8f4ee7349e9ed06f6a2a99ee7caec4fe".to_string());
let client = crate::http::make_client();
let req = client.post("https://gitlab.com/oauth/revoke")
.form(&[
("client_id", client_id.as_str()),
("token", token),
("token_type_hint", "access_token"),
]);
let resp = req.send().map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab revoke: {}", e) })?;
let status = resp.status().as_u16();
match status {
200 | 401 | 404 => Ok(true),
_ => {
let body = resp.text().unwrap_or_default();
Err(ToriiError::Network { provider: "gitlab".into(), message: format!(
"GitLab revoke returned HTTP {}: {}", status, body
) })
}
}
}
fn revoke_github(token: &str) -> Result<bool> {
let client_id = std::env::var("TORII_GITHUB_APP_ID").ok()
.unwrap_or_else(|| "Ov23liDcA2Njn7eRWnYV".to_string());
let Ok(client_secret) = std::env::var("TORII_GITHUB_APP_SECRET") else {
return Ok(false);
};
let client = crate::http::make_client();
let url = format!("https://api.github.com/applications/{}/token", client_id);
let req = client.delete(&url)
.basic_auth(client_id.clone(), Some(client_secret))
.header("Accept", "application/vnd.github+json")
.json(&serde_json::json!({ "access_token": token }));
let resp = req.send().map_err(|e| ToriiError::Network { provider: "github".into(), message: format!("GitHub revoke: {}", e) })?;
let status = resp.status().as_u16();
match status {
204 | 404 | 422 => Ok(true),
_ => {
let body = resp.text().unwrap_or_default();
Err(ToriiError::Network { provider: "github".into(), message: format!(
"GitHub revoke returned HTTP {}: {}", status, body
) })
}
}
}
pub fn revoke_hint_url(provider: &str) -> Option<&'static str> {
match provider {
"github" => Some("https://github.com/settings/applications"),
"gitlab" => Some("https://gitlab.com/-/profile/applications"),
"codeberg" => Some("https://codeberg.org/user/settings/applications"),
"bitbucket"=> Some("https://bitbucket.org/account/settings/app-authorizations/"),
_ => None,
}
}
pub fn rotate_gitlab_pat(token: &str) -> Result<String> {
let client = crate::http::make_client();
let req = client
.post("https://gitlab.com/api/v4/personal_access_tokens/self/rotate")
.header("Authorization", format!("Bearer {}", token));
let resp = req.send().map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab rotate PAT: {}", e) })?;
let status = resp.status().as_u16();
let body = resp.text().unwrap_or_default();
if status != 200 && status != 201 {
return Err(ToriiError::Network { provider: "gitlab".into(), message: format!(
"GitLab rotate PAT returned HTTP {}: {}", status, body
) });
}
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("parse rotate response: {}", e) }
})?;
json["token"].as_str()
.map(String::from)
.ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: format!(
"GitLab rotate PAT response missing `token`: {}", body
) })
}
use std::io::{Read, Write as IoWrite};
use std::net::TcpListener;
struct AuthCodeProvider {
authorize_url: &'static str,
token_url: &'static str,
scopes: &'static str,
client_id_env: &'static str,
bundled_client_id: Option<&'static str>,
client_secret_env: Option<&'static str>,
}
fn auth_code_provider(provider: &str) -> Option<AuthCodeProvider> {
match provider {
"bitbucket" => Some(AuthCodeProvider {
authorize_url: "https://bitbucket.org/site/oauth2/authorize",
token_url: "https://bitbucket.org/site/oauth2/access_token",
scopes: "repository repository:write account pullrequest pullrequest:write issue:write pipeline",
client_id_env: "TORII_BITBUCKET_APP_ID",
bundled_client_id: Some("xQAkJEqx3LK4WtJ3KD"),
client_secret_env: Some("TORII_BITBUCKET_APP_SECRET"),
}),
_ => None,
}
}
const LOOPBACK_PORT: u16 = 8888;
const LOOPBACK_PATH: &str = "/callback";
pub fn run_auth_code_flow(provider: &str) -> Result<String> {
let cfg = auth_code_provider(provider).ok_or_else(|| ToriiError::InvalidConfig(format!(
"OAuth authorization-code flow not configured for `{}`.", provider
)))?;
let client_id = std::env::var(cfg.client_id_env).ok()
.or_else(|| cfg.bundled_client_id.map(String::from))
.ok_or_else(|| ToriiError::InvalidConfig(format!(
"No OAuth client_id for `{}`. Set {} or create a PAT manually and run \
`torii auth set {} ...`.",
provider, cfg.client_id_env, provider
)))?;
let client_secret = cfg.client_secret_env
.and_then(|name| std::env::var(name).ok());
let code_verifier = random_verifier();
let code_challenge = sha256_base64url(&code_verifier);
let redirect_uri = format!("http://localhost:{}{}", LOOPBACK_PORT, LOOPBACK_PATH);
let state = random_verifier(); let authz_url = format!(
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
cfg.authorize_url,
urlencode(&client_id),
urlencode(&redirect_uri),
urlencode(cfg.scopes),
urlencode(&state),
urlencode(&code_challenge),
);
let listener = TcpListener::bind(("127.0.0.1", LOOPBACK_PORT))
.map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!(
"OAuth loopback: cannot bind 127.0.0.1:{} ({}). Is another flow already running?",
LOOPBACK_PORT, e
) })?;
println!();
println!("⛩ Open this URL in your browser to authorise Torii:");
println!();
println!(" {}", authz_url);
println!();
println!("Waiting for the redirect on localhost:{}{}…", LOOPBACK_PORT, LOOPBACK_PATH);
let (mut stream, _addr) = listener.accept()
.map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth loopback accept: {}", e) })?;
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)
.map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth loopback read: {}", e) })?;
let request = String::from_utf8_lossy(&buf[..n]);
let request_line = request.lines().next().unwrap_or("");
let path_query = request_line.split_whitespace().nth(1).unwrap_or("");
let body = "<!doctype html><html><body><h2>⛩ Authorised — you can close this tab.</h2></body></html>";
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
let (code, returned_state) = parse_callback(path_query)
.ok_or_else(|| ToriiError::Auth { provider: "oauth".into(), message: "OAuth callback didn't include a `code` parameter.".to_string() })?;
if returned_state != state {
return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth state mismatch (possible CSRF). Run the command again.".to_string() });
}
let client = crate::http::make_client();
let mut params = vec![
("grant_type", "authorization_code".to_string()),
("code", code),
("redirect_uri", redirect_uri),
("client_id", client_id.clone()),
("code_verifier", code_verifier),
];
let mut req = client.post(cfg.token_url).header("Accept", "application/json");
if let Some(secret) = &client_secret {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", client_id, secret));
req = req.header("Authorization", format!("Basic {}", b64));
} else {
params.push(("client_secret_present", "false".to_string()));
params.pop(); }
let resp = req.form(¶ms).send()
.map_err(|e| ToriiError::Auth { provider: "oauth".into(), message: format!("OAuth token exchange: {}", e) })?;
let json: serde_json::Value = resp.json()
.map_err(|e| ToriiError::Auth { provider: "oauth".into(), message: format!("OAuth token: malformed JSON: {}", e) })?;
if let Some(err) = json.get("error").and_then(|v| v.as_str()) {
return Err(ToriiError::Auth { provider: "oauth".into(), message: format!(
"OAuth token exchange failed: {} — {}", err,
json.get("error_description").and_then(|v| v.as_str()).unwrap_or("")
) });
}
let token = json.get("access_token").and_then(|v| v.as_str())
.ok_or_else(|| ToriiError::Auth { provider: "oauth".into(), message: format!(
"OAuth token exchange: response had no access_token. Body: {}", json
) })?
.to_string();
println!("✅ Authorised. Token saved.");
Ok(token)
}
fn parse_callback(path_query: &str) -> Option<(String, String)> {
let qs = path_query.split('?').nth(1)?;
let mut code = None;
let mut state = None;
for pair in qs.split('&') {
let mut iter = pair.splitn(2, '=');
match (iter.next(), iter.next()) {
(Some("code"), Some(v)) => code = Some(urldecode(v)),
(Some("state"), Some(v)) => state = Some(urldecode(v)),
_ => {}
}
}
Some((code?, state?))
}
fn random_verifier() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut seed = [0u8; 48];
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
let pid = std::process::id() as u64;
let bump = COUNTER.fetch_add(1, Ordering::Relaxed);
seed[..8].copy_from_slice(&now.as_nanos().to_le_bytes()[..8]);
seed[8..16].copy_from_slice(&pid.to_le_bytes());
seed[16..24].copy_from_slice(&bump.to_le_bytes());
let hash = sha256_raw(&seed);
base64url_nopad(&hash)[..43].to_string()
}
fn sha256_raw(input: &[u8]) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(input);
hasher.finalize().into()
}
fn sha256_base64url(input: &str) -> String {
let digest = sha256_raw(input.as_bytes());
base64url_nopad(&digest)
}
fn base64url_nopad(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn urlencode(s: &str) -> String {
crate::url::encode(s)
}
fn urldecode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => { out.push(' '); i += 1; }
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i+1] as char).to_digit(16);
let lo = (bytes[i+2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push(((hi << 4) | lo) as u8 as char);
i += 3;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
c => { out.push(c as char); i += 1; }
}
}
out
}
pub fn auth_code_flow_supported(provider: &str) -> bool {
auth_code_provider(provider).is_some()
}