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 { hasValidPairReauth } from '@/lib/auth/pair-reauth'; 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 { // Step-up gate: minting a pairing code grants new-device access, so it // requires a recent fresh IdP re-authentication (see the reauth SSO flow). // The client turns this 401 into a re-auth redirect, then retries. if (!(await hasValidPairReauth())) { return NextResponse.json({ error: 'reauth_required' }, { status: 401 }); } 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 }); } }