From d3a2431ff64163a08e4aa1d7c7216666a2ddeaf0 Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 14:22:58 -0400 Subject: [PATCH 01/10] feat: add configurable OIDC/Keycloak authentication for enterprise deployments Adds optional OIDC authentication support to ADK Web UI, enabling enterprise deployments where agents are managed by Kagenti with SPIFFE/SPIRE zero-trust security. When auth.enabled is true in runtime-config.json (or injected via window.__ADK_CONFIG__ ConfigMap), users must authenticate via an OIDC provider before accessing the UI. When disabled (default), behavior is unchanged. Key changes: - Add AuthConfig to RuntimeConfig model - Add AuthService (keycloak-js) for OIDC lifecycle management - Add AuthInterceptor to attach Bearer tokens to API requests - Add AuthGuard for route protection - Add runtime config resolution (ConfigMap + runtime-config.json) - Add AuthUserMenuComponent showing authenticated user info + logout - Wire up APP_INITIALIZER and HTTP_INTERCEPTORS in main.ts - Add silent-check-sso.html for background token refresh Design principles: - Opt-in: auth disabled by default, zero impact on existing users - Provider-agnostic: standard OIDC, works with Keycloak/Okta/Auth0 - Runtime-configurable: enable via ConfigMap without rebuilding - Zero backend changes: auth enforced at UI + interceptor level --- angular.json | 7 +- package-lock.json | 10 ++ package.json | 1 + src/app/app-routing.module.ts | 2 + src/app/components/chat/chat.component.html | 4 + src/app/components/chat/chat.component.ts | 4 + .../user-menu/user-menu.component.ts | 140 ++++++++++++++++++ src/app/core/auth/auth.config.ts | 43 ++++++ src/app/core/auth/auth.guard.ts | 49 ++++++ src/app/core/auth/auth.interceptor.ts | 56 +++++++ src/app/core/auth/auth.service.ts | 133 +++++++++++++++++ src/app/core/auth/index.ts | 21 +++ src/app/core/models/RuntimeConfig.ts | 20 +++ src/main.ts | 19 ++- src/silent-check-sso.html | 6 + 15 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 src/app/components/user-menu/user-menu.component.ts create mode 100644 src/app/core/auth/auth.config.ts create mode 100644 src/app/core/auth/auth.guard.ts create mode 100644 src/app/core/auth/auth.interceptor.ts create mode 100644 src/app/core/auth/auth.service.ts create mode 100644 src/app/core/auth/index.ts create mode 100644 src/silent-check-sso.html diff --git a/angular.json b/angular.json index c5344f1e..52c2d38a 100644 --- a/angular.json +++ b/angular.json @@ -30,7 +30,12 @@ "glob": "**/*", "input": "public" }, - "src/assets" + "src/assets", + { + "glob": "silent-check-sso.html", + "input": "src", + "output": "/" + } ], "styles": [ "src/styles.scss" diff --git a/package-lock.json b/package-lock.json index aadf9dea..3fb2e998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@codemirror/lint": "^6.8.5", "@viz-js/viz": "^3.12.0", "codemirror": "^6.0.2", + "keycloak-js": "^26.2.4", "mermaid": "^11.14.0", "ngx-json-viewer": "^3.2.1", "ngx-markdown": "^21.0.1", @@ -11964,6 +11965,15 @@ "katex": "cli.js" } }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", diff --git a/package.json b/package.json index 4244bf46..e58397b6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@codemirror/lint": "^6.8.5", "@viz-js/viz": "^3.12.0", "codemirror": "^6.0.2", + "keycloak-js": "^26.2.4", "mermaid": "^11.14.0", "ngx-json-viewer": "^3.2.1", "ngx-markdown": "^21.0.1", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index d7148045..74909064 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -18,11 +18,13 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {AppComponent} from './app.component'; +import {authGuard} from './core/auth/auth.guard'; const routes: Routes = [ { path: '', component: AppComponent, + canActivate: [authGuard], } ]; diff --git a/src/app/components/chat/chat.component.html b/src/app/components/chat/chat.component.html index cab97598..0f9fc494 100644 --- a/src/app/components/chat/chat.component.html +++ b/src/app/components/chat/chat.component.html @@ -174,6 +174,9 @@ } + @if (authService.isEnabled) { + + } @else { + +
+
+ account_circle + +
+ @if (userInfo?.roles?.length) { +
+ @for (role of userInfo?.roles; track role) { + {{ role }} + } +
+ } + +
+
+ } + `, + styles: [` + .auth-user-menu-panel { + padding: 16px; + min-width: 240px; + } + .auth-user-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + } + .auth-user-icon { + font-size: 40px; + width: 40px; + height: 40px; + color: var(--mat-sys-primary); + } + .auth-user-info { + display: flex; + flex-direction: column; + } + .auth-user-name { + font-weight: 500; + font-size: 14px; + color: var(--mat-sys-on-surface); + } + .auth-user-email { + font-size: 12px; + color: var(--mat-sys-on-surface-variant); + } + .auth-user-roles { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 12px; + padding-top: 8px; + border-top: 1px solid var(--mat-sys-outline-variant); + } + .auth-role-chip { + font-size: 11px; + padding: 2px 8px; + border-radius: 12px; + background: var(--mat-sys-surface-container-high); + color: var(--mat-sys-on-surface-variant); + } + .auth-user-actions { + display: flex; + justify-content: flex-end; + padding-top: 8px; + border-top: 1px solid var(--mat-sys-outline-variant); + } + `], +}) +export class AuthUserMenuComponent { + protected readonly authService = inject(AuthService); + + get userInfo(): UserInfo | null { + return this.authService.getUserInfo(); + } + + async logout(): Promise { + await this.authService.logout(); + } +} diff --git a/src/app/core/auth/auth.config.ts b/src/app/core/auth/auth.config.ts new file mode 100644 index 00000000..408ec336 --- /dev/null +++ b/src/app/core/auth/auth.config.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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 {AuthConfig} from '../models/RuntimeConfig'; +import {RuntimeConfigUtil} from '../../../utils/runtime-config-util'; + +/** + * Resolves auth configuration from available sources. + * + * Priority order: + * 1. window.__ADK_CONFIG__.auth (Kubernetes ConfigMap injection) + * 2. runtime-config.json auth (standard runtime config) + * + * This allows container deployments to enable OIDC auth without + * rebuilding the application -- just mount a ConfigMap with a script + * that sets window.__ADK_CONFIG__. + */ +export function resolveAuthConfig(): AuthConfig | undefined { + // Check for Kubernetes ConfigMap-injected config + const adkConfig = + (window as any)['__ADK_CONFIG__'] as {auth?: AuthConfig} | undefined; + if (adkConfig?.auth?.enabled) { + return adkConfig.auth; + } + + // Fall back to standard runtime-config.json + const runtimeConfig = RuntimeConfigUtil.getRuntimeConfig(); + return runtimeConfig?.auth; +} diff --git a/src/app/core/auth/auth.guard.ts b/src/app/core/auth/auth.guard.ts new file mode 100644 index 00000000..dc403817 --- /dev/null +++ b/src/app/core/auth/auth.guard.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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 {inject} from '@angular/core'; +import {CanActivateFn} from '@angular/router'; + +import {AuthService} from './auth.service'; + +/** + * Route guard that protects routes when OIDC authentication is enabled. + * + * When auth is disabled (default), the guard always returns true. + * When auth is enabled and the user is not authenticated, the guard + * triggers the OIDC login flow via keycloak-js redirect. + */ +export const authGuard: CanActivateFn = async () => { + const authService = inject(AuthService); + + if (!authService.isEnabled) { + return true; + } + + if (authService.isAuthenticated()) { + return true; + } + + // Not authenticated -- keycloak init with onLoad: 'login-required' + // should have already redirected, but as a safety net, trigger login. + try { + await authService.init(); + return authService.isAuthenticated(); + } catch { + return false; + } +}; diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts new file mode 100644 index 00000000..6dc1790a --- /dev/null +++ b/src/app/core/auth/auth.interceptor.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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 {Injectable} from '@angular/core'; +import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; +import {Observable, from, switchMap} from 'rxjs'; + +import {AuthService} from './auth.service'; + +/** + * HTTP interceptor that attaches OIDC Bearer tokens to outgoing API + * requests when authentication is enabled. + * + * When auth is disabled, requests pass through unmodified. When + * enabled, each request to the backend receives an + * `Authorization: Bearer ` header. This token is validated + * by Kagenti's Envoy AuthBridge sidecar before reaching the agent. + */ +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + constructor(private authService: AuthService) {} + + intercept( + req: HttpRequest, + next: HttpHandler): Observable> { + if (!this.authService.isEnabled) { + return next.handle(req); + } + + return from(this.authService.getToken()).pipe( + switchMap((token) => { + if (token) { + const authReq = req.clone({ + setHeaders: {Authorization: `Bearer ${token}`}, + }); + return next.handle(authReq); + } + return next.handle(req); + }), + ); + } +} diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts new file mode 100644 index 00000000..cdf67953 --- /dev/null +++ b/src/app/core/auth/auth.service.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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 {Injectable} from '@angular/core'; +import Keycloak from 'keycloak-js'; + +import {AuthConfig} from '../models/RuntimeConfig'; +import {resolveAuthConfig} from './auth.config'; + +export interface UserInfo { + name: string; + email: string; + userId: string; + roles: string[]; +} + +/** + * OIDC authentication service for ADK Web UI. + * + * When auth is enabled in runtime-config.json, this service manages + * the Keycloak/OIDC lifecycle: login, token management, refresh, and + * logout. When auth is disabled, all methods are no-ops and + * isAuthenticated() returns true. + * + * Designed for enterprise deployments where agents are managed by + * Kagenti with SPIFFE/SPIRE zero-trust security. + */ +@Injectable({providedIn: 'root'}) +export class AuthService { + private keycloak: Keycloak | null = null; + private authConfig: AuthConfig | undefined; + private initialized = false; + + get isEnabled(): boolean { + return !!this.authConfig?.enabled; + } + + /** + * Initialize the auth service. Must be called before the app renders. + * When auth is disabled, resolves immediately. + */ + async init(): Promise { + const config = resolveAuthConfig(); + this.authConfig = config; + + if (!this.authConfig?.enabled) { + this.initialized = true; + return; + } + + this.keycloak = new Keycloak({ + url: this.authConfig.oidcUrl, + realm: this.authConfig.oidcRealm, + clientId: this.authConfig.oidcClientId, + }); + + try { + const authenticated = await this.keycloak.init({ + onLoad: 'login-required', + checkLoginIframe: false, + silentCheckSsoRedirectUri: + this.authConfig.silentRefresh !== false + ? `${window.location.origin}/silent-check-sso.html` + : undefined, + }); + + if (!authenticated) { + await this.keycloak.login(); + } + + // Set up automatic token refresh + this.keycloak.onTokenExpired = () => { + this.keycloak?.updateToken(30).catch(() => { + console.warn('Token refresh failed, redirecting to login'); + this.keycloak?.login(); + }); + }; + + this.initialized = true; + } catch (err) { + console.error('OIDC initialization failed:', err); + throw err; + } + } + + isAuthenticated(): boolean { + if (!this.isEnabled) return true; + return this.keycloak?.authenticated ?? false; + } + + async getToken(): Promise { + if (!this.isEnabled || !this.keycloak) return undefined; + + try { + await this.keycloak.updateToken(5); + } catch { + // Token refresh failed, will be caught by onTokenExpired + } + return this.keycloak.token; + } + + getUserInfo(): UserInfo | null { + if (!this.isEnabled || !this.keycloak?.tokenParsed) return null; + + const parsed = this.keycloak.tokenParsed; + return { + name: (parsed as any)['name'] ?? + (parsed as any)['preferred_username'] ?? 'User', + email: (parsed as any)['email'] ?? '', + userId: parsed.sub ?? '', + roles: (parsed as any)['realm_access']?.['roles'] ?? [], + }; + } + + async logout(): Promise { + if (!this.isEnabled || !this.keycloak) return; + await this.keycloak.logout({redirectUri: window.location.origin}); + } +} diff --git a/src/app/core/auth/index.ts b/src/app/core/auth/index.ts new file mode 100644 index 00000000..d9135c7b --- /dev/null +++ b/src/app/core/auth/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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. + */ + +export {AuthService} from './auth.service'; +export {AuthInterceptor} from './auth.interceptor'; +export {authGuard} from './auth.guard'; +export {resolveAuthConfig} from './auth.config'; diff --git a/src/app/core/models/RuntimeConfig.ts b/src/app/core/models/RuntimeConfig.ts index 6fd989a1..37a8bd39 100644 --- a/src/app/core/models/RuntimeConfig.ts +++ b/src/app/core/models/RuntimeConfig.ts @@ -22,6 +22,7 @@ export declare interface RuntimeConfig { backendUrl: string; logo?: LogoConfig; + auth?: AuthConfig; } /** @@ -31,3 +32,22 @@ export declare interface LogoConfig { text: string; imageUrl: string; } + +/** + * OIDC authentication configuration for enterprise deployments. + * When enabled, users must authenticate via an OIDC provider (Keycloak, + * Okta, Auth0, etc.) before accessing the UI. When disabled or absent, + * the UI is accessible without authentication (default behavior). + * + * Designed for use with Kagenti-managed agents where SPIFFE/SPIRE + * provides zero-trust workload identity and Envoy AuthBridge validates + * JWT tokens on inbound API requests. + */ +export declare interface AuthConfig { + enabled: boolean; + oidcUrl: string; + oidcRealm: string; + oidcClientId: string; + oidcScopes?: string; + silentRefresh?: boolean; +} diff --git a/src/main.ts b/src/main.ts index e67e0c73..86b14822 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,8 +18,8 @@ import {Catalog, DEFAULT_CATALOG, Theme} from '@a2ui/angular'; import {Location} from '@angular/common'; -import {HttpClientModule} from '@angular/common/http'; -import {importProvidersFrom} from '@angular/core'; +import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; +import {APP_INITIALIZER, importProvidersFrom} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatFormFieldModule} from '@angular/material/form-field'; @@ -35,6 +35,8 @@ import {EVAL_TAB_COMPONENT, EvalTabComponent} from './app/components/eval-tab/ev import {MarkdownComponent} from './app/components/markdown/markdown.component'; import {MARKDOWN_COMPONENT} from './app/components/markdown/markdown.component.interface'; import {A2UI_THEME} from './app/core/constants/a2ui-theme'; +import {AuthService} from './app/core/auth/auth.service'; +import {AuthInterceptor} from './app/core/auth/auth.interceptor'; import {AgentBuilderService} from './app/core/services/agent-builder.service'; import {AgentService} from './app/core/services/agent.service'; import {ArtifactService} from './app/core/services/artifact.service'; @@ -127,7 +129,18 @@ fetch('./assets/config/runtime-config.json') provideMarkdown(), {provide: LOCATION_SERVICE, useClass: Location}, {provide: UI_STATE_SERVICE, useClass: UiStateService}, - {provide: THEME_SERVICE, useClass: ThemeService} + {provide: THEME_SERVICE, useClass: ThemeService}, + { + provide: APP_INITIALIZER, + useFactory: (authService: AuthService) => () => authService.init(), + deps: [AuthService], + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true, + }, ] }).catch((err) => console.error(err)); }); diff --git a/src/silent-check-sso.html b/src/silent-check-sso.html new file mode 100644 index 00000000..32bcff29 --- /dev/null +++ b/src/silent-check-sso.html @@ -0,0 +1,6 @@ + + + + + + From c7a445d259c1e46440bcdc195be145899d91e7ea Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 14:34:23 -0400 Subject: [PATCH 02/10] fix: add missing MarkdownComponent import to LongRunningResponseComponent The component template uses but the class was imported without being added to the @Component imports array, causing build failures. --- .../components/long-running-response/long-running-response.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/components/long-running-response/long-running-response.ts b/src/app/components/long-running-response/long-running-response.ts index 8eab06cc..7591d588 100644 --- a/src/app/components/long-running-response/long-running-response.ts +++ b/src/app/components/long-running-response/long-running-response.ts @@ -39,6 +39,7 @@ import {MatIcon} from '@angular/material/icon'; MatButton, MatIcon, NgxJsonViewerModule, + MarkdownComponent, ], }) export class LongRunningResponseComponent implements OnChanges { From 754cf42c677ca3cb00d3b924f104be251e5eef66 Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 14:48:34 -0400 Subject: [PATCH 03/10] feat: add Bearer token to SSE fetch and WebSocket connections The AuthInterceptor only covers Angular HttpClient requests, but the SSE streaming endpoint (run_sse) uses raw fetch() and WebSocket connections bypass HttpClient entirely. Without this fix, Kagenti's Envoy AuthBridge would reject these requests when OIDC auth is enabled. - AgentService.runSse: inject AuthService, get token before fetch, add Authorization header to SSE requests - WebSocketService.connect: inject AuthService, append token as query parameter (standard WS auth pattern since browser WebSocket API doesn't support custom headers) - Update WebSocketService interface for async connect signature --- src/app/core/services/agent.service.ts | 20 ++++++++++++++----- src/app/core/services/interfaces/websocket.ts | 2 +- src/app/core/services/websocket.service.ts | 13 ++++++++++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/app/core/services/agent.service.ts b/src/app/core/services/agent.service.ts index 489a89d7..4099e731 100644 --- a/src/app/core/services/agent.service.ts +++ b/src/app/core/services/agent.service.ts @@ -19,6 +19,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { URLUtil } from '../../../utils/url-util'; +import { AuthService } from '../auth/auth.service'; import { AgentRunRequest } from '../models/AgentRunRequest'; import { LlmResponse } from '../models/types'; import { AgentService as AgentServiceInterface } from './interfaces/agent'; @@ -35,6 +36,7 @@ export class AgentService implements AgentServiceInterface { constructor( private http: HttpClient, private zone: NgZone, + private authService: AuthService, ) { } getApp(): Observable { @@ -54,14 +56,22 @@ export class AgentService implements AgentServiceInterface { this.isLoading.next(true); return new Observable((observer) => { const self = this; - fetch(url, { - method: 'POST', - headers: { + const buildHeaders = async () => { + const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', - }, + }; + const token = await this.authService.getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; + }; + buildHeaders().then((headers) => fetch(url, { + method: 'POST', + headers, body: JSON.stringify(req), - }) + })) .then((response) => { const reader = response.body?.getReader(); const decoder = new TextDecoder('utf-8'); diff --git a/src/app/core/services/interfaces/websocket.ts b/src/app/core/services/interfaces/websocket.ts index d60eb5da..61edca8b 100644 --- a/src/app/core/services/interfaces/websocket.ts +++ b/src/app/core/services/interfaces/websocket.ts @@ -26,7 +26,7 @@ export const WEBSOCKET_SERVICE = * Service to provide methods to handle websocket connections. */ export declare abstract class WebSocketService { - abstract connect(serverUrl: string): void; + abstract connect(serverUrl: string): void | Promise; abstract sendMessage(data: LiveRequest): void; abstract closeConnection(): void; abstract getMessages(): Observable; diff --git a/src/app/core/services/websocket.service.ts b/src/app/core/services/websocket.service.ts index 4aa984e3..8dca7c99 100644 --- a/src/app/core/services/websocket.service.ts +++ b/src/app/core/services/websocket.service.ts @@ -24,12 +24,14 @@ import {Event} from '../models/types'; import {AUDIO_PLAYING_SERVICE} from './interfaces/audio-playing'; import {WebSocketService as WebSocketServiceInterface} from './interfaces/websocket'; +import {AuthService} from '../auth/auth.service'; @Injectable({ providedIn: 'root', }) export class WebSocketService implements WebSocketServiceInterface { private readonly audioPlayingService = inject(AUDIO_PLAYING_SERVICE); + private readonly authService = inject(AuthService); private socket$!: WebSocketSubject; private messages$: BehaviorSubject = new BehaviorSubject(''); @@ -37,9 +39,16 @@ export class WebSocketService implements WebSocketServiceInterface { private audioIntervalId: any = null; private closeReasonSubject = new Subject(); - connect(serverUrl: string) { + async connect(serverUrl: string) { + let wsUrl = serverUrl; + const token = await this.authService.getToken(); + if (token) { + const separator = wsUrl.includes('?') ? '&' : '?'; + wsUrl = `${wsUrl}${separator}token=${encodeURIComponent(token)}`; + } + this.socket$ = new WebSocketSubject({ - url: serverUrl, + url: wsUrl, serializer: (msg) => JSON.stringify(msg), deserializer: (event) => event.data, closeObserver: { From 786ef0564a5adcdb10888c7204e1e504d6e1e3fe Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 15:22:40 -0400 Subject: [PATCH 04/10] refactor: harden OIDC auth to enterprise/production grade Critical fixes: - Replace keycloak-js with oidc-client-ts for provider-agnostic OIDC support (works with Keycloak, Okta, Auth0, Azure AD, any OIDC provider) - Remove WebSocket token-from-query-string (log/referrer leakage risk); document as known limitation requiring server-side protocol changes Major fixes: - Make AuthService.init() idempotent (deduplicate concurrent calls) - Fail-closed: block requests when auth enabled but token missing, instead of sending unauthenticated requests - Fix getToken() to propagate refresh failures and trigger re-login - Add SSE response.ok check before reading stream body - Improve auth guard: redirect to login instead of blank page - Handle bootstrap auth failure with visible error state - Migrate to Angular 21 APIs: provideAppInitializer (replaces deprecated APP_INITIALIZER), functional interceptor (replaces class-based HTTP_INTERCEPTORS), provideHttpClient Minor fixes: - Add Window augmentation for __ADK_CONFIG__ (removes any casts) - Export UserInfo as type from barrel - Wire scopes config through to OIDC client - Add Apache 2.0 license headers to all new files - Consistent copyright year (2025) Testing: - Add Karma/Jasmine specs for auth.service, auth.interceptor, auth.guard, auth.config, and user-menu component - Tests follow existing initTestBed() pattern for 1p compatibility Documentation: - Add Authentication section to README covering OIDC setup, Kubernetes ConfigMap injection, Kagenti/SPIFFE integration, supported providers, and security model --- README.md | 85 ++++++++++++ package-lock.json | 32 +++-- package.json | 2 +- .../user-menu/user-menu.component.spec.ts | 120 ++++++++++++++++ .../user-menu/user-menu.component.ts | 4 +- src/app/core/auth/auth.config.spec.ts | 126 +++++++++++++++++ src/app/core/auth/auth.config.ts | 11 +- src/app/core/auth/auth.guard.spec.ts | 64 +++++++++ src/app/core/auth/auth.guard.ts | 15 +- src/app/core/auth/auth.interceptor.spec.ts | 105 ++++++++++++++ src/app/core/auth/auth.interceptor.ts | 60 ++++---- src/app/core/auth/auth.service.spec.ts | 89 ++++++++++++ src/app/core/auth/auth.service.ts | 131 ++++++++++++------ src/app/core/auth/index.ts | 3 +- src/app/core/models/RuntimeConfig.ts | 18 ++- src/app/core/services/agent.service.ts | 9 ++ src/app/core/services/interfaces/websocket.ts | 2 +- src/app/core/services/websocket.service.ts | 21 ++- src/assets/config/runtime-config.json | 2 +- src/main.ts | 36 ++--- src/silent-check-sso.html | 15 ++ 21 files changed, 809 insertions(+), 141 deletions(-) create mode 100644 src/app/components/user-menu/user-menu.component.spec.ts create mode 100644 src/app/core/auth/auth.config.spec.ts create mode 100644 src/app/core/auth/auth.guard.spec.ts create mode 100644 src/app/core/auth/auth.interceptor.spec.ts create mode 100644 src/app/core/auth/auth.service.spec.ts diff --git a/README.md b/README.md index 5e500345..94d29431 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,91 @@ Go to `localhost:4200` and start developing! ### And more! +## 🔐 Authentication (Optional) + +ADK Web supports optional OIDC authentication for enterprise deployments. When enabled, users must authenticate via an OIDC-compliant provider before accessing the UI. When disabled (default), the UI is accessible without authentication. + +### Supported Providers + +Any OIDC-compliant identity provider works, including: +- **Keycloak** (including Red Hat build of Keycloak) +- **Okta** / Auth0 +- **Azure AD** / Entra ID +- **Google Identity Platform** +- Any provider with a standard `/.well-known/openid-configuration` endpoint + +### Enabling Authentication + +Add an `auth` section to `runtime-config.json`: + +```json +{ + "backendUrl": "http://localhost:8000", + "auth": { + "enabled": true, + "authority": "https://keycloak.example.com/realms/my-realm", + "clientId": "adk-web-ui", + "scopes": "openid profile email" + } +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `enabled` | Yes | Set to `true` to enable OIDC authentication | +| `authority` | Yes | OIDC issuer URL. For Keycloak: `https://host/realms/{realm}` | +| `clientId` | Yes | OIDC public client ID registered with your provider | +| `scopes` | No | Space-separated scopes (default: `openid profile email`) | +| `silentRefresh` | No | Enable background token refresh (default: `true`) | +| `postLogoutRedirectUri` | No | URL to redirect to after logout | + +### Kubernetes / Container Deployments + +For container deployments, enable auth without rebuilding by injecting a ConfigMap: + +```javascript +// Mount as runtime-config.js and include via script tag, or +// inject into runtime-config.json via ConfigMap mount +window.__ADK_CONFIG__ = { + auth: { + enabled: true, + authority: "https://keycloak.example.com/realms/kagenti", + clientId: "adk-web-ui" + } +}; +``` + +`window.__ADK_CONFIG__` takes priority over `runtime-config.json`. + +### Integration with Kagenti + SPIFFE/SPIRE + +When agents are managed by [Kagenti](https://github.com/kagenti) with SPIFFE/SPIRE zero-trust security, OIDC authentication provides the browser-to-agent security layer: + +``` +Browser --OIDC--> Identity Provider --JWT--> Browser + | + | Authorization: Bearer + v +Envoy AuthBridge (Kagenti sidecar) --validates JWT--> ADK Agent + | + | SPIFFE/SPIRE mTLS (automatic via ztunnel) + v +Backend Services (SonataFlow, MCP servers, etc.) +``` + +The UI handles the first hop (Browser to Envoy). Kagenti infrastructure handles the second hop (Agent to Services) via SPIFFE/SPIRE mTLS -- no application code changes needed. + +### Security Model + +- **PKCE** (Proof Key for Code Exchange) is enforced for all authorization flows +- **Fail-closed**: if auth is enabled but a token cannot be obtained, requests are blocked rather than sent without credentials +- **Automatic refresh**: tokens are silently refreshed before expiry +- **Provider-agnostic**: uses standard `oidc-client-ts`, not provider-specific adapters + +### Behavior When Auth Is Enabled + +When authentication is active, the "User ID" text field in the toolbar is replaced by an authenticated user menu showing the user's name, email, and roles from the OIDC token. + ## 🤝 Contributing We welcome contributions from the community! Whether it's bug reports, feature requests, documentation improvements, or code contributions, please see our diff --git a/package-lock.json b/package-lock.json index 3fb2e998..9f171c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,11 +23,11 @@ "@codemirror/lint": "^6.8.5", "@viz-js/viz": "^3.12.0", "codemirror": "^6.0.2", - "keycloak-js": "^26.2.4", "mermaid": "^11.14.0", "ngx-json-viewer": "^3.2.1", "ngx-markdown": "^21.0.1", "ngx-vflow": "^1.16.4", + "oidc-client-ts": "^3.5.0", "rxjs": "~7.8.0", "safevalues": "^1.2.0", "string-to-color": "2.2.2", @@ -11459,6 +11459,15 @@ "jsonrepair": "bin/cli.js" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/karma": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", @@ -11965,15 +11974,6 @@ "katex": "cli.js" } }, - "node_modules/keycloak-js": { - "version": "26.2.4", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", - "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", - "license": "Apache-2.0", - "workspaces": [ - "test" - ] - }, "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", @@ -13439,6 +13439,18 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", diff --git a/package.json b/package.json index e58397b6..7b8adef5 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,11 @@ "@codemirror/lint": "^6.8.5", "@viz-js/viz": "^3.12.0", "codemirror": "^6.0.2", - "keycloak-js": "^26.2.4", "mermaid": "^11.14.0", "ngx-json-viewer": "^3.2.1", "ngx-markdown": "^21.0.1", "ngx-vflow": "^1.16.4", + "oidc-client-ts": "^3.5.0", "rxjs": "~7.8.0", "safevalues": "^1.2.0", "string-to-color": "2.2.2", diff --git a/src/app/components/user-menu/user-menu.component.spec.ts b/src/app/components/user-menu/user-menu.component.spec.ts new file mode 100644 index 00000000..dd00de2e --- /dev/null +++ b/src/app/components/user-menu/user-menu.component.spec.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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 {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it} + +import {initTestBed} from '../../testing/utils'; +import {AuthService, UserInfo} from '../../core/auth/auth.service'; +import {AuthUserMenuComponent} from './user-menu.component'; + +describe('AuthUserMenuComponent', () => { + let component: AuthUserMenuComponent; + let fixture: ComponentFixture; + let authServiceSpy: jasmine.SpyObj; + + function createComponent( + isEnabled: boolean, + isAuthenticated: boolean = false, + userInfo: UserInfo|null = null) { + authServiceSpy = jasmine.createSpyObj( + 'AuthService', ['isAuthenticated', 'getUserInfo', 'logout'], + {isEnabled}); + authServiceSpy.isAuthenticated.and.returnValue(isAuthenticated); + authServiceSpy.getUserInfo.and.returnValue(userInfo); + authServiceSpy.logout.and.resolveTo(); + + initTestBed(); + TestBed.configureTestingModule({ + imports: [AuthUserMenuComponent, NoopAnimationsModule], + providers: [ + {provide: AuthService, useValue: authServiceSpy}, + ], + }); + + fixture = TestBed.createComponent(AuthUserMenuComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + describe('auth disabled', () => { + beforeEach(() => { + createComponent(false); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); + + it('should render empty template', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).toBeNull(); + }); + }); + + describe('auth enabled + authenticated', () => { + const mockUser: UserInfo = { + name: 'Jane Doe', + email: 'jane@example.com', + userId: 'user-42', + roles: ['admin', 'editor'], + }; + + beforeEach(() => { + createComponent(true, true, mockUser); + }); + + it('should show user avatar button', () => { + const button = fixture.debugElement.query( + By.css('.user-avatar-button')); + expect(button).toBeTruthy(); + }); + + it('should expose userInfo from AuthService', () => { + expect(component.userInfo).toEqual(mockUser); + }); + }); + + describe('auth enabled + not authenticated', () => { + beforeEach(() => { + createComponent(true, false); + }); + + it('should render empty template', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).toBeNull(); + }); + }); + + describe('logout', () => { + beforeEach(() => { + createComponent(true, true, { + name: 'Test', + email: 'test@test.com', + userId: '1', + roles: [], + }); + }); + + it('should call authService.logout', async () => { + await component.logout(); + expect(authServiceSpy.logout).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/components/user-menu/user-menu.component.ts b/src/app/components/user-menu/user-menu.component.ts index 586d9c5c..af15645e 100644 --- a/src/app/components/user-menu/user-menu.component.ts +++ b/src/app/components/user-menu/user-menu.component.ts @@ -21,7 +21,7 @@ import {MatIcon} from '@angular/material/icon'; import {MatMenuModule} from '@angular/material/menu'; import {MatTooltip} from '@angular/material/tooltip'; -import {AuthService, UserInfo} from '../../core/auth/auth.service'; +import {AuthService, UserInfo} from '../../core/auth'; /** * User menu component for authenticated sessions. @@ -32,7 +32,7 @@ import {AuthService, UserInfo} from '../../core/auth/auth.service'; * remains visible instead. * * Designed to integrate with Kagenti-managed deployments where - * Keycloak provides OIDC authentication. + * an OIDC provider handles authentication. */ @Component({ selector: 'app-auth-user-menu', diff --git a/src/app/core/auth/auth.config.spec.ts b/src/app/core/auth/auth.config.spec.ts new file mode 100644 index 00000000..b90b1dcd --- /dev/null +++ b/src/app/core/auth/auth.config.spec.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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. + */ + +// 1p-ONLY-IMPORTS: import {afterEach, beforeEach, describe, expect, it} + +import {AuthConfig} from '../models/RuntimeConfig'; +import {RuntimeConfigUtil} from '../../../utils/runtime-config-util'; +import {resolveAuthConfig} from './auth.config'; + +const VALID_AUTH_CONFIG: AuthConfig = { + enabled: true, + authority: 'https://keycloak.example.com/realms/test', + clientId: 'adk-web', +}; + +describe('resolveAuthConfig', () => { + let originalAdkConfig: any; + let getRuntimeConfigSpy: jasmine.Spy; + + beforeEach(() => { + originalAdkConfig = (window as any)['__ADK_CONFIG__']; + delete (window as any)['__ADK_CONFIG__']; + getRuntimeConfigSpy = + spyOn(RuntimeConfigUtil, 'getRuntimeConfig').and.returnValue( + undefined as any); + }); + + afterEach(() => { + if (originalAdkConfig !== undefined) { + (window as any)['__ADK_CONFIG__'] = originalAdkConfig; + } else { + delete (window as any)['__ADK_CONFIG__']; + } + }); + + it('returns undefined when no config is available', () => { + expect(resolveAuthConfig()).toBeUndefined(); + }); + + it('returns auth from __ADK_CONFIG__ when set with enabled=true', () => { + (window as any)['__ADK_CONFIG__'] = {auth: VALID_AUTH_CONFIG}; + + const result = resolveAuthConfig(); + + expect(result).toEqual(VALID_AUTH_CONFIG); + }); + + it('falls through __ADK_CONFIG__ when auth.enabled is false', () => { + const runtimeAuth: AuthConfig = { + enabled: true, + authority: 'https://runtime.example.com/realms/test', + clientId: 'runtime-client', + }; + (window as any)['__ADK_CONFIG__'] = { + auth: {enabled: false, authority: '', clientId: ''}, + }; + getRuntimeConfigSpy.and.returnValue({ + backendUrl: '', + auth: runtimeAuth, + }); + + const result = resolveAuthConfig(); + + expect(result).toEqual(runtimeAuth); + }); + + it('returns auth from runtime config when __ADK_CONFIG__ is absent', () => { + const runtimeAuth: AuthConfig = { + enabled: true, + authority: 'https://okta.example.com', + clientId: 'okta-client', + }; + getRuntimeConfigSpy.and.returnValue({ + backendUrl: 'http://localhost:8080', + auth: runtimeAuth, + }); + + const result = resolveAuthConfig(); + + expect(result).toEqual(runtimeAuth); + }); + + it('__ADK_CONFIG__ takes priority over runtime config', () => { + const adkAuth: AuthConfig = { + enabled: true, + authority: 'https://adk.example.com', + clientId: 'adk-client', + }; + const runtimeAuth: AuthConfig = { + enabled: true, + authority: 'https://runtime.example.com', + clientId: 'runtime-client', + }; + (window as any)['__ADK_CONFIG__'] = {auth: adkAuth}; + getRuntimeConfigSpy.and.returnValue({ + backendUrl: '', + auth: runtimeAuth, + }); + + const result = resolveAuthConfig(); + + expect(result).toEqual(adkAuth); + }); + + it('returns undefined when runtime config has no auth', () => { + getRuntimeConfigSpy.and.returnValue({ + backendUrl: 'http://localhost:8080', + }); + + expect(resolveAuthConfig()).toBeUndefined(); + }); +}); diff --git a/src/app/core/auth/auth.config.ts b/src/app/core/auth/auth.config.ts index 408ec336..aa4beefe 100644 --- a/src/app/core/auth/auth.config.ts +++ b/src/app/core/auth/auth.config.ts @@ -18,6 +18,12 @@ import {AuthConfig} from '../models/RuntimeConfig'; import {RuntimeConfigUtil} from '../../../utils/runtime-config-util'; +declare global { + interface Window { + __ADK_CONFIG__?: {auth?: AuthConfig}; + } +} + /** * Resolves auth configuration from available sources. * @@ -30,14 +36,11 @@ import {RuntimeConfigUtil} from '../../../utils/runtime-config-util'; * that sets window.__ADK_CONFIG__. */ export function resolveAuthConfig(): AuthConfig | undefined { - // Check for Kubernetes ConfigMap-injected config - const adkConfig = - (window as any)['__ADK_CONFIG__'] as {auth?: AuthConfig} | undefined; + const adkConfig = window.__ADK_CONFIG__; if (adkConfig?.auth?.enabled) { return adkConfig.auth; } - // Fall back to standard runtime-config.json const runtimeConfig = RuntimeConfigUtil.getRuntimeConfig(); return runtimeConfig?.auth; } diff --git a/src/app/core/auth/auth.guard.spec.ts b/src/app/core/auth/auth.guard.spec.ts new file mode 100644 index 00000000..245634bc --- /dev/null +++ b/src/app/core/auth/auth.guard.spec.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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 {TestBed} from '@angular/core/testing'; +// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it} + +import {initTestBed} from '../../testing/utils'; +import {AuthService} from './auth.service'; +import {authGuard} from './auth.guard'; + +describe('authGuard', () => { + let authServiceSpy: jasmine.SpyObj; + + function configureModule( + isEnabled: boolean, isAuthenticated: boolean = true) { + authServiceSpy = jasmine.createSpyObj( + 'AuthService', ['login', 'isAuthenticated'], + {isEnabled}); + authServiceSpy.isAuthenticated.and.returnValue(isAuthenticated); + authServiceSpy.login.and.resolveTo(); + initTestBed(); + TestBed.configureTestingModule({ + providers: [ + {provide: AuthService, useValue: authServiceSpy}, + ], + }); + } + + it('returns true when auth is disabled', () => { + configureModule(false); + const result = TestBed.runInInjectionContext(() => authGuard( + {} as any, {} as any)); + expect(result).toBeTrue(); + }); + + it('returns true when auth is enabled and user is authenticated', () => { + configureModule(true, true); + const result = TestBed.runInInjectionContext(() => authGuard( + {} as any, {} as any)); + expect(result).toBeTrue(); + }); + + it('calls login and returns false when not authenticated', () => { + configureModule(true, false); + const result = TestBed.runInInjectionContext(() => authGuard( + {} as any, {} as any)); + expect(result).toBeFalse(); + expect(authServiceSpy.login).toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/auth/auth.guard.ts b/src/app/core/auth/auth.guard.ts index dc403817..009ba9c0 100644 --- a/src/app/core/auth/auth.guard.ts +++ b/src/app/core/auth/auth.guard.ts @@ -25,9 +25,10 @@ import {AuthService} from './auth.service'; * * When auth is disabled (default), the guard always returns true. * When auth is enabled and the user is not authenticated, the guard - * triggers the OIDC login flow via keycloak-js redirect. + * triggers the OIDC login redirect. APP_INITIALIZER handles initial + * authentication; this guard is a safety net for late navigation. */ -export const authGuard: CanActivateFn = async () => { +export const authGuard: CanActivateFn = () => { const authService = inject(AuthService); if (!authService.isEnabled) { @@ -38,12 +39,6 @@ export const authGuard: CanActivateFn = async () => { return true; } - // Not authenticated -- keycloak init with onLoad: 'login-required' - // should have already redirected, but as a safety net, trigger login. - try { - await authService.init(); - return authService.isAuthenticated(); - } catch { - return false; - } + authService.login(); + return false; }; diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts new file mode 100644 index 00000000..17b7b97e --- /dev/null +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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 {HttpClient, provideHttpClient, withInterceptors} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +// 1p-ONLY-IMPORTS: import {afterEach, beforeEach, describe, expect, it} + +import {initTestBed} from '../../testing/utils'; +import {AuthService} from './auth.service'; +import {authInterceptorFn} from './auth.interceptor'; + +describe('authInterceptorFn', () => { + let httpClient: HttpClient; + let httpTesting: HttpTestingController; + let authServiceSpy: jasmine.SpyObj; + + function configureModule(isEnabled: boolean) { + authServiceSpy = jasmine.createSpyObj( + 'AuthService', ['getToken'], {isEnabled}); + initTestBed(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptorFn])), + provideHttpClientTesting(), + {provide: AuthService, useValue: authServiceSpy}, + ], + }); + httpClient = TestBed.inject(HttpClient); + httpTesting = TestBed.inject(HttpTestingController); + } + + afterEach(() => { + httpTesting.verify(); + }); + + describe('auth disabled', () => { + beforeEach(() => { + configureModule(false); + }); + + it('passes requests through without Authorization header', () => { + httpClient.get('/api/test').subscribe(); + const req = httpTesting.expectOne('/api/test'); + expect(req.request.headers.has('Authorization')).toBeFalse(); + req.flush({}); + }); + + it('does not call getToken', () => { + httpClient.get('/api/test').subscribe(); + const req = httpTesting.expectOne('/api/test'); + expect(authServiceSpy.getToken).not.toHaveBeenCalled(); + req.flush({}); + }); + }); + + describe('auth enabled', () => { + beforeEach(() => { + configureModule(true); + }); + + it('adds Authorization header when token is available', + (done: DoneFn) => { + authServiceSpy.getToken.and.resolveTo('test-token-123'); + + httpClient.get('/api/test').subscribe({ + next: () => done(), + error: done.fail, + }); + + setTimeout(() => { + const req = httpTesting.expectOne('/api/test'); + expect(req.request.headers.get('Authorization')) + .toBe('Bearer test-token-123'); + req.flush({}); + }); + }); + + it('rejects request when no token is available', (done: DoneFn) => { + authServiceSpy.getToken.and.resolveTo(''); + + httpClient.get('/api/test').subscribe({ + next: () => done.fail('Expected error'), + error: (err) => { + expect(err.message).toContain('no token'); + done(); + }, + }); + }); + }); +}); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 6dc1790a..38d2564f 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -15,42 +15,42 @@ * limitations under the License. */ -import {Injectable} from '@angular/core'; -import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; -import {Observable, from, switchMap} from 'rxjs'; +import {inject} from '@angular/core'; +import {HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpEvent} from '@angular/common/http'; +import {Observable, from, switchMap, throwError} from 'rxjs'; import {AuthService} from './auth.service'; /** - * HTTP interceptor that attaches OIDC Bearer tokens to outgoing API - * requests when authentication is enabled. + * Functional HTTP interceptor that attaches OIDC Bearer tokens to + * outgoing API requests when authentication is enabled. * * When auth is disabled, requests pass through unmodified. When - * enabled, each request to the backend receives an - * `Authorization: Bearer ` header. This token is validated - * by Kagenti's Envoy AuthBridge sidecar before reaching the agent. + * enabled, each request receives an `Authorization: Bearer ` + * header. Fails closed: if auth is enabled but no token is available, + * the request is rejected rather than sent without credentials. */ -@Injectable() -export class AuthInterceptor implements HttpInterceptor { - constructor(private authService: AuthService) {} +export const authInterceptorFn: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn, +): Observable> => { + const authService = inject(AuthService); - intercept( - req: HttpRequest, - next: HttpHandler): Observable> { - if (!this.authService.isEnabled) { - return next.handle(req); - } - - return from(this.authService.getToken()).pipe( - switchMap((token) => { - if (token) { - const authReq = req.clone({ - setHeaders: {Authorization: `Bearer ${token}`}, - }); - return next.handle(authReq); - } - return next.handle(req); - }), - ); + if (!authService.isEnabled) { + return next(req); } -} + + return from(authService.getToken()).pipe( + switchMap((token) => { + if (token) { + const authReq = req.clone({ + setHeaders: {Authorization: `Bearer ${token}`}, + }); + return next(authReq); + } + return throwError( + () => new Error('Auth is enabled but no token is available'), + ); + }), + ); +}; diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts new file mode 100644 index 00000000..35382ff1 --- /dev/null +++ b/src/app/core/auth/auth.service.spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed 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 {TestBed} from '@angular/core/testing'; +// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it} + +import {initTestBed} from '../../testing/utils'; +import * as authConfigModule from './auth.config'; + +import {AuthService} from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(() => { + spyOn(authConfigModule, 'resolveAuthConfig').and.returnValue(undefined); + initTestBed(); + TestBed.configureTestingModule({ + providers: [AuthService], + }); + service = TestBed.inject(AuthService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('auth disabled (default)', () => { + beforeEach(async () => { + await service.init(); + }); + + it('isEnabled should be false', () => { + expect(service.isEnabled).toBeFalse(); + }); + + it('isAuthenticated should return true when disabled', () => { + expect(service.isAuthenticated()).toBeTrue(); + }); + + it('getToken should return empty string', async () => { + const token = await service.getToken(); + expect(token).toBe(''); + }); + + it('getUserInfo should return null', () => { + expect(service.getUserInfo()).toBeNull(); + }); + + it('init should resolve immediately', async () => { + await expectAsync(service.init()).toBeResolved(); + }); + + it('login should resolve without error', async () => { + await expectAsync(service.login()).toBeResolved(); + }); + + it('logout should resolve without error', async () => { + await expectAsync(service.logout()).toBeResolved(); + }); + }); + + describe('init idempotency', () => { + it('second init call returns same promise', () => { + const p1 = service.init(); + const p2 = service.init(); + expect(p1).toBe(p2); + }); + + it('init after completion is a no-op', async () => { + await service.init(); + await expectAsync(service.init()).toBeResolved(); + }); + }); +}); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index cdf67953..8b367d2f 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -16,7 +16,7 @@ */ import {Injectable} from '@angular/core'; -import Keycloak from 'keycloak-js'; +import {UserManager, UserManagerSettings, User} from 'oidc-client-ts'; import {AuthConfig} from '../models/RuntimeConfig'; import {resolveAuthConfig} from './auth.config'; @@ -28,32 +28,45 @@ export interface UserInfo { roles: string[]; } +export interface OidcUserProfile { + name?: string; + preferred_username?: string; + email?: string; + realm_access?: {roles?: string[]}; +} + /** * OIDC authentication service for ADK Web UI. * * When auth is enabled in runtime-config.json, this service manages - * the Keycloak/OIDC lifecycle: login, token management, refresh, and - * logout. When auth is disabled, all methods are no-ops and - * isAuthenticated() returns true. + * the OIDC lifecycle via oidc-client-ts: login, token management, + * silent refresh, and logout. When auth is disabled, all methods are + * no-ops and isAuthenticated() returns true. * * Designed for enterprise deployments where agents are managed by * Kagenti with SPIFFE/SPIRE zero-trust security. */ @Injectable({providedIn: 'root'}) export class AuthService { - private keycloak: Keycloak | null = null; + private userManager: UserManager | null = null; private authConfig: AuthConfig | undefined; private initialized = false; + private initPromise: Promise | null = null; + private currentUser: User | null = null; get isEnabled(): boolean { return !!this.authConfig?.enabled; } - /** - * Initialize the auth service. Must be called before the app renders. - * When auth is disabled, resolves immediately. - */ async init(): Promise { + if (this.initialized) return; + if (this.initPromise) return this.initPromise; + + this.initPromise = this.doInit(); + return this.initPromise; + } + + private async doInit(): Promise { const config = resolveAuthConfig(); this.authConfig = config; @@ -62,35 +75,49 @@ export class AuthService { return; } - this.keycloak = new Keycloak({ - url: this.authConfig.oidcUrl, - realm: this.authConfig.oidcRealm, - clientId: this.authConfig.oidcClientId, - }); + const settings: UserManagerSettings = { + authority: this.authConfig.authority, + client_id: this.authConfig.clientId, + redirect_uri: window.location.origin, + post_logout_redirect_uri: + this.authConfig.postLogoutRedirectUri ?? window.location.origin, + response_type: 'code', + scope: this.authConfig.scopes ?? 'openid profile email', + automaticSilentRenew: this.authConfig.silentRefresh !== false, + silent_redirect_uri: + window.location.origin + '/silent-check-sso.html', + }; + + this.userManager = new UserManager(settings); try { - const authenticated = await this.keycloak.init({ - onLoad: 'login-required', - checkLoginIframe: false, - silentCheckSsoRedirectUri: - this.authConfig.silentRefresh !== false - ? `${window.location.origin}/silent-check-sso.html` - : undefined, - }); + if ( + window.location.search.includes('code=') || + window.location.hash.includes('code=') + ) { + const user = await this.userManager.signinCallback(); + this.currentUser = user as User; + window.history.replaceState({}, document.title, window.location.pathname); + } - if (!authenticated) { - await this.keycloak.login(); + if (!this.currentUser) { + this.currentUser = await this.userManager.getUser(); } - // Set up automatic token refresh - this.keycloak.onTokenExpired = () => { - this.keycloak?.updateToken(30).catch(() => { - console.warn('Token refresh failed, redirecting to login'); - this.keycloak?.login(); - }); - }; + if (!this.currentUser || this.currentUser.expired) { + await this.userManager.signinRedirect(); + return; + } this.initialized = true; + + this.userManager.events.addUserLoaded((user: User) => { + this.currentUser = user; + }); + + this.userManager.events.addSilentRenewError(() => { + this.userManager?.signinRedirect(); + }); } catch (err) { console.error('OIDC initialization failed:', err); throw err; @@ -99,35 +126,47 @@ export class AuthService { isAuthenticated(): boolean { if (!this.isEnabled) return true; - return this.keycloak?.authenticated ?? false; + return this.currentUser != null && !this.currentUser.expired; } - async getToken(): Promise { - if (!this.isEnabled || !this.keycloak) return undefined; + async getToken(): Promise { + if (!this.isEnabled) return ''; + + if (this.currentUser && !this.currentUser.expired) { + return this.currentUser.access_token; + } try { - await this.keycloak.updateToken(5); + const user = await this.userManager!.signinSilent(); + this.currentUser = user; + return user!.access_token; } catch { - // Token refresh failed, will be caught by onTokenExpired + this.userManager?.signinRedirect(); + throw new Error('Token refresh failed'); } - return this.keycloak.token; } getUserInfo(): UserInfo | null { - if (!this.isEnabled || !this.keycloak?.tokenParsed) return null; + if (!this.isEnabled || !this.currentUser) return null; - const parsed = this.keycloak.tokenParsed; + const profile = this.currentUser.profile as OidcUserProfile; return { - name: (parsed as any)['name'] ?? - (parsed as any)['preferred_username'] ?? 'User', - email: (parsed as any)['email'] ?? '', - userId: parsed.sub ?? '', - roles: (parsed as any)['realm_access']?.['roles'] ?? [], + name: profile.name ?? profile.preferred_username ?? 'User', + email: profile.email ?? '', + userId: this.currentUser.profile.sub ?? '', + roles: profile.realm_access?.roles ?? [], }; } + async login(): Promise { + await this.userManager?.signinRedirect(); + } + async logout(): Promise { - if (!this.isEnabled || !this.keycloak) return; - await this.keycloak.logout({redirectUri: window.location.origin}); + if (!this.isEnabled || !this.userManager) return; + await this.userManager.signoutRedirect({ + post_logout_redirect_uri: + this.authConfig?.postLogoutRedirectUri ?? window.location.origin, + }); } } diff --git a/src/app/core/auth/index.ts b/src/app/core/auth/index.ts index d9135c7b..782375b3 100644 --- a/src/app/core/auth/index.ts +++ b/src/app/core/auth/index.ts @@ -16,6 +16,7 @@ */ export {AuthService} from './auth.service'; -export {AuthInterceptor} from './auth.interceptor'; +export type {UserInfo} from './auth.service'; +export {authInterceptorFn} from './auth.interceptor'; export {authGuard} from './auth.guard'; export {resolveAuthConfig} from './auth.config'; diff --git a/src/app/core/models/RuntimeConfig.ts b/src/app/core/models/RuntimeConfig.ts index 37a8bd39..24c28db8 100644 --- a/src/app/core/models/RuntimeConfig.ts +++ b/src/app/core/models/RuntimeConfig.ts @@ -35,9 +35,13 @@ export declare interface LogoConfig { /** * OIDC authentication configuration for enterprise deployments. - * When enabled, users must authenticate via an OIDC provider (Keycloak, - * Okta, Auth0, etc.) before accessing the UI. When disabled or absent, - * the UI is accessible without authentication (default behavior). + * Works with any OIDC provider (Keycloak, Okta, Auth0, etc.). + * When enabled, users must authenticate before accessing the UI. + * When disabled or absent, the UI is accessible without authentication + * (default behavior). + * + * The `authority` field is the OIDC issuer URL. For Keycloak, the + * format is `https://keycloak.example.com/realms/{realm}`. * * Designed for use with Kagenti-managed agents where SPIFFE/SPIRE * provides zero-trust workload identity and Envoy AuthBridge validates @@ -45,9 +49,9 @@ export declare interface LogoConfig { */ export declare interface AuthConfig { enabled: boolean; - oidcUrl: string; - oidcRealm: string; - oidcClientId: string; - oidcScopes?: string; + authority: string; + clientId: string; + scopes?: string; silentRefresh?: boolean; + postLogoutRedirectUri?: string; } diff --git a/src/app/core/services/agent.service.ts b/src/app/core/services/agent.service.ts index 4099e731..a893a9e9 100644 --- a/src/app/core/services/agent.service.ts +++ b/src/app/core/services/agent.service.ts @@ -62,6 +62,9 @@ export class AgentService implements AgentServiceInterface { 'Accept': 'text/event-stream', }; const token = await this.authService.getToken(); + if (this.authService.isEnabled && !token) { + throw new Error('Auth is enabled but no token is available'); + } if (token) { headers['Authorization'] = `Bearer ${token}`; } @@ -72,6 +75,12 @@ export class AgentService implements AgentServiceInterface { headers, body: JSON.stringify(req), })) + .then((response) => { + if (!response.ok) { + throw new Error(`SSE request failed: ${response.status}`); + } + return response; + }) .then((response) => { const reader = response.body?.getReader(); const decoder = new TextDecoder('utf-8'); diff --git a/src/app/core/services/interfaces/websocket.ts b/src/app/core/services/interfaces/websocket.ts index 61edca8b..d60eb5da 100644 --- a/src/app/core/services/interfaces/websocket.ts +++ b/src/app/core/services/interfaces/websocket.ts @@ -26,7 +26,7 @@ export const WEBSOCKET_SERVICE = * Service to provide methods to handle websocket connections. */ export declare abstract class WebSocketService { - abstract connect(serverUrl: string): void | Promise; + abstract connect(serverUrl: string): void; abstract sendMessage(data: LiveRequest): void; abstract closeConnection(): void; abstract getMessages(): Observable; diff --git a/src/app/core/services/websocket.service.ts b/src/app/core/services/websocket.service.ts index 8dca7c99..f2fe5f64 100644 --- a/src/app/core/services/websocket.service.ts +++ b/src/app/core/services/websocket.service.ts @@ -24,14 +24,19 @@ import {Event} from '../models/types'; import {AUDIO_PLAYING_SERVICE} from './interfaces/audio-playing'; import {WebSocketService as WebSocketServiceInterface} from './interfaces/websocket'; -import {AuthService} from '../auth/auth.service'; + +// TODO: WebSocket-based features (live audio) currently operate without +// bearer auth. The HTTP API and SSE paths use Authorization headers, but +// WebSocket does not support custom headers. Passing tokens as query +// parameters is insecure (logged in server access logs, browser history, +// proxies). Proper WebSocket auth requires server-side protocol changes +// (e.g. subprotocol negotiation or first-message auth). @Injectable({ providedIn: 'root', }) export class WebSocketService implements WebSocketServiceInterface { private readonly audioPlayingService = inject(AUDIO_PLAYING_SERVICE); - private readonly authService = inject(AuthService); private socket$!: WebSocketSubject; private messages$: BehaviorSubject = new BehaviorSubject(''); @@ -39,16 +44,9 @@ export class WebSocketService implements WebSocketServiceInterface { private audioIntervalId: any = null; private closeReasonSubject = new Subject(); - async connect(serverUrl: string) { - let wsUrl = serverUrl; - const token = await this.authService.getToken(); - if (token) { - const separator = wsUrl.includes('?') ? '&' : '?'; - wsUrl = `${wsUrl}${separator}token=${encodeURIComponent(token)}`; - } - + connect(serverUrl: string) { this.socket$ = new WebSocketSubject({ - url: wsUrl, + url: serverUrl, serializer: (msg) => JSON.stringify(msg), deserializer: (event) => event.data, closeObserver: { @@ -134,7 +132,6 @@ export class WebSocketService implements WebSocketServiceInterface { urlSafeBase64ToBase64(urlSafeBase64: string): string { let base64 = urlSafeBase64.replace(/_/g, '/').replace(/-/g, '+'); - // Ensure correct padding while (base64.length % 4 !== 0) { base64 += '='; } diff --git a/src/assets/config/runtime-config.json b/src/assets/config/runtime-config.json index c6732406..238c76ca 100644 --- a/src/assets/config/runtime-config.json +++ b/src/assets/config/runtime-config.json @@ -1,3 +1,3 @@ { "backendUrl": "http://localhost:8000" -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index 86b14822..a91d8382 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,8 +18,8 @@ import {Catalog, DEFAULT_CATALOG, Theme} from '@a2ui/angular'; import {Location} from '@angular/common'; -import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; -import {APP_INITIALIZER, importProvidersFrom} from '@angular/core'; +import {provideHttpClient, withInterceptors} from '@angular/common/http'; +import {importProvidersFrom, inject, provideAppInitializer} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatFormFieldModule} from '@angular/material/form-field'; @@ -36,7 +36,7 @@ import {MarkdownComponent} from './app/components/markdown/markdown.component'; import {MARKDOWN_COMPONENT} from './app/components/markdown/markdown.component.interface'; import {A2UI_THEME} from './app/core/constants/a2ui-theme'; import {AuthService} from './app/core/auth/auth.service'; -import {AuthInterceptor} from './app/core/auth/auth.interceptor'; +import {authInterceptorFn} from './app/core/auth/auth.interceptor'; import {AgentBuilderService} from './app/core/services/agent-builder.service'; import {AgentService} from './app/core/services/agent.service'; import {ArtifactService} from './app/core/services/artifact.service'; @@ -90,8 +90,9 @@ fetch('./assets/config/runtime-config.json') bootstrapApplication(AppComponent, { providers: [ importProvidersFrom( - BrowserModule, FormsModule, HttpClientModule, AppRoutingModule, + BrowserModule, FormsModule, AppRoutingModule, MatInputModule, MatFormFieldModule, MatButtonModule), + provideHttpClient(withInterceptors([authInterceptorFn])), {provide: SESSION_SERVICE, useClass: SessionService}, {provide: AGENT_SERVICE, useClass: AgentService}, {provide: FEEDBACK_SERVICE, useClass: FeedbackService}, @@ -130,17 +131,20 @@ fetch('./assets/config/runtime-config.json') {provide: LOCATION_SERVICE, useClass: Location}, {provide: UI_STATE_SERVICE, useClass: UiStateService}, {provide: THEME_SERVICE, useClass: ThemeService}, - { - provide: APP_INITIALIZER, - useFactory: (authService: AuthService) => () => authService.init(), - deps: [AuthService], - multi: true, - }, - { - provide: HTTP_INTERCEPTORS, - useClass: AuthInterceptor, - multi: true, - }, + provideAppInitializer(() => { + const authService = inject(AuthService); + return authService.init(); + }), ] - }).catch((err) => console.error(err)); + }).catch((err) => { + console.error(err); + const errorDiv = document.createElement('div'); + errorDiv.style.cssText = + 'position:fixed;inset:0;display:flex;align-items:center;' + + 'justify-content:center;background:#fafafa;color:#d32f2f;' + + 'font-family:sans-serif;font-size:18px;padding:32px;text-align:center;'; + errorDiv.textContent = + 'Application failed to start. Please check the console for details.'; + document.body.appendChild(errorDiv); + }); }); diff --git a/src/silent-check-sso.html b/src/silent-check-sso.html index 32bcff29..0f07f053 100644 --- a/src/silent-check-sso.html +++ b/src/silent-check-sso.html @@ -1,3 +1,18 @@ + From e25214392ffd8c735f495ae18317e7af3d88d75e Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 15:38:57 -0400 Subject: [PATCH 05/10] feat: add OpenShift deployment manifests for ADK Web UI Includes: - ConfigMap for runtime auth config (OIDC authority, clientId) - Deployment with nginx serving the SPA + API reverse proxy - nginx config with SPA fallback, API proxy, security headers - Service and Route with TLS edge termination - Dockerfile for multi-stage production build Auth is configurable via ConfigMap -- update authority/clientId and restart the pod. No image rebuild needed. --- deploy/Dockerfile | 10 +++ deploy/openshift.yaml | 163 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 deploy/Dockerfile create mode 100644 deploy/openshift.yaml diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 00000000..bdea5a11 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npx ng build --configuration production + +FROM nginx:1.27-alpine +COPY --from=build /app/dist/agent_framework_web/browser /usr/share/nginx/html +EXPOSE 8080 diff --git a/deploy/openshift.yaml b/deploy/openshift.yaml new file mode 100644 index 00000000..0bf6fc3f --- /dev/null +++ b/deploy/openshift.yaml @@ -0,0 +1,163 @@ +# ADK Web UI - OpenShift Deployment +# +# Deploys the ADK Web UI with configurable OIDC authentication. +# Auth settings are injected via ConfigMap -- no image rebuild needed. +# +# Usage: +# 1. Update the ConfigMap values below for your environment +# 2. oc apply -f deploy/openshift.yaml +# 3. Access via the Route URL +# +# To disable auth: remove the "auth" section from runtime-config.json +# or set "enabled": false. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: adk-web-config + labels: + app: adk-web +data: + runtime-config.json: | + { + "backendUrl": "http://branch-monitor:8080", + "auth": { + "enabled": true, + "authority": "https://KEYCLOAK_HOST/realms/REALM_NAME", + "clientId": "adk-web-ui", + "scopes": "openid profile email" + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: adk-web + labels: + app: adk-web +spec: + replicas: 1 + selector: + matchLabels: + app: adk-web + template: + metadata: + labels: + app: adk-web + spec: + containers: + - name: adk-web + image: nginx:1.27-alpine + ports: + - containerPort: 8080 + name: http + volumeMounts: + - name: app-dist + mountPath: /usr/share/nginx/html + - name: config + mountPath: /usr/share/nginx/html/assets/config/runtime-config.json + subPath: runtime-config.json + - name: nginx-conf + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 5 + volumes: + - name: app-dist + emptyDir: {} + - name: config + configMap: + name: adk-web-config + - name: nginx-conf + configMap: + name: adk-web-nginx +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: adk-web-nginx + labels: + app: adk-web +data: + default.conf: | + server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback -- all routes serve index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to the ADK backend + location /list-apps { proxy_pass http://branch-monitor:8080; } + location /apps/ { proxy_pass http://branch-monitor:8080; } + location /run_sse { proxy_pass http://branch-monitor:8080; } + location /run { proxy_pass http://branch-monitor:8080; } + location /version { proxy_pass http://branch-monitor:8080; } + location /debug/ { proxy_pass http://branch-monitor:8080; } + location /builder/ { proxy_pass http://branch-monitor:8080; } + location /dev/ { proxy_pass http://branch-monitor:8080; } + location /a2a/ { proxy_pass http://branch-monitor:8080; } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1d; + add_header Cache-Control "public, immutable"; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } +--- +apiVersion: v1 +kind: Service +metadata: + name: adk-web + labels: + app: adk-web +spec: + selector: + app: adk-web + ports: + - port: 8080 + targetPort: 8080 + name: http +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: adk-web + labels: + app: adk-web +spec: + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect + to: + kind: Service + name: adk-web + weight: 100 + port: + targetPort: http From 257df1ddcb591bbb2afba3730c0b0602b2f0e358 Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 16:57:52 -0400 Subject: [PATCH 06/10] fix: OpenShift-compatible nginx config for non-root container - Use sed to rewrite pid path to /tmp/nginx.pid (writable by non-root) - Remove 'user nginx' directive for arbitrary UID compatibility - Extract nginx.conf to separate file for ConfigMap override - Set non-root USER 101 for OpenShift restricted SCC --- deploy/Dockerfile | 7 +++++++ deploy/nginx.conf | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 deploy/nginx.conf diff --git a/deploy/Dockerfile b/deploy/Dockerfile index bdea5a11..6b774d07 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -6,5 +6,12 @@ COPY . . RUN npx ng build --configuration production FROM nginx:1.27-alpine +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +RUN sed -i '/^user /d' /etc/nginx/nginx.conf && \ + sed -i 's|pid .*|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf && \ + chown -R 101:0 /var/cache/nginx /var/log/nginx /etc/nginx/conf.d && \ + chmod -R g+w /var/cache/nginx /var/log/nginx /etc/nginx/conf.d COPY --from=build /app/dist/agent_framework_web/browser /usr/share/nginx/html +USER 101 EXPOSE 8080 +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 00000000..31ca1c12 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /list-apps { proxy_pass http://branch-monitor:8080; } + location /apps/ { proxy_pass http://branch-monitor:8080; } + location /run_sse { proxy_pass http://branch-monitor:8080; proxy_read_timeout 300s; } + location /run { proxy_pass http://branch-monitor:8080; } + location /version { proxy_pass http://branch-monitor:8080; } + location /debug/ { proxy_pass http://branch-monitor:8080; } + location /builder/ { proxy_pass http://branch-monitor:8080; } + location /dev/ { proxy_pass http://branch-monitor:8080; } + location /a2a/ { proxy_pass http://branch-monitor:8080; } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1d; + add_header Cache-Control "public, immutable"; + } + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; +} From a51f0d755fb74db2df9aab879f623b5cc69f65ff Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 17:56:16 -0400 Subject: [PATCH 07/10] docs: add OpenShift deployment guide, Keycloak setup, and demo prompts - Fix deploy/openshift.yaml: use quay.io/rbrhssa/adk-web:latest image instead of nginx:1.27-alpine + emptyDir, add imagePullSecrets, add proxy_read_timeout 300s for SSE, add OIDC provider format comments - Add to README: step-by-step OpenShift deployment with oc commands, Keycloak client creation guide, and demo test prompts for both F5 provisioning and branch monitoring use cases --- README.md | 89 +++++++++++++++++++++++++++++++++++++++++++ deploy/openshift.yaml | 83 ++++++++++++++++++++++------------------ 2 files changed, 134 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 94d29431..377cc56e 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,95 @@ The UI handles the first hop (Browser to Envoy). Kagenti infrastructure handles When authentication is active, the "User ID" text field in the toolbar is replaced by an authenticated user menu showing the user's name, email, and roles from the OIDC token. +### OpenShift Deployment + +A pre-built container image is available at `quay.io/rbrhssa/adk-web:latest`. + +**Step 1: Create a pull secret (if the image is private)** + +```bash +oc create secret docker-registry quay-pull-secret \ + --docker-server=quay.io \ + --docker-username= \ + --docker-password= \ + -n +``` + +**Step 2: Update the ConfigMap in `deploy/openshift.yaml`** + +Edit the `adk-web-config` ConfigMap with your OIDC provider's authority URL: + +```yaml +# For Keycloak: +"authority": "https://keycloak.example.com/realms/my-realm" +# For Okta: +"authority": "https://dev-12345.okta.com" +``` + +To disable auth entirely, set `"enabled": false` or remove the `auth` section. + +**Step 3: Deploy** + +```bash +oc apply -f deploy/openshift.yaml -n +``` + +**Step 4: Verify** + +```bash +oc get pods -l app=adk-web # Should be 1/1 Running +oc get route adk-web -o jsonpath='{.spec.host}' # Your URL +``` + +**Step 5: Access the UI** -- open the Route URL in your browser. + +### Keycloak Client Setup + +If using Keycloak as your OIDC provider, create a public client: + +1. Login to the Keycloak admin console +2. Select your realm (e.g. `kagenti`) +3. Go to **Clients** > **Create client** +4. Set: + - **Client ID**: `adk-web-ui` + - **Client type**: OpenID Connect + - **Client authentication**: OFF (public client) + - **Standard flow**: ON + - **Direct access grants**: ON (optional, for testing) +5. Under **Access settings**, set: + - **Root URL**: `https://` + - **Valid redirect URIs**: `https:///*` + - **Web origins**: `+` (allows all origins from redirect URIs) +6. Save the client + +### Demo Test Prompts + +After deploying the agents and the UI, use these prompts to verify the system works: + +**Use Case 1 -- F5 VIP Provisioning** (select the `f5_provisioning` agent): + +``` +I need to provision a new F5 VIP. The hostname is myapp.prod.internal.bank.com, +IP 10.120.100.50, port 443, pool members 10.120.100.10:8443 and 10.120.100.11:8443, +partition production, VLAN 120. Validate the DNS naming, check for conflicts, +and trigger the provisioning workflow. +``` + +Expected: The agent validates DNS naming conventions, checks subnet/VLAN compliance, +reviews historical assignments, and either proceeds with the workflow or flags issues. + +**Use Case 2 -- Branch Network Monitoring** (select the `branch_monitor` agent): + +``` +Proactively check all Charlotte branches for network risks. Get branch inventory, +weather alerts for Mecklenburg county NC, power outage and ISP status, equipment +health, and correlate threats. Flag any branches at risk. +``` + +Expected: The agent calls 15+ tools (inventory, weather, power, ISP, equipment, correlation), +produces a threat assessment per branch (HIGH/LOW with scores), and triggers response +workflows for HIGH-threat branches. + ## 🤝 Contributing We welcome contributions from the community! Whether it's bug reports, feature requests, documentation improvements, or code contributions, please see our diff --git a/deploy/openshift.yaml b/deploy/openshift.yaml index 0bf6fc3f..8718025c 100644 --- a/deploy/openshift.yaml +++ b/deploy/openshift.yaml @@ -3,13 +3,20 @@ # Deploys the ADK Web UI with configurable OIDC authentication. # Auth settings are injected via ConfigMap -- no image rebuild needed. # +# Prerequisites: +# - ADK agent deployed (e.g. branch-monitor service on port 8080) +# - Keycloak with an 'adk-web-ui' public client (see README) +# # Usage: -# 1. Update the ConfigMap values below for your environment -# 2. oc apply -f deploy/openshift.yaml -# 3. Access via the Route URL +# 1. Create pull secret: +# oc create secret docker-registry quay-pull-secret \ +# --docker-server=quay.io --docker-username= --docker-password= +# 2. Update the ConfigMap below with your Keycloak authority URL +# 3. oc apply -f deploy/openshift.yaml -n +# 4. oc get route adk-web (to get the URL) # -# To disable auth: remove the "auth" section from runtime-config.json -# or set "enabled": false. +# To disable auth: set "enabled": false in the ConfigMap below, +# or remove the "auth" section entirely. --- apiVersion: v1 kind: ConfigMap @@ -18,9 +25,14 @@ metadata: labels: app: adk-web data: + # Edit authority to match your OIDC provider: + # Keycloak: https:///realms/ + # Okta: https://dev-.okta.com + # Auth0: https://.auth0.com + # Azure AD: https://login.microsoftonline.com//v2.0 runtime-config.json: | { - "backendUrl": "http://branch-monitor:8080", + "backendUrl": "", "auth": { "enabled": true, "authority": "https://KEYCLOAK_HOST/realms/REALM_NAME", @@ -45,15 +57,15 @@ spec: labels: app: adk-web spec: + imagePullSecrets: + - name: quay-pull-secret containers: - name: adk-web - image: nginx:1.27-alpine + image: quay.io/rbrhssa/adk-web:latest ports: - containerPort: 8080 name: http volumeMounts: - - name: app-dist - mountPath: /usr/share/nginx/html - name: config mountPath: /usr/share/nginx/html/assets/config/runtime-config.json subPath: runtime-config.json @@ -80,8 +92,6 @@ spec: initialDelaySeconds: 3 periodSeconds: 5 volumes: - - name: app-dist - emptyDir: {} - name: config configMap: name: adk-web-config @@ -98,37 +108,34 @@ metadata: data: default.conf: | server { - listen 8080; - server_name _; - root /usr/share/nginx/html; - index index.html; + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; - # SPA fallback -- all routes serve index.html - location / { - try_files $uri $uri/ /index.html; - } + location / { + try_files $uri $uri/ /index.html; + } - # Proxy API requests to the ADK backend - location /list-apps { proxy_pass http://branch-monitor:8080; } - location /apps/ { proxy_pass http://branch-monitor:8080; } - location /run_sse { proxy_pass http://branch-monitor:8080; } - location /run { proxy_pass http://branch-monitor:8080; } - location /version { proxy_pass http://branch-monitor:8080; } - location /debug/ { proxy_pass http://branch-monitor:8080; } - location /builder/ { proxy_pass http://branch-monitor:8080; } - location /dev/ { proxy_pass http://branch-monitor:8080; } - location /a2a/ { proxy_pass http://branch-monitor:8080; } + # Proxy API requests to the ADK agent backend + location /list-apps { proxy_pass http://branch-monitor:8080; } + location /apps/ { proxy_pass http://branch-monitor:8080; } + location /run_sse { proxy_pass http://branch-monitor:8080; proxy_read_timeout 300s; } + location /run { proxy_pass http://branch-monitor:8080; } + location /version { proxy_pass http://branch-monitor:8080; } + location /debug/ { proxy_pass http://branch-monitor:8080; } + location /builder/ { proxy_pass http://branch-monitor:8080; } + location /dev/ { proxy_pass http://branch-monitor:8080; } + location /a2a/ { proxy_pass http://branch-monitor:8080; } - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 1d; - add_header Cache-Control "public, immutable"; - } + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1d; + add_header Cache-Control "public, immutable"; + } - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; } --- apiVersion: v1 From 20819631bb822defe457e0d09a5ec1e88a9c8b55 Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 20:57:39 -0400 Subject: [PATCH 08/10] chore: trigger CLA re-check From 65b83b76caae3ac5acd7615cae7056d94764bf8d Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 21:03:12 -0400 Subject: [PATCH 09/10] chore: remove deploy/ from upstream PR OpenShift deployment manifests (Dockerfile, nginx config, K8s YAML) are infrastructure-specific and should not be in the core repo. Users deploy according to their own platform. The deploy files remain available in the rrbanda/adk-web fork for reference. --- deploy/Dockerfile | 17 ----- deploy/nginx.conf | 29 ------- deploy/openshift.yaml | 170 ------------------------------------------ 3 files changed, 216 deletions(-) delete mode 100644 deploy/Dockerfile delete mode 100644 deploy/nginx.conf delete mode 100644 deploy/openshift.yaml diff --git a/deploy/Dockerfile b/deploy/Dockerfile deleted file mode 100644 index 6b774d07..00000000 --- a/deploy/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM node:22-alpine AS build -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci -COPY . . -RUN npx ng build --configuration production - -FROM nginx:1.27-alpine -COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf -RUN sed -i '/^user /d' /etc/nginx/nginx.conf && \ - sed -i 's|pid .*|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf && \ - chown -R 101:0 /var/cache/nginx /var/log/nginx /etc/nginx/conf.d && \ - chmod -R g+w /var/cache/nginx /var/log/nginx /etc/nginx/conf.d -COPY --from=build /app/dist/agent_framework_web/browser /usr/share/nginx/html -USER 101 -EXPOSE 8080 -CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/nginx.conf b/deploy/nginx.conf deleted file mode 100644 index 31ca1c12..00000000 --- a/deploy/nginx.conf +++ /dev/null @@ -1,29 +0,0 @@ -server { - listen 8080; - server_name _; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - location /list-apps { proxy_pass http://branch-monitor:8080; } - location /apps/ { proxy_pass http://branch-monitor:8080; } - location /run_sse { proxy_pass http://branch-monitor:8080; proxy_read_timeout 300s; } - location /run { proxy_pass http://branch-monitor:8080; } - location /version { proxy_pass http://branch-monitor:8080; } - location /debug/ { proxy_pass http://branch-monitor:8080; } - location /builder/ { proxy_pass http://branch-monitor:8080; } - location /dev/ { proxy_pass http://branch-monitor:8080; } - location /a2a/ { proxy_pass http://branch-monitor:8080; } - - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 1d; - add_header Cache-Control "public, immutable"; - } - - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; -} diff --git a/deploy/openshift.yaml b/deploy/openshift.yaml deleted file mode 100644 index 8718025c..00000000 --- a/deploy/openshift.yaml +++ /dev/null @@ -1,170 +0,0 @@ -# ADK Web UI - OpenShift Deployment -# -# Deploys the ADK Web UI with configurable OIDC authentication. -# Auth settings are injected via ConfigMap -- no image rebuild needed. -# -# Prerequisites: -# - ADK agent deployed (e.g. branch-monitor service on port 8080) -# - Keycloak with an 'adk-web-ui' public client (see README) -# -# Usage: -# 1. Create pull secret: -# oc create secret docker-registry quay-pull-secret \ -# --docker-server=quay.io --docker-username= --docker-password= -# 2. Update the ConfigMap below with your Keycloak authority URL -# 3. oc apply -f deploy/openshift.yaml -n -# 4. oc get route adk-web (to get the URL) -# -# To disable auth: set "enabled": false in the ConfigMap below, -# or remove the "auth" section entirely. ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: adk-web-config - labels: - app: adk-web -data: - # Edit authority to match your OIDC provider: - # Keycloak: https:///realms/ - # Okta: https://dev-.okta.com - # Auth0: https://.auth0.com - # Azure AD: https://login.microsoftonline.com//v2.0 - runtime-config.json: | - { - "backendUrl": "", - "auth": { - "enabled": true, - "authority": "https://KEYCLOAK_HOST/realms/REALM_NAME", - "clientId": "adk-web-ui", - "scopes": "openid profile email" - } - } ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: adk-web - labels: - app: adk-web -spec: - replicas: 1 - selector: - matchLabels: - app: adk-web - template: - metadata: - labels: - app: adk-web - spec: - imagePullSecrets: - - name: quay-pull-secret - containers: - - name: adk-web - image: quay.io/rbrhssa/adk-web:latest - ports: - - containerPort: 8080 - name: http - volumeMounts: - - name: config - mountPath: /usr/share/nginx/html/assets/config/runtime-config.json - subPath: runtime-config.json - - name: nginx-conf - mountPath: /etc/nginx/conf.d/default.conf - subPath: default.conf - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 200m - memory: 128Mi - livenessProbe: - httpGet: - path: / - port: 8080 - initialDelaySeconds: 5 - periodSeconds: 10 - readinessProbe: - httpGet: - path: / - port: 8080 - initialDelaySeconds: 3 - periodSeconds: 5 - volumes: - - name: config - configMap: - name: adk-web-config - - name: nginx-conf - configMap: - name: adk-web-nginx ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: adk-web-nginx - labels: - app: adk-web -data: - default.conf: | - server { - listen 8080; - server_name _; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - # Proxy API requests to the ADK agent backend - location /list-apps { proxy_pass http://branch-monitor:8080; } - location /apps/ { proxy_pass http://branch-monitor:8080; } - location /run_sse { proxy_pass http://branch-monitor:8080; proxy_read_timeout 300s; } - location /run { proxy_pass http://branch-monitor:8080; } - location /version { proxy_pass http://branch-monitor:8080; } - location /debug/ { proxy_pass http://branch-monitor:8080; } - location /builder/ { proxy_pass http://branch-monitor:8080; } - location /dev/ { proxy_pass http://branch-monitor:8080; } - location /a2a/ { proxy_pass http://branch-monitor:8080; } - - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 1d; - add_header Cache-Control "public, immutable"; - } - - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - } ---- -apiVersion: v1 -kind: Service -metadata: - name: adk-web - labels: - app: adk-web -spec: - selector: - app: adk-web - ports: - - port: 8080 - targetPort: 8080 - name: http ---- -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - name: adk-web - labels: - app: adk-web -spec: - tls: - termination: edge - insecureEdgeTerminationPolicy: Redirect - to: - kind: Service - name: adk-web - weight: 100 - port: - targetPort: http From 0464f133c7a14fb553c7544b4c9a9d3ebee5b8c2 Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Sat, 16 May 2026 21:03:51 -0400 Subject: [PATCH 10/10] docs: simplify deployment section to be platform-agnostic --- README.md | 42 ++---------------------------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 377cc56e..59f86bb4 100644 --- a/README.md +++ b/README.md @@ -176,47 +176,9 @@ The UI handles the first hop (Browser to Envoy). Kagenti infrastructure handles When authentication is active, the "User ID" text field in the toolbar is replaced by an authenticated user menu showing the user's name, email, and roles from the OIDC token. -### OpenShift Deployment +### OpenShift / Kubernetes Deployment -A pre-built container image is available at `quay.io/rbrhssa/adk-web:latest`. - -**Step 1: Create a pull secret (if the image is private)** - -```bash -oc create secret docker-registry quay-pull-secret \ - --docker-server=quay.io \ - --docker-username= \ - --docker-password= \ - -n -``` - -**Step 2: Update the ConfigMap in `deploy/openshift.yaml`** - -Edit the `adk-web-config` ConfigMap with your OIDC provider's authority URL: - -```yaml -# For Keycloak: -"authority": "https://keycloak.example.com/realms/my-realm" -# For Okta: -"authority": "https://dev-12345.okta.com" -``` - -To disable auth entirely, set `"enabled": false` or remove the `auth` section. - -**Step 3: Deploy** - -```bash -oc apply -f deploy/openshift.yaml -n -``` - -**Step 4: Verify** - -```bash -oc get pods -l app=adk-web # Should be 1/1 Running -oc get route adk-web -o jsonpath='{.spec.host}' # Your URL -``` - -**Step 5: Access the UI** -- open the Route URL in your browser. +A container image can be built using the standard Angular build and any static file server (nginx, Caddy, etc.). Auth is configured at runtime via `runtime-config.json` mounted as a ConfigMap -- no image rebuild needed when switching OIDC providers or disabling auth. ### Keycloak Client Setup