use std::path::PathBuf;
use axum::http::HeaderMap;
use tokio::sync::{Mutex, RwLock};
use crate::error::{Result, RototoError};
use super::github::{self, GitHubClient};
use super::identity::ActorIdentity;
use super::store::{SessionUser, Store};
pub const GITHUB_CLIENT_ID_ENV: &str = "ROTOTO_GITHUB_CLIENT_ID";
pub const GITHUB_CLIENT_SECRET_ENV: &str = "ROTOTO_GITHUB_CLIENT_SECRET";
pub const SESSION_COOKIE: &str = "rototo_console_session";
pub const OAUTH_STATE_COOKIE: &str = "rototo_console_oauth_state";
pub const GITHUB_OAUTH_SCOPES: &str = "read:user repo";
const BAKED_DEVICE_CLIENT_ID: &str = "";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostedOAuth {
pub client_id: String,
pub client_secret: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitHubCredentialSource {
Flag,
Environment,
DeviceFlow,
GhCli,
OAuthSession,
}
#[derive(Clone)]
pub struct AmbientToken {
pub token: String,
pub source: GitHubCredentialSource,
}
pub struct DeviceFlowState {
pub device_code: String,
}
pub struct LocalAuth {
token: RwLock<Option<AmbientToken>>,
identity: RwLock<Option<(String, SessionUser)>>,
pub device_flow: Mutex<Option<DeviceFlowState>>,
credentials_path: Option<PathBuf>,
device_client_id: Option<String>,
}
impl LocalAuth {
pub fn new(
initial: Option<AmbientToken>,
data_dir: Option<&std::path::Path>,
device_client_id: Option<String>,
) -> Self {
Self {
token: RwLock::new(initial),
identity: RwLock::new(None),
device_flow: Mutex::new(None),
credentials_path: data_dir.map(|dir| dir.join("credentials.json")),
device_client_id,
}
}
pub fn device_flow_available(&self) -> bool {
self.device_client_id.is_some()
}
pub fn device_client_id(&self) -> Option<&str> {
self.device_client_id.as_deref()
}
pub async fn token(&self) -> Option<AmbientToken> {
self.token.read().await.clone()
}
pub async fn set_device_token(&self, token: String) -> Result<()> {
if let Some(credentials_path) = self.credentials_path.as_ref() {
let credentials = serde_json::json!({ "github_token": token });
write_private_file(credentials_path, &credentials.to_string()).await?;
}
*self.token.write().await = Some(AmbientToken {
token,
source: GitHubCredentialSource::DeviceFlow,
});
*self.identity.write().await = None;
Ok(())
}
pub async fn identity(&self, github: &GitHubClient) -> Result<Option<SessionUser>> {
let Some(ambient) = self.token().await else {
return Ok(None);
};
{
let identity = self.identity.read().await;
if let Some((token, user)) = identity.as_ref()
&& *token == ambient.token
{
return Ok(Some(user.clone()));
}
}
let viewer = github.viewer(&ambient.token).await.map_err(|err| {
RototoError::new(format!(
"the configured GitHub token was rejected: {}",
github::github_error_message(&err, "Authenticating")
))
})?;
let user = SessionUser {
session_hash: "local".to_owned(),
principal_id: format!("github:{}", viewer.id),
identity: ActorIdentity::GitHub {
id: viewer.id.to_string(),
login: viewer.login,
name: viewer.name,
avatar_url: viewer.avatar_url,
},
github_token: Some(ambient.token.clone()),
};
*self.identity.write().await = Some((ambient.token, user.clone()));
Ok(Some(user))
}
}
pub(super) fn baked_device_client_id() -> Option<String> {
let id = BAKED_DEVICE_CLIENT_ID.to_owned();
let id = id.trim().to_owned();
(!id.is_empty()).then_some(id)
}
pub async fn resolve_ambient_token(
flag_token: Option<&str>,
data_dir: Option<&std::path::Path>,
) -> Option<AmbientToken> {
if let Some(token) = flag_token {
let token = token.trim();
if !token.is_empty() {
let source = if std::env::args().any(|arg| arg == "--workspace-token") {
GitHubCredentialSource::Flag
} else {
GitHubCredentialSource::Environment
};
tracing::info!(
operation = "console.auth.ambient_token",
source = ?source,
"console local auth token resolved from explicit configuration"
);
return Some(AmbientToken {
token: token.to_owned(),
source,
});
}
}
if let Some(data_dir) = data_dir {
let credentials_path = data_dir.join("credentials.json");
if let Ok(contents) = tokio::fs::read_to_string(&credentials_path).await
&& let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&contents)
&& let Some(token) = credentials
.get("github_token")
.and_then(serde_json::Value::as_str)
&& !token.trim().is_empty()
{
tracing::info!(
operation = "console.auth.ambient_token",
source = ?GitHubCredentialSource::DeviceFlow,
credentials_path = %credentials_path.display(),
"console local auth token resolved from stored device-flow credentials"
);
return Some(AmbientToken {
token: token.trim().to_owned(),
source: GitHubCredentialSource::DeviceFlow,
});
}
} else {
tracing::debug!(
operation = "console.auth.ambient_token",
"console local auth skipped stored device-flow credentials in ephemeral state mode"
);
}
let started = std::time::Instant::now();
tracing::debug!(
operation = "process.command",
command = "gh auth token",
"console outbound process call started"
);
match tokio::process::Command::new("gh")
.args(["auth", "token"])
.output()
.await
{
Ok(output) if output.status.success() => {
let token = String::from_utf8_lossy(&output.stdout).trim().to_owned();
tracing::info!(
operation = "process.command",
command = "gh auth token",
status = output.status.code(),
token_found = !token.is_empty(),
latency_ms = started.elapsed().as_millis(),
"console outbound process call completed"
);
if !token.is_empty() {
tracing::info!(
operation = "console.auth.ambient_token",
source = ?GitHubCredentialSource::GhCli,
"console local auth token resolved from GitHub CLI"
);
return Some(AmbientToken {
token,
source: GitHubCredentialSource::GhCli,
});
}
}
Ok(output) => {
tracing::debug!(
operation = "process.command",
command = "gh auth token",
status = output.status.code(),
latency_ms = started.elapsed().as_millis(),
"console outbound process call returned non-zero status"
);
}
Err(err) => {
tracing::debug!(
operation = "process.command",
command = "gh auth token",
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound process call failed to start"
);
}
}
tracing::info!(
operation = "console.auth.ambient_token",
"console local auth token was not found"
);
None
}
async fn write_private_file(path: &std::path::Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|err| {
RototoError::new(format!("failed to create {}: {err}", parent.display()))
})?;
}
tokio::fs::write(path, contents)
.await
.map_err(|err| RototoError::new(format!("failed to write {}: {err}", path.display())))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|err| {
RototoError::new(format!(
"failed to set permissions on {}: {err}",
path.display()
))
})?;
}
Ok(())
}
pub fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
for header in headers.get_all(axum::http::header::COOKIE) {
let Ok(value) = header.to_str() else {
continue;
};
for pair in value.split(';') {
let pair = pair.trim();
if let Some((cookie_name, cookie_value)) = pair.split_once('=')
&& cookie_name == name
{
return Some(cookie_value.to_owned());
}
}
}
None
}
pub fn set_cookie(name: &str, value: &str, secure: bool, max_age: Option<u64>) -> String {
let mut cookie = format!("{name}={value}; HttpOnly; SameSite=Lax; Path=/");
if secure {
cookie.push_str("; Secure");
}
if let Some(max_age) = max_age {
cookie.push_str(&format!("; Max-Age={max_age}"));
}
cookie
}
pub async fn session_from_headers(store: &Store, headers: &HeaderMap) -> Option<SessionUser> {
let token = cookie_value(headers, SESSION_COOKIE)?;
store.get_session(&token).await.ok().flatten()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cookie_parsing_picks_named_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
"a=1; rototo_console_session=tok-en; b=2".parse().unwrap(),
);
assert_eq!(
cookie_value(&headers, SESSION_COOKIE).as_deref(),
Some("tok-en")
);
assert_eq!(cookie_value(&headers, "missing"), None);
}
#[test]
fn set_cookie_includes_expected_attributes() {
let cookie = set_cookie("name", "value", true, Some(600));
assert_eq!(
cookie,
"name=value; HttpOnly; SameSite=Lax; Path=/; Secure; Max-Age=600"
);
let session = set_cookie("name", "value", false, None);
assert!(!session.contains("Secure"));
assert!(!session.contains("Max-Age"));
}
}