Files
webmail/app/api/auth/pair/token/route.ts
T
Linus Rath a5583aa8e8 fix: re-check a paired phone's token endpoint against the current config
The token proxy trusted the endpoint sealed into a phone's refresh token
for good: after a server was removed or its provider moved, it kept
posting there with the plain fetch and whichever secret the server id
now maps to. Trust is now re-derived on every renewal; an endpoint the
webmail is no longer configured for gets neither the secret nor the
unguarded fetch.
2026-09-27 04:50:38 +02:00

126 lines
5.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { openPhoneRefreshToken, sealPhoneRefreshToken, type SealedRefresh } from '@/lib/auth/pair-bundle';
import { DEFAULT_CLIENT_ID, getClientSecret, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { fetchPublicUrl } from '@/lib/security/url-guard';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Token endpoint for paired phones whose grant the phone cannot renew itself:
// the client is confidential (its secret stays on this server), or the
// provider's host is one current app builds refuse to talk to. The app posts
// the standard refresh grant here; we renew at the provider that minted the
// grant, with the secret, and hand back a re-sealed refresh token.
//
// Only refresh tokens this webmail sealed for a phone are accepted (see
// lib/auth/pair-bundle.ts), so the route cannot be used to renew arbitrary
// tokens with our client secret. No cookies are involved.
const UPSTREAM_TIMEOUT_MS = 10_000;
const NO_STORE = { 'Cache-Control': 'no-store', Pragma: 'no-cache' };
/**
* Whether the sealed endpoint is still one this webmail is configured for:
* the discovered token endpoint of its server entry (SSO grants), or the
* structured-login endpoint of an admin-configured JMAP server (password
* grants).
*/
async function stillTrusted(sealed: SealedRefresh): Promise<boolean> {
try {
if (await getTokenEndpoint(sealed.serverId, { fallbackClientId: DEFAULT_CLIENT_ID }) === sealed.tokenEndpoint) {
return true;
}
} catch {
// No OAuth configured for it (any more): try the JMAP server below.
}
const base = sealed.tokenEndpoint.replace(/\/auth\/token$/, '');
return base !== sealed.tokenEndpoint && await isTrustedJmapServerUrl(base);
}
function oauthError(error: string, status: number) {
return NextResponse.json({ error }, { status, headers: NO_STORE });
}
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
let params: URLSearchParams;
try {
params = new URLSearchParams(await request.text());
} catch {
return oauthError('invalid_request', 400);
}
if (params.get('grant_type') !== 'refresh_token') {
return oauthError('unsupported_grant_type', 400);
}
const sealed = openPhoneRefreshToken(params.get('refresh_token') ?? '');
if (!sealed) {
return oauthError('invalid_grant', 400);
}
const upstreamParams = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: sealed.refreshToken,
client_id: sealed.clientId,
});
// The secret is the admin's, for the admin's servers only - as configured
// now, not when the token was sealed: a server removed or moved since then
// gets neither the secret nor the unguarded fetch.
const trusted = sealed.trusted && await stillTrusted(sealed);
const secret = trusted ? getClientSecret(sealed.serverId) : '';
if (secret) upstreamParams.set('client_secret', secret);
let upstream: Response;
try {
const init = {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: upstreamParams.toString(),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
};
// The body carries the refresh token and the client secret: never follow
// a redirect with it. A user-chosen server goes through the
// rebinding-safe fetch (which never follows redirects either).
upstream = trusted
? await fetch(sealed.tokenEndpoint, { ...init, redirect: 'error' })
: (await fetchPublicUrl(sealed.tokenEndpoint, init)) as unknown as Response;
} catch (error) {
logger.warn('Pair token proxy: provider unreachable', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return oauthError('temporarily_unavailable', 503);
}
if (!upstream.ok) {
// 400/401/403: the grant is dead (expired, revoked, password changed) and
// the app signs the account out. Anything else is an outage it waits out.
const dead = upstream.status === 400 || upstream.status === 401 || upstream.status === 403;
logger.warn('Pair token proxy: refresh rejected', { status: upstream.status });
return oauthError(dead ? 'invalid_grant' : 'temporarily_unavailable', dead ? 400 : 503);
}
let tokens: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown; token_type?: unknown };
try {
tokens = await upstream.json();
} catch {
return oauthError('temporarily_unavailable', 503);
}
if (typeof tokens.access_token !== 'string' || !tokens.access_token) {
return oauthError('temporarily_unavailable', 503);
}
const refreshToken = typeof tokens.refresh_token === 'string' && tokens.refresh_token
? tokens.refresh_token
: sealed.refreshToken;
return NextResponse.json(
{
access_token: tokens.access_token,
token_type: typeof tokens.token_type === 'string' ? tokens.token_type : 'Bearer',
...(typeof tokens.expires_in === 'number' ? { expires_in: tokens.expires_in } : {}),
refresh_token: sealPhoneRefreshToken({ ...sealed, refreshToken }),
},
{ headers: NO_STORE },
);
}