diff --git a/fluxer_api/src/api/auth/AuthController.ts b/fluxer_api/src/api/auth/AuthController.ts index fbbe3eb0d..cba56a04b 100644 --- a/fluxer_api/src/api/auth/AuthController.ts +++ b/fluxer_api/src/api/auth/AuthController.ts @@ -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); }, diff --git a/fluxer_api/src/api/auth/AuthRequestService.ts b/fluxer_api/src/api/auth/AuthRequestService.ts index 05cb286ee..668012832 100644 --- a/fluxer_api/src/api/auth/AuthRequestService.ts +++ b/fluxer_api/src/api/auth/AuthRequestService.ts @@ -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 { + async initiateHandoff({request, returnUri}: AuthHandoffInitiateRequest): Promise { 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 { + async denyHandoff({code, clientIp}: AuthHandoffInfoRequest): Promise { + await this.desktopHandoffService.denyHandoff(code, clientIp); + } + + async completeHandoff({ + data, + clientIp, + authToken, + approverOrigin, + }: AuthHandoffCompleteRequest): Promise { 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 { - const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret); + async getHandoffStatus({ + code, + clientIp, + pollSecret, + grant, + }: AuthHandoffStatusRequest): Promise { + const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret, grant); return { status: result.status, token: result.token, diff --git a/fluxer_api/src/api/auth/services/DesktopHandoffService.ts b/fluxer_api/src/api/auth/services/DesktopHandoffService.ts index 54d68447a..f0de045d4 100644 --- a/fluxer_api/src/api/auth/services/DesktopHandoffService.ts +++ b/fluxer_api/src/api/auth/services/DesktopHandoffService.ts @@ -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(`${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(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 { + 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(approverKey); + const handoffData = await cache.get(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(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(`${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(codeKey); const tokenData = await cache.get(tokenKey); const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash; - if (!pollSecretMatches(pollSecret, storedHash)) { + if (!secretMatches(pollSecret, storedHash)) { throw new InvalidHandoffCodeError(); } await cache.delete(codeKey); diff --git a/fluxer_api/src/api/auth/tests/DesktopHandoffDeepLink.test.ts b/fluxer_api/src/api/auth/tests/DesktopHandoffDeepLink.test.ts new file mode 100644 index 000000000..33c5ac75c --- /dev/null +++ b/fluxer_api/src/api/auth/tests/DesktopHandoffDeepLink.test.ts @@ -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 | null): Promise { + return createBuilderWithoutAuth(harness) + .post('/auth/handoff/initiate') + .body(body) + .execute(); + } + + async function readStatus(code: string, body: Record): Promise { + return createBuilderWithoutAuth(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(harness) + .get(`/auth/handoff/${initResp.code}/info`) + .execute(); + expect(info.return_method).toBe('deep_link'); + const completed = await createBuilderWithoutAuth(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(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(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(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(); + }); +}); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 361809c54..23ca0cd6c 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -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": { diff --git a/fluxer_api/src/api/rate_limit_configs/AuthRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/AuthRateLimitConfig.ts index e3c815457..dd78acc7f 100644 --- a/fluxer_api/src/api/rate_limit_configs/AuthRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/AuthRateLimitConfig.ts @@ -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')}, diff --git a/packages/schema/src/domains/auth/AuthSchemas.ts b/packages/schema/src/domains/auth/AuthSchemas.ts index 2976d28b0..11462a8c7 100644 --- a/packages/schema/src/domains/auth/AuthSchemas.ts +++ b/packages/schema/src/domains/auth/AuthSchemas.ts @@ -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; +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; + +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; + 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; @@ -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; 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; @@ -396,8 +423,15 @@ export const HandoffCodeParam = z.object({ export type HandoffCodeParam = z.infer; +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; + 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;