Skip to content

Commit bd52225

Browse files
feat(console): Add Keycloak authentication support
- Add AuthType type ('internal' | 'keycloak') to support multiple auth providers - Update auth.ts to handle both internal Polaris and Keycloak authentication - Add Keycloak proxy configuration in vite.config.ts for development - Update login UI with authentication type selector - Add conditional Keycloak realm field when using external auth - Store auth type and realms in localStorage for session persistence - Update .env.example with Keycloak configuration documentation This allows users to choose between: 1. Internal Polaris authentication (default) 2. External Keycloak authentication Fixes #98
1 parent f4b9a91 commit bd52225

6 files changed

Lines changed: 141 additions & 22 deletions

File tree

console/.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22
# The base URL for the Polaris API backend
33
VITE_POLARIS_API_URL=http://polaris-polaris-1:8181
44

5-
# Polaris RealmI
5+
# Polaris Realm
66
# The realm identifier for Polaris
77
VITE_POLARIS_REALM=POLARIS
88

9+
# Keycloak Configuration (Optional)
10+
# The base URL for Keycloak server when using external authentication
11+
# Only required if you're using Keycloak for authentication
12+
# Example: http://localhost:8080
13+
VITE_KEYCLOAK_URL=
14+
915
# Docker Configuration
1016
# Port on which the UI will be accessible (default: 3000)
1117
PORT=3000

console/src/api/auth.ts

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,41 +21,74 @@ import axios from "axios"
2121
import { apiClient } from "./client"
2222
import { navigate } from "@/lib/navigation"
2323
import { REALM_HEADER_NAME } from "@/lib/constants"
24-
import type { OAuthTokenResponse } from "@/types/api"
24+
import type { OAuthTokenResponse, AuthType } from "@/types/api"
2525

2626
// Always use relative URL to go through the proxy (dev server or production server)
2727
// This avoids CORS issues by proxying requests through the server
2828
// The server.ts proxy handles /api routes in production, and Vite handles them in development
29-
const TOKEN_URL = "/api/catalog/v1/oauth/tokens"
29+
const INTERNAL_TOKEN_URL = "/api/catalog/v1/oauth/tokens"
3030

3131
// Log OAuth URL in development only
3232
if (import.meta.env.DEV) {
33-
console.log("🔐 Using OAuth token URL:", TOKEN_URL)
33+
console.log("🔐 Using Internal OAuth token URL:", INTERNAL_TOKEN_URL)
3434
}
3535

