97 lines
4.4 KiB
TypeScript
97 lines
4.4 KiB
TypeScript
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 });
|
|
}
|
|
}
|