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