diff --git a/README.md b/README.md index 5e500345..59f86bb4 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,142 @@ 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. + +### OpenShift / Kubernetes Deployment + +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 + +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/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..9f171c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "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", @@ -11458,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", @@ -13429,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 4244bf46..7b8adef5 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "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/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.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 new file mode 100644 index 00000000..aa4beefe --- /dev/null +++ b/src/app/core/auth/auth.config.ts @@ -0,0 +1,46 @@ +/** + * @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'; + +declare global { + interface Window { + __ADK_CONFIG__?: {auth?: AuthConfig}; + } +} + +/** + * 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 { + const adkConfig = window.__ADK_CONFIG__; + if (adkConfig?.auth?.enabled) { + return adkConfig.auth; + } + + 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 new file mode 100644 index 00000000..009ba9c0 --- /dev/null +++ b/src/app/core/auth/auth.guard.ts @@ -0,0 +1,44 @@ +/** + * @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 redirect. APP_INITIALIZER handles initial + * authentication; this guard is a safety net for late navigation. + */ +export const authGuard: CanActivateFn = () => { + const authService = inject(AuthService); + + if (!authService.isEnabled) { + return true; + } + + if (authService.isAuthenticated()) { + return true; + } + + 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 new file mode 100644 index 00000000..38d2564f --- /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 {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'; + +/** + * 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 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. + */ +export const authInterceptorFn: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn, +): Observable> => { + const authService = inject(AuthService); + + 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 new file mode 100644 index 00000000..8b367d2f --- /dev/null +++ b/src/app/core/auth/auth.service.ts @@ -0,0 +1,172 @@ +/** + * @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 {UserManager, UserManagerSettings, User} from 'oidc-client-ts'; + +import {AuthConfig} from '../models/RuntimeConfig'; +import {resolveAuthConfig} from './auth.config'; + +export interface UserInfo { + name: string; + email: string; + userId: string; + 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 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 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; + } + + 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; + + if (!this.authConfig?.enabled) { + this.initialized = true; + return; + } + + 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 { + 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 (!this.currentUser) { + this.currentUser = await this.userManager.getUser(); + } + + 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; + } + } + + isAuthenticated(): boolean { + if (!this.isEnabled) return true; + return this.currentUser != null && !this.currentUser.expired; + } + + async getToken(): Promise { + if (!this.isEnabled) return ''; + + if (this.currentUser && !this.currentUser.expired) { + return this.currentUser.access_token; + } + + try { + const user = await this.userManager!.signinSilent(); + this.currentUser = user; + return user!.access_token; + } catch { + this.userManager?.signinRedirect(); + throw new Error('Token refresh failed'); + } + } + + getUserInfo(): UserInfo | null { + if (!this.isEnabled || !this.currentUser) return null; + + const profile = this.currentUser.profile as OidcUserProfile; + return { + 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.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 new file mode 100644 index 00000000..782375b3 --- /dev/null +++ b/src/app/core/auth/index.ts @@ -0,0 +1,22 @@ +/** + * @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 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 6fd989a1..24c28db8 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,26 @@ export declare interface LogoConfig { text: string; imageUrl: string; } + +/** + * OIDC authentication configuration for enterprise deployments. + * 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 + * JWT tokens on inbound API requests. + */ +export declare interface AuthConfig { + enabled: boolean; + 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 489a89d7..a893a9e9 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,31 @@ 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 (this.authService.isEnabled && !token) { + throw new Error('Auth is enabled but no token is available'); + } + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; + }; + buildHeaders().then((headers) => fetch(url, { + method: 'POST', + 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/websocket.service.ts b/src/app/core/services/websocket.service.ts index 4aa984e3..f2fe5f64 100644 --- a/src/app/core/services/websocket.service.ts +++ b/src/app/core/services/websocket.service.ts @@ -25,6 +25,13 @@ import {Event} from '../models/types'; import {AUDIO_PLAYING_SERVICE} from './interfaces/audio-playing'; import {WebSocketService as WebSocketServiceInterface} from './interfaces/websocket'; +// 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', }) @@ -125,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 e67e0c73..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 {HttpClientModule} from '@angular/common/http'; -import {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'; @@ -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 {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'; @@ -88,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}, @@ -127,7 +130,21 @@ 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}, + 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 new file mode 100644 index 00000000..0f07f053 --- /dev/null +++ b/src/silent-check-sso.html @@ -0,0 +1,21 @@ + + + + + + +