Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <JWT>
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://<your-adk-web-route>`
- **Valid redirect URIs**: `https://<your-adk-web-route>/*`
- **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
Expand Down
7 changes: 6 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@
"glob": "**/*",
"input": "public"
},
"src/assets"
"src/assets",
{
"glob": "silent-check-sso.html",
"input": "src",
"output": "/"
}
],
"styles": [
"src/styles.scss"
Expand Down
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
];

Expand Down
4 changes: 4 additions & 0 deletions src/app/components/chat/chat.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@
</div>
</div>
}
@if (authService.isEnabled) {
<app-auth-user-menu></app-auth-user-menu>
} @else {
<button
mat-icon-button
class="toolbar-icon-button user-avatar-button"
Expand Down Expand Up @@ -202,6 +205,7 @@
</div>
</div>
</mat-menu>
}
</mat-toolbar>

<mat-drawer-container class="drawer-container" autosize>
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ import { SidePanelComponent } from '../side-panel/side-panel.component';
import { ViewImageDialogComponent } from '../view-image-dialog/view-image-dialog.component';
import { InlineEditComponent } from '../inline-edit/inline-edit.component';
import { FormatMetricNamePipe } from '../eval-tab/format-metric-name.pipe';
import { AuthUserMenuComponent } from '../user-menu/user-menu.component';
import { AuthService } from '../../core/auth/auth.service';

import { ChatMessagesInjectionToken } from './chat.component.i18n';
import { SidePanelMessagesInjectionToken } from '../side-panel/side-panel.component.i18n';
Expand Down Expand Up @@ -174,6 +176,7 @@ const BIDI_STREAMING_RESTART_WARNING =
SessionTabComponent,
InlineEditComponent,
FormatMetricNamePipe,
AuthUserMenuComponent,
],
})
export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
Expand Down Expand Up @@ -207,6 +210,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly uiStateService = inject(UI_STATE_SERVICE);
protected readonly agentBuilderService = inject(AGENT_BUILDER_SERVICE);
protected readonly themeService = inject(THEME_SERVICE, {optional: true});
protected readonly authService = inject(AuthService);
protected readonly logoComponent: Type<Component> | null = inject(LOGO_COMPONENT, {
optional: true,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {MatIcon} from '@angular/material/icon';
MatButton,
MatIcon,
NgxJsonViewerModule,
MarkdownComponent,
],
})
export class LongRunningResponseComponent implements OnChanges {
Expand Down
120 changes: 120 additions & 0 deletions src/app/components/user-menu/user-menu.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AuthUserMenuComponent>;
let authServiceSpy: jasmine.SpyObj<AuthService>;

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();
});
});
});
Loading