feat: add non-interactive SSO login flow for embedded/iframe deployments (closes #69)
This commit is contained in:
@@ -3,12 +3,10 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
try {
|
||||
const { code, state } = await request.json();
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Read and decrypt the pending SSO cookie
|
||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||
if (!pendingCookie) {
|
||||
logger.warn('SSO complete: no pending cookie found');
|
||||
return NextResponse.json({ error: 'No pending SSO session. Please start the login flow again.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pending = decryptPayload(pendingCookie);
|
||||
if (!pending) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'Invalid SSO session' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate state
|
||||
if (pending.state !== state) {
|
||||
logger.warn('SSO complete: state mismatch');
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate TTL
|
||||
const createdAt = pending.created_at as number;
|
||||
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
|
||||
logger.warn('SSO complete: pending session expired');
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'SSO session expired. Please try again.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'Invalid SSO session data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||
|
||||
// Store refresh token
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(0);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
// Delete pending cookie
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up pending cookie on any error
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
logger.error('SSO complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!process.env.SESSION_SECRET) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale } = await request.json();
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||
try {
|
||||
const redirectOrigin = new URL(redirect_uri).origin;
|
||||
if (redirectOrigin !== requestOrigin) {
|
||||
logger.warn('SSO start: redirect_uri origin mismatch', { redirectOrigin, requestOrigin });
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig();
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Generate PKCE + state server-side
|
||||
const codeVerifier = generateCodeVerifierServer();
|
||||
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||
const state = generateStateServer();
|
||||
|
||||
// Encrypt and store in httpOnly cookie
|
||||
const pendingData = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
const cookieStore = await cookies();
|
||||
const baseCookieOpts = getCookieOptions();
|
||||
cookieStore.set(SSO_PENDING_COOKIE, encrypted, {
|
||||
...baseCookieOpts,
|
||||
maxAge: SSO_PENDING_MAX_AGE,
|
||||
});
|
||||
|
||||
// Build authorize URL
|
||||
const authUrl = new URL(metadata.authorization_endpoint);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
if (locale) {
|
||||
authUrl.searchParams.set('ui_locales', locale);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authorize_url: authUrl.toString(),
|
||||
state,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('SSO start error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,9 @@
|
||||
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';
|
||||
|
||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
};
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
@@ -22,44 +13,6 @@ function getSlot(request: NextRequest): number {
|
||||
return slot;
|
||||
}
|
||||
|
||||
|
||||
function getRequiredConfig() {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
||||
if (!clientId || !serverUrl) {
|
||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
||||
}
|
||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
||||
if (issuerUrl !== undefined && !issuerUrl.trim()) {
|
||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
||||
}
|
||||
return { clientId, serverUrl, discoveryUrl };
|
||||
}
|
||||
|
||||
async function getTokenEndpoint(): Promise<string> {
|
||||
const { discoveryUrl } = getRequiredConfig();
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
if (!metadata?.token_endpoint) {
|
||||
throw new Error('OAuth token endpoint not found');
|
||||
}
|
||||
return metadata.token_endpoint;
|
||||
}
|
||||
|
||||
async function getMetadata(): Promise<import('@/lib/oauth/discovery').OAuthMetadata | null> {
|
||||
const { discoveryUrl } = getRequiredConfig();
|
||||
return discoverOAuth(discoveryUrl);
|
||||
}
|
||||
|
||||
function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
||||
const { clientId } = getRequiredConfig();
|
||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||
if (CLIENT_SECRET) {
|
||||
params.set('client_secret', CLIENT_SECRET);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||
@@ -69,43 +22,18 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
|
||||
const params = buildOAuthParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
});
|
||||
|
||||
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.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
|
||||
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
if (!tokens.access_token) {
|
||||
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
||||
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
||||
}
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
||||
|
||||
const response = NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in || 3600,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -154,7 +82,7 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -36,5 +36,8 @@ export async function GET() {
|
||||
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
||||
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
||||
demoMode: process.env.DEMO_MODE === 'true',
|
||||
autoSsoEnabled: process.env.AUTO_SSO_ENABLED === 'true',
|
||||
embeddedMode: !!process.env.ALLOWED_FRAME_ANCESTORS && process.env.ALLOWED_FRAME_ANCESTORS !== "'none'",
|
||||
parentOrigin: process.env.NEXT_PUBLIC_PARENT_ORIGIN || '',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user