feat(auth): 服务端托管 Google OAuth,desktop 轮询登录;补充服务端环境变量文档
Some checks failed
Secrets v3 CI / 检查 (push) Failing after 2m6s
Some checks failed
Secrets v3 CI / 检查 (push) Failing after 2m6s
This commit is contained in:
@@ -2,7 +2,6 @@ mod local_vault;
|
||||
mod session_api;
|
||||
|
||||
use anyhow::{Context, Result as AnyResult, anyhow};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use local_vault::{
|
||||
LocalDetailField, LocalEntryDetail, LocalEntryDraft, LocalEntryQuery, LocalHistoryItem,
|
||||
LocalSecretDraft, LocalSecretUpdateDraft, LocalSecretValue, LocalVault,
|
||||
@@ -24,7 +23,6 @@ use secrets_device_auth::new_device_fingerprint;
|
||||
use secrets_domain::KdfConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_api::start_desktop_session_server;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
@@ -32,11 +30,7 @@ use std::{
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use tauri::Manager;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
use url::Url;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DesktopState {
|
||||
@@ -200,33 +194,28 @@ struct DemoLoginResponse {
|
||||
device_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GoogleDesktopClientFile {
|
||||
installed: GoogleDesktopClient,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GoogleDesktopClient {
|
||||
client_id: String,
|
||||
client_secret: Option<String>,
|
||||
auth_uri: String,
|
||||
token_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GoogleTokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GoogleDesktopLoginRequest {
|
||||
access_token: String,
|
||||
struct DesktopLoginStartRequest {
|
||||
device_name: String,
|
||||
platform: String,
|
||||
client_version: String,
|
||||
device_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DesktopLoginStartResponse {
|
||||
session_id: String,
|
||||
auth_url: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DesktopLoginPollResponse {
|
||||
status: String,
|
||||
device_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct EntryListQuery {
|
||||
folder: Option<String>,
|
||||
@@ -320,8 +309,7 @@ async fn continue_demo_login(
|
||||
window: tauri::Window,
|
||||
state: tauri::State<'_, DesktopState>,
|
||||
) -> Result<AppBootstrap, String> {
|
||||
let google_client = load_google_desktop_client().map_err(|err| err.to_string())?;
|
||||
let payload = complete_google_desktop_login(&state, &google_client)
|
||||
let payload = complete_hosted_google_desktop_login(&state)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
@@ -929,118 +917,11 @@ fn apply_mcp_config_to_all() -> AnyResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..")
|
||||
}
|
||||
|
||||
fn default_google_oauth_client_filename() -> &'static str {
|
||||
"client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json"
|
||||
}
|
||||
|
||||
fn resolve_google_oauth_client_path() -> AnyResult<PathBuf> {
|
||||
let configured = std::env::var("GOOGLE_OAUTH_CLIENT_FILE").ok();
|
||||
let mut candidates = Vec::new();
|
||||
let repo_root = workspace_root();
|
||||
|
||||
if let Some(raw_path) = configured.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let path = PathBuf::from(raw_path);
|
||||
if path.is_absolute() {
|
||||
candidates.push(path);
|
||||
} else {
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
candidates.push(cwd.join(&path));
|
||||
}
|
||||
candidates.push(repo_root.join(&path));
|
||||
}
|
||||
} else {
|
||||
candidates.push(repo_root.join(default_google_oauth_client_filename()));
|
||||
}
|
||||
|
||||
candidates.dedup();
|
||||
|
||||
candidates.into_iter().find(|path| path.exists()).with_context(|| {
|
||||
let configured_hint = configured
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("当前 GOOGLE_OAUTH_CLIENT_FILE={value}。"))
|
||||
.unwrap_or_else(|| "当前未设置 GOOGLE_OAUTH_CLIENT_FILE。".to_string());
|
||||
format!(
|
||||
"google oauth client file not found. {} 请把 {} 放到仓库根目录,或把 GOOGLE_OAUTH_CLIENT_FILE 设置为该文件的绝对路径",
|
||||
configured_hint,
|
||||
default_google_oauth_client_filename(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_google_desktop_client() -> AnyResult<GoogleDesktopClient> {
|
||||
let configured = resolve_google_oauth_client_path()?;
|
||||
let raw = fs::read_to_string(&configured)
|
||||
.with_context(|| format!("failed to read {}", configured.display()))?;
|
||||
let parsed: GoogleDesktopClientFile =
|
||||
serde_json::from_str(&raw).context("failed to parse google desktop client file")?;
|
||||
Ok(parsed.installed)
|
||||
}
|
||||
|
||||
async fn complete_google_desktop_login(
|
||||
state: &DesktopState,
|
||||
google_client: &GoogleDesktopClient,
|
||||
) -> AnyResult<DemoLoginResponse> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.context("failed to bind local oauth callback listener")?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.context("failed to read callback listener address")?
|
||||
.port();
|
||||
let redirect_uri = format!("http://localhost:{port}/oauth/callback");
|
||||
let verifier = new_device_fingerprint();
|
||||
let challenge = pkce_challenge(&verifier);
|
||||
|
||||
let mut auth_url = Url::parse(&google_client.auth_uri).context("invalid google auth uri")?;
|
||||
auth_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("client_id", &google_client.client_id)
|
||||
.append_pair("redirect_uri", &redirect_uri)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("scope", "openid email profile")
|
||||
.append_pair("code_challenge", &challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("access_type", "offline")
|
||||
.append_pair("prompt", "consent");
|
||||
|
||||
open_system_browser(auth_url.as_str())?;
|
||||
let auth_code = wait_for_google_callback(listener).await?;
|
||||
|
||||
let mut form = vec![
|
||||
("client_id", google_client.client_id.clone()),
|
||||
("code", auth_code),
|
||||
("code_verifier", verifier),
|
||||
("grant_type", "authorization_code".to_string()),
|
||||
("redirect_uri", redirect_uri),
|
||||
];
|
||||
if let Some(secret) = google_client.client_secret.clone() {
|
||||
form.push(("client_secret", secret));
|
||||
}
|
||||
|
||||
let google_token = state
|
||||
async fn complete_hosted_google_desktop_login(state: &DesktopState) -> AnyResult<DemoLoginResponse> {
|
||||
let start = state
|
||||
.client
|
||||
.post(&google_client.token_uri)
|
||||
.form(&form)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to exchange google auth code")?
|
||||
.error_for_status()
|
||||
.context("google token exchange failed")?
|
||||
.json::<GoogleTokenResponse>()
|
||||
.await
|
||||
.context("failed to decode google token response")?;
|
||||
|
||||
let response = state
|
||||
.client
|
||||
.post(format!("{}/auth/google/desktop-login", state.api_base))
|
||||
.json(&GoogleDesktopLoginRequest {
|
||||
access_token: google_token.access_token,
|
||||
.post(format!("{}/auth/desktop/start", state.api_base))
|
||||
.json(&DesktopLoginStartRequest {
|
||||
device_name: current_device_name(),
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
@@ -1048,56 +929,50 @@ async fn complete_google_desktop_login(
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.context("failed to call google desktop login API")?;
|
||||
|
||||
response
|
||||
.context("failed to create desktop login session")?
|
||||
.error_for_status()
|
||||
.context("google desktop login failed")?
|
||||
.json::<DemoLoginResponse>()
|
||||
.context("desktop login session creation failed")?
|
||||
.json::<DesktopLoginStartResponse>()
|
||||
.await
|
||||
.context("failed to decode desktop login response")
|
||||
}
|
||||
.context("failed to decode desktop login session")?;
|
||||
|
||||
async fn wait_for_google_callback(listener: TcpListener) -> AnyResult<String> {
|
||||
let (mut socket, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.context("failed to accept oauth callback connection")?;
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let read = socket
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.context("failed to read oauth callback request")?;
|
||||
let request = String::from_utf8_lossy(&buffer[..read]);
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.context("invalid oauth callback request line")?;
|
||||
let callback_url = Url::parse(&format!("http://localhost{path}"))
|
||||
.context("failed to parse oauth callback url")?;
|
||||
let code = callback_url
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "code")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
.context("oauth callback missing code")?;
|
||||
let _ = &start.expires_at;
|
||||
open_system_browser(&start.auth_url)?;
|
||||
|
||||
let body = "<html><body><h3>登录成功,可以返回 Secrets。</h3></body></html>";
|
||||
let response = format!(
|
||||
"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
|
||||
);
|
||||
socket
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.context("failed to respond to oauth callback")?;
|
||||
Ok(code)
|
||||
}
|
||||
for _ in 0..120 {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
let poll = state
|
||||
.client
|
||||
.get(format!("{}/auth/desktop/poll", state.api_base))
|
||||
.query(&[("session_id", start.session_id.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.context("failed to poll desktop login session")?
|
||||
.error_for_status()
|
||||
.context("desktop login poll failed")?
|
||||
.json::<DesktopLoginPollResponse>()
|
||||
.await
|
||||
.context("failed to decode desktop login poll response")?;
|
||||
|
||||
fn pkce_challenge(verifier: &str) -> String {
|
||||
let digest = Sha256::digest(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
match poll.status.as_str() {
|
||||
"pending" => continue,
|
||||
"succeeded" => {
|
||||
let device_token = poll
|
||||
.device_token
|
||||
.context("desktop login succeeded without device token")?;
|
||||
return Ok(DemoLoginResponse { device_token });
|
||||
}
|
||||
"failed" | "expired" | "consumed" => {
|
||||
return Err(anyhow!(
|
||||
"{}",
|
||||
poll.error.unwrap_or_else(|| format!("desktop login {}", poll.status))
|
||||
));
|
||||
}
|
||||
other => return Err(anyhow!("unexpected desktop login status: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("desktop login timed out"))
|
||||
}
|
||||
|
||||
fn open_system_browser(url: &str) -> AnyResult<()> {
|
||||
|
||||
Reference in New Issue
Block a user