feat: add QR code device pairing for mobile app login
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { createPairing } from '@/lib/auth/pairing-store';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
// Desktop side of the cross-device QR login. The caller must be a signed-in
|
||||
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
|
||||
// refresh that token to (a) prove the session is live and (b) obtain a fresh
|
||||
// access token to hand the phone, then stash the bundle under a one-time
|
||||
// pairing code. The desktop renders the returned code as a QR; the phone
|
||||
// redeems it at /api/auth/pair/redeem.
|
||||
//
|
||||
// Token sharing note: the phone receives the SAME refresh token as the desktop.
|
||||
// That is correct for OAuth servers (such as Stalwart in its default config)
|
||||
// that do not rotate refresh tokens on use. If the server rotates refresh
|
||||
// tokens, the two devices would fight over the latest token — such deployments
|
||||
// should disable rotation for this client or use a token-exchange grant.
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const slot =
|
||||
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
|
||||
? body.slot
|
||||
: 0;
|
||||
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
|
||||
}
|
||||
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
|
||||
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
|
||||
// Stale session — clear the dead cookie so the user is prompted to log
|
||||
// back in, mirroring the token route's behaviour.
|
||||
cookieStore.delete(cookieName);
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
if (!tokens.access_token) {
|
||||
logger.error('Pair create: refresh response missing access_token');
|
||||
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
||||
}
|
||||
|
||||
// If the server rotated the refresh token, persist the new one back to the
|
||||
// desktop's cookie so this very session keeps working. The phone will get
|
||||
// the same (new) token below.
|
||||
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
|
||||
if (tokens.refresh_token) {
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
const { clientId, serverUrl } = getRequiredConfig(serverId);
|
||||
|
||||
const { code, expiresIn } = createPairing({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: effectiveRefreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
tokenEndpoint,
|
||||
clientId,
|
||||
serverUrl,
|
||||
serverId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
|
||||
} catch (error) {
|
||||
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user