fix(auth): require a hashed poll secret for desktop handoff (#2357)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-02 17:25:54 +02:00
1 parent b82681b77a
commit f703969e80
45 files changed
+382 -188

No files matched your search

+35 -1
View File
@@ -9,10 +9,12 @@ import {
AuthTokenWithUserIdResponse,
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCancelRequest,
HandoffCodeParam,
HandoffCompleteRequest,
HandoffInfoResponse,
HandoffInitiateResponse,
HandoffStatusRequest,
HandoffStatusResponse,
IpAuthorizationPollQuery,
IpAuthorizationPollResponse,
@@ -632,10 +634,39 @@ export function AuthController(app: HonoApp) {
return ctx.json(response);
},
);
app.post(
'/auth/handoff/:code/status',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_STATUS),
Validator('param', HandoffCodeParam),
Validator('json', HandoffStatusRequest),
OpenAPI({
operationId: 'get_handoff_status_with_secret',
summary: 'Get handoff status with secret',
responseSchema: HandoffStatusResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Check the status of a handoff session using the poll secret from initiation. Returns the authentication token once the handoff is complete and the presented secret matches.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const response = await ctx.get('authRequestService').getHandoffStatus({
code: ctx.req.valid('param').code,
clientIp,
pollSecret: ctx.req.valid('json').poll_secret,
});
return ctx.json(response);
},
);
app.delete(
'/auth/handoff/:code',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_CANCEL),
Validator('param', HandoffCodeParam),
Validator('json', HandoffCancelRequest),
OpenAPI({
operationId: 'cancel_handoff',
summary: 'Cancel handoff',
@@ -646,7 +677,10 @@ export function AuthController(app: HonoApp) {
description: 'Cancel an ongoing handoff session. The handoff code will no longer be valid for authentication.',
}),
async (ctx) => {
await ctx.get('authRequestService').cancelHandoff({code: ctx.req.valid('param').code});
await ctx.get('authRequestService').cancelHandoff({
code: ctx.req.valid('param').code,
pollSecret: ctx.req.valid('json').poll_secret,
});
return ctx.body(null, 204);
},
);
+11 -4
View File
@@ -133,6 +133,12 @@ interface AuthHandoffInfoRequest {
interface AuthHandoffStatusRequest {
code: string;
clientIp: string;
pollSecret?: string;
}
interface AuthHandoffCancelRequest {
code: string;
pollSecret: string;
}
export class AuthRequestService {
@@ -305,6 +311,7 @@ export class AuthRequestService {
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
poll_secret: result.pollSecret,
};
}
@@ -352,8 +359,8 @@ export class AuthRequestService {
);
}
async getHandoffStatus({code, clientIp}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp);
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret);
return {
status: result.status,
token: result.token,
@@ -362,8 +369,8 @@ export class AuthRequestService {
};
}
async cancelHandoff({code}: {code: string}): Promise<void> {
await this.desktopHandoffService.cancelHandoff(code);
async cancelHandoff({code, pollSecret}: AuthHandoffCancelRequest): Promise<void> {
await this.desktopHandoffService.cancelHandoff(code, pollSecret);
}
private async getUserPartial(userId: string): Promise<UserPartialResponse> {
@@ -1,60 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomBytes} from 'node:crypto';
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCodeExpiredError';
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
import {
DESKTOP_HANDOFF_CODE_ALPHABET,
DESKTOP_HANDOFF_CODE_LENGTH,
formatDesktopHandoffCode,
parseDesktopHandoffCode,
} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
import type {SessionOrigin} from '../AuthSession';
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 12;
const NORMALIZED_CODE_REGEX = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{12}$/;
const HANDOFF_ATTEMPT_PREFIX = 'desktop-handoff-attempts:';
const HANDOFF_APPROVER_PREFIX = 'desktop-handoff-approver:';
const MAX_FAILED_ATTEMPTS = 5;
const ATTEMPT_TTL_SECONDS = 900;
const MAX_INFO_LOOKUPS = 3;
const POLL_SECRET_BYTES = 32;
interface HandoffData {
createdAt: number;
origin: SessionOrigin;
infoLookupCount: number;
pollSecretHash: string;
}
interface HandoffTokenData {
token: string;
userId: string;
pollSecretHash: string;
}
interface HandoffApproverData {
approvedAt: number;
}
function generateHandoffCode(): string {
const maxUnbiased = 256 - (256 % CODE_CHARACTERS.length);
function generateNormalizedHandoffCode(): string {
const maxUnbiased = 256 - (256 % DESKTOP_HANDOFF_CODE_ALPHABET.length);
let code = '';
while (code.length < CODE_LENGTH) {
const bytes = randomBytes(CODE_LENGTH - code.length);
for (let i = 0; i < bytes.length && code.length < CODE_LENGTH; i++) {
while (code.length < DESKTOP_HANDOFF_CODE_LENGTH) {
const bytes = randomBytes(DESKTOP_HANDOFF_CODE_LENGTH - code.length);
for (let i = 0; i < bytes.length && code.length < DESKTOP_HANDOFF_CODE_LENGTH; i++) {
if (bytes[i] < maxUnbiased) {
code += CODE_CHARACTERS[bytes[i] % CODE_CHARACTERS.length];
code += DESKTOP_HANDOFF_CODE_ALPHABET[bytes[i] % DESKTOP_HANDOFF_CODE_ALPHABET.length];
}
}
}
return `${code.slice(0, 6)}-${code.slice(6, 12)}`;
return code;
}
function normalizeHandoffCode(code: string): string {
return code.replace(/[-\s]/g, '').toUpperCase();
}
function assertValidHandoffCode(code: string): void {
if (!NORMALIZED_CODE_REGEX.test(code)) {
function requireNormalizedHandoffCode(code: string): string {
const normalized = parseDesktopHandoffCode(code);
if (normalized == null) {
throw new InvalidHandoffCodeError();
}
return normalized;
}
function generatePollSecret(): string {
return randomBytes(POLL_SECRET_BYTES).toString('base64url');
}
function hashPollSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
function pollSecretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
if (!presented || !storedHash) {
return false;
}
const presentedHash = Buffer.from(hashPollSecret(presented), 'hex');
const stored = Buffer.from(storedHash, 'hex');
if (presentedHash.length !== stored.length) {
return false;
}
return timingSafeEqual(presentedHash, stored);
}
export class DesktopHandoffService {
@@ -63,19 +87,21 @@ export class DesktopHandoffService {
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
code: string;
expiresAt: Date;
pollSecret: string;
}> {
const {cache} = this.apiContext.services;
const code = generateHandoffCode();
const normalizedCode = normalizeHandoffCode(code);
const normalizedCode = generateNormalizedHandoffCode();
const pollSecret = generatePollSecret();
const handoffData: HandoffData = {
createdAt: Date.now(),
origin: args.origin,
infoLookupCount: 0,
pollSecretHash: hashPollSecret(pollSecret),
};
const expirySeconds = seconds('5 minutes');
await cache.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds);
const expiresAt = new Date(Date.now() + ms('5 minutes'));
return {code, expiresAt};
return {code: formatDesktopHandoffCode(normalizedCode), expiresAt, pollSecret};
}
async completeHandoff(
@@ -84,8 +110,7 @@ export class DesktopHandoffService {
approverIp: string,
): Promise<void> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const storedApprover = await cache.get<HandoffApproverData>(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
if (!storedApprover) {
@@ -108,6 +133,7 @@ export class DesktopHandoffService {
const tokenData: HandoffTokenData = {
token,
userId,
pollSecretHash: handoffData.pollSecretHash,
};
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
@@ -122,8 +148,7 @@ export class DesktopHandoffService {
origin?: SessionOrigin;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const handoffData = await cache.get<HandoffData>(codeKey);
@@ -149,18 +174,23 @@ export class DesktopHandoffService {
async getHandoffStatus(
code: string,
_pollerIp: string,
pollerIp: string,
pollSecret: string | undefined,
): Promise<{
status: 'pending' | 'completed' | 'expired';
token?: string;
userId?: string;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(pollerIp);
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
if (tokenData) {
if (!pollSecretMatches(pollSecret, tokenData.pollSecretHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
await cache.delete(tokenKey);
return {
status: 'completed',
@@ -175,12 +205,19 @@ export class DesktopHandoffService {
return {status: 'expired'};
}
async cancelHandoff(code: string): Promise<void> {
async cancelHandoff(code: string, pollSecret: string): Promise<void> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
await cache.delete(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`);
const normalizedCode = requireNormalizedHandoffCode(code);
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
const handoffData = await cache.get<HandoffData>(codeKey);
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash;
if (!pollSecretMatches(pollSecret, storedHash)) {
throw new InvalidHandoffCodeError();
}
await cache.delete(codeKey);
await cache.delete(tokenKey);
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
}
@@ -7,6 +7,7 @@ import {createAuthHarness, createTestAccount, loginAccount} from './AuthTestUtil
interface HandoffInitiateResponse {
code: string;
poll_secret: string;
}
interface HandoffInfoResponse {
@@ -59,7 +60,8 @@ describe('Auth desktop handoff code normalization', () => {
.expect(204)
.execute();
const status2 = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.post(`/auth/handoff/${initResp.code}/status`)
.body({poll_secret: initResp.poll_secret})
.execute();
expect(status2.status).toBe('completed');
expect(status2.token).toBeTruthy();
@@ -12,6 +12,7 @@ import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from './Aut
interface HandoffInitiateResponse {
code: string;
expires_at: string;
poll_secret: string;
}
interface HandoffInfoResponse {
@@ -93,7 +94,8 @@ describe('Auth desktop handoff flow', () => {
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.post(`/auth/handoff/${initResp.code}/status`)
.body({poll_secret: initResp.poll_secret})
.execute();
const sessions = await createBuilder<Array<AuthSessionsResponseItem>>(harness, completed.token!)
.get('/auth/sessions')
@@ -116,13 +118,15 @@ describe('Auth desktop handoff flow', () => {
expect(initResp.code).toBeTruthy();
expect(validateHandoffCodeFormat(initResp.code)).toBe(true);
expect(initResp.expires_at).toBeTruthy();
expect(initResp.poll_secret).toBeTruthy();
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.status).toBe('pending');
expect(info.client_info).toBeTruthy();
const pending = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.post(`/auth/handoff/${initResp.code}/status`)
.body({poll_secret: initResp.poll_secret})
.execute();
expect(pending.status).toBe('pending');
await createBuilderWithoutAuth(harness)
@@ -135,7 +139,8 @@ describe('Auth desktop handoff flow', () => {
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.post(`/auth/handoff/${initResp.code}/status`)
.body({poll_secret: initResp.poll_secret})
.execute();
expect(completed.status).toBe('completed');
expect(completed.token).toBeTruthy();
@@ -150,7 +155,8 @@ describe('Auth desktop handoff flow', () => {
const handoffUser = handoffSession.json as UserMeResponse;
expect(handoffUser.id).toBe(login.userId);
const retrieved = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.post(`/auth/handoff/${initResp.code}/status`)
.body({poll_secret: initResp.poll_secret})
.execute();
expect(retrieved.status).toBe('expired');
});
@@ -159,7 +165,11 @@ describe('Auth desktop handoff flow', () => {
.post('/auth/handoff/initiate')
.body(null)
.execute();
await createBuilderWithoutAuth(harness).delete(`/auth/handoff/${initResp.code}`).expect(204).execute();
await createBuilderWithoutAuth(harness)
.delete(`/auth/handoff/${initResp.code}`)
.body({poll_secret: initResp.poll_secret})
.expect(204)
.execute();
const cancelled = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
@@ -184,7 +194,8 @@ describe('Auth desktop handoff flow', () => {
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.post(`/auth/handoff/${initResp.code}/status`)
.body({poll_secret: initResp.poll_secret})
.execute();
expect(completed.status).toBe('completed');
expect(completed.token).toBeTruthy();
@@ -233,8 +244,9 @@ describe('Auth desktop handoff flow', () => {
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.post(`/auth/handoff/${initResp.code}/status`)
.header('x-forwarded-for', pollerIp)
.body({poll_secret: initResp.poll_secret})
.execute();
expect(completed.status).toBe('completed');
expect(completed.user_id).toBe(login.userId);
@@ -267,8 +279,9 @@ describe('Auth desktop handoff flow', () => {
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.post(`/auth/handoff/${initResp.code}/status`)
.header('x-forwarded-for', pollerIp)
.body({poll_secret: initResp.poll_secret})
.execute();
expect(completed.status).toBe('completed');
expect(completed.user_id).toBe(login.userId);
@@ -72,6 +72,7 @@ describe('Auth desktop handoff negative paths', () => {
it('handles cancel for unknown handoff code gracefully', async () => {
await createBuilderWithoutAuth(harness)
.delete('/auth/handoff/unknown-code')
.body({poll_secret: 'not-the-secret'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
@@ -7,6 +7,7 @@ import {createAuthHarness, createTestAccount, loginAccount} from './AuthTestUtil
interface HandoffInitiateResponse {
code: string;
poll_secret?: string;
}
interface HandoffStatusResponse {
@@ -56,8 +57,38 @@ describe('Auth desktop handoff complete single use', () => {
const status = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
expect(status.status).toBe('completed');
expect(status.token).toBeTruthy();
expect(status.token).not.toBe(login.token);
expect(status.status).toBe('pending');
expect(status.token).toBeUndefined();
});
it('releases the token only to a poller presenting the correct secret', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(null)
.execute();
expect(initResp.poll_secret).toBeTruthy();
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.header('Authorization', login.token)
.body({
code: initResp.code,
user_id: login.userId,
})
.expect(204)
.execute();
const withoutSecret = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
expect(withoutSecret.status).toBe('pending');
expect(withoutSecret.token).toBeUndefined();
const withSecret = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.post(`/auth/handoff/${initResp.code}/status`)
.body({poll_secret: initResp.poll_secret})
.execute();
expect(withSecret.status).toBe('completed');
expect(withSecret.token).toBeTruthy();
expect(withSecret.token).not.toBe(login.token);
});
});