feat(auth): 服务端托管 Google OAuth;修复未解锁 vault 时 bootstrap

- API:桌面登录 session、Google 托管回调与轮询
- Desktop:轮询登录;bootstrap 在 vault 未解锁时不返回 shell,避免跳过主密码
- 文档与 deploy/.env.example 对齐 GOOGLE_OAUTH_* 与 SECRETS_PUBLIC_BASE_URL
This commit is contained in:
agent
2026-04-14 20:28:52 +08:00
committed by voson
parent e6bd2225cd
commit 57c3efb70e
10 changed files with 738 additions and 266 deletions

View File

@@ -113,18 +113,23 @@ secrets/
当前登录流为 **Google Desktop OAuth** 当前登录流为 **Google Desktop OAuth**
- 桌面端使用系统浏览器拉起 Google 授权 - 桌面端使用系统浏览器拉起 Google 授权
- 使用本地 loopback callback - API 服务端持有 Google OAuth client 配置并处理 callback / token exchange
- 使用 `PKCE` - desktop 创建一次性 login session打开托管登录页后轮询状态
- 桌面端换取 Google token 后调用 API 的桌面登录接口
- API 校验 Google userinfo 后发放本地 device token - API 校验 Google userinfo 后发放本地 device token
桌面端优先读取 官网 DMG 正式分发时,服务端至少需要配置
- `GOOGLE_OAUTH_CLIENT_FILE` - `SECRETS_PUBLIC_BASE_URL`
- `GOOGLE_OAUTH_CLIENT_ID`
- `GOOGLE_OAUTH_CLIENT_SECRET`
- `GOOGLE_OAUTH_REDIRECT_URI`
默认开发文件名 推荐约束
- `client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json` - `SECRETS_PUBLIC_BASE_URL` 使用用户浏览器实际访问的 HTTPS 官网地址
- `GOOGLE_OAUTH_REDIRECT_URI` 配置为 `${SECRETS_PUBLIC_BASE_URL}/auth/google/callback`
- `GOOGLE_OAUTH_CLIENT_SECRET` 只保留在服务端环境变量或密钥管理系统中,不入库
- Google Cloud Console 中登记的 callback URL 必须与 `GOOGLE_OAUTH_REDIRECT_URI` 完全一致
## MCP ## MCP

3
Cargo.lock generated
View File

@@ -3669,6 +3669,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"base64 0.22.1",
"chrono", "chrono",
"dotenvy", "dotenvy",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -3678,10 +3679,12 @@ dependencies = [
"secrets-infrastructure-db", "secrets-infrastructure-db",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url",
"uuid", "uuid",
] ]

View File

@@ -27,8 +27,42 @@ cargo run -p secrets-desktop
- `apps/desktop/src-tauri/tauri.conf.json``build.frontendDist` 指向 `apps/desktop/dist` - `apps/desktop/src-tauri/tauri.conf.json``build.frontendDist` 指向 `apps/desktop/dist`
- 当前仓库会直接提交 `apps/desktop/dist/` 下的桌面端静态资源 - 当前仓库会直接提交 `apps/desktop/dist/` 下的桌面端静态资源
- 因此新机器 clone 后,无需额外前端构建步骤即可启动 desktop - 因此新机器 clone 后,无需额外前端构建步骤即可启动 desktop
- Google Desktop OAuth 的 `client_secret_*.json` **不会入库** - 官网 DMG 正式分发不依赖本地 `client_secret_*.json`
- 新机器需要自行提供该文件,并通过 `GOOGLE_OAUTH_CLIENT_FILE` 指向它;推荐使用绝对路径 - Google OAuth 凭据只配置在 API 服务端desktop 通过浏览器完成托管登录
## 官网 DMG 的服务端 OAuth 配置
官网 DMG 正式分发时,**Google OAuth 只配置在 API 服务端**。桌面端不需要本地 `client_secret_*.json`,也不直接向 Google 换 token。
建议先复制 `deploy/.env.example``.env`,然后至少配置以下变量:
```bash
SECRETS_PUBLIC_BASE_URL=https://secrets.example.com
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
GOOGLE_OAUTH_REDIRECT_URI=https://secrets.example.com/auth/google/callback
```
变量含义:
- `SECRETS_PUBLIC_BASE_URL`:桌面端打开浏览器时访问的 API 外网基地址,必须是用户浏览器能访问到的公开地址
- `GOOGLE_OAUTH_CLIENT_ID`Google Cloud Console 中为服务端登录流程配置的 OAuth Client ID
- `GOOGLE_OAUTH_CLIENT_SECRET`:对应的 Client Secret只能保留在服务端
- `GOOGLE_OAUTH_REDIRECT_URI`Google 登录完成后回调到 API 的地址,必须与 Google Console 中登记的回调地址完全一致
配置步骤建议:
1. 在 Google Cloud Console 创建或选择 OAuth Client
2. 把授权回调地址加入允许列表,例如 `https://secrets.example.com/auth/google/callback`
3. 把上面的 4 个变量配置到 API 服务的运行环境中
4. 确认 `SECRETS_PUBLIC_BASE_URL``GOOGLE_OAUTH_REDIRECT_URI` 使用同一公开域名
5. 重启 API 服务后,再用 desktop / DMG 验证浏览器登录流程
注意:
- `GOOGLE_OAUTH_CLIENT_SECRET` 不要提交到仓库
- `GOOGLE_OAUTH_REDIRECT_URI` 不要写成 `localhost`,正式分发应使用官网可访问域名
- 如果 API 部署在反向代理后面,`SECRETS_PUBLIC_BASE_URL` 应填写用户实际访问的 HTTPS 地址,而不是内网监听地址
## 当前能力 ## 当前能力
@@ -69,25 +103,27 @@ cargo test --locked
- 桌面端登录态仅在当前进程内有效,不持久化 `device token` - 桌面端登录态仅在当前进程内有效,不持久化 `device token`
- 本地 daemon 默认监听 `http://127.0.0.1:9515/mcp` - 本地 daemon 默认监听 `http://127.0.0.1:9515/mcp`
- daemon 通过活跃 desktop 进程提供的本地会话转发访问 APIdesktop 进程退出后所有工具不可用 - daemon 通过活跃 desktop 进程提供的本地会话转发访问 APIdesktop 进程退出后所有工具不可用
- `target_exec` 会显式读取真实 secret 值后再生成 `TARGET_*` 环境变量 - `target_exec` 会显式读取真实 secret 值后再生成 `TARGET_`* 环境变量
- 不保留 `secrets_env_map` - 不保留 `secrets_env_map`
### Canonical MCP 工具 ### Canonical MCP 工具
| 工具 | 说明 |
| --- | --- | | 工具 | 说明 |
| `secrets_entry_find` | 从 desktop 已解锁本地 vault 搜索对象,支持 `query` / `folder` / `type` | | ------------------------- | --------------------------------------------------------- |
| `secrets_entry_get` | 读取单条本地对象,并返回当前 secrets 的真实值 | | `secrets_entry_find` | 从 desktop 已解锁本地 vault 搜索对象,支持 `query` / `folder` / `type` |
| `secrets_entry_add` | 在本地 vault 创建对象,可选附带初始 secrets | | `secrets_entry_get` | 读取单条本地对象,并返回当前 secrets 的真实值 |
| `secrets_entry_update` | 更新本地对象的 folder / type / name / metadata | | `secrets_entry_add` | 在本地 vault 创建对象,可选附带初始 secrets |
| `secrets_entry_delete` | 本地对象标记为删除 | | `secrets_entry_update` | 更新本地对象的 folder / type / name / metadata |
| `secrets_entry_restore` | 恢复本地已删除对象 | | `secrets_entry_delete` | 将本地对象标记为删除 |
| `secrets_secret_add` | 向已有本地对象新增 secret | | `secrets_entry_restore` | 恢复本地已删除对象 |
| `secrets_secret_update` | 更新本地 secret 名称、类型或内容 | | `secrets_secret_add` | 向已有本地对象新增 secret |
| `secrets_secret_delete` | 删除单个本地 secret | | `secrets_secret_update` | 更新本地 secret 名称、类型或内容 |
| `secrets_secret_history` | 查看单个本地 secret 的历史版本 | | `secrets_secret_delete` | 删除单个本地 secret |
| `secrets_secret_rollback` | 单个本地 secret 回滚到指定版本 | | `secrets_secret_history` | 查看单个本地 secret 的历史版本 |
| `target_exec` | 用本地对象的 metadata 和 secrets 生成 `TARGET_*` 环境变量并执行本地命令 | | `secrets_secret_rollback` | 将单个本地 secret 回滚到指定版本 |
| `target_exec` | 用本地对象的 metadata 和 secrets 生成 `TARGET_`* 环境变量并执行本地命令 |
## AI 客户端配置 ## AI 客户端配置
@@ -115,7 +151,7 @@ cargo test --locked
- 服务端保存 `vault_objects``vault_object_revisions` - 服务端保存 `vault_objects``vault_object_revisions`
- desktop 本地保存 `vault_objects``vault_object_history``pending_changes``sync_state` - desktop 本地保存 `vault_objects``vault_object_history``pending_changes``sync_state`
- 搜索、详情、reveal、history 主要在本地已解锁 vault 上完成 - 搜索、详情、reveal、history 主要在本地已解锁 vault 上完成
- 服务端负责 `auth/device``/sync/*`,不再承担明文搜索与明文 reveal - 服务端负责 `auth/device``/sync/`*,不再承担明文搜索与明文 reveal
主要表: 主要表:
@@ -129,23 +165,25 @@ cargo test --locked
字段职责: 字段职责:
| 位置 | 字段 | 说明 |
| --- | --- | --- | | 位置 | 字段 | 说明 |
| `vault_objects` | `object_id` | 同步对象标识 | | ------------------------ | ------------------------- | --------------------- |
| `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` | | `vault_objects` | `object_id` | 同步对象标识 |
| `vault_objects` | `revision` | 服务端对象版本 | | `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` |
| `vault_objects` | `ciphertext` | 密文对象载荷 | | `vault_objects` | `revision` | 服务端对象版本 |
| `vault_objects` | `content_hash` | 密文摘要 | | `vault_objects` | `ciphertext` | 密文对象载荷 |
| `vault_objects` | `deleted_at` | 对象级删除标记 | | `vault_objects` | `content_hash` | 密文摘要 |
| `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 | | `vault_objects` | `deleted_at` | 对象级删除标记 |
| `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 |
## 认证与事件 ## 认证与事件
当前登录流为 Google Desktop OAuth 当前登录流为 Google Desktop OAuth
- 桌面端使用系统浏览器拉起 Google 授权 - 桌面端使用系统浏览器拉起 Google 授权
- 使用本地 loopback callback + PKCE - API 服务端负责发起 OAuth、处理 callback、校验 Google userinfo
- API 校验 Google userinfo 后发放 `device token` - desktop 通过创建一次性 login session 并轮询状态获取 `device token`
- 登录与设备活动写入 `auth_events` - 登录与设备活动写入 `auth_events`
## 项目结构 ## 项目结构

View File

@@ -20,6 +20,9 @@ tracing-subscriber.workspace = true
uuid.workspace = true uuid.workspace = true
chrono.workspace = true chrono.workspace = true
reqwest.workspace = true reqwest.workspace = true
sha2.workspace = true
url.workspace = true
base64 = "0.22.1"
secrets-application = { path = "../../crates/application" } secrets-application = { path = "../../crates/application" }
secrets-device-auth = { path = "../../crates/device-auth" } secrets-device-auth = { path = "../../crates/device-auth" }

View File

@@ -1,23 +1,25 @@
use anyhow::{Context, Result as AnyResult}; use anyhow::{Context, Result as AnyResult};
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, State}, extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header}, http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect},
routing::{get, post}, routing::{get, post},
}; };
use chrono::{DateTime, Utc}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Duration, Utc};
use reqwest::Client; use reqwest::Client;
use secrets_application::sync::{fetch_object, sync_pull, sync_push}; use secrets_application::sync::{fetch_object, sync_pull, sync_push};
use secrets_device_auth::{ use secrets_device_auth::{hash_device_login_token, new_device_fingerprint, new_device_login_token};
hash_device_login_token, new_device_fingerprint, new_device_login_token,
};
use secrets_domain::{ use secrets_domain::{
SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, VaultObjectEnvelope, SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, VaultObjectEnvelope,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use sqlx::PgPool; use sha2::{Digest, Sha256};
use sqlx::{PgPool, Postgres, Transaction};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use url::Url;
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)] #[derive(Clone)]
@@ -32,20 +34,79 @@ struct DemoLoginResponse {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct DesktopGoogleLoginRequest { struct DesktopLoginStartRequest {
access_token: String,
device_name: String, device_name: String,
platform: String, platform: String,
client_version: String, client_version: String,
device_fingerprint: String, device_fingerprint: String,
} }
#[derive(Debug, Deserialize)]
struct DesktopLoginPollQuery {
session_id: String,
}
#[derive(Debug, Deserialize)]
struct GoogleStartQuery {
session_id: String,
}
#[derive(Debug, Deserialize)]
struct GoogleCallbackQuery {
state: Option<String>,
code: Option<String>,
error: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GoogleUserInfo { struct GoogleUserInfo {
sub: String,
email: String, email: String,
name: Option<String>, name: Option<String>,
} }
#[derive(Clone)]
struct GoogleOAuthConfig {
client_id: String,
client_secret: String,
auth_uri: String,
token_uri: String,
redirect_uri: String,
}
#[derive(Serialize)]
struct DesktopLoginStartResponse {
session_id: String,
auth_url: String,
expires_at: String,
}
#[derive(Serialize)]
struct DesktopLoginPollResponse {
status: String,
device_token: Option<String>,
error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GoogleTokenResponse {
access_token: String,
}
#[derive(Debug, sqlx::FromRow)]
struct DesktopLoginSessionRow {
session_id: String,
oauth_state: String,
pkce_verifier: String,
device_name: String,
platform: String,
client_version: String,
device_fingerprint: String,
status: String,
error_message: Option<String>,
expires_at: DateTime<Utc>,
}
#[derive(Serialize)] #[derive(Serialize)]
struct DeviceView { struct DeviceView {
name: String, name: String,
@@ -84,6 +145,13 @@ struct ObjectResponse {
object: VaultObjectEnvelope, object: VaultObjectEnvelope,
} }
const LOGIN_STATUS_PENDING: &str = "pending";
const LOGIN_STATUS_SUCCEEDED: &str = "succeeded";
const LOGIN_STATUS_FAILED: &str = "failed";
const LOGIN_STATUS_EXPIRED: &str = "expired";
const LOGIN_STATUS_CONSUMED: &str = "consumed";
const DESKTOP_LOGIN_SESSION_TTL_MINUTES: i64 = 10;
#[tokio::main] #[tokio::main]
async fn main() -> AnyResult<()> { async fn main() -> AnyResult<()> {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
@@ -104,7 +172,10 @@ async fn main() -> AnyResult<()> {
let app = Router::new() let app = Router::new()
.route("/healthz", get(|| async { "ok" })) .route("/healthz", get(|| async { "ok" }))
.route("/auth/demo-login", post(api_demo_login)) .route("/auth/demo-login", post(api_demo_login))
.route("/auth/google/desktop-login", post(api_google_desktop_login)) .route("/auth/desktop/start", post(api_desktop_login_start))
.route("/auth/desktop/poll", get(api_desktop_login_poll))
.route("/auth/google/start", get(api_google_login_start))
.route("/auth/google/callback", get(api_google_login_callback))
.route("/me", get(api_me)) .route("/me", get(api_me))
.route("/sync/pull", post(api_sync_pull)) .route("/sync/pull", post(api_sync_pull))
.route("/sync/push", post(api_sync_push)) .route("/sync/push", post(api_sync_push))
@@ -174,33 +245,130 @@ async fn api_demo_login(
Ok(Json(DemoLoginResponse { device_token })) Ok(Json(DemoLoginResponse { device_token }))
} }
async fn api_google_desktop_login( async fn api_desktop_login_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<DesktopGoogleLoginRequest>, Json(payload): Json<DesktopLoginStartRequest>,
) -> std::result::Result<Json<DemoLoginResponse>, (StatusCode, Json<serde_json::Value>)> { ) -> std::result::Result<Json<DesktopLoginStartResponse>, (StatusCode, Json<serde_json::Value>)> {
let google_user = state let session_id = new_session_secret();
.http let oauth_state = new_session_secret();
.get("https://openidconnect.googleapis.com/v1/userinfo") let pkce_verifier = new_session_secret();
.bearer_auth(&payload.access_token) let expires_at = Utc::now() + Duration::minutes(DESKTOP_LOGIN_SESSION_TTL_MINUTES);
.send() let auth_url = format!(
"{}/auth/google/start?session_id={}",
public_base_url().map_err(internal_error)?,
session_id
);
sqlx::query(
r#"
INSERT INTO desktop_login_sessions (
session_id, oauth_state, pkce_verifier, device_name, platform, client_version,
device_fingerprint, status, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
)
.bind(&session_id)
.bind(&oauth_state)
.bind(&pkce_verifier)
.bind(&payload.device_name)
.bind(&payload.platform)
.bind(&payload.client_version)
.bind(&payload.device_fingerprint)
.bind(LOGIN_STATUS_PENDING)
.bind(expires_at)
.execute(&state.pool)
.await
.map_err(internal_error)?;
Ok(Json(DesktopLoginStartResponse {
session_id,
auth_url,
expires_at: expires_at.to_rfc3339(),
}))
}
async fn api_google_login_start(
State(state): State<AppState>,
Query(query): Query<GoogleStartQuery>,
) -> std::result::Result<Redirect, (StatusCode, Json<serde_json::Value>)> {
let session = fetch_desktop_login_session(&state.pool, &query.session_id)
.await .await
.map_err(internal_error)? .map_err(internal_error)?
.error_for_status() .ok_or_else(|| unauthorized("desktop login session not found"))?;
ensure_login_session_pending(&session).map_err(unauthorized)?;
let google = google_oauth_config().map_err(internal_error)?;
let challenge = pkce_challenge(&session.pkce_verifier);
let mut auth_url = Url::parse(&google.auth_uri).map_err(internal_error)?;
auth_url
.query_pairs_mut()
.append_pair("client_id", &google.client_id)
.append_pair("redirect_uri", &google.redirect_uri)
.append_pair("response_type", "code")
.append_pair("scope", "openid email profile")
.append_pair("state", &session.oauth_state)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("access_type", "offline")
.append_pair("prompt", "consent");
Ok(Redirect::temporary(auth_url.as_str()))
}
async fn api_google_login_callback(
State(state): State<AppState>,
Query(query): Query<GoogleCallbackQuery>,
) -> std::result::Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let oauth_state = query
.state
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| unauthorized("missing oauth state"))?;
let mut tx = state.pool.begin().await.map_err(internal_error)?;
let session = fetch_desktop_login_session_by_state(&mut tx, oauth_state)
.await
.map_err(internal_error)? .map_err(internal_error)?
.json::<GoogleUserInfo>() .ok_or_else(|| unauthorized("desktop login session not found"))?;
if let Some(error) = query.error.as_deref().filter(|value| !value.is_empty()) {
mark_login_session_failed(&mut tx, &session.session_id, &format!("google oauth error: {error}"))
.await
.map_err(internal_error)?;
tx.commit().await.map_err(internal_error)?;
return Ok(Html(login_result_html(
"登录未完成",
"你已取消 Google 授权或授权未成功,可以返回 Secrets 重试。",
)));
}
ensure_login_session_pending(&session).map_err(unauthorized)?;
let code = query
.code
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| unauthorized("missing google auth code"))?;
let google = google_oauth_config().map_err(internal_error)?;
let google_token = exchange_google_auth_code(&state.http, &google, code, &session.pkce_verifier)
.await
.map_err(internal_error)?;
let google_user = fetch_google_userinfo(&state.http, &google_token.access_token)
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
let user_id = upsert_user_from_google(&state.pool, &google_user) let user_id = upsert_user_from_google(&state.pool, &google_user)
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
upsert_google_oauth_account(&state.pool, user_id, &google_user)
.await
.map_err(internal_error)?;
let device_id = upsert_device_for_login( let device_id = upsert_device_for_login(
&state.pool, &state.pool,
user_id, user_id,
&payload.device_name, &session.device_name,
&payload.platform, &session.platform,
&payload.client_version, &session.client_version,
&payload.device_fingerprint, &session.device_fingerprint,
) )
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
@@ -209,14 +377,115 @@ async fn api_google_desktop_login(
&state.pool, &state.pool,
user_id, user_id,
device_id, device_id,
&payload.device_name, &session.device_name,
&payload.platform, &session.platform,
&payload.client_version, &session.client_version,
) )
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
Ok(Json(DemoLoginResponse { device_token })) mark_login_session_succeeded(
&mut tx,
&session.session_id,
user_id,
device_id,
device_token.clone(),
hash_device_login_token(&device_token),
)
.await
.map_err(internal_error)?;
tx.commit().await.map_err(internal_error)?;
Ok(Html(login_result_html(
"登录成功",
"Google 授权已完成,可以返回 Secrets 桌面端继续。",
)))
}
async fn api_desktop_login_poll(
State(state): State<AppState>,
Query(query): Query<DesktopLoginPollQuery>,
) -> std::result::Result<Json<DesktopLoginPollResponse>, (StatusCode, Json<serde_json::Value>)> {
let mut tx = state.pool.begin().await.map_err(internal_error)?;
let session = fetch_desktop_login_session_for_update(&mut tx, &query.session_id)
.await
.map_err(internal_error)?
.ok_or_else(|| unauthorized("desktop login session not found"))?;
let now = Utc::now();
if session.expires_at < now && session.status == LOGIN_STATUS_PENDING {
mark_login_session_expired(&mut tx, &session.session_id)
.await
.map_err(internal_error)?;
tx.commit().await.map_err(internal_error)?;
return Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_EXPIRED.to_string(),
device_token: None,
error: Some("login session expired".to_string()),
}));
}
match session.status.as_str() {
LOGIN_STATUS_PENDING => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_PENDING.to_string(),
device_token: None,
error: None,
}))
}
LOGIN_STATUS_FAILED => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_FAILED.to_string(),
device_token: None,
error: session.error_message,
}))
}
LOGIN_STATUS_EXPIRED => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_EXPIRED.to_string(),
device_token: None,
error: session.error_message.or(Some("login session expired".to_string())),
}))
}
LOGIN_STATUS_CONSUMED => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_CONSUMED.to_string(),
device_token: None,
error: Some("login session already consumed".to_string()),
}))
}
LOGIN_STATUS_SUCCEEDED => {
let device_token = consume_device_token_for_poll(&mut tx, &session.session_id)
.await
.map_err(internal_error)?;
sqlx::query(
"UPDATE desktop_login_sessions SET status = $2, consumed_at = NOW(), updated_at = NOW() WHERE session_id = $1",
)
.bind(&session.session_id)
.bind(LOGIN_STATUS_CONSUMED)
.execute(&mut *tx)
.await
.map_err(internal_error)?;
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_SUCCEEDED.to_string(),
device_token: Some(device_token),
error: None,
}))
}
_ => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_FAILED.to_string(),
device_token: None,
error: Some("invalid login session status".to_string()),
}))
}
}
} }
async fn api_sync_pull( async fn api_sync_pull(
@@ -313,6 +582,215 @@ async fn api_me(
})) }))
} }
fn public_base_url() -> AnyResult<String> {
std::env::var("SECRETS_PUBLIC_BASE_URL")
.or_else(|_| std::env::var("SECRETS_API_BASE"))
.context("SECRETS_PUBLIC_BASE_URL or SECRETS_API_BASE must be set")
}
fn google_oauth_config() -> AnyResult<GoogleOAuthConfig> {
Ok(GoogleOAuthConfig {
client_id: std::env::var("GOOGLE_OAUTH_CLIENT_ID")
.context("GOOGLE_OAUTH_CLIENT_ID is not set")?,
client_secret: std::env::var("GOOGLE_OAUTH_CLIENT_SECRET")
.context("GOOGLE_OAUTH_CLIENT_SECRET is not set")?,
auth_uri: std::env::var("GOOGLE_OAUTH_AUTH_URI")
.unwrap_or_else(|_| "https://accounts.google.com/o/oauth2/v2/auth".to_string()),
token_uri: std::env::var("GOOGLE_OAUTH_TOKEN_URI")
.unwrap_or_else(|_| "https://oauth2.googleapis.com/token".to_string()),
redirect_uri: std::env::var("GOOGLE_OAUTH_REDIRECT_URI")
.context("GOOGLE_OAUTH_REDIRECT_URI is not set")?,
})
}
fn new_session_secret() -> String {
new_device_login_token()
}
fn pkce_challenge(verifier: &str) -> String {
let digest = Sha256::digest(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
fn login_result_html(title: &str, message: &str) -> String {
format!(
"<html><body><h3>{}</h3><p>{}</p><p>现在可以返回 Secrets 桌面端。</p></body></html>",
title, message
)
}
fn ensure_login_session_pending(session: &DesktopLoginSessionRow) -> Result<(), &'static str> {
if session.expires_at < Utc::now() {
return Err("desktop login session expired");
}
if session.status != LOGIN_STATUS_PENDING {
return Err("desktop login session is no longer pending");
}
Ok(())
}
async fn fetch_desktop_login_session(
pool: &PgPool,
session_id: &str,
) -> AnyResult<Option<DesktopLoginSessionRow>> {
sqlx::query_as::<_, DesktopLoginSessionRow>(
r#"
SELECT
session_id, oauth_state, pkce_verifier, device_name, platform, client_version,
device_fingerprint, status, error_message, user_id, device_id, device_token,
device_token_hash, expires_at
FROM desktop_login_sessions
WHERE session_id = $1
"#,
)
.bind(session_id)
.fetch_optional(pool)
.await
.context("failed to load desktop login session")
}
async fn fetch_desktop_login_session_for_update(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
) -> AnyResult<Option<DesktopLoginSessionRow>> {
sqlx::query_as::<_, DesktopLoginSessionRow>(
r#"
SELECT
session_id, oauth_state, pkce_verifier, device_name, platform, client_version,
device_fingerprint, status, error_message, user_id, device_id, device_token,
device_token_hash, expires_at
FROM desktop_login_sessions
WHERE session_id = $1
FOR UPDATE
"#,
)
.bind(session_id)
.fetch_optional(&mut **tx)
.await
.context("failed to lock desktop login session")
}
async fn fetch_desktop_login_session_by_state(
tx: &mut Transaction<'_, Postgres>,
oauth_state: &str,
) -> AnyResult<Option<DesktopLoginSessionRow>> {
sqlx::query_as::<_, DesktopLoginSessionRow>(
r#"
SELECT
session_id, oauth_state, pkce_verifier, device_name, platform, client_version,
device_fingerprint, status, error_message, user_id, device_id, device_token,
device_token_hash, expires_at
FROM desktop_login_sessions
WHERE oauth_state = $1
FOR UPDATE
"#,
)
.bind(oauth_state)
.fetch_optional(&mut **tx)
.await
.context("failed to load desktop login session by oauth state")
}
async fn mark_login_session_failed(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
message: &str,
) -> AnyResult<()> {
sqlx::query(
"UPDATE desktop_login_sessions SET status = $2, error_message = $3, updated_at = NOW() WHERE session_id = $1",
)
.bind(session_id)
.bind(LOGIN_STATUS_FAILED)
.bind(message)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn mark_login_session_expired(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
) -> AnyResult<()> {
sqlx::query(
"UPDATE desktop_login_sessions SET status = $2, error_message = $3, updated_at = NOW() WHERE session_id = $1",
)
.bind(session_id)
.bind(LOGIN_STATUS_EXPIRED)
.bind("login session expired")
.execute(&mut **tx)
.await?;
Ok(())
}
async fn mark_login_session_succeeded(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
user_id: Uuid,
device_id: Uuid,
device_token: String,
device_token_hash: String,
) -> AnyResult<()> {
sqlx::query(
r#"
UPDATE desktop_login_sessions
SET status = $2,
user_id = $3,
device_id = $4,
device_token = $5,
device_token_hash = $6,
updated_at = NOW()
WHERE session_id = $1
"#,
)
.bind(session_id)
.bind(LOGIN_STATUS_SUCCEEDED)
.bind(user_id)
.bind(device_id)
.bind(device_token)
.bind(device_token_hash)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn exchange_google_auth_code(
http: &Client,
google: &GoogleOAuthConfig,
code: &str,
code_verifier: &str,
) -> AnyResult<GoogleTokenResponse> {
http.post(&google.token_uri)
.form(&[
("client_id", google.client_id.clone()),
("client_secret", google.client_secret.clone()),
("code", code.to_string()),
("code_verifier", code_verifier.to_string()),
("grant_type", "authorization_code".to_string()),
("redirect_uri", google.redirect_uri.clone()),
])
.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")
}
async fn fetch_google_userinfo(http: &Client, access_token: &str) -> AnyResult<GoogleUserInfo> {
http.get("https://openidconnect.googleapis.com/v1/userinfo")
.bearer_auth(access_token)
.send()
.await
.context("failed to request google userinfo")?
.error_for_status()
.context("google userinfo request failed")?
.json::<GoogleUserInfo>()
.await
.context("failed to decode google userinfo")
}
async fn require_auth( async fn require_auth(
pool: &PgPool, pool: &PgPool,
headers: &HeaderMap, headers: &HeaderMap,
@@ -465,6 +943,37 @@ async fn upsert_user_from_google(pool: &PgPool, google_user: &GoogleUserInfo) ->
.context("failed to create user from google login") .context("failed to create user from google login")
} }
async fn upsert_google_oauth_account(
pool: &PgPool,
user_id: Uuid,
google_user: &GoogleUserInfo,
) -> AnyResult<()> {
sqlx::query(
r#"
INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name)
VALUES ($1, 'google', $2, $3, $4)
ON CONFLICT (provider, provider_id)
DO UPDATE SET
user_id = EXCLUDED.user_id,
email = EXCLUDED.email,
name = EXCLUDED.name
"#,
)
.bind(user_id)
.bind(&google_user.sub)
.bind(&google_user.email)
.bind(
google_user
.name
.clone()
.unwrap_or_else(|| google_user.email.clone()),
)
.execute(pool)
.await
.context("failed to upsert google oauth account")?;
Ok(())
}
async fn upsert_device_for_login( async fn upsert_device_for_login(
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,
@@ -556,6 +1065,28 @@ async fn issue_device_login_token(
Ok(device_token) Ok(device_token)
} }
async fn consume_device_token_for_poll(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
) -> AnyResult<String> {
let token = sqlx::query_scalar::<_, Option<String>>(
"SELECT device_token FROM desktop_login_sessions WHERE session_id = $1 FOR UPDATE",
)
.bind(session_id)
.fetch_one(&mut **tx)
.await?
.context("device token already consumed")?;
sqlx::query(
"UPDATE desktop_login_sessions SET device_token = NULL, updated_at = NOW() WHERE session_id = $1",
)
.bind(session_id)
.execute(&mut **tx)
.await?;
Ok(token)
}
fn internal_error<E: std::fmt::Display>(error: E) -> (StatusCode, Json<serde_json::Value>) { fn internal_error<E: std::fmt::Display>(error: E) -> (StatusCode, Json<serde_json::Value>) {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,

View File

@@ -31,7 +31,7 @@
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" /> <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg> </svg>
<span>使用 Google 登录</span> <span>前往浏览器登录</span>
</button> </button>
</div> </div>
<p id="login-error" class="error-text hidden"></p> <p id="login-error" class="error-text hidden"></p>

View File

@@ -510,6 +510,7 @@ async function doDemoLogin() {
if (!invoke) return; if (!invoke) return;
setLoginError(""); setLoginError("");
loginButton.disabled = true; loginButton.disabled = true;
loginButton.textContent = "正在打开浏览器...";
try { try {
let data = await invoke("continue_demo_login"); let data = await invoke("continue_demo_login");
data = await ensureUnlockedShell(data); data = await ensureUnlockedShell(data);
@@ -518,6 +519,7 @@ async function doDemoLogin() {
} catch (error) { } catch (error) {
setLoginError(String(error)); setLoginError(String(error));
} finally { } finally {
loginButton.textContent = "前往浏览器登录";
loginButton.disabled = false; loginButton.disabled = false;
} }
} }

View File

@@ -2,7 +2,6 @@ mod local_vault;
mod session_api; mod session_api;
use anyhow::{Context, Result as AnyResult, anyhow}; use anyhow::{Context, Result as AnyResult, anyhow};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use local_vault::{ use local_vault::{
LocalDetailField, LocalEntryDetail, LocalEntryDraft, LocalEntryQuery, LocalHistoryItem, LocalDetailField, LocalEntryDetail, LocalEntryDraft, LocalEntryQuery, LocalHistoryItem,
LocalSecretDraft, LocalSecretUpdateDraft, LocalSecretValue, LocalVault, LocalSecretDraft, LocalSecretUpdateDraft, LocalSecretValue, LocalVault,
@@ -24,7 +23,6 @@ use secrets_device_auth::new_device_fingerprint;
use secrets_domain::KdfConfig; use secrets_domain::KdfConfig;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_api::start_desktop_session_server; use session_api::start_desktop_session_server;
use sha2::{Digest, Sha256};
use std::{ use std::{
fs, fs,
path::PathBuf, path::PathBuf,
@@ -32,11 +30,7 @@ use std::{
sync::{Arc, RwLock}, sync::{Arc, RwLock},
}; };
use tauri::Manager; use tauri::Manager;
use tokio::{ use tokio::time::{Duration, sleep};
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
use url::Url;
#[derive(Clone)] #[derive(Clone)]
struct DesktopState { struct DesktopState {
@@ -200,33 +194,28 @@ struct DemoLoginResponse {
device_token: String, 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)] #[derive(Serialize)]
struct GoogleDesktopLoginRequest { struct DesktopLoginStartRequest {
access_token: String,
device_name: String, device_name: String,
platform: String, platform: String,
client_version: String, client_version: String,
device_fingerprint: 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)] #[derive(Serialize, Deserialize)]
struct EntryListQuery { struct EntryListQuery {
folder: Option<String>, folder: Option<String>,
@@ -320,8 +309,7 @@ async fn continue_demo_login(
window: tauri::Window, window: tauri::Window,
state: tauri::State<'_, DesktopState>, state: tauri::State<'_, DesktopState>,
) -> Result<AppBootstrap, String> { ) -> Result<AppBootstrap, String> {
let google_client = load_google_desktop_client().map_err(|err| err.to_string())?; let payload = complete_hosted_google_desktop_login(&state)
let payload = complete_google_desktop_login(&state, &google_client)
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
@@ -599,21 +587,9 @@ async fn bootstrap_from_disk(state: &DesktopState) -> AnyResult<AppBootstrap> {
} }
if !vault_state.unlocked { if !vault_state.unlocked {
let user = authorized_get(state, "/me")
.await?
.json::<UserProfile>()
.await
.context("failed to decode current user")?;
return Ok(AppBootstrap { return Ok(AppBootstrap {
logged_in: true, logged_in: true,
shell: Some(ShellData { shell: None,
user,
folders: Vec::new(),
entry_types: Vec::new(),
entries: Vec::new(),
selected_entry_id: None,
selected_entry: None,
}),
vault: Some(VaultStatus { vault: Some(VaultStatus {
unlocked: false, unlocked: false,
has_master_password: vault_state.has_master_password, has_master_password: vault_state.has_master_password,
@@ -929,118 +905,11 @@ fn apply_mcp_config_to_all() -> AnyResult<()> {
Ok(()) Ok(())
} }
fn workspace_root() -> PathBuf { async fn complete_hosted_google_desktop_login(state: &DesktopState) -> AnyResult<DemoLoginResponse> {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") let start = state
}
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
.client .client
.post(&google_client.token_uri) .post(format!("{}/auth/desktop/start", state.api_base))
.form(&form) .json(&DesktopLoginStartRequest {
.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,
device_name: current_device_name(), device_name: current_device_name(),
platform: std::env::consts::OS.to_string(), platform: std::env::consts::OS.to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(),
@@ -1048,56 +917,50 @@ async fn complete_google_desktop_login(
}) })
.send() .send()
.await .await
.context("failed to call google desktop login API")?; .context("failed to create desktop login session")?
response
.error_for_status() .error_for_status()
.context("google desktop login failed")? .context("desktop login session creation failed")?
.json::<DemoLoginResponse>() .json::<DesktopLoginStartResponse>()
.await .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 _ = &start.expires_at;
let (mut socket, _) = listener open_system_browser(&start.auth_url)?;
.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 body = "<html><body><h3>登录成功,可以返回 Secrets。</h3></body></html>"; for _ in 0..120 {
let response = format!( sleep(Duration::from_secs(1)).await;
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", let poll = state
body.len(), .client
body .get(format!("{}/auth/desktop/poll", state.api_base))
); .query(&[("session_id", start.session_id.as_str())])
socket .send()
.write_all(response.as_bytes()) .await
.await .context("failed to poll desktop login session")?
.context("failed to respond to oauth callback")?; .error_for_status()
Ok(code) .context("desktop login poll failed")?
} .json::<DesktopLoginPollResponse>()
.await
.context("failed to decode desktop login poll response")?;
fn pkce_challenge(verifier: &str) -> String { match poll.status.as_str() {
let digest = Sha256::digest(verifier.as_bytes()); "pending" => continue,
URL_SAFE_NO_PAD.encode(digest) "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<()> { fn open_system_browser(url: &str) -> AnyResult<()> {

View File

@@ -69,6 +69,28 @@ pub async fn migrate_current_schema(pool: &PgPool) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_auth_events_device_id_created_at CREATE INDEX IF NOT EXISTS idx_auth_events_device_id_created_at
ON auth_events(device_id, created_at DESC); ON auth_events(device_id, created_at DESC);
CREATE TABLE IF NOT EXISTS desktop_login_sessions (
session_id TEXT PRIMARY KEY,
oauth_state TEXT NOT NULL UNIQUE,
pkce_verifier TEXT NOT NULL,
device_name VARCHAR(256) NOT NULL,
platform VARCHAR(64) NOT NULL,
client_version VARCHAR(64) NOT NULL,
device_fingerprint TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
error_message TEXT,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
device_token TEXT,
device_token_hash TEXT,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_desktop_login_sessions_status_expires
ON desktop_login_sessions(status, expires_at);
CREATE TABLE IF NOT EXISTS vault_objects ( CREATE TABLE IF NOT EXISTS vault_objects (
object_id UUID PRIMARY KEY, object_id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

View File

@@ -17,10 +17,15 @@ SECRETS_DAEMON_BIND=127.0.0.1:9515
SECRETS_API_BASE=http://127.0.0.1:9415 SECRETS_API_BASE=http://127.0.0.1:9415
SECRETS_DAEMON_URL=http://127.0.0.1:9515/mcp SECRETS_DAEMON_URL=http://127.0.0.1:9515/mcp
# ─── Google OAuth ───────────────────────────────────────────────────── # ─── Google OAuth(服务端托管)──────────────────────────────────────────
# 桌面端优先从这个 installed client JSON 读取 Desktop OAuth 配置 # 官网 DMG 正式分发时Google OAuth 凭据只配置在 API 服务端
# 推荐填写绝对路径;若使用相对路径,则以仓库根目录为基准解析 SECRETS_PUBLIC_BASE_URL=http://127.0.0.1:9415
GOOGLE_OAUTH_CLIENT_FILE=/absolute/path/to/client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:9415/auth/google/callback
# 可选:如不配置则使用 Google 默认公开端点
# GOOGLE_OAUTH_AUTH_URI=https://accounts.google.com/o/oauth2/v2/auth
# GOOGLE_OAUTH_TOKEN_URI=https://oauth2.googleapis.com/token
# 若仍无法换 token仅提供端口代理、无系统代理可取消注释并改为本机代理地址 # 若仍无法换 token仅提供端口代理、无系统代理可取消注释并改为本机代理地址
# HTTPS_PROXY=http://127.0.0.1:7890 # HTTPS_PROXY=http://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1 # NO_PROXY=localhost,127.0.0.1