diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6538ff4..ebdbaee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: HFY_PLAYWRIGHT_MODULE="$RUNNER_TEMP/hfy-browser/node_modules/playwright/index.mjs" node tests/cli-polish-browser.mjs HFY_PLAYWRIGHT_MODULE="$RUNNER_TEMP/hfy-browser/node_modules/playwright/index.mjs" node tests/admin-browser.mjs HFY_PLAYWRIGHT_MODULE="$RUNNER_TEMP/hfy-browser/node_modules/playwright/index.mjs" node tests/feedback-language.browser.mjs + HFY_PLAYWRIGHT_MODULE="$RUNNER_TEMP/hfy-browser/node_modules/playwright/index.mjs" node tests/app-login.browser.mjs - name: Build Linux release run: cargo build --locked --release --workspace diff --git a/docs/native-login.md b/docs/native-login.md new file mode 100644 index 0000000..e557642 --- /dev/null +++ b/docs/native-login.md @@ -0,0 +1,54 @@ +# 应用与系统浏览器之间的登录返回 + +移动客户端使用系统浏览器完成 Google 登录,业务页面继续运行在应用 WebView。两者的 Cookie 存储彼此独立;不能通过在浏览器里登录就假定应用也已登录。本功能通过一次性的、明确确认的请求为原 WebView 创建自己的会话。 + +## 用户流程 + +1. 在应用中点击 Google 登录,原生导航策略将当前 WebView 带到同一站点的 `/auth/app`。 +2. 过渡页请求一个有效期 **5 分钟**的连接,显示核对编号。服务端把随机校验凭据放在该 WebView 的 HttpOnly Cookie 中,页面脚本无法读取它。 +3. 点击“打开浏览器确认”。系统浏览器打开 `/auth/app/authorize?request=…`,链接仅包含随机请求编号。用户可以使用已有浏览器会话,也可以在该页通过 Google 或邮箱验证码登录。 +4. 浏览器显示账户、站点、相同核对编号和应用将获得的账户权限。用户勾选确认并点击“确认登录应用”之后,服务端才批准连接。 +5. 点击固定的 `hireferry://login-return` 唤回应用,或手动切回应用。该链接不含 code、token、服务器地址或跳转目标,不导入任何身份;原 WebView 通过自己的 HttpOnly 凭据领取正常的 `hf_session` Cookie,然后进入 `/app`。 + +应用挂起时轮询暂停;恢复可见或收到原生固定返回事件时立即重新检查。常态轮询间隔为 3 秒,每个网络请求有 15 秒截止时间。冷启动只回到本地连接页,用户需要重新发起登录;没有尝试用外部 URI 恢复身份。 + +Google 的浏览器授权仍走现有服务端 PKCE 和挑战 Cookie。新增的 `app_request` 只允许引用服务器中尚未结束的请求;Google 成功或取消后只返回固定的应用确认页,没有开放任意 `redirect_uri`。 + +## 权限与生命周期 + +- 目前仅支持普通用户账户。总管理员、引导管理员、活动团队员工和经理不能通过此流程转移会话,必须直接在应用中登录。员工和经理按数据库中的实际成员身份检查,不只检查会话上的 `admin` 标志。 +- 浏览器确认需要有效 Cookie 会话、准确的请求 Origin 和 CSRF token;Bearer/API/CLI 凭据不能批准连接。没有自动同意步骤。 +- 服务端只保存校验凭据的哈希。公开请求编号和核对编号不具有兑换能力;另一个 WebView 的 Cookie 也不能使用它。 +- 批准和领取均在事务中重新检查账户与浏览器会话。注销、封禁、过期或在批准后成为员工/经理都会阻止领取。 +- 领取只成功一次,并发请求也只产生一个会话。新会话继承源会话的认证时间,有效期不晚于源会话,不把一次确认当成重新认证。 +- 应用和浏览器会话创建后彼此独立,日常退出只结束当前会话。原有账户封禁和会话管理规则继续生效。 +- 取消可与领取并发执行。服务端保留本次领取所签发会话的哈希;即使兑换已提交、响应还没送达,取消也只撤销这一份新应用会话,保留浏览器原会话。前端收到取消确认前不会显示取消成功;取消失败时停止继续轮询,允许重试。 +- 页面、状态和授权响应均为 `Cache-Control: no-store`,并保留网站现有的 `no-referrer`、Origin 和 CSP 策略。请求编号不写进审计描述;会话和校验凭据不会出现在 JSON、URL、localStorage 或原生调用参数中。 + +## 路由契约 + +| 路由 | 调用方与行为 | +| --- | --- | +| `GET /auth/app` | WebView 登录过渡页 | +| `GET /auth/app/authorize?request=…` | 系统浏览器确认页;请求必须仍然有效 | +| `POST /api/auth/app/start` | 同源 WebView 创建或恢复当前连接;校验凭据只通过 HttpOnly Cookie 发送 | +| `GET /api/auth/app/context?request=…` | 返回当前浏览器自己的可确认身份、站点和核对编号 | +| `POST /api/auth/app/decide` | `{request, approve}`,要求浏览器 Cookie、Origin 与 CSRF | +| `POST /api/auth/app/redeem` | `{request}`,要求同源及发起 WebView 的校验 Cookie;返回 pending / denied / complete | +| `POST /api/auth/app/cancel` | `{request}`,凭发起 Cookie 或本次新签发的会话撤销连接 | + +成功领取仅返回 `{"status":"complete","redirect":"/app"}`,会话通过 `Set-Cookie` 发送。请求创建上限为全站每 5 分钟 100 个;恢复同一未过期请求不占用新名额。新增表使用幂等创建,保持既有数据库版本与升级路径。 + +## 部署与验收 + +服务端与移动端应一起验收。旧服务端不具备 `/auth/app` 路由,不能宣称已经支持 Google 登录返回;邮箱验证码仍可直接在 WebView 使用。Google 本身仍要求维护者已配置真实 Google OAuth 客户端和允许的 HTTPS 回调,本文没有提供或生成凭据。 + +可自动验证的部分使用临时 SQLite、测试会话和两个独立 Chromium Cookie 上下文: + +```sh +cargo test --locked --test app_login --test auth_flows --test cli_auth +cargo build --locked --bin hireferry +HFY_PLAYWRIGHT_MODULE=playwright node tests/app-login.browser.mjs +``` + +Rust 回归覆盖不匹配 Origin、缺少/错误校验 Cookie、CSRF、过期、拒绝、撤销、并发领取、取消竞态、实际员工身份、审批后身份变更和认证时间继承。浏览器回归覆盖独立 Cookie 存储之间的真实 HTTP 流程、显式确认、邮箱验证码、取消与迟到响应、中英文和窄屏布局。Google 外部账号与真实设备登录仍需要对应开发者配置和实际设备验收;本测试不会调用生产邮箱或登录生产账户。 diff --git a/src/app_login.rs b/src/app_login.rs new file mode 100644 index 0000000..e433632 --- /dev/null +++ b/src/app_login.rs @@ -0,0 +1,390 @@ +//! Browser-to-WebView sign-in. The public request ID is never a credential. +//! The initiating cookie proves possession; explicit browser consent binds a +//! live member session, and redemption consumes the grant in one transaction. +use super::{ + cookie, csrf_check, hash_token, new_session, now, origin_check, random_token, rate_limit, + require_session, +}; +use crate::{AppError, AppResult, AppState}; +use axum::{ + Json, Router, + extract::{Query, State}, + http::{HeaderMap, HeaderValue, header}, + middleware, + response::{Html, IntoResponse, Response}, + routing::{get, post}, +}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; +use serde::Deserialize; +use serde_json::{Value, json}; + +const COOKIE: &str = "hf_app_login"; +const TTL: i64 = 300; + +pub(super) fn routes() -> Router { + Router::new() + .route("/auth/app", get(page)) + .route("/auth/app/authorize", get(authorize_page)) + .route("/api/auth/app/start", post(start)) + .route("/api/auth/app/context", get(context)) + .route("/api/auth/app/decide", post(decide)) + .route("/api/auth/app/redeem", post(redeem)) + .route("/api/auth/app/cancel", post(cancel)) + .layer(middleware::map_response(no_store)) +} + +async fn no_store(mut response: Response) -> Response { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response + .headers_mut() + .insert(header::PRAGMA, HeaderValue::from_static("no-cache")); + response +} + +pub(super) fn migrate(db: &Connection) -> AppResult<()> { + db.execute_batch("SAVEPOINT app_login_v1;")?; + let result = db.execute_batch( + "CREATE TABLE IF NOT EXISTS app_login_grants ( + request_id TEXT PRIMARY KEY, + verifier_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL CHECK(status IN ('pending','approved','denied','consumed')), + source_session_hash TEXT, + issued_session_hash TEXT, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS app_login_expiry ON app_login_grants(expires_at);", + ); + match result { + Ok(()) => { + db.execute_batch("RELEASE app_login_v1;")?; + Ok(()) + } + Err(error) => { + let _ = db.execute_batch("ROLLBACK TO app_login_v1; RELEASE app_login_v1;"); + Err(error.into()) + } + } +} + +fn valid_request(request: &str) -> AppResult<()> { + if request.len() == 43 + && request + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + Ok(()) + } else { + Err(AppError::bad_request("Invalid app sign-in request")) + } +} + +pub(super) fn pending_request(db: &Connection, request: &str) -> AppResult<()> { + valid_request(request)?; + let found: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM app_login_grants WHERE request_id=?1 AND status='pending' AND expires_at>?2)", params![request,now()], |row| row.get(0))?; + if found { + Ok(()) + } else { + Err(AppError::bad_request( + "App sign-in expired or was already completed. Start again in the app.", + )) + } +} + +pub(super) fn authorize_path(request: &str) -> String { + // Only server-generated 43-character request IDs reach this function. + format!("/auth/app/authorize?request={request}") +} + +fn comparison_code(request: &str) -> String { + let digest = hash_token(request).to_ascii_uppercase(); + format!("{}-{}", &digest[..4], &digest[4..8]) +} + +fn app_cookie( + mut response: Response, + state: &AppState, + value: &str, + age: i64, +) -> AppResult { + let secure = if state.cfg.public_url.starts_with("https://") { + "; Secure" + } else { + "" + }; + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_str(&format!( + "{COOKIE}={value}; Path=/api/auth/app; HttpOnly; SameSite=Strict; Max-Age={age}{secure}" + )) + .map_err(AppError::internal)?, + ); + Ok(response) +} + +fn browser_only(headers: &HeaderMap) -> AppResult<()> { + if headers.contains_key(header::AUTHORIZATION) { + return Err(AppError::forbidden( + "Use an interactive browser session for app sign-in", + )); + } + Ok(()) +} + +fn ordinary_member(db: &Connection, uid: &str) -> AppResult { + Ok(crate::team::human_member(db, uid)?.is_none() + && crate::staff::active_role(db, uid)?.is_none()) +} + +async fn page() -> Html<&'static str> { + Html(include_str!("../static/app-login.html")) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RequestId { + request: String, +} + +async fn authorize_page( + State(state): State, + Query(input): Query, +) -> AppResult> { + { + let db = state.conn()?; + pending_request(&db, &input.request)?; + } + Ok(page().await) +} + +async fn start(State(state): State, headers: HeaderMap) -> AppResult { + browser_only(&headers)?; + origin_check(&state, &headers)?; + let old = cookie(&headers, COOKIE); + let mut db = state.conn()?; + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("DELETE FROM app_login_grants WHERE expires_at<=?1", [now()])?; + let existing: Option<(String, i64)> = if let Some(value) = &old { + tx.query_row("SELECT request_id,expires_at FROM app_login_grants WHERE verifier_hash=?1 AND status IN ('pending','approved') AND expires_at>?2", params![hash_token(value),now()], |r| Ok((r.get(0)?,r.get(1)?))).optional()? + } else { + None + }; + let (request, verifier, expires) = if let Some((request, expires)) = existing { + (request, old.unwrap(), expires) + } else { + rate_limit(&tx, "app-login-start", 100, TTL)?; + let request = random_token(); + let verifier = random_token(); + let expires = now() + TTL; + tx.execute("INSERT INTO app_login_grants(request_id,verifier_hash,status,created_at,expires_at) VALUES(?1,?2,'pending',?3,?4)", params![request,hash_token(&verifier),now(),expires])?; + (request, verifier, expires) + }; + tx.commit()?; + let url = format!("{}{}", state.cfg.public_url, authorize_path(&request)); + app_cookie(Json(json!({"request":request,"verification_uri":url,"comparison_code":comparison_code(&request),"expires_at":expires,"interval":3})).into_response(), &state, &verifier, expires-now()) +} + +async fn context( + State(state): State, + headers: HeaderMap, + Query(input): Query, +) -> AppResult> { + browser_only(&headers)?; + let session = super::session(&state, &headers)?; + let db = state.conn()?; + pending_request(&db, &input.request)?; + let operator = if let Some(s) = &session { + s.admin + || s.bootstrap + || match s.user_id.as_deref() { + Some(uid) => !ordinary_member(&db, uid)?, + None => true, + } + } else { + false + }; + let user = match &session { + Some(s) if !operator && s.user_id.is_some() => { + let id = s.user_id.as_ref().unwrap(); + let (email, name): (String, String) = db.query_row( + "SELECT email,name FROM users WHERE id=?1 AND banned=0", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + json!({"email":email,"name":name}) + } + _ => Value::Null, + }; + Ok(Json( + json!({"request":input.request,"comparison_code":comparison_code(&input.request),"site":state.cfg.public_url,"user":user,"can_approve":!user.is_null(),"admin_session":operator,"csrf":session.map(|s| s.csrf),"google_enabled":state.cfg.google.enabled,"email_enabled":state.cfg.smtp.enabled || state.cfg.development}), + )) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Decision { + request: String, + approve: bool, +} + +async fn decide( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> AppResult> { + browser_only(&headers)?; + let session = require_session(&state, &headers)?; + csrf_check(&state, &headers, &session)?; + let uid = session.user_id.as_deref().filter(|_| !session.admin && !session.bootstrap) + .ok_or_else(|| AppError::forbidden("App sign-in requires a member account. Administrator sessions cannot be transferred."))?; + let mut db = state.conn()?; + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + pending_request(&tx, &input.request)?; + if !ordinary_member(&tx, uid)? { + return Err(AppError::forbidden( + "Staff and managers must sign in directly in the app", + )); + } + let live: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=?1 AND s.user_id=?2 AND s.expires_at>?3 AND s.admin=0 AND s.bootstrap=0 AND u.banned=0)", params![session.token_hash,uid,now()], |r| r.get(0))?; + if !live { + return Err(AppError::unauthorized( + "Sign in again before approving the app", + )); + } + tx.execute("UPDATE app_login_grants SET status=?2,source_session_hash=?3,user_id=?4 WHERE request_id=?1 AND status='pending'", params![input.request,if input.approve {"approved"} else {"denied"},session.token_hash,uid])?; + crate::db::audit( + &tx, + uid, + if input.approve { + "app_login_approved" + } else { + "app_login_denied" + }, + "native_app", + "", + )?; + tx.commit()?; + Ok(Json(json!({"approved":input.approve}))) +} + +async fn redeem( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> AppResult { + browser_only(&headers)?; + origin_check(&state, &headers)?; + valid_request(&input.request)?; + let verifier = cookie(&headers, COOKIE) + .ok_or_else(|| AppError::unauthorized("Start sign-in in this app again"))?; + let mut db = state.conn()?; + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let grant: Option<(String, Option, Option)> = tx.query_row("SELECT status,source_session_hash,user_id FROM app_login_grants WHERE request_id=?1 AND verifier_hash=?2 AND expires_at>?3", params![input.request,hash_token(&verifier),now()], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).optional()?; + let (status, source, uid) = grant.ok_or_else(|| { + AppError::unauthorized("App sign-in expired or does not belong to this app") + })?; + if status == "pending" { + return Ok(Json(json!({"status":"pending","interval":3})).into_response()); + } + if status == "denied" { + return app_cookie( + Json(json!({"status":"denied"})).into_response(), + &state, + "", + 0, + ); + } + if status != "approved" { + return Err(AppError::conflict("This app sign-in was already used")); + } + if !ordinary_member(&tx, uid.as_deref().unwrap_or(""))? { + return Err(AppError::forbidden( + "Staff and managers must sign in directly in the app", + )); + } + let source_times: Option<(i64,i64)> = tx.query_row("SELECT s.expires_at,s.recent_auth_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=?1 AND s.user_id=?2 AND s.expires_at>?3 AND s.admin=0 AND s.bootstrap=0 AND u.banned=0", params![source,uid,now()], |r| Ok((r.get(0)?,r.get(1)?))).optional()?; + let (source_expires, source_recent) = source_times.ok_or_else(|| { + AppError::unauthorized("The approving browser session was revoked. Start again.") + })?; + let (raw, _) = new_session(&tx, &state.cfg, uid.as_deref(), false, false)?; + // Consent is not a fresh authentication event and cannot extend the + // lifetime of the source session by repeatedly hopping between clients. + tx.execute( + "UPDATE sessions SET expires_at=MIN(expires_at,?2),recent_auth_at=?3 WHERE token_hash=?1", + params![hash_token(&raw), source_expires, source_recent], + )?; + let expires: i64 = tx.query_row( + "SELECT expires_at FROM sessions WHERE token_hash=?1", + [hash_token(&raw)], + |r| r.get(0), + )?; + // Replace only a session already owned by the initiating WebView. The + // independent approving browser remains signed in and can still log out. + if let Some(old) = cookie(&headers, super::SESSION_COOKIE) { + tx.execute( + "DELETE FROM sessions WHERE token_hash=?1 AND token_hash<>?2", + params![hash_token(&old), source], + )?; + } + tx.execute( + "UPDATE app_login_grants SET status='consumed',issued_session_hash=?2 WHERE request_id=?1 AND status='approved'", + params![input.request,hash_token(&raw)], + )?; + crate::db::audit( + &tx, + uid.as_deref().unwrap_or(""), + "app_login_completed", + "native_app", + "", + )?; + tx.commit()?; + let response = super::with_cookie( + Json(json!({"status":"complete","redirect":"/app"})).into_response(), + &state.cfg, + super::SESSION_COOKIE, + &raw, + expires - now(), + )?; + app_cookie(response, &state, "", 0) +} + +async fn cancel( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> AppResult { + browser_only(&headers)?; + origin_check(&state, &headers)?; + valid_request(&input.request)?; + let verifier = cookie(&headers, COOKIE).map(|v| hash_token(&v)); + let session_hash = cookie(&headers, super::SESSION_COOKIE).map(|v| hash_token(&v)); + let mut db = state.conn()?; + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let issued:Option> = tx.query_row("SELECT issued_session_hash FROM app_login_grants WHERE request_id=?1 AND (verifier_hash=?2 OR issued_session_hash=?3)",params![input.request,verifier,session_hash],|r|r.get(0)).optional()?; + let issued = + issued.ok_or_else(|| AppError::unauthorized("App sign-in is not active in this app"))?; + if let Some(hash) = &issued { + // Cancellation can race a poll response. Revoke the exact newly issued + // app session even if its Set-Cookie response has not arrived yet. + tx.execute("DELETE FROM sessions WHERE token_hash=?1", [hash])?; + } + tx.execute( + "DELETE FROM app_login_grants WHERE request_id=?1", + [&input.request], + )?; + tx.commit()?; + let response = app_cookie( + Json(json!({"cancelled":true})).into_response(), + &state, + "", + 0, + )?; + if issued.is_some() && issued == session_hash { + super::with_cookie(response, &state.cfg, super::SESSION_COOKIE, "", 0) + } else { + Ok(response) + } +} diff --git a/src/auth.rs b/src/auth.rs index a010d25..fb444ad 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -26,6 +26,9 @@ use subtle::ConstantTimeEq; use uuid::Uuid; use webauthn_rs::prelude::*; +#[path = "app_login.rs"] +pub mod app_login; + const SESSION_COOKIE: &str = "hf_session"; const EMAIL_COOKIE: &str = "hf_email_challenge"; const GOOGLE_COOKIE: &str = "hf_google_challenge"; @@ -118,6 +121,7 @@ pub struct Session { pub fn routes() -> Router { Router::new() + .merge(app_login::routes()) .route("/api/session", get(get_session)) .route("/api/auth/email/start", post(email_start)) .route("/api/auth/email/verify", post(email_verify)) @@ -201,6 +205,7 @@ pub fn migrate(db: &Connection) -> AppResult<()> { DELETE FROM auth_challenges WHERE NEW.banned=1 AND kind='email' AND subject=NEW.email COLLATE NOCASE; END;", )?; + app_login::migrate(db)?; Ok(()) } @@ -723,6 +728,8 @@ struct GooglePending { verifier: String, #[serde(default)] access_generation: i64, + #[serde(default)] + app_request: Option, } fn google_access_unchanged(db: &Connection, user_id: &str, generation: i64) -> AppResult<()> { @@ -739,7 +746,15 @@ fn google_access_unchanged(db: &Connection, user_id: &str, generation: i64) -> A Ok(()) } -async fn google_start(State(state): State) -> AppResult { +#[derive(Default, Deserialize)] +struct GoogleStart { + app_request: Option, +} + +async fn google_start( + State(state): State, + Query(input): Query, +) -> AppResult { if !state.cfg.google.enabled { return Err(AppError::unavailable("Google sign-in is not configured")); } @@ -752,6 +767,9 @@ async fn google_start(State(state): State) -> AppResult { .lock() .map_err(|_| AppError::internal("Database lock failed"))?; cleanup(&db)?; + if let Some(request) = &input.app_request { + app_login::pending_request(&db, request)?; + } rate_limit(&db, "google-start", 300, 300)?; let access_generation: i64 = db.query_row( "SELECT generation FROM account_access_clock WHERE id=1", @@ -759,7 +777,7 @@ async fn google_start(State(state): State) -> AppResult { |row| row.get(0), )?; db.execute("INSERT INTO auth_challenges(id,kind,subject,payload,expires_at) VALUES(?1,'google','',?2,?3)", - params![hash_token(&id),serde_json::to_string(&GooglePending{verifier,access_generation})?,now()+CHALLENGE_TTL])?; + params![hash_token(&id),serde_json::to_string(&GooglePending{verifier,access_generation,app_request:input.app_request})?,now()+CHALLENGE_TTL])?; } let callback = format!( "{}/auth/google/callback", @@ -829,14 +847,25 @@ async fn google_callback( .map_err(|_| AppError::internal("Database lock failed"))?; consume_challenge(&db, &id, "google", None)? }; + let pending: GooglePending = serde_json::from_str(&pending)?; if input.error.is_some() { - return Ok(Redirect::to("/login?error=google_cancelled").into_response()); + let destination = pending + .app_request + .as_ref() + .map(|request| app_login::authorize_path(request)) + .unwrap_or_else(|| "/login?error=google_cancelled".into()); + return with_cookie( + Redirect::to(&destination).into_response(), + &state.cfg, + GOOGLE_COOKIE, + "", + 0, + ); } let code = input .code .filter(|c| !c.is_empty() && c.len() < 4096) .ok_or_else(|| AppError::unauthorized("Google sign-in was not completed"))?; - let pending: GooglePending = serde_json::from_str(&pending)?; let callback = format!( "{}/auth/google/callback", state.cfg.public_url.trim_end_matches('/') @@ -930,8 +959,13 @@ async fn google_callback( tx.commit()?; raw }; + let destination = pending + .app_request + .as_ref() + .map(|request| app_login::authorize_path(request)) + .unwrap_or_else(|| "/app".into()); let response = with_cookie( - Redirect::to("/app").into_response(), + Redirect::to(&destination).into_response(), &state.cfg, SESSION_COOKIE, &raw, diff --git a/static/app-login.css b/static/app-login.css new file mode 100644 index 0000000..bc63e83 --- /dev/null +++ b/static/app-login.css @@ -0,0 +1,21 @@ +.app-login-content {max-width:620px;} +.app-login-eyebrow {font:600 11px/1.8 var(--mono);letter-spacing:.1em;color:var(--accent);margin:0 0 14px;} +.app-login-content .card {border:1px solid var(--line);box-shadow:0 12px 45px #00000004;} +.app-login-content .code-confirm {background:var(--accent-soft);color:var(--on-accent-soft);padding:22px 24px;} +.app-login-content .code-confirm strong {font-size:30px;} +.app-login-content .code-confirm span {color:inherit;} +.app-login-content input[type=email] {font:16px/1.5 var(--sans, sans-serif);letter-spacing:0;} +.app-login-content .app-login-confirm {display:flex;align-items:flex-start;gap:12px;font-size:13px;line-height:1.8;cursor:pointer;} +.app-login-content .app-login-confirm input {width:20px;height:20px;flex:none;margin:2px 0 0;padding:0;accent-color:var(--accent);} +.app-login-content .actions {gap:12px;} +.app-login-content .actions button,.app-login-content .actions a {min-height:44px;} +.app-login-content #approve:disabled {cursor:not-allowed;} +.app-login-footnote {font-size:11px;line-height:1.9;color:var(--muted);margin:20px 16px;} +@media(max-width:480px) { + .app-login-content {width:calc(100% - 32px);margin-top:34px;padding-bottom:env(safe-area-inset-bottom);} + .app-login-content .card {padding:24px 20px;} + .app-login-content .code-confirm {padding:18px;} + .app-login-content dl {grid-template-columns:70px minmax(0,1fr);} + .app-login-content .actions {align-items:stretch;} + .app-login-content .actions .primary {flex:1;} +} diff --git a/static/app-login.html b/static/app-login.html new file mode 100644 index 0000000..ff2b9fb --- /dev/null +++ b/static/app-login.html @@ -0,0 +1,71 @@ + + + + + + + + 应用登录 · HireFerry + + + + + + + + + + + +
+ 聘渡HireFerry + +
+
+ +

继续登录聘渡应用

+

在浏览器中确认账户,应用会接着完成登录。

+
+

+ + + + + +
+ +
+ + diff --git a/static/app-login.js b/static/app-login.js new file mode 100644 index 0000000..3349bf3 --- /dev/null +++ b/static/app-login.js @@ -0,0 +1,152 @@ +const $ = id => document.getElementById(id); +const messages = { + zh: { + eyebrow:'连接你的账户', title:'继续登录聘渡应用', intro:'在浏览器中确认账户,应用会接着完成登录。', cardLabel:'应用登录', + compareApp:'在浏览器中核对这个编号', compareBrowser:'核对聘渡应用中的编号', + appHelp:'确认后返回此应用。编号五分钟内有效,登录凭据不会放进链接。', openBrowser:'打开浏览器确认', cancel:'取消', + site:'网站', account:'账户', signInHelp:'先登录,再确认本次应用连接。', google:'使用 Google 登录', email:'邮箱', sendCode:'发送验证码', otp:'8 位验证码', verifyCode:'登录并继续', + permission:'此应用将以显示的账户登录网站,可执行该账户原有的操作。仅确认你自己刚在应用中发起的请求。', + confirm:'我已核对编号,这是我正在登录的应用', approve:'确认登录应用', deny:'拒绝', returnApp:'返回聘渡应用', website:'继续使用网站', retry:'重新开始', + footnote:'应用和浏览器各自保留登录会话。团队员工、经理和总管理员请直接在应用中登录。', + loading:'正在准备登录…', waiting:'等待你在浏览器中确认', choose:'请核对账户和编号', signedOut:'尚未登录', sent:'验证码已发送,请检查邮箱', + approved:'已确认。请返回聘渡应用。', denied:'已拒绝本次连接', cancelling:'正在取消,请稍候…', cancelFailed:'尚未确认取消,请重试。', cancelled:'已取消本次连接', complete:'登录完成,正在打开工作台…', + returnHelp:'切回刚才的聘渡应用,它会自动完成登录。此网页不会收到应用的登录凭据。', + stopHelp:'没有为此请求创建应用会话。你仍可继续使用网站。', expired:'编号已过期,请在应用中重新开始。', + admin:'此回跳仅支持普通账户。团队员工、经理和总管理员请直接在应用中登录。', network:'暂时无法连接,请检查网络后重试。' + }, + en: { + eyebrow:'CONNECT YOUR ACCOUNT', title:'Continue to HireFerry', intro:'Confirm your account in the browser. The app will finish signing you in.', cardLabel:'App sign-in', + compareApp:'Match this number in your browser', compareBrowser:'Match the number shown in your app', + appHelp:'Return to the app after confirming. This number expires in five minutes; sign-in credentials stay out of links.', openBrowser:'Confirm in browser', cancel:'Cancel', + site:'Website', account:'Account', signInHelp:'Sign in first, then confirm this app connection.', google:'Continue with Google', email:'Email', sendCode:'Send code', otp:'8-digit code', verifyCode:'Sign in and continue', + permission:'The app will sign in as the displayed account and can perform its existing website actions. Confirm only a request you just started in your own app.', + confirm:'The number matches the app I am signing in to', approve:'Confirm app sign-in', deny:'Decline', returnApp:'Return to HireFerry', website:'Continue on website', retry:'Start again', + footnote:'The app and browser keep separate sessions. Staff, managers and administrators must sign in directly in the app.', + loading:'Preparing sign-in…', waiting:'Waiting for your browser confirmation', choose:'Check your account and the number', signedOut:'Not signed in', sent:'Code sent. Please check your email.', + approved:'Confirmed. Return to the HireFerry app.', denied:'Connection declined', cancelling:'Cancelling. Please wait…', cancelFailed:'Cancellation is not confirmed. Please try again.', cancelled:'Connection cancelled', complete:'Signed in. Opening your workspace…', + returnHelp:'Switch back to the HireFerry app you just opened. It will finish signing you in. This webpage never receives the app session credential.', + stopHelp:'No app session was created for this request. You can still use the website.', expired:'This number expired. Start again in the app.', + admin:'This return flow supports ordinary accounts. Staff, managers and administrators must sign in directly in the app.', network:'Unable to connect. Check your connection and try again.' + } +}; +let language = 'zh'; +try { language = localStorage.getItem('hireferry-language') === 'en' ? 'en' : 'zh'; } catch {} +let statusKey = 'loading', finishKey = '', request = '', csrf = '', expires = 0, timer, busy = false, polling = false, cancelling = false, cancelInFlight = false, done = false; +const browser = location.pathname === '/auth/app/authorize'; +const t = key => messages[language][key] || key; +function translate() { + document.documentElement.lang = language === 'en' ? 'en' : 'zh-CN'; + document.title = `${t('title')} · HireFerry`; + document.querySelectorAll('[data-text]').forEach(node => { node.textContent = t(node.dataset.text); }); + document.querySelectorAll('[data-label]').forEach(node => { node.setAttribute('aria-label', t(node.dataset.label)); }); + $('language').textContent = language === 'en' ? '中文' : 'EN'; + $('language').setAttribute('aria-label', language === 'en' ? '切换到中文' : 'Switch to English'); + $('status').textContent = t(statusKey); + if (finishKey) $('finished-help').textContent = t(finishKey); +} +function status(key) { statusKey = key; $('status').textContent = t(key); } +function error(message) { $('error').textContent = message; $('error').hidden = !message; } +async function api(path, body, token) { + const controller = new AbortController(), deadline = setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch(path, { method: body === undefined ? 'GET' : 'POST', credentials:'same-origin', cache:'no-store', signal:controller.signal, + headers: { ...(body === undefined ? {} : {'content-type':'application/json'}), ...(token ? {'x-csrf-token':token} : {}) }, + ...(body === undefined ? {} : { body:JSON.stringify(body) }) }); + const data = await response.json(); + if (!response.ok) { const failure = new Error(data.error || data.message || t('network')); failure.status = response.status; throw failure; } + return data; + } catch (failure) { if (failure.name === 'AbortError') throw new Error(t('network')); throw failure; } + finally { clearTimeout(deadline); } +} +function finish(key, help) { + done = true; clearTimeout(timer); status(key); finishKey = help; + $('app-flow').hidden = true; $('browser-flow').hidden = true; $('finished').hidden = false; + $('return-app').hidden = key !== 'approved'; + $('finished-help').textContent = t(help); +} +async function poll() { + clearTimeout(timer); + if (done || polling || cancelling || browser || !request) return; + if (Date.now() / 1000 >= expires) { finish('expired','stopHelp'); $('retry').hidden = false; return; } + if (document.hidden) { timer = setTimeout(poll, 3000); return; } + polling = true; + try { + const result = await api('/api/auth/app/redeem', {request}); + if (done || cancelling) return; + error(''); + if (result.status === 'complete') { done = true; status('complete'); location.replace('/app'); return; } + if (result.status === 'denied') { finish('denied','stopHelp'); $('retry').hidden = false; return; } + status('waiting'); + } catch (failure) { + if (done || cancelling) return; + error(failure.message); + if (failure.status && failure.status !== 429 && failure.status < 500) { done = true; $('retry').hidden = false; } + } finally { + polling = false; + if (!done && !cancelling) timer = setTimeout(poll, 3000); + } +} +async function start() { + done = false; cancelling = false; error(''); status('loading'); $('retry').hidden = true; $('finished').hidden = true; + try { + const data = await api('/api/auth/app/start', {}); + request = data.request; expires = data.expires_at; + $('app-code').textContent = data.comparison_code; + // Only this exact same-origin route is eligible for the native browser hop. + const link = new URL(data.verification_uri, location.origin); + if (link.origin !== location.origin || link.pathname !== '/auth/app/authorize' || link.searchParams.get('request') !== request) throw new Error(t('network')); + $('open-browser').href = link.href; $('app-flow').hidden = false; status('waiting'); + timer = setTimeout(poll, 3000); + } catch (failure) { error(failure.message); $('retry').hidden = false; } +} +async function loadContext() { + try { + const context = await api(`/api/auth/app/context?request=${encodeURIComponent(request)}`); + csrf = context.csrf || ''; + $('browser-code').textContent = context.comparison_code; + $('site').textContent = context.site; + $('account').textContent = context.user ? `${context.user.name ? context.user.name + ' · ' : ''}${context.user.email}` : t('signedOut'); + $('sign-in').hidden = context.can_approve || context.admin_session; + $('google').hidden = !context.google_enabled; + $('google').href = `/api/auth/google/start?app_request=${encodeURIComponent(request)}`; + $('email-form').hidden = !context.email_enabled || context.can_approve; + $('consent').hidden = !context.can_approve; + $('browser-flow').hidden = false; + status('choose'); + if (context.admin_session) error(t('admin')); + } catch (failure) { error(failure.message); status('expired'); } +} +async function decide(approve) { + if (busy || (approve && !$('confirm').checked)) return; + busy = true; $('approve').disabled = true; $('deny').disabled = true; error(''); + try { await api('/api/auth/app/decide', {request,approve}, csrf); finish(approve ? 'approved' : 'denied', approve ? 'returnHelp' : 'stopHelp'); } + catch (failure) { error(failure.message); } + finally { busy = false; $('approve').disabled = !$('confirm').checked; $('deny').disabled = false; } +} +$('language').addEventListener('click', () => { language = language === 'en' ? 'zh' : 'en'; try { localStorage.setItem('hireferry-language',language); } catch {} translate(); }); +$('retry').addEventListener('click', start); +$('confirm').addEventListener('change', () => { $('approve').disabled = busy || !$('confirm').checked; }); +$('approve').addEventListener('click', () => decide(true)); +$('deny').addEventListener('click', () => decide(false)); +$('cancel').addEventListener('click', async () => { + if (cancelInFlight || done) return; cancelling = true; cancelInFlight = true; clearTimeout(timer); $('cancel').disabled = true; status('cancelling'); + try { await api('/api/auth/app/cancel', {request}); finish('cancelled','stopHelp'); $('retry').hidden = false; } + catch (failure) { error(failure.message); status('cancelFailed'); } + finally { cancelInFlight = false; $('cancel').disabled = false; } +}); +$('email-form').addEventListener('submit', async event => { + event.preventDefault(); if (busy) return; busy = true; const button = event.currentTarget.querySelector('button'); button.disabled = true; error(''); + try { await api('/api/auth/email/start', {email:$('email').value.trim()}); $('otp-form').hidden = false; status('sent'); $('otp').focus(); } + catch (failure) { error(failure.message); } finally { busy = false; button.disabled = false; } +}); +$('otp-form').addEventListener('submit', async event => { + event.preventDefault(); if (busy) return; busy = true; const button = event.currentTarget.querySelector('button'); button.disabled = true; error(''); + try { await api('/api/auth/email/verify', {email:$('email').value.trim(),code:$('otp').value.trim()}); $('otp-form').hidden = true; $('otp').value = ''; await loadContext(); } + catch (failure) { error(failure.message); } finally { busy = false; button.disabled = false; } +}); +document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); }); +window.addEventListener('hireferry-app-return', poll); +window.addEventListener('pageshow', () => { if (!browser && request) poll(); }); +window.addEventListener('pagehide', () => clearTimeout(timer)); +translate(); +if (browser) { request = new URLSearchParams(location.search).get('request') || ''; loadContext(); } else start(); diff --git a/tests/app-login.browser.mjs b/tests/app-login.browser.mjs new file mode 100644 index 0000000..a368cc8 --- /dev/null +++ b/tests/app-login.browser.mjs @@ -0,0 +1,330 @@ +// Run against the real Rust routes and a temporary SQLite database. The two browser +// contexts model a WebView and its separate system browser, not native OS integration. +import assert from 'node:assert/strict'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { startAppLoginFixture } from './helpers/app-login-browser-fixture.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const modulePath = process.env.HFY_PLAYWRIGHT_MODULE || 'playwright'; +const { chromium } = await import(isAbsolute(modulePath) ? pathToFileURL(modulePath).href : modulePath); +const fixture = await startAppLoginFixture(root); +const contexts = [], pageErrors = [], externalRequests = []; +const delay = milliseconds => new Promise(resolve_ => setTimeout(resolve_, milliseconds)); +let browser; + +function deferred() { + let resolve_; + const promise = new Promise(resolve => { resolve_ = resolve; }); + return { promise, resolve: resolve_ }; +} +async function within(promise, label, timeout = 10000) { + let timer; + try { return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(label)), timeout); })]); } + finally { clearTimeout(timer); } +} +const matches = (response, path) => new URL(response.url()).pathname === path && response.request().method() === 'POST'; +const responseFor = (page, path) => page.waitForResponse(response => matches(response, path)); + +try { + browser = await chromium.launch({ + headless: true, executablePath: process.env.HFY_BROWSER_EXECUTABLE || undefined, + args: ['--no-sandbox', '--disk-cache-size=1048576', '--media-cache-size=1048576'] + }); + async function createContext({ source = false, language = 'zh' } = {}) { + const context = await browser.newContext({ viewport: { width: 390, height: 900 }, locale: language === 'en' ? 'en-US' : 'zh-CN', reducedMotion: 'reduce' }); + contexts.push(context); + await context.addInitScript(language_ => { + if (!localStorage.getItem('hireferry-language')) localStorage.setItem('hireferry-language', language_); + localStorage.setItem('hireferry-theme', 'light'); + localStorage.setItem('hireferry-palette', 'neutral'); + }, language); + await context.route('**/*', route => { + const url = new URL(route.request().url()); + if (url.origin === fixture.origin) return route.continue(); + externalRequests.push(url.origin + url.pathname); + return route.abort(); + }); + if (source) await context.addCookies([{ + name: 'hf_session', value: fixture.sourceSession, url: fixture.origin, + httpOnly: true, sameSite: 'Lax', expires: fixture.sourceExpires + }]); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + page.on('pageerror', error => pageErrors.push(error.message)); + return { context, page }; + } + async function api(page, path, body, csrf) { + return page.evaluate(async ({ path_, body_, csrf_ }) => { + const response = await fetch(path_, { + method: body_ === undefined ? 'GET' : 'POST', credentials: 'same-origin', cache: 'no-store', + headers: { ...(body_ === undefined ? {} : { 'content-type': 'application/json' }), ...(csrf_ ? { 'x-csrf-token': csrf_ } : {}) }, + ...(body_ === undefined ? {} : { body: JSON.stringify(body_) }) + }); + return { status: response.status, body: await response.json() }; + }, { path_: path, body_: body, csrf_: csrf }); + } + async function cookie(context, name) { return (await context.cookies()).find(item => item.name === name); } + async function signedIn(page) { return (await api(page, '/api/session')).body; } + async function assertAnonymous(page) { assert.equal((await signedIn(page)).user, null, 'App must remain signed out'); } + async function beginApp(app) { + const response = responseFor(app.page, '/api/auth/app/start'); + await app.page.goto(fixture.origin + '/auth/app'); + const result = await response; + assert.equal(result.status(), 200); + assert.equal(result.headers()['cache-control'], 'no-store'); + const data = await result.json(); + await app.page.locator('#app-flow').waitFor({ state: 'visible' }); + assert.match(data.request, /^[A-Za-z0-9_-]{43}$/); + assert.match(data.comparison_code, /^[A-Z0-9]{4}-[A-Z0-9]{4}$/); + const link = new URL(await app.page.locator('#open-browser').getAttribute('href')); + assert.equal(link.origin, fixture.origin); + assert.equal(link.pathname, '/auth/app/authorize'); + assert.deepEqual([...link.searchParams.keys()], ['request']); + assert.equal(link.searchParams.get('request'), data.request); + assert.equal(link.hash, ''); + const proof = await cookie(app.context, 'hf_app_login'); + assert.ok(proof, 'Only the initiating context receives a proof cookie'); + assert.equal(proof.httpOnly, true); + assert.equal(proof.sameSite, 'Strict'); + assert.equal(proof.path, '/api/auth/app'); + assert.ok(!JSON.stringify(data).includes(proof.value), 'Proof must not be serialized to page JSON'); + assert.ok(!link.href.includes(proof.value), 'Proof must not enter the public URL'); + assert.ok(!(await app.page.evaluate(() => document.cookie)).includes('hf_app_login')); + assert.equal(await cookie(app.context, 'hf_session'), undefined); + await assertAnonymous(app.page); + return data; + } + async function openConsent(system, request) { + await system.page.goto(request.verification_uri); + await system.page.locator('#consent').waitFor({ state: 'visible' }); + assert.equal(await system.page.locator('#browser-code').textContent(), request.comparison_code); + assert.equal(await system.page.locator('#confirm').isChecked(), false); + assert.equal(await system.page.locator('#approve').isDisabled(), true); + assert.equal(await cookie(system.context, 'hf_app_login'), undefined); + } + async function approve(system) { + const response = responseFor(system.page, '/api/auth/app/decide'); + await system.page.locator('#confirm').check(); + await system.page.locator('#approve').click(); + const result = await response; + assert.equal(result.status(), 200); + assert.deepEqual(await result.json(), { approved: true }); + await system.page.locator('#finished').waitFor({ state: 'visible' }); + assert.equal(await system.page.locator('#return-app').getAttribute('href'), 'hireferry://login-return'); + } + async function wake(app) { + await app.page.bringToFront(); + await app.page.evaluate(() => window.dispatchEvent(new Event('hireferry-app-return'))); + } + async function complete(app) { + await wake(app); + await app.page.waitForURL(url => url.pathname === '/app'); + const identity = await signedIn(app.page); + assert.equal(identity.user.id, fixture.member.id); + assert.equal(identity.admin, false); + const session = await cookie(app.context, 'hf_session'); + assert.ok(session?.httpOnly); + assert.notEqual(session.value, fixture.sourceSession); + assert.equal(await cookie(app.context, 'hf_app_login'), undefined); + return session; + } + async function assertSourceIntact(system) { + assert.equal((await cookie(system.context, 'hf_session')).value, fixture.sourceSession); + assert.equal((await signedIn(system.page)).user.id, fixture.member.id); + assert.ok(fixture.session(fixture.sourceSession), 'The browser source session must remain live'); + } + async function assertLayout(page, label) { + const bounds = await page.evaluate(() => ({ viewport: innerWidth, document: document.documentElement.scrollWidth, body: document.body.scrollWidth })); + assert.ok(bounds.document <= bounds.viewport + 1 && bounds.body <= bounds.viewport + 1, `${label}: horizontal overflow ${JSON.stringify(bounds)}`); + } + async function languagesAndWidths(page, flow) { + for (const width of [320, 390]) { + await page.setViewportSize({ width, height: 900 }); + for (const language of ['zh-CN', 'en']) { + if (await page.locator('html').getAttribute('lang') !== language) await page.locator('#language').click(); + assert.equal(await page.locator('html').getAttribute('lang'), language); + assert.equal(await page.locator('.card').getAttribute('aria-label'), language === 'en' ? 'App sign-in' : '应用登录'); + const primary = page.locator(flow === 'app' ? '#open-browser' : '#approve'); + assert.equal(await primary.textContent(), flow === 'app' ? (language === 'en' ? 'Confirm in browser' : '打开浏览器确认') : (language === 'en' ? 'Confirm app sign-in' : '确认登录应用')); + await assertLayout(page, `${flow}, ${language}, ${width}px`); + } + } + } + + const system = await createContext({ source: true }); + { + const app = await createContext(); + const request = await beginApp(app); + await openConsent(system, request); + await languagesAndWidths(app.page, 'app'); + await languagesAndWidths(system.page, 'browser'); + assert.equal(fixture.grant(request.request).status, 'pending', 'Opening the public link must not grant consent'); + assert.equal(fixture.sessionCount(), 1); + const withoutProof = await api(system.page, '/api/auth/app/redeem', { request: request.request }); + assert.equal(withoutProof.status, 401, 'Even an authenticated browser cannot redeem the public ID without the initiating proof'); + await assertSourceIntact(system); + await approve(system); + const alsoWithoutProof = await api(system.page, '/api/auth/app/redeem', { request: request.request }); + assert.equal(alsoWithoutProof.status, 401); + const appSession = await complete(app); + const stored = fixture.session(appSession.value); + assert.equal(stored.user_id, fixture.member.id); + assert.equal(stored.recent_auth_at, fixture.sourceRecent, 'Handoff must not refresh recent authentication'); + assert.ok(stored.expires_at <= fixture.sourceExpires, 'Handoff must not extend the source session lifetime'); + assert.equal(fixture.sessionCount(), 2); + const replay = await api(app.page, '/api/auth/app/redeem', { request: request.request }); + assert.ok([401, 409].includes(replay.status)); + assert.equal(fixture.sessionCount(), 2); + await assertSourceIntact(system); + await app.context.close(); + process.stdout.write('ok - explicit consent, isolated HttpOnly proof, one-time app session, source lifetime and source browser preserved\n'); + process.stdout.write('ok - app and consent screens in Chinese/English at 320px and 390px without horizontal overflow\n'); + } + + { + const app = await createContext({ language: 'en' }); + const baseline = fixture.sessionCount(), request = await beginApp(app); + await openConsent(system, request); + const response = responseFor(system.page, '/api/auth/app/decide'); + await system.page.locator('#deny').click(); + assert.deepEqual(await (await response).json(), { approved: false }); + await wake(app); + await app.page.locator('#finished').waitFor({ state: 'visible' }); + assert.equal(await app.page.locator('#status').textContent(), 'Connection declined'); + await assertAnonymous(app.page); + assert.equal(await cookie(app.context, 'hf_app_login'), undefined); + assert.equal(fixture.sessionCount(), baseline); + await assertSourceIntact(system); + await app.context.close(); + process.stdout.write('ok - browser decline leaves the app signed out and consumes no session\n'); + } + + { + const app = await createContext({ language: 'en' }); + const baseline = fixture.sessionCount(), request = await beginApp(app); + await openConsent(system, request); + const response = responseFor(app.page, '/api/auth/app/cancel'); + await app.page.locator('#cancel').click(); + assert.equal((await response).status(), 200); + await app.page.locator('#finished').waitFor({ state: 'visible' }); + assert.equal(await app.page.locator('#status').textContent(), 'Connection cancelled'); + assert.equal(fixture.grant(request.request), undefined); + await assertAnonymous(app.page); + const lateApproval = await api(system.page, '/api/auth/app/decide', { request: request.request, approve: true }, fixture.sourceCsrf); + assert.equal(lateApproval.status, 400); + assert.equal(fixture.sessionCount(), baseline); + await app.context.close(); + process.stdout.write('ok - cancellation invalidates the outstanding browser approval link\n'); + } + + { + const app = await createContext({ language: 'en' }), emailBrowser = await createContext({ language: 'en' }); + const request = await beginApp(app); + await emailBrowser.page.goto(request.verification_uri); + await emailBrowser.page.locator('#email-form').waitFor({ state: 'visible' }); + assert.equal(await emailBrowser.page.locator('#consent').isVisible(), false); + const started = responseFor(emailBrowser.page, '/api/auth/email/start'); + await emailBrowser.page.locator('#email').fill(fixture.member.email); + await emailBrowser.page.locator('#email-form button').click(); + const startResponse = await started; + assert.equal(startResponse.status(), 200); + // The development-only response is read inside the test, never rendered or logged. + const code = (await startResponse.json()).dev_code; + assert.match(code, /^\d{8}$/); + await emailBrowser.page.locator('#otp').fill(code); + const verified = responseFor(emailBrowser.page, '/api/auth/email/verify'); + await emailBrowser.page.locator('#otp-form button').click(); + assert.equal((await verified).status(), 200); + await emailBrowser.page.locator('#consent').waitFor({ state: 'visible' }); + assert.equal(await emailBrowser.page.locator('#confirm').isChecked(), false); + assert.equal(await emailBrowser.page.locator('#approve').isDisabled(), true); + assert.equal(await emailBrowser.page.locator('#otp').inputValue(), ''); + assert.ok(!emailBrowser.page.url().includes(code)); + assert.equal(fixture.grant(request.request).status, 'pending'); + await assertAnonymous(app.page); + await assertLayout(emailBrowser.page, 'email sign-in to consent'); + await approve(emailBrowser); + const appSession = await complete(app), browserSession = await cookie(emailBrowser.context, 'hf_session'); + assert.notEqual(appSession.value, browserSession.value); + assert.equal((await signedIn(emailBrowser.page)).user.id, fixture.member.id); + assert.equal(fixture.userCount(), 1, 'Email OTP must reuse the one synthetic member'); + await assertSourceIntact(system); + await app.context.close(); await emailBrowser.context.close(); + process.stdout.write('ok - real development email OTP reaches unchecked consent before a separate app session is issued\n'); + } + + { + const app = await createContext({ language: 'en' }); + const heldRedeem = deferred(), releaseRedeem = deferred(), heldCancel = deferred(), releaseCancel = deferred(); + const baseline = fixture.sessionCount(); + await app.page.route('**/api/auth/app/redeem', async route => { + heldRedeem.resolve(); await releaseRedeem.promise; await route.continue(); + }); + await app.page.route('**/api/auth/app/cancel', async route => { + heldCancel.resolve(); await releaseCancel.promise; await route.continue(); + }); + try { + const request = await beginApp(app); + await wake(app); await within(heldRedeem.promise, 'The pending redeem was not intercepted'); + const cancelResponse = responseFor(app.page, '/api/auth/app/cancel'); + await app.page.locator('#cancel').click(); + await within(heldCancel.promise, 'Cancel must be sent while redeem is still pending'); + assert.equal(await app.page.locator('#status').textContent(), 'Cancelling. Please wait…'); + assert.equal(await app.page.locator('#finished').isVisible(), false, 'Cancellation must not claim success before server acknowledgement'); + assert.ok(fixture.grant(request.request)); + releaseCancel.resolve(); assert.equal((await cancelResponse).status(), 200); + await app.page.locator('#finished').waitFor({ state: 'visible' }); + const redeemResponse = responseFor(app.page, '/api/auth/app/redeem'); + releaseRedeem.resolve(); assert.equal((await redeemResponse).status(), 401); + assert.equal(await app.page.locator('#status').textContent(), 'Connection cancelled'); + assert.equal(new URL(app.page.url()).pathname, '/auth/app'); + await assertAnonymous(app.page); + assert.equal(fixture.grant(request.request), undefined); + assert.equal(fixture.sessionCount(), baseline); + process.stdout.write('ok - hanging redeem does not block cancel; cancellation is shown only after the real server confirms it\n'); + } finally { releaseRedeem.resolve(); releaseCancel.resolve(); await app.context.close(); } + } + + { + const app = await createContext({ language: 'en' }); + const committed = deferred(), releaseResponse = deferred(), delivered = deferred(); + const baseline = fixture.sessionCount(); + await app.page.route('**/api/auth/app/redeem', async route => { + const response = await route.fetch(); + const data = await response.json(); + if (data.status === 'complete') { committed.resolve(); await releaseResponse.promise; } + await route.fulfill({ response }); + if (data.status === 'complete') delivered.resolve(); + }); + try { + const request = await beginApp(app); + await openConsent(system, request); await approve(system); await wake(app); + await within(committed.promise, 'Approved redemption did not reach the real server'); + assert.equal(fixture.grant(request.request).status, 'consumed'); + assert.equal(fixture.sessionCount(), baseline + 1); + const cancelled = responseFor(app.page, '/api/auth/app/cancel'); + await app.page.locator('#cancel').click(); assert.equal((await cancelled).status(), 200); + await app.page.locator('#finished').waitFor({ state: 'visible' }); + assert.equal(fixture.grant(request.request), undefined); + assert.equal(fixture.sessionCount(), baseline, 'Cancel must revoke the newly committed app session'); + const late = responseFor(app.page, '/api/auth/app/redeem'); + releaseResponse.resolve(); assert.equal((await late).status(), 200); + await within(delivered.promise, 'Late redeem response was not delivered'); + await delay(100); + assert.equal(new URL(app.page.url()).pathname, '/auth/app', 'A late successful response must not navigate after cancellation'); + assert.equal(await app.page.locator('#status').textContent(), 'Connection cancelled'); + await assertAnonymous(app.page); + await assertSourceIntact(system); + process.stdout.write('ok - cancel after redeem commit revokes the app session; a late successful response cannot sign the app back in\n'); + } finally { releaseResponse.resolve(); await app.context.close(); } + } + + assert.deepEqual(pageErrors, []); + assert.deepEqual(externalRequests, [], 'All authentication traffic must stay on the isolated fixture origin'); + process.stdout.write('ok - no page exceptions, external authentication traffic, or production data; temporary server/database cleaned up\n'); +} finally { + await Promise.allSettled(contexts.map(context => context.close())); + await browser?.close(); + await fixture.close(); +} diff --git a/tests/app_login.rs b/tests/app_login.rs new file mode 100644 index 0000000..d41086b --- /dev/null +++ b/tests/app_login.rs @@ -0,0 +1,734 @@ +//! Real HTTP/SQLite tests for one-time browser-to-WebView login. +use axum::{ + Router, + body::Body, + http::{HeaderMap, Request, StatusCode}, +}; +use hireferry::{AppState, auth, config::Config, now}; +use http_body_util::BodyExt; +use rusqlite::params; +use serde_json::{Value, json}; +use tower::ServiceExt; + +struct Fixture { + state: AppState, + app: Router, +} +struct Seen { + status: StatusCode, + headers: HeaderMap, + body: String, + json: Value, +} +impl Fixture { + fn new() -> Self { + let mut cfg = Config { + development: true, + database: ":memory:".into(), + ..Default::default() + }; + cfg.google.enabled = true; + cfg.google.client_id = "test-google-client".into(); + cfg.google.client_secret = "fixture-not-a-real-secret".into(); + let state = AppState::new(cfg).unwrap(); + let db = state.conn().unwrap(); + db.execute("INSERT INTO users(id,email,name,role,created_at,updated_at) VALUES('member','member@example.invalid','Test member','seeker',?1,?1)", [now()]).unwrap(); + db.execute("INSERT INTO sessions(token_hash,user_id,csrf,expires_at,recent_auth_at) VALUES(?1,'member','csrf-member',?2,?3)", params![auth::hash_token("browser-session"),now()+3600,now()]).unwrap(); + db.execute("INSERT INTO sessions(token_hash,admin,csrf,expires_at,recent_auth_at) VALUES(?1,1,'csrf-admin',?2,?3)", params![auth::hash_token("admin-session"),now()+3600,now()]).unwrap(); + drop(db); + let app = hireferry::app(state.clone()); + Self { state, app } + } + async fn request( + &self, + method: &str, + path: &str, + body: Value, + cookie: &str, + origin: Option<&str>, + csrf: Option<&str>, + bearer: bool, + ) -> Seen { + let mut builder = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json"); + if !cookie.is_empty() { + builder = builder.header("cookie", cookie); + } + if let Some(value) = origin { + builder = builder.header("origin", value); + } + if let Some(value) = csrf { + builder = builder.header("x-csrf-token", value); + } + if bearer { + builder = builder.header("authorization", "Bearer explicitly-not-a-session"); + } + let response = self + .app + .clone() + .oneshot(builder.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let body = String::from_utf8( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(), + ) + .unwrap(); + let json = serde_json::from_str(&body).unwrap_or(Value::Null); + Seen { + status, + headers, + body, + json, + } + } + async fn post(&self, path: &str, body: Value, cookie: &str) -> Seen { + self.request( + "POST", + path, + body, + cookie, + Some("http://localhost:3000"), + Some("csrf-member"), + false, + ) + .await + } + async fn start(&self) -> (Value, String) { + let seen = self.post("/api/auth/app/start", json!({}), "").await; + assert_eq!(seen.status, StatusCode::OK, "{}", seen.body); + let cookie = cookie_value(&seen, "hf_app_login"); + (seen.json, cookie) + } + async fn approve(&self, request: &Value, approve: bool) -> Seen { + self.post( + "/api/auth/app/decide", + json!({"request":request["request"],"approve":approve}), + "hf_session=browser-session", + ) + .await + } + async fn redeem(&self, request: &Value, cookie: &str) -> Seen { + self.post( + "/api/auth/app/redeem", + json!({"request":request["request"]}), + cookie, + ) + .await + } +} +fn cookie_value(seen: &Seen, name: &str) -> String { + seen.headers + .get_all("set-cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .find(|v| v.starts_with(&format!("{name}="))) + .unwrap_or_else(|| panic!("Missing {name}: {:?}", seen.headers)) + .split(';') + .next() + .unwrap() + .to_owned() +} + +#[tokio::test] +async fn start_requires_exact_origin_and_never_returns_verifier_in_json() { + let f = Fixture::new(); + for origin in [None, Some("https://unrelated.example"), Some("null")] { + let r = f + .request( + "POST", + "/api/auth/app/start", + json!({}), + "", + origin, + None, + false, + ) + .await; + assert_eq!(r.status, StatusCode::FORBIDDEN); + assert!(!r.headers.contains_key("set-cookie")); + } + let (request, cookie) = f.start().await; + assert_eq!(request["request"].as_str().unwrap().len(), 43); + assert!( + !request + .to_string() + .contains(cookie.split_once('=').unwrap().1) + ); + let again = f.post("/api/auth/app/start", json!({}), &cookie).await; + assert_eq!(again.json["request"], request["request"]); + assert_eq!(again.json["expires_at"], request["expires_at"]); + let header = again.headers["set-cookie"].to_str().unwrap(); + assert!(header.contains("HttpOnly; SameSite=Strict")); + assert!(header.contains("Path=/api/auth/app")); + assert_eq!(again.headers["cache-control"], "no-store"); + assert_eq!(again.headers["pragma"], "no-cache"); +} + +#[tokio::test] +async fn public_request_alone_never_creates_or_redeems_a_session() { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + let pending = f.redeem(&request, &cookie).await; + assert_eq!(pending.json["status"], "pending"); + assert!(!pending.headers.contains_key("set-cookie")); + assert_eq!( + f.redeem(&request, "").await.status, + StatusCode::UNAUTHORIZED + ); + assert_eq!(f.approve(&request, true).await.status, StatusCode::OK); + assert_eq!( + f.redeem(&request, "hf_app_login=wrong-cookie").await.status, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + f.redeem(&request, "hf_session=browser-session") + .await + .status, + StatusCode::UNAUTHORIZED + ); + let completed = f.redeem(&request, &cookie).await; + assert_eq!(completed.status, StatusCode::OK, "{}", completed.body); + assert_eq!( + completed.json, + json!({"status":"complete","redirect":"/app"}) + ); + let app_session = cookie_value(&completed, "hf_session"); + assert_ne!(app_session, "hf_session=browser-session"); + assert!( + !completed + .body + .contains(app_session.split_once('=').unwrap().1) + ); + assert!( + completed + .headers + .get_all("set-cookie") + .iter() + .any(|v| v.to_str().unwrap().contains("hf_app_login=;")) + ); + let identity = f + .request( + "GET", + "/api/session", + Value::Null, + &app_session, + None, + None, + false, + ) + .await; + assert_eq!(identity.json["user"]["id"], "member"); + assert_eq!(identity.json["admin"], false); + let browser = f + .request( + "GET", + "/api/session", + Value::Null, + "hf_session=browser-session", + None, + None, + false, + ) + .await; + assert_eq!(browser.json["user"]["id"], "member"); + assert_eq!( + f.redeem(&request, &cookie).await.status, + StatusCode::CONFLICT + ); +} + +#[tokio::test] +async fn approval_requires_member_browser_and_csrf_and_is_single_use() { + let f = Fixture::new(); + let (request, _) = f.start().await; + let body = json!({"request":request["request"],"approve":true}); + for (cookie, csrf, bearer) in [ + ("", None, false), + ("hf_session=browser-session", None, false), + ("hf_session=admin-session", Some("csrf-admin"), false), + ("hf_session=browser-session", Some("csrf-member"), true), + ] { + let r = f + .request( + "POST", + "/api/auth/app/decide", + body.clone(), + cookie, + Some("http://localhost:3000"), + csrf, + bearer, + ) + .await; + assert!( + matches!(r.status, StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED), + "{}", + r.body + ); + } + assert_eq!(f.approve(&request, true).await.status, StatusCode::OK); + assert_eq!( + f.approve(&request, true).await.status, + StatusCode::BAD_REQUEST + ); +} + +#[tokio::test] +async fn revoked_or_suspended_approver_cannot_be_transferred() { + for suspend in [false, true] { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + assert_eq!(f.approve(&request, true).await.status, StatusCode::OK); + if suspend { + f.state + .conn() + .unwrap() + .execute("UPDATE users SET banned=1 WHERE id='member'", []) + .unwrap(); + } else { + f.state + .conn() + .unwrap() + .execute( + "DELETE FROM sessions WHERE token_hash=?1", + [auth::hash_token("browser-session")], + ) + .unwrap(); + } + let r = f.redeem(&request, &cookie).await; + assert_eq!(r.status, StatusCode::UNAUTHORIZED); + assert!(!r.headers.contains_key("set-cookie")); + } +} + +#[tokio::test] +async fn expiration_denial_and_cancellation_do_not_issue_sessions() { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + f.state + .conn() + .unwrap() + .execute("UPDATE app_login_grants SET expires_at=?1", [now() - 1]) + .unwrap(); + assert_eq!( + f.approve(&request, true).await.status, + StatusCode::BAD_REQUEST + ); + assert_eq!( + f.redeem(&request, &cookie).await.status, + StatusCode::UNAUTHORIZED + ); + let (request, cookie) = f.start().await; + assert_eq!(f.approve(&request, false).await.status, StatusCode::OK); + let denied = f.redeem(&request, &cookie).await; + assert_eq!(denied.json["status"], "denied"); + assert!( + !denied + .headers + .get_all("set-cookie") + .iter() + .any(|v| v.to_str().unwrap().starts_with("hf_session=")) + ); + let (request, cookie) = f.start().await; + let cancel = f + .post( + "/api/auth/app/cancel", + json!({"request":request["request"]}), + &cookie, + ) + .await; + assert_eq!(cancel.status, StatusCode::OK); + assert_eq!( + f.redeem(&request, &cookie).await.status, + StatusCode::UNAUTHORIZED + ); +} + +#[tokio::test] +async fn concurrent_redemptions_create_exactly_one_session() { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + assert_eq!(f.approve(&request, true).await.status, StatusCode::OK); + let (a, b) = tokio::join!(f.redeem(&request, &cookie), f.redeem(&request, &cookie)); + assert_eq!( + [a.status, b.status] + .iter() + .filter(|s| **s == StatusCode::OK) + .count(), + 1 + ); + assert_eq!( + f.state + .conn() + .unwrap() + .query_row( + "SELECT count(*) FROM sessions WHERE user_id='member'", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 2 + ); +} + +#[tokio::test] +async fn cookie_from_another_request_cannot_redeem_or_cancel() { + let f = Fixture::new(); + let (a, ca) = f.start().await; + let (_, cb) = f.start().await; + f.post("/api/auth/app/cancel", json!({"request":a["request"]}), &cb) + .await; + assert_eq!(f.approve(&a, true).await.status, StatusCode::OK); + assert_eq!(f.redeem(&a, &cb).await.status, StatusCode::UNAUTHORIZED); + assert_eq!(f.redeem(&a, &ca).await.status, StatusCode::OK); +} + +#[tokio::test] +async fn consent_context_is_current_identity_and_sensitive_response_is_not_cached() { + let f = Fixture::new(); + let (request, _) = f.start().await; + let path = format!( + "/api/auth/app/context?request={}", + request["request"].as_str().unwrap() + ); + let anon = f + .request("GET", &path, Value::Null, "", None, None, false) + .await; + assert_eq!(anon.json["user"], Value::Null); + assert_eq!(anon.json["can_approve"], false); + let browser = f + .request( + "GET", + &path, + Value::Null, + "hf_session=browser-session", + None, + None, + false, + ) + .await; + assert_eq!(browser.json["user"]["email"], "member@example.invalid"); + assert_eq!(browser.json["comparison_code"], request["comparison_code"]); + assert_eq!(browser.headers["cache-control"], "no-store"); + assert_eq!(browser.json["can_approve"], true); + let admin = f + .request( + "GET", + &path, + Value::Null, + "hf_session=admin-session", + None, + None, + false, + ) + .await; + assert_eq!(admin.json["can_approve"], false); + assert_eq!(admin.json["admin_session"], true); +} + +#[tokio::test] +async fn google_return_is_bound_to_a_live_request_and_cancel_clears_cookie() { + let f = Fixture::new(); + let (request, _) = f.start().await; + let id = request["request"].as_str().unwrap(); + let start = f + .request( + "GET", + &format!("/api/auth/google/start?app_request={id}"), + Value::Null, + "", + None, + None, + false, + ) + .await; + assert_eq!(start.status, StatusCode::SEE_OTHER); + let cookie = cookie_value(&start, "hf_google_challenge"); + let state = cookie.split_once('=').unwrap().1; + let payload: String = f + .state + .conn() + .unwrap() + .query_row( + "SELECT payload FROM auth_challenges WHERE kind='google'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&payload).unwrap()["app_request"], + id + ); + let cancelled = f + .request( + "GET", + &format!("/auth/google/callback?state={state}&error=access_denied"), + Value::Null, + &cookie, + None, + None, + false, + ) + .await; + assert_eq!( + cancelled.headers["location"], + format!("/auth/app/authorize?request={id}") + ); + assert!( + cancelled.headers["set-cookie"] + .to_str() + .unwrap() + .contains("Max-Age=0") + ); + let bad = f + .request( + "GET", + "/api/auth/google/start?app_request=https%3A%2F%2Fevil.invalid", + Value::Null, + "", + None, + None, + false, + ) + .await; + assert_eq!(bad.status, StatusCode::BAD_REQUEST); + let none = f + .request( + "GET", + &format!("/api/auth/google/start?app_request={}", "x".repeat(43)), + Value::Null, + "", + None, + None, + false, + ) + .await; + assert_eq!(none.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn requests_are_bounded_and_unknown_redirect_fields_are_rejected() { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + let r = f + .post( + "/api/auth/app/redeem", + json!({"request":request["request"],"redirect":"https://evil.invalid"}), + &cookie, + ) + .await; + assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY); + f.state.conn().unwrap().execute("INSERT INTO auth_limits(key,window_start,count) VALUES('app-login-start',?1,100) ON CONFLICT(key) DO UPDATE SET count=100,window_start=excluded.window_start",[now()]).unwrap(); + let r = f.post("/api/auth/app/start", json!({}), "").await; + assert_eq!(r.status, StatusCode::TOO_MANY_REQUESTS); + // The existing initiating WebView can resume without consuming more slots. + assert_eq!( + f.post("/api/auth/app/start", json!({}), &cookie) + .await + .status, + StatusCode::OK + ); +} + +#[tokio::test] +async fn staff_and_manager_memberships_are_rechecked_at_approval_and_redemption() { + for manager in [false, true] { + for after_approval in [false, true] { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + if after_approval { + assert_eq!(f.approve(&request, true).await.status, StatusCode::OK); + } + if manager { + f.state.conn().unwrap().execute("INSERT INTO staff_members(user_id,role,joined_at,updated_at) VALUES('member','manager',?1,?1)",[now()]).unwrap(); + } else { + f.state.conn().unwrap().execute("INSERT INTO team_members(id,kind,user_id,name,created_by,created_at,updated_at) VALUES('employee','human','member','Employee','test',?1,?1)",[now()]).unwrap(); + } + if after_approval { + assert_eq!( + f.redeem(&request, &cookie).await.status, + StatusCode::FORBIDDEN + ); + } else { + let path = format!( + "/api/auth/app/context?request={}", + request["request"].as_str().unwrap() + ); + let ctx = f + .request( + "GET", + &path, + Value::Null, + "hf_session=browser-session", + None, + None, + false, + ) + .await; + assert_eq!(ctx.json["can_approve"], false); + assert_eq!(ctx.json["admin_session"], true); + assert_eq!( + f.approve(&request, true).await.status, + StatusCode::FORBIDDEN + ); + } + } + } +} + +#[tokio::test] +async fn transferred_session_does_not_refresh_authentication_age_or_extend_expiry() { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + let recent = now() - 1200; + let expires = now() + 300; + f.state + .conn() + .unwrap() + .execute( + "UPDATE sessions SET expires_at=?1,recent_auth_at=?2 WHERE user_id='member'", + params![expires, recent], + ) + .unwrap(); + assert_eq!(f.approve(&request, true).await.status, StatusCode::OK); + let completed = f.redeem(&request, &cookie).await; + let session = cookie_value(&completed, "hf_session"); + let hash = auth::hash_token(session.split_once('=').unwrap().1); + let times: (i64, i64) = f + .state + .conn() + .unwrap() + .query_row( + "SELECT expires_at,recent_auth_at FROM sessions WHERE token_hash=?1", + [hash], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(times, (expires, recent)); +} + +#[tokio::test] +async fn cancelling_a_committed_poll_revokes_only_its_new_app_session() { + for using_issued_cookie in [false, true] { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + assert_eq!(f.approve(&request, true).await.status, StatusCode::OK); + let completed = f.redeem(&request, &cookie).await; + let app_cookie = cookie_value(&completed, "hf_session"); + let cancel = f + .post( + "/api/auth/app/cancel", + json!({"request":request["request"]}), + if using_issued_cookie { + &app_cookie + } else { + &cookie + }, + ) + .await; + assert_eq!(cancel.status, StatusCode::OK, "{}", cancel.body); + let identity = f + .request( + "GET", + "/api/session", + Value::Null, + &app_cookie, + None, + None, + false, + ) + .await; + assert_eq!(identity.json["user"], Value::Null); + let source = f + .request( + "GET", + "/api/session", + Value::Null, + "hf_session=browser-session", + None, + None, + false, + ) + .await; + assert_eq!(source.json["user"]["id"], "member"); + assert_eq!( + f.redeem(&request, &cookie).await.status, + StatusCode::UNAUTHORIZED + ); + } +} + +#[tokio::test] +async fn concurrent_cancel_and_redeem_leave_no_app_session() { + let f = Fixture::new(); + let (request, cookie) = f.start().await; + assert_eq!(f.approve(&request, true).await.status, StatusCode::OK); + let body = json!({"request":request["request"]}); + let (cancel, _) = tokio::join!( + f.post("/api/auth/app/cancel", body, &cookie), + f.redeem(&request, &cookie) + ); + assert_eq!(cancel.status, StatusCode::OK); + assert_eq!( + f.state + .conn() + .unwrap() + .query_row( + "SELECT count(*) FROM sessions WHERE user_id='member'", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); +} + +#[test] +fn additive_login_schema_is_atomic_and_keeps_version_thirteen_on_retry() { + let f = Fixture::new(); + let db = f.state.conn().unwrap(); + db.execute_batch( + "DROP TABLE app_login_grants; CREATE TABLE app_login_expiry(blocker INTEGER);", + ) + .unwrap(); + assert!(hireferry::db::migrate(&db).is_err()); + let exists: bool = db + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='app_login_grants')", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + !exists, + "A failed index creation must roll back the feature table" + ); + assert_eq!( + db.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)) + .unwrap(), + 13 + ); + db.execute_batch("DROP TABLE app_login_expiry;").unwrap(); + hireferry::db::migrate(&db).unwrap(); + hireferry::db::migrate(&db).unwrap(); + assert_eq!( + db.query_row("SELECT count(*) FROM users WHERE id='member'", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + db.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)) + .unwrap(), + 13 + ); +} diff --git a/tests/helpers/app-login-browser-fixture.mjs b/tests/helpers/app-login-browser-fixture.mjs new file mode 100644 index 0000000..9d684cf --- /dev/null +++ b/tests/helpers/app-login-browser-fixture.mjs @@ -0,0 +1,94 @@ +// A real Rust server with a disposable database and one ordinary synthetic member. +// No repository config, production identity, SMTP or external authentication is used. +import { createServer } from 'node:net'; +import { spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const delay = milliseconds => new Promise(resolve_ => setTimeout(resolve_, milliseconds)); +const hash = value => createHash('sha256').update(value).digest('hex'); + +export async function startAppLoginFixture(root) { + const directory = await mkdtemp(join(tmpdir(), 'hireferry-app-login-')); + let child; + const running = () => child && child.exitCode === null && child.signalCode === null; + const close = async () => { + if (running()) { + const exited = new Promise(resolve_ => child.once('exit', resolve_)); + child.kill('SIGTERM'); + await Promise.race([exited, delay(2000)]); + if (running()) { child.kill('SIGKILL'); await exited; } + } + await rm(directory, { recursive: true, force: true }); + }; + try { + const listener = createServer(); + await new Promise((resolve_, reject) => { + listener.once('error', reject); + listener.listen(0, '127.0.0.1', resolve_); + }); + const port = listener.address().port; + await new Promise(resolve_ => listener.close(resolve_)); + const origin = `http://localhost:${port}`, database = join(directory, 'fixture.sqlite3'); + const config = join(directory, 'fixture.toml'); + await writeFile(config, `bind = "127.0.0.1:${port}" +public_url = "${origin}" +webauthn_rp_id = "localhost" +database = ${JSON.stringify(database)} +development = true +session_hours = 24 +[site] +name = "HireFerry · Synthetic app-login acceptance" +default_language = "zh" +[google] +enabled = false +[smtp] +enabled = false +[webhooks] +enabled = false +`, { mode: 0o600 }); + child = spawn(process.env.HFY_SERVER_BINARY || resolve(root, 'target/debug/hireferry'), ['--config', config, 'serve'], { + cwd: directory, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, RUST_LOG: 'error' } + }); + let output = '', failure; + child.on('error', error => { failure = error; }); + child.stdout.on('data', value => { output = (output + value).slice(-6000); }); + child.stderr.on('data', value => { output = (output + value).slice(-6000); }); + let ready = false; + for (let attempt = 0; attempt < 150; attempt++) { + if (failure || !running()) throw failure || new Error(output || 'Fixture server exited'); + try { ready = (await fetch(`${origin}/healthz`, { signal: AbortSignal.timeout(1000) })).ok; } catch { /* Starting. */ } + if (ready) break; + await delay(100); + } + if (!ready) throw new Error('Fixture server did not become ready: ' + output); + + const member = { id: 'app-login-member', email: 'app-login-member@example.invalid', name: 'Synthetic member' }; + const sourceSession = randomBytes(32).toString('hex'), sourceCsrf = randomBytes(24).toString('hex'); + const now = Math.floor(Date.now() / 1000), sourceExpires = now + 3600, sourceRecent = now - 600; + const db = new DatabaseSync(database); + try { + db.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; BEGIN;'); + db.prepare('INSERT INTO users(id,email,name,role,created_at,updated_at) VALUES(?,?,?,?,?,?)') + .run(member.id, member.email, member.name, 'seeker', now, now); + db.prepare('INSERT INTO sessions(token_hash,user_id,admin,bootstrap,csrf,expires_at,recent_auth_at) VALUES(?,?,0,0,?,?,?)') + .run(hash(sourceSession), member.id, sourceCsrf, sourceExpires, sourceRecent); + db.exec('COMMIT;'); + } finally { db.close(); } + function read(sql, ...parameters) { + const connection = new DatabaseSync(database, { readOnly: true }); + try { connection.exec('PRAGMA busy_timeout=5000;'); return connection.prepare(sql).get(...parameters); } + finally { connection.close(); } + } + return { + origin, member, sourceSession, sourceCsrf, sourceExpires, sourceRecent, close, + session: raw => read('SELECT token_hash,user_id,admin,bootstrap,expires_at,recent_auth_at FROM sessions WHERE token_hash=?', hash(raw)), + grant: request => read('SELECT status,issued_session_hash FROM app_login_grants WHERE request_id=?', request), + sessionCount: () => read('SELECT COUNT(*) AS count FROM sessions WHERE user_id=?', member.id).count, + userCount: () => read('SELECT COUNT(*) AS count FROM users').count + }; + } catch (error) { await close(); throw error; } +}