Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions console/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
# 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

# Polaris Principal Scope
VITE_POLARIS_PRINCIPAL_SCOPE=PRINCIPAL_ROLE:ALL
# 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)
Expand Down
63 changes: 50 additions & 13 deletions console/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,42 +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,
scope: string,
realm?: string
authType: AuthType,
realm?: string,
polarisRealm?: string
): Promise<OAuthTokenResponse> => {
const formData = new URLSearchParams()
formData.append("grant_type", "client_credentials")
formData.append("client_id", clientId)
formData.append("client_secret", clientSecret)
formData.append("scope", scope)

// 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<string, string> = {
"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<OAuthTokenResponse>(
TOKEN_URL,
tokenUrl,
formData,
{
headers,
Expand All @@ -74,13 +106,14 @@ export const authApi = {
subjectToken: string,
subjectTokenType: string
): Promise<OAuthTokenResponse> => {
// 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<OAuthTokenResponse>(
TOKEN_URL,
INTERNAL_TOKEN_URL,
formData,
{
headers: {
Expand All @@ -98,13 +131,14 @@ export const authApi = {
},

refreshToken: async (accessToken: string): Promise<OAuthTokenResponse> => {
// 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<OAuthTokenResponse>(
TOKEN_URL,
INTERNAL_TOKEN_URL,
formData,
{
headers: {
Expand All @@ -122,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)
Expand Down
26 changes: 22 additions & 4 deletions console/src/hooks/useAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, scope: string, realm: string) => Promise<void>
login: (
clientId: string,
clientSecret: string,
authType: AuthType,
realm: string,
keycloakRealm?: string
) => Promise<void>
logout: () => void
loading: boolean
}
Expand All @@ -34,13 +41,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false)
const [loading] = useState<boolean>(false)

const login = async (clientId: string, clientSecret: string, scope: 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, scope, realm)
if (keycloakRealm) {
localStorage.setItem("polaris_keycloak_realm", keycloakRealm)
}

await authApi.getToken(clientId, clientSecret, authType, keycloakRealm, realm)
setIsAuthenticated(true)
} catch (error) {
setIsAuthenticated(false)
Expand Down
45 changes: 39 additions & 6 deletions console/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +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 [scope, setScope] = useState(import.meta.env.VITE_POLARIS_PRINCIPAL_SCOPE || "")
const [authType, setAuthType] = useState<AuthType>(
(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()
Expand All @@ -44,7 +52,7 @@ export function Login() {
setLoading(true)

try {
await login(clientId, clientSecret, scope, realm)
await login(clientId, clientSecret, authType, realm, keycloakRealm)
navigate("/")
} catch (err) {
setError(
Expand All @@ -68,6 +76,18 @@ export function Login() {
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="authType">Authentication Type</Label>
<select
id="authType"
value={authType}
onChange={(e) => setAuthType(e.target.value as AuthType)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="internal">Internal (Polaris)</option>
<option value="keycloak">External (Keycloak)</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="clientId">Client ID</Label>
<Input
Expand All @@ -90,15 +110,28 @@ export function Login() {
placeholder="Enter your client secret"
/>
</div>
{authType === "keycloak" && (
<div className="space-y-2">
<Label htmlFor="keycloakRealm">Keycloak Realm</Label>
<Input
id="keycloakRealm"
type="text"
value={keycloakRealm}
onChange={(e) => setKeycloakRealm(e.target.value)}
required
placeholder="Enter Keycloak realm (e.g., myrealm)"
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="realm">Realm</Label>
<Label htmlFor="realm">Polaris Realm</Label>
<Input
id="realm"
type="text"
value={realm}
onChange={(e) => setRealm(e.target.value)}
required
placeholder="Enter your realm"
placeholder="Enter your Polaris realm"
/>
</div>
<div className="space-y-2">
Expand Down
3 changes: 3 additions & 0 deletions console/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
* under the License.
*/

// Auth Types
export type AuthType = "internal" | "keycloak"

// Management Service API Types

export interface StorageConfigInfo {
Expand Down
20 changes: 20 additions & 0 deletions console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
},
},
},
},
})