();
+
+await builder.Build().RunAsync().ConfigureAwait(false);
diff --git a/src/StellaOps.Notify.Worker/Properties/AssemblyInfo.cs b/src/StellaOps.Notify.Worker/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..7a46cf70
--- /dev/null
+++ b/src/StellaOps.Notify.Worker/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("StellaOps.Notify.Worker.Tests")]
diff --git a/src/StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj b/src/StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj
index c444aa16..0ee4ae2d 100644
--- a/src/StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj
+++ b/src/StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj
@@ -1,8 +1,24 @@
-
-
- net10.0
- enable
- enable
- Exe
-
-
+
+
+ net10.0
+ enable
+ enable
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/src/StellaOps.Notify.Worker/TASKS.md b/src/StellaOps.Notify.Worker/TASKS.md
index b35be7f6..fbb40aff 100644
--- a/src/StellaOps.Notify.Worker/TASKS.md
+++ b/src/StellaOps.Notify.Worker/TASKS.md
@@ -2,7 +2,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
-| NOTIFY-WORKER-15-201 | TODO | Notify Worker Guild | NOTIFY-QUEUE-15-401 | Implement bus subscription + leasing loop with correlation IDs, backoff, dead-letter handling (§1–§5). | Worker consumes events from queue, ack/retry behaviour proven in integration tests; logs include correlation IDs. |
-| NOTIFY-WORKER-15-202 | TODO | Notify Worker Guild | NOTIFY-ENGINE-15-301 | Wire rules evaluation pipeline (tenant scoping, filters, throttles, digests, idempotency) with deterministic decisions. | Evaluation unit tests cover rule combinations; throttles/digests produce expected suppression; idempotency keys validated. |
-| NOTIFY-WORKER-15-203 | TODO | Notify Worker Guild | NOTIFY-ENGINE-15-302 | Channel dispatch orchestration: invoke connectors, manage retries/jitter, record delivery outcomes. | Connector mocks show retries/backoff; delivery results stored; metrics incremented per outcome. |
+| NOTIFY-WORKER-15-201 | DONE (2025-10-23) | Notify Worker Guild | NOTIFY-QUEUE-15-401 | Implement bus subscription + leasing loop with correlation IDs, backoff, dead-letter handling (§1–§5). | Worker consumes events from queue, ack/retry behaviour proven in integration tests; logs include correlation IDs. |
+| NOTIFY-WORKER-15-202 | TODO | Notify Worker Guild | NOTIFY-WORKER-15-201 | Wire rules evaluation pipeline (tenant scoping, filters, throttles, digests, idempotency) with deterministic decisions. | Evaluation unit tests cover rule combinations; throttles/digests produce expected suppression; idempotency keys validated. |
+| NOTIFY-WORKER-15-203 | TODO | Notify Worker Guild | NOTIFY-WORKER-15-202 | Channel dispatch orchestration: invoke connectors, manage retries/jitter, record delivery outcomes. | Connector mocks show retries/backoff; delivery results stored; metrics incremented per outcome. |
| NOTIFY-WORKER-15-204 | TODO | Notify Worker Guild | NOTIFY-WORKER-15-203 | Metrics/telemetry: `notify.sent_total`, `notify.dropped_total`, latency histograms, tracing integration. | Metrics emitted per spec; OTLP spans annotated; dashboards documented. |
diff --git a/src/StellaOps.Notify.Worker/appsettings.json b/src/StellaOps.Notify.Worker/appsettings.json
new file mode 100644
index 00000000..56b6cce6
--- /dev/null
+++ b/src/StellaOps.Notify.Worker/appsettings.json
@@ -0,0 +1,43 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "notify": {
+ "worker": {
+ "leaseBatchSize": 16,
+ "leaseDuration": "00:00:30",
+ "idleDelay": "00:00:00.250",
+ "maxConcurrency": 4,
+ "failureBackoffThreshold": 3,
+ "failureBackoffDelay": "00:00:05"
+ },
+ "queue": {
+ "transport": "Redis",
+ "redis": {
+ "connectionString": "localhost:6379",
+ "streams": [
+ {
+ "stream": "notify:events",
+ "consumerGroup": "notify-workers",
+ "idempotencyKeyPrefix": "notify:events:idemp:",
+ "approximateMaxLength": 100000
+ }
+ ]
+ }
+ },
+ "deliveryQueue": {
+ "transport": "Redis",
+ "redis": {
+ "connectionString": "localhost:6379",
+ "streamName": "notify:deliveries",
+ "consumerGroup": "notify-delivery",
+ "idempotencyKeyPrefix": "notify:deliveries:idemp:",
+ "deadLetterStreamName": "notify:deliveries:dead"
+ }
+ }
+ }
+}
diff --git a/src/StellaOps.UI/TASKS.md b/src/StellaOps.UI/TASKS.md
index f332bfec..9650b1a9 100644
--- a/src/StellaOps.UI/TASKS.md
+++ b/src/StellaOps.UI/TASKS.md
@@ -2,11 +2,11 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
-| UI-AUTH-13-001 | TODO | UI Guild | AUTH-DPOP-11-001, AUTH-MTLS-11-002 | Integrate Authority OIDC + DPoP flows with session management. | Login/logout flows pass e2e tests; tokens refreshed; DPoP nonce handling validated. |
+| UI-AUTH-13-001 | DONE (2025-10-23) | UI Guild | AUTH-DPOP-11-001, AUTH-MTLS-11-002 | Integrate Authority OIDC + DPoP flows with session management. | Login/logout flows pass e2e tests; tokens refreshed; DPoP nonce handling validated. |
| UI-SCANS-13-002 | TODO | UI Guild | SCANNER-WEB-09-102, SIGNER-API-11-101 | Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets. | Cypress tests cover SBOM/diff; performance budgets met; accessibility checks pass. |
| UI-VEX-13-003 | TODO | UI Guild | EXCITITOR-CORE-02-001, EXCITITOR-EXPORT-01-005 | Implement VEX explorer + policy editor with preview integration. | VEX views render consensus/conflicts; staged policy preview works; accessibility checks pass. |
| UI-ADMIN-13-004 | TODO | UI Guild | AUTH-MTLS-11-002 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. | Admin e2e tests pass; unauthorized access blocked; telemetry wired. |
-| UI-ATTEST-11-005 | TODO | UI Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Attestation visibility (Rekor id, status) on Scan Detail. | UI shows Rekor UUID/status; mock attestation fixtures displayed; tests cover success/failure. |
+| UI-ATTEST-11-005 | DONE (2025-10-23) | UI Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Attestation visibility (Rekor id, status) on Scan Detail. | UI shows Rekor UUID/status; mock attestation fixtures displayed; tests cover success/failure. |
| UI-SCHED-13-005 | TODO | UI Guild | SCHED-WEB-16-101 | Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks. | Panel functional with mocked endpoints; UX signoff; integration tests added. |
| UI-NOTIFY-13-006 | DOING (2025-10-19) | UI Guild | NOTIFY-WEB-15-101 | Notify panel: channels/rules CRUD, deliveries view, test send integration. | Panel interacts with mocked Notify API; tests cover rule lifecycle; docs updated. |
| UI-POLICY-13-007 | TODO | UI Guild | POLICY-CORE-09-006, SCANNER-WEB-09-103 | Surface policy confidence metadata (band, age, quiet provenance) on preview and report views. | UI renders new columns/tooltips, accessibility and responsive checks pass, Cypress regression updated with confidence fixtures. |
diff --git a/src/StellaOps.Web/README.md b/src/StellaOps.Web/README.md
index d47caeeb..f58e34bc 100644
--- a/src/StellaOps.Web/README.md
+++ b/src/StellaOps.Web/README.md
@@ -33,6 +33,26 @@ Run `ng build` to build the project. The build artifacts will be stored in the `
- `npm run test:watch` keeps Karma in watch mode for local development.
`verify:chromium` prints every location inspected (environment overrides, system paths, `.cache/chromium/`). Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` if you host the binary in a non-standard path.
+
+## Runtime configuration
+
+The SPA loads environment details from `/config.json` at startup. During development we ship a stub configuration under `src/config/config.json`; adjust the issuer, client ID, and API base URLs to match your Authority instance. To reset, copy `src/config/config.sample.json` back to `src/config/config.json`:
+
+```bash
+cp src/config/config.sample.json src/config/config.json
+```
+
+When packaging for another environment, replace the file before building so the generated bundle contains the correct defaults. Gateways that rewrite `/config.json` at request time can override these settings without rebuilding.
+
+## End-to-end tests
+
+Playwright drives the high-level auth UX using the stub configuration above. Ensure the Angular dev server can bind to `127.0.0.1:4400`, then run:
+
+```bash
+npm run test:e2e
+```
+
+The Playwright config auto-starts `npm run serve:test` and intercepts Authority redirects, so no live IdP is required. For CI/offline nodes, pre-install the required browsers via `npx playwright install --with-deps` and cache the results alongside your npm cache.
## Running end-to-end tests
diff --git a/src/StellaOps.Web/TASKS.md b/src/StellaOps.Web/TASKS.md
index bf519f49..2d0d2485 100644
--- a/src/StellaOps.Web/TASKS.md
+++ b/src/StellaOps.Web/TASKS.md
@@ -6,3 +6,4 @@
| WEB1.TRIVY-SETTINGS-TESTS | DONE (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS | **DONE (2025-10-21)** – Added headless Karma harness (`ng test --watch=false`) wired to ChromeHeadless/CI launcher, created `karma.conf.cjs`, updated npm scripts + docs with Chromium prerequisites so CI/offline runners can execute specs deterministically. | Angular CLI available (npm scripts chained), Karma suite for Trivy DB components passing locally and in CI, docs note required prerequisites. |
| WEB1.DEPS-13-001 | DONE (2025-10-21) | UX Specialist, Angular Eng, DevEx | WEB1.TRIVY-SETTINGS-TESTS | Stabilise Angular workspace dependencies for CI/offline nodes: refresh `package-lock.json`, ensure Puppeteer/Chromium binaries optional, document deterministic install workflow. | `npm install` completes without manual intervention on air-gapped nodes, `npm test` headless run succeeds from clean checkout, README updated with lockfile + cache steps. |
| WEB-POLICY-FIXTURES-10-001 | DONE (2025-10-23) | Angular Eng | SAMPLES-13-004 | Wire policy preview/report doc fixtures into UI harness (test utility or Storybook substitute) with type bindings and validation guard so UI stays aligned with documented payloads. | JSON fixtures importable within Angular workspace, typed helpers exported for reuse, Karma spec validates critical fields (confidence band, unknown metrics, DSSE summary). |
+| UI-AUTH-13-001 | DONE (2025-10-23) | UI Guild | AUTH-DPOP-11-001, AUTH-MTLS-11-002 | Integrate Authority OIDC + DPoP flows with session management (Angular SPA). | APP_INITIALIZER loads runtime config; login/logout flows drive Authority code flow; DPoP proofs generated/stored, nonce retries handled; unit specs cover proof binding + session persistence. |
diff --git a/src/StellaOps.Web/angular.json b/src/StellaOps.Web/angular.json
index a1c26707..91cbe8a1 100644
--- a/src/StellaOps.Web/angular.json
+++ b/src/StellaOps.Web/angular.json
@@ -25,10 +25,15 @@
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
- "assets": [
- "src/favicon.ico",
- "src/assets"
- ],
+ "assets": [
+ "src/favicon.ico",
+ "src/assets",
+ {
+ "glob": "config.json",
+ "input": "src/config",
+ "output": "."
+ }
+ ],
"styles": [
"src/styles.scss"
],
@@ -88,7 +93,12 @@
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
- "src/assets"
+ "src/assets",
+ {
+ "glob": "config.json",
+ "input": "src/config",
+ "output": "."
+ }
],
"styles": [
"src/styles.scss"
diff --git a/src/StellaOps.Web/package-lock.json b/src/StellaOps.Web/package-lock.json
index d617ec82..a6a4fab2 100644
--- a/src/StellaOps.Web/package-lock.json
+++ b/src/StellaOps.Web/package-lock.json
@@ -24,6 +24,7 @@
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
+ "@playwright/test": "^1.47.2",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
@@ -5074,6 +5075,21 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
+ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
+ "dev": true,
+ "dependencies": {
+ "playwright": "1.56.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.5",
"cpu": [
@@ -5313,9 +5329,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/node": {
- "dev": true
- },
"node_modules/@types/node-forge": {
"version": "1.3.14",
"dev": true,
@@ -8233,6 +8246,20 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
@@ -10928,6 +10955,36 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
+ "node_modules/playwright": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
+ "dev": true,
+ "dependencies": {
+ "playwright-core": "1.56.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
+ "dev": true,
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/postcss": {
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
diff --git a/src/StellaOps.Web/package.json b/src/StellaOps.Web/package.json
index 3f27afca..2bea2a78 100644
--- a/src/StellaOps.Web/package.json
+++ b/src/StellaOps.Web/package.json
@@ -9,6 +9,8 @@
"test": "npm run verify:chromium && ng test --watch=false",
"test:watch": "ng test --watch",
"test:ci": "npm run test",
+ "test:e2e": "playwright test",
+ "serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
"verify:chromium": "node ./scripts/verify-chromium.js",
"ci:install": "npm ci --prefer-offline --no-audit --no-fund"
},
@@ -33,7 +35,8 @@
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
- "@angular/compiler-cli": "^17.3.0",
+ "@angular/compiler-cli": "^17.3.0",
+ "@playwright/test": "^1.47.2",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
diff --git a/src/StellaOps.Web/playwright.config.ts b/src/StellaOps.Web/playwright.config.ts
new file mode 100644
index 00000000..0169231f
--- /dev/null
+++ b/src/StellaOps.Web/playwright.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from '@playwright/test';
+
+const port = process.env.PLAYWRIGHT_PORT
+ ? Number.parseInt(process.env.PLAYWRIGHT_PORT, 10)
+ : 4400;
+
+export default defineConfig({
+ testDir: 'tests/e2e',
+ timeout: 30_000,
+ retries: process.env.CI ? 1 : 0,
+ use: {
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`,
+ trace: 'retain-on-failure',
+ },
+ webServer: {
+ command: 'npm run serve:test',
+ reuseExistingServer: !process.env.CI,
+ url: `http://127.0.0.1:${port}`,
+ stdout: 'ignore',
+ stderr: 'ignore',
+ },
+});
diff --git a/src/StellaOps.Web/src/app/app.component.html b/src/StellaOps.Web/src/app/app.component.html
index 96b5f9d4..8e67e1dc 100644
--- a/src/StellaOps.Web/src/app/app.component.html
+++ b/src/StellaOps.Web/src/app/app.component.html
@@ -5,7 +5,19 @@
Trivy DB Export
+
+ Scan Detail
+
+
+
+ {{ displayName() }}
+
+
+
+
+
+
diff --git a/src/StellaOps.Web/src/app/app.component.scss b/src/StellaOps.Web/src/app/app.component.scss
index c964b5fd..044b7887 100644
--- a/src/StellaOps.Web/src/app/app.component.scss
+++ b/src/StellaOps.Web/src/app/app.component.scss
@@ -50,6 +50,36 @@
}
}
+.app-auth {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+
+ .app-user {
+ font-size: 0.9rem;
+ font-weight: 500;
+ }
+
+ button {
+ appearance: none;
+ border: none;
+ border-radius: 9999px;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ color: #0f172a;
+ background-color: rgba(248, 250, 252, 0.9);
+ transition: transform 0.2s ease, background-color 0.2s ease;
+
+ &:hover,
+ &:focus-visible {
+ background-color: #facc15;
+ transform: translateY(-1px);
+ }
+ }
+}
+
.app-content {
flex: 1;
padding: 2rem 1.5rem;
diff --git a/src/StellaOps.Web/src/app/app.component.spec.ts b/src/StellaOps.Web/src/app/app.component.spec.ts
index 2fa3536f..0f063363 100644
--- a/src/StellaOps.Web/src/app/app.component.spec.ts
+++ b/src/StellaOps.Web/src/app/app.component.spec.ts
@@ -1,11 +1,22 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
+import { AuthorityAuthService } from './core/auth/authority-auth.service';
+import { AuthSessionStore } from './core/auth/auth-session.store';
+
+class AuthorityAuthServiceStub {
+ beginLogin = jasmine.createSpy('beginLogin');
+ logout = jasmine.createSpy('logout');
+}
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent, RouterTestingModule],
+ providers: [
+ AuthSessionStore,
+ { provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
+ ],
}).compileComponents();
});
diff --git a/src/StellaOps.Web/src/app/app.component.ts b/src/StellaOps.Web/src/app/app.component.ts
index c2e71b02..a01ba51e 100644
--- a/src/StellaOps.Web/src/app/app.component.ts
+++ b/src/StellaOps.Web/src/app/app.component.ts
@@ -1,11 +1,51 @@
-import { Component } from '@angular/core';
-import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+} from '@angular/core';
+import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+
+import { AuthorityAuthService } from './core/auth/authority-auth.service';
+import { AuthSessionStore } from './core/auth/auth-session.store';
@Component({
selector: 'app-root',
standalone: true,
- imports: [RouterOutlet, RouterLink, RouterLinkActive],
+ imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.component.html',
- styleUrl: './app.component.scss'
+ styleUrl: './app.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class AppComponent {}
+export class AppComponent {
+ private readonly router = inject(Router);
+ private readonly auth = inject(AuthorityAuthService);
+ private readonly sessionStore = inject(AuthSessionStore);
+
+ readonly status = this.sessionStore.status;
+ readonly identity = this.sessionStore.identity;
+ readonly subjectHint = this.sessionStore.subjectHint;
+ readonly isAuthenticated = this.sessionStore.isAuthenticated;
+
+ readonly displayName = computed(() => {
+ const identity = this.identity();
+ if (identity?.name) {
+ return identity.name;
+ }
+ if (identity?.email) {
+ return identity.email;
+ }
+ const hint = this.subjectHint();
+ return hint ?? 'anonymous';
+ });
+
+ onSignIn(): void {
+ const returnUrl = this.router.url === '/' ? undefined : this.router.url;
+ void this.auth.beginLogin(returnUrl);
+ }
+
+ onSignOut(): void {
+ void this.auth.logout();
+ }
+}
diff --git a/src/StellaOps.Web/src/app/app.config.ts b/src/StellaOps.Web/src/app/app.config.ts
index 053df09d..495db8b9 100644
--- a/src/StellaOps.Web/src/app/app.config.ts
+++ b/src/StellaOps.Web/src/app/app.config.ts
@@ -1,14 +1,28 @@
-import { provideHttpClient } from '@angular/common/http';
-import { ApplicationConfig } from '@angular/core';
+import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
+import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
+import { AppConfigService } from './core/config/app-config.service';
+import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
- provideHttpClient(),
+ provideHttpClient(withInterceptorsFromDi()),
+ {
+ provide: APP_INITIALIZER,
+ multi: true,
+ useFactory: (configService: AppConfigService) => () =>
+ configService.load(),
+ deps: [AppConfigService],
+ },
+ {
+ provide: HTTP_INTERCEPTORS,
+ useClass: AuthHttpInterceptor,
+ multi: true,
+ },
{
provide: CONCELIER_EXPORTER_API_BASE_URL,
useValue: '/api/v1/concelier/exporters/trivy-db',
diff --git a/src/StellaOps.Web/src/app/app.routes.ts b/src/StellaOps.Web/src/app/app.routes.ts
index 6af3b45c..1bc2570c 100644
--- a/src/StellaOps.Web/src/app/app.routes.ts
+++ b/src/StellaOps.Web/src/app/app.routes.ts
@@ -8,6 +8,20 @@ export const routes: Routes = [
(m) => m.TrivyDbSettingsPageComponent
),
},
+ {
+ path: 'scans/:scanId',
+ loadComponent: () =>
+ import('./features/scans/scan-detail-page.component').then(
+ (m) => m.ScanDetailPageComponent
+ ),
+ },
+ {
+ path: 'auth/callback',
+ loadComponent: () =>
+ import('./features/auth/auth-callback.component').then(
+ (m) => m.AuthCallbackComponent
+ ),
+ },
{
path: '',
pathMatch: 'full',
diff --git a/src/StellaOps.Web/src/app/core/api/scanner.models.ts b/src/StellaOps.Web/src/app/core/api/scanner.models.ts
new file mode 100644
index 00000000..b905a4ff
--- /dev/null
+++ b/src/StellaOps.Web/src/app/core/api/scanner.models.ts
@@ -0,0 +1,17 @@
+export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
+
+export interface ScanAttestationStatus {
+ readonly uuid: string;
+ readonly status: ScanAttestationStatusKind;
+ readonly index?: number;
+ readonly logUrl?: string;
+ readonly checkedAt?: string;
+ readonly statusMessage?: string;
+}
+
+export interface ScanDetail {
+ readonly scanId: string;
+ readonly imageDigest: string;
+ readonly completedAt: string;
+ readonly attestation?: ScanAttestationStatus;
+}
diff --git a/src/StellaOps.Web/src/app/core/auth/auth-http.interceptor.ts b/src/StellaOps.Web/src/app/core/auth/auth-http.interceptor.ts
new file mode 100644
index 00000000..66ceecff
--- /dev/null
+++ b/src/StellaOps.Web/src/app/core/auth/auth-http.interceptor.ts
@@ -0,0 +1,171 @@
+import {
+ HttpErrorResponse,
+ HttpEvent,
+ HttpHandler,
+ HttpInterceptor,
+ HttpRequest,
+} from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { Observable, firstValueFrom, from, throwError } from 'rxjs';
+import { catchError, switchMap } from 'rxjs/operators';
+
+import { AppConfigService } from '../config/app-config.service';
+import { DpopService } from './dpop/dpop.service';
+import { AuthorityAuthService } from './authority-auth.service';
+
+const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
+
+@Injectable()
+export class AuthHttpInterceptor implements HttpInterceptor {
+ private excludedOrigins: Set | null = null;
+ private tokenEndpoint: string | null = null;
+ private authorityResolved = false;
+
+ constructor(
+ private readonly auth: AuthorityAuthService,
+ private readonly config: AppConfigService,
+ private readonly dpop: DpopService
+ ) {
+ // lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
+ }
+
+ intercept(
+ request: HttpRequest,
+ next: HttpHandler
+ ): Observable> {
+ this.ensureAuthorityInfo();
+
+ if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
+ return next.handle(request);
+ }
+
+ return from(
+ this.auth.getAuthHeadersForRequest(
+ this.resolveAbsoluteUrl(request.url),
+ request.method
+ )
+ ).pipe(
+ switchMap((headers) => {
+ if (!headers) {
+ return next.handle(request);
+ }
+ const authorizedRequest = request.clone({
+ setHeaders: {
+ Authorization: headers.authorization,
+ DPoP: headers.dpop,
+ },
+ headers: request.headers.set(RETRY_HEADER, '0'),
+ });
+ return next.handle(authorizedRequest);
+ }),
+ catchError((error: HttpErrorResponse) =>
+ this.handleError(request, error, next)
+ )
+ );
+ }
+
+ private handleError(
+ request: HttpRequest,
+ error: HttpErrorResponse,
+ next: HttpHandler
+ ): Observable> {
+ if (error.status !== 401) {
+ return throwError(() => error);
+ }
+
+ const nonce = error.headers?.get('DPoP-Nonce');
+ if (!nonce) {
+ return throwError(() => error);
+ }
+
+ if (request.headers.get(RETRY_HEADER) === '1') {
+ return throwError(() => error);
+ }
+
+ return from(this.retryWithNonce(request, nonce, next)).pipe(
+ catchError(() => throwError(() => error))
+ );
+ }
+
+ private async retryWithNonce(
+ request: HttpRequest,
+ nonce: string,
+ next: HttpHandler
+ ): Promise> {
+ await this.dpop.setNonce(nonce);
+ const headers = await this.auth.getAuthHeadersForRequest(
+ this.resolveAbsoluteUrl(request.url),
+ request.method
+ );
+ if (!headers) {
+ throw new Error('Unable to refresh authorization headers after nonce.');
+ }
+
+ const retried = request.clone({
+ setHeaders: {
+ Authorization: headers.authorization,
+ DPoP: headers.dpop,
+ },
+ headers: request.headers.set(RETRY_HEADER, '1'),
+ });
+
+ return firstValueFrom(next.handle(retried));
+ }
+
+ private shouldSkip(url: string): boolean {
+ this.ensureAuthorityInfo();
+ const absolute = this.resolveAbsoluteUrl(url);
+ if (!absolute) {
+ return false;
+ }
+
+ try {
+ const resolved = new URL(absolute);
+ if (resolved.pathname.endsWith('/config.json')) {
+ return true;
+ }
+ if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
+ return true;
+ }
+ const origin = resolved.origin;
+ return this.excludedOrigins?.has(origin) ?? false;
+ } catch {
+ return false;
+ }
+ }
+
+ private resolveAbsoluteUrl(url: string): string {
+ try {
+ if (url.startsWith('http://') || url.startsWith('https://')) {
+ return url;
+ }
+ const base =
+ typeof window !== 'undefined' && window.location
+ ? window.location.origin
+ : undefined;
+ return base ? new URL(url, base).toString() : url;
+ } catch {
+ return url;
+ }
+ }
+
+ private ensureAuthorityInfo(): void {
+ if (this.authorityResolved) {
+ return;
+ }
+ try {
+ const authority = this.config.authority;
+ this.tokenEndpoint = new URL(
+ authority.tokenEndpoint,
+ authority.issuer
+ ).toString();
+ this.excludedOrigins = new Set([
+ this.tokenEndpoint,
+ new URL(authority.authorizeEndpoint, authority.issuer).origin,
+ ]);
+ this.authorityResolved = true;
+ } catch {
+ // Configuration not yet loaded; interceptor will retry on the next request.
+ }
+ }
+}
diff --git a/src/StellaOps.Web/src/app/core/auth/auth-session.model.ts b/src/StellaOps.Web/src/app/core/auth/auth-session.model.ts
new file mode 100644
index 00000000..47363689
--- /dev/null
+++ b/src/StellaOps.Web/src/app/core/auth/auth-session.model.ts
@@ -0,0 +1,49 @@
+export interface AuthTokens {
+ readonly accessToken: string;
+ readonly expiresAtEpochMs: number;
+ readonly refreshToken?: string;
+ readonly tokenType: 'Bearer';
+ readonly scope: string;
+}
+
+export interface AuthIdentity {
+ readonly subject: string;
+ readonly name?: string;
+ readonly email?: string;
+ readonly roles: readonly string[];
+ readonly idToken?: string;
+}
+
+export interface AuthSession {
+ readonly tokens: AuthTokens;
+ readonly identity: AuthIdentity;
+ /**
+ * SHA-256 JWK thumbprint of the active DPoP key pair.
+ */
+ readonly dpopKeyThumbprint: string;
+ readonly issuedAtEpochMs: number;
+}
+
+export interface PersistedSessionMetadata {
+ readonly subject: string;
+ readonly expiresAtEpochMs: number;
+ readonly issuedAtEpochMs: number;
+ readonly dpopKeyThumbprint: string;
+}
+
+export type AuthStatus =
+ | 'unauthenticated'
+ | 'authenticated'
+ | 'refreshing'
+ | 'loading';
+
+export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
+
+export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
+
+export type AuthErrorReason =
+ | 'invalid_state'
+ | 'token_exchange_failed'
+ | 'refresh_failed'
+ | 'dpop_generation_failed'
+ | 'configuration_missing';
diff --git a/src/StellaOps.Web/src/app/core/auth/auth-session.store.spec.ts b/src/StellaOps.Web/src/app/core/auth/auth-session.store.spec.ts
new file mode 100644
index 00000000..2070db25
--- /dev/null
+++ b/src/StellaOps.Web/src/app/core/auth/auth-session.store.spec.ts
@@ -0,0 +1,48 @@
+import { TestBed } from '@angular/core/testing';
+
+import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
+import { AuthSessionStore } from './auth-session.store';
+
+describe('AuthSessionStore', () => {
+ let store: AuthSessionStore;
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ TestBed.configureTestingModule({
+ providers: [AuthSessionStore],
+ });
+ store = TestBed.inject(AuthSessionStore);
+ });
+
+ it('persists minimal metadata when session is set', () => {
+ const tokens: AuthTokens = {
+ accessToken: 'token-abc',
+ expiresAtEpochMs: Date.now() + 120_000,
+ refreshToken: 'refresh-xyz',
+ scope: 'openid ui.read',
+ tokenType: 'Bearer',
+ };
+
+ const session: AuthSession = {
+ tokens,
+ identity: {
+ subject: 'user-123',
+ name: 'Alex Operator',
+ roles: ['ui.read'],
+ },
+ dpopKeyThumbprint: 'thumbprint-1',
+ issuedAtEpochMs: Date.now(),
+ };
+
+ store.setSession(session);
+
+ const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
+ expect(persisted).toBeTruthy();
+ const parsed = JSON.parse(persisted ?? '{}');
+ expect(parsed.subject).toBe('user-123');
+ expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
+
+ store.clear();
+ expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
+ });
+});
diff --git a/src/StellaOps.Web/src/app/core/auth/auth-session.store.ts b/src/StellaOps.Web/src/app/core/auth/auth-session.store.ts
new file mode 100644
index 00000000..43fc7292
--- /dev/null
+++ b/src/StellaOps.Web/src/app/core/auth/auth-session.store.ts
@@ -0,0 +1,107 @@
+import { Injectable, computed, signal } from '@angular/core';
+
+import {
+ AuthSession,
+ AuthStatus,
+ PersistedSessionMetadata,
+ SESSION_STORAGE_KEY,
+} from './auth-session.model';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AuthSessionStore {
+ private readonly sessionSignal = signal(null);
+ private readonly statusSignal = signal('unauthenticated');
+ private readonly persistedSignal =
+ signal(this.readPersistedMetadata());
+
+ readonly session = computed(() => this.sessionSignal());
+ readonly status = computed(() => this.statusSignal());
+
+ readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
+ readonly subjectHint = computed(
+ () =>
+ this.sessionSignal()?.identity.subject ??
+ this.persistedSignal()?.subject ??
+ null
+ );
+
+ readonly expiresAtEpochMs = computed(
+ () => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
+ );
+
+ readonly isAuthenticated = computed(
+ () => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
+ );
+
+ setStatus(status: AuthStatus): void {
+ this.statusSignal.set(status);
+ }
+
+ setSession(session: AuthSession | null): void {
+ this.sessionSignal.set(session);
+ if (!session) {
+ this.statusSignal.set('unauthenticated');
+ this.persistedSignal.set(null);
+ this.clearPersistedMetadata();
+ return;
+ }
+
+ this.statusSignal.set('authenticated');
+ const metadata: PersistedSessionMetadata = {
+ subject: session.identity.subject,
+ expiresAtEpochMs: session.tokens.expiresAtEpochMs,
+ issuedAtEpochMs: session.issuedAtEpochMs,
+ dpopKeyThumbprint: session.dpopKeyThumbprint,
+ };
+ this.persistedSignal.set(metadata);
+ this.persistMetadata(metadata);
+ }
+
+ clear(): void {
+ this.sessionSignal.set(null);
+ this.statusSignal.set('unauthenticated');
+ this.persistedSignal.set(null);
+ this.clearPersistedMetadata();
+ }
+
+ private readPersistedMetadata(): PersistedSessionMetadata | null {
+ if (typeof sessionStorage === 'undefined') {
+ return null;
+ }
+
+ try {
+ const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+ const parsed = JSON.parse(raw) as PersistedSessionMetadata;
+ if (
+ typeof parsed.subject !== 'string' ||
+ typeof parsed.expiresAtEpochMs !== 'number' ||
+ typeof parsed.issuedAtEpochMs !== 'number' ||
+ typeof parsed.dpopKeyThumbprint !== 'string'
+ ) {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+ }
+
+ private persistMetadata(metadata: PersistedSessionMetadata): void {
+ if (typeof sessionStorage === 'undefined') {
+ return;
+ }
+ sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
+ }
+
+ private clearPersistedMetadata(): void {
+ if (typeof sessionStorage === 'undefined') {
+ return;
+ }
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
+ }
+}
diff --git a/src/StellaOps.Web/src/app/core/auth/auth-storage.service.ts b/src/StellaOps.Web/src/app/core/auth/auth-storage.service.ts
new file mode 100644
index 00000000..7017dc16
--- /dev/null
+++ b/src/StellaOps.Web/src/app/core/auth/auth-storage.service.ts
@@ -0,0 +1,45 @@
+import { Injectable } from '@angular/core';
+
+const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
+
+export interface PendingLoginRequest {
+ readonly state: string;
+ readonly codeVerifier: string;
+ readonly createdAtEpochMs: number;
+ readonly returnUrl?: string;
+ readonly nonce?: string;
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AuthStorageService {
+ savePendingLogin(request: PendingLoginRequest): void {
+ if (typeof sessionStorage === 'undefined') {
+ return;
+ }
+ sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
+ }
+
+ consumePendingLogin(expectedState: string): PendingLoginRequest | null {
+ if (typeof sessionStorage === 'undefined') {
+ return null;
+ }
+
+ const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
+ if (!raw) {
+ return null;
+ }
+
+ sessionStorage.removeItem(LOGIN_REQUEST_KEY);
+ try {
+ const request = JSON.parse(raw) as PendingLoginRequest;
+ if (request.state !== expectedState) {
+ return null;
+ }
+ return request;
+ } catch {
+ return null;
+ }
+ }
+}
diff --git a/src/StellaOps.Web/src/app/core/auth/authority-auth.service.ts b/src/StellaOps.Web/src/app/core/auth/authority-auth.service.ts
new file mode 100644
index 00000000..410a187a
--- /dev/null
+++ b/src/StellaOps.Web/src/app/core/auth/authority-auth.service.ts
@@ -0,0 +1,430 @@
+import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+
+import { AppConfigService } from '../config/app-config.service';
+import { AuthorityConfig } from '../config/app-config.model';
+import {
+ ACCESS_TOKEN_REFRESH_THRESHOLD_MS,
+ AuthErrorReason,
+ AuthSession,
+ AuthTokens,
+} from './auth-session.model';
+import { AuthSessionStore } from './auth-session.store';
+import {
+ AuthStorageService,
+ PendingLoginRequest,
+} from './auth-storage.service';
+import { DpopService } from './dpop/dpop.service';
+import { base64UrlDecode } from './dpop/jose-utilities';
+import { createPkcePair } from './pkce.util';
+
+interface TokenResponse {
+ readonly access_token: string;
+ readonly token_type: string;
+ readonly expires_in: number;
+ readonly scope?: string;
+ readonly refresh_token?: string;
+ readonly id_token?: string;
+}
+
+interface RefreshTokenResponse extends TokenResponse {}
+
+export interface AuthorizationHeaders {
+ readonly authorization: string;
+ readonly dpop: string;
+}
+
+export interface CompleteLoginResult {
+ readonly returnUrl?: string;
+}
+
+const TOKEN_CONTENT_TYPE = 'application/x-www-form-urlencoded';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AuthorityAuthService {
+ private refreshTimer: ReturnType | null = null;
+ private refreshInFlight: Promise