Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/auth-handoff.test.ts
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);
});
});
148 changes: 120 additions & 28 deletions apps/self-hosted/hosting/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
} 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);
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. No per-account handoff limit 📎 Requirement gap ⛨ Security

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
## Issue description
`POST /v1/auth/handoff` lacks rate limiting by authenticated account; only per-IP rate limiting is applied at the app level.

## Issue Context
Compliance requires rate limiting by both account and IP. The app currently applies `rateLimit({ name: 'auth', ... })` which keys solely by trusted client IP; the new handoff mint route does not add any account-based budget.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/routes/auth.ts[207-244]
- apps/self-hosted/hosting/api/src/index.ts[59-77]
- apps/self-hosted/hosting/api/src/middleware/rate-limit.ts[28-82]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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(),
});
},
);
Comment thread
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');
Expand Down
41 changes: 40 additions & 1 deletion apps/self-hosted/hosting/api/src/utils/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,43 @@ 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<void> {
const client = await getRedisClient();
await client.set(`auth:handoff:${code}`, JSON.stringify(payload), {
EX: ttlSeconds,
});
},

/** 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 };
65 changes: 65 additions & 0 deletions apps/self-hosted/src/features/auth/setup-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,71 @@ 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 context = {
isAuthEnabled: true,
isAuthenticated: false,
ownerUsername: 'alice',
exchangeCode: exchange,
};
const user = await actOnTokenHandoff(context);
expect(exchange).toHaveBeenCalledWith('code-abc');
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',
})),
}),
).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();
Expand Down
Loading
Loading