feat: add non-interactive SSO login flow for embedded/iframe deployments (closes #69)
This commit is contained in:
@@ -13,7 +13,7 @@ function OAuthCallbackInner() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const { loginWithOAuth } = useAuthStore();
|
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,44 +32,71 @@ function OAuthCallbackInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
if (!state || state !== savedState) {
|
|
||||||
setError("invalid_state");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
if (savedState) {
|
||||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
||||||
|
if (!state || state !== savedState) {
|
||||||
|
setError("invalid_state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!codeVerifier || !serverUrl) {
|
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||||
setError("missing_params");
|
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
if (!codeVerifier || !serverUrl) {
|
||||||
|
setError("missing_params");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||||
.then((success) => {
|
|
||||||
if (success) {
|
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
||||||
sessionStorage.removeItem("oauth_state");
|
.then((success) => {
|
||||||
sessionStorage.removeItem("oauth_code_verifier");
|
if (success) {
|
||||||
sessionStorage.removeItem("oauth_server_url");
|
sessionStorage.removeItem("oauth_state");
|
||||||
sessionStorage.removeItem("oauth_add_account_mode");
|
sessionStorage.removeItem("oauth_code_verifier");
|
||||||
let redirectTo = `/${params.locale}`;
|
sessionStorage.removeItem("oauth_server_url");
|
||||||
try {
|
sessionStorage.removeItem("oauth_add_account_mode");
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
let redirectTo = `/${params.locale}`;
|
||||||
if (saved) {
|
try {
|
||||||
sessionStorage.removeItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
redirectTo = saved;
|
if (saved) {
|
||||||
}
|
sessionStorage.removeItem('redirect_after_login');
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
redirectTo = saved;
|
||||||
router.push(redirectTo);
|
}
|
||||||
} else {
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
|
router.push(redirectTo);
|
||||||
|
} else {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
});
|
||||||
})
|
} else if (state) {
|
||||||
.catch(() => {
|
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
||||||
setError("token_exchange_failed");
|
loginWithServerSso(code, state)
|
||||||
});
|
.then((success) => {
|
||||||
|
if (success) {
|
||||||
|
let redirectTo = `/${params.locale}`;
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
|
if (saved) {
|
||||||
|
sessionStorage.removeItem('redirect_after_login');
|
||||||
|
redirectTo = saved;
|
||||||
|
}
|
||||||
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
|
router.push(redirectTo);
|
||||||
|
} else {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError("invalid_state");
|
||||||
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||||
|
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
@@ -27,9 +28,11 @@ export default async function LocaleLayout({
|
|||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CalendarAlertProvider>
|
<CalendarAlertProvider>
|
||||||
<TourProvider>
|
<EmbeddedBridgeProvider>
|
||||||
{children}
|
<TourProvider>
|
||||||
</TourProvider>
|
{children}
|
||||||
|
</TourProvider>
|
||||||
|
</EmbeddedBridgeProvider>
|
||||||
</CalendarAlertProvider>
|
</CalendarAlertProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function LoginPage() {
|
|||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -173,6 +173,63 @@ export default function LoginPage() {
|
|||||||
});
|
});
|
||||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
||||||
|
|
||||||
|
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||||
|
const ssoError = searchParams.get("sso_error");
|
||||||
|
const autoSsoTriggered = useRef(false);
|
||||||
|
|
||||||
|
const startServerSideSso = useCallback(async () => {
|
||||||
|
setOauthLoading(true);
|
||||||
|
try {
|
||||||
|
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||||
|
const res = await fetch('/api/auth/sso/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setOauthLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { authorize_url } = await res.json();
|
||||||
|
|
||||||
|
// Navigate to the authorize URL
|
||||||
|
const isIframe = (() => { try { return window.self !== window.top; } catch { return true; } })();
|
||||||
|
if (isIframe) {
|
||||||
|
// In an iframe, try top-level navigation
|
||||||
|
try {
|
||||||
|
window.top!.location.href = authorize_url;
|
||||||
|
} catch {
|
||||||
|
// Cross-origin restriction — fall back to current frame
|
||||||
|
window.location.href = authorize_url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.location.href = authorize_url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOauthLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||||
|
if (ssoError || isAddAccountMode || isAuthenticated) return;
|
||||||
|
if (autoSsoTriggered.current) return;
|
||||||
|
|
||||||
|
// Guard against redirect loops
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem("sso_attempted")) return;
|
||||||
|
sessionStorage.setItem("sso_attempted", "1");
|
||||||
|
// Clear the flag after 30 seconds so retries are possible
|
||||||
|
setTimeout(() => { try { sessionStorage.removeItem("sso_attempted"); } catch { /* ignore */ } }, 30000);
|
||||||
|
} catch { /* sessionStorage unavailable */ }
|
||||||
|
|
||||||
|
autoSsoTriggered.current = true;
|
||||||
|
startServerSideSso();
|
||||||
|
}, [autoSsoEnabled, oauthOnly, oauthDiscoveryDone, oauthMetadata, ssoError, isAddAccountMode, isAuthenticated, startServerSideSso]);
|
||||||
|
|
||||||
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
setShowThemeMenu(false);
|
setShowThemeMenu(false);
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import { cookies } from 'next/headers';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
...getCookieOptions(),
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax' as const,
|
|
||||||
path: '/',
|
|
||||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
|
||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax' as const,
|
|
||||||
path: '/',
|
|
||||||
maxAge: 30 * 24 * 60 * 60,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSlot(request: NextRequest): number {
|
function getSlot(request: NextRequest): number {
|
||||||
const raw = request.nextUrl.searchParams.get('slot');
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
@@ -22,44 +13,6 @@ function getSlot(request: NextRequest): number {
|
|||||||
return slot;
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
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 slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
||||||
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 response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_in: tokens.expires_in || 3600,
|
expires_in: tokens.expires_in,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -154,7 +82,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -36,5 +36,8 @@ export async function GET() {
|
|||||||
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
||||||
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
||||||
demoMode: process.env.DEMO_MODE === 'true',
|
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 || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ export default async function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||||
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
{parentOrigin && (
|
||||||
|
<meta name="parent-origin" content={parentOrigin} />
|
||||||
|
)}
|
||||||
<script
|
<script
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { isEmbedded, listenFromParent } from "@/lib/iframe-bridge";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
|
||||||
|
export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { parentOrigin, embeddedMode } = useConfig();
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!embeddedMode || !isEmbedded()) return;
|
||||||
|
|
||||||
|
const unsubscribe = listenFromParent((msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "sso:trigger-login": {
|
||||||
|
// Navigate to login page to start SSO flow
|
||||||
|
const segments = window.location.pathname.split("/").filter(Boolean);
|
||||||
|
const locale = segments[0] || "en";
|
||||||
|
window.location.href = `/${locale}/login`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "sso:trigger-logout":
|
||||||
|
logout();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, parentOrigin || undefined);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [embeddedMode, parentOrigin, logout]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ interface ConfigData {
|
|||||||
loginPrivacyPolicyUrl: string;
|
loginPrivacyPolicyUrl: string;
|
||||||
loginWebsiteUrl: string;
|
loginWebsiteUrl: string;
|
||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
|
autoSsoEnabled: boolean;
|
||||||
|
embeddedMode: boolean;
|
||||||
|
parentOrigin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppConfig extends ConfigData {
|
interface AppConfig extends ConfigData {
|
||||||
@@ -93,6 +96,9 @@ export function useConfig(): AppConfig {
|
|||||||
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
||||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||||
demoMode: configCache?.demoMode || false,
|
demoMode: configCache?.demoMode || false,
|
||||||
|
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||||
|
embeddedMode: configCache?.embeddedMode || false,
|
||||||
|
parentOrigin: configCache?.parentOrigin || '',
|
||||||
isLoading: !configCache,
|
isLoading: !configCache,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -121,6 +127,9 @@ export function useConfig(): AppConfig {
|
|||||||
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
||||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||||
demoMode: configCache.demoMode,
|
demoMode: configCache.demoMode,
|
||||||
|
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||||
|
embeddedMode: configCache.embeddedMode,
|
||||||
|
parentOrigin: configCache.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -150,6 +159,9 @@ export function useConfig(): AppConfig {
|
|||||||
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
||||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||||
demoMode: data.demoMode,
|
demoMode: data.demoMode,
|
||||||
|
autoSsoEnabled: data.autoSsoEnabled,
|
||||||
|
embeddedMode: data.embeddedMode,
|
||||||
|
parentOrigin: data.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,3 +48,38 @@ export function decryptSession(token: string): { serverUrl: string; username: st
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encryptPayload(payload: Record<string, unknown>): string {
|
||||||
|
const key = getKey();
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptPayload(token: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const key = getKey();
|
||||||
|
const data = Buffer.from(token, 'base64');
|
||||||
|
if (data.length < IV_LENGTH + TAG_LENGTH) return null;
|
||||||
|
|
||||||
|
const iv = data.subarray(0, IV_LENGTH);
|
||||||
|
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||||
|
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
|
||||||
|
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||||
|
return JSON.parse(decrypted.toString('utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Payload decryption failed', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const PARENT_ORIGIN = typeof window !== 'undefined'
|
||||||
|
? (document.querySelector('meta[name="parent-origin"]')?.getAttribute('content') || '')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
export function isEmbedded(): boolean {
|
||||||
|
try {
|
||||||
|
return window.self !== window.top;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyParent(type: string, payload: Record<string, unknown> = {}) {
|
||||||
|
if (!isEmbedded()) return;
|
||||||
|
|
||||||
|
const targetOrigin = PARENT_ORIGIN || '*';
|
||||||
|
try {
|
||||||
|
window.parent.postMessage({ source: 'bulwark', type, ...payload }, targetOrigin);
|
||||||
|
} catch {
|
||||||
|
// Cross-origin postMessage may fail in restricted contexts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listenFromParent(
|
||||||
|
handler: (msg: { type: string; [k: string]: unknown }) => void,
|
||||||
|
allowedOrigin?: string,
|
||||||
|
): () => void {
|
||||||
|
const listener = (event: MessageEvent) => {
|
||||||
|
// Validate origin if configured
|
||||||
|
if (allowedOrigin && event.origin !== allowedOrigin) return;
|
||||||
|
|
||||||
|
// Only accept messages from the portal
|
||||||
|
if (!event.data || event.data.source !== 'portal') return;
|
||||||
|
|
||||||
|
handler(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', listener);
|
||||||
|
return () => window.removeEventListener('message', listener);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
|
||||||
|
|
||||||
|
export function getCookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: COOKIE_SAME_SITE === 'none' ? true : process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: COOKIE_SAME_SITE,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { randomBytes, createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
function base64urlEncode(buffer: Buffer): string {
|
||||||
|
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeVerifierServer(): string {
|
||||||
|
return base64urlEncode(randomBytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeChallengeServer(verifier: string): string {
|
||||||
|
const hash = createHash('sha256').update(verifier).digest();
|
||||||
|
return base64urlEncode(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStateServer(): string {
|
||||||
|
return base64urlEncode(randomBytes(32));
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
|
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||||
|
|
||||||
|
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||||
|
|
||||||
|
export 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMetadata(): Promise<OAuthMetadata | null> {
|
||||||
|
const { discoveryUrl } = getRequiredConfig();
|
||||||
|
return discoverOAuth(discoveryUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 interface TokenResult {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCodeForTokens(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<TokenResult> {
|
||||||
|
const tokenEndpoint = await getTokenEndpoint();
|
||||||
|
|
||||||
|
const params = buildOAuthParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
throw new Error('Token exchange failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
||||||
|
throw new Error('Invalid token response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in || 3600,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export function proxy(request: NextRequest) {
|
|||||||
|
|
||||||
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
|
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
|
||||||
|
|
||||||
|
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
|
||||||
|
|
||||||
const csp = [
|
const csp = [
|
||||||
`default-src 'self'`,
|
`default-src 'self'`,
|
||||||
`script-src ${scriptSrc}`,
|
`script-src ${scriptSrc}`,
|
||||||
@@ -25,7 +27,7 @@ export function proxy(request: NextRequest) {
|
|||||||
`object-src 'none'`,
|
`object-src 'none'`,
|
||||||
`base-uri 'self'`,
|
`base-uri 'self'`,
|
||||||
`form-action 'self'`,
|
`form-action 'self'`,
|
||||||
`frame-ancestors 'none'`,
|
`frame-ancestors ${frameAncestors}`,
|
||||||
].join("; ");
|
].join("; ");
|
||||||
|
|
||||||
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
||||||
@@ -44,7 +46,13 @@ export function proxy(request: NextRequest) {
|
|||||||
response.headers.set("x-middleware-request-x-nonce", nonce);
|
response.headers.set("x-middleware-request-x-nonce", nonce);
|
||||||
|
|
||||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
response.headers.set("X-Frame-Options", "DENY");
|
|
||||||
|
// X-Frame-Options only supports DENY/SAMEORIGIN. When frame-ancestors
|
||||||
|
// specifies explicit origins, we rely solely on the CSP header.
|
||||||
|
if (frameAncestors === "'none'") {
|
||||||
|
response.headers.set("X-Frame-Options", "DENY");
|
||||||
|
}
|
||||||
|
|
||||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
response.headers.set("X-XSS-Protection", "0");
|
response.headers.set("X-XSS-Protection", "0");
|
||||||
response.headers.set(
|
response.headers.set(
|
||||||
|
|||||||
+118
-1
@@ -13,6 +13,7 @@ import { fetchConfig } from '@/hooks/use-config';
|
|||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
import { generateAccountId } from '@/lib/account-utils';
|
import { generateAccountId } from '@/lib/account-utils';
|
||||||
import { replaceWindowLocation } from '@/lib/browser-navigation';
|
import { replaceWindowLocation } from '@/lib/browser-navigation';
|
||||||
|
import { notifyParent } from '@/lib/iframe-bridge';
|
||||||
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
|
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
|
||||||
import type { Identity } from '@/lib/jmap/types';
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ interface AuthState {
|
|||||||
|
|
||||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
||||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||||
|
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
|
||||||
loginDemo: () => Promise<boolean>;
|
loginDemo: () => Promise<boolean>;
|
||||||
refreshAccessToken: () => Promise<string | null>;
|
refreshAccessToken: () => Promise<string | null>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
@@ -534,6 +536,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||||
|
|
||||||
|
notifyParent('sso:auth-success', { username });
|
||||||
|
|
||||||
// Sync settings from server (only if enabled)
|
// Sync settings from server (only if enabled)
|
||||||
fetchConfig().then(config => {
|
fetchConfig().then(config => {
|
||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
@@ -550,9 +554,118 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('OAuth login error:', error);
|
debug.error('OAuth login error:', error);
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'generic';
|
||||||
|
notifyParent('sso:auth-failure', { error: errorMsg });
|
||||||
set({
|
set({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: error instanceof Error ? error.message : 'generic',
|
error: errorMsg,
|
||||||
|
isAuthenticated: false,
|
||||||
|
client: null,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loginWithServerSso: async (code, state) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie
|
||||||
|
const ssoRes = await fetch('/api/auth/sso/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ssoRes.ok) {
|
||||||
|
const errorData = await ssoRes.json().catch(() => ({ error: 'token_exchange_failed' }));
|
||||||
|
throw new Error(errorData.error || 'token_exchange_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { access_token, expires_in } = await ssoRes.json();
|
||||||
|
|
||||||
|
// We need the server URL from config
|
||||||
|
const config = await fetchConfig();
|
||||||
|
const ssoServerUrl = config.jmapServerUrl;
|
||||||
|
|
||||||
|
if (!ssoServerUrl) {
|
||||||
|
throw new Error('Server URL not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
|
||||||
|
const refreshFn = get().refreshAccessToken;
|
||||||
|
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
|
||||||
|
client.onConnectionChange((connected) => {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const username = client.getUsername();
|
||||||
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||||
|
initializeFeatureStores(client);
|
||||||
|
|
||||||
|
const accountId = generateAccountId(username, ssoServerUrl);
|
||||||
|
|
||||||
|
const prevAccountId = get().activeAccountId;
|
||||||
|
if (prevAccountId && prevAccountId !== accountId) {
|
||||||
|
snapshotAccount(prevAccountId);
|
||||||
|
clearAllStores();
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.set(accountId, client);
|
||||||
|
|
||||||
|
accountStore.addAccount({
|
||||||
|
label: primaryIdentity?.name || username,
|
||||||
|
serverUrl: ssoServerUrl,
|
||||||
|
username,
|
||||||
|
authMode: 'oauth',
|
||||||
|
rememberMe: true,
|
||||||
|
displayName: primaryIdentity?.name || username,
|
||||||
|
email: primaryIdentity?.email || username,
|
||||||
|
lastLoginAt: Date.now(),
|
||||||
|
isConnected: true,
|
||||||
|
hasError: false,
|
||||||
|
isDefault: accountStore.accounts.length === 0,
|
||||||
|
});
|
||||||
|
accountStore.setActiveAccount(accountId);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
serverUrl: ssoServerUrl,
|
||||||
|
username,
|
||||||
|
client,
|
||||||
|
identities,
|
||||||
|
primaryIdentity,
|
||||||
|
authMode: 'oauth',
|
||||||
|
accessToken: access_token,
|
||||||
|
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||||
|
|
||||||
|
notifyParent('sso:auth-success', { username });
|
||||||
|
|
||||||
|
fetchConfig().then(cfg => {
|
||||||
|
if (!cfg.settingsSyncEnabled) return;
|
||||||
|
useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => {
|
||||||
|
useSettingsStore.getState().enableSync(username, ssoServerUrl);
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Server SSO login error:', error);
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'generic';
|
||||||
|
notifyParent('sso:auth-failure', { error: errorMsg });
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMsg,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
client: null,
|
client: null,
|
||||||
});
|
});
|
||||||
@@ -576,6 +689,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const res = await fetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' });
|
const res = await fetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' });
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
notifyParent('sso:session-expired');
|
||||||
markSessionExpired();
|
markSessionExpired();
|
||||||
get().logout();
|
get().logout();
|
||||||
return null;
|
return null;
|
||||||
@@ -594,6 +708,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return access_token;
|
return access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Token refresh failed:', error);
|
debug.error('Token refresh failed:', error);
|
||||||
|
notifyParent('sso:session-expired');
|
||||||
markSessionExpired();
|
markSessionExpired();
|
||||||
get().logout();
|
get().logout();
|
||||||
return null;
|
return null;
|
||||||
@@ -694,6 +809,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// No accounts remaining (or demo mode) — full logout + redirect
|
// No accounts remaining (or demo mode) — full logout + redirect
|
||||||
performFullLogout(set);
|
performFullLogout(set);
|
||||||
|
|
||||||
|
notifyParent('sso:logout');
|
||||||
|
|
||||||
// Background cookie/token cleanup — keepalive ensures completion during navigation
|
// Background cookie/token cleanup — keepalive ensures completion during navigation
|
||||||
if (!wasDemoMode) {
|
if (!wasDemoMode) {
|
||||||
fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
|
fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user