semi implemented and features implemented save checkpoint

This commit is contained in:
master
2026-02-08 18:00:49 +02:00
parent 04360dff63
commit 1bf6bbf395
20895 changed files with 716795 additions and 64 deletions

View File

@@ -15,6 +15,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
logout = jasmine.createSpy('logout');
trySilentRefresh = jasmine.createSpy('trySilentRefresh').and.returnValue(Promise.resolve(false));
}
describe('AppComponent', () => {

View File

@@ -73,6 +73,10 @@ export class AppComponent {
// Initialize branding on app start
this.brandingService.fetchBranding().subscribe();
// Attempt to silently restore the auth session if the user was
// previously logged in (session cookie may still be active at the Authority).
void this.auth.trySilentRefresh();
// Initialize legacy route telemetry tracking (ROUTE-002)
this.legacyRouteTelemetry.initialize();
}

View File

@@ -1,7 +1,8 @@
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter } from '@angular/router';
import { provideRouter, TitleStrategy } from '@angular/router';
import { PageTitleStrategy } from './core/navigation/page-title.strategy';
import { routes } from './app.routes';
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
@@ -145,6 +146,7 @@ export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimationsAsync(),
{ provide: TitleStrategy, useClass: PageTitleStrategy },
provideHttpClient(withInterceptorsFromDi()),
provideAppInitializer(() => {
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService) => async () => {

View File

@@ -26,6 +26,7 @@ export const routes: Routes = [
{
path: '',
pathMatch: 'full',
title: 'Control Plane',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/control-plane/control-plane.routes').then(
@@ -36,6 +37,7 @@ export const routes: Routes = [
// Approvals - promotion decision cockpit
{
path: 'approvals',
title: 'Approvals',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/approvals/approvals.routes').then(
@@ -46,6 +48,7 @@ export const routes: Routes = [
// Security - consolidated security analysis (SEC-005, SEC-006)
{
path: 'security',
title: 'Security Overview',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/security/security.routes').then(
@@ -56,6 +59,7 @@ export const routes: Routes = [
// Analytics - SBOM and attestation insights (SPRINT_20260120_031)
{
path: 'analytics',
title: 'Analytics',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAnalyticsViewerGuard],
loadChildren: () =>
import('./features/analytics/analytics.routes').then(
@@ -66,6 +70,7 @@ export const routes: Routes = [
// Policy - governance and exceptions (SEC-007)
{
path: 'policy',
title: 'Policy',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/policy/policy.routes').then(
@@ -76,6 +81,7 @@ export const routes: Routes = [
// Settings - consolidated configuration (SPRINT_20260118_002)
{
path: 'settings',
title: 'Settings',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/settings/settings.routes').then(
@@ -453,6 +459,13 @@ export const routes: Routes = [
(m) => m.AuthCallbackComponent
),
},
{
path: 'auth/silent-refresh',
loadComponent: () =>
import('./features/auth/silent-refresh.component').then(
(m) => m.SilentRefreshComponent
),
},
// Exceptions route
{
path: 'exceptions',

View File

@@ -55,8 +55,11 @@ interface AccessTokenMetadata {
providedIn: 'root',
})
export class AuthorityAuthService {
private static readonly SILENT_REFRESH_TIMEOUT_MS = 10_000;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private refreshInFlight: Promise<void> | null = null;
private silentRefreshInFlight: Promise<boolean> | null = null;
private lastError: AuthErrorReason | null = null;
constructor(
@@ -101,6 +104,120 @@ export class AuthorityAuthService {
window.location.assign(authorizeUrl);
}
/**
* Attempts to silently restore the session after a page reload by
* performing an OAuth2 authorize request with `prompt=none` in a
* hidden iframe. If the Authority still has an active session cookie
* the iframe will redirect back with a new authorization code.
*
* @returns `true` if session was restored, `false` otherwise.
*/
async trySilentRefresh(): Promise<boolean> {
// Already authenticated — nothing to do.
if (this.sessionStore.session()) {
return true;
}
// No persisted metadata — user was never logged in.
const metadata = this.sessionStore.subjectHint();
if (!metadata) {
return false;
}
// Deduplicate concurrent calls.
if (this.silentRefreshInFlight) {
return this.silentRefreshInFlight;
}
this.sessionStore.setStatus('loading');
this.silentRefreshInFlight = this.executeSilentRefresh()
.finally(() => {
this.silentRefreshInFlight = null;
});
return this.silentRefreshInFlight;
}
private async executeSilentRefresh(): Promise<boolean> {
const authority = this.config.authority;
const silentRedirectUri = this.resolveSilentRefreshRedirectUri(authority);
const pkce = await createPkcePair();
const state = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
const nonce = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
// Ensure DPoP key pair is available (persisted in IndexedDB).
await this.dpop.getThumbprint();
this.storage.savePendingLogin({
state,
codeVerifier: pkce.verifier,
createdAtEpochMs: Date.now(),
nonce,
});
const authorizeUrl = this.buildAuthorizeUrl(authority, {
state,
nonce,
codeChallenge: pkce.challenge,
codeChallengeMethod: pkce.method,
});
// Append prompt=none and override redirect_uri for the iframe.
const url = new URL(authorizeUrl);
url.searchParams.set('prompt', 'none');
url.searchParams.set('redirect_uri', new URL(silentRedirectUri, window.location.origin).toString());
return new Promise<boolean>((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.setAttribute('aria-hidden', 'true');
let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
window.removeEventListener('message', onMessage);
clearTimeout(timer);
iframe.remove();
};
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
if (!data || typeof data.type !== 'string') return;
if (data.type === 'silent-refresh-success') {
cleanup();
resolve(true);
} else if (data.type === 'silent-refresh-error') {
cleanup();
this.sessionStore.setStatus('unauthenticated');
resolve(false);
}
};
const timer = setTimeout(() => {
cleanup();
this.sessionStore.setStatus('unauthenticated');
resolve(false);
}, AuthorityAuthService.SILENT_REFRESH_TIMEOUT_MS);
window.addEventListener('message', onMessage);
document.body.appendChild(iframe);
iframe.src = url.toString();
});
}
private resolveSilentRefreshRedirectUri(authority: AuthorityConfig): string {
if (authority.silentRefreshRedirectUri) {
return authority.silentRefreshRedirectUri;
}
// Default: derive from redirectUri by replacing the last path segment.
const base = authority.redirectUri;
const lastSlash = base.lastIndexOf('/');
return (lastSlash >= 0 ? base.substring(0, lastSlash) : '') + '/silent-refresh';
}
/**
* Completes the authorization code flow after the Authority redirects back with ?code & ?state.
*/

View File

@@ -18,6 +18,7 @@ export interface AuthorityConfig {
readonly tokenEndpoint: string;
readonly logoutEndpoint?: string;
readonly redirectUri: string;
readonly silentRefreshRedirectUri?: string;
readonly postLogoutRedirectUri?: string;
readonly scope: string;
readonly audience: string;

View File

@@ -0,0 +1,64 @@
import { Injectable, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { BrandingService } from '../branding/branding.service';
const APP_NAME = 'Stella Ops';
/** Path segments that should be excluded from the generated title. */
const NOISE_SEGMENTS = new Set(['', 'admin', 'ops', 'console', 'api', 'v1']);
/**
* Custom TitleStrategy that builds page titles from route data.
*
* Resolution order:
* 1. Route `title` property (set explicitly in route config)
* 2. Auto-generated from the URL path segments
*
* All titles are prefixed with the branding title (default: "Stella Ops").
*/
@Injectable()
export class PageTitleStrategy extends TitleStrategy {
private readonly title = inject(Title);
private readonly branding = inject(BrandingService);
override updateTitle(snapshot: RouterStateSnapshot): void {
const routeTitle = this.buildTitle(snapshot);
const prefix = this.branding.getTitle() || APP_NAME;
if (routeTitle) {
this.title.setTitle(`${routeTitle} - ${prefix}`);
} else {
const generated = this.generateTitleFromUrl(snapshot.url);
if (generated) {
this.title.setTitle(`${generated} - ${prefix}`);
} else {
this.title.setTitle(prefix);
}
}
}
private generateTitleFromUrl(url: string): string | null {
const path = url.split('?')[0].split('#')[0];
const segments = path
.split('/')
.filter((s) => s && !NOISE_SEGMENTS.has(s))
// Skip dynamic segments (UUIDs, IDs)
.filter((s) => !/^[0-9a-f-]{8,}$/i.test(s) && !/^\d+$/.test(s));
if (segments.length === 0) {
return null;
}
// Take the last meaningful segment and humanize it
const raw = segments[segments.length - 1];
return this.humanize(raw);
}
private humanize(segment: string): string {
return segment
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
}

View File

@@ -0,0 +1,76 @@
/**
* Silent Refresh Component
*
* Loaded inside a hidden iframe during silent token renewal.
* Receives the authorization code from the Authority redirect,
* completes the token exchange, and posts the result back to
* the parent window via postMessage.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
/** Message posted to the parent window on success. */
export interface SilentRefreshSuccess {
readonly type: 'silent-refresh-success';
}
/** Message posted to the parent window on failure. */
export interface SilentRefreshError {
readonly type: 'silent-refresh-error';
readonly error: string;
}
export type SilentRefreshMessage = SilentRefreshSuccess | SilentRefreshError;
@Component({
selector: 'app-silent-refresh',
standalone: true,
template: '',
})
export class SilentRefreshComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly auth = inject(AuthorityAuthService);
async ngOnInit(): Promise<void> {
// If not in an iframe, do nothing (prevent accidental direct navigation).
if (window.parent === window) {
return;
}
const params = this.route.snapshot.queryParamMap;
// Authority may return an error (e.g. login_required, consent_required).
const error = params.get('error');
if (error) {
this.postToParent({
type: 'silent-refresh-error',
error,
});
return;
}
const searchParams = new URLSearchParams();
params.keys.forEach((key) => {
const value = params.get(key);
if (value != null) {
searchParams.set(key, value);
}
});
try {
await this.auth.completeLoginFromRedirect(searchParams);
this.postToParent({ type: 'silent-refresh-success' });
} catch (err) {
this.postToParent({
type: 'silent-refresh-error',
error: err instanceof Error ? err.message : 'unknown',
});
}
}
private postToParent(message: SilentRefreshMessage): void {
window.parent.postMessage(message, window.location.origin);
}
}

View File

@@ -6,6 +6,7 @@
"tokenEndpoint": "/authority/connect/token",
"logoutEndpoint": "/authority/connect/logout",
"redirectUri": "/auth/callback",
"silentRefreshRedirectUri": "/auth/silent-refresh",
"postLogoutRedirectUri": "/",
"scope": "openid profile email ui.read authority:tenants.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read vuln:view vuln:investigate vuln:operate vuln:audit",
"audience": "/scanner",