mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
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.
126 lines
5.2 KiB
TypeScript
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 },
|
|
);
|
|
}
|