From feac3d074134fcfe59cee54ff94e8df1ea40f800 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 25 Jun 2026 08:45:36 -0500 Subject: [PATCH] fix: handle reauthenticating to service entry accounts Signed-off-by: Trezy Signed-off-by: Trezy --- src/admin/service_entries.rs | 52 ++++++++++-- src/service_identity.rs | 21 +++-- src/setup.rs | 52 ++++++++++-- .../settings/service-identity/page.tsx | 81 ++++++++++++++++++- web/src/lib/api.ts | 22 ++++- web/tests/e2e/auth-helper.ts | 24 ++++++ .../e2e/service-identity-settings.spec.ts | 23 +++++- 7 files changed, 255 insertions(+), 20 deletions(-) diff --git a/src/admin/service_entries.rs b/src/admin/service_entries.rs index abf24e3..90a05c8 100644 --- a/src/admin/service_entries.rs +++ b/src/admin/service_entries.rs @@ -17,6 +17,19 @@ use crate::service_identity::IdentityMode; use super::auth::UserAuth; use super::permissions::Permission; +fn is_pds_session_expired(err: &impl std::fmt::Display) -> bool { + let msg = err.to_string(); + msg.contains("invalid_token") || msg.contains("expired") || msg.contains("revoked") +} + +fn pds_reauth_error() -> AppError { + AppError::Auth( + "Your PDS session has expired or been revoked. \ + Use the Re-authenticate button on the Service Identity page to sign in again." + .into(), + ) +} + /// GET /admin/service-entries — list all service entries. pub(super) async fn list( State(state): State, @@ -307,7 +320,14 @@ pub(super) async fn sync_plc_request( } }; - let session = crate::repo::session::get_oauth_session(&state, &account_did).await?; + let session = crate::repo::session::get_oauth_session(&state, &account_did) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + e + })?; let agent = Agent::new(session); agent @@ -317,7 +337,12 @@ pub(super) async fn sync_plc_request( .identity .request_plc_operation_signature() .await - .map_err(|e| AppError::Internal(format!("requestPlcOperationSignature failed: {e}")))?; + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("requestPlcOperationSignature failed: {e}")) + })?; Ok(StatusCode::NO_CONTENT) } @@ -359,7 +384,14 @@ pub(super) async fn sync_plc_submit( } }; - let session = crate::repo::session::get_oauth_session(&state, &account_did).await?; + let session = crate::repo::session::get_oauth_session(&state, &account_did) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + e + })?; let agent = Agent::new(session); // Fetch current PLC operation state @@ -442,7 +474,12 @@ pub(super) async fn sync_plc_submit( .into(), ) .await - .map_err(|e| AppError::Internal(format!("signPlcOperation failed: {e}")))?; + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("signPlcOperation failed: {e}")) + })?; // Submit the signed operation use atrium_api::com::atproto::identity::submit_plc_operation; @@ -458,7 +495,12 @@ pub(super) async fn sync_plc_submit( .into(), ) .await - .map_err(|e| AppError::Internal(format!("submitPlcOperation failed: {e}")))?; + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("submitPlcOperation failed: {e}")) + })?; log_event( &state.db, diff --git a/src/service_identity.rs b/src/service_identity.rs index 342de75..b638be9 100644 --- a/src/service_identity.rs +++ b/src/service_identity.rs @@ -38,6 +38,7 @@ pub struct ServiceIdentity { pub mode: IdentityMode, pub did: Option, pub signing_key_enc: Option, + pub attached_account_did: Option, pub setup_complete: bool, pub created_at: String, pub updated_at: String, @@ -51,7 +52,15 @@ pub struct SetupStatus { pub setup_complete: bool, } -type ServiceIdentityRow = (String, Option, Option, i32, String, String); +type ServiceIdentityRow = ( + String, + Option, + Option, + Option, + i32, + String, + String, +); fn parse_row(r: ServiceIdentityRow) -> Result { let mode = IdentityMode::parse(&r.0) @@ -60,9 +69,10 @@ fn parse_row(r: ServiceIdentityRow) -> Result { mode, did: r.1, signing_key_enc: r.2, - setup_complete: r.3 != 0, - created_at: r.4, - updated_at: r.5, + attached_account_did: r.3, + setup_complete: r.4 != 0, + created_at: r.5, + updated_at: r.6, }) } @@ -72,7 +82,7 @@ pub async fn get_identity( backend: DatabaseBackend, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT mode, did, signing_key_enc, CAST(setup_complete AS INTEGER), created_at, updated_at FROM service_identity WHERE id = 1", + "SELECT mode, did, signing_key_enc, attached_account_did, CAST(setup_complete AS INTEGER), created_at, updated_at FROM service_identity WHERE id = 1", backend, ); @@ -229,6 +239,7 @@ mod tests { mode, did: did.map(String::from), signing_key_enc: None, + attached_account_did: None, setup_complete: true, created_at: "2024-01-01".into(), updated_at: "2024-01-01".into(), diff --git a/src/setup.rs b/src/setup.rs index 3a83dbf..a8e1e19 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -18,6 +18,19 @@ use crate::event_log::{EventLog, Severity, log_event}; use crate::service_identity::{self, IdentityMode}; use crate::{AppState, error::AppError}; +fn is_pds_session_expired(err: &impl std::fmt::Display) -> bool { + let msg = err.to_string(); + msg.contains("invalid_token") || msg.contains("expired") || msg.contains("revoked") +} + +fn pds_reauth_error() -> AppError { + AppError::Auth( + "Your PDS session has expired or been revoked. \ + Use the Re-authenticate button on the Service Identity page to sign in again." + .into(), + ) +} + async fn require_setup_incomplete(state: &AppState) -> Result<(), AppError> { let status = service_identity::get_setup_status(&state.db, state.db_backend).await?; if status.setup_complete { @@ -265,7 +278,14 @@ async fn plc_request( }; // Restore OAuth session for the attached account - let session = crate::repo::session::get_oauth_session(&state, &account_did).await?; + let session = crate::repo::session::get_oauth_session(&state, &account_did) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + e + })?; let agent = Agent::new(session); // Request PLC operation signature — sends confirmation code to account's email @@ -276,7 +296,12 @@ async fn plc_request( .identity .request_plc_operation_signature() .await - .map_err(|e| AppError::Internal(format!("requestPlcOperationSignature failed: {e}")))?; + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("requestPlcOperationSignature failed: {e}")) + })?; Ok(StatusCode::NO_CONTENT) } @@ -310,7 +335,14 @@ async fn plc_submit( } }; - let session = crate::repo::session::get_oauth_session(&state, &account_did).await?; + let session = crate::repo::session::get_oauth_session(&state, &account_did) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + e + })?; let agent = Agent::new(session); // Fetch current PLC operation state @@ -387,7 +419,12 @@ async fn plc_submit( .into(), ) .await - .map_err(|e| AppError::Internal(format!("signPlcOperation failed: {e}")))?; + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("signPlcOperation failed: {e}")) + })?; // Submit the signed operation use atrium_api::com::atproto::identity::submit_plc_operation; @@ -403,7 +440,12 @@ async fn plc_submit( .into(), ) .await - .map_err(|e| AppError::Internal(format!("submitPlcOperation failed: {e}")))?; + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("submitPlcOperation failed: {e}")) + })?; // Update service_identity with the account's DID service_identity::upsert_identity( diff --git a/web/src/app/dashboard/settings/service-identity/page.tsx b/web/src/app/dashboard/settings/service-identity/page.tsx index b4bd413..ddbb26b 100644 --- a/web/src/app/dashboard/settings/service-identity/page.tsx +++ b/web/src/app/dashboard/settings/service-identity/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { AlertTriangle, HelpCircle, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; +import { AlertTriangle, HelpCircle, KeyRound, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { useCurrentUser } from "@/hooks/use-current-user"; @@ -16,6 +16,7 @@ import { syncPlc, syncPlcRequest, syncPlcSubmit, + confirmAttachAuth, type ServiceIdentityResponse, type ServiceEntry, } from "@/lib/api"; @@ -67,6 +68,8 @@ import { } from "@/components/ui/tooltip"; const SYNC_STORAGE_KEY = "happyview:service-identity:last-synced-at"; +const REAUTH_STORAGE_KEY = "happyview:service-identity:reauth"; +const REAUTH_MAX_AGE_MS = 10 * 60 * 1000; const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone/.test(navigator.userAgent); const MOD_KEY = IS_MAC ? "⌘" : "Ctrl+"; const FRAGMENT_ID_RE = /^#?[a-zA-Z][a-zA-Z0-9_-]*$/; @@ -142,6 +145,7 @@ export default function ServiceIdentityPage() { const [sessionDirty, setSessionDirty] = useState(false); const [selected, setSelected] = useState>(new Set()); const [bulkDeleting, setBulkDeleting] = useState(false); + const [reauthing, setReauthing] = useState(false); const fragmentIdRef = useRef(null); @@ -210,6 +214,70 @@ export default function ServiceIdentityPage() { return () => window.removeEventListener("keydown", onKeyDown); }, [canManage]); + useEffect(() => { + const stored = localStorage.getItem(REAUTH_STORAGE_KEY); + if (!stored) return; + + let payload: { originalDid: string; timestamp?: number }; + try { + payload = JSON.parse(stored); + } catch { + localStorage.removeItem(REAUTH_STORAGE_KEY); + return; + } + + if (payload.timestamp && Date.now() - payload.timestamp > REAUTH_MAX_AGE_MS) { + localStorage.removeItem(REAUTH_STORAGE_KEY); + return; + } + + localStorage.removeItem(REAUTH_STORAGE_KEY); + setReauthing(true); + + confirmAttachAuth({ original_did: payload.originalDid }) + .then(() => { + toast.success("PDS session refreshed"); + load(); + }) + .catch((e) => { + toastError("Failed to restore admin session", e); + }) + .finally(() => setReauthing(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function handleReauthenticate() { + if (!identity?.attached_account_did) return; + setReauthing(true); + + fetch("/auth/me", { credentials: "same-origin" }) + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch current user"); + return res.json() as Promise<{ did: string }>; + }) + .then(({ did: originalDid }) => { + localStorage.setItem( + REAUTH_STORAGE_KEY, + JSON.stringify({ originalDid, timestamp: Date.now() }), + ); + return fetch( + `/auth/login?handle=${encodeURIComponent(identity.attached_account_did!)}&scope=${encodeURIComponent("atproto identity:*")}&redirect_uri=${encodeURIComponent("/dashboard/settings/service-identity")}`, + { credentials: "same-origin" }, + ); + }) + .then((resp) => { + if (!resp.ok) throw new Error("Login request failed"); + return resp.json() as Promise<{ url: string }>; + }) + .then(({ url }) => { + window.location.href = url; + }) + .catch((e) => { + toastError("Failed to start re-authentication", e); + setReauthing(false); + }); + } + const showSyncButton = canManage && identity && (identity.mode === "did_plc" || identity.mode === "attach_account"); @@ -507,6 +575,17 @@ export default function ServiceIdentityPage() { )} + {canManage && identity?.mode === "attach_account" && identity.attached_account_did && ( + + )} {showSyncButton && ( identity?.mode === "did_plc" ? ( needsSync ? ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f365c69..120b3db 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -110,7 +110,12 @@ async function apiFetch( if (!res.ok) { const text = await res.text().catch(() => res.statusText); - throw new ApiError(res.status, text); + let message = text; + try { + const parsed = JSON.parse(text); + if (typeof parsed.error === "string") message = parsed.error; + } catch { /* not JSON, use raw text */ } + throw new ApiError(res.status, message); } if (res.status === 204) return null as T; const text = await res.text(); @@ -344,7 +349,12 @@ export async function xrpcQuery( ); if (!res.ok) { const text = await res.text().catch(() => res.statusText); - throw new ApiError(res.status, text); + let message = text; + try { + const parsed = JSON.parse(text); + if (typeof parsed.error === "string") message = parsed.error; + } catch { /* not JSON, use raw text */ } + throw new ApiError(res.status, message); } return res.json(); } @@ -432,7 +442,12 @@ export async function uploadLogo(file: File) { }); if (!res.ok) { const text = await res.text().catch(() => res.statusText); - throw new ApiError(res.status, text); + let message = text; + try { + const parsed = JSON.parse(text); + if (typeof parsed.error === "string") message = parsed.error; + } catch { /* not JSON, use raw text */ } + throw new ApiError(res.status, message); } } @@ -841,6 +856,7 @@ export interface SetupStatus { export interface ServiceIdentityResponse { mode: string; did: string | null; + attached_account_did: string | null; setup_complete: boolean; created_at: string; updated_at: string; diff --git a/web/tests/e2e/auth-helper.ts b/web/tests/e2e/auth-helper.ts index 451100f..2cd74e5 100644 --- a/web/tests/e2e/auth-helper.ts +++ b/web/tests/e2e/auth-helper.ts @@ -72,6 +72,30 @@ export async function resetServiceIdentity(): Promise { } } +export async function setServiceIdentityMode( + mode: string, + opts?: { did?: string; attachedAccountDid?: string }, +): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + const now = new Date().toISOString() + await client.query( + `INSERT INTO service_identity (id, mode, did, attached_account_did, setup_complete, created_at, updated_at) + VALUES (1, $1, $2, $3, TRUE, $4, $4) + ON CONFLICT (id) DO UPDATE SET + mode = $1, + did = $2, + attached_account_did = $3, + setup_complete = TRUE, + updated_at = $4`, + [mode, opts?.did ?? null, opts?.attachedAccountDid ?? null, now], + ) + } finally { + await client.end() + } +} + export async function loginAsTestAdmin(page: Page): Promise { await ensureTestUser(TEST_DID) diff --git a/web/tests/e2e/service-identity-settings.spec.ts b/web/tests/e2e/service-identity-settings.spec.ts index 0a5a1a3..69f810d 100644 --- a/web/tests/e2e/service-identity-settings.spec.ts +++ b/web/tests/e2e/service-identity-settings.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test" -import { loginAsTestAdmin } from "./auth-helper" +import { loginAsTestAdmin, setServiceIdentityMode } from "./auth-helper" test.describe("Service Identity Settings", () => { test.beforeEach(async ({ page }) => { @@ -115,6 +115,27 @@ test.describe("Service Identity Settings", () => { await expect(page.getByText("#testentry", { exact: true })).not.toBeVisible({ timeout: 5000 }) }) + test("re-authenticate button visible in attach_account mode", async ({ page }) => { + await setServiceIdentityMode("attach_account", { + did: "did:plc:e2e-reauth-test", + attachedAccountDid: "did:plc:e2e-attached-account", + }) + + await page.reload() + await expect( + page.getByRole("button", { name: /re-authenticate/i }), + ).toBeVisible({ timeout: 5000 }) + + // Restore to did_web mode for subsequent tests + await setServiceIdentityMode("did_web", { did: "did:web:localhost" }) + }) + + test("re-authenticate button hidden in did_web mode", async ({ page }) => { + await expect( + page.getByRole("button", { name: /re-authenticate/i }), + ).not.toBeVisible({ timeout: 3000 }) + }) + test("change mode redirects to setup", async ({ page }) => { const changeModeButton = page.getByRole("button", { name: /change mode/i }) await expect(changeModeButton).toBeVisible({ timeout: 5000 }) -- 2.51.2