mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(api): hand desktop sign-in back with a deep link grant (#3278)
This commit is contained in:
1 parent
f8505d19f7
commit
e0d7625c39
7 files changed
+462
-32
No files matched your search
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user