-
Notifications
You must be signed in to change notification settings - Fork 7
Hosting: one-time exchange code for the signup session handoff #1459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
d279397
bcec696
948d5ac
5efa5c0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| const mocks = vi.hoisted(() => ({ | ||
| handoffSet: vi.fn(), | ||
| handoffConsume: 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 }, | ||
| })); | ||
| 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<string, unknown>) => | ||
| 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); | ||
| }); | ||
|
|
||
| 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('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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,78 @@ 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; | ||
|
|
||
| const code = nanoid(32); | ||
| await handoffStore.set(code, { accessToken, username }, HANDOFF_TTL_SECONDS); | ||
|
Comment on lines
+221
to
+236
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. No per-account handoff limit The new /v1/auth/handoff mint endpoint is only covered by existing per-IP rate limiting, with no additional rate limit keyed by the resolved account. This violates the requirement to rate limit minting by both account and IP, and enables high-volume minting spread across many accounts behind one IP (or vice versa). Agent Prompt
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed and fixed in 948d5ac: minting now counts against the resolved account in a rolling minute (Redis INCR with expiry) beside the per-IP limits, capped well above legitimate use (one code per success screen plus a slow refresh). Route test covers the cap. |
||
|
|
||
| // 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(), | ||
| }); | ||
| }, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // 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'); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.