feat(api): hand desktop sign-in back with a deep link grant (#3278)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 17:23:28 +02:00
1 parent f8505d19f7
commit e0d7625c39
7 files changed
+462 -32

No files matched your search

+39 -6
View File
@@ -29,7 +29,9 @@ import {
HandoffCancelRequest,
HandoffCodeParam,
HandoffCompleteRequest,
HandoffCompleteResponse,
HandoffInfoResponse,
HandoffInitiateRequest,
HandoffInitiateResponse,
HandoffStatusRequest,
HandoffStatusResponse,
@@ -609,6 +611,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/handoff/initiate',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
Validator('json', HandoffInitiateRequest),
OpenAPI({
operationId: 'initiate_handoff',
summary: 'Initiate handoff',
@@ -617,10 +620,15 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
return ctx.json(
await ctx.get('authRequestService').initiateHandoff({
request: ctx.req.raw,
returnUri: ctx.req.valid('json')?.return_uri ?? null,
}),
);
},
);
app.get(
@@ -656,23 +664,47 @@ export function AuthController(app: HonoApp) {
OpenAPI({
operationId: 'complete_handoff',
summary: 'Complete handoff',
responseSchema: null,
statusCode: 204,
responseSchema: HandoffCompleteResponse,
statusCode: [200, 204],
security: [],
tags: ['Auth'],
description: 'Complete the handoff process and authenticate on the target device using the handoff code.',
description:
'Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').completeHandoff({
const response = await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
clientIp,
authToken: ctx.get('authToken') ?? undefined,
approverOrigin: ctx.req.header('origin'),
});
return response ? ctx.json(response) : ctx.body(null, 204);
},
);
app.post(
'/auth/handoff/:code/deny',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_DENY),
Validator('param', HandoffCodeParam),
OpenAPI({
operationId: 'deny_handoff',
summary: 'Deny handoff',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').denyHandoff({code: ctx.req.valid('param').code, clientIp});
return ctx.body(null, 204);
},
);
@@ -726,6 +758,7 @@ export function AuthController(app: HonoApp) {
code: ctx.req.valid('param').code,
clientIp,
pollSecret: ctx.req.valid('json').poll_secret,
grant: ctx.req.valid('json').grant,
});
return ctx.json(response);
},
+27 -5
View File
@@ -41,6 +41,7 @@ import type {
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCompleteRequest,
HandoffCompleteResponse,
HandoffInfoResponse,
HandoffInitiateResponse,
HandoffStatusResponse,
@@ -141,6 +142,7 @@ interface AuthLogoutAuthSessionsRequest {
interface AuthHandoffInitiateRequest {
request: Request;
returnUri?: string | null;
}
interface AuthHandoffInfoRequest {
@@ -152,6 +154,7 @@ interface AuthHandoffStatusRequest {
code: string;
clientIp: string;
pollSecret?: string;
grant?: string;
}
interface AuthHandoffCancelRequest {
@@ -339,16 +342,18 @@ export class AuthRequestService {
return {available: !(await isUsernameTaken(this.apiContext.services.users, username))};
}
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
async initiateHandoff({request, returnUri}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
const result = await this.desktopHandoffService.initiateHandoff({
origin,
initiatorOrigin: request.headers.get('origin'),
returnUri,
});
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
poll_secret: result.pollSecret,
return_method: result.returnMethod,
};
}
@@ -376,16 +381,26 @@ export class AuthRequestService {
country: geo.countryName,
},
},
return_method: info.returnMethod,
};
}
async completeHandoff({data, clientIp, authToken, approverOrigin}: AuthHandoffCompleteRequest): Promise<void> {
async denyHandoff({code, clientIp}: AuthHandoffInfoRequest): Promise<void> {
await this.desktopHandoffService.denyHandoff(code, clientIp);
}
async completeHandoff({
data,
clientIp,
authToken,
approverOrigin,
}: AuthHandoffCompleteRequest): Promise<HandoffCompleteResponse | null> {
const sessionToken = data.token ?? authToken;
if (!sessionToken) {
throw new UnauthorizedError();
}
let createdToken: string | null = null;
const {initiatorOrigin} = await this.desktopHandoffService.completeHandoff(
const {initiatorOrigin, returnUrl} = await this.desktopHandoffService.completeHandoff(
data.code,
async (origin) => {
const created = await AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
@@ -397,10 +412,12 @@ export class AuthRequestService {
return created;
},
clientIp,
data.return_method,
);
if (createdToken !== null) {
await this.recordPushSessionPredecessor(createdToken, sessionToken, initiatorOrigin, approverOrigin);
}
return returnUrl ? {return_url: returnUrl} : null;
}
private async recordPushSessionPredecessor(
@@ -428,8 +445,13 @@ export class AuthRequestService {
}
}
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret);
async getHandoffStatus({
code,
clientIp,
pollSecret,
grant,
}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret, grant);
return {
status: result.status,
token: result.token,
@@ -21,6 +21,7 @@ const MAX_FAILED_ATTEMPTS = 5;
const ATTEMPT_TTL_SECONDS = 900;
const MAX_INFO_LOOKUPS = 3;
const POLL_SECRET_BYTES = 32;
const GRANT_BYTES = 32;
interface HandoffData {
createdAt: number;
@@ -28,14 +29,19 @@ interface HandoffData {
initiatorOrigin?: string | null;
infoLookupCount: number;
pollSecretHash: string;
returnUri?: string | null;
denied?: boolean;
}
interface HandoffTokenData {
token: string;
userId: string;
pollSecretHash: string;
grantHash?: string;
}
export type DesktopHandoffReturnMethod = 'deep_link' | 'code';
interface HandoffApproverData {
approvedAt: number;
}
@@ -62,15 +68,19 @@ function requireNormalizedHandoffCode(code: string): string {
return normalized;
}
function generatePollSecret(): string {
return randomBytes(POLL_SECRET_BYTES).toString('base64url');
function generateSecret(byteLength: number): string {
return randomBytes(byteLength).toString('base64url');
}
function hashPollSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
function pollSecretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
function resolveReturnMethod(handoffData: HandoffData): DesktopHandoffReturnMethod {
return handoffData.returnUri ? 'deep_link' : 'code';
}
function secretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
if (!presented || !storedHash) {
return false;
}
@@ -85,32 +95,44 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
export class DesktopHandoffService {
constructor(private readonly apiContext: ApiContext) {}
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
async initiateHandoff(args: {
origin: SessionOrigin;
initiatorOrigin?: string | null;
returnUri?: string | null;
}): Promise<{
code: string;
expiresAt: Date;
pollSecret: string;
returnMethod: DesktopHandoffReturnMethod;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = generateNormalizedHandoffCode();
const pollSecret = generatePollSecret();
const pollSecret = generateSecret(POLL_SECRET_BYTES);
const handoffData: HandoffData = {
createdAt: Date.now(),
origin: args.origin,
initiatorOrigin: args.initiatorOrigin ?? null,
infoLookupCount: 0,
pollSecretHash: hashPollSecret(pollSecret),
returnUri: args.returnUri ?? null,
};
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: formatDesktopHandoffCode(normalizedCode), expiresAt, pollSecret};
return {
code: formatDesktopHandoffCode(normalizedCode),
expiresAt,
pollSecret,
returnMethod: resolveReturnMethod(handoffData),
};
}
async completeHandoff(
code: string,
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
approverIp: string,
): Promise<{initiatorOrigin: string | null}> {
returnMethod: DesktopHandoffReturnMethod = 'code',
): Promise<{initiatorOrigin: string | null; returnUrl: string | null}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
@@ -120,10 +142,13 @@ export class DesktopHandoffService {
throw new InvalidHandoffCodeError();
}
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (!handoffData) {
if (!handoffData || handoffData.denied) {
await this.recordFailedAttempt(approverIp);
throw new InvalidHandoffCodeError();
}
if (returnMethod === 'deep_link' && !handoffData.returnUri) {
throw new InvalidHandoffCodeError();
}
const remainingSeconds = Math.max(
0,
seconds('5 minutes') - Math.floor((Date.now() - handoffData.createdAt) / 1000),
@@ -132,15 +157,22 @@ export class DesktopHandoffService {
throw new HandoffCodeExpiredError();
}
const {token, userId} = await createTokenData(handoffData.origin);
const grant = returnMethod === 'deep_link' ? generateSecret(GRANT_BYTES) : null;
const tokenData: HandoffTokenData = {
token,
userId,
pollSecretHash: handoffData.pollSecretHash,
...(grant ? {grantHash: hashPollSecret(grant)} : {}),
};
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
return {initiatorOrigin: handoffData.initiatorOrigin ?? null};
let returnUrl: string | null = null;
if (grant && handoffData.returnUri) {
const params = new URLSearchParams({code: formatDesktopHandoffCode(normalizedCode), grant});
returnUrl = `${handoffData.returnUri}?${params.toString()}`;
}
return {initiatorOrigin: handoffData.initiatorOrigin ?? null, returnUrl};
}
async getHandoffInfo(
@@ -149,13 +181,14 @@ export class DesktopHandoffService {
): Promise<{
status: 'pending' | 'expired';
origin?: SessionOrigin;
returnMethod?: DesktopHandoffReturnMethod;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const handoffData = await cache.get<HandoffData>(codeKey);
if (!handoffData) {
if (!handoffData || handoffData.denied) {
await this.recordFailedAttempt(approverIp);
return {status: 'expired'};
}
@@ -172,15 +205,35 @@ export class DesktopHandoffService {
{approvedAt: Date.now()},
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
);
return {status: 'pending', origin: handoffData.origin};
return {status: 'pending', origin: handoffData.origin, returnMethod: resolveReturnMethod(handoffData)};
}
async denyHandoff(code: string, approverIp: string): Promise<void> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const approverKey = `${HANDOFF_APPROVER_PREFIX}${normalizedCode}`;
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const storedApprover = await cache.get<HandoffApproverData>(approverKey);
const handoffData = await cache.get<HandoffData>(codeKey);
if (!storedApprover || !handoffData) {
await this.recordFailedAttempt(approverIp);
throw new InvalidHandoffCodeError();
}
const remainingTtl = await cache.ttl(codeKey);
if (remainingTtl > 0) {
await cache.set(codeKey, {...handoffData, denied: true}, remainingTtl);
}
await cache.delete(approverKey);
}
async getHandoffStatus(
code: string,
pollerIp: string,
pollSecret: string | undefined,
grant?: string,
): Promise<{
status: 'pending' | 'completed' | 'expired';
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string;
userId?: string;
}> {
@@ -190,10 +243,19 @@ export class DesktopHandoffService {
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
if (tokenData) {
if (!pollSecretMatches(pollSecret, tokenData.pollSecretHash)) {
if (!secretMatches(pollSecret, tokenData.pollSecretHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
if (tokenData.grantHash) {
if (!grant) {
return {status: 'pending'};
}
if (!secretMatches(grant, tokenData.grantHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
}
await cache.delete(tokenKey);
return {
status: 'completed',
@@ -203,7 +265,7 @@ export class DesktopHandoffService {
}
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (handoffData) {
return {status: 'pending'};
return {status: handoffData.denied ? 'denied' : 'pending'};
}
return {status: 'expired'};
}
@@ -216,7 +278,7 @@ export class DesktopHandoffService {
const handoffData = await cache.get<HandoffData>(codeKey);
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash;
if (!pollSecretMatches(pollSecret, storedHash)) {
if (!secretMatches(pollSecret, storedHash)) {
throw new InvalidHandoffCodeError();
}
await cache.delete(codeKey);
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, loginAccount} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface HandoffInitiateResponse {
code: string;
poll_secret: string;
return_method: 'deep_link' | 'code';
}
interface HandoffInfoResponse {
status: 'pending' | 'expired';
return_method?: 'deep_link' | 'code';
}
interface HandoffCompleteResponse {
return_url: string;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string | null;
user_id?: string | null;
}
const RETURN_URI = 'fluxer-canary://handoff';
describe('Auth desktop handoff deep link return', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function initiate(body: Record<string, unknown> | null): Promise<HandoffInitiateResponse> {
return createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(body)
.execute();
}
async function readStatus(code: string, body: Record<string, unknown>): Promise<HandoffStatusResponse> {
return createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.post(`/auth/handoff/${code}/status`)
.body(body)
.execute();
}
it('releases the token only to the app that presents both the poll secret and the grant', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
expect(initResp.return_method).toBe('deep_link');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.return_method).toBe('deep_link');
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.expect(200)
.execute();
const returnUrl = new URL(completed.return_url);
expect(`${returnUrl.protocol}//${returnUrl.host}`).toBe(RETURN_URI);
expect(returnUrl.searchParams.get('code')).toBe(initResp.code);
const grant = returnUrl.searchParams.get('grant');
expect(grant).toBeTruthy();
const withoutGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(withoutGrant.status).toBe('pending');
expect(withoutGrant.token ?? null).toBeNull();
const wrongGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: 'not-the-grant'});
expect(wrongGrant.status).toBe('pending');
const grantWithoutSecret = await readStatus(initResp.code, {poll_secret: 'not-the-secret', grant: grant!});
expect(grantWithoutSecret.status).toBe('pending');
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: grant!});
expect(released.status).toBe('completed');
expect(released.token).toBeTruthy();
expect(released.user_id).toBe(login.userId);
});
it('keeps polling without a grant from counting as a failed attempt', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.execute();
for (let i = 0; i < 8; i++) {
const pending = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(pending.status).toBe('pending');
}
const grant = new URL(completed.return_url).searchParams.get('grant')!;
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant});
expect(released.status).toBe('completed');
});
it('still completes through the typed code when the app registered a deep link', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'code'})
.expect(204)
.execute();
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(released.status).toBe('completed');
});
it('reports the code return method when the app did not register a deep link', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate(null);
expect(initResp.return_method).toBe('code');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.return_method).toBe('code');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
it('rejects a return deep link outside the desktop app schemes', async () => {
for (const returnUri of ['https://evil.example/handoff', 'fluxer://handoff/extra', 'javascript://handoff']) {
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/initiate')
.body({return_uri: returnUri})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
}
});
it('tells the app when the browser declines the request and blocks later approval', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness)
.post(`/auth/handoff/${initResp.code}/deny`)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness).post(`/auth/handoff/${initResp.code}/deny`).expect(204).execute();
const denied = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(denied.status).toBe('denied');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.status).toBe('expired');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
});
+109 -5
View File
@@ -308,6 +308,14 @@
"summary": "Complete handoff",
"tags": ["Auth"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {"anyOf": [{"$ref": "#/components/schemas/HandoffCompleteResponse"}, {"type": "null"}]}
}
}
},
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
@@ -340,7 +348,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Complete the handoff process and authenticate on the target device using the handoff code.",
"description": "Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HandoffCompleteRequest"}}}
@@ -388,7 +396,11 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking."
"description": "Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.",
"requestBody": {
"required": false,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HandoffInitiateRequest"}}}
}
}
},
"/auth/handoff/{code}": {
@@ -445,6 +457,56 @@
}
}
},
"/auth/handoff/{code}/deny": {
"post": {
"operationId": "deny_handoff",
"summary": "Deny handoff",
"tags": ["Auth"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.",
"parameters": [
{
"name": "code",
"in": "path",
"required": true,
"schema": {"description": "The handoff code", "type": "string"},
"description": "The handoff code"
}
]
}
},
"/auth/handoff/{code}/info": {
"get": {
"operationId": "get_handoff_info",
@@ -32794,14 +32856,18 @@
"HandoffStatusRequest": {
"type": "object",
"properties": {
"poll_secret": {"description": "The poll secret issued when the handoff was initiated", "type": "string"}
"poll_secret": {"description": "The poll secret issued when the handoff was initiated", "type": "string"},
"grant": {"description": "The one-time grant delivered to the app through the deep link", "type": "string"}
},
"required": ["poll_secret"]
},
"HandoffStatusResponse": {
"type": "object",
"properties": {
"status": {"type": "string", "description": "Current status of the handoff (pending, completed, expired)"},
"status": {
"type": "string",
"description": "Current status of the handoff (pending, completed, denied, expired)"
},
"token": {"description": "Authentication token if handoff is complete", "type": ["string", "null"]},
"user_id": {
"description": "User ID if handoff is complete",
@@ -32842,6 +32908,10 @@
},
{"type": "null"}
]
},
"return_method": {
"description": "How the approving browser hands the sign-in back. deep_link opens the initiating app, code relies on the user comparing the code",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["status"],
@@ -32854,6 +32924,20 @@
},
"required": ["poll_secret"]
},
"HandoffInitiateRequest": {
"anyOf": [
{
"type": "object",
"properties": {
"return_uri": {
"description": "Deep link the approving browser opens to return the sign-in to the initiating desktop app",
"type": "string"
}
}
},
{"type": "null"}
]
},
"HandoffInitiateResponse": {
"type": "object",
"properties": {
@@ -32867,6 +32951,10 @@
"poll_secret": {
"description": "Secret the initiating device must present to retrieve the token",
"type": "string"
},
"return_method": {
"description": "deep_link when the approving browser will hand the sign-in back through the return deep link, code otherwise",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["code", "expires_at"],
@@ -32877,10 +32965,25 @@
"properties": {
"code": {"description": "The handoff code from the initiating session", "type": "string"},
"token": {"description": "The authentication token to transfer", "type": "string"},
"user_id": {"description": "The user ID associated with the authenticated session", "type": "string"}
"user_id": {"description": "The user ID associated with the authenticated session", "type": "string"},
"return_method": {
"description": "deep_link returns a one-time grant the initiating app must present, code releases the token to the poll secret alone",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["code", "user_id"]
},
"HandoffCompleteResponse": {
"type": "object",
"properties": {
"return_url": {
"type": "string",
"description": "Deep link that returns the sign-in to the initiating app, with its one-time grant"
}
},
"required": ["return_url"],
"additionalProperties": false
},
"ForgotPasswordRequest": {
"type": "object",
"properties": {
@@ -33609,6 +33712,7 @@
},
"PasswordType": {"type": "string"},
"EmailType": {"type": "string"},
"DesktopHandoffReturnMethod": {"type": "string", "enum": ["deep_link", "code"]},
"AuthSessionLocation": {
"type": "object",
"properties": {
@@ -132,6 +132,10 @@ export const AuthRateLimitConfigs = {
bucket: 'auth:handoff:status',
config: {limit: 60, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
AUTH_HANDOFF_DENY: {
bucket: 'auth:handoff:deny',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
AUTH_HANDOFF_CANCEL: {
bucket: 'auth:handoff:cancel',
config: {limit: 10, windowMs: ms('1 minute')},
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ThemeTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
WebAuthnAuthenticationOptions,
WebAuthnAuthenticationResponse,
@@ -288,10 +289,30 @@ export const UsernameAvailabilityResponse = z.object({
export type UsernameAvailabilityResponse = z.infer<typeof UsernameAvailabilityResponse>;
export const DESKTOP_HANDOFF_RETURN_URI_PATTERN = /^fluxer(?:-canary|-development)?:\/\/handoff$/u;
export const DesktopHandoffReturnMethod = z.enum(['deep_link', 'code']);
export type DesktopHandoffReturnMethod = z.infer<typeof DesktopHandoffReturnMethod>;
export const HandoffInitiateRequest = z
.object({
return_uri: createStringType(1, 64)
.refine((value) => DESKTOP_HANDOFF_RETURN_URI_PATTERN.test(value), ValidationErrorCodes.INVALID_FORMAT)
.optional()
.describe('Deep link the approving browser opens to return the sign-in to the initiating desktop app'),
})
.nullish();
export type HandoffInitiateRequest = z.infer<typeof HandoffInitiateRequest>;
export const HandoffInitiateResponse = z.object({
code: z.string().describe('Handoff code to share with the receiving device'),
expires_at: z.iso.datetime().describe('ISO 8601 timestamp when the handoff code expires'),
poll_secret: z.string().optional().describe('Secret the initiating device must present to retrieve the token'),
return_method: DesktopHandoffReturnMethod.optional().describe(
'deep_link when the approving browser will hand the sign-in back through the return deep link, code otherwise',
),
});
export type HandoffInitiateResponse = z.infer<typeof HandoffInitiateResponse>;
@@ -306,12 +327,15 @@ const HandoffInfoClientInfo = z.object({
export const HandoffInfoResponse = z.object({
status: z.string().describe('Current status of the handoff (pending, expired)'),
client_info: HandoffInfoClientInfo.nullish().describe('Client information of the initiating device'),
return_method: DesktopHandoffReturnMethod.optional().describe(
'How the approving browser hands the sign-in back. deep_link opens the initiating app, code relies on the user comparing the code',
),
});
export type HandoffInfoResponse = z.infer<typeof HandoffInfoResponse>;
export const HandoffStatusResponse = z.object({
status: z.string().describe('Current status of the handoff (pending, completed, expired)'),
status: z.string().describe('Current status of the handoff (pending, completed, denied, expired)'),
token: z.string().nullish().describe('Authentication token if handoff is complete'),
user_id: SnowflakeStringType.nullish().describe('User ID if handoff is complete'),
user: UserPartialResponse.nullish().describe('Partial user data if handoff is complete'),
@@ -386,6 +410,9 @@ export const HandoffCompleteRequest = z.object({
code: createStringType().describe('The handoff code from the initiating session'),
token: createStringType().optional().describe('The authentication token to transfer'),
user_id: createStringType().describe('The user ID associated with the authenticated session'),
return_method: DesktopHandoffReturnMethod.optional().describe(
'deep_link returns a one-time grant the initiating app must present, code releases the token to the poll secret alone',
),
});
export type HandoffCompleteRequest = z.infer<typeof HandoffCompleteRequest>;
@@ -396,8 +423,15 @@ export const HandoffCodeParam = z.object({
export type HandoffCodeParam = z.infer<typeof HandoffCodeParam>;
export const HandoffCompleteResponse = z.object({
return_url: z.string().describe('Deep link that returns the sign-in to the initiating app, with its one-time grant'),
});
export type HandoffCompleteResponse = z.infer<typeof HandoffCompleteResponse>;
export const HandoffStatusRequest = z.object({
poll_secret: createStringType().describe('The poll secret issued when the handoff was initiated'),
grant: createStringType(1, 128).optional().describe('The one-time grant delivered to the app through the deep link'),
});
export type HandoffStatusRequest = z.infer<typeof HandoffStatusRequest>;