From bd522250cc7aa01b6b6e1b62fd2bb33105cea9fe Mon Sep 17 00:00:00 2001 From: Muhammad Hammad Date: Sun, 25 Jan 2026 15:00:21 +0500 Subject: [PATCH] feat(console): Add Keycloak authentication support - Add AuthType type ('internal' | 'keycloak') to support multiple auth providers - Update auth.ts to handle both internal Polaris and Keycloak authentication - Add Keycloak proxy configuration in vite.config.ts for development - Update login UI with authentication type selector - Add conditional Keycloak realm field when using external auth - Store auth type and realms in localStorage for session persistence - Update .env.example with Keycloak configuration documentation This allows users to choose between: 1. Internal Polaris authentication (default) 2. External Keycloak authentication Fixes #98 --- console/.env.example | 8 ++++- console/src/api/auth.ts | 62 ++++++++++++++++++++++++++++------- console/src/hooks/useAuth.tsx | 26 ++++++++++++--- console/src/pages/Login.tsx | 44 ++++++++++++++++++++++--- console/src/types/api.ts | 3 ++ console/vite.config.ts | 20 +++++++++++ 6 files changed, 141 insertions(+), 22 deletions(-) diff --git a/console/.env.example b/console/.env.example index a7d9e8a1..455b1c0a 100644 --- a/console/.env.example +++ b/console/.env.example @@ -2,10 +2,16 @@ # The base URL for the Polaris API backend VITE_POLARIS_API_URL=http://polaris-polaris-1:8181 -# Polaris RealmI +# Polaris Realm # The realm identifier for Polaris VITE_POLARIS_REALM=POLARIS +# Keycloak Configuration (Optional) +# The base URL for Keycloak server when using external authentication +# Only required if you're using Keycloak for authentication +# Example: http://localhost:8080 +VITE_KEYCLOAK_URL= + # Docker Configuration # Port on which the UI will be accessible (default: 3000) PORT=3000 diff --git a/console/src/api/auth.ts b/console/src/api/auth.ts index 8fdad7e2..42e29045 100644 --- a/console/src/api/auth.ts +++ b/console/src/api/auth.ts @@ -21,41 +21,74 @@ import axios from "axios" import { apiClient } from "./client" import { navigate } from "@/lib/navigation" import { REALM_HEADER_NAME } from "@/lib/constants" -import type { OAuthTokenResponse } from "@/types/api" +import type { OAuthTokenResponse, AuthType } from "@/types/api" // Always use relative URL to go through the proxy (dev server or production server) // This avoids CORS issues by proxying requests through the server // The server.ts proxy handles /api routes in production, and Vite handles them in development -const TOKEN_URL = "/api/catalog/v1/oauth/tokens" +const INTERNAL_TOKEN_URL = "/api/catalog/v1/oauth/tokens" // Log OAuth URL in development only if (import.meta.env.DEV) { - console.log("🔐 Using OAuth token URL:", TOKEN_URL) + console.log("🔐 Using Internal OAuth token URL:", INTERNAL_TOKEN_URL) } export const authApi = { getToken: async ( clientId: string, clientSecret: string, - realm?: string + authType: AuthType, + realm?: string, + polarisRealm?: string ): Promise => { const formData = new URLSearchParams() - formData.append("grant_type", "client_credentials") formData.append("client_id", clientId) formData.append("client_secret", clientSecret) - formData.append("scope", "PRINCIPAL_ROLE:ALL") + + // Internal auth uses scope, external (Keycloak) uses grant_type + if (authType === "internal") { + formData.append("scope", "PRINCIPAL_ROLE:ALL") + formData.append("grant_type", "client_credentials") + } else { + formData.append("grant_type", "client_credentials") + } const headers: Record = { "Content-Type": "application/x-www-form-urlencoded", } - // Add realm header if provided - if (realm) { - headers[REALM_HEADER_NAME] = realm + let tokenUrl: string + + if (authType === "keycloak") { + // For Keycloak, use relative path that goes through proxy (dev server or production server) + // This avoids CORS issues by proxying requests through the server + // The vite.config.ts proxy handles /keycloak routes in development + // In production, a similar proxy should be configured on the server + if (!realm) { + throw new Error("Keycloak realm is required for Keycloak authentication") + } + // Use relative path that goes through proxy + tokenUrl = `/keycloak/realms/${realm}/protocol/openid-connect/token` + // Add Polaris realm header if provided (for Polaris API calls) + if (polarisRealm) { + headers[REALM_HEADER_NAME] = polarisRealm + } + } else { + // For internal, use the relative URL that goes through proxy + tokenUrl = INTERNAL_TOKEN_URL + // Add realm header if provided (for internal auth) + if (realm) { + headers[REALM_HEADER_NAME] = realm + } + } + + // Log token URL in development only + if (import.meta.env.DEV) { + console.log("🔐 Using token URL:", tokenUrl, "Auth type:", authType) } const response = await axios.post( - TOKEN_URL, + tokenUrl, formData, { headers, @@ -73,13 +106,14 @@ export const authApi = { subjectToken: string, subjectTokenType: string ): Promise => { + // Token exchange always uses internal endpoint const formData = new URLSearchParams() formData.append("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") formData.append("subject_token", subjectToken) formData.append("subject_token_type", subjectTokenType) const response = await axios.post( - TOKEN_URL, + INTERNAL_TOKEN_URL, formData, { headers: { @@ -97,13 +131,14 @@ export const authApi = { }, refreshToken: async (accessToken: string): Promise => { + // Token refresh always uses internal endpoint const formData = new URLSearchParams() formData.append("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") formData.append("subject_token", accessToken) formData.append("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") const response = await axios.post( - TOKEN_URL, + INTERNAL_TOKEN_URL, formData, { headers: { @@ -121,6 +156,9 @@ export const authApi = { logout: (): void => { apiClient.clearAccessToken() + localStorage.removeItem("polaris_realm") + localStorage.removeItem("polaris_auth_type") + localStorage.removeItem("polaris_keycloak_realm") // Use a small delay to allow toast to show before redirect setTimeout(() => { navigate("/login", true) diff --git a/console/src/hooks/useAuth.tsx b/console/src/hooks/useAuth.tsx index 255da899..5183b11b 100644 --- a/console/src/hooks/useAuth.tsx +++ b/console/src/hooks/useAuth.tsx @@ -20,10 +20,17 @@ import { createContext, useContext, useState, type ReactNode } from "react" import { toast } from "sonner" import { authApi } from "@/api/auth" +import type { AuthType } from "@/types/api" interface AuthContextType { isAuthenticated: boolean - login: (clientId: string, clientSecret: string, realm: string) => Promise + login: ( + clientId: string, + clientSecret: string, + authType: AuthType, + realm: string, + keycloakRealm?: string + ) => Promise logout: () => void loading: boolean } @@ -34,13 +41,24 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false) const [loading] = useState(false) - const login = async (clientId: string, clientSecret: string, realm: string) => { + const login = async ( + clientId: string, + clientSecret: string, + authType: AuthType, + realm: string, + keycloakRealm?: string + ) => { try { - // Store realm in localStorage (non-sensitive configuration) + // Store auth configuration in localStorage (non-sensitive configuration) + localStorage.setItem("polaris_auth_type", authType) if (realm) { localStorage.setItem("polaris_realm", realm) } - await authApi.getToken(clientId, clientSecret, realm) + if (keycloakRealm) { + localStorage.setItem("polaris_keycloak_realm", keycloakRealm) + } + + await authApi.getToken(clientId, clientSecret, authType, keycloakRealm, realm) setIsAuthenticated(true) } catch (error) { setIsAuthenticated(false) diff --git a/console/src/pages/Login.tsx b/console/src/pages/Login.tsx index 6d0980e3..da9bd994 100644 --- a/console/src/pages/Login.tsx +++ b/console/src/pages/Login.tsx @@ -26,12 +26,21 @@ import { Label } from "@/components/ui/label" import { Card, CardContent, CardHeader } from "@/components/ui/card" import { Logo } from "@/components/layout/Logo" import { Footer } from "@/components/layout/Footer" +import type { AuthType } from "@/types/api" export function Login() { const [clientId, setClientId] = useState("") const [clientSecret, setClientSecret] = useState("") - // Initialize realm with value from .env file if present - const [realm, setRealm] = useState(import.meta.env.VITE_POLARIS_REALM || "") + const [authType, setAuthType] = useState( + (localStorage.getItem("polaris_auth_type") as AuthType) || "internal" + ) + // Initialize realm with value from .env file or localStorage if present + const [realm, setRealm] = useState( + localStorage.getItem("polaris_realm") || import.meta.env.VITE_POLARIS_REALM || "" + ) + const [keycloakRealm, setKeycloakRealm] = useState( + localStorage.getItem("polaris_keycloak_realm") || "" + ) const [error, setError] = useState("") const [loading, setLoading] = useState(false) const { login } = useAuth() @@ -43,7 +52,7 @@ export function Login() { setLoading(true) try { - await login(clientId, clientSecret, realm) + await login(clientId, clientSecret, authType, realm, keycloakRealm) navigate("/") } catch (err) { setError( @@ -67,6 +76,18 @@ export function Login() {
+
+ + +
+ {authType === "keycloak" && ( +
+ + setKeycloakRealm(e.target.value)} + required + placeholder="Enter Keycloak realm (e.g., myrealm)" + /> +
+ )}
- + setRealm(e.target.value)} required - placeholder="Enter your realm" + placeholder="Enter your Polaris realm" />
{error && ( diff --git a/console/src/types/api.ts b/console/src/types/api.ts index ded04a99..3aab35fa 100644 --- a/console/src/types/api.ts +++ b/console/src/types/api.ts @@ -17,6 +17,9 @@ * under the License. */ +// Auth Types +export type AuthType = "internal" | "keycloak" + // Management Service API Types export interface StorageConfigInfo { diff --git a/console/vite.config.ts b/console/vite.config.ts index 11a3248b..e8823f47 100644 --- a/console/vite.config.ts +++ b/console/vite.config.ts @@ -72,6 +72,26 @@ export default defineConfig({ } }, }, + '/keycloak': { + target: process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080', + changeOrigin: true, + secure: false, + configure: (proxy) => { + // Only log in development mode + if (process.env.NODE_ENV === 'development') { + proxy.on('error', (err) => { + console.error('Keycloak Proxy error:', err); + }); + proxy.on('proxyReq', (proxyReq, req) => { + const target = process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080'; + console.log('📤 Proxying to Keycloak:', req.method, req.url, '→', target + proxyReq.path); + }); + proxy.on('proxyRes', (proxyRes, req) => { + console.log('Received Response from Keycloak:', proxyRes.statusCode, req.url); + }); + } + }, + }, }, }, })