This is an automated email from the ASF dual-hosted git repository.

jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris-tools.git


The following commit(s) were added to refs/heads/main by this push:
     new d92e689  feat: Add OIDC authentication support to Polaris Console 
(#168)
d92e689 is described below

commit d92e689c57956d9fe653827a8af6ee030e9aeddb
Author: Artur Rakhmatulin <[email protected]>
AuthorDate: Wed Feb 25 16:22:08 2026 +0000

    feat: Add OIDC authentication support to Polaris Console (#168)
    
    * feat: add oidc iter 1
    
    * feat: add PopoverContent ui component
    
    * feat: add OIDC config
    
    * feat: unification using config.REALM_HEADER_NAME and config.POLARIS_REALM
    
    * feat: using OIDC_ISSUER_URL
    
    * feat: cleanup logs
    
    * feat: oidc login error handling
    
    * feat: update REAMDE
    
    * feat: fix linting
    
    * feat: fix "Sign in"
---
 console/.env.example                     |  17 ++++-
 console/README.md                        |  76 +++++++++++++++++-
 console/docker/generate-config.sh        |   6 +-
 console/src/api/auth.ts                  | 126 +++++++++++++++++++++++++++---
 console/src/api/client.ts                |  20 ++---
 console/src/app.tsx                      |   2 +
 console/src/components/layout/Header.tsx |  15 +---
 console/src/components/ui/popover.tsx    |  47 ++++++++++++
 console/src/hooks/useAuth.tsx            |  39 +++++++---
 console/src/lib/config.ts                |  10 ++-
 console/src/lib/constants.ts             |   5 --
 console/src/lib/oidc-discovery.ts        |  93 ++++++++++++++++++++++
 console/src/lib/pkce.ts                  |  85 +++++++++++++++++++++
 console/src/pages/AuthCallback.tsx       | 103 +++++++++++++++++++++++++
 console/src/pages/Login.tsx              | 127 +++++++++++++++++++++++--------
 console/src/types/api.ts                 |   3 +
 16 files changed, 685 insertions(+), 89 deletions(-)

diff --git a/console/.env.example b/console/.env.example
index 36e02d7..23acbfb 100644
--- a/console/.env.example
+++ b/console/.env.example
@@ -1,14 +1,23 @@
 # Polaris API Configuration
-# The base URL for the Polaris API backend
 VITE_POLARIS_API_URL=http://polaris-polaris-1:8181
 
-# Polaris RealmI
-# The realm identifier for Polaris
+# Polaris Realm
 VITE_POLARIS_REALM=POLARIS
 
+# Polaris Realm Header Name (optional, defaults to "Polaris-Realm")
+VITE_POLARIS_REALM_HEADER_NAME=Polaris-Realm
+
 # Polaris Principal Scope
 VITE_POLARIS_PRINCIPAL_SCOPE=PRINCIPAL_ROLE:ALL
 
+# OAuth Token URL (optional, defaults to 
${VITE_POLARIS_API_URL}/api/catalog/v1/oauth/tokens)
+# 
VITE_OAUTH_TOKEN_URL=http://polaris-polaris-1:8181/api/catalog/v1/oauth/tokens
+
+# OIDC Configuration (optional, for OIDC authentication)
+# VITE_OIDC_ISSUER_URL=https://your-idp.com/realms/your-realm
+# VITE_OIDC_CLIENT_ID=your-client-id
+# VITE_OIDC_REDIRECT_URI=http://localhost:3000/auth/callback
+# VITE_OIDC_SCOPE=openid profile email
+
 # Docker Configuration
-# Port on which the UI will be accessible (default: 3000)
 PORT=3000
diff --git a/console/README.md b/console/README.md
index cb904be..4d117f3 100644
--- a/console/README.md
+++ b/console/README.md
@@ -44,11 +44,20 @@ make build
 Create a `.env` file based on `.env.example`:
 
 ```env
+# Required
 VITE_POLARIS_API_URL=http://localhost:8181
 VITE_POLARIS_REALM=POLARIS
 VITE_POLARIS_PRINCIPAL_SCOPE=PRINCIPAL_ROLE:ALL
-VITE_POLARIS_REALM_HEADER_NAME=Polaris-Realm  # optional, defaults to 
"Polaris-Realm"
-VITE_OAUTH_TOKEN_URL=http://localhost:8181/api/catalog/v1/oauth/tokens  # 
optional, defaults to ${VITE_POLARIS_API_URL}/api/catalog/v1/oauth/tokens
+
+# Optional
+VITE_POLARIS_REALM_HEADER_NAME=Polaris-Realm  # defaults to "Polaris-Realm"
+VITE_OAUTH_TOKEN_URL=http://localhost:8181/api/catalog/v1/oauth/tokens  # 
defaults to ${VITE_POLARIS_API_URL}/api/catalog/v1/oauth/tokens
+
+# OIDC Authentication (optional)
+VITE_OIDC_ISSUER_URL=http://localhost:8080/realms/EXTERNAL
+VITE_OIDC_CLIENT_ID=polaris-console
+VITE_OIDC_REDIRECT_URI=http://localhost:5173/auth/callback
+VITE_OIDC_SCOPE=openid profile email
 ```
 
 > **Note:** The console makes direct API calls to the Polaris server. Ensure 
 > CORS is properly configured on the server (see below).
@@ -116,6 +125,47 @@ advancedConfig:
 
 See [Quarkus CORS documentation](https://quarkus.io/guides/security-cors) for 
more details.
 
+## Authentication
+
+The console supports two authentication methods:
+
+### 1. Client Credentials (Default)
+
+Standard OAuth 2.0 client credentials flow using username/password. This is 
the default authentication method.
+
+### 2. OIDC Authentication (Optional)
+
+The console supports OpenID Connect (OIDC) authentication with PKCE flow. When 
configured, users can authenticate using an external identity provider (e.g., 
Keycloak, Auth0, Okta).
+
+#### OIDC Configuration
+
+Set these environment variables to enable OIDC:
+
+```env
+VITE_OIDC_ISSUER_URL=http://localhost:8080/realms/EXTERNAL
+VITE_OIDC_CLIENT_ID=polaris-console
+VITE_OIDC_REDIRECT_URI=http://localhost:5173/auth/callback
+VITE_OIDC_SCOPE=openid profile email
+```
+
+**Configuration Details:**
+
+- `VITE_OIDC_ISSUER_URL`: Your OIDC provider's issuer URL. The console will 
automatically discover endpoints using `.well-known/openid-configuration`
+- `VITE_OIDC_CLIENT_ID`: Client ID registered with your OIDC provider
+- `VITE_OIDC_REDIRECT_URI`: Callback URL where the OIDC provider redirects 
after authentication (must match your app URL + `/auth/callback`)
+- `VITE_OIDC_SCOPE`: OAuth scopes to request (typically `openid profile email`)
+
+#### OIDC Provider Setup (Keycloak Example)
+
+1. Create a new client in Keycloak
+2. Set **Client ID** to match `VITE_OIDC_CLIENT_ID`
+3. Set **Access Type** to `public` (PKCE flow)
+4. Add **Valid Redirect URIs**: `http://localhost:5173/auth/callback`
+5. Enable **Standard Flow** (Authorization Code Flow)
+6. Configure token claims to include user principal information
+
+**Note:** Both the console and Polaris server must use the same OIDC provider.
+
 ## Project Structure
 
 ```
@@ -193,7 +243,21 @@ Then, you run Polaris Console using:
 docker run -p 8080:80 \
   -e VITE_POLARIS_API_URL=http://polaris:8181 \
   -e VITE_POLARIS_REALM=POLARIS \
-  -e VITE_POLARIS_PRINCIPAL_SCOPE=PRINCIPAL_ROLE:ALL
+  -e VITE_POLARIS_PRINCIPAL_SCOPE=PRINCIPAL_ROLE:ALL \
+  apache/polaris-console:latest
+```
+
+To enable OIDC authentication, add OIDC environment variables:
+
+```bash
+docker run -p 8080:80 \
+  -e VITE_POLARIS_API_URL=http://polaris:8181 \
+  -e VITE_POLARIS_REALM=POLARIS \
+  -e VITE_POLARIS_PRINCIPAL_SCOPE=PRINCIPAL_ROLE:ALL \
+  -e VITE_OIDC_ISSUER_URL=http://keycloak:8080/realms/EXTERNAL \
+  -e VITE_OIDC_CLIENT_ID=polaris-console \
+  -e VITE_OIDC_REDIRECT_URI=http://localhost:8080/auth/callback \
+  -e VITE_OIDC_SCOPE="openid profile email" \
   apache/polaris-console:latest
 ```
 
@@ -238,6 +302,12 @@ env:
   polarisRealm: "POLARIS"
   oauthTokenUrl: "http://polaris:8181/api/catalog/v1/oauth/tokens";
 
+  # OIDC Configuration (optional)
+  oidcIssuerUrl: "http://keycloak:8080/realms/EXTERNAL";
+  oidcClientId: "polaris-console"
+  oidcRedirectUri: "http://localhost:4000/auth/callback";
+  oidcScope: "openid profile email"
+
 service:
   type: ClusterIP
   port: 80
diff --git a/console/docker/generate-config.sh 
b/console/docker/generate-config.sh
index f5b182f..3f311dd 100644
--- a/console/docker/generate-config.sh
+++ b/console/docker/generate-config.sh
@@ -29,7 +29,11 @@ window.APP_CONFIG = {
   VITE_POLARIS_REALM: '${VITE_POLARIS_REALM}',
   VITE_POLARIS_PRINCIPAL_SCOPE: '${VITE_POLARIS_PRINCIPAL_SCOPE}',
   VITE_OAUTH_TOKEN_URL: '${VITE_OAUTH_TOKEN_URL}',
-  VITE_POLARIS_REALM_HEADER_NAME: '${VITE_POLARIS_REALM_HEADER_NAME}'
+  VITE_POLARIS_REALM_HEADER_NAME: '${VITE_POLARIS_REALM_HEADER_NAME}',
+  VITE_OIDC_ISSUER_URL: '${VITE_OIDC_ISSUER_URL}',
+  VITE_OIDC_CLIENT_ID: '${VITE_OIDC_CLIENT_ID}',
+  VITE_OIDC_REDIRECT_URI: '${VITE_OIDC_REDIRECT_URI}',
+  VITE_OIDC_SCOPE: '${VITE_OIDC_SCOPE}'
 };
 EOF
 
diff --git a/console/src/api/auth.ts b/console/src/api/auth.ts
index 11bd42d..26b370d 100644
--- a/console/src/api/auth.ts
+++ b/console/src/api/auth.ts
@@ -20,22 +20,26 @@
 import axios from "axios"
 import { apiClient } from "./client"
 import { navigate } from "@/lib/navigation"
-import { REALM_HEADER_NAME } from "@/lib/constants"
 import { config } from "@/lib/config"
 import type { OAuthTokenResponse } from "@/types/api"
+import {
+  generatePKCE,
+  generateState,
+  storePKCEVerifier,
+  getPKCEVerifier,
+  storeState,
+  getState,
+  clearPKCESession,
+} from "@/lib/pkce"
+import { discoverOIDCEndpoints } from "@/lib/oidc-discovery"
 
 const TOKEN_URL = config.OAUTH_TOKEN_URL || 
`${config.POLARIS_API_URL}/api/catalog/v1/oauth/tokens`
 
-if (import.meta.env.DEV) {
-  console.log("🔐 Using OAuth token URL:", TOKEN_URL)
-}
-
 export const authApi = {
   getToken: async (
     clientId: string,
     clientSecret: string,
-    scope: string,
-    realm?: string
+    scope: string
   ): Promise<OAuthTokenResponse> => {
     const formData = new URLSearchParams()
     formData.append("grant_type", "client_credentials")
@@ -47,9 +51,8 @@ export const authApi = {
       "Content-Type": "application/x-www-form-urlencoded",
     }
 
-    // Add realm header if provided
-    if (realm) {
-      headers[REALM_HEADER_NAME] = realm
+    if (config.POLARIS_REALM) {
+      headers[config.REALM_HEADER_NAME] = config.POLARIS_REALM
     }
 
     const response = await axios.post<OAuthTokenResponse>(TOKEN_URL, formData, 
{
@@ -107,9 +110,110 @@ export const authApi = {
 
   logout: (): void => {
     apiClient.clearAccessToken()
-    // Use a small delay to allow toast to show before redirect
+    clearPKCESession()
     setTimeout(() => {
       navigate("/login", true)
     }, 100)
   },
+
+  initiateOIDCFlow: async (): Promise<void> => {
+    const issuerUrl = config.OIDC_ISSUER_URL
+    const clientId = config.OIDC_CLIENT_ID
+    const redirectUri = config.OIDC_REDIRECT_URI
+    const scope = config.OIDC_SCOPE
+
+    if (!issuerUrl || !clientId || !redirectUri) {
+      throw new Error("OIDC configuration is incomplete. Please check 
environment variables.")
+    }
+
+    clearPKCESession()
+
+    const discovery = await discoverOIDCEndpoints(issuerUrl)
+    const authorizationUrl = discovery.authorization_endpoint
+
+    const { verifier, challenge } = await generatePKCE()
+    const state = generateState()
+
+    storePKCEVerifier(verifier)
+    storeState(state)
+
+    const params = new URLSearchParams({
+      response_type: "code",
+      client_id: clientId,
+      redirect_uri: redirectUri,
+      scope: scope,
+      state: state,
+      code_challenge: challenge,
+      code_challenge_method: "S256",
+      prompt: "login",
+    })
+
+    window.location.href = `${authorizationUrl}?${params.toString()}`
+  },
+
+  handleOIDCCallback: async (code: string, state: string): 
Promise<OAuthTokenResponse> => {
+    const storedState = getState()
+    if (!storedState || storedState !== state) {
+      clearPKCESession()
+      throw new Error("Invalid state parameter. Possible CSRF attack.")
+    }
+
+    const verifier = getPKCEVerifier()
+    if (!verifier) {
+      clearPKCESession()
+      throw new Error("Code verifier not found. Please restart the login 
process.")
+    }
+
+    const redirectUri = config.OIDC_REDIRECT_URI
+    if (!redirectUri) {
+      clearPKCESession()
+      throw new Error("Redirect URI not configured.")
+    }
+
+    try {
+      const oidcTokenResponse = await authApi.exchangeAuthCode(code, verifier, 
redirectUri)
+      clearPKCESession()
+      return oidcTokenResponse
+    } catch (error) {
+      clearPKCESession()
+      throw error
+    }
+  },
+
+  exchangeAuthCode: async (
+    code: string,
+    codeVerifier: string,
+    redirectUri: string
+  ): Promise<OAuthTokenResponse> => {
+    const issuerUrl = config.OIDC_ISSUER_URL
+    const clientId = config.OIDC_CLIENT_ID
+
+    if (!issuerUrl || !clientId) {
+      throw new Error("OIDC configuration is incomplete. Please check 
environment variables.")
+    }
+
+    const discovery = await discoverOIDCEndpoints(issuerUrl)
+    const tokenUrl = discovery.token_endpoint
+
+    const formData = new URLSearchParams()
+    formData.append("grant_type", "authorization_code")
+    formData.append("code", code)
+    formData.append("client_id", clientId)
+    formData.append("redirect_uri", redirectUri)
+    formData.append("code_verifier", codeVerifier)
+
+    const headers: Record<string, string> = {
+      "Content-Type": "application/x-www-form-urlencoded",
+    }
+
+    const response = await axios.post<OAuthTokenResponse>(tokenUrl, formData, {
+      headers,
+    })
+
+    if (response.data.access_token) {
+      apiClient.setAccessToken(response.data.access_token)
+    }
+
+    return response.data
+  },
 }
diff --git a/console/src/api/client.ts b/console/src/api/client.ts
index f82d811..0a0e41d 100644
--- a/console/src/api/client.ts
+++ b/console/src/api/client.ts
@@ -19,10 +19,9 @@
 
 import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 
"axios"
 import { navigate } from "@/lib/navigation"
-import { REALM_HEADER_NAME } from "@/lib/constants"
-import { config } from "@/lib/config"
+import { config as appConfig } from "@/lib/config"
 
-const API_BASE_URL = config.POLARIS_API_URL
+const API_BASE_URL = appConfig.POLARIS_API_URL
 const MANAGEMENT_BASE_URL = `${API_BASE_URL}/api/management/v1`
 const CATALOG_BASE_URL = `${API_BASE_URL}/api/catalog/v1`
 const GENERIC_TABLES_BASE_URL = `${API_BASE_URL}/api/catalog/polaris/v1`
@@ -31,7 +30,6 @@ class ApiClient {
   private managementClient: AxiosInstance
   private catalogClient: AxiosInstance
   private polarisClient: AxiosInstance
-  // Store access token in memory only (not in localStorage for security)
   private accessToken: string | null = null
 
   constructor() {
@@ -60,29 +58,28 @@ class ApiClient {
   }
 
   private setupInterceptors() {
-    // Request interceptor to add auth token
     const requestInterceptor = (config: InternalAxiosRequestConfig) => {
       const token = this.getAccessToken()
-      // Read realm from localStorage (non-sensitive configuration)
-      const realm = localStorage.getItem("polaris_realm") || 
import.meta.env.VITE_POLARIS_REALM
 
       if (token) {
         config.headers.Authorization = `Bearer ${token}`
       }
 
-      if (realm) {
-        config.headers[REALM_HEADER_NAME] = realm
+      if (appConfig.POLARIS_REALM) {
+        config.headers[appConfig.REALM_HEADER_NAME] = appConfig.POLARIS_REALM
       }
 
       return config
     }
 
-    // Response interceptor for error handling
     const responseErrorInterceptor = (error: unknown) => {
       if (axios.isAxiosError(error)) {
         if (error.response?.status === 401) {
-          // Unauthorized - clear token and redirect to login
           this.clearAccessToken()
+          const errorMessage =
+            error.response?.data?.message ||
+            "Authentication failed. User may not exist in Polaris or token is 
invalid."
+          sessionStorage.setItem("auth_error", errorMessage)
           navigate("/login", true)
         }
       }
@@ -106,7 +103,6 @@ class ApiClient {
 
   clearAccessToken(): void {
     this.accessToken = null
-    localStorage.removeItem("polaris_realm")
   }
 
   setAccessToken(token: string): void {
diff --git a/console/src/app.tsx b/console/src/app.tsx
index aa8e09a..dbcc367 100644
--- a/console/src/app.tsx
+++ b/console/src/app.tsx
@@ -26,6 +26,7 @@ import { Layout } from "@/components/layout/Layout"
 import { ProtectedRoute } from "@/components/layout/ProtectedRoute"
 import { ErrorBoundary } from "@/components/ErrorBoundary"
 import { Login } from "@/pages/Login"
+import { AuthCallback } from "@/pages/AuthCallback"
 import { Home } from "@/pages/Home"
 import { Connections } from "@/pages/Connections"
 import { Catalogs } from "@/pages/Catalogs"
@@ -58,6 +59,7 @@ function App() {
             <BrowserRouter>
               <Routes>
                 <Route path="/login" element={<Login />} />
+                <Route path="/auth/callback" element={<AuthCallback />} />
                 <Route
                   element={
                     <ProtectedRoute>
diff --git a/console/src/components/layout/Header.tsx 
b/console/src/components/layout/Header.tsx
index 71206c1..7f1d42c 100644
--- a/console/src/components/layout/Header.tsx
+++ b/console/src/components/layout/Header.tsx
@@ -17,11 +17,11 @@
  * under the License.
  */
 
-import { useState, useEffect } from "react"
 import { LogOut, ChevronDown, Sun, Moon, Monitor } from "lucide-react"
 import { useAuth } from "@/hooks/useAuth"
 import { useCurrentUser } from "@/hooks/useCurrentUser"
 import { useTheme } from "@/hooks/useTheme"
+import { config } from "@/lib/config"
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -36,13 +36,6 @@ export function Header() {
   const { logout } = useAuth()
   const { principal, principalRoles, loading } = useCurrentUser()
   const { theme, setTheme } = useTheme()
-  const [realm, setRealm] = useState<string | null>(null)
-
-  // Get realm from localStorage
-  useEffect(() => {
-    const storedRealm = localStorage.getItem("polaris_realm")
-    setRealm(storedRealm)
-  }, [])
 
   // Get display name and role
   const displayName =
@@ -114,9 +107,9 @@ export function Header() {
               <div className="text-xs text-muted-foreground truncate">
                 {loading ? "..." : primaryRole}
               </div>
-              {realm && (
-                <div className="text-xs text-muted-foreground/70 
truncate">Realm: {realm}</div>
-              )}
+              <div className="text-xs text-muted-foreground/70 truncate">
+                {config.POLARIS_REALM}
+              </div>
             </div>
             <ChevronDown className="h-4 w-4 text-muted-foreground 
flex-shrink-0" />
           </button>
diff --git a/console/src/components/ui/popover.tsx 
b/console/src/components/ui/popover.tsx
new file mode 100644
index 0000000..d7023ba
--- /dev/null
+++ b/console/src/components/ui/popover.tsx
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+import { cn } from "@/lib/utils"
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverContent = React.forwardRef<
+  React.ElementRef<typeof PopoverPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+  <PopoverPrimitive.Portal>
+    <PopoverPrimitive.Content
+      ref={ref}
+      align={align}
+      sideOffset={sideOffset}
+      className={cn(
+        "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground 
shadow-md outline-none data-[state=open]:animate-in 
data-[state=closed]:animate-out data-[state=closed]:fade-out-0 
data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 
data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 
data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 
data-[side=top]:slide-in-from-bottom-2",
+        className
+      )}
+      {...props}
+    />
+  </PopoverPrimitive.Portal>
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/console/src/hooks/useAuth.tsx b/console/src/hooks/useAuth.tsx
index 5e366ef..cfa289b 100644
--- a/console/src/hooks/useAuth.tsx
+++ b/console/src/hooks/useAuth.tsx
@@ -20,10 +20,13 @@
 import { createContext, useContext, useState, type ReactNode } from "react"
 import { toast } from "sonner"
 import { authApi } from "@/api/auth"
+import { apiClient } from "@/api/client"
 
 interface AuthContextType {
   isAuthenticated: boolean
-  login: (clientId: string, clientSecret: string, scope: string, realm: 
string) => Promise<void>
+  login: (clientId: string, clientSecret: string, scope: string) => 
Promise<void>
+  loginWithOIDC: () => Promise<void>
+  completeOIDCLogin: (code: string, state: string) => Promise<void>
   logout: () => void
   loading: boolean
 }
@@ -34,13 +37,9 @@ 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, scope: string) 
=> {
     try {
-      // Store realm in localStorage (non-sensitive configuration)
-      if (realm) {
-        localStorage.setItem("polaris_realm", realm)
-      }
-      await authApi.getToken(clientId, clientSecret, scope, realm)
+      await authApi.getToken(clientId, clientSecret, scope)
       setIsAuthenticated(true)
     } catch (error) {
       setIsAuthenticated(false)
@@ -48,14 +47,36 @@ export function AuthProvider({ children }: { children: 
ReactNode }) {
     }
   }
 
+  const loginWithOIDC = async () => {
+    try {
+      await authApi.initiateOIDCFlow()
+    } catch (error) {
+      setIsAuthenticated(false)
+      throw error
+    }
+  }
+
+  const completeOIDCLogin = async (code: string, state: string) => {
+    try {
+      await authApi.handleOIDCCallback(code, state)
+      setIsAuthenticated(true)
+    } catch (error) {
+      setIsAuthenticated(false)
+      apiClient.clearAccessToken()
+      throw error
+    }
+  }
+
   const logout = () => {
     toast.success("Logged out successfully")
-    authApi.logout()
     setIsAuthenticated(false)
+    authApi.logout()
   }
 
   return (
-    <AuthContext.Provider value={{ isAuthenticated, login, logout, loading }}>
+    <AuthContext.Provider
+      value={{ isAuthenticated, login, loginWithOIDC, completeOIDCLogin, 
logout, loading }}
+    >
       {children}
     </AuthContext.Provider>
   )
diff --git a/console/src/lib/config.ts b/console/src/lib/config.ts
index d5a64b0..b2e9498 100644
--- a/console/src/lib/config.ts
+++ b/console/src/lib/config.ts
@@ -23,6 +23,10 @@ interface AppConfig {
   VITE_POLARIS_PRINCIPAL_SCOPE: string
   VITE_OAUTH_TOKEN_URL?: string
   VITE_POLARIS_REALM_HEADER_NAME?: string
+  VITE_OIDC_ISSUER_URL?: string
+  VITE_OIDC_CLIENT_ID?: string
+  VITE_OIDC_REDIRECT_URI?: string
+  VITE_OIDC_SCOPE?: string
 }
 
 declare global {
@@ -50,8 +54,12 @@ function getConfig<T extends string | undefined>(key: keyof 
AppConfig, defaultVa
 
 export const config = {
   POLARIS_API_URL: getConfig("VITE_POLARIS_API_URL", ""),
-  POLARIS_REALM: getConfig("VITE_POLARIS_REALM", ""),
+  POLARIS_REALM: getConfig("VITE_POLARIS_REALM", "POLARIS"),
   POLARIS_PRINCIPAL_SCOPE: getConfig("VITE_POLARIS_PRINCIPAL_SCOPE", ""),
   OAUTH_TOKEN_URL: getConfig("VITE_OAUTH_TOKEN_URL", ""),
   REALM_HEADER_NAME: getConfig("VITE_POLARIS_REALM_HEADER_NAME", 
"Polaris-Realm"),
+  OIDC_ISSUER_URL: getConfig("VITE_OIDC_ISSUER_URL", ""),
+  OIDC_CLIENT_ID: getConfig("VITE_OIDC_CLIENT_ID", ""),
+  OIDC_REDIRECT_URI: getConfig("VITE_OIDC_REDIRECT_URI", ""),
+  OIDC_SCOPE: getConfig("VITE_OIDC_SCOPE", "openid profile email"),
 }
diff --git a/console/src/lib/constants.ts b/console/src/lib/constants.ts
index 46b898f..893623c 100644
--- a/console/src/lib/constants.ts
+++ b/console/src/lib/constants.ts
@@ -47,11 +47,6 @@ export const NAV_ITEMS = [
   { path: "/access-control", label: "Access Control", icon: "Shield" },
 ] as const
 
-// Realm header name configuration
-// Defaults to "Polaris-Realm" if not specified in environment variables
-// Can be configured via VITE_POLARIS_REALM_HEADER_NAME environment variable
-export const REALM_HEADER_NAME = 
import.meta.env.VITE_POLARIS_REALM_HEADER_NAME || "Polaris-Realm"
-
 // Catalog Explorer resize configuration
 export const CATALOG_EXPLORER_STORAGE_KEY = "catalog-explorer-width"
 export const CATALOG_EXPLORER_MIN_WIDTH = 200
diff --git a/console/src/lib/oidc-discovery.ts 
b/console/src/lib/oidc-discovery.ts
new file mode 100644
index 0000000..f2a42de
--- /dev/null
+++ b/console/src/lib/oidc-discovery.ts
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import axios from "axios"
+
+interface OIDCDiscoveryDocument {
+  issuer: string
+  authorization_endpoint: string
+  token_endpoint: string
+  userinfo_endpoint?: string
+  jwks_uri?: string
+  end_session_endpoint?: string
+}
+
+const DISCOVERY_CACHE_KEY = "oidc_discovery_cache"
+const CACHE_TTL_MS = 3600000
+
+interface CachedDiscovery {
+  document: OIDCDiscoveryDocument
+  timestamp: number
+}
+
+function getCachedDiscovery(issuer: string): OIDCDiscoveryDocument | null {
+  try {
+    const cached = sessionStorage.getItem(`${DISCOVERY_CACHE_KEY}_${issuer}`)
+    if (!cached) return null
+
+    const parsed: CachedDiscovery = JSON.parse(cached)
+    if (Date.now() - parsed.timestamp > CACHE_TTL_MS) {
+      sessionStorage.removeItem(`${DISCOVERY_CACHE_KEY}_${issuer}`)
+      return null
+    }
+
+    return parsed.document
+  } catch {
+    return null
+  }
+}
+
+function setCachedDiscovery(issuer: string, document: OIDCDiscoveryDocument): 
void {
+  try {
+    const cached: CachedDiscovery = {
+      document,
+      timestamp: Date.now(),
+    }
+    sessionStorage.setItem(`${DISCOVERY_CACHE_KEY}_${issuer}`, 
JSON.stringify(cached))
+  } catch {
+    // Ignore cache errors
+  }
+}
+
+export async function discoverOIDCEndpoints(issuer: string): 
Promise<OIDCDiscoveryDocument> {
+  const cached = getCachedDiscovery(issuer)
+  if (cached) {
+    return cached
+  }
+
+  const wellKnownUrl = `${issuer.replace(/\/$/, 
"")}/.well-known/openid-configuration`
+
+  try {
+    const response = await axios.get<OIDCDiscoveryDocument>(wellKnownUrl, {
+      timeout: 5000,
+    })
+
+    if (!response.data.authorization_endpoint || 
!response.data.token_endpoint) {
+      throw new Error("Invalid OIDC discovery document: missing required 
endpoints")
+    }
+
+    setCachedDiscovery(issuer, response.data)
+    return response.data
+  } catch (error) {
+    if (axios.isAxiosError(error)) {
+      throw new Error(`Failed to discover OIDC endpoints from ${wellKnownUrl}: 
${error.message}`)
+    }
+    throw error
+  }
+}
diff --git a/console/src/lib/pkce.ts b/console/src/lib/pkce.ts
new file mode 100644
index 0000000..056b349
--- /dev/null
+++ b/console/src/lib/pkce.ts
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const PKCE_VERIFIER_KEY = "pkce_code_verifier"
+const PKCE_STATE_KEY = "pkce_state"
+
+function generateRandomString(length: number): string {
+  const charset = 
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
+  const randomValues = new Uint8Array(length)
+  crypto.getRandomValues(randomValues)
+  return Array.from(randomValues)
+    .map((value) => charset[value % charset.length])
+    .join("")
+}
+
+async function sha256(plain: string): Promise<ArrayBuffer> {
+  const encoder = new TextEncoder()
+  const data = encoder.encode(plain)
+  return crypto.subtle.digest("SHA-256", data)
+}
+
+function base64UrlEncode(buffer: ArrayBuffer): string {
+  const bytes = new Uint8Array(buffer)
+  let binary = ""
+  for (let i = 0; i < bytes.byteLength; i++) {
+    binary += String.fromCharCode(bytes[i])
+  }
+  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
+}
+
+export async function generatePKCE(): Promise<{ verifier: string; challenge: 
string }> {
+  const verifier = generateRandomString(128)
+  const hashed = await sha256(verifier)
+  const challenge = base64UrlEncode(hashed)
+  return { verifier, challenge }
+}
+
+export function generateState(): string {
+  return generateRandomString(32)
+}
+
+export function storePKCEVerifier(verifier: string): void {
+  sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier)
+}
+
+export function getPKCEVerifier(): string | null {
+  return sessionStorage.getItem(PKCE_VERIFIER_KEY)
+}
+
+export function clearPKCEVerifier(): void {
+  sessionStorage.removeItem(PKCE_VERIFIER_KEY)
+}
+
+export function storeState(state: string): void {
+  sessionStorage.setItem(PKCE_STATE_KEY, state)
+}
+
+export function getState(): string | null {
+  return sessionStorage.getItem(PKCE_STATE_KEY)
+}
+
+export function clearState(): void {
+  sessionStorage.removeItem(PKCE_STATE_KEY)
+}
+
+export function clearPKCESession(): void {
+  clearPKCEVerifier()
+  clearState()
+}
diff --git a/console/src/pages/AuthCallback.tsx 
b/console/src/pages/AuthCallback.tsx
new file mode 100644
index 0000000..8ad430d
--- /dev/null
+++ b/console/src/pages/AuthCallback.tsx
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useState, useRef } from "react"
+import { useNavigate, useSearchParams } from "react-router-dom"
+import { useAuth } from "@/hooks/useAuth"
+import { Card, CardContent, CardHeader } from "@/components/ui/card"
+import { Logo } from "@/components/layout/Logo"
+import { Footer } from "@/components/layout/Footer"
+import { Loader2 } from "lucide-react"
+
+export function AuthCallback() {
+  const [searchParams] = useSearchParams()
+  const navigate = useNavigate()
+  const { completeOIDCLogin } = useAuth()
+  const [error, setError] = useState<string>("")
+  const hasProcessed = useRef(false)
+
+  useEffect(() => {
+    if (hasProcessed.current) {
+      return
+    }
+
+    const handleCallback = async () => {
+      const code = searchParams.get("code")
+      const state = searchParams.get("state")
+      const errorParam = searchParams.get("error")
+      const errorDescription = searchParams.get("error_description")
+
+      if (errorParam) {
+        setError(errorDescription || errorParam)
+        setTimeout(() => navigate("/login"), 3000)
+        return
+      }
+
+      if (!code || !state) {
+        setError("Missing authorization code or state parameter")
+        setTimeout(() => navigate("/login"), 3000)
+        return
+      }
+
+      hasProcessed.current = true
+
+      try {
+        await completeOIDCLogin(code, state)
+        navigate("/")
+      } catch (err) {
+        const errorMessage = err instanceof Error ? err.message : 
"Authentication failed"
+        setError(errorMessage)
+        sessionStorage.setItem("auth_error", errorMessage)
+        setTimeout(() => navigate("/login"), 3000)
+      }
+    }
+
+    handleCallback()
+  }, [searchParams, navigate, completeOIDCLogin])
+
+  return (
+    <div className="flex min-h-screen flex-col bg-background">
+      <div className="flex flex-1 items-center justify-center">
+        <Card className="w-full max-w-md">
+          <CardHeader className="text-center">
+            <div className="flex justify-center">
+              <Logo clickable={false} />
+            </div>
+          </CardHeader>
+          <CardContent className="text-center">
+            {error ? (
+              <div className="space-y-4">
+                <div className="rounded-md bg-destructive/10 p-3 text-sm 
text-destructive">
+                  {error}
+                </div>
+                <p className="text-sm text-muted-foreground">Redirecting to 
login...</p>
+              </div>
+            ) : (
+              <div className="space-y-4">
+                <Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" 
/>
+                <p className="text-sm text-muted-foreground">Completing 
authentication...</p>
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      </div>
+      <Footer />
+    </div>
+  )
+}
diff --git a/console/src/pages/Login.tsx b/console/src/pages/Login.tsx
index 96a634e..09d53a4 100644
--- a/console/src/pages/Login.tsx
+++ b/console/src/pages/Login.tsx
@@ -17,26 +17,40 @@
  * under the License.
  */
 
-import { useState } from "react"
+import { useState, useEffect } from "react"
 import { useNavigate } from "react-router-dom"
+import { Info } from "lucide-react"
 import { useAuth } from "@/hooks/useAuth"
 import { Button } from "@/components/ui/button"
 import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { Card, CardContent, CardHeader } from "@/components/ui/card"
+import { Popover, PopoverContent, PopoverTrigger } from 
"@/components/ui/popover"
 import { Logo } from "@/components/layout/Logo"
 import { Footer } from "@/components/layout/Footer"
+import { config } from "@/lib/config"
 
 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 [username, setUsername] = useState("")
+  const [password, setPassword] = useState("")
+  const [scope, setScope] = useState(config.POLARIS_PRINCIPAL_SCOPE)
   const [error, setError] = useState("")
   const [loading, setLoading] = useState(false)
-  const { login } = useAuth()
+  const { login, loginWithOIDC } = useAuth()
   const navigate = useNavigate()
+  const isOIDCConfigured = !!(
+    config.OIDC_ISSUER_URL &&
+    config.OIDC_CLIENT_ID &&
+    config.OIDC_REDIRECT_URI
+  )
+
+  useEffect(() => {
+    const authError = sessionStorage.getItem("auth_error")
+    if (authError) {
+      setError(authError)
+      sessionStorage.removeItem("auth_error")
+    }
+  }, [])
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
@@ -44,7 +58,7 @@ export function Login() {
     setLoading(true)
 
     try {
-      await login(clientId, clientSecret, scope, realm)
+      await login(username, password, scope)
       navigate("/")
     } catch (err) {
       setError(
@@ -57,6 +71,18 @@ export function Login() {
     }
   }
 
+  const handleOIDCLogin = async () => {
+    setError("")
+    setLoading(true)
+
+    try {
+      await loginWithOIDC()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to initiate OIDC 
login.")
+      setLoading(false)
+    }
+  }
+
   return (
     <div className="flex min-h-screen flex-col bg-background">
       <div className="flex flex-1 items-center justify-center">
@@ -68,37 +94,53 @@ export function Login() {
           </CardHeader>
           <CardContent>
             <form onSubmit={handleSubmit} className="space-y-4">
+              <div className="rounded-md border p-3 text-sm">
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2">
+                    <span 
className="text-muted-foreground">{config.REALM_HEADER_NAME}:</span>
+                    <span className="font-medium">{config.POLARIS_REALM}</span>
+                  </div>
+                  <Popover>
+                    <PopoverTrigger asChild>
+                      <button
+                        type="button"
+                        className="text-muted-foreground hover:text-foreground 
transition-colors"
+                      >
+                        <Info className="h-4 w-4" />
+                      </button>
+                    </PopoverTrigger>
+                    <PopoverContent className="w-80">
+                      <div className="space-y-2">
+                        <h4 className="font-medium text-sm">Realm 
Configuration</h4>
+                        <p className="text-xs text-muted-foreground">
+                          This UI console is configured to connect to a 
specific Polaris server
+                          realm.
+                        </p>
+                      </div>
+                    </PopoverContent>
+                  </Popover>
+                </div>
+              </div>
               <div className="space-y-2">
-                <Label htmlFor="clientId">Client ID</Label>
+                <Label htmlFor="username">Username</Label>
                 <Input
-                  id="clientId"
+                  id="username"
                   type="text"
-                  value={clientId}
-                  onChange={(e) => setClientId(e.target.value)}
+                  value={username}
+                  onChange={(e) => setUsername(e.target.value)}
                   required
-                  placeholder="Enter your client ID"
+                  placeholder="Enter your username"
                 />
               </div>
               <div className="space-y-2">
-                <Label htmlFor="clientSecret">Client Secret</Label>
+                <Label htmlFor="password">Password</Label>
                 <Input
-                  id="clientSecret"
+                  id="password"
                   type="password"
-                  value={clientSecret}
-                  onChange={(e) => setClientSecret(e.target.value)}
+                  value={password}
+                  onChange={(e) => setPassword(e.target.value)}
                   required
-                  placeholder="Enter your client secret"
-                />
-              </div>
-              <div className="space-y-2">
-                <Label htmlFor="realm">Realm</Label>
-                <Input
-                  id="realm"
-                  type="text"
-                  value={realm}
-                  onChange={(e) => setRealm(e.target.value)}
-                  required
-                  placeholder="Enter your realm"
+                  placeholder="Enter your password"
                 />
               </div>
               <div className="space-y-2">
@@ -112,14 +154,35 @@ export function Login() {
                   placeholder="Enter the scope"
                 />
               </div>
+              <Button type="submit" className="w-full" disabled={loading}>
+                {loading ? "Signing in..." : "Sign in"}
+              </Button>
+              {isOIDCConfigured && (
+                <>
+                  <div className="relative">
+                    <div className="absolute inset-0 flex items-center">
+                      <span className="w-full border-t" />
+                    </div>
+                    <div className="relative flex justify-center text-xs 
uppercase">
+                      <span className="bg-background px-2 
text-muted-foreground">External IDP</span>
+                    </div>
+                  </div>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    className="w-full"
+                    onClick={handleOIDCLogin}
+                    disabled={loading}
+                  >
+                    {loading ? "Redirecting..." : "Sign in with OIDC"}
+                  </Button>
+                </>
+              )}
               {error && (
                 <div className="rounded-md bg-destructive/10 p-3 text-sm 
text-destructive">
                   {error}
                 </div>
               )}
-              <Button type="submit" className="w-full" disabled={loading}>
-                {loading ? "Signing in..." : "Sign in"}
-              </Button>
             </form>
           </CardContent>
         </Card>
diff --git a/console/src/types/api.ts b/console/src/types/api.ts
index 53af74f..e72d9a9 100644
--- a/console/src/types/api.ts
+++ b/console/src/types/api.ts
@@ -429,6 +429,9 @@ export interface OAuthTokenResponse {
   token_type: string
   expires_in?: number
   issued_token_type?: string
+  refresh_token?: string
+  id_token?: string
+  scope?: string
 }
 
 // Error Responses


Reply via email to