diff --git a/README.md b/README.md index 265729d7..97bc3a71 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,12 @@ All decisions are cached in Redis for sub-millisecond latency; all configuration ## Key Features ### 🔐 Authentication & Authorization at the Gate +- **Dual identity provider support**: Switch between Entra ID (Azure AD) and Keycloak via a single `AuthProvider` config toggle - Entra ID JWT bearer tokens with multi-tenant support +- Keycloak OIDC with client_credentials flow for APIM service-to-service calls - Pre-check endpoint validates client, plan, deployment access, and quotas BEFORE backend receives the request - No subscription keys — pure identity-driven access control +- Runtime auth config endpoint (`/api/auth-config`) — one container image works across all environments ### 🚀 Intelligent Model Routing (Auto-Router) - Automatically routes requests to optimal deployments based on plan routing policies @@ -238,6 +241,10 @@ The React SPA provides five pages: ## Authentication +The AI Policy Engine supports two identity providers, selected at runtime via the `AuthProvider` configuration value (`AzureAd` or `Keycloak`). The same container image works for both — no rebuild required. + +### Entra ID (Azure AD) — Default + Authentication uses **Entra ID (Azure AD) App Registrations** with JWT bearer tokens and a **two-app model**: | App Registration | Audience | Purpose | @@ -253,7 +260,39 @@ The APIM policy (`policies/entra-jwt-policy.xml`) validates incoming tokens agai | `aud` | Audience — validates the token is intended for this API | | `azp` / `appid` | Authorized Party — identifies the calling client application (`azp` for delegated tokens, `appid` for client_credentials) | -> **Subscription keys are disabled.** All authentication uses standard `Authorization: Bearer ` headers validated by Entra ID. +> **Subscription keys are disabled.** All authentication uses standard `Authorization: Bearer ` headers validated by the configured identity provider. + +### Keycloak OIDC — Alternative + +To use Keycloak instead of Entra ID, set `AuthProvider=Keycloak` in the configmap/appsettings and configure the Keycloak section: + +```yaml +# Kubernetes configmap +AuthProvider: "Keycloak" +Keycloak__Authority: "https://keycloak.example.com/realms/myrealm" +Keycloak__Audience: "ai-policy-engine" +Keycloak__ClientId: "ai-policy-engine-api" # Confidential client (backend) +Keycloak__FrontendClientId: "ai-policy-engine-ui" # Public client (SPA) +Keycloak__Realm: "myrealm" +Keycloak__FrontendUrl: "https://keycloak.example.com" +Keycloak__RequireHttpsMetadata: "true" +``` + +**Required Keycloak roles** (create as realm roles or client roles): + +| Role | Purpose | +|------|---------| +| `AIPolicy.Admin` | Create/edit/delete plans, clients, pricing, routing policies | +| `AIPolicy.Apim` | APIM service-to-service calls (precheck, log ingest) | +| `AIPolicy.Export` | Access to data export endpoints | + +Ensure roles are included in the `roles` claim of the access token via a Keycloak protocol mapper. + +**APIM integration**: Use `policies/keycloak-jwt-policy.xml` instead of `entra-jwt-policy.xml`. Configure these APIM named values: +- `KeycloakOpenIdConfigUrl` — OIDC discovery URL +- `KeycloakTokenEndpoint` — Token endpoint for client_credentials +- `AIPolicyEngineApiBaseUrl` — Backend API URL +- `AIPolicyEngineApimClientId` / `AIPolicyEngineApimClientSecret` — APIM service account credentials ### Multi-Tenant Customer Model diff --git a/infra/terraform/modules/compute/main.tf b/infra/terraform/modules/compute/main.tf index b4804150..63412724 100644 --- a/infra/terraform/modules/compute/main.tf +++ b/infra/terraform/modules/compute/main.tf @@ -41,11 +41,17 @@ resource "azurerm_storage_account" "this" { min_tls_version = "TLS1_2" shared_access_key_enabled = false default_to_oauth_authentication = true + public_network_access_enabled = false tags = var.tags identity { type = "SystemAssigned" } + + network_rules { + default_action = "Deny" + bypass = ["AzureServices"] + } } # ============================================================================= diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 00000000..d347fa97 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: aipolicyengine-config + namespace: apim +data: + + # ASPNET Core settings + ASPNETCORE_ENVIRONMENT: "Production" + ASPNETCORE_URLS: "http://+:8080" + AllowedHosts: "*" + + # Entra ID JWT authentication + AzureAd__Instance: "https://login.microsoftonline.com/" + AzureAd__TenantId: "" + AzureAd__ClientId: "" + AzureAd__Audience: "" + + # Authentication provider toggle: "AzureAd" or "Keycloak" + AuthProvider: "AzureAd" + + # Keycloak OIDC authentication (used when AuthProvider=Keycloak) + Keycloak__Authority: "" + Keycloak__Audience: "" + Keycloak__ClientId: "" + Keycloak__RequireHttpsMetadata: "true" + Keycloak__FrontendUrl: "" + Keycloak__Realm: "" + Keycloak__FrontendClientId: "" + + # Usage policy defaults + UsagePolicy__BillingCycleStartDay: "1" + UsagePolicy__AggregatedLogRetentionDays: "30" + UsagePolicy__TraceRetentionDays: "30" + + # Purview (DLP) — non-sensitive settings + PURVIEW_TENANT_ID: "" + PURVIEW_APP_NAME: "AI Policy Engine API" + PURVIEW_APP_LOCATION: "" # optional URI + PURVIEW_IGNORE_EXCEPTIONS: "false" + PURVIEW_BACKGROUND_JOB_LIMIT: "100" + PURVIEW_MAX_CONCURRENT_CONSUMERS: "10" + PURVIEW_BLOCK_ENABLED: "false" # set true to enforce DLP blocking + + # OpenTelemetry (optional — omit if not using a collector) + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317" \ No newline at end of file diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 00000000..9dfde10d --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ai-policy-engine + namespace: apim + labels: + app: ai-policy-engine +spec: + replicas: 2 + selector: + matchLabels: + app: ai-policy-engine + template: + metadata: + labels: + app: ai-policy-engine + spec: + # Optional: pin to specific nodes if needed + # nodeSelector: + # kubernetes.io/os: linux + + containers: + - name: ai-policy-engine + image: your-registry/ai-policy-engine:latest # <-- replace with your registry + imagePullPolicy: Always + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + ports: + - containerPort: 8080 + protocol: TCP + + # Load non-sensitive config from ConfigMap + envFrom: + - configMapRef: + name: aipolicyengine-config + - secretRef: + name: aipolicyengine-secrets + + # Health probes — Aspire ServiceDefaults exposes /health and /alive + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 20 + failureThreshold: 3 + + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 3 + + resources: + requests: + cpu: "250m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "512Mi" + + # If your registry requires pull credentials: + # imagePullSecrets: + # - name: registry-credentials diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 00000000..935a730f --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: apim diff --git a/k8s/secret.yaml b/k8s/secret.yaml new file mode 100644 index 00000000..4a88d7d7 --- /dev/null +++ b/k8s/secret.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Secret +metadata: + name: aipolicyengine-secrets + namespace: apim +type: Opaque +stringData: + # Redis — StackExchange.Redis connection string format + # e.g. "myredis.redis.cache.windows.net:6380,password=,ssl=True,abortConnect=False" + ConnectionStrings__redis: "" + + # Cosmos DB — account endpoint URI (used with DefaultAzureCredential/workload identity) + # or full connection string: "AccountEndpoint=https://...;AccountKey=..." + ConnectionStrings__aipolicy: "https://.documents.azure.com:443/" + + # Application Insights (omit if not using) + APPLICATIONINSIGHTS_CONNECTION_STRING: "InstrumentationKey=...;IngestionEndpoint=..." + + # Purview DLP client app ID — triggers full Purview integration when set + # Leave empty / omit to use the no-op implementation + PURVIEW_CLIENT_APP_ID: "" \ No newline at end of file diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 00000000..a3c1a2c0 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: ai-policy-engine + namespace: apim + labels: + app: ai-policy-engine +spec: + selector: + app: ai-policy-engine + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 8080 + # ClusterIP makes the service reachable only within the cluster. + # Your existing APIM should reach this via the cluster's internal DNS: + # http://ai-policy-engine.apim.svc.cluster.local + # or via an Ingress / NodePort if APIM is external to the cluster. + type: ClusterIP diff --git a/k8s/virtualservice.yaml b/k8s/virtualservice.yaml new file mode 100644 index 00000000..3b920939 --- /dev/null +++ b/k8s/virtualservice.yaml @@ -0,0 +1,112 @@ +# Istio Gateway + VirtualService for the Chargeback API. +# Replaces ingress.yaml when using the Istio ingress gateway. +# +# Prerequisites: +# - Istio installed in the cluster +# - An Istio IngressGateway running (default: istio-system namespace) +# - A DNS record pointing your hostname at the IngressGateway LoadBalancer IP +# +# Apply with: +# kubectl apply -f k8s/virtualservice.yaml +--- +apiVersion: networking.istio.io/v1beta1 +kind: Gateway +metadata: + name: aipolicyengine-gateway + namespace: apim +spec: + selector: + # Targets the default Istio ingress gateway pod. + # Change if you use a dedicated ingress gateway (e.g. istio: ingressgateway-internal). + istio: ingressgateway + servers: + - port: + number: 80 + name: http + protocol: HTTP + hosts: + - ai-policy-engine.internal.example.com + # Optional: redirect all HTTP to HTTPS by uncommenting below + # tls: + # httpsRedirect: true + + # Uncomment for TLS termination at the ingress gateway. + # Requires a TLS credential (kubectl create secret tls chargeback-tls -n istio-system ...). + # - port: + # number: 443 + # name: https + # protocol: HTTPS + # hosts: + # - ai-policy-engine.internal.example.com + # tls: + # mode: SIMPLE + # credentialName: ai-policy-engine-tls # Secret must be in istio-system namespace +--- +apiVersion: networking.istio.io/v1beta1 +kind: VirtualService +metadata: + name: aipolicyengine-api + namespace: apim +spec: + hosts: + - ai-policy-engine.internal.example.com + gateways: + - aipolicyengine-gateway + # Also allow mesh-internal traffic (e.g. from APIM running inside the cluster) + - mesh + http: + # Health probe paths — bypass any auth/retry so k8s probes always succeed + - match: + - uri: + prefix: /health + - uri: + prefix: /alive + route: + - destination: + host: aipolicyengine-api + port: + number: 80 + retries: + attempts: 0 + + # API routes + - match: + - uri: + prefix: /api + - uri: + prefix: /chargeback + - uri: + prefix: /openapi + - uri: + exact: /config + route: + - destination: + host: aipolicyengine-api + port: + number: 80 + - match: + - uri: + prefix: /ws + headers: + upgrade: + exact: websocket + timeout: 30s + retries: + attempts: 2 + perTryTimeout: 10s + retryOn: gateway-error,connect-failure,retriable-4xx + route: + - destination: + host: aipolicyengine-api + port: + number: 80 + + # React SPA + static assets (catch-all) + - match: + - uri: + prefix: / + route: + - destination: + host: aipolicyengine-api + port: + number: 80 diff --git a/policies/keycloak-jwt-policy.md b/policies/keycloak-jwt-policy.md new file mode 100644 index 00000000..fec3b8ec --- /dev/null +++ b/policies/keycloak-jwt-policy.md @@ -0,0 +1,65 @@ +# APIM Policy Analysis: `keycloak-jwt-policy.xml` + +This is an **Azure API Management (APIM) policy** that validates Keycloak-issued JWT tokens instead of Azure AD tokens. It is functionally equivalent to `entra-jwt-policy.xml` but uses Keycloak's OpenID Connect endpoints and client_credentials flow for backend authentication. + +--- + +## Prerequisites + +### APIM Named Values + +| Named Value | Description | +|-------------|-------------| +| `KeycloakOpenIdConfigUrl` | Keycloak OIDC discovery URL (e.g., `https:///realms//.well-known/openid-configuration`) | +| `KeycloakTokenEndpoint` | Keycloak token endpoint (e.g., `https:///realms//protocol/openid-connect/token`) | +| `ExpectedAudience` | The expected `aud` claim in the JWT token | +| `AIPolicyEngineApiBaseUrl` | Base URL of the AI Policy Engine API | +| `AIPolicyEngineApimClientId` | Keycloak client ID for APIM's service account | +| `AIPolicyEngineApimClientSecret` | Keycloak client secret for APIM's service account (store as Secret named value) | + +--- + +## Key Differences from `entra-jwt-policy.xml` + +### 1. JWT Validation + +Uses Keycloak's OIDC discovery endpoint instead of Azure AD's `/common/.well-known/openid-configuration`. + +### 2. Claim Extraction + +| Variable | Keycloak Claim | Notes | +|----------|---------------|-------| +| `tenantId` | `tenant_id` (custom) or extracted from issuer URL | Keycloak doesn't have a native `tid` claim; uses custom mapper or parses realm from issuer | +| `clientAppId` | `azp` or `client_id` | `azp` for authorization code flow, `client_id` for client_credentials | +| `audience` | `aud` | Same as Entra | + +### 3. Backend Authentication + +Instead of Azure Managed Identity, this policy acquires a token from Keycloak using the **client_credentials** grant: + +```xml + + {{KeycloakTokenEndpoint}} + POST + grant_type=client_credentials&client_id=...&client_secret=... + +``` + +The resulting access token is used for both the pre-check call and the fire-and-forget log call. + +### 4. Named Value References + +- `{{ContainerAppUrl}}` → `{{AIPolicyEngineApiBaseUrl}}` +- `{{ContainerAppAudience}}` → removed (token acquired via client_credentials) +- Managed identity → Keycloak client_credentials token + +--- + +## Keycloak Setup Requirements + +1. **Create a client** for the AI Policy Engine API (the resource server / audience) +2. **Create a client** for APIM with: + - `Service accounts roles` enabled (for client_credentials) + - Appropriate roles/scopes to access the API +3. **Add a custom protocol mapper** (optional) to include `tenant_id` in tokens if multi-tenancy is needed +4. **Configure audience mapper** on the API client to ensure `aud` claim is present in tokens diff --git a/policies/keycloak-jwt-policy.xml b/policies/keycloak-jwt-policy.xml new file mode 100644 index 00000000..cd7bb18b --- /dev/null +++ b/policies/keycloak-jwt-policy.xml @@ -0,0 +1,274 @@ + + + + + + + + + + {{ExpectedAudience}} + + + + + 0 ? parts[parts.Length - 1] : ""; + } + } + return tid; + }" /> + + + + + + = 0) { + var rest = path.Substring(idx + marker.Length); + var slash = rest.IndexOf('/'); + if (slash >= 0) { return rest.Substring(0, slash); } + return rest; + } + try { + var body = context.Variables.GetValueOrDefault("requestBody"); + if (!string.IsNullOrEmpty(body)) { + var json = Newtonsoft.Json.Linq.JObject.Parse(body); + var model = json["model"]?.ToString(); + if (!string.IsNullOrEmpty(model)) { return model; } + } + } catch { } + var segments = path.Split('/'); + if (segments.Length > 1) { return segments[segments.Length - 1]; } + return "unknown"; + }" /> + + + {{KeycloakTokenEndpoint}} + POST + + application/x-www-form-urlencoded + + @{ + return "grant_type=client_credentials&client_id={{AIPolicyEngineApimClientId}}&client_secret={{AIPolicyEngineApimClientSecret}}&audience={{ExpectedAudience}}"; + } + + (); + return (string)response["access_token"]; + }" /> + + + + @((string)context.Variables["containerAppBaseUrl"] + "/api/precheck/" + (string)context.Variables["clientAppId"] + "/" + (string)context.Variables["tenantId"] + "?deploymentId=" + (string)context.Variables["deploymentId"]) + GET + + @("Bearer " + (string)context.Variables["backend-access-token"]) + + + + + + + application/json + @{ + var resp = context.Variables["precheckResponse"] as IResponse; + return resp != null ? resp.Body.As() : "{\"error\":\"Pre-check failed — could not reach authorization service\"}"; + } + + + + + + application/json + @(((IResponse)context.Variables["precheckResponse"]).Body.As()) + + + + + + application/json + @(((IResponse)context.Variables["precheckResponse"]).Body.As()) + + + + + + application/json + {"error":"Pre-authorization check failed"} + + + + + (preserveContent: true)["routedDeployment"]?.ToString())" /> + (preserveContent: true)["routingPolicyId"]?.ToString())" /> + + + + + + + + + + + + + @{ + var rawBody = context.Variables.GetValueOrDefault("requestBody"); + var requestBody = Newtonsoft.Json.Linq.JObject.Parse(rawBody); + if (requestBody["stream"] != null && (bool)requestBody["stream"] == true) { + requestBody["stream_options"] = Newtonsoft.Json.Linq.JObject.Parse(@"{""include_usage"":true}"); + } + return requestBody.ToString(); + } + + + + + + + + + + l.Trim().StartsWith("data:") && l.Contains("\"usage\"") && !l.Contains("[DONE]")) + .LastOrDefault(); + if (chunkLine != null) { + int index = chunkLine.IndexOf('{'); + string jsonPart = chunkLine.Substring(index); + return Newtonsoft.Json.Linq.JObject.Parse(jsonPart); + } + return null; + } else { + return Newtonsoft.Json.Linq.JObject.Parse(txt); + } + }" /> + + + + {{AIPolicyEngineApiBaseUrl}}/api/log + POST + + application/json + + + @("Bearer " + (string)context.Variables["backend-access-token"]) + + @{ + var requestBodyRaw = context.Variables.GetValueOrDefault("requestBody"); + var parsedResponseString = context.Variables.GetValueOrDefault("parsedResponseString"); + var deploymentId = context.Variables.GetValueOrDefault("deploymentId"); + var requestedDeploymentId = context.Variables.ContainsKey("originalDeploymentId") + ? context.Variables.GetValueOrDefault("originalDeploymentId") + : deploymentId; + object requestBodyValue = null; + if (!string.IsNullOrEmpty(requestBodyRaw)) { + try { + requestBodyValue = Newtonsoft.Json.Linq.JToken.Parse(requestBodyRaw); + } catch { + requestBodyValue = requestBodyRaw; + } + } + object responseBodyValue = null; + if (!string.IsNullOrEmpty(parsedResponseString)) { + try { + responseBodyValue = Newtonsoft.Json.Linq.JToken.Parse(parsedResponseString); + } catch { + responseBodyValue = parsedResponseString; + } + } + var payload = new Newtonsoft.Json.Linq.JObject(); + payload.Add(new Newtonsoft.Json.Linq.JProperty("tenantId", (string)context.Variables.GetValueOrDefault("tenantId", ""))); + payload.Add(new Newtonsoft.Json.Linq.JProperty("clientAppId", (string)context.Variables.GetValueOrDefault("clientAppId", ""))); + payload.Add(new Newtonsoft.Json.Linq.JProperty("audience", (string)context.Variables.GetValueOrDefault("audience", ""))); + payload.Add(new Newtonsoft.Json.Linq.JProperty("requestBody", requestBodyValue)); + payload.Add(new Newtonsoft.Json.Linq.JProperty("responseBody", responseBodyValue)); + payload.Add(new Newtonsoft.Json.Linq.JProperty("deploymentId", deploymentId ?? "")); + payload.Add(new Newtonsoft.Json.Linq.JProperty("requestedDeploymentId", requestedDeploymentId ?? "")); + payload.Add(new Newtonsoft.Json.Linq.JProperty("routedDeployment", context.Variables.GetValueOrDefault("routedDeployment") ?? "")); + payload.Add(new Newtonsoft.Json.Linq.JProperty("routingPolicyId", context.Variables.GetValueOrDefault("routingPolicyId") ?? "")); + payload.Add(new Newtonsoft.Json.Linq.JProperty("correlationId", context.RequestId.ToString())); + return payload.ToString(); + } + + + + + + @(context.LastError.Source) + + + @(context.LastError.Reason) + + + @(context.LastError.Message) + + + @(context.LastError.Scope) + + + @(context.LastError.Section) + + + @(context.LastError.Path) + + + @(context.LastError.PolicyId) + + + @(context.Response.StatusCode.ToString()) + + + diff --git a/src/AIPolicyEngine.Api/Endpoints/AuthConfigEndpoints.cs b/src/AIPolicyEngine.Api/Endpoints/AuthConfigEndpoints.cs new file mode 100644 index 00000000..fc377770 --- /dev/null +++ b/src/AIPolicyEngine.Api/Endpoints/AuthConfigEndpoints.cs @@ -0,0 +1,52 @@ +namespace AIPolicyEngine.Api.Endpoints; + +/// +/// Exposes runtime authentication configuration so the SPA can discover +/// which identity provider to use without baking values into the JS bundle. +/// +public static class AuthConfigEndpoints +{ + public static IEndpointRouteBuilder MapAuthConfigEndpoints(this IEndpointRouteBuilder routes) + { + routes.MapGet("/api/auth-config", GetAuthConfig) + .WithName("GetAuthConfig") + .WithDescription("Returns runtime auth provider configuration for the SPA") + .AllowAnonymous() + .Produces(); + + return routes; + } + + private static IResult GetAuthConfig(IConfiguration config) + { + var provider = config.GetValue("AuthProvider") ?? "AzureAd"; + + if (provider.Equals("Keycloak", StringComparison.OrdinalIgnoreCase)) + { + var kc = config.GetSection("Keycloak"); + return Results.Ok(new + { + authProvider = "Keycloak", + authority = kc["Authority"] ?? "", + clientId = kc["FrontendClientId"] ?? kc["ClientId"] ?? "", + audience = kc["Audience"] ?? "", + realm = kc["Realm"] ?? "", + frontendUrl = kc["FrontendUrl"] ?? "", + }); + } + + return Results.Ok(new + { + authProvider = "AzureAd", + clientId = config["AzureAd:ClientId"] ?? "", + tenantId = config["AzureAd:TenantId"] ?? "", + authority = config["AzureAd:Instance"] is string inst && config["AzureAd:TenantId"] is string tid + ? $"{inst.TrimEnd('/')}/{tid}" + : "", + audience = config["AzureAd:Audience"] ?? "", + scope = config["AzureAd:Audience"] is string aud && !string.IsNullOrEmpty(aud) + ? $"api://{aud}/access_as_user" + : "", + }); + } +} diff --git a/src/AIPolicyEngine.Api/Program.cs b/src/AIPolicyEngine.Api/Program.cs index 23b61f38..5257421f 100644 --- a/src/AIPolicyEngine.Api/Program.cs +++ b/src/AIPolicyEngine.Api/Program.cs @@ -1,10 +1,13 @@ using System.Text.Json.Serialization; +using System.Security.Claims; using System.Threading.Channels; using Azure.Identity; using AIPolicyEngine.Api.Endpoints; using AIPolicyEngine.Api.Models; using AIPolicyEngine.Api.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Identity.Web; +using Microsoft.IdentityModel.Tokens; using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); @@ -123,9 +126,90 @@ // Purview integration for DLP policy validation and audit emission (Agent 365) builder.Services.AddPurviewServices(builder.Configuration); -// Entra ID JWT Bearer authentication -builder.Services.AddAuthentication() - .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); +// Authentication: supports AzureAd or Keycloak based on AuthProvider config +var authProvider = builder.Configuration.GetValue("AuthProvider") ?? "AzureAd"; + +if (authProvider.Equals("Keycloak", StringComparison.OrdinalIgnoreCase)) +{ + var keycloakSection = builder.Configuration.GetSection("Keycloak"); + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = keycloakSection["Authority"]; + options.Audience = keycloakSection["Audience"]; + options.RequireHttpsMetadata = keycloakSection.GetValue("RequireHttpsMetadata", true); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + // Keycloak uses "preferred_username" instead of "name" + NameClaimType = "preferred_username", + RoleClaimType = "roles" + }; + // Extract roles from Keycloak's nested realm_access/resource_access claims + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + if (context.Principal?.Identity is ClaimsIdentity identity) + { + // Extract realm roles from realm_access.roles + var realmAccess = context.Principal.FindFirst("realm_access"); + if (realmAccess != null) + { + try + { + var parsed = System.Text.Json.JsonDocument.Parse(realmAccess.Value); + if (parsed.RootElement.TryGetProperty("roles", out var roles)) + { + foreach (var role in roles.EnumerateArray()) + { + var roleValue = role.GetString(); + if (!string.IsNullOrEmpty(roleValue)) + identity.AddClaim(new Claim("roles", roleValue)); + } + } + } + catch { /* ignore malformed claim */ } + } + + // Extract client roles from resource_access..roles + var resourceAccess = context.Principal.FindFirst("resource_access"); + if (resourceAccess != null) + { + try + { + var parsed = System.Text.Json.JsonDocument.Parse(resourceAccess.Value); + foreach (var client in parsed.RootElement.EnumerateObject()) + { + if (client.Value.TryGetProperty("roles", out var roles)) + { + foreach (var role in roles.EnumerateArray()) + { + var roleValue = role.GetString(); + if (!string.IsNullOrEmpty(roleValue)) + identity.AddClaim(new Claim("roles", roleValue)); + } + } + } + } + catch { /* ignore malformed claim */ } + } + } + return Task.CompletedTask; + } + }; + }); +} +else +{ + // Entra ID JWT Bearer authentication (default) + builder.Services.AddAuthentication() + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); +} + builder.Services.AddAuthorizationBuilder() .AddPolicy("ExportPolicy", policy => policy.RequireRole("AIPolicy.Export")) @@ -169,6 +253,7 @@ app.UseWebSockets(); // Map all endpoints +app.MapAuthConfigEndpoints(); app.MapLogIngestEndpoints(); app.MapDashboardEndpoints(); app.MapPlanEndpoints(); diff --git a/src/AIPolicyEngine.Api/appsettings.json b/src/AIPolicyEngine.Api/appsettings.json index 985e9dcc..8b4ec154 100644 --- a/src/AIPolicyEngine.Api/appsettings.json +++ b/src/AIPolicyEngine.Api/appsettings.json @@ -7,6 +7,8 @@ }, "AllowedHosts": "*", + "AuthProvider": "AzureAd", + "AzureAd": { "Instance": "https://login.microsoftonline.com/", "TenantId": "", @@ -14,6 +16,16 @@ "Audience": "" }, + "Keycloak": { + "Authority": "", + "Audience": "", + "ClientId": "", + "RequireHttpsMetadata": true, + "FrontendUrl": "", + "Realm": "", + "FrontendClientId": "" + }, + "APPLICATIONINSIGHTS_CONNECTION_STRING": "", "UsagePolicy": { diff --git a/src/Dockerfile b/src/Dockerfile index 82ca2d09..171c9a59 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /ui COPY aipolicyengine-ui/package*.json ./ RUN npm ci COPY aipolicyengine-ui/ ./ -RUN npm run build +RUN npm run build -- --outDir /ui/dist --emptyOutDir FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src @@ -26,4 +26,5 @@ WORKDIR /app EXPOSE 8080 ENV ASPNETCORE_URLS=http://+:8080 COPY --from=build /app/publish . +USER $APP_UID ENTRYPOINT ["dotnet", "AIPolicyEngine.Api.dll"] diff --git a/src/aipolicyengine-ui/.env.sample b/src/aipolicyengine-ui/.env.sample index 06c50301..1b293ea5 100644 --- a/src/aipolicyengine-ui/.env.sample +++ b/src/aipolicyengine-ui/.env.sample @@ -6,3 +6,7 @@ VITE_AZURE_TENANT_ID= VITE_AZURE_API_APP_ID= VITE_AZURE_AUTHORITY=https://login.microsoftonline.com/ VITE_AZURE_SCOPE=api:///access_as_user + +# Auth provider and Keycloak settings are now served at runtime from the +# backend /api/auth-config endpoint. Configure them in the API's appsettings +# or Kubernetes configmap instead of here. diff --git a/src/aipolicyengine-ui/package-lock.json b/src/aipolicyengine-ui/package-lock.json index c5609762..d9eb249a 100644 --- a/src/aipolicyengine-ui/package-lock.json +++ b/src/aipolicyengine-ui/package-lock.json @@ -1,11 +1,11 @@ { - "name": "aipolicy-ui", + "name": "aipolicyengine-ui", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "aipolicy-ui", + "name": "aipolicyengine-ui", "version": "0.0.0", "dependencies": { "@azure/msal-browser": "^5.4.0", @@ -15,6 +15,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.577.0", + "oidc-client-ts": "^3.5.0", "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "^3.7.0", @@ -23,7 +24,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/node": "^24.10.1", + "@types/node": "^24.12.4", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -1839,9 +1840,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", - "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3232,6 +3233,15 @@ "node": ">=6" } }, + "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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3608,6 +3618,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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/src/aipolicyengine-ui/package.json b/src/aipolicyengine-ui/package.json index 70ea0c1a..c0328886 100644 --- a/src/aipolicyengine-ui/package.json +++ b/src/aipolicyengine-ui/package.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.577.0", + "oidc-client-ts": "^3.5.0", "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "^3.7.0", @@ -25,7 +26,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/node": "^24.10.1", + "@types/node": "^24.12.4", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/src/aipolicyengine-ui/src/App.tsx b/src/aipolicyengine-ui/src/App.tsx index 05a088dc..f392acb1 100644 --- a/src/aipolicyengine-ui/src/App.tsx +++ b/src/aipolicyengine-ui/src/App.tsx @@ -10,18 +10,90 @@ import { Export } from "./pages/Export" import { ClientDetail } from "./pages/ClientDetail" import { RoutingPolicies } from "./pages/RoutingPolicies" import { RequestBilling } from "./pages/RequestBilling" -import { loginRequest } from "./auth/msalConfig" -import { fetchPlans } from "./api" +import { getResolvedAuthProvider, getResolvedAuthConfig, fetchPlans } from "./api" import type { PlanData, BillingMode } from "./types" import { Button } from "./components/ui/button" import { Activity, LogIn } from "lucide-react" -function App() { +function KeycloakApp() { + const [activeTab, setActiveTab] = useState("dashboard") + const [selectedClient, setSelectedClient] = useState<{ clientAppId: string; tenantId: string } | null>(null) + const [plans, setPlans] = useState([]) + const authProvider = getResolvedAuthProvider() + const [isAuthenticated, setIsAuthenticated] = useState(authProvider?.isAuthenticated() ?? false) + + useEffect(() => { + setIsAuthenticated(authProvider?.isAuthenticated() ?? false) + }, [authProvider]) + + const loadPlans = useCallback(async () => { + try { + const res = await fetchPlans() + setPlans(res.plans ?? []) + } catch { + // Plans may not be loaded yet + } + }, []) + + useEffect(() => { + if (isAuthenticated) loadPlans() + }, [isAuthenticated, loadPlans]) + + const billingMode: BillingMode = useMemo(() => { + if (plans.length === 0) return 'token' + const hasMultiplier = plans.some(p => p.useMultiplierBilling) + const hasToken = plans.some(p => !p.useMultiplierBilling) + if (hasMultiplier && hasToken) return 'hybrid' + if (hasMultiplier) return 'multiplier' + return 'token' + }, [plans]) + + if (!isAuthenticated) { + return ( +
+
+ +
+

AI Policy Engine Dashboard

+

Sign in with your organization account to access the dashboard.

+
+ +
+
+ ) + } + + if (selectedClient) { + return ( + { setSelectedClient(null); setActiveTab(tab); }} billingMode={billingMode}> + setSelectedClient(null)} /> + + ) + } + + return ( + + {activeTab === "dashboard" && setSelectedClient({ clientAppId, tenantId })} />} + {activeTab === "clients" && setSelectedClient({ clientAppId, tenantId })} />} + {activeTab === "plans" && } + {activeTab === "pricing" && } + {activeTab === "routing" && } + {activeTab === "requests" && setSelectedClient({ clientAppId, tenantId })} />} + {activeTab === "export" && } + + ) +} + +function AzureAdApp() { const [activeTab, setActiveTab] = useState("dashboard") const [selectedClient, setSelectedClient] = useState<{ clientAppId: string; tenantId: string } | null>(null) const [plans, setPlans] = useState([]) const isAuthenticated = useIsAuthenticated() - const { instance, inProgress } = useMsal() + const { inProgress } = useMsal() + const authProvider = getResolvedAuthProvider() const loadPlans = useCallback(async () => { try { @@ -66,7 +138,7 @@ function App() {

AI Policy Engine Dashboard

Sign in with your organization account to access the dashboard.

- @@ -96,4 +168,10 @@ function App() { ) } +function App() { + const config = getResolvedAuthConfig(); + if (config?.authProvider === "Keycloak") return ; + return ; +} + export default App diff --git a/src/aipolicyengine-ui/src/api.ts b/src/aipolicyengine-ui/src/api.ts index d04534c6..d51a58aa 100644 --- a/src/aipolicyengine-ui/src/api.ts +++ b/src/aipolicyengine-ui/src/api.ts @@ -1,55 +1,52 @@ -import { InteractionRequiredAuthError, PublicClientApplication, type SilentRequest } from "@azure/msal-browser"; -import { msalConfig, loginRequest } from "./auth/msalConfig"; +import { initAuth, type AuthProvider, type AuthConfig } from "./auth"; import type { ChargebackResponse, QuotasResponse, QuotaUpdateRequest, QuotaData, PlansResponse, PlanCreateRequest, PlanUpdateRequest, PlanData, ClientsResponse, ClientAssignRequest, ClientUsageResponse, ClientTracesResponse, UsageSummaryResponse, RequestLogsResponse, ModelPricingResponse, ModelPricingCreateRequest, ModelPricing, ExportPeriodsResponse, DeploymentsResponse, RoutingPoliciesResponse, ModelRoutingPolicy, ModelRoutingPolicyCreateRequest, ModelRoutingPolicyUpdateRequest, RequestSummaryResponse } from "./types"; const API_BASE = import.meta.env.VITE_API_URL || ""; -const msalInstance = new PublicClientApplication(msalConfig); -let redirectInFlight = false; +// --- Auth initialization (single promise for the whole app) --- +let _authProvider: AuthProvider | null = null; +let _authConfig: AuthConfig | null = null; -// Initialize MSAL and handle redirect/popup responses on page load. -// This is critical — without it, popup auth flow will hang. -const msalReady = msalInstance.initialize().then(() => { - return msalInstance.handleRedirectPromise(); -}); +/** Initialize auth — call once at app startup, before rendering. */ +export async function initializeAuth(): Promise<{ provider: AuthProvider; config: AuthConfig }> { + const result = await initAuth(); + _authProvider = result.provider; + _authConfig = result.config; + return result; +} -function isInteractionInProgress(): boolean { - return redirectInFlight || window.sessionStorage.getItem("msal.interaction.status") === "interaction_in_progress"; +export function getResolvedAuthProvider(): AuthProvider | null { + return _authProvider; } -async function startRedirectOnce(): Promise { - if (isInteractionInProgress()) return; - redirectInFlight = true; - await msalInstance.acquireTokenRedirect(loginRequest); +export function getResolvedAuthConfig(): AuthConfig | null { + return _authConfig; } async function getToken(): Promise { - await msalReady; - const accounts = msalInstance.getAllAccounts(); - if (accounts.length === 0) return null; - try { - const request: SilentRequest = { ...loginRequest, account: accounts[0] }; - const response = await msalInstance.acquireTokenSilent(request); - return response.accessToken; - } catch (error) { - // Only initiate interactive auth when MSAL explicitly requires it. - // Avoid re-entrant redirects that cause interaction_in_progress loops. - if (error instanceof InteractionRequiredAuthError) { - await startRedirectOnce(); - return null; - } - if ((error as { errorCode?: string })?.errorCode === "interaction_in_progress") { - return null; - } - throw error; - } finally { - if (!isInteractionInProgress()) { - redirectInFlight = false; - } + if (!_authProvider) return null; + return _authProvider.getToken(); +} + +/** + * Allowed API path prefixes — only paths starting with one of these + * will be permitted through to fetch, preventing SSRF. + */ +const ALLOWED_PATH_PREFIXES = ["/api/", "/chargeback"]; + +/** + * Constructs a safe, absolute URL from a relative path by validating + * against the allowlist and prepending the hardcoded API_BASE. + */ +function buildApiUrl(path: string): string { + if (!ALLOWED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix))) { + throw new Error("Request blocked: path is not in the allowed API routes."); } + return `${API_BASE}${path}`; } -async function authFetch(url: string, options: RequestInit = {}): Promise { +async function authFetch(path: string, options: RequestInit = {}): Promise { + const url = buildApiUrl(path); let token: string | null = null; try { token = await getToken(); @@ -65,6 +62,9 @@ async function authFetch(url: string, options: RequestInit = {}): Promise { - const res = await authFetch(`${API_BASE}/api/usage`); + const res = await authFetch(`/api/usage`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch usage summary")); return res.json(); } export async function fetchRequestLogs(): Promise { - const res = await authFetch(`${API_BASE}/api/logs`); + const res = await authFetch(`/api/logs`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch request logs")); return res.json(); } export async function fetchChargeback(): Promise { - const res = await authFetch(`${API_BASE}/chargeback`); + const res = await authFetch(`/chargeback`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch chargeback")); return res.json(); } export async function fetchQuotas(): Promise { - const res = await authFetch(`${API_BASE}/api/quotas`); + const res = await authFetch(`/api/quotas`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch quotas")); return res.json(); } export async function updateQuota(clientAppId: string, data: QuotaUpdateRequest): Promise { - const res = await authFetch(`${API_BASE}/api/quotas/${encodeURIComponent(clientAppId)}`, { + const res = await authFetch(`/api/quotas/${encodeURIComponent(clientAppId)}`, { method: "PUT", body: JSON.stringify(data), }); @@ -108,20 +108,20 @@ export async function updateQuota(clientAppId: string, data: QuotaUpdateRequest) } export async function deleteQuota(clientAppId: string): Promise { - const res = await authFetch(`${API_BASE}/api/quotas/${encodeURIComponent(clientAppId)}`, { + const res = await authFetch(`/api/quotas/${encodeURIComponent(clientAppId)}`, { method: "DELETE", }); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to delete quota")); } export async function fetchPlans(): Promise { - const res = await authFetch(`${API_BASE}/api/plans`); + const res = await authFetch(`/api/plans`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch plans")); return res.json(); } export async function createPlan(data: PlanCreateRequest): Promise { - const res = await authFetch(`${API_BASE}/api/plans`, { + const res = await authFetch(`/api/plans`, { method: "POST", body: JSON.stringify(data), }); @@ -130,7 +130,7 @@ export async function createPlan(data: PlanCreateRequest): Promise { } export async function updatePlan(planId: string, data: PlanUpdateRequest): Promise { - const res = await authFetch(`${API_BASE}/api/plans/${encodeURIComponent(planId)}`, { + const res = await authFetch(`/api/plans/${encodeURIComponent(planId)}`, { method: "PUT", body: JSON.stringify(data), }); @@ -139,20 +139,20 @@ export async function updatePlan(planId: string, data: PlanUpdateRequest): Promi } export async function deletePlan(planId: string): Promise { - const res = await authFetch(`${API_BASE}/api/plans/${encodeURIComponent(planId)}`, { + const res = await authFetch(`/api/plans/${encodeURIComponent(planId)}`, { method: "DELETE", }); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to delete plan")); } export async function fetchClients(): Promise { - const res = await authFetch(`${API_BASE}/api/clients`); + const res = await authFetch(`/api/clients`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch clients")); return res.json(); } export async function assignClient(clientAppId: string, tenantId: string, data: ClientAssignRequest): Promise { - const res = await authFetch(`${API_BASE}/api/clients/${encodeURIComponent(clientAppId)}/${encodeURIComponent(tenantId)}`, { + const res = await authFetch(`/api/clients/${encodeURIComponent(clientAppId)}/${encodeURIComponent(tenantId)}`, { method: "PUT", body: JSON.stringify(data), }); @@ -161,32 +161,32 @@ export async function assignClient(clientAppId: string, tenantId: string, data: } export async function removeClient(clientAppId: string, tenantId: string): Promise { - const res = await authFetch(`${API_BASE}/api/clients/${encodeURIComponent(clientAppId)}/${encodeURIComponent(tenantId)}`, { + const res = await authFetch(`/api/clients/${encodeURIComponent(clientAppId)}/${encodeURIComponent(tenantId)}`, { method: "DELETE", }); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to remove client")); } export async function fetchClientUsage(clientAppId: string, tenantId: string): Promise { - const res = await authFetch(`${API_BASE}/api/clients/${encodeURIComponent(clientAppId)}/${encodeURIComponent(tenantId)}/usage`); + const res = await authFetch(`/api/clients/${encodeURIComponent(clientAppId)}/${encodeURIComponent(tenantId)}/usage`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch client usage")); return res.json(); } export async function fetchClientTraces(clientAppId: string, tenantId: string): Promise { - const res = await authFetch(`${API_BASE}/api/clients/${encodeURIComponent(clientAppId)}/${encodeURIComponent(tenantId)}/traces`); + const res = await authFetch(`/api/clients/${encodeURIComponent(clientAppId)}/${encodeURIComponent(tenantId)}/traces`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch client traces")); return res.json(); } export async function fetchPricing(): Promise { - const res = await authFetch(`${API_BASE}/api/pricing`); + const res = await authFetch(`/api/pricing`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch pricing")); return res.json(); } export async function updatePricing(modelId: string, data: ModelPricingCreateRequest): Promise { - const res = await authFetch(`${API_BASE}/api/pricing/${encodeURIComponent(modelId)}`, { + const res = await authFetch(`/api/pricing/${encodeURIComponent(modelId)}`, { method: "PUT", body: JSON.stringify(data), }); @@ -195,7 +195,7 @@ export async function updatePricing(modelId: string, data: ModelPricingCreateReq } export async function deletePricing(modelId: string): Promise { - const res = await authFetch(`${API_BASE}/api/pricing/${encodeURIComponent(modelId)}`, { method: "DELETE" }); + const res = await authFetch(`/api/pricing/${encodeURIComponent(modelId)}`, { method: "DELETE" }); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to delete pricing")); } @@ -204,25 +204,25 @@ export function exportCsvUrl(): string { } export async function fetchDeployments(): Promise { - const res = await authFetch(`${API_BASE}/api/deployments`); + const res = await authFetch(`/api/deployments`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch deployments")); return res.json(); } export async function fetchExportPeriods(): Promise { - const res = await authFetch(`${API_BASE}/api/export/available-periods`); + const res = await authFetch(`/api/export/available-periods`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch export periods")); return res.json(); } export async function downloadBillingSummary(year: number, month: number): Promise { - const res = await authFetch(`${API_BASE}/api/export/billing-summary?year=${year}&month=${month}`); + const res = await authFetch(`/api/export/billing-summary?year=${year}&month=${month}`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to download billing summary")); await triggerBlobDownload(res); } export async function downloadClientAudit(clientAppId: string, tenantId: string, year: number, month: number): Promise { - const res = await authFetch(`${API_BASE}/api/export/client-audit?clientAppId=${encodeURIComponent(clientAppId)}&tenantId=${encodeURIComponent(tenantId)}&year=${year}&month=${month}`); + const res = await authFetch(`/api/export/client-audit?clientAppId=${encodeURIComponent(clientAppId)}&tenantId=${encodeURIComponent(tenantId)}&year=${year}&month=${month}`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to download client audit")); await triggerBlobDownload(res); } @@ -245,19 +245,19 @@ async function triggerBlobDownload(res: Response): Promise { // --- Phase 4: Model Routing & Multiplier Billing API --- export async function fetchRoutingPolicies(): Promise { - const res = await authFetch(`${API_BASE}/api/routing-policies`); + const res = await authFetch(`/api/routing-policies`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch routing policies")); return res.json(); } export async function fetchRoutingPolicy(policyId: string): Promise { - const res = await authFetch(`${API_BASE}/api/routing-policies/${encodeURIComponent(policyId)}`); + const res = await authFetch(`/api/routing-policies/${encodeURIComponent(policyId)}`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch routing policy")); return res.json(); } export async function createRoutingPolicy(data: ModelRoutingPolicyCreateRequest): Promise { - const res = await authFetch(`${API_BASE}/api/routing-policies`, { + const res = await authFetch(`/api/routing-policies`, { method: "POST", body: JSON.stringify(data), }); @@ -266,7 +266,7 @@ export async function createRoutingPolicy(data: ModelRoutingPolicyCreateRequest) } export async function updateRoutingPolicy(policyId: string, data: ModelRoutingPolicyUpdateRequest): Promise { - const res = await authFetch(`${API_BASE}/api/routing-policies/${encodeURIComponent(policyId)}`, { + const res = await authFetch(`/api/routing-policies/${encodeURIComponent(policyId)}`, { method: "PUT", body: JSON.stringify(data), }); @@ -275,7 +275,7 @@ export async function updateRoutingPolicy(policyId: string, data: ModelRoutingPo } export async function deleteRoutingPolicy(policyId: string): Promise { - const res = await authFetch(`${API_BASE}/api/routing-policies/${encodeURIComponent(policyId)}`, { + const res = await authFetch(`/api/routing-policies/${encodeURIComponent(policyId)}`, { method: "DELETE", }); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to delete routing policy")); @@ -286,7 +286,7 @@ export async function fetchRequestSummary(year?: number, month?: number): Promis if (year !== undefined) params.set("year", String(year)); if (month !== undefined) params.set("month", String(month)); const qs = params.toString(); - const res = await authFetch(`${API_BASE}/api/chargeback/request-summary${qs ? `?${qs}` : ""}`); + const res = await authFetch(`/api/chargeback/request-summary${qs ? `?${qs}` : ""}`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to fetch request summary")); return res.json(); } @@ -296,9 +296,7 @@ export async function downloadRequestBilling(year?: number, month?: number): Pro if (year !== undefined) params.set("year", String(year)); if (month !== undefined) params.set("month", String(month)); const qs = params.toString(); - const res = await authFetch(`${API_BASE}/api/export/request-billing${qs ? `?${qs}` : ""}`); + const res = await authFetch(`/api/export/request-billing${qs ? `?${qs}` : ""}`); if (!res.ok) throw new Error(await parseErrorMessage(res, "Failed to download request billing")); await triggerBlobDownload(res); } - -export { msalInstance, msalReady }; diff --git a/src/aipolicyengine-ui/src/auth/authProvider.ts b/src/aipolicyengine-ui/src/auth/authProvider.ts new file mode 100644 index 00000000..ae906eae --- /dev/null +++ b/src/aipolicyengine-ui/src/auth/authProvider.ts @@ -0,0 +1,56 @@ +/** + * Auth provider abstraction — allows switching between MSAL (Azure AD) + * and OIDC (Keycloak) based on the runtime /api/auth-config endpoint. + */ + +export interface AuthProvider { + /** Initialize the auth provider (handle redirects, etc.) */ + initialize(): Promise; + /** Get an access token for API calls, or null if not authenticated */ + getToken(): Promise; + /** Check if the user is currently authenticated */ + isAuthenticated(): boolean; + /** Trigger interactive login */ + login(): Promise; + /** Trigger logout */ + logout(): Promise; + /** Get the display name of the current user */ + getUserDisplayName(): string | null; +} + +export type AuthProviderType = "AzureAd" | "Keycloak"; + +export interface AuthConfig { + authProvider: AuthProviderType; + clientId: string; + authority: string; + audience: string; + // AzureAd-specific + tenantId?: string; + scope?: string; + // Keycloak-specific + realm?: string; + frontendUrl?: string; +} + +const API_BASE = import.meta.env.VITE_API_URL || ""; + +let _cachedConfig: AuthConfig | null = null; + +/** + * Fetch auth configuration from the backend at runtime. + * Result is cached — only one network call per page load. + */ +export async function fetchAuthConfig(): Promise { + if (_cachedConfig) return _cachedConfig; + const res = await fetch(`${API_BASE}/api/auth-config`); + if (!res.ok) { + throw new Error(`Failed to load auth config: ${res.statusText}`); + } + _cachedConfig = await res.json(); + return _cachedConfig!; +} + +export function getCachedAuthConfig(): AuthConfig | null { + return _cachedConfig; +} diff --git a/src/aipolicyengine-ui/src/auth/index.ts b/src/aipolicyengine-ui/src/auth/index.ts new file mode 100644 index 00000000..d27e4db6 --- /dev/null +++ b/src/aipolicyengine-ui/src/auth/index.ts @@ -0,0 +1,33 @@ +import type { AuthProvider } from "./authProvider"; +import { fetchAuthConfig, type AuthConfig } from "./authProvider"; + +export type { AuthProvider } from "./authProvider"; +export type { AuthProviderType, AuthConfig } from "./authProvider"; +export { fetchAuthConfig, getCachedAuthConfig } from "./authProvider"; + +let _authProvider: AuthProvider | null = null; +let _resolvedConfig: AuthConfig | null = null; + +/** + * Fetch runtime config from the backend, then create and initialize + * the correct auth provider. Returns both the provider and the config. + */ +export async function initAuth(): Promise<{ provider: AuthProvider; config: AuthConfig }> { + if (_authProvider && _resolvedConfig) { + return { provider: _authProvider, config: _resolvedConfig }; + } + + const config = await fetchAuthConfig(); + _resolvedConfig = config; + + if (config.authProvider === "Keycloak") { + const { createKeycloakAuthProvider } = await import("./keycloakAuth"); + _authProvider = createKeycloakAuthProvider(config); + } else { + const { createMsalAuthProvider } = await import("./msalAuth"); + _authProvider = createMsalAuthProvider(config); + } + + await _authProvider.initialize(); + return { provider: _authProvider, config }; +} diff --git a/src/aipolicyengine-ui/src/auth/keycloakAuth.ts b/src/aipolicyengine-ui/src/auth/keycloakAuth.ts new file mode 100644 index 00000000..6214417c --- /dev/null +++ b/src/aipolicyengine-ui/src/auth/keycloakAuth.ts @@ -0,0 +1,80 @@ +import { UserManager, WebStorageStateStore, type User } from "oidc-client-ts"; +import type { AuthProvider, AuthConfig } from "./authProvider"; + +let userManager: UserManager | null = null; +let currentUser: User | null = null; + +export function createKeycloakAuthProvider(config: AuthConfig): AuthProvider { + // Build authority from components if not directly provided + const authority = config.authority + || (config.frontendUrl && config.realm ? `${config.frontendUrl}/realms/${config.realm}` : ""); + + userManager = new UserManager({ + authority, + client_id: config.clientId, + redirect_uri: window.location.origin, + post_logout_redirect_uri: window.location.origin, + response_type: "code", + response_mode: "fragment", + scope: "openid profile email", + userStore: new WebStorageStateStore({ store: window.localStorage }), + automaticSilentRenew: true, + silent_redirect_uri: window.location.origin, + }); + + const mgr = userManager; + + return { + async initialize(): Promise { + // Handle callback redirect (fragment mode: params are in hash, not query string) + const hasCallback = window.location.hash.includes("code=") || window.location.hash.includes("state=") + || window.location.search.includes("code=") || window.location.search.includes("state="); + if (hasCallback) { + try { + currentUser = await mgr.signinRedirectCallback(); + window.history.replaceState({}, document.title, window.location.pathname); + } catch { + currentUser = await mgr.getUser(); + } + } else { + currentUser = await mgr.getUser(); + } + + mgr.events.addUserLoaded((user) => { + currentUser = user; + }); + mgr.events.addUserUnloaded(() => { + currentUser = null; + }); + }, + + async getToken(): Promise { + if (!currentUser || currentUser.expired) { + try { + currentUser = await mgr.signinSilent(); + } catch { + return null; + } + } + return currentUser?.access_token ?? null; + }, + + isAuthenticated(): boolean { + return currentUser != null && !currentUser.expired; + }, + + async login(): Promise { + await mgr.signinRedirect(); + }, + + async logout(): Promise { + await mgr.signoutRedirect(); + }, + + getUserDisplayName(): string | null { + return currentUser?.profile?.preferred_username + ?? currentUser?.profile?.name + ?? null; + }, + }; +} diff --git a/src/aipolicyengine-ui/src/auth/msalAuth.ts b/src/aipolicyengine-ui/src/auth/msalAuth.ts new file mode 100644 index 00000000..114c785f --- /dev/null +++ b/src/aipolicyengine-ui/src/auth/msalAuth.ts @@ -0,0 +1,81 @@ +import { InteractionRequiredAuthError, PublicClientApplication, type SilentRequest } from "@azure/msal-browser"; +import type { AuthProvider, AuthConfig } from "./authProvider"; + +let msalInstance: PublicClientApplication | null = null; +let scopes: string[] = []; +let redirectInFlight = false; + +function isInteractionInProgress(): boolean { + return redirectInFlight || window.sessionStorage.getItem("msal.interaction.status") === "interaction_in_progress"; +} + +export function createMsalAuthProvider(config: AuthConfig): AuthProvider { + msalInstance = new PublicClientApplication({ + auth: { + clientId: config.clientId, + authority: config.authority, + redirectUri: window.location.origin, + }, + cache: { cacheLocation: "localStorage" }, + }); + + scopes = config.scope ? [config.scope] : []; + + const instance = msalInstance; + const loginRequest = { scopes }; + + return { + async initialize(): Promise { + await instance.initialize(); + await instance.handleRedirectPromise(); + }, + + async getToken(): Promise { + const accounts = instance.getAllAccounts(); + if (accounts.length === 0) return null; + try { + const request: SilentRequest = { ...loginRequest, account: accounts[0] }; + const response = await instance.acquireTokenSilent(request); + return response.accessToken; + } catch (error) { + if (error instanceof InteractionRequiredAuthError) { + if (!isInteractionInProgress()) { + redirectInFlight = true; + await instance.acquireTokenRedirect(loginRequest); + } + return null; + } + if ((error as { errorCode?: string })?.errorCode === "interaction_in_progress") { + return null; + } + throw error; + } finally { + if (!isInteractionInProgress()) { + redirectInFlight = false; + } + } + }, + + isAuthenticated(): boolean { + return instance.getAllAccounts().length > 0; + }, + + async login(): Promise { + await instance.loginRedirect(loginRequest); + }, + + async logout(): Promise { + await instance.logoutRedirect(); + }, + + getUserDisplayName(): string | null { + const accounts = instance.getAllAccounts(); + return accounts.length > 0 ? accounts[0].name ?? accounts[0].username : null; + }, + }; +} + +/** Expose the raw MSAL instance for MsalProvider (React context) */ +export function getMsalInstance(): PublicClientApplication | null { + return msalInstance; +} diff --git a/src/aipolicyengine-ui/src/main.tsx b/src/aipolicyengine-ui/src/main.tsx index e7844c43..52ccaf56 100644 --- a/src/aipolicyengine-ui/src/main.tsx +++ b/src/aipolicyengine-ui/src/main.tsx @@ -2,19 +2,40 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { MsalProvider } from '@azure/msal-react' import { ThemeProvider } from './context/ThemeProvider' -import { msalInstance, msalReady } from './api' +import { initializeAuth } from './api' import './index.css' import App from './App.tsx' -// Wait for MSAL to initialize and handle any auth redirects before rendering -msalReady.then(() => { - createRoot(document.getElementById('root')!).render( +// Fetch runtime auth config from the backend, then initialize the correct +// auth provider before rendering the React tree. +initializeAuth().then(({ config }) => { + const app = ( - - - - - - , - ) + + + + + ); + + if (config.authProvider === "AzureAd") { + // Lazy import to avoid loading MSAL when using Keycloak + import('./auth/msalAuth').then(({ getMsalInstance }) => { + const msalInstance = getMsalInstance(); + if (msalInstance) { + createRoot(document.getElementById('root')!).render( + + + + + + + + ); + } else { + createRoot(document.getElementById('root')!).render(app); + } + }); + } else { + createRoot(document.getElementById('root')!).render(app); + } })