3636
export const authApi = {
3737
getToken: async (
3838
clientId: string,
3939
clientSecret: string,
40-
realm?: string
40+
authType: AuthType,
41+
realm?: string,
42+
polarisRealm?: string
4143
): Promise<OAuthTokenResponse> => {
4244
const formData = new URLSearchParams()
43-
formData.append("grant_type", "client_credentials")
4445
formData.append("client_id", clientId)
4546
formData.append("client_secret", clientSecret)
46-
formData.append("scope", "PRINCIPAL_ROLE:ALL")
47+
48+
// Internal auth uses scope, external (Keycloak) uses grant_type
49+
if (authType === "internal") {
50+
formData.append("scope", "PRINCIPAL_ROLE:ALL")
51+
formData.append("grant_type", "client_credentials")
52+
} else {
53+
formData.append("grant_type", "client_credentials")
54+
}
4755

4856
const headers: Record<string, string> = {
4957
"Content-Type": "application/x-www-form-urlencoded",
5058
}
5159

52-
// Add realm header if provided
53-
if (realm) {
54-
headers[REALM_HEADER_NAME] = realm
60+
let tokenUrl: string
61+
62+
if (authType === "keycloak") {
63+
// For Keycloak, use relative path that goes through proxy (dev server or production server)
64+
// This avoids CORS issues by proxying requests through the server
65+
// The vite.config.ts proxy handles /keycloak routes in development
66+
// In production, a similar proxy should be configured on the server
67+
if (!realm) {
68+
throw new Error("Keycloak realm is required for Keycloak authentication")
69+
}
70+
// Use relative path that goes through proxy
71+
tokenUrl = `/keycloak/realms/${realm}/protocol/openid-connect/token`
72+
// Add Polaris realm header if provided (for Polaris API calls)
73+
if (polarisRealm) {
74+
headers[REALM_HEADER_NAME] = polarisRealm
75+
}
76+
} else {
77+
// For internal, use the relative URL that goes through proxy
78+
tokenUrl = INTERNAL_TOKEN_URL
79+
// Add realm header if provided (for internal auth)
80+
if (realm) {
81+
headers[REALM_HEADER_NAME] = realm
82+
}
83+
}
84+
85+
// Log token URL in development only
86+
if (import.meta.env.DEV) {
87+
console.log("🔐 Using token URL:", tokenUrl, "Auth type:", authType)
5588
}
5689

5790
const response = await axios.post<OAuthTokenResponse>(
58-
TOKEN_URL,
91+
tokenUrl,
5992
formData,
6093
{
6194
headers,
@@ -73,13 +106,14 @@ export const authApi = {
73106
subjectToken: string,
74107
subjectTokenType: string
75108
): Promise<OAuthTokenResponse> => {
109+
// Token exchange always uses internal endpoint
76110
const formData = new URLSearchParams()
77111
formData.append("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
78112
formData.append("subject_token", subjectToken)
79113
formData.append("subject_token_type", subjectTokenType)
80114

81115
const response = await axios.post<OAuthTokenResponse>(
82-
TOKEN_URL,
116+
INTERNAL_TOKEN_URL,
83117
formData,
84118
{
85119
headers: {
@@ -97,13 +131,14 @@ export const authApi = {
97131
},
98132

99133
refreshToken: async (accessToken: string): Promise<OAuthTokenResponse> => {
134+
// Token refresh always uses internal endpoint
100135
const formData = new URLSearchParams()
101136
formData.append("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
102137
formData.append("subject_token", accessToken)
103138
formData.append("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
104139

105140
const response = await axios.post<OAuthTokenResponse>(
106-
TOKEN_URL,
141+
INTERNAL_TOKEN_URL,
107142
formData,
108143
{
109144
headers: {
@@ -121,6 +156,9 @@ export const authApi = {
121156

122157
logout: (): void => {
123158
apiClient.clearAccessToken()
159+
localStorage.removeItem("polaris_realm")
160+
localStorage.removeItem("polaris_auth_type")
161+
localStorage.removeItem("polaris_keycloak_realm")
124162
// Use a small delay to allow toast to show before redirect
125163
setTimeout(() => {
126164
navigate("/login", true)

console/src/hooks/useAuth.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@
2020
import { createContext, useContext, useState, type ReactNode } from "react"
2121
import { toast } from "sonner"
2222
import { authApi } from "@/api/auth"
23+
import type { AuthType } from "@/types/api"
2324

2425
interface AuthContextType {
2526
isAuthenticated: boolean
26-
login: (clientId: string, clientSecret: string, realm: string) => Promise<void>
27+
login: (
28+
clientId: string,
29+
clientSecret: string,
30+
authType: AuthType,
31+
realm: string,
32+
keycloakRealm?: string
33+
) => Promise<void>
2734
logout: () => void
2835
loading: boolean
2936
}
@@ -34,13 +41,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
3441
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false)
3542
const [loading] = useState<boolean>(false)
3643

37-
const login = async (clientId: string, clientSecret: string, realm: string) => {
44+
const login = async (
45+
clientId: string,
46+
clientSecret: string,
47+
authType: AuthType,
48+
realm: string,
49+
keycloakRealm?: string
50+
) => {
3851
try {
39-
// Store realm in localStorage (non-sensitive configuration)
52+
// Store auth configuration in localStorage (non-sensitive configuration)
53+
localStorage.setItem("polaris_auth_type", authType)
4054
if (realm) {
4155
localStorage.setItem("polaris_realm", realm)
4256
}
43-
await authApi.getToken(clientId, clientSecret, realm)
57+
if (keycloakRealm) {
58+
localStorage.setItem("polaris_keycloak_realm", keycloakRealm)
59+
}
60+
61+
await authApi.getToken(clientId, clientSecret, authType, keycloakRealm, realm)
4462
setIsAuthenticated(true)
4563
} catch (error) {
4664
setIsAuthenticated(false)

console/src/pages/Login.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,21 @@ import { Label } from "@/components/ui/label"
2626
import { Card, CardContent, CardHeader } from "@/components/ui/card"
2727
import { Logo } from "@/components/layout/Logo"
2828
import { Footer } from "@/components/layout/Footer"
29+
import type { AuthType } from "@/types/api"
2930

3031
export function Login() {
3132
const [clientId, setClientId] = useState("")
3233
const [clientSecret, setClientSecret] = useState("")
33-
// Initialize realm with value from .env file if present
34-
const [realm, setRealm] = useState(import.meta.env.VITE_POLARIS_REALM || "")
34+
const [authType, setAuthType] = useState<AuthType>(
35+
(localStorage.getItem("polaris_auth_type") as AuthType) || "internal"
36+
)
37+
// Initialize realm with value from .env file or localStorage if present
38+
const [realm, setRealm] = useState(
39+
localStorage.getItem("polaris_realm") || import.meta.env.VITE_POLARIS_REALM || ""
40+
)
41+
const [keycloakRealm, setKeycloakRealm] = useState(
42+
localStorage.getItem("polaris_keycloak_realm") || ""
43+
)
3544
const [error, setError] = useState("")
3645
const [loading, setLoading] = useState(false)
3746
const { login } = useAuth()
@@ -43,7 +52,7 @@ export function Login() {
4352
setLoading(true)
4453

4554
try {
46-
await login(clientId, clientSecret, realm)
55+
await login(clientId, clientSecret, authType, realm, keycloakRealm)
4756
navigate("/")
4857
} catch (err) {
4958
setError(
@@ -67,6 +76,18 @@ export function Login() {
6776
</CardHeader>
6877
<CardContent>
6978
<form onSubmit={handleSubmit} className="space-y-4">
79+
<div className="space-y-2">
80+
<Label htmlFor="authType">Authentication Type</Label>
81+
<select
82+
id="authType"
83+
value={authType}
84+
onChange={(e) => setAuthType(e.target.value as AuthType)}
85+
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"
86+
>
87+
<option value="internal">Internal (Polaris)</option>
88+
<option value="keycloak">External (Keycloak)</option>
89+
</select>
90+
</div>
7091
<div className="space-y-2">
7192
<Label htmlFor="clientId">Client ID</Label>
7293
<Input
@@ -89,15 +110,28 @@ export function Login() {
89110
placeholder="Enter your client secret"
90111
/>
91112
</div>
113+
{authType === "keycloak" && (
114+
<div className="space-y-2">
115+
<Label htmlFor="keycloakRealm">Keycloak Realm</Label>
116+
<Input
117+
id="keycloakRealm"
118+
type="text"
119+
value={keycloakRealm}
120+
onChange={(e) => setKeycloakRealm(e.target.value)}
121+
required
122+
placeholder="Enter Keycloak realm (e.g., myrealm)"
123+
/>
124+
</div>
125+
)}
92126
<div className="space-y-2">
93-
<Label htmlFor="realm">Realm</Label>
127+
<Label htmlFor="realm">Polaris Realm</Label>
94128
<Input
95129
id="realm"
96130
type="text"
97131
value={realm}
98132
onChange={(e) => setRealm(e.target.value)}
99133
required
100-
placeholder="Enter your realm"
134+
placeholder="Enter your Polaris realm"
101135
/>
102136
</div>
103137
{error && (

console/src/types/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
* under the License.
1818
*/
1919

20+
// Auth Types
21+
export type AuthType = "internal" | "keycloak"
22+
2023
// Management Service API Types
2124

2225
export interface StorageConfigInfo {

console/vite.config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ export default defineConfig({
7272
}
7373
},
7474
},
75+
'/keycloak': {
76+
target: process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080',
77+
changeOrigin: true,
78+
secure: false,
79+
configure: (proxy) => {
80+
// Only log in development mode
81+
if (process.env.NODE_ENV === 'development') {
82+
proxy.on('error', (err) => {
83+
console.error('Keycloak Proxy error:', err);
84+
});
85+
proxy.on('proxyReq', (proxyReq, req) => {
86+
const target = process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080';
87+
console.log('📤 Proxying to Keycloak:', req.method, req.url, '→', target + proxyReq.path);
88+
});
89+
proxy.on('proxyRes', (proxyRes, req) => {
90+
console.log('Received Response from Keycloak:', proxyRes.statusCode, req.url);
91+
});
92+
}
93+
},
94+
},
7595
},
7696
},
7797
})

0 commit comments

Comments
 (0)