diff --git a/README.md b/README.md index 970716ff2..d97413b11 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Set these environment variables if you need to change their defaults | CADENCE_WEB_PORT | HTTP port to serve on | 8088 | | CADENCE_WEB_HOSTNAME | Host name to serve on | 0.0.0.0 | | CADENCE_ADMIN_SECURITY_TOKEN | Admin token for accessing admin methods | '' | +| CADENCE_WEB_RBAC_ENABLED | Enables RBAC-aware UI (login/logout). | false | | CADENCE_GRPC_TLS_CA_FILE | Path to root CA certificate file for enabling one-way TLS on gRPC connections | '' | | CADENCE_WEB_SERVICE_NAME | Name of the web service used as GRPC caller and OTEL resource name | cadence-web | @@ -34,6 +35,20 @@ CADENCE_GRPC_SERVICES_NAMES=cadence-frontend-cluster0,cadence-frontend-cluster1 CADENCE_CLUSTERS_NAMES=cluster0,cluster1 ``` +#### RBAC Authentication (JWT cookie) + +When `CADENCE_WEB_RBAC_ENABLED=true`, cadence-web authenticates using a cookie: + +- Cookie name: `cadence-authorization` +- Cookie value: raw JWT string + +To integrate an upstream proxy / IdP, set the cookie for the cadence-web origin: + +``` +Set-Cookie: cadence-authorization=; Path=/; HttpOnly; SameSite=Lax; Secure +``` +You can also set/clear the cookie via `POST /api/auth/token` and `DELETE /api/auth/token`; or use `Login with JWT` button in the UI. + #### Feature flags Feature flags control various UI features and functionality in `cadence-web`. These can be configured using environment variables. diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 000000000..168ed03b8 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,13 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { + getPublicAuthContext, + resolveAuthContext, +} from '@/utils/auth/auth-context'; + +export async function GET(request: NextRequest) { + const authContext = await resolveAuthContext(request.cookies); + return NextResponse.json(getPublicAuthContext(authContext), { + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/src/app/api/auth/token/route.ts b/src/app/api/auth/token/route.ts new file mode 100644 index 000000000..dafc36b16 --- /dev/null +++ b/src/app/api/auth/token/route.ts @@ -0,0 +1,61 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { CADENCE_AUTH_COOKIE_NAME } from '@/utils/auth/auth-context'; + +const COOKIE_OPTIONS = { + httpOnly: true, + sameSite: 'lax' as const, + path: '/', +}; + +function getCookieSecureAttribute(request: NextRequest) { + const xfProto = request.headers.get('x-forwarded-proto'); + const proto = xfProto?.split(',')[0]?.trim().toLowerCase(); + if (proto) return proto === 'https'; + return request.nextUrl.protocol === 'https:'; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + if (!body?.token || typeof body.token !== 'string') { + return NextResponse.json( + { message: 'A valid token is required' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ); + } + + const normalizedToken = body.token.trim().replace(/^bearer\s+/i, ''); + if (!normalizedToken) { + return NextResponse.json( + { message: 'A valid token is required' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ); + } + + const response = NextResponse.json({ ok: true }); + response.headers.set('Cache-Control', 'no-store'); + response.cookies.set(CADENCE_AUTH_COOKIE_NAME, normalizedToken, { + ...COOKIE_OPTIONS, + secure: getCookieSecureAttribute(request), + }); + return response; + } catch { + return NextResponse.json( + { message: 'Invalid request body' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ); + } +} + +export async function DELETE(request: NextRequest) { + const response = NextResponse.json({ ok: true }); + response.headers.set('Cache-Control', 'no-store'); + response.cookies.set(CADENCE_AUTH_COOKIE_NAME, '', { + ...COOKIE_OPTIONS, + secure: getCookieSecureAttribute(request), + expires: new Date(0), + maxAge: 0, + }); + return response; +} diff --git a/src/components/app-nav-bar/app-nav-bar.tsx b/src/components/app-nav-bar/app-nav-bar.tsx index c30c221e3..db36bc344 100644 --- a/src/components/app-nav-bar/app-nav-bar.tsx +++ b/src/components/app-nav-bar/app-nav-bar.tsx @@ -1,29 +1,202 @@ 'use client'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + import { AppNavBar as BaseAppNavBar } from 'baseui/app-nav-bar'; +import { useSnackbar } from 'baseui/snackbar'; import NextLink from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; import useStyletronClasses from '@/hooks/use-styletron-classes'; +import useUserInfo from '@/hooks/use-user-info/use-user-info'; +import request from '@/utils/request'; + +import AuthTokenModal from '../auth-token-modal/auth-token-modal'; import { cssStyles } from './app-nav-bar.styles'; +const LOGIN_ITEM = 'login'; +const LOGOUT_ITEM = 'logout'; + export default function AppNavBar() { const { cls } = useStyletronClasses(cssStyles); - return ( - - - - - + const router = useRouter(); + const pathname = usePathname(); + const { enqueue } = useSnackbar(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { data: authInfo, isLoading: isAuthLoading, refetch } = useUserInfo(); + const isRbacEnabled = authInfo?.rbacEnabled === true; + const isAuthenticated = authInfo?.isAuthenticated === true; + const isAdmin = authInfo?.isAdmin === true; + const expiresAtMs = + typeof authInfo?.expiresAtMs === 'number' + ? authInfo.expiresAtMs + : undefined; + const logoutInFlightRef = useRef(false); + const prevIsAuthenticatedRef = useRef(null); + const logoutReasonRef = useRef<'manual' | 'expired' | null>(null); + + const userItems = useMemo(() => { + if (!isRbacEnabled) return undefined; + if (!isAuthenticated) { + return [{ label: 'Login with JWT', info: LOGIN_ITEM }]; + } + return [ + { label: 'Switch token', info: LOGIN_ITEM }, + { label: 'Logout', info: LOGOUT_ITEM }, + ]; + }, [isAuthenticated, isRbacEnabled]); + + const username = useMemo(() => { + if (!isRbacEnabled) { + return undefined; + } + if (isAuthLoading || !authInfo) { + return 'Checking access...'; + } + return isAuthenticated + ? authInfo.userName || 'Authenticated user (unknown username)' + : 'Authenticate'; + }, [authInfo, isAuthLoading, isAuthenticated, isRbacEnabled]); + + const usernameSubtitle = + isRbacEnabled && isAuthenticated + ? isAdmin + ? 'Admin' + : undefined + : isRbacEnabled + ? 'Provide a Cadence JWT' + : undefined; + + const saveToken = async (token: string) => { + try { + await request('/api/auth/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }); + await refetch(); + enqueue({ message: 'Token saved' }); + setIsModalOpen(false); + router.refresh(); + } catch (e) { + const message = + e instanceof Error ? e.message : 'Failed to save authentication token'; + enqueue({ message }); + throw e; + } + }; + + const logout = useCallback( + async (reason: 'manual' | 'expired') => { + if (logoutInFlightRef.current) return; + logoutInFlightRef.current = true; + logoutReasonRef.current = reason; + try { + await request('/api/auth/token', { method: 'DELETE' }); + } catch (e) { + logoutReasonRef.current = null; + const message = e instanceof Error ? e.message : 'Failed to sign out'; + enqueue({ message }); + } finally { + setIsModalOpen(false); + await refetch(); + router.refresh(); + router.replace('/domains'); + logoutInFlightRef.current = false; } - /> + }, + [enqueue, refetch, router] + ); + + useEffect(() => { + if (!isRbacEnabled || isAuthLoading || !authInfo) return; + const prevIsAuthenticated = prevIsAuthenticatedRef.current; + prevIsAuthenticatedRef.current = isAuthenticated; + + if (prevIsAuthenticated === true && !isAuthenticated) { + const reason = logoutReasonRef.current; + logoutReasonRef.current = null; + enqueue({ + message: + reason === 'manual' + ? 'Signed out' + : 'Session expired. Please sign in again.', + }); + if (pathname === '/domains') { + router.refresh(); + } else { + router.replace('/domains'); + } + } + }, [ + authInfo, + enqueue, + isAuthenticated, + isAuthLoading, + isRbacEnabled, + pathname, + router, + ]); + + useEffect(() => { + if (!isRbacEnabled || !isAuthenticated || expiresAtMs === undefined) return; + const timeoutMs = expiresAtMs - Date.now(); + if (timeoutMs <= 0) { + void logout('expired'); + return; + } + + const id = window.setTimeout(() => { + void logout('expired'); + }, timeoutMs); + + return () => { + window.clearTimeout(id); + }; + }, [expiresAtMs, isAuthenticated, isRbacEnabled, logout]); + + return ( + <> + + + + + + } + username={userItems ? username : undefined} + usernameSubtitle={userItems ? usernameSubtitle : undefined} + userItems={userItems} + onUserItemSelect={(item) => { + if (item.info === LOGIN_ITEM) { + setIsModalOpen(true); + } else if (item.info === LOGOUT_ITEM) { + void logout('manual'); + } + }} + /> + {isRbacEnabled && ( + setIsModalOpen(false)} + onSubmit={saveToken} + /> + )} + ); } diff --git a/src/components/auth-token-modal/auth-token-modal.tsx b/src/components/auth-token-modal/auth-token-modal.tsx new file mode 100644 index 000000000..de3e65519 --- /dev/null +++ b/src/components/auth-token-modal/auth-token-modal.tsx @@ -0,0 +1,85 @@ +'use client'; +import React, { useState } from 'react'; + +import { FormControl } from 'baseui/form-control'; +import { + Modal, + ModalBody, + ModalButton, + ModalFooter, + ModalHeader, +} from 'baseui/modal'; +import { Textarea } from 'baseui/textarea'; + +type Props = { + isOpen: boolean; + onClose: () => void; + onSubmit: (token: string) => Promise | void; +}; + +export default function AuthTokenModal({ isOpen, onClose, onSubmit }: Props) { + const [token, setToken] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + if (!token.trim()) { + setError('Please paste a JWT token first'); + return; + } + + setIsSubmitting(true); + setError(null); + try { + await onSubmit(token.trim()); + setToken(''); + } catch (e) { + setError( + e instanceof Error ? e.message : 'Failed to save authentication token' + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + Authenticate with JWT + + +