feat: multi-server JMAP support
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
...getCookieOptions(),
|
||||
@@ -39,10 +40,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. Only when no server URL
|
||||
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
||||
// do we honor the body URL — and even then it must be a public URL.
|
||||
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. We accept the global
|
||||
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||
// we fall back to the request URL only if `allowCustomJmapEndpoint` is on
|
||||
// — and even then the URL must resolve to a public address.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
@@ -50,11 +52,13 @@ export async function POST(request: NextRequest) {
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||
|
||||
let upstreamUrl: string;
|
||||
let upstreamTrusted: boolean;
|
||||
if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
if (trustedUrl) {
|
||||
upstreamUrl = trustedUrl;
|
||||
upstreamTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
@@ -55,6 +55,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
@@ -62,13 +63,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
|
||||
// Store refresh token in the per-account cookie slot.
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
// Delete pending cookie
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
|
||||
@@ -18,12 +18,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale } = await request.json();
|
||||
const { redirect_uri, locale, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||
try {
|
||||
@@ -36,7 +38,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig();
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
@@ -48,12 +50,14 @@ export async function POST(request: NextRequest) {
|
||||
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||
const state = generateStateServer();
|
||||
|
||||
// Encrypt and store in httpOnly cookie
|
||||
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||
const pendingData = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
|
||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||
@@ -26,10 +27,9 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. Only when no server URL
|
||||
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
||||
// do we honor the body URL — and even then it must be a public URL.
|
||||
// Pin the upstream URL to a configured JMAP server (single `jmapServerUrl`
|
||||
// or any entry in `jmapServers`). Falls back to the request URL only when
|
||||
// `allowCustomJmapEndpoint` is enabled, and even then it must be public.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
@@ -37,11 +37,13 @@ export async function POST(request: NextRequest) {
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||
|
||||
let upstreamUrl: string;
|
||||
let upstreamTrusted: boolean;
|
||||
if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
if (trustedUrl) {
|
||||
upstreamUrl = trustedUrl;
|
||||
upstreamTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
|
||||
+27
-10
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
@@ -15,26 +15,36 @@ function getSlot(request: NextRequest): number {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!code || !code_verifier || !redirect_uri) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
|
||||
|
||||
const response = NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
// Persist which server entry minted this refresh token so the PUT/DELETE
|
||||
// handlers can route the refresh/revocation calls to the right token
|
||||
// endpoint without the client having to track it across page loads.
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
@@ -49,17 +59,18 @@ export async function PUT(request: NextRequest) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||
|
||||
const params = buildOAuthParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}, serverId);
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
@@ -71,6 +82,7 @@ export async function PUT(request: NextRequest) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||
cookieStore.delete(cookieName);
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -104,13 +116,15 @@ export async function DELETE(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const name = refreshTokenCookieName(i);
|
||||
const serverCookieName = refreshTokenServerCookieName(i);
|
||||
const token = cookieStore.get(name)?.value;
|
||||
const slotServerId = cookieStore.get(serverCookieName)?.value || null;
|
||||
if (token) {
|
||||
// Best-effort revocation
|
||||
try {
|
||||
const metadata = await getMetadata().catch(() => null);
|
||||
const metadata = await getMetadata(slotServerId).catch(() => null);
|
||||
if (metadata?.revocation_endpoint) {
|
||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId);
|
||||
await fetch(metadata.revocation_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
@@ -120,6 +134,7 @@ export async function DELETE(request: NextRequest) {
|
||||
} catch { /* best effort */ }
|
||||
cookieStore.delete(name);
|
||||
}
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -128,7 +143,8 @@ export async function DELETE(request: NextRequest) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const metadata = await getMetadata().catch((err) => {
|
||||
const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
const metadata = await getMetadata(slotServerId).catch((err) => {
|
||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
@@ -140,7 +156,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const params = buildOAuthParams({
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
});
|
||||
}, slotServerId);
|
||||
|
||||
try {
|
||||
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
||||
@@ -158,6 +174,7 @@ export async function DELETE(request: NextRequest) {
|
||||
|
||||
cookieStore.delete(cookieName);
|
||||
}
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
|
||||
let end_session_url: string | undefined;
|
||||
if (metadata?.end_session_endpoint) {
|
||||
|
||||
@@ -2,12 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
@@ -78,18 +79,19 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. Only when no server
|
||||
// URL is configured (and the deployment explicitly allows custom JMAP
|
||||
// endpoints) do we fall back to the user-supplied URL - and even then
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||
// no server is configured (and the deployment explicitly allows custom
|
||||
// JMAP endpoints) do we fall back to the user-supplied URL — and even then
|
||||
// it must resolve to a public address.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
@@ -98,9 +100,17 @@ export async function POST(request: NextRequest) {
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
|
||||
let upstreamUrl: string;
|
||||
if (configuredServerUrl) {
|
||||
let resolvedServerId: string | null = null;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
@@ -118,7 +128,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
}
|
||||
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||
} catch (error) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -131,11 +141,21 @@ async function attemptAllStrategies(
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
@@ -147,7 +167,7 @@ async function attemptAllStrategies(
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
}
|
||||
@@ -159,7 +179,7 @@ async function attemptAllStrategies(
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
}
|
||||
@@ -171,7 +191,7 @@ async function attemptAllStrategies(
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
}
|
||||
@@ -183,7 +203,7 @@ async function attemptAllStrategies(
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
}
|
||||
@@ -199,12 +219,19 @@ async function attemptAllStrategies(
|
||||
async function storeAndRespond(
|
||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
|
||||
Reference in New Issue
Block a user