diff --git a/apps/self-hosted/hosting/api/src/routes/auth-handoff.test.ts b/apps/self-hosted/hosting/api/src/routes/auth-handoff.test.ts new file mode 100644 index 0000000000..76985e7e14 --- /dev/null +++ b/apps/self-hosted/hosting/api/src/routes/auth-handoff.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + handoffSet: vi.fn(), + handoffConsume: vi.fn(), + handoffCountMint: vi.fn(), + challengeStore: { set: vi.fn(), get: vi.fn(), delete: vi.fn() }, + auditLog: vi.fn(), + fetch: vi.fn(), +})); + +vi.mock('../utils/redis', () => ({ + challengeStore: mocks.challengeStore, + handoffStore: { + set: mocks.handoffSet, + consume: mocks.handoffConsume, + countMint: mocks.handoffCountMint, + }, +})); +vi.mock('@ecency/sdk/hive', () => ({ + callRPC: vi.fn(), + config: {}, +})); +vi.mock('../services/tenant-service', () => ({ TenantService: {} })); +vi.mock('../services/audit-service', () => ({ + AuditService: { log: (...args: unknown[]) => mocks.auditLog(...args) }, + parseClientIp: () => '203.0.113.9', +})); +vi.mock('../utils/auth', () => ({ + createToken: vi.fn(() => 'jwt'), + getTokenExpiry: vi.fn(() => new Date()), + verifyToken: vi.fn(), + verifyChallengeSignature: vi.fn(), +})); + +const { authRoutes } = await import('./auth'); + +const post = (path: string, body: Record) => + authRoutes.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + +const ME_OK = { + ok: true, + status: 200, + json: async () => ({ account: { name: 'alice' } }), +}; + +describe('handoff mint and exchange', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', mocks.fetch); + mocks.fetch.mockResolvedValue(ME_OK); + mocks.handoffSet.mockResolvedValue(undefined); + mocks.handoffCountMint.mockResolvedValue(1); + }); + + it('mints a one-time code for the account the token belongs to', async () => { + const response = await post('/handoff', { accessToken: 'a'.repeat(32) }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + code: string; + username: string; + expiresAt: string; + }; + expect(body.username).toBe('alice'); + expect(body.code.length).toBeGreaterThanOrEqual(21); + // The stored payload carries the token and the /me-derived identity, with + // the short TTL that makes a captured link worthless in minutes. + expect(mocks.handoffSet).toHaveBeenCalledWith( + body.code, + { accessToken: 'a'.repeat(32), username: 'alice' }, + 300, + ); + // Neither the code nor the token reaches the audit trail. + expect(JSON.stringify(mocks.auditLog.mock.calls)).not.toContain(body.code); + expect(JSON.stringify(mocks.auditLog.mock.calls)).not.toContain('a'.repeat(32)); + }); + + it('caps minting per account inside the window', async () => { + mocks.handoffCountMint.mockResolvedValue(11); + const response = await post('/handoff', { accessToken: 'a'.repeat(32) }); + expect(response.status).toBe(429); + expect(mocks.handoffSet).not.toHaveBeenCalled(); + }); + + it('refuses to mint for a token HiveSigner rejects', async () => { + mocks.fetch.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }); + const response = await post('/handoff', { accessToken: 'b'.repeat(32) }); + expect(response.status).toBe(401); + expect(mocks.handoffSet).not.toHaveBeenCalled(); + }); + + it('exchanges a live code exactly once through the consuming read', async () => { + mocks.handoffConsume.mockResolvedValueOnce({ + accessToken: 'tok-alice', + username: 'alice', + }); + const response = await post('/handoff/exchange', { code: 'c'.repeat(32) }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + accessToken: 'tok-alice', + username: 'alice', + }); + expect(mocks.handoffConsume).toHaveBeenCalledWith('c'.repeat(32)); + }); + + it('404s a missing, expired or already-used code', async () => { + mocks.handoffConsume.mockResolvedValue(null); + const response = await post('/handoff/exchange', { code: 'd'.repeat(32) }); + expect(response.status).toBe(404); + }); +}); diff --git a/apps/self-hosted/hosting/api/src/routes/auth.ts b/apps/self-hosted/hosting/api/src/routes/auth.ts index 8b6a4e3f3d..d66705ad78 100644 --- a/apps/self-hosted/hosting/api/src/routes/auth.ts +++ b/apps/self-hosted/hosting/api/src/routes/auth.ts @@ -16,7 +16,7 @@ import { verifyToken, getTokenExpiry, } from '../utils/auth'; -import { challengeStore } from '../utils/redis'; +import { challengeStore, handoffStore } from '../utils/redis'; import { AuditService, parseClientIp } from '../services/audit-service'; export const authRoutes = new Hono(); @@ -130,40 +130,60 @@ const hivesignerLoginSchema = z.object({ accessToken: z.string().min(16).max(4096), }); +/** + * Who a HiveSigner access token belongs to, asked from HiveSigner itself. + * Shared by the login exchange and the handoff mint: identity is never taken + * from the caller, only from the token. + */ +async function resolveHivesignerUsername( + accessToken: string, +): Promise< + | { ok: true; username: string } + | { ok: false; status: 401 | 503; error: string } +> { + let res: Response; + try { + res = await fetch('https://hivesigner.com/api/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(8000), + }); + } catch { + return { ok: false, status: 503, error: 'Auth service unavailable' }; + } + + if (res.status === 401 || res.status === 403) { + return { ok: false, status: 401, error: 'Invalid or expired HiveSigner token' }; + } + if (!res.ok) { + return { ok: false, status: 503, error: 'Auth service unavailable' }; + } + + let username: unknown; + try { + const data = (await res.json()) as any; + username = data?.account?.name ?? data?.user; + } catch { + return { ok: false, status: 503, error: 'Auth service unavailable' }; + } + + if (typeof username !== 'string' || !/^[a-z][a-z0-9.-]{2,15}$/.test(username)) { + return { ok: false, status: 401, error: 'Invalid or expired HiveSigner token' }; + } + + return { ok: true, username }; +} + authRoutes.post( '/hivesigner', zValidator('json', hivesignerLoginSchema), async (c) => { const { accessToken } = c.req.valid('json'); - let res: Response; - try { - res = await fetch('https://hivesigner.com/api/me', { - headers: { Authorization: `Bearer ${accessToken}` }, - signal: AbortSignal.timeout(8000), - }); - } catch { - return c.json({ error: 'Auth service unavailable' }, 503); - } - - if (res.status === 401 || res.status === 403) { - return c.json({ error: 'Invalid or expired HiveSigner token' }, 401); - } - if (!res.ok) { - return c.json({ error: 'Auth service unavailable' }, 503); - } - - let username: unknown; - try { - const data = (await res.json()) as any; - username = data?.account?.name ?? data?.user; - } catch { - return c.json({ error: 'Auth service unavailable' }, 503); - } - - if (typeof username !== 'string' || !/^[a-z][a-z0-9.-]{2,15}$/.test(username)) { - return c.json({ error: 'Invalid or expired HiveSigner token' }, 401); + const resolved = await resolveHivesignerUsername(accessToken); + if (!resolved.ok) { + return c.json({ error: resolved.error }, resolved.status); } + const { username } = resolved; const expiresInMs = 24 * 60 * 60 * 1000; // 24 hours const token = createToken(username, expiresInMs); @@ -184,6 +204,86 @@ authRoutes.post( } ); +// POST /v1/auth/handoff - Mint a one-time short-TTL handoff code for the +// signup session carry-over. The success screen used to put the bearer itself +// in the Customize link's fragment; a captured link then stayed a live +// credential until upstream expiry. A code is worthless after one exchange or +// five minutes, whichever comes first. Identity comes from the token, never +// the caller, exactly like the login exchange above. +const HANDOFF_TTL_SECONDS = 5 * 60; + +authRoutes.post( + '/handoff', + zValidator('json', hivesignerLoginSchema), + async (c) => { + const { accessToken } = c.req.valid('json'); + + const resolved = await resolveHivesignerUsername(accessToken); + if (!resolved.ok) { + return c.json({ error: resolved.error }, resolved.status); + } + const { username } = resolved; + + // Per-account cap beside the per-IP limits: a leaked token must not + // become an unbounded code mill, and legitimate use is one code per + // success screen plus a slow refresh. + const mintCount = await handoffStore.countMint(username); + if (mintCount > 10) { + return c.json({ error: 'Too many handoff requests' }, 429); + } + + const code = nanoid(32); + await handoffStore.set(code, { accessToken, username }, HANDOFF_TTL_SECONDS); + + // The code (a capability) and the token never reach the audit trail. + void AuditService.log({ + eventType: 'auth.handoff_minted', + eventData: { username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + + return c.json({ + code, + username, + expiresAt: new Date(Date.now() + HANDOFF_TTL_SECONDS * 1000).toISOString(), + }); + }, +); + +// POST /v1/auth/handoff/exchange - Trade the code for the carried session, +// exactly once: the read deletes. The instance still applies its own owner +// gate to whatever comes back; this endpoint only shortens how long anything +// secret exists inside a URL. +const handoffExchangeSchema = z.object({ + code: z.string().min(16).max(128), +}); + +authRoutes.post( + '/handoff/exchange', + zValidator('json', handoffExchangeSchema), + async (c) => { + const { code } = c.req.valid('json'); + + const payload = await handoffStore.consume(code); + if (!payload) { + return c.json({ error: 'Invalid or expired handoff code' }, 404); + } + + void AuditService.log({ + eventType: 'auth.handoff_exchanged', + eventData: { username: payload.username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + + return c.json({ + accessToken: payload.accessToken, + username: payload.username, + }); + }, +); + // GET /v1/auth/me - Get current user info authRoutes.get('/me', async (c) => { const authHeader = c.req.header('Authorization'); diff --git a/apps/self-hosted/hosting/api/src/utils/redis.ts b/apps/self-hosted/hosting/api/src/utils/redis.ts index 87b113f76b..e1642ead54 100644 --- a/apps/self-hosted/hosting/api/src/utils/redis.ts +++ b/apps/self-hosted/hosting/api/src/utils/redis.ts @@ -110,4 +110,58 @@ export const challengeStore = { }, }; -export default { getRedisClient, challengeStore }; +/** + * One-time handoff codes for the signup session carry-over: the code in the + * URL is worthless after a single exchange or a few minutes, which is the + * whole point of minting it instead of putting the bearer in the link. + */ +export const handoffStore = { + async set( + code: string, + payload: { accessToken: string; username: string }, + ttlSeconds: number = 300 + ): Promise { + const client = await getRedisClient(); + await client.set(`auth:handoff:${code}`, JSON.stringify(payload), { + EX: ttlSeconds, + }); + }, + + /** + * Count a mint against the account inside a rolling minute, for the + * per-account cap that sits beside the per-IP limits: a stolen token must + * not become an unbounded code mill. + */ + async countMint(username: string, windowSeconds: number = 60): Promise { + const client = await getRedisClient(); + const key = `auth:handoff:mint:${username.toLowerCase()}`; + const count = await client.incr(key); + if (count === 1) { + await client.expire(key, windowSeconds); + } + return count; + }, + + /** Read AND delete atomically: a code can only ever be exchanged once. */ + async consume( + code: string + ): Promise<{ accessToken: string; username: string } | null> { + const client = await getRedisClient(); + const data = await client.getDel(`auth:handoff:${code}`); + if (!data) return null; + try { + const parsed = JSON.parse(data); + if ( + typeof parsed?.accessToken !== 'string' || + typeof parsed?.username !== 'string' + ) { + return null; + } + return parsed; + } catch { + return null; + } + }, +}; + +export default { getRedisClient, challengeStore, handoffStore }; diff --git a/apps/self-hosted/src/features/auth/setup-handoff.test.ts b/apps/self-hosted/src/features/auth/setup-handoff.test.ts index efe2f5be85..69dddff51f 100644 --- a/apps/self-hosted/src/features/auth/setup-handoff.test.ts +++ b/apps/self-hosted/src/features/auth/setup-handoff.test.ts @@ -279,6 +279,96 @@ describe('carried-session token handoff', () => { expect(resolve).not.toHaveBeenCalled(); }); + it('captures a #hc code, exchanges it once and owner-gates the result', async () => { + const { actOnTokenHandoff } = await import('./setup-handoff'); + window.history.replaceState(null, '', '/?setup=1#hc=code-abc'); + captureSetupParams(); + // The code is gone from the URL before anything renders... + expect(window.location.hash).toBe(''); + expect(isSetupPending()).toBe(true); + + const exchange = vi.fn(async () => ({ + accessToken: 'tok-from-exchange', + username: 'alice', + })); + const resolve = vi.fn(async () => 'alice'); + const context = { + isAuthEnabled: true, + isAuthenticated: false, + ownerUsername: 'alice', + exchangeCode: exchange, + resolveAccount: resolve, + }; + const user = await actOnTokenHandoff(context); + expect(exchange).toHaveBeenCalledWith('code-abc'); + // The exchanged session is re-resolved against /me by the instance + // itself: identity is never taken on the API's word alone. + expect(resolve).toHaveBeenCalledWith('tok-from-exchange'); + expect(user).toMatchObject({ + username: 'alice', + accessToken: 'tok-from-exchange', + loginType: 'hivesigner', + }); + // One attempt per page load: a replay observes the same outcome without + // a second exchange (the code is single-use server-side anyway). + expect(await actOnTokenHandoff(context)).toBe(user); + expect(exchange).toHaveBeenCalledTimes(1); + }); + + it('refuses an exchanged session for anyone but the owner', async () => { + const { actOnTokenHandoff } = await import('./setup-handoff'); + window.history.replaceState(null, '', '/#hc=code-mallory'); + captureSetupParams(); + expect( + await actOnTokenHandoff({ + isAuthEnabled: true, + isAuthenticated: false, + ownerUsername: 'alice', + exchangeCode: vi.fn(async () => ({ + accessToken: 'tok-m', + username: 'mallory', + })), + resolveAccount: vi.fn(async () => 'mallory'), + }), + ).toBeNull(); + }); + + it('refuses an exchange whose token does not verify as the claimed account', async () => { + const { actOnTokenHandoff } = await import('./setup-handoff'); + window.history.replaceState(null, '', '/#hc=code-forged'); + captureSetupParams(); + expect( + await actOnTokenHandoff({ + isAuthEnabled: true, + isAuthenticated: false, + ownerUsername: 'alice', + exchangeCode: vi.fn(async () => ({ + accessToken: 'tok-x', + username: 'alice', + })), + // /me says the token is somebody else's (or dead): refuse. + resolveAccount: vi.fn(async () => 'mallory'), + }), + ).toBeNull(); + }); + + it('survives a malformed #hc fragment: no code, but the URL is scrubbed', async () => { + const { actOnTokenHandoff } = await import('./setup-handoff'); + window.history.replaceState(null, '', '/#hc=%'); + expect(() => captureSetupParams()).not.toThrow(); + expect(window.location.hash).toBe(''); + const exchange = vi.fn(); + expect( + await actOnTokenHandoff({ + isAuthEnabled: true, + isAuthenticated: false, + ownerUsername: 'alice', + exchangeCode: exchange, + }), + ).toBeNull(); + expect(exchange).not.toHaveBeenCalled(); + }); + it('leaves a foreign fragment untouched', () => { window.history.replaceState(null, '', '/page#section-2'); captureSetupParams(); diff --git a/apps/self-hosted/src/features/auth/setup-handoff.ts b/apps/self-hosted/src/features/auth/setup-handoff.ts index 4b7b05883a..e55919b514 100644 --- a/apps/self-hosted/src/features/auth/setup-handoff.ts +++ b/apps/self-hosted/src/features/auth/setup-handoff.ts @@ -1,4 +1,5 @@ import { loginWithHivesigner } from './auth-actions'; +import { exchangeHandoffCode } from './utils/handoff-exchange'; import { resolveHivesignerAccount } from './utils/hivesigner'; import type { AuthUser } from './types'; @@ -36,6 +37,14 @@ export const LOGIN_PARAM = 'login'; */ let carriedToken: string | null = null; +/** + * The one-time handoff CODE, the successor to the bearer fragment: minted by + * the hosting API at click time on ecency.com and worthless after a single + * exchange or a few minutes. The bearer path above stays until every + * deployed ecency.com sends codes. + */ +let carriedCode: string | null = null; + /** The one attempt this page load makes at the carried session, shared so * every effect replay observes the same outcome (see actOnTokenHandoff). */ let handoffAttempt: Promise | null = null; @@ -43,6 +52,7 @@ let handoffAttempt: Promise | null = null; /** Test seam: module memory otherwise leaks between cases. */ export function resetCarriedToken(): void { carriedToken = null; + carriedCode = null; handoffAttempt = null; } @@ -76,6 +86,13 @@ export function captureSetupParams(): void { carriedToken = null; } hash = ''; + } else if (hash.startsWith('#hc=')) { + try { + carriedCode = decodeURIComponent(hash.slice(4)) || null; + } catch { + carriedCode = null; + } + hash = ''; } if (!wantsSetup && !wantsLogin && hash === window.location.hash) return; @@ -108,6 +125,10 @@ export interface TokenHandoffContext { ownerUsername: string | null | undefined; /** Injectable for tests; defaults to the real Hivesigner /me lookup. */ resolveAccount?: (token: string) => Promise; + /** Injectable for tests; defaults to the real hosting-API exchange. */ + exchangeCode?: ( + code: string, + ) => Promise<{ accessToken: string; username: string } | null>; } /** @@ -135,23 +156,49 @@ export function actOnTokenHandoff( if (handoffAttempt) return handoffAttempt; const token = carriedToken; - if (!token) return Promise.resolve(null); + const code = carriedCode; + if (!token && !code) return Promise.resolve(null); carriedToken = null; + carriedCode = null; handoffAttempt = (async (): Promise => { if (!context.isAuthEnabled || context.isAuthenticated) return null; - const account = await (context.resolveAccount ?? resolveHivesignerAccount)( - token, - ); - if (!account) return null; + let account: string | null = null; + let sessionToken: string | null = null; + + if (code) { + // The code path: one exchange at the hosting API returns the session + // and the identity the API resolved from Hivesigner AT MINT TIME. The + // instance still re-resolves the session against /me itself, so the + // identity it signs in was never taken on anyone else's word, and the + // owner gate below decides whether it may sign in here. + const exchanged = await (context.exchangeCode ?? exchangeHandoffCode)( + code, + ); + if (!exchanged) return null; + const verified = await (context.resolveAccount ?? resolveHivesignerAccount)( + exchanged.accessToken, + ); + if (!verified || verified.toLowerCase() !== exchanged.username.toLowerCase()) { + return null; + } + account = verified.toLowerCase(); + sessionToken = exchanged.accessToken; + } else if (token) { + account = await (context.resolveAccount ?? resolveHivesignerAccount)( + token, + ); + sessionToken = token; + } + if (!account || !sessionToken) return null; const owner = context.ownerUsername?.trim().toLowerCase(); if (!owner || account.toLowerCase() !== owner) return null; return { username: account, - accessToken: token, + accessToken: sessionToken, loginType: 'hivesigner', // The carried token's real TTL is not knowable here; a conservative day // keeps the session honest and the periodic expiry check in the provider diff --git a/apps/self-hosted/src/features/auth/utils/handoff-exchange.ts b/apps/self-hosted/src/features/auth/utils/handoff-exchange.ts new file mode 100644 index 0000000000..d0519a0259 --- /dev/null +++ b/apps/self-hosted/src/features/auth/utils/handoff-exchange.ts @@ -0,0 +1,43 @@ +/** + * Trade a one-time handoff code for the carried session. The code arrives in + * the Customize link's fragment from ecency.com's signup and is worthless + * after this single exchange or a few minutes, which is exactly why it is a + * code and not the bearer itself. Only managed instances ever receive one + * (the link is minted by the managed signup), so the managed API base is the + * right and only place to ask. + */ + +const HANDOFF_EXCHANGE_URL = + 'https://api.blogs.ecency.com/hosting/v1/auth/handoff/exchange'; +const EXCHANGE_TIMEOUT_MS = 15_000; + +export async function exchangeHandoffCode( + code: string, +): Promise<{ accessToken: string; username: string } | null> { + try { + const response = await fetch(HANDOFF_EXCHANGE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS), + }); + if (!response.ok) return null; + const data = (await response.json()) as { + accessToken?: unknown; + username?: unknown; + }; + if ( + typeof data.accessToken !== 'string' || + !data.accessToken || + typeof data.username !== 'string' || + !/^[a-z][a-z0-9.-]{2,15}$/.test(data.username) + ) { + return null; + } + return { accessToken: data.accessToken, username: data.username }; + } catch { + // Unreachable API or timeout: the owner lands logged out with the setup + // intent still pending, same as every other failed handoff. + return null; + } +} diff --git a/apps/web/src/features/hosting-signup/hosting-api.ts b/apps/web/src/features/hosting-signup/hosting-api.ts index beb9e06b66..671f4eb3b7 100644 --- a/apps/web/src/features/hosting-signup/hosting-api.ts +++ b/apps/web/src/features/hosting-signup/hosting-api.ts @@ -168,6 +168,14 @@ export const hostingApi = { authHivesigner: (accessToken: string) => post("/v1/auth/hivesigner", { accessToken }), + /** Mint a one-time short-TTL handoff code for the signup session carry-over: the code goes in + * the Customize link's fragment instead of the bearer, so a captured URL is worthless after + * one exchange or a few minutes. */ + mintHandoff: (accessToken: string) => + post<{ code: string; username: string; expiresAt: string }>("/v1/auth/handoff", { + accessToken + }), + /** Keychain rail: fetch a challenge to sign with the posting key... */ authChallenge: (username: string) => post<{ username: string; challenge: string; expiresAt: string }>("/v1/auth/challenge", { diff --git a/apps/web/src/features/hosting-signup/hosting-signup.tsx b/apps/web/src/features/hosting-signup/hosting-signup.tsx index d8ee4da578..96f25ac63b 100644 --- a/apps/web/src/features/hosting-signup/hosting-signup.tsx +++ b/apps/web/src/features/hosting-signup/hosting-signup.tsx @@ -399,26 +399,51 @@ export function HostingSignup() { } }, [tenantUsername, isCommunity, title, description, styleTemplate, accent, fontPreset, activeUser]); - // Resolve the session token as soon as the success screen shows: the - // Customize link carries it over to the new instance at CLICK time (see the - // anchor's onClick), and a long-lived login's stored token may be stale. - // Only a token that survived ensureValidToken is ever carried; while the - // refresh is in flight, or if it fails, the click takes the credential-free - // fallback href instead of shipping a stale bearer the instance would - // reject. - const [handoffToken, setHandoffToken] = useState(null); + // Mint a one-time handoff CODE as soon as the success screen shows: the + // Customize link used to carry the session bearer itself in its fragment, + // which left a captured link a live credential until upstream expiry. The + // hosting API now stores the ensureValidToken-resolved token behind a code + // that dies on first exchange or in five minutes, so the URL artifact is + // worthless afterwards. Re-minted on an interval while the screen stays + // open (codes outlive nobody's coffee break); a failed mint leaves the + // click on the credential-free fallback href. + const [handoff, setHandoff] = useState<{ code: string; expiresAt: number } | null>( + null + ); + // Bumped by a click: the opened code is consumed by the instance, so the + // click clears it and this forces a fresh mint for any further click. + const [mintNonce, setMintNonce] = useState(0); useEffect(() => { + // Whatever code exists belongs to the PREVIOUS context (another account, + // a logout, an earlier screen) and must never ride the button into this + // one: cleared before anything else, minted fresh below if eligible. + setHandoff(null); if (step !== "success" || !activeUser) return; let cancelled = false; - ensureValidToken(activeUser.username) - .then((token) => { - if (!cancelled && token) setHandoffToken(token); - }) - .catch(() => {}); + const mint = async () => { + try { + const token = await ensureValidToken(activeUser.username); + if (!token || cancelled) return; + const minted = await hostingApi.mintHandoff(token); + if (!cancelled) { + setHandoff({ + code: minted.code, + // The server's word on the TTL; an unparseable answer counts as + // already stale rather than forever fresh. + expiresAt: Date.parse(minted.expiresAt) || 0 + }); + } + } catch { + if (!cancelled) setHandoff(null); + } + }; + mint(); + const timer = setInterval(mint, 4 * 60 * 1000); return () => { cancelled = true; + clearInterval(timer); }; - }, [step, activeUser]); + }, [step, activeUser, mintNonce]); // The reservation is made and paid for; the in-progress draft has served its purpose. useEffect(() => { @@ -1057,15 +1082,35 @@ export function HostingSignup() { rel="noreferrer" onClick={(e) => { // Synchronous within the gesture, so popup blockers allow the - // open. Only the mount-resolved token is carried; without it - // the default navigation takes the fallback href. - if (!handoffToken) return; + // open. Only the minted one-time code travels in the URL, + // never the bearer; without a code the default navigation + // takes the fallback href. The opened URL keeps the OAuth + // fallback param, so a code the instance cannot exchange + // still lands a Hivesigner owner in a login flow. The click + // consumes the code (the instance's exchange deletes it), so + // it is cleared here and a fresh one is minted for any + // further click. + if (!handoff) return; + // A code past (or within thirty seconds of) its server TTL + // would exchange as dead: take the fallback navigation and + // mint a replacement instead of opening it. + if (handoff.expiresAt - 30_000 < Date.now()) { + setHandoff(null); + setMintNonce((n) => n + 1); + return; + } e.preventDefault(); + const loginParam = + activeUser && getLoginType(activeUser.username) === "hivesigner" + ? "&login=hivesigner" + : ""; window.open( - `${safeBlogUrl}?setup=1#hs=${encodeURIComponent(handoffToken)}`, + `${safeBlogUrl}?setup=1${loginParam}#hc=${encodeURIComponent(handoff.code)}`, "_blank", "noopener,noreferrer" ); + setHandoff(null); + setMintNonce((n) => n + 1); }} className="inline-block text-center px-4 py-3 rounded-lg bg-blue-dark-sky text-white font-semibold hover:opacity-90" > diff --git a/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx b/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx index 1b13582034..263eeaf922 100644 --- a/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx +++ b/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx @@ -18,7 +18,8 @@ const mocks = vi.hoisted(() => ({ createTenant: vi.fn(), paymentInstructions: vi.fn(), tenant: vi.fn(), - tenantsByOwner: vi.fn() + tenantsByOwner: vi.fn(), + mintHandoff: vi.fn() } })); const { mutateAsync, hostingApi } = mocks; @@ -61,6 +62,11 @@ describe("HostingSignup one-click HBD pay", () => { window.history.replaceState(null, "", "/"); // clear any ?resume= from a prior test mocks.authLoginType = "keychain"; mocks.accessToken = "tok-alice"; + hostingApi.mintHandoff.mockResolvedValue({ + code: "hand-off-code-1234567890abcdef", + username: "alice", + expiresAt: new Date(Date.now() + 300000).toISOString() + }); // Card disabled so the payment step defaults to the HBD rail (where the one-click lives). hostingApi.paymentMethods.mockResolvedValue({ hbd: { enabled: true, monthly: "2.000", account: "ecency.hosting" }, @@ -179,21 +185,81 @@ describe("HostingSignup one-click HBD pay", () => { const customize = screen.getByText("hosting.customize-your-blog") as HTMLAnchorElement; expect(customize.getAttribute("href")).toBe("https://alice.blogs.ecency.com/?setup=1"); const open = vi.spyOn(window, "open").mockImplementation(() => null); - // The carried token resolves asynchronously on success-screen mount - // (ensureValidToken), so retry the click until the state lands. + // Let the mount mint land, then make any FURTHER mint hang: the re-mint + // a click triggers must not hand the second click a code again. + await waitFor(() => expect(hostingApi.mintHandoff).toHaveBeenCalledWith("tok-alice")); + hostingApi.mintHandoff.mockReturnValue(new Promise(() => {})); + // The minted code resolves asynchronously into state, so retry the click + // until it lands. Only the one-time code travels in the URL; the bearer + // stays out of it entirely. await waitFor(() => { fireEvent.click(customize); expect(open).toHaveBeenCalledWith( - "https://alice.blogs.ecency.com/?setup=1#hs=tok-alice", + "https://alice.blogs.ecency.com/?setup=1#hc=hand-off-code-1234567890abcdef", "_blank", "noopener,noreferrer" ); }); + + // The click consumed the code (the instance's exchange deletes it): an + // immediate second click must not replay it, only re-mint for later. + open.mockClear(); + fireEvent.click(customize); + expect(open).not.toHaveBeenCalled(); // The tokened URL replaced the default navigation; the clean href never // gained the fragment. expect(customize.getAttribute("href")).toBe("https://alice.blogs.ecency.com/?setup=1"); }); + it("never opens a code past its server TTL", async () => { + // A stalled refresh must not leave a dead code clickable: the click takes + // the fallback navigation and mints a replacement instead. + hostingApi.mintHandoff.mockResolvedValue({ + code: "expired-code", + username: "alice", + expiresAt: new Date(Date.now() - 1000).toISOString() + }); + hostingApi.tenantsByOwner.mockResolvedValue({ tenants: [] }); + renderWithQueryClient(); + fireEvent.click(screen.getByText("g.continue")); + fireEvent.click(await screen.findByText("g.continue")); + const payBtn = (await screen.findByRole("button", { + name: "hosting.pay-hbd-oneclick" + })) as HTMLButtonElement; + await waitFor(() => expect(payBtn.disabled).toBe(false)); + fireEvent.click(payBtn); + await screen.findByText("hosting.success-title"); + await waitFor(() => expect(hostingApi.mintHandoff).toHaveBeenCalled()); + + const customize = screen.getByText("hosting.customize-your-blog") as HTMLAnchorElement; + const open = vi.spyOn(window, "open").mockImplementation(() => null); + fireEvent.click(customize); + expect(open).not.toHaveBeenCalled(); + }); + + it("falls back to the credential-free href when minting fails", async () => { + // The hosting API is down: no code means the default navigation, never a + // bearer smuggled back into the URL as a substitute. + hostingApi.mintHandoff.mockRejectedValue(new Error("api down")); + hostingApi.tenantsByOwner.mockResolvedValue({ tenants: [] }); + renderWithQueryClient(); + fireEvent.click(screen.getByText("g.continue")); + fireEvent.click(await screen.findByText("g.continue")); + const payBtn = (await screen.findByRole("button", { + name: "hosting.pay-hbd-oneclick" + })) as HTMLButtonElement; + await waitFor(() => expect(payBtn.disabled).toBe(false)); + fireEvent.click(payBtn); + await screen.findByText("hosting.success-title"); + await waitFor(() => expect(hostingApi.mintHandoff).toHaveBeenCalled()); + + const customize = screen.getByText("hosting.customize-your-blog") as HTMLAnchorElement; + expect(customize.getAttribute("href")).toBe("https://alice.blogs.ecency.com/?setup=1"); + const open = vi.spyOn(window, "open").mockImplementation(() => null); + fireEvent.click(customize); + expect(open).not.toHaveBeenCalled(); + }); + it("falls back to a plain setup intent when no access token is stored", async () => { mocks.accessToken = undefined; hostingApi.tenantsByOwner.mockResolvedValue({ tenants: [] });