From f8e1a9457f54287f8be527adf039b7c7edddf404 Mon Sep 17 00:00:00 2001
From: Alessandro Franceschi
Date: Wed, 10 Jun 2026 13:37:58 +0200
Subject: [PATCH 01/13] feat(auth): add Azure Entra ID (OpenID Connect)
authentication
- Add EntraIdService with OAuth 2.0 Authorization Code Flow and PKCE support
- Implement ID token validation via JWKS with configurable caching
- Add federated identity provisioning and account linking on first SSO login
- Support automatic group-to-role synchronization from Entra ID claims
- Add database migrations for federated_identities and oauth_state_store tables
- Create /api/auth/entra-id/* routes for login, callback, and token exchange
- Add EntraIdLoginButton component with Microsoft branding for frontend
- Implement server-side ephemeral state store with 10-minute TTL
- Add single-use authorization code flow for secure frontend token delivery
- Update authentication middleware to treat federated and local auth identically
- Add comprehensive unit, integration, and property-based test coverage
- Update configuration schema and environment examples for Entra ID setup
- Add Entra ID integration documentation with setup and group mapping examples
- Ensure zero disruption to existing local authentication flows
---
.kiro/specs/azure-entra-id-auth/.config.kiro | 1 +
.kiro/specs/azure-entra-id-auth/design.md | 509 ++++++++++
.../specs/azure-entra-id-auth/requirements.md | 173 ++++
.kiro/specs/azure-entra-id-auth/tasks.md | 243 +++++
AGENTS.md | 4 +-
backend/.env.example | 34 +
backend/src/config/ConfigService.ts | 106 ++
backend/src/config/schema.ts | 18 +
backend/src/container/DIContainer.ts | 2 +
.../database/migrations/016_entra_id_auth.sql | 48 +
.../migrations/017_nullable_password_hash.sql | 39 +
backend/src/routes/auth.ts | 59 ++
backend/src/routes/entraIdAuth.ts | 264 +++++
backend/src/server.ts | 70 ++
backend/src/services/EntraIdService.ts | 902 ++++++++++++++++++
backend/src/services/UserService.ts | 215 +++++
.../database/migration-integration.test.ts | 10 +-
backend/test/database/rbac-schema.test.ts | 2 +-
.../test/integration/EntraIdAuthFlow.test.ts | 639 +++++++++++++
.../EntraIdAuthCode.property.test.ts | 377 ++++++++
.../EntraIdCallback.property.test.ts | 637 +++++++++++++
.../EntraIdGroupSync.property.test.ts | 462 +++++++++
.../EntraIdProviders.property.test.ts | 223 +++++
.../EntraIdProvisioning.property.test.ts | 519 ++++++++++
.../test/unit/services/EntraIdService.test.ts | 656 +++++++++++++
docs/api.md | 38 +-
docs/architecture.md | 3 +
docs/configuration.md | 18 +
docs/integrations/entra-id.md | 149 +++
docs/permissions-rbac.md | 21 +
.../src/components/EntraIdLoginButton.svelte | 32 +
.../src/components/EntraIdLoginButton.test.ts | 68 ++
frontend/src/lib/auth.svelte.ts | 30 +-
frontend/src/lib/entraIdAuth.svelte.test.ts | 180 ++++
frontend/src/lib/entraIdAuth.svelte.ts | 127 +++
frontend/src/pages/LoginPage.svelte | 288 ++++--
frontend/src/pages/LoginPage.test.ts | 161 ++++
scripts/setup.sh | 47 +
38 files changed, 7272 insertions(+), 102 deletions(-)
create mode 100644 .kiro/specs/azure-entra-id-auth/.config.kiro
create mode 100644 .kiro/specs/azure-entra-id-auth/design.md
create mode 100644 .kiro/specs/azure-entra-id-auth/requirements.md
create mode 100644 .kiro/specs/azure-entra-id-auth/tasks.md
create mode 100644 backend/src/database/migrations/016_entra_id_auth.sql
create mode 100644 backend/src/database/migrations/017_nullable_password_hash.sql
create mode 100644 backend/src/routes/entraIdAuth.ts
create mode 100644 backend/src/services/EntraIdService.ts
create mode 100644 backend/test/integration/EntraIdAuthFlow.test.ts
create mode 100644 backend/test/properties/EntraIdAuthCode.property.test.ts
create mode 100644 backend/test/properties/EntraIdCallback.property.test.ts
create mode 100644 backend/test/properties/EntraIdGroupSync.property.test.ts
create mode 100644 backend/test/properties/EntraIdProviders.property.test.ts
create mode 100644 backend/test/properties/EntraIdProvisioning.property.test.ts
create mode 100644 backend/test/unit/services/EntraIdService.test.ts
create mode 100644 docs/integrations/entra-id.md
create mode 100644 frontend/src/components/EntraIdLoginButton.svelte
create mode 100644 frontend/src/components/EntraIdLoginButton.test.ts
create mode 100644 frontend/src/lib/entraIdAuth.svelte.test.ts
create mode 100644 frontend/src/lib/entraIdAuth.svelte.ts
create mode 100644 frontend/src/pages/LoginPage.test.ts
diff --git a/.kiro/specs/azure-entra-id-auth/.config.kiro b/.kiro/specs/azure-entra-id-auth/.config.kiro
new file mode 100644
index 00000000..88d16210
--- /dev/null
+++ b/.kiro/specs/azure-entra-id-auth/.config.kiro
@@ -0,0 +1 @@
+{"specId": "2cd475d9-ff37-4ae1-af19-3e546cc2ffff", "workflowType": "requirements-first", "specType": "feature"}
\ No newline at end of file
diff --git a/.kiro/specs/azure-entra-id-auth/design.md b/.kiro/specs/azure-entra-id-auth/design.md
new file mode 100644
index 00000000..57a7e5fa
--- /dev/null
+++ b/.kiro/specs/azure-entra-id-auth/design.md
@@ -0,0 +1,509 @@
+# Design Document: Azure Entra ID Authentication
+
+## Overview
+
+This design adds Azure Entra ID (OpenID Connect) as a federated authentication provider to Pabawi, running alongside the existing local username/password flow. The implementation follows the OAuth 2.0 Authorization Code Flow with PKCE, validates ID tokens via JWKS, and issues standard Pabawi JWT tokens so that downstream middleware and RBAC remain unaware of the authentication origin.
+
+Key design goals:
+- **Zero disruption** to existing auth flows — local login continues unchanged
+- **Identical JWT tokens** regardless of authentication method — auth middleware sees no difference
+- **Automatic user provisioning** on first SSO login with optional account linking
+- **Group-to-role synchronization** from Entra ID claims to Pabawi RBAC
+- **Security-first** — PKCE, state/nonce validation, JWKS signature verification, short-lived ephemeral state
+
+## Architecture
+
+### High-Level Integration
+
+```mermaid
+graph TB
+ subgraph Frontend
+ LP[Login Page]
+ AC[Auth Callback Handler]
+ end
+
+ subgraph Backend
+ AR[Auth Routes - /api/auth/entra-id/*]
+ EIS[EntraIdService]
+ AS[AuthenticationService]
+ US[UserService]
+ RS[RoleService]
+ CS[ConfigService]
+ DB[(Database)]
+ SS[(State Store - DB)]
+ end
+
+ subgraph External
+ ENTRA[Azure Entra ID]
+ JWKS[JWKS Endpoint]
+ end
+
+ LP -->|1. Click SSO| AR
+ AR -->|2. 302 Redirect| ENTRA
+ ENTRA -->|3. Callback with code| AR
+ AR -->|4. Token exchange| ENTRA
+ AR -->|5. Validate ID token| JWKS
+ EIS -->|6. Provision/link user| US
+ EIS -->|7. Sync roles| RS
+ EIS -->|8. Issue Pabawi JWT| AS
+ AR -->|9. Redirect with auth code| AC
+ AC -->|10. Exchange code for tokens| AR
+```
+
+### Architectural Decisions
+
+| Decision | Rationale |
+|----------|-----------|
+| New `EntraIdService` class (not a BasePlugin) | This is an auth provider, not an infrastructure integration. Plugins are for inventory/execution sources. |
+| Server-side state store in DB table | Supports clustered deployments; avoids in-memory state loss on restart. 10-minute TTL with cleanup. |
+| Single-use authorization code for frontend token delivery | Prevents token exposure in URL fragments/history. Frontend exchanges ephemeral code for actual JWT pair. |
+| JWKS caching with configurable TTL | Avoids hitting Microsoft on every login while allowing key rotation detection. |
+| Group-to-role sync at login time only | Avoids continuous polling of Entra ID; roles reflect state at last login. |
+
+## Components and Interfaces
+
+### New Files
+
+| File | Purpose |
+|------|---------|
+| `backend/src/services/EntraIdService.ts` | Core SSO logic: authorization URL generation, token exchange, ID token validation, JWKS management, user provisioning orchestration |
+| `backend/src/routes/entraIdAuth.ts` | Express route factory: `/api/auth/entra-id/login`, `/callback`, `/token`, and `/api/auth/providers` |
+| `backend/src/database/migrations/016_entra_id_auth.sql` | New tables: `federated_identities`, `oauth_state_store` |
+| `frontend/src/lib/entraIdAuth.svelte.ts` | Frontend SSO state: provider discovery, callback handling |
+| `frontend/src/components/EntraIdLoginButton.svelte` | Microsoft-branded SSO button component |
+
+### Modified Files
+
+| File | Change |
+|------|--------|
+| `backend/src/config/ConfigService.ts` | Add Entra ID configuration parsing block |
+| `backend/src/config/schema.ts` | Add `EntraIdConfigSchema` to Zod schemas |
+| `backend/src/routes/auth.ts` | Add `/providers` endpoint; modify logout to include `entraIdLogoutUrl` |
+| `backend/src/services/UserService.ts` | Add `createFederatedUser()` and `linkFederatedIdentity()` methods |
+| `backend/src/container/DIContainer.ts` | Register `EntraIdService` in `ServiceRegistry` (optional, only when enabled) |
+| `frontend/src/pages/Login.svelte` | Conditionally render SSO button based on provider discovery |
+
+### EntraIdService Interface
+
+```typescript
+export interface EntraIdConfig {
+ enabled: boolean;
+ tenantId: string;
+ clientId: string;
+ clientSecret: string;
+ redirectUri: string;
+ scopes: string[];
+ groupMapping: Record | null;
+ postLogoutRedirectUri: string;
+ jwksCacheTtlMs: number;
+}
+
+export interface OAuthStateEntry {
+ state: string;
+ nonce: string;
+ codeVerifier: string;
+ createdAt: string; // ISO 8601
+ expiresAt: string; // ISO 8601, createdAt + 10 minutes
+}
+
+export interface AuthCodeEntry {
+ code: string;
+ accessToken: string;
+ refreshToken: string;
+ userId: string;
+ idToken: string; // stored for logout id_token_hint
+ authMethod: string; // 'entra-id'
+ createdAt: string;
+ expiresAt: string; // createdAt + 60 seconds
+}
+
+export class EntraIdService {
+ constructor(
+ db: DatabaseAdapter,
+ config: EntraIdConfig,
+ authService: AuthenticationService,
+ userService: UserService,
+ roleService: RoleService,
+ auditLogger: AuditLoggingService,
+ logger: LoggerService,
+ );
+
+ /** Generate authorization URL and store state/nonce/PKCE verifier */
+ generateAuthorizationUrl(): Promise<{ url: string; state: string }>;
+
+ /** Handle callback: validate state, exchange code, validate ID token, provision user, issue tokens */
+ handleCallback(code: string, state: string): Promise;
+
+ /** Exchange frontend auth code for tokens */
+ exchangeAuthCode(code: string): Promise<{ accessToken: string; refreshToken: string; user: UserDTO }>;
+
+ /** Build Entra ID logout URL for single sign-out */
+ buildLogoutUrl(idToken: string): string;
+
+ /** Get provider info for discovery endpoint */
+ getProviderInfo(): { enabled: true; name: string };
+
+ /** Cleanup expired state entries (called periodically) */
+ cleanupExpiredState(): Promise;
+}
+```
+
+### Route Factory
+
+```typescript
+// backend/src/routes/entraIdAuth.ts
+export function createEntraIdAuthRouter(
+ databaseService: DatabaseService,
+ container: DIContainer,
+): Router;
+```
+
+Endpoints:
+- `GET /api/auth/entra-id/login` → 302 redirect to Entra ID authorization endpoint
+- `GET /api/auth/entra-id/callback` → handles OAuth callback, redirects to frontend with auth code
+- `POST /api/auth/entra-id/token` → exchanges single-use auth code for Pabawi JWT pair
+- `GET /api/auth/providers` → returns available auth methods (public, no auth required)
+
+## Data Models
+
+### Database Schema (Migration 016)
+
+```sql
+-- Migration 016: Entra ID federated authentication support
+
+-- Federated identity links: maps external IdP subjects to Pabawi users
+CREATE TABLE IF NOT EXISTS federated_identities (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ provider TEXT NOT NULL, -- 'entra-id'
+ subject TEXT NOT NULL, -- Entra ID 'sub' claim (unique per tenant+user)
+ issuer TEXT NOT NULL, -- Token issuer URL
+ email TEXT, -- Email from IdP (informational, not authoritative)
+ id_token TEXT, -- Last ID token (for logout id_token_hint)
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(provider, subject)
+);
+
+CREATE INDEX IF NOT EXISTS idx_federated_identities_user ON federated_identities(user_id);
+CREATE INDEX IF NOT EXISTS idx_federated_identities_lookup ON federated_identities(provider, subject);
+
+-- OAuth state store: PKCE + state + nonce for in-flight authorization requests
+CREATE TABLE IF NOT EXISTS oauth_state_store (
+ state TEXT PRIMARY KEY,
+ nonce TEXT NOT NULL,
+ code_verifier TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_oauth_state_expires ON oauth_state_store(expires_at);
+
+-- Single-use authorization codes for frontend token delivery
+CREATE TABLE IF NOT EXISTS oauth_auth_codes (
+ code TEXT PRIMARY KEY,
+ access_token TEXT NOT NULL,
+ refresh_token TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ id_token TEXT,
+ auth_method TEXT NOT NULL DEFAULT 'entra-id',
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ exchanged INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires ON oauth_auth_codes(expires_at);
+```
+
+### TypeScript Interfaces
+
+```typescript
+interface FederatedIdentity {
+ id: string;
+ userId: string;
+ provider: string;
+ subject: string;
+ issuer: string;
+ email: string | null;
+ idToken: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+```
+
+### Configuration Schema (Zod)
+
+```typescript
+export const EntraIdConfigSchema = z.object({
+ enabled: z.boolean().default(false),
+ tenantId: z.string().min(1),
+ clientId: z.string().min(1),
+ clientSecret: z.string().min(1),
+ redirectUri: z.string().url(),
+ scopes: z.array(z.string()).default(['openid', 'profile', 'email']),
+ groupMapping: z.record(z.string(), z.string()).nullable().default(null),
+ postLogoutRedirectUri: z.string().url().optional(),
+ jwksCacheTtlMs: z.number().int().positive().default(86400000), // 24 hours
+});
+```
+
+The `ConfigService` parsing block follows the same pattern as other integrations:
+- `ENTRA_ID_ENABLED !== "true"` → skip entirely, no other vars required
+- `ENTRA_ID_ENABLED === "true"` → parse and validate all required vars; throw on missing/invalid
+
+## OAuth 2.0 Flow Sequence
+
+```mermaid
+sequenceDiagram
+ participant U as User Browser
+ participant FE as Frontend
+ participant BE as Backend (EntraIdService)
+ participant DB as Database
+ participant AAD as Azure Entra ID
+
+ Note over U,AAD: Phase 1: Initiation
+ U->>FE: Click "Sign in with Microsoft"
+ FE->>BE: GET /api/auth/entra-id/login
+ BE->>BE: Generate state (32 bytes), nonce (32 bytes), code_verifier (43-128 chars)
+ BE->>BE: Compute code_challenge = BASE64URL(SHA256(code_verifier))
+ BE->>DB: Store {state, nonce, code_verifier} with 10min TTL
+ BE->>U: 302 → https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?...
+
+ Note over U,AAD: Phase 2: User Authentication (at Microsoft)
+ U->>AAD: Authenticate (credentials, MFA, etc.)
+ AAD->>U: 302 → redirect_uri?code=...&state=...
+
+ Note over U,AAD: Phase 3: Callback Processing
+ U->>BE: GET /api/auth/entra-id/callback?code=...&state=...
+ BE->>DB: Lookup state entry, verify not expired
+ BE->>DB: Delete state entry (one-time use)
+ BE->>AAD: POST /oauth2/v2.0/token {code, code_verifier, client_secret, ...}
+ AAD-->>BE: {access_token, id_token, ...}
+
+ Note over U,AAD: Phase 4: Token Validation
+ BE->>AAD: GET /discovery/v2.0/keys (JWKS, cached)
+ BE->>BE: Verify ID token signature (RS256)
+ BE->>BE: Validate nonce, aud, iss, exp claims
+
+ Note over U,AAD: Phase 5: User Provisioning
+ BE->>DB: Lookup federated_identities WHERE provider='entra-id' AND subject=sub
+ alt New user
+ BE->>DB: Check users WHERE email = id_token.email
+ alt Email match exists
+ BE->>DB: Link federated identity to existing user
+ else No match
+ BE->>DB: Create new user (federation-only, null password_hash)
+ BE->>DB: Assign default role
+ end
+ end
+ BE->>BE: Sync group-to-role mapping (if configured)
+
+ Note over U,AAD: Phase 6: Session Issuance
+ BE->>BE: Generate Pabawi JWT (access + refresh) via AuthenticationService
+ BE->>DB: Store single-use auth code → tokens mapping (60s TTL)
+ BE->>DB: Update last_login_at
+ BE->>DB: Write audit log (AUTH, LOGIN_SUCCESS, method=entra-id)
+ BE->>U: 302 → frontend_url?code=auth_code
+
+ Note over U,AAD: Phase 7: Frontend Token Exchange
+ U->>FE: Page loads with ?code= parameter
+ FE->>BE: POST /api/auth/entra-id/token {code}
+ BE->>DB: Lookup auth code, verify not expired/exchanged
+ BE->>DB: Mark auth code as exchanged
+ BE-->>FE: {token, refreshToken, user}
+ FE->>FE: Store in authManager, navigate to landing page
+```
+
+## Error Handling
+
+| Scenario | HTTP Status | Error Code | Response |
+|----------|-------------|------------|----------|
+| Entra ID disabled, SSO endpoint hit | 404 | — | Standard 404 |
+| Missing/invalid state on callback | 400 | `INVALID_STATE` | State parameter missing or mismatched |
+| State expired (>10 min) | 400 | `SESSION_EXPIRED` | Authentication session expired |
+| Token exchange failure (non-2xx from AAD) | 401 | `TOKEN_EXCHANGE_FAILED` | Could not exchange authorization code |
+| Token exchange network timeout (>10s) | 401 | `TOKEN_EXCHANGE_FAILED` | Token endpoint unreachable |
+| ID token signature invalid | 401 | `INVALID_ID_TOKEN` | Token signature verification failed |
+| ID token nonce mismatch | 401 | `INVALID_ID_TOKEN` | Token nonce validation failed |
+| ID token aud/iss mismatch | 401 | `INVALID_ID_TOKEN` | Token audience/issuer mismatch |
+| ID token expired (>5min skew) | 401 | `INVALID_ID_TOKEN` | Token has expired |
+| AAD returns error parameter | 401 | `AUTH_PROVIDER_ERROR` | Includes AAD error + description |
+| Missing email/preferred_username claims | 401 | `MISSING_CLAIMS` | Required identity claims absent |
+| User provisioning DB failure | 500 | `PROVISIONING_FAILED` | Account creation failed |
+| Auth code expired or already exchanged | 400 | `INVALID_AUTH_CODE` | Authorization code invalid |
+| JWKS endpoint unreachable, no cache | 503 | `JWKS_UNAVAILABLE` | Cannot verify token signatures |
+| Config missing at request time | 500 | `SERVER_CONFIGURATION_ERROR` | Server configuration problem |
+
+All error responses follow the existing `{ error: { code, message } }` pattern from `utils/errorHandling.ts`.
+
+Sensitive values (client_secret, authorization codes, tokens) are never logged. The `LoggerService` calls use only metadata like `{ component: 'EntraIdService', operation: 'handleCallback' }`.
+
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: Non-"true" ENTRA_ID_ENABLED skips config validation
+
+*For any* string value of `ENTRA_ID_ENABLED` that is not exactly `"true"` (including undefined, empty, "false", "yes", "1", random strings), the ConfigService SHALL parse without error and without requiring any other `ENTRA_ID_*` variables.
+
+**Validates: Requirements 1.1**
+
+### Property 2: Missing required variables produce comprehensive error
+
+*For any* non-empty subset of the required variables (`ENTRA_ID_TENANT_ID`, `ENTRA_ID_CLIENT_ID`, `ENTRA_ID_CLIENT_SECRET`, `ENTRA_ID_REDIRECT_URI`) that are undefined or empty when `ENTRA_ID_ENABLED` is `"true"`, the ConfigService SHALL throw an error whose message contains the name of every missing variable in that subset.
+
+**Validates: Requirements 1.2, 1.3**
+
+### Property 3: Scope parsing discards empty entries
+
+*For any* comma-separated string set as `ENTRA_ID_SCOPES`, the parsed scope array SHALL contain no empty strings, and when `ENTRA_ID_SCOPES` is unset, the result SHALL default to `["openid", "profile", "email"]`.
+
+**Validates: Requirements 1.4**
+
+### Property 4: Group mapping JSON round-trip
+
+*For any* valid `Record` object serialized as JSON and set as `ENTRA_ID_GROUP_MAPPING`, the parsed configuration SHALL produce an equivalent object. For any string that is not valid JSON or does not parse to a `Record`, parsing SHALL throw a validation error.
+
+**Validates: Requirements 1.5, 1.6**
+
+### Property 5: Authorization URL contains all required parameters
+
+*For any* valid Entra ID configuration (tenant_id, client_id, redirect_uri, scopes), calling `generateAuthorizationUrl()` SHALL produce a URL containing query parameters `response_type=code`, `client_id` matching the configured value, `redirect_uri` matching the configured value, all configured scopes in the `scope` parameter, a `state` parameter of at least 32 bytes of entropy, a `nonce` parameter of at least 32 bytes of entropy, `code_challenge_method=S256`, and a `code_challenge` parameter.
+
+**Validates: Requirements 2.2**
+
+### Property 6: PKCE code_verifier/code_challenge correctness
+
+*For any* call to `generateAuthorizationUrl()`, the stored `code_verifier` SHALL be between 43 and 128 characters (inclusive) per RFC 7636, and the `code_challenge` parameter in the URL SHALL equal `BASE64URL(SHA256(code_verifier))`.
+
+**Validates: Requirements 2.3, 9.3**
+
+### Property 7: State mismatch rejects callback
+
+*For any* callback request where the `state` query parameter does not exactly match the stored state value (including missing, empty, or expired state), the service SHALL reject the request with HTTP 400 and error code `INVALID_STATE` without contacting the token endpoint.
+
+**Validates: Requirements 3.2, 3.6, 9.1**
+
+### Property 8: ID token signature validation
+
+*For any* JWT signed with a key present in the JWKS key set, signature validation SHALL pass. *For any* JWT signed with a key NOT present in the JWKS key set, signature validation SHALL fail and the callback SHALL return HTTP 401 with error code `INVALID_ID_TOKEN`.
+
+**Validates: Requirements 3.3**
+
+### Property 9: Nonce mismatch rejects token
+
+*For any* ID token where the `nonce` claim does not match the stored nonce value, the service SHALL reject the token and return HTTP 401 with error code `INVALID_ID_TOKEN`.
+
+**Validates: Requirements 3.4, 9.2**
+
+### Property 10: Audience and issuer validation
+
+*For any* ID token where the `aud` claim does not match the configured `client_id` OR the `iss` claim does not match `https://login.microsoftonline.com/{tenant_id}/v2.0`, the service SHALL reject the token with error code `INVALID_ID_TOKEN`.
+
+**Validates: Requirements 3.5**
+
+### Property 11: State entries deleted after callback processing
+
+*For any* callback execution (whether successful or failed), the `oauth_state_store` entry matching the request's state parameter SHALL be deleted, ensuring it cannot be reused.
+
+**Validates: Requirements 3.10**
+
+### Property 12: New federated user provisioning invariant
+
+*For any* valid ID token claims (sub, email, preferred_username/derived username, given_name, family_name) where no federated identity exists with that sub: the service SHALL create a user with `is_active=1`, null `password_hash`, a federated_identities record with `provider='entra-id'` and `subject=sub`, and SHALL assign the default viewer role.
+
+**Validates: Requirements 4.1, 4.2, 4.5**
+
+### Property 13: Existing federated user profile immutability
+
+*For any* returning user (federated identity already linked), calling the provisioning flow with different claim values (name, email) SHALL NOT modify the existing user record's `first_name`, `last_name`, or `email` fields.
+
+**Validates: Requirements 4.3**
+
+### Property 14: Username derivation from invalid preferred_username
+
+*For any* `preferred_username` that does not match the pattern `^[a-zA-Z0-9_]{3,50}$`, the service SHALL derive the username from the email local-part by replacing all characters not in `[a-zA-Z0-9_]` with underscores and truncating to 50 characters.
+
+**Validates: Requirements 4.7**
+
+### Property 15: Group-to-role synchronization correctness
+
+*For any* group mapping configuration and any `groups` claim array (with UUIDs in any case), the user SHALL end up with exactly the Pabawi roles whose group IDs are present in both the mapping keys (case-insensitive comparison) and the groups claim, plus any roles that were not part of the mapping (manually assigned). Roles previously assigned by the mapping whose group IDs are no longer in the claim SHALL be revoked.
+
+**Validates: Requirements 5.1, 5.2, 5.3**
+
+### Property 16: Authorization code single-use and TTL
+
+*For any* successfully generated auth code, the code SHALL have `expires_at` ≤ 60 seconds from creation. After a successful exchange, any subsequent exchange attempt with the same code SHALL be rejected. After the code expires, exchange SHALL also be rejected.
+
+**Validates: Requirements 6.2, 6.3, 6.4**
+
+### Property 17: Providers endpoint always includes local authentication
+
+*For any* application configuration state (Entra ID enabled or disabled, any combination of integrations), the `GET /api/auth/providers` response SHALL always contain `{ "local": true }`.
+
+**Validates: Requirements 11.2**
+
+## Testing Strategy
+
+### Property-Based Testing
+
+Property-based tests will use **fast-check** (already a project dependency) with a minimum of 100 iterations per property.
+
+Properties particularly well-suited for PBT in this feature:
+- **Config parsing properties (1–4)**: Generate random env var combinations
+- **PKCE correctness (6)**: Verify math relationship across many generations
+- **Token validation properties (7–10)**: Generate tokens with random claim permutations
+- **Username derivation (14)**: Generate random strings, verify transformation rules
+- **Group-to-role sync (15)**: Generate random mappings and group claims, verify set arithmetic
+- **Auth code single-use (16)**: Generate codes and attempt double-exchange
+
+Each property test will be tagged:
+```typescript
+// Feature: azure-entra-id-auth, Property 6: PKCE code_verifier/code_challenge correctness
+```
+
+### Unit Tests (Example-Based)
+
+- Provider discovery endpoint (11.1–11.5)
+- OAuth error parameter handling (3.9)
+- Email-match account linking (4.4)
+- Logout URL construction (8.2, 8.3)
+- Frontend component rendering based on provider state (7.5, 7.6)
+- Federation-only account local login rejection (7.4)
+
+### Integration Tests
+
+- Full OAuth flow with mocked Entra ID endpoints (token exchange, JWKS fetch)
+- JWKS cache fallback on endpoint failure (9.8)
+- Token exchange timeout behavior (3.1)
+- Database failure during provisioning — atomicity (4.8)
+- Audit logging verification (6.6)
+
+### Frontend Tests
+
+- Login page provider discovery and conditional rendering (10.1–10.7)
+- Auth callback handler — code extraction and token exchange (10.5, 10.6)
+- SSO logout redirect (8.4)
+
+### Test Organization
+
+```
+backend/test/
+├── unit/
+│ └── EntraIdService.test.ts # Unit tests for service logic
+├── properties/
+│ └── EntraIdAuth.property.test.ts # Property-based tests (fast-check)
+├── integration/
+│ └── EntraIdAuthFlow.test.ts # Full flow with mocked external endpoints
+└── middleware/
+ └── entraIdRoutes.test.ts # Route-level tests with supertest
+
+frontend/src/
+├── components/
+│ └── EntraIdLoginButton.test.ts # Component test
+└── lib/
+ └── entraIdAuth.svelte.test.ts # Callback handler test
+```
diff --git a/.kiro/specs/azure-entra-id-auth/requirements.md b/.kiro/specs/azure-entra-id-auth/requirements.md
new file mode 100644
index 00000000..02fe396e
--- /dev/null
+++ b/.kiro/specs/azure-entra-id-auth/requirements.md
@@ -0,0 +1,173 @@
+# Requirements Document
+
+## Introduction
+
+Azure Entra ID (formerly Azure AD) authentication for Pabawi, providing SSO/OAuth 2.0 + OpenID Connect as an alternative authentication method alongside the existing local username/password flow. Users authenticate via their organization's Azure tenant, with automatic provisioning on first login and group-to-role mapping from Entra ID claims.
+
+## Glossary
+
+- **Entra_ID_Provider**: The Azure Entra ID OpenID Connect identity provider that issues ID tokens and access tokens after user authentication
+- **Auth_Service**: The Pabawi backend AuthenticationService responsible for issuing and verifying Pabawi JWT tokens
+- **RBAC_Service**: The Pabawi permission system comprising UserService, RoleService, PermissionService, and GroupService
+- **Config_Service**: The Pabawi ConfigService that loads and validates environment-variable-based configuration via Zod
+- **SSO_Session**: A Pabawi session established via Entra ID authentication, represented by Pabawi-issued JWT access and refresh tokens
+- **ID_Token**: An OpenID Connect JWT issued by Entra ID containing user identity claims (sub, email, name, groups)
+- **Authorization_Code**: A short-lived code returned by Entra ID after user consent, exchangeable for tokens at the token endpoint
+- **Nonce**: A cryptographically random value bound to the authentication request and validated in the returned ID token to prevent replay attacks
+- **PKCE**: Proof Key for Code Exchange — a code_verifier/code_challenge mechanism that protects the authorization code flow against interception
+- **Federated_User**: A Pabawi user account linked to an Entra ID identity via the `sub` claim (subject identifier)
+- **Group_Claim**: An Entra ID token claim containing the user's Azure group memberships (object IDs or names)
+
+## Requirements
+
+### Requirement 1: Entra ID Provider Configuration
+
+**User Story:** As an administrator, I want to configure Azure Entra ID as an authentication provider, so that organization users can sign in with their corporate credentials.
+
+#### Acceptance Criteria
+
+1. IF `ENTRA_ID_ENABLED` is not set or is set to any value other than `"true"`, THEN THE Config_Service SHALL skip Entra ID configuration parsing and not require any other `ENTRA_ID_*` variables
+2. IF `ENTRA_ID_ENABLED` is set to `"true"`, THEN THE Config_Service SHALL require `ENTRA_ID_TENANT_ID`, `ENTRA_ID_CLIENT_ID`, `ENTRA_ID_CLIENT_SECRET`, and `ENTRA_ID_REDIRECT_URI` to be non-empty strings, and SHALL validate that `ENTRA_ID_REDIRECT_URI` is a valid URL
+3. IF `ENTRA_ID_ENABLED` is `"true"` and any required variable is undefined or an empty string, THEN THE Config_Service SHALL throw a configuration validation error at startup that includes the names of all missing variables
+4. THE Config_Service SHALL accept an optional `ENTRA_ID_SCOPES` variable containing a comma-separated list of scope strings, defaulting to `"openid,profile,email"`, and SHALL discard empty entries resulting from the split
+5. THE Config_Service SHALL accept an optional `ENTRA_ID_GROUP_MAPPING` variable containing a JSON object whose keys are Entra ID group identifier strings and whose values are Pabawi role name strings
+6. IF `ENTRA_ID_GROUP_MAPPING` is set and contains invalid JSON or does not parse to an object with string keys and string values, THEN THE Config_Service SHALL throw a configuration validation error at startup indicating the parsing failure
+
+### Requirement 2: OAuth 2.0 Authorization Code Flow Initiation
+
+**User Story:** As a user, I want to click a "Sign in with Microsoft" button and be redirected to my organization's Azure login page, so that I can authenticate using my corporate credentials.
+
+#### Acceptance Criteria
+
+1. WHEN a GET request is received at `/api/auth/entra-id/login`, THE Auth_Service SHALL respond with an HTTP 302 redirect to the Entra ID authorization endpoint (`https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize`) with a valid OpenID Connect authorization request
+2. THE Auth_Service SHALL include `response_type=code`, the configured `client_id`, the configured `redirect_uri`, scopes including at minimum `openid profile email`, a cryptographically random `state` parameter (minimum 32 bytes of entropy), and a cryptographically random `nonce` parameter (minimum 32 bytes of entropy) in the authorization URL query parameters
+3. THE Auth_Service SHALL use PKCE by generating a `code_verifier` of 43 to 128 characters per RFC 7636, computing a `code_challenge` using S256, and including `code_challenge` and `code_challenge_method=S256` in the authorization URL
+4. THE Auth_Service SHALL store the `state`, `nonce`, and `code_verifier` values server-side associated with the user's session, with a maximum time-to-live of 10 minutes, for validation during the callback
+5. IF `ENTRA_ID_ENABLED` is not `"true"`, THEN THE Auth_Service SHALL return HTTP 404 for the `/api/auth/entra-id/login` endpoint
+6. IF the Entra ID integration is enabled but required configuration values (`ENTRA_ID_TENANT_ID`, `ENTRA_ID_CLIENT_ID`, or `ENTRA_ID_REDIRECT_URI`) are missing or empty at request time, THEN THE Auth_Service SHALL return an HTTP 500 response with an error message indicating a server configuration problem without exposing the specific missing values
+
+### Requirement 3: Authorization Code Callback and Token Exchange
+
+**User Story:** As a user returning from Azure login, I want Pabawi to securely exchange the authorization code for tokens, so that my identity is verified without exposing credentials.
+
+#### Acceptance Criteria
+
+1. WHEN a GET request is received at `/api/auth/entra-id/callback` with a non-empty `code` query parameter and a non-empty `state` query parameter, THE Auth_Service SHALL exchange the authorization code for tokens at the Entra ID token endpoint using the stored `code_verifier`, completing the exchange within 10 seconds or treating it as a failure
+2. WHEN performing the authorization code exchange, THE Auth_Service SHALL validate that the `state` query parameter matches the value stored for the session prior to contacting the token endpoint
+3. THE Auth_Service SHALL validate the returned ID_Token signature against the Entra ID JWKS (JSON Web Key Set) endpoint keys
+4. THE Auth_Service SHALL validate that the `nonce` claim in the ID_Token matches the stored nonce value
+5. THE Auth_Service SHALL validate the `aud` (audience) claim matches the configured `client_id` and the `iss` (issuer) claim matches the expected Entra ID issuer URL for the configured tenant
+6. IF the `state` query parameter is missing, empty, or does not match the value stored for the session, THEN THE Auth_Service SHALL return HTTP 400 with error code `INVALID_STATE`
+7. IF the authorization code exchange fails due to a non-2xx response from the token endpoint or a network timeout, THEN THE Auth_Service SHALL return HTTP 401 with error code `TOKEN_EXCHANGE_FAILED`
+8. IF the ID_Token validation fails (signature, nonce, audience, or issuer), THEN THE Auth_Service SHALL return HTTP 401 with error code `INVALID_ID_TOKEN`
+9. IF the callback request contains an `error` query parameter instead of a `code` parameter, THEN THE Auth_Service SHALL return HTTP 401 with error code `AUTH_PROVIDER_ERROR` and include the `error` and `error_description` values from the query string in the response body
+10. WHEN the authorization code exchange and token validation complete (whether successfully or unsuccessfully), THE Auth_Service SHALL delete the stored `state`, `nonce`, and `code_verifier` values for the session so they cannot be reused
+
+### Requirement 4: User Provisioning on First SSO Login
+
+**User Story:** As a user signing in via Entra ID for the first time, I want Pabawi to automatically create my account from my Azure profile, so that I do not need a separate registration step.
+
+#### Acceptance Criteria
+
+1. WHEN the ID_Token is validated and no Federated_User exists with the matching `sub` claim, THE Auth_Service SHALL create a new user account using the `preferred_username`, `email`, `given_name`, and `family_name` claims from the ID_Token, storing an empty or null password hash to indicate the account is federation-only
+2. THE Auth_Service SHALL store the Entra ID `sub` claim as the federated identity link on the new user record
+3. WHEN the ID_Token is validated and a Federated_User already exists with the matching `sub` claim, THE Auth_Service SHALL use the existing user account for session creation without modifying the stored profile claims
+4. WHEN a local user account exists with the same email as the Entra ID user but no federated link, THE Auth_Service SHALL link the Entra ID identity to the existing local account rather than creating a duplicate, preserving the existing password hash so that local login remains available
+5. THE Auth_Service SHALL set newly provisioned Federated_User accounts as active by default and assign the same default role that locally created non-admin users receive
+6. IF the `email` or `preferred_username` claim is missing from the ID_Token, THEN THE Auth_Service SHALL reject the authentication attempt and return an error response indicating which required claims are absent
+7. IF the `preferred_username` claim does not conform to the username validation rules (3–50 characters, alphanumeric and underscores only), THEN THE Auth_Service SHALL derive the username from the local-part of the `email` claim, truncated to 50 characters, replacing disallowed characters with underscores
+8. IF account provisioning fails after token validation (database write error or uniqueness constraint violation on the derived username), THEN THE Auth_Service SHALL reject the authentication attempt and return an error response indicating that account creation failed, without creating a partial user record
+
+### Requirement 5: Group-to-Role Mapping
+
+**User Story:** As an administrator, I want Entra ID group memberships to map to Pabawi roles, so that access control is managed centrally in Azure.
+
+#### Acceptance Criteria
+
+1. WHEN `ENTRA_ID_GROUP_MAPPING` is configured and the ID_Token contains a `groups` claim, THE RBAC_Service SHALL assign Pabawi roles to the user by matching each group object ID in the claim against the mapping keys and assigning the corresponding role values, and SHALL revoke any previously-mapped roles whose group object IDs are no longer present in the current `groups` claim
+2. THE RBAC_Service SHALL match Entra ID group object IDs from the `groups` claim against keys in the `ENTRA_ID_GROUP_MAPPING` configuration using case-insensitive string comparison of UUID values
+3. WHEN the user holds Pabawi roles that were assigned independently of the group mapping (manually or via other mechanisms), THE RBAC_Service SHALL preserve those roles unchanged during SSO login group synchronization
+4. WHEN no `ENTRA_ID_GROUP_MAPPING` is configured, THE RBAC_Service SHALL not modify the user's existing Pabawi roles during SSO login
+5. IF `ENTRA_ID_GROUP_MAPPING` references a Pabawi role name that does not exist in the roles table, THEN THE RBAC_Service SHALL log a warning indicating the unresolvable role name and skip that mapping entry without failing the login
+6. IF `ENTRA_ID_GROUP_MAPPING` is configured but the ID_Token does not contain a `groups` claim, THEN THE RBAC_Service SHALL skip group-to-role synchronization and preserve the user's existing Pabawi roles unchanged
+7. IF `ENTRA_ID_GROUP_MAPPING` contains invalid JSON that cannot be parsed, THEN THE RBAC_Service SHALL log an error at startup indicating the malformed configuration and disable group-to-role synchronization until the configuration is corrected
+
+### Requirement 6: Pabawi Session Issuance After SSO Authentication
+
+**User Story:** As a user who authenticated via Entra ID, I want to receive Pabawi JWT tokens, so that subsequent API requests are authorized without re-contacting Azure.
+
+#### Acceptance Criteria
+
+1. WHEN user provisioning or lookup completes after Entra ID authentication, THE Auth_Service SHALL issue a Pabawi JWT access token containing the user's id, username, and Pabawi roles, and a refresh token, using the same signing algorithm, issuer, audience, and expiry configuration as locally-authenticated tokens
+2. WHEN issuing tokens for an SSO-authenticated user, THE Auth_Service SHALL generate a single-use authorization code with a maximum lifetime of 60 seconds, store it server-side mapped to the issued tokens, and redirect the user to the frontend application URL with the authorization code as a query parameter
+3. WHEN the frontend exchanges the authorization code at the token endpoint, THE Auth_Service SHALL return the mapped access token and refresh token, then immediately invalidate the authorization code so it cannot be reused
+4. IF the authorization code has expired or has already been exchanged, THEN THE Auth_Service SHALL reject the exchange request with an error response indicating the code is invalid and SHALL NOT issue tokens
+5. WHEN issuing tokens after successful SSO login, THE Auth_Service SHALL update the user's `last_login_at` timestamp to the current UTC time
+6. WHEN issuing tokens after successful SSO login, THE Auth_Service SHALL record the login via the AuditLoggingService with event type AUTH, action LOGIN_SUCCESS, and authentication method set to `entra-id`
+
+### Requirement 7: Coexistence with Local Authentication
+
+**User Story:** As an administrator, I want both local and SSO authentication to work simultaneously, so that users can choose their preferred login method.
+
+#### Acceptance Criteria
+
+1. WHILE `ENTRA_ID_ENABLED` is `"true"`, THE Auth_Service SHALL continue to accept local username/password authentication at `/api/auth/login`
+2. THE Auth_Service SHALL verify JWT tokens issued via either local or Entra ID authentication using the same middleware and grant equivalent access to protected API endpoints for tokens carrying identical role and permission claims
+3. WHEN a Federated_User has a password hash set, THE Auth_Service SHALL allow that user to authenticate via either local credentials or Entra ID
+4. IF a Federated_User has no password hash set and attempts local login, THEN THE Auth_Service SHALL reject the request with HTTP 401 and an error message indicating the account requires SSO authentication
+5. WHILE `ENTRA_ID_ENABLED` is `"true"`, THE frontend SHALL display both the "Sign in with Microsoft" button and the local login form on the login page
+6. WHILE `ENTRA_ID_ENABLED` is `"false"`, THE frontend SHALL display only the local login form and SHALL NOT render the "Sign in with Microsoft" button
+
+### Requirement 8: Logout Flow
+
+**User Story:** As a user who authenticated via SSO, I want to log out completely, so that my session is terminated in both Pabawi and optionally in Azure.
+
+#### Acceptance Criteria
+
+1. WHEN an authenticated user calls the `POST /api/auth/logout` endpoint, THE Auth_Service SHALL revoke the access token presented in the Authorization header and any associated refresh token, and return an HTTP 200 response
+2. IF the user's session was established via Entra ID authentication, THEN THE Auth_Service SHALL include an `entraIdLogoutUrl` field in the logout response body containing the Entra ID end-session endpoint URL with the `post_logout_redirect_uri` parameter set to the configured redirect destination and the `id_token_hint` parameter set to the user's stored ID token
+3. IF the user's session was established via local authentication (not via Entra ID), THEN THE Auth_Service SHALL omit the `entraIdLogoutUrl` field from the logout response body
+4. WHEN the frontend receives a logout response containing an `entraIdLogoutUrl` field, THE frontend SHALL redirect the user to that URL to complete single sign-out at Azure
+5. THE Auth_Service SHALL accept an optional `ENTRA_ID_POST_LOGOUT_REDIRECT_URI` configuration variable for the post-logout redirect destination, defaulting to the value of the application's base URL (the `HOST` and `PORT` configuration)
+6. IF the access token presented in the logout request is already revoked or invalid, THEN THE Auth_Service SHALL return an HTTP 401 response with error code `TOKEN_REVOKED`
+
+### Requirement 9: Security Controls
+
+**User Story:** As an administrator, I want the SSO integration to follow security best practices, so that the authentication flow is resistant to common attacks.
+
+#### Acceptance Criteria
+
+1. THE Auth_Service SHALL validate the `state` parameter on every callback request and reject with HTTP 400 if missing or mismatched
+2. THE Auth_Service SHALL validate the `nonce` in every ID_Token and reject with HTTP 401 if it does not match the stored value
+3. THE Auth_Service SHALL use PKCE (S256) on every authorization request to prevent authorization code interception
+4. THE Auth_Service SHALL validate ID_Token signatures using keys fetched from the Entra ID JWKS endpoint and cache the JWKS keys with a configurable TTL (default 24 hours)
+5. THE Auth_Service SHALL reject ID_Tokens where the `exp` claim indicates the token has expired, allowing a clock skew tolerance of no more than 5 minutes
+6. THE Auth_Service SHALL store `state`, `nonce`, and `code_verifier` values with a maximum lifetime of 10 minutes, and SHALL reject callback requests arriving after expiry with HTTP 400 and error code `SESSION_EXPIRED`
+7. THE Auth_Service SHALL never log or expose the `client_secret`, authorization codes, or Entra ID tokens in application logs
+8. IF the JWKS endpoint is unreachable (connection timeout exceeding 5 seconds or non-2xx response), THEN THE Auth_Service SHALL use cached keys if available and log a warning, or return HTTP 503 with error code `JWKS_UNAVAILABLE` if no cached keys exist
+
+### Requirement 10: Frontend SSO Integration
+
+**User Story:** As a user, I want the login page to clearly present SSO as an option, so that I can authenticate with my corporate account.
+
+#### Acceptance Criteria
+
+1. WHEN the frontend loads the login page, THE frontend SHALL call `/api/auth/providers` to determine which authentication methods are available
+2. IF the `/api/auth/providers` call fails or does not respond within 5 seconds, THEN THE frontend SHALL display only the local login form and show an error indication that SSO availability could not be determined
+3. WHEN the providers response indicates Entra ID is enabled, THE frontend SHALL display a "Sign in with Microsoft" button using the Microsoft identity branding guidelines
+4. WHEN the user clicks "Sign in with Microsoft", THE frontend SHALL redirect to `/api/auth/entra-id/login`
+5. WHEN the frontend receives the redirect back from the SSO callback with an authorization code in the URL query parameter, THE frontend SHALL POST the code to `/api/auth/entra-id/token` and upon receiving a successful response, store the returned access token, refresh token, and user object in auth state and navigate to the authenticated landing page
+6. IF the token exchange request to `/api/auth/entra-id/token` returns a non-success response, THEN THE frontend SHALL display an error message indicating authentication failed, retain the login page, and not store any tokens
+7. WHEN the providers response does not indicate Entra ID is enabled, THE frontend SHALL display only the local login form without SSO options
+
+### Requirement 11: Authentication Provider Discovery
+
+**User Story:** As a frontend application, I want to discover available authentication methods, so that I can render the appropriate login options.
+
+#### Acceptance Criteria
+
+1. WHEN a GET request is received at `/api/auth/providers`, THE Auth_Service SHALL return a 200 response with a JSON object listing available authentication methods
+2. THE Auth_Service SHALL include `{ "local": true }` in the providers response at all times
+3. IF `ENTRA_ID_ENABLED` is set to `"true"`, THEN THE Auth_Service SHALL include `{ "entraId": { "enabled": true, "name": "Microsoft Entra ID" } }` in the providers response
+4. IF `ENTRA_ID_ENABLED` is not set to `"true"`, THEN THE Auth_Service SHALL omit the `entraId` key from the providers response
+5. THE `/api/auth/providers` endpoint SHALL be accessible without authentication
diff --git a/.kiro/specs/azure-entra-id-auth/tasks.md b/.kiro/specs/azure-entra-id-auth/tasks.md
new file mode 100644
index 00000000..d031cc75
--- /dev/null
+++ b/.kiro/specs/azure-entra-id-auth/tasks.md
@@ -0,0 +1,243 @@
+# Implementation Plan: Azure Entra ID Authentication
+
+## Overview
+
+Implements Azure Entra ID (OpenID Connect) as a federated authentication provider using the OAuth 2.0 Authorization Code Flow with PKCE. The implementation adds a new `EntraIdService`, database migration, Express routes, and frontend SSO components while maintaining full backward compatibility with local authentication.
+
+## Tasks
+
+- [x] 1. Database migration and configuration schema
+ - [x] 1.1 Create database migration `016_entra_id_auth.sql`
+ - Create `federated_identities` table with columns: id, user_id, provider, subject, issuer, email, id_token, created_at, updated_at
+ - Create `oauth_state_store` table with columns: state, nonce, code_verifier, created_at, expires_at
+ - Create `oauth_auth_codes` table with columns: code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged
+ - Add indexes: idx_federated_identities_user, idx_federated_identities_lookup, idx_oauth_state_expires, idx_oauth_auth_codes_expires
+ - Add FOREIGN KEY on federated_identities.user_id → users(id) ON DELETE CASCADE
+ - Add UNIQUE constraint on (provider, subject) in federated_identities
+ - _Requirements: 4.1, 4.2, 6.2, 9.6_
+
+ - [x] 1.2 Add `EntraIdConfigSchema` to `backend/src/config/schema.ts`
+ - Define Zod schema with: enabled (boolean, default false), tenantId (string min 1), clientId (string min 1), clientSecret (string min 1), redirectUri (string url), scopes (array of strings, default ["openid","profile","email"]), groupMapping (record string→string, nullable, default null), postLogoutRedirectUri (string url, optional), jwksCacheTtlMs (number int positive, default 86400000)
+ - Export `EntraIdConfigSchema` and inferred `EntraIdConfig` type
+ - Add `entraId` optional field to `AppConfigSchema`
+ - _Requirements: 1.1, 1.2, 1.4, 1.5, 1.6_
+
+ - [x] 1.3 Add Entra ID configuration parsing to `ConfigService.ts`
+ - Add `parseEntraIdConfig()` private method following existing integration parsing pattern
+ - Parse `ENTRA_ID_ENABLED`, `ENTRA_ID_TENANT_ID`, `ENTRA_ID_CLIENT_ID`, `ENTRA_ID_CLIENT_SECRET`, `ENTRA_ID_REDIRECT_URI`
+ - Parse optional `ENTRA_ID_SCOPES` (comma-separated, discard empty entries, default to openid,profile,email)
+ - Parse optional `ENTRA_ID_GROUP_MAPPING` (JSON Record, throw on invalid JSON)
+ - Parse optional `ENTRA_ID_POST_LOGOUT_REDIRECT_URI`, `ENTRA_ID_JWKS_CACHE_TTL_MS`
+ - Skip all parsing when `ENTRA_ID_ENABLED !== "true"`; throw with all missing variable names when enabled but required vars absent
+ - Add `getEntraIdConfig()` public accessor method
+ - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
+
+ - [x] 1.4 Write property tests for configuration parsing (Properties 1–4)
+ - **Property 1: Non-"true" ENTRA_ID_ENABLED skips config validation**
+ - **Property 2: Missing required variables produce comprehensive error**
+ - **Property 3: Scope parsing discards empty entries**
+ - **Property 4: Group mapping JSON round-trip**
+ - **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6**
+
+- [x] 2. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 3. Core EntraIdService implementation
+ - [x] 3.1 Create `backend/src/services/EntraIdService.ts` — authorization URL generation
+ - Implement class with constructor accepting DatabaseAdapter, EntraIdConfig, AuthenticationService, UserService, RoleService, AuditLoggingService, LoggerService
+ - Implement `generateAuthorizationUrl()`: generate state (32 bytes crypto random), nonce (32 bytes), code_verifier (43–128 chars per RFC 7636), compute code_challenge via SHA256+BASE64URL, store state/nonce/code_verifier in `oauth_state_store` with 10-minute TTL, return authorization URL with all required query parameters
+ - Implement `cleanupExpiredState()`: delete expired entries from oauth_state_store
+ - Implement `getProviderInfo()`: return `{ enabled: true, name: "Microsoft Entra ID" }`
+ - _Requirements: 2.1, 2.2, 2.3, 2.4, 9.3, 9.6_
+
+ - [x] 3.2 Write property tests for authorization URL and PKCE (Properties 5–6)
+ - **Property 5: Authorization URL contains all required parameters**
+ - **Property 6: PKCE code_verifier/code_challenge correctness**
+ - **Validates: Requirements 2.2, 2.3, 9.3**
+
+ - [x] 3.3 Implement `handleCallback()` in EntraIdService
+ - Validate state parameter against stored entry (reject if missing/expired with INVALID_STATE)
+ - Delete state entry immediately (one-time use, even on failure)
+ - Exchange authorization code at `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` with code_verifier, client_id, client_secret, redirect_uri (10s timeout)
+ - Fetch JWKS keys from `https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys` (cache with configurable TTL, fallback to cache on failure)
+ - Validate ID token: verify RS256 signature against JWKS keys, validate nonce, aud (=client_id), iss (=`https://login.microsoftonline.com/{tenant}/v2.0`), exp (5min skew max)
+ - On validation failure, return typed errors (INVALID_STATE, TOKEN_EXCHANGE_FAILED, INVALID_ID_TOKEN, AUTH_PROVIDER_ERROR, MISSING_CLAIMS)
+ - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 9.1, 9.2, 9.4, 9.5, 9.7, 9.8_
+
+ - [x] 3.4 Write property tests for callback validation (Properties 7–11)
+ - **Property 7: State mismatch rejects callback**
+ - **Property 8: ID token signature validation**
+ - **Property 9: Nonce mismatch rejects token**
+ - **Property 10: Audience and issuer validation**
+ - **Property 11: State entries deleted after callback processing**
+ - **Validates: Requirements 3.2, 3.3, 3.4, 3.5, 3.6, 3.10, 9.1, 9.2**
+
+- [x] 4. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 5. User provisioning and group-to-role synchronization
+ - [x] 5.1 Add `createFederatedUser()` and `linkFederatedIdentity()` to `UserService.ts`
+ - `createFederatedUser(claims)`: create user with null password_hash, is_active=1, derive username from preferred_username or email local-part (replace non-[a-zA-Z0-9_] with underscores, truncate to 50 chars), assign default viewer role, create federated_identities record with provider='entra-id' and subject=sub
+ - `linkFederatedIdentity(userId, provider, subject, issuer, email)`: insert into federated_identities linking existing user to Entra ID identity
+ - `findByFederatedIdentity(provider, subject)`: lookup user by federated identity
+ - `findByEmail(email)`: public wrapper around existing private getUserByEmail
+ - Handle uniqueness constraint violations on derived username with retry/error
+ - _Requirements: 4.1, 4.2, 4.4, 4.5, 4.7, 4.8_
+
+ - [x] 5.2 Implement user provisioning logic in EntraIdService
+ - After ID token validation, look up federated_identities by (provider='entra-id', subject=sub)
+ - If found: use existing user, do NOT update profile claims (immutability)
+ - If not found: check users by email; if email match exists, link federated identity to existing account (preserve password_hash)
+ - If no match: create new federated user via UserService.createFederatedUser()
+ - Reject if email or preferred_username claims missing (MISSING_CLAIMS error)
+ - Validate username derivation when preferred_username doesn't match ^[a-zA-Z0-9_]{3,50}$
+ - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8_
+
+ - [x] 5.3 Write property tests for user provisioning (Properties 12–14)
+ - **Property 12: New federated user provisioning invariant**
+ - **Property 13: Existing federated user profile immutability**
+ - **Property 14: Username derivation from invalid preferred_username**
+ - **Validates: Requirements 4.1, 4.2, 4.3, 4.5, 4.7**
+
+ - [x] 5.4 Implement group-to-role synchronization in EntraIdService
+ - If groupMapping is configured and groups claim is present: match group object IDs case-insensitively against mapping keys
+ - Assign corresponding Pabawi roles for matched groups
+ - Revoke previously-mapped roles whose group IDs are no longer in the claim
+ - Preserve roles assigned independently of the mapping (manually assigned)
+ - If mapping references non-existent Pabawi role: log warning, skip entry
+ - If no groups claim present or no mapping configured: skip sync entirely, preserve existing roles
+ - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_
+
+ - [x] 5.5 Write property test for group-to-role synchronization (Property 15)
+ - **Property 15: Group-to-role synchronization correctness**
+ - **Validates: Requirements 5.1, 5.2, 5.3**
+
+- [x] 6. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 7. Session issuance and routes
+ - [x] 7.1 Implement `exchangeAuthCode()` and session issuance in EntraIdService
+ - After user provisioning: generate Pabawi JWT access + refresh tokens via AuthenticationService
+ - Generate single-use authorization code (crypto random, 60s TTL), store in oauth_auth_codes mapped to tokens + userId + id_token
+ - `exchangeAuthCode(code)`: lookup code, verify not expired and not already exchanged, mark as exchanged, return tokens + user DTO
+ - Reject expired/exchanged codes with INVALID_AUTH_CODE error
+ - Update user's last_login_at timestamp
+ - Record audit log via AuditLoggingService (AUTH, LOGIN_SUCCESS, method=entra-id)
+ - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6_
+
+ - [x] 7.2 Write property test for authorization code single-use and TTL (Property 16)
+ - **Property 16: Authorization code single-use and TTL**
+ - **Validates: Requirements 6.2, 6.3, 6.4**
+
+ - [x] 7.3 Create `backend/src/routes/entraIdAuth.ts` route factory
+ - Export `createEntraIdAuthRouter(databaseService, container)` following existing pattern
+ - `GET /api/auth/entra-id/login`: call EntraIdService.generateAuthorizationUrl(), respond with 302 redirect
+ - `GET /api/auth/entra-id/callback`: handle OAuth callback — validate state, exchange code, provision user, issue tokens, redirect to frontend with auth code
+ - `POST /api/auth/entra-id/token`: exchange single-use auth code for Pabawi JWT pair (returns { token, refreshToken, user })
+ - Return 404 for all endpoints when Entra ID is not enabled
+ - Return 500 with SERVER_CONFIGURATION_ERROR if config values missing at request time
+ - Handle error parameter from Entra ID (AUTH_PROVIDER_ERROR)
+ - _Requirements: 2.1, 2.5, 2.6, 3.1, 3.6, 3.7, 3.8, 3.9, 6.2, 6.3, 6.4_
+
+ - [x] 7.4 Add `/api/auth/providers` endpoint and modify logout in `auth.ts`
+ - Add `GET /api/auth/providers` endpoint (no auth required): return `{ local: true }` always, add `{ entraId: { enabled: true, name: "Microsoft Entra ID" } }` when enabled
+ - Modify `POST /api/auth/logout`: after token revocation, check if user session was established via Entra ID (lookup federated_identities + stored id_token), include `entraIdLogoutUrl` in response when applicable
+ - Implement `buildLogoutUrl(idToken)` in EntraIdService: construct Entra ID end-session URL with post_logout_redirect_uri and id_token_hint
+ - _Requirements: 7.4, 8.1, 8.2, 8.3, 8.5, 8.6, 11.1, 11.2, 11.3, 11.4, 11.5_
+
+ - [x] 7.5 Write property test for providers endpoint (Property 17)
+ - **Property 17: Providers endpoint always includes local authentication**
+ - **Validates: Requirements 11.2**
+
+- [x] 8. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 9. DI container registration and server wiring
+ - [x] 9.1 Register EntraIdService in `DIContainer.ts` and wire in `server.ts`
+ - Add `entraId` as optional key in `ServiceRegistry` interface
+ - In server.ts: conditionally instantiate EntraIdService when Entra ID config is enabled
+ - Mount entraIdAuth router at `/api/auth/entra-id` (conditional on config)
+ - Ensure EntraIdService receives all required dependencies: DatabaseAdapter, EntraIdConfig, AuthenticationService, UserService, RoleService, AuditLoggingService, LoggerService
+ - Set up periodic cleanup of expired state entries (e.g., every 5 minutes)
+ - _Requirements: 2.5, 7.1, 7.2_
+
+ - [x] 9.2 Write integration tests for the full OAuth flow
+ - Test full authorization URL generation → callback → token exchange flow with mocked Entra ID endpoints
+ - Test JWKS cache fallback on endpoint failure
+ - Test token exchange timeout behavior (>10s)
+ - Test database failure during provisioning (atomicity)
+ - Test audit logging verification
+ - Test federation-only account local login rejection (HTTP 401)
+ - Test coexistence: local auth continues working when Entra ID enabled
+ - _Requirements: 2.1, 3.1, 7.1, 7.2, 7.3, 7.4, 9.4, 9.8_
+
+- [x] 10. Frontend SSO integration
+ - [x] 10.1 Create `frontend/src/lib/entraIdAuth.svelte.ts`
+ - Reactive state for provider availability (call `/api/auth/providers` on init)
+ - Handle provider discovery failure (5s timeout → show only local login)
+ - Expose `isEntraIdEnabled` derived state and `entraIdProviderName` state
+ - Implement callback handler: extract `code` from URL query parameter, POST to `/api/auth/entra-id/token`, store tokens in auth state, navigate to landing page
+ - Handle token exchange errors: display error message, retain login page
+ - _Requirements: 10.1, 10.2, 10.5, 10.6, 10.7_
+
+ - [x] 10.2 Create `frontend/src/components/EntraIdLoginButton.svelte`
+ - Microsoft-branded "Sign in with Microsoft" button following Microsoft identity branding guidelines
+ - On click: redirect to `/api/auth/entra-id/login`
+ - Accessible: proper button semantics, ARIA label, keyboard interaction
+ - _Requirements: 10.3, 10.4_
+
+ - [x] 10.3 Modify `frontend/src/pages/Login.svelte` for SSO support
+ - Import and use entraIdAuth state module
+ - Conditionally render EntraIdLoginButton when `isEntraIdEnabled` is true
+ - Show only local login form when Entra ID is not enabled
+ - Show error indication when provider discovery fails
+ - Handle callback redirect: detect `?code=` in URL, trigger token exchange flow
+ - Implement SSO logout redirect when logout response contains `entraIdLogoutUrl`
+ - _Requirements: 7.5, 7.6, 8.4, 10.1, 10.2, 10.3, 10.5, 10.6, 10.7_
+
+ - [x] 10.4 Write frontend tests
+ - Test EntraIdLoginButton renders correctly with Microsoft branding
+ - Test Login.svelte conditionally renders SSO button based on provider state
+ - Test callback handler extracts code and exchanges for tokens
+ - Test error state when provider discovery or token exchange fails
+ - Test SSO logout redirect behavior
+ - _Requirements: 7.5, 7.6, 10.1, 10.2, 10.3, 10.5, 10.6_
+
+- [x] 11. Final checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+## Notes
+
+- Tasks marked with `*` are optional and can be skipped for faster MVP
+- Each task references specific requirements for traceability
+- Checkpoints ensure incremental validation
+- Property tests validate the 17 correctness properties from the design document using fast-check
+- Unit tests validate specific examples and edge cases
+- The project uses TypeScript strict mode; all code must pass `tsc --noEmit`, ESLint, and vitest
+- Database columns use `snake_case` with `AS "camelCase"` aliases in SELECT queries (per database conventions)
+- Phased execution: each task group touches ≤5 files to comply with workspace steering rules
+- Never log client_secret, authorization codes, or tokens (security requirement 9.7)
+
+## Task Dependency Graph
+
+```json
+{
+ "waves": [
+ { "id": 0, "tasks": ["1.1", "1.2"] },
+ { "id": 1, "tasks": ["1.3", "1.4"] },
+ { "id": 2, "tasks": ["3.1"] },
+ { "id": 3, "tasks": ["3.2", "3.3"] },
+ { "id": 4, "tasks": ["3.4", "5.1"] },
+ { "id": 5, "tasks": ["5.2", "5.4"] },
+ { "id": 6, "tasks": ["5.3", "5.5"] },
+ { "id": 7, "tasks": ["7.1"] },
+ { "id": 8, "tasks": ["7.2", "7.3"] },
+ { "id": 9, "tasks": ["7.4", "7.5"] },
+ { "id": 10, "tasks": ["9.1"] },
+ { "id": 11, "tasks": ["9.2", "10.1"] },
+ { "id": 12, "tasks": ["10.2"] },
+ { "id": 13, "tasks": ["10.3"] },
+ { "id": 14, "tasks": ["10.4"] }
+ ]
+}
+```
diff --git a/AGENTS.md b/AGENTS.md
index e957d81c..87ecc53a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,9 +95,9 @@ The frontend uses **Svelte 5 runes** throughout (`$state()`, `$effect()`, `$deri
### Configuration
-All configuration is via `backend/.env`. Run `scripts/setup.sh` for interactive setup. Key variable groups: `PORT/HOST/LOG_LEVEL`, `JWT_SECRET` (required), `PABAWI_LIFECYCLE_TOKEN` (optional), `BOLT_*`, `PUPPETDB_*`, `PUPPETSERVER_*`, `HIERA_*`, `ANSIBLE_*`, `SSH_*`, `AWS_*`, `AZURE_*`, `PROXMOX_*`, `COMMAND_WHITELIST*`, `CACHE_*`, `CONCURRENT_EXECUTION_LIMIT`, `MCP_ENABLED`.
+All configuration is via `backend/.env`. Run `scripts/setup.sh` for interactive setup. Key variable groups: `PORT/HOST/LOG_LEVEL`, `JWT_SECRET` (required), `PABAWI_LIFECYCLE_TOKEN` (optional), `ENTRA_ID_*` (SSO), `BOLT_*`, `PUPPETDB_*`, `PUPPETSERVER_*`, `HIERA_*`, `ANSIBLE_*`, `SSH_*`, `AWS_*`, `AZURE_*`, `PROXMOX_*`, `COMMAND_WHITELIST*`, `CACHE_*`, `CONCURRENT_EXECUTION_LIMIT`, `MCP_ENABLED`.
-See `docs/configuration.md` for the full reference. Other useful docs: `docs/mcp.md` (MCP setup and tools), `docs/permissions-rbac.md` (RBAC model), `docs/architecture.md` (system overview), `docs/api.md` (REST API reference), `docs/integrations/` (per-integration guides).
+See `docs/configuration.md` for the full reference. Other useful docs: `docs/mcp.md` (MCP setup and tools), `docs/permissions-rbac.md` (RBAC model), `docs/architecture.md` (system overview), `docs/api.md` (REST API reference), `docs/integrations/` (per-integration guides), `docs/integrations/entra-id.md` (Azure Entra ID SSO setup).
### Testing conventions
diff --git a/backend/.env.example b/backend/.env.example
index 4774720e..b7e057b9 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -180,6 +180,40 @@ PROXMOX_SSL_REJECT_UNAUTHORIZED=true
# PROXMOX_TIMEOUT=30000
# PROXMOX_PRIORITY=7
+# -----------------------------------------------------------------------------
+# Azure Entra ID SSO (optional)
+# -----------------------------------------------------------------------------
+ENTRA_ID_ENABLED=false
+# ENTRA_ID_TENANT_ID=12345678-abcd-efgh-ijkl-123456789012
+# ENTRA_ID_CLIENT_ID=abcdef01-2345-6789-abcd-ef0123456789
+# ENTRA_ID_CLIENT_SECRET= # pragma: allowlist secret
+# ENTRA_ID_REDIRECT_URI=https://pabawi.example.com/api/auth/entra-id/callback
+# Scopes (comma-separated, default: openid,profile,email)
+# ENTRA_ID_SCOPES=openid,profile,email
+# Group-to-role mapping (JSON object: {"azure-group-id": "pabawi-role-name"})
+# ENTRA_ID_GROUP_MAPPING={"e5f3a1b2-...":"administrator","c7d8e9f0-...":"operator"}
+# Post-logout redirect URI (defaults to app base URL)
+# ENTRA_ID_POST_LOGOUT_REDIRECT_URI=https://pabawi.example.com
+# JWKS cache TTL in ms (default: 86400000 = 24 hours)
+# ENTRA_ID_JWKS_CACHE_TTL_MS=86400000
+
+# -----------------------------------------------------------------------------
+# Azure Entra ID SSO (optional)
+# -----------------------------------------------------------------------------
+ENTRA_ID_ENABLED=false
+# ENTRA_ID_TENANT_ID=12345678-abcd-efgh-ijkl-123456789012
+# ENTRA_ID_CLIENT_ID=abcdef01-2345-6789-abcd-ef0123456789
+# ENTRA_ID_CLIENT_SECRET= # pragma: allowlist secret
+# ENTRA_ID_REDIRECT_URI=https://pabawi.example.com/api/auth/entra-id/callback
+# Scopes (comma-separated, default: openid,profile,email)
+# ENTRA_ID_SCOPES=openid,profile,email
+# Group-to-role mapping (JSON object: {"azure-group-id": "pabawi-role-name"})
+# ENTRA_ID_GROUP_MAPPING={"e5f3a1b2-...":"administrator","c7d8e9f0-...":"operator"}
+# Post-logout redirect URI (defaults to app base URL)
+# ENTRA_ID_POST_LOGOUT_REDIRECT_URI=https://pabawi.example.com
+# JWKS cache TTL in ms (default: 86400000 = 24 hours)
+# ENTRA_ID_JWKS_CACHE_TTL_MS=86400000
+
# -----------------------------------------------------------------------------
# AWS integration (optional)
# -----------------------------------------------------------------------------
diff --git a/backend/src/config/ConfigService.ts b/backend/src/config/ConfigService.ts
index f5f51bb3..a75937c2 100644
--- a/backend/src/config/ConfigService.ts
+++ b/backend/src/config/ConfigService.ts
@@ -2,6 +2,7 @@ import { config as loadDotenv } from "dotenv";
import {
AppConfigSchema,
type AppConfig,
+ type EntraIdConfig,
type WhitelistConfig,
} from "./schema";
import { z } from "zod";
@@ -13,6 +14,7 @@ import { parseJson } from "../utils/json";
*/
export class ConfigService {
private config: AppConfig;
+ private entraIdConfig: EntraIdConfig | null = null;
constructor() {
// Load .env file only if not in test environment
@@ -610,6 +612,101 @@ export class ConfigService {
return integrations;
}
+ /**
+ * Parse Entra ID (Azure AD) authentication configuration from environment variables.
+ * Skips all parsing when ENTRA_ID_ENABLED is not "true".
+ * Throws with all missing variable names when enabled but required vars are absent.
+ */
+ private parseEntraIdConfig(): EntraIdConfig | null {
+ if (process.env.ENTRA_ID_ENABLED !== "true") {
+ return null;
+ }
+
+ // Collect all missing required variables
+ const missing: string[] = [];
+ const tenantId = process.env.ENTRA_ID_TENANT_ID;
+ const clientId = process.env.ENTRA_ID_CLIENT_ID;
+ const clientSecret = process.env.ENTRA_ID_CLIENT_SECRET; // pragma: allowlist secret
+ const redirectUri = process.env.ENTRA_ID_REDIRECT_URI;
+
+ if (!tenantId) missing.push("ENTRA_ID_TENANT_ID");
+ if (!clientId) missing.push("ENTRA_ID_CLIENT_ID");
+ if (!clientSecret) missing.push("ENTRA_ID_CLIENT_SECRET"); // pragma: allowlist secret
+ if (!redirectUri) missing.push("ENTRA_ID_REDIRECT_URI");
+
+ if (missing.length > 0) {
+ throw new Error(
+ `Entra ID authentication is enabled but required configuration variables are missing: ${missing.join(", ")}`,
+ );
+ }
+
+ // After the guard above, these are guaranteed non-empty strings.
+ // Narrow explicitly so TypeScript tracks the guarantee without assertions.
+ if (!tenantId || !clientId || !clientSecret || !redirectUri) {
+ // Unreachable — the missing[] guard above already throws.
+ throw new Error("Unreachable: required Entra ID variables validated");
+ }
+
+ // Parse optional scopes (comma-separated, discard empty entries)
+ const scopesRaw = process.env.ENTRA_ID_SCOPES;
+ const scopes = scopesRaw
+ ? scopesRaw.split(",").map((s) => s.trim()).filter(Boolean)
+ : ["openid", "profile", "email"];
+
+ // Parse optional group mapping (JSON Record)
+ let groupMapping: Record | null = null;
+ const groupMappingRaw = process.env.ENTRA_ID_GROUP_MAPPING;
+ if (groupMappingRaw) {
+ try {
+ const parsed = parseJson(groupMappingRaw);
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ Array.isArray(parsed)
+ ) {
+ throw new Error("must be a JSON object");
+ }
+ // Validate all keys and values are strings
+ for (const [key, value] of Object.entries(parsed as Record)) {
+ if (typeof key !== "string" || typeof value !== "string") {
+ throw new Error("all keys and values must be strings");
+ }
+ }
+ groupMapping = parsed as Record;
+ } catch (error) {
+ throw new Error(
+ `ENTRA_ID_GROUP_MAPPING contains invalid JSON: ${error instanceof Error ? error.message : "parse error"}`,
+ );
+ }
+ }
+
+ // Parse optional post-logout redirect URI
+ const postLogoutRedirectUri =
+ process.env.ENTRA_ID_POST_LOGOUT_REDIRECT_URI ?? undefined;
+
+ // Parse optional JWKS cache TTL
+ const jwksCacheTtlMs = process.env.ENTRA_ID_JWKS_CACHE_TTL_MS
+ ? parseInt(process.env.ENTRA_ID_JWKS_CACHE_TTL_MS, 10)
+ : undefined;
+
+ // At this point tenantId, clientId, clientSecret, redirectUri are guaranteed non-empty
+ // (we threw above if any were falsy)
+ const config: EntraIdConfig = {
+ enabled: true,
+ tenantId,
+ clientId,
+ clientSecret, // pragma: allowlist secret
+ redirectUri,
+ scopes,
+ groupMapping,
+ postLogoutRedirectUri,
+ jwksCacheTtlMs: jwksCacheTtlMs ?? 86400000,
+ };
+
+ this.entraIdConfig = config;
+ return config;
+ }
+
/**
* Load configuration from environment variables with validation
*/
@@ -728,6 +825,7 @@ export class ConfigService {
ui,
mcpEnabled: process.env.MCP_ENABLED === "true",
mcpAuthToken: process.env.MCP_AUTH_TOKEN ?? undefined,
+ entraId: this.parseEntraIdConfig() ?? undefined,
};
// Validate with Zod schema
@@ -981,4 +1079,12 @@ export class ConfigService {
}
return null;
}
+
+ /**
+ * Get Entra ID authentication configuration if enabled.
+ * Returns null when ENTRA_ID_ENABLED is not "true".
+ */
+ public getEntraIdConfig(): EntraIdConfig | null {
+ return this.entraIdConfig;
+ }
}
diff --git a/backend/src/config/schema.ts b/backend/src/config/schema.ts
index 0a34e0c6..f253508c 100644
--- a/backend/src/config/schema.ts
+++ b/backend/src/config/schema.ts
@@ -376,6 +376,23 @@ export const CheckmkConfigSchema = z.object({
export type CheckmkConfig = z.infer;
+/**
+ * Azure Entra ID (OpenID Connect) authentication provider configuration schema
+ */
+export const EntraIdConfigSchema = z.object({
+ enabled: z.boolean().default(false),
+ tenantId: z.string().min(1),
+ clientId: z.string().min(1),
+ clientSecret: z.string().min(1),
+ redirectUri: z.string().url(),
+ scopes: z.array(z.string()).default(["openid", "profile", "email"]),
+ groupMapping: z.record(z.string(), z.string()).nullable().default(null),
+ postLogoutRedirectUri: z.string().url().optional(),
+ jwksCacheTtlMs: z.number().int().positive().default(86400000), // 24 hours
+});
+
+export type EntraIdConfig = z.infer;
+
/**
* Integrations configuration schema
*/
@@ -434,6 +451,7 @@ export const AppConfigSchema = z.object({
ui: UIConfigSchema.default({ showHomePageRunChart: true }),
mcpEnabled: z.boolean().default(false),
mcpAuthToken: z.string().optional(),
+ entraId: EntraIdConfigSchema.optional(),
});
export type AppConfig = z.infer;
diff --git a/backend/src/container/DIContainer.ts b/backend/src/container/DIContainer.ts
index 6f02db04..1e312998 100644
--- a/backend/src/container/DIContainer.ts
+++ b/backend/src/container/DIContainer.ts
@@ -1,11 +1,13 @@
import { LoggerService } from "../services/LoggerService";
import { ExpertModeService } from "../services/ExpertModeService";
import { ConfigService } from "../config/ConfigService";
+import type { EntraIdService } from "../services/EntraIdService";
export interface ServiceRegistry {
logger: LoggerService;
expertMode: ExpertModeService;
config: ConfigService;
+ entraId?: EntraIdService;
}
export class DIContainer {
diff --git a/backend/src/database/migrations/016_entra_id_auth.sql b/backend/src/database/migrations/016_entra_id_auth.sql
new file mode 100644
index 00000000..fb642147
--- /dev/null
+++ b/backend/src/database/migrations/016_entra_id_auth.sql
@@ -0,0 +1,48 @@
+-- Migration 016: Entra ID federated authentication support
+-- Adds tables for federated identity linking, OAuth state management,
+-- and single-use authorization codes for the frontend token exchange flow.
+-- Requirements: 4.1, 4.2, 6.2, 9.6
+
+-- Federated identity links: maps external IdP subjects to Pabawi users
+CREATE TABLE IF NOT EXISTS federated_identities (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ provider TEXT NOT NULL, -- 'entra-id'
+ subject TEXT NOT NULL, -- Entra ID 'sub' claim (unique per tenant+user)
+ issuer TEXT NOT NULL, -- Token issuer URL
+ email TEXT, -- Email from IdP (informational, not authoritative)
+ id_token TEXT, -- Last ID token (for logout id_token_hint)
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(provider, subject)
+);
+
+CREATE INDEX IF NOT EXISTS idx_federated_identities_user ON federated_identities(user_id);
+CREATE INDEX IF NOT EXISTS idx_federated_identities_lookup ON federated_identities(provider, subject);
+
+-- OAuth state store: PKCE + state + nonce for in-flight authorization requests
+CREATE TABLE IF NOT EXISTS oauth_state_store (
+ state TEXT PRIMARY KEY,
+ nonce TEXT NOT NULL,
+ code_verifier TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_oauth_state_expires ON oauth_state_store(expires_at);
+
+-- Single-use authorization codes for frontend token delivery
+CREATE TABLE IF NOT EXISTS oauth_auth_codes (
+ code TEXT PRIMARY KEY,
+ access_token TEXT NOT NULL,
+ refresh_token TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ id_token TEXT,
+ auth_method TEXT NOT NULL DEFAULT 'entra-id',
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ exchanged INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires ON oauth_auth_codes(expires_at);
diff --git a/backend/src/database/migrations/017_nullable_password_hash.sql b/backend/src/database/migrations/017_nullable_password_hash.sql
new file mode 100644
index 00000000..7839302f
--- /dev/null
+++ b/backend/src/database/migrations/017_nullable_password_hash.sql
@@ -0,0 +1,39 @@
+-- Migration 017: Make password_hash nullable for federated (SSO) users
+--
+-- Federated users authenticated via Entra ID (or other OIDC providers)
+-- have no local password. The design stores NULL in password_hash for
+-- these accounts. SQLite does not support ALTER COLUMN, so we recreate
+-- the users table with password_hash TEXT (nullable).
+
+-- Step 1: Create new table without NOT NULL on password_hash
+CREATE TABLE users_new (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT, -- NULL for federation-only accounts
+ first_name TEXT NOT NULL,
+ last_name TEXT NOT NULL,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ last_login_at TEXT
+);
+
+-- Step 2: Copy all existing data
+INSERT INTO users_new (id, username, email, password_hash, first_name, last_name, is_active, is_admin, created_at, updated_at, last_login_at)
+SELECT id, username, email, password_hash, first_name, last_name, is_active, is_admin, created_at, updated_at, last_login_at
+FROM users;
+
+-- Step 3: Drop old table
+DROP TABLE users;
+
+-- Step 4: Rename new table
+ALTER TABLE users_new RENAME TO users;
+
+-- Step 5: Recreate indexes (username and email have UNIQUE in the CREATE TABLE)
+-- The federated_identities FK ON DELETE CASCADE still references users(id)
+-- which is the same PRIMARY KEY.
+CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
index 84952d07..7b2bd5a9 100644
--- a/backend/src/routes/auth.ts
+++ b/backend/src/routes/auth.ts
@@ -20,6 +20,7 @@ import {
import { ZodError } from "zod";
import { createAuthMiddleware } from "../middleware/authMiddleware";
import { type DIContainer, createDefaultContainer } from "../container/DIContainer";
+import type { EntraIdService } from "../services/EntraIdService";
/**
* Zod schema for user registration
@@ -81,6 +82,35 @@ export function createAuthRouter(
const setupService = new SetupService(databaseService.getAdapter());
const authMiddleware = createAuthMiddleware(databaseService.getAdapter(), jwtSecret);
+ /**
+ * Resolve EntraIdService from the container (if registered).
+ */
+ function getEntraIdService(): EntraIdService | null {
+ const services = (container as unknown as { services: Map }).services;
+ const service = services.get("entraId") as EntraIdService | undefined;
+ return service ?? null;
+ }
+
+ /**
+ * GET /api/auth/providers
+ * Returns available authentication methods (public, no auth required)
+ *
+ * Requirements: 11.1, 11.2, 11.3, 11.4, 11.5
+ */
+ router.get(
+ "/providers",
+ asyncHandler((_req: Request, res: Response): void => {
+ const providers: Record = { local: true };
+
+ const entraIdService = getEntraIdService();
+ if (entraIdService) {
+ providers.entraId = entraIdService.getProviderInfo();
+ }
+
+ res.status(200).json(providers);
+ }),
+ );
+
/**
* POST /api/auth/register
* Register a new user account
@@ -389,6 +419,35 @@ export function createAuthRouter(
metadata: { userId: req.user?.userId, username: req.user?.username },
});
+ // Check if user session was established via Entra ID
+ const entraIdService = getEntraIdService();
+ if (entraIdService && req.user?.userId) {
+ try {
+ const fedIdentity = await databaseService.getAdapter().queryOne<{
+ idToken: string | null;
+ }>(
+ `SELECT id_token AS "idToken" FROM federated_identities WHERE user_id = ? AND provider = 'entra-id'`,
+ [req.user.userId],
+ );
+
+ if (fedIdentity?.idToken) {
+ const entraIdLogoutUrl = entraIdService.buildLogoutUrl(fedIdentity.idToken);
+ res.status(200).json({
+ message: "Logout successful",
+ entraIdLogoutUrl,
+ });
+ return;
+ }
+ } catch (lookupErr) {
+ // Best-effort: federated identity lookup must not fail the logout
+ logger.warn("Failed to look up federated identity during logout", {
+ component: "AuthRouter",
+ operation: "logout",
+ metadata: { userId: req.user.userId, error: lookupErr instanceof Error ? lookupErr.message : String(lookupErr) },
+ });
+ }
+ }
+
// Return 200 OK with success message
res.status(200).json({
message: "Logout successful",
diff --git a/backend/src/routes/entraIdAuth.ts b/backend/src/routes/entraIdAuth.ts
new file mode 100644
index 00000000..97e29360
--- /dev/null
+++ b/backend/src/routes/entraIdAuth.ts
@@ -0,0 +1,264 @@
+import { Router, type Request, type Response } from "express";
+import { z } from "zod";
+import { asyncHandler } from "./asyncHandler";
+import { type EntraIdService, EntraIdError, ENTRA_ID_ERROR_CODES } from "../services/EntraIdService";
+import type { DatabaseService } from "../database/DatabaseService";
+import type { DIContainer } from "../container/DIContainer";
+
+const TokenExchangeSchema = z.object({
+ code: z.string().min(1, "Authorization code is required"),
+});
+
+/**
+ * Map EntraIdError codes to HTTP status codes.
+ */
+function httpStatusForEntraIdError(code: string): number {
+ switch (code) {
+ case ENTRA_ID_ERROR_CODES.INVALID_STATE:
+ return 400;
+ case ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE:
+ return 400;
+ case ENTRA_ID_ERROR_CODES.TOKEN_EXCHANGE_FAILED:
+ case ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN:
+ case ENTRA_ID_ERROR_CODES.AUTH_PROVIDER_ERROR:
+ case ENTRA_ID_ERROR_CODES.MISSING_CLAIMS:
+ return 401;
+ case ENTRA_ID_ERROR_CODES.JWKS_UNAVAILABLE:
+ return 503;
+ case ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED:
+ return 500;
+ default:
+ return 500;
+ }
+}
+
+/**
+ * Derive the frontend base URL from the configured redirectUri.
+ * The redirectUri is something like "https://app.example.com/api/auth/entra-id/callback".
+ * We want the origin: "https://app.example.com".
+ */
+function deriveFrontendUrl(redirectUri: string): string {
+ try {
+ const parsed = new URL(redirectUri);
+ return parsed.origin;
+ } catch {
+ return redirectUri;
+ }
+}
+
+/**
+ * Create Entra ID authentication router.
+ *
+ * Endpoints (mounted at /api/auth/entra-id):
+ * GET /login — 302 redirect to Entra ID authorization endpoint
+ * GET /callback — handle OAuth callback, redirect to frontend with auth code
+ * POST /token — exchange single-use auth code for Pabawi JWT pair
+ *
+ * All endpoints return 404 when Entra ID is not enabled (service absent from container).
+ */
+export function createEntraIdAuthRouter(
+ _databaseService: DatabaseService,
+ container: DIContainer,
+): Router {
+ const router = Router();
+ const logger = container.resolve("logger");
+
+ /**
+ * Resolve EntraIdService from the container's service map.
+ * Returns null when Entra ID is not enabled.
+ */
+ function getEntraIdService(): EntraIdService | null {
+ if (!container.has("entraId")) {
+ return null;
+ }
+ return container.resolve("entraId") ?? null;
+ }
+
+ /**
+ * Middleware that gates all endpoints behind Entra ID availability.
+ */
+ function requireEntraId(
+ _req: Request,
+ res: Response,
+ entraIdService: EntraIdService | null,
+ ): entraIdService is EntraIdService {
+ if (!entraIdService) {
+ res.status(404).json({
+ error: { code: "NOT_FOUND", message: "Not found" },
+ });
+ return false;
+ }
+ return true;
+ }
+
+ // ─── GET /login ─────────────────────────────────────────────────────────────
+ router.get(
+ "/login",
+ asyncHandler(async (_req: Request, res: Response): Promise => {
+ const entraIdService = getEntraIdService();
+ if (!requireEntraId(_req, res, entraIdService)) return;
+
+ try {
+ const { url } = await entraIdService.generateAuthorizationUrl();
+ res.redirect(302, url);
+ } catch (error) {
+ if (error instanceof EntraIdError) {
+ const status = httpStatusForEntraIdError(error.code);
+ res.status(status).json({
+ error: { code: error.code, message: error.message },
+ });
+ return;
+ }
+
+ logger.error("Unexpected error during login redirect", {
+ component: "EntraIdAuthRouter",
+ operation: "login",
+ }, error instanceof Error ? error : undefined);
+
+ res.status(500).json({
+ error: {
+ code: "SERVER_CONFIGURATION_ERROR",
+ message: "Server configuration problem",
+ },
+ });
+ }
+ }),
+ );
+
+ // ─── GET /callback ──────────────────────────────────────────────────────────
+ router.get(
+ "/callback",
+ asyncHandler(async (req: Request, res: Response): Promise => {
+ const entraIdService = getEntraIdService();
+ if (!requireEntraId(req, res, entraIdService)) return;
+
+ // Handle error parameter from Entra ID (Requirement 3.9)
+ const errorParam = req.query.error as string | undefined;
+ if (errorParam) {
+ const errorDescription =
+ (req.query.error_description as string | undefined) ?? "Authentication denied by provider";
+
+ logger.warn("Entra ID returned error on callback", {
+ component: "EntraIdAuthRouter",
+ operation: "callback",
+ metadata: { error: errorParam },
+ });
+
+ res.status(401).json({
+ error: {
+ code: ENTRA_ID_ERROR_CODES.AUTH_PROVIDER_ERROR,
+ message: errorDescription,
+ details: { error: errorParam, errorDescription },
+ },
+ });
+ return;
+ }
+
+ const code = req.query.code as string | undefined;
+ const state = req.query.state as string | undefined;
+
+ if (!code || !state) {
+ res.status(400).json({
+ error: {
+ code: ENTRA_ID_ERROR_CODES.INVALID_STATE,
+ message: "Missing code or state parameter",
+ },
+ });
+ return;
+ }
+
+ try {
+ const authCodeEntry = await entraIdService.handleCallback(code, state);
+
+ // Derive frontend URL and redirect with the single-use auth code
+ const configService = container.resolve("config");
+ const entraIdConfig = configService.getEntraIdConfig();
+ if (!entraIdConfig) {
+ res.status(500).json({
+ error: {
+ code: "SERVER_CONFIGURATION_ERROR",
+ message: "Server configuration problem",
+ },
+ });
+ return;
+ }
+
+ const frontendUrl = deriveFrontendUrl(entraIdConfig.redirectUri);
+ res.redirect(302, `${frontendUrl}?code=${encodeURIComponent(authCodeEntry.code)}`);
+ } catch (error) {
+ if (error instanceof EntraIdError) {
+ const status = httpStatusForEntraIdError(error.code);
+ res.status(status).json({
+ error: { code: error.code, message: error.message },
+ });
+ return;
+ }
+
+ logger.error("Unexpected error during callback processing", {
+ component: "EntraIdAuthRouter",
+ operation: "callback",
+ }, error instanceof Error ? error : undefined);
+
+ res.status(500).json({
+ error: {
+ code: "SERVER_CONFIGURATION_ERROR",
+ message: "Server configuration problem",
+ },
+ });
+ }
+ }),
+ );
+
+ // ─── POST /token ────────────────────────────────────────────────────────────
+ router.post(
+ "/token",
+ asyncHandler(async (req: Request, res: Response): Promise => {
+ const entraIdService = getEntraIdService();
+ if (!requireEntraId(req, res, entraIdService)) return;
+
+ const parseResult = TokenExchangeSchema.safeParse(req.body);
+ if (!parseResult.success) {
+ res.status(400).json({
+ error: {
+ code: ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE,
+ message: "Authorization code is required",
+ },
+ });
+ return;
+ }
+
+ try {
+ const { accessToken, refreshToken, user } =
+ await entraIdService.exchangeAuthCode(parseResult.data.code);
+
+ res.status(200).json({
+ token: accessToken,
+ refreshToken,
+ user,
+ });
+ } catch (error) {
+ if (error instanceof EntraIdError) {
+ const status = httpStatusForEntraIdError(error.code);
+ res.status(status).json({
+ error: { code: error.code, message: error.message },
+ });
+ return;
+ }
+
+ logger.error("Unexpected error during token exchange", {
+ component: "EntraIdAuthRouter",
+ operation: "token",
+ }, error instanceof Error ? error : undefined);
+
+ res.status(500).json({
+ error: {
+ code: "SERVER_CONFIGURATION_ERROR",
+ message: "Server configuration problem",
+ },
+ });
+ }
+ }),
+ );
+
+ return router;
+}
diff --git a/backend/src/server.ts b/backend/src/server.ts
index b8b85f19..cd44504a 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -74,6 +74,9 @@ import { AuthenticationService } from "./services/AuthenticationService";
import { UserService } from "./services/UserService";
import { RoleService } from "./services/RoleService";
import { PermissionService } from "./services/PermissionService";
+import { AuditLoggingService } from "./services/AuditLoggingService";
+import { EntraIdService } from "./services/EntraIdService";
+import { createEntraIdAuthRouter } from "./routes/entraIdAuth";
import { provisionMcpServiceUser } from "./mcp/McpServiceUser";
import { createMcpServer } from "./mcp/McpServer";
@@ -575,6 +578,70 @@ async function startServer(): Promise {
const authRateLimitMiddleware = createAuthRateLimitMiddleware();
app.use("/api/auth", authRateLimitMiddleware, createAuthRouter(databaseService, container));
+ // Conditionally initialize Entra ID SSO authentication
+ const entraIdConfig = configService.getEntraIdConfig();
+ let entraIdCleanupInterval: ReturnType | undefined;
+ if (entraIdConfig?.enabled) {
+ logger.info("Entra ID authentication enabled, initializing...", {
+ component: "Server",
+ operation: "initializeEntraId",
+ });
+
+ try {
+ const auditLogger = new AuditLoggingService(databaseService.getAdapter());
+ const authService = new AuthenticationService(
+ databaseService.getAdapter(),
+ configService.getJwtSecret(),
+ auditLogger,
+ );
+ const userService = new UserService(databaseService.getAdapter(), authService);
+ const roleService = new RoleService(databaseService.getAdapter());
+
+ const entraIdService = new EntraIdService(
+ databaseService.getAdapter(),
+ entraIdConfig,
+ authService,
+ userService,
+ roleService,
+ auditLogger,
+ logger,
+ );
+ container.register("entraId", entraIdService);
+
+ app.use(
+ "/api/auth/entra-id",
+ authRateLimitMiddleware,
+ createEntraIdAuthRouter(databaseService, container),
+ );
+
+ // Periodic cleanup of expired OAuth state entries (every 5 minutes)
+ entraIdCleanupInterval = setInterval(() => {
+ entraIdService.cleanupExpiredState().catch((err: unknown) => {
+ logger.warn("Entra ID state cleanup failed", {
+ component: "Server",
+ operation: "entraIdCleanup",
+ metadata: { error: err instanceof Error ? err.message : String(err) },
+ });
+ });
+ }, 5 * 60 * 1000);
+
+ logger.info("Entra ID authentication initialized, /api/auth/entra-id routes mounted", {
+ component: "Server",
+ operation: "initializeEntraId",
+ });
+ } catch (error) {
+ logger.error("Failed to initialize Entra ID authentication", {
+ component: "Server",
+ operation: "initializeEntraId",
+ }, error instanceof Error ? error : undefined);
+ }
+ } else {
+ logger.info("Entra ID authentication disabled", {
+ component: "Server",
+ operation: "initializeEntraId",
+ });
+ }
+
// Create authentication and RBAC middleware instances
// Wrap async middleware with asyncHandler to satisfy Express's void return expectation
const authMiddleware = asyncHandler(createAuthMiddleware(databaseService.getAdapter(), configService.getJwtSecret()));
@@ -1010,6 +1077,9 @@ async function startServer(): Promise {
});
streamingManager.cleanup();
integrationManager.stopHealthCheckScheduler();
+ if (entraIdCleanupInterval) {
+ clearInterval(entraIdCleanupInterval);
+ }
server.close(() => {
void databaseService.close().then(() => {
logger.info("Server closed", {
diff --git a/backend/src/services/EntraIdService.ts b/backend/src/services/EntraIdService.ts
new file mode 100644
index 00000000..450d63b3
--- /dev/null
+++ b/backend/src/services/EntraIdService.ts
@@ -0,0 +1,902 @@
+import { createHash, createPublicKey, randomBytes } from 'crypto';
+
+import jwt from 'jsonwebtoken';
+
+import type { DatabaseAdapter } from '../database/DatabaseAdapter';
+import type { EntraIdConfig } from '../config/schema';
+import type { AuthenticationService } from './AuthenticationService';
+import type { UserService, User } from './UserService';
+import type { UserDTO } from './UserService';
+import type { RoleService } from './RoleService';
+import type { AuditLoggingService } from './AuditLoggingService';
+import type { LoggerService } from './LoggerService';
+
+// --- Error code constants ---
+export const ENTRA_ID_ERROR_CODES = {
+ INVALID_STATE: 'INVALID_STATE',
+ TOKEN_EXCHANGE_FAILED: 'TOKEN_EXCHANGE_FAILED',
+ INVALID_ID_TOKEN: 'INVALID_ID_TOKEN',
+ AUTH_PROVIDER_ERROR: 'AUTH_PROVIDER_ERROR',
+ MISSING_CLAIMS: 'MISSING_CLAIMS',
+ JWKS_UNAVAILABLE: 'JWKS_UNAVAILABLE',
+ PROVISIONING_FAILED: 'PROVISIONING_FAILED',
+ INVALID_AUTH_CODE: 'INVALID_AUTH_CODE',
+} as const;
+
+export type EntraIdErrorCode = typeof ENTRA_ID_ERROR_CODES[keyof typeof ENTRA_ID_ERROR_CODES];
+
+// --- Typed error class ---
+export class EntraIdError extends Error {
+ readonly code: EntraIdErrorCode;
+
+ constructor(code: EntraIdErrorCode, message: string) {
+ super(message);
+ this.name = 'EntraIdError';
+ this.code = code;
+ }
+}
+
+// --- IdTokenClaims interface ---
+export interface IdTokenClaims {
+ sub: string;
+ email: string;
+ preferred_username: string;
+ given_name: string;
+ family_name: string;
+ nonce: string;
+ aud: string;
+ iss: string;
+ exp: number;
+ groups?: string[];
+}
+
+export interface OAuthStateEntry {
+ state: string;
+ nonce: string;
+ codeVerifier: string;
+ createdAt: string;
+ expiresAt: string;
+}
+
+export interface AuthCodeEntry {
+ code: string;
+ accessToken: string;
+ refreshToken: string;
+ userId: string;
+ idToken: string;
+ authMethod: string;
+ createdAt: string;
+ expiresAt: string;
+}
+
+// --- JWKS types ---
+interface JwksKey {
+ kty: string;
+ use?: string;
+ kid: string;
+ n: string;
+ e: string;
+ x5c?: string[];
+}
+
+interface JwksCache {
+ keys: JwksKey[];
+ fetchedAt: number;
+}
+
+/**
+ * EntraIdService handles Azure Entra ID (OpenID Connect) authentication.
+ *
+ * This service manages:
+ * - Authorization URL generation with PKCE and state/nonce
+ * - State store lifecycle (creation + TTL-based expiry cleanup)
+ * - Token exchange and ID token validation
+ * - JWKS key fetching and caching
+ * - Provider metadata for frontend discovery
+ */
+export class EntraIdService {
+ private readonly db: DatabaseAdapter;
+ private readonly config: EntraIdConfig;
+ readonly authService: AuthenticationService;
+ readonly userService: UserService;
+ readonly roleService: RoleService;
+ readonly auditLogger: AuditLoggingService;
+ private readonly logger: LoggerService;
+ private jwksCache: JwksCache | null = null;
+
+ constructor(
+ db: DatabaseAdapter,
+ config: EntraIdConfig,
+ authService: AuthenticationService,
+ userService: UserService,
+ roleService: RoleService,
+ auditLogger: AuditLoggingService,
+ logger: LoggerService,
+ ) {
+ this.db = db;
+ this.config = config;
+ this.authService = authService;
+ this.userService = userService;
+ this.roleService = roleService;
+ this.auditLogger = auditLogger;
+ this.logger = logger;
+ }
+
+ /**
+ * Generate an authorization URL for the Entra ID OAuth 2.0 + OIDC flow.
+ *
+ * Creates cryptographically random state, nonce, and PKCE code_verifier,
+ * stores them in oauth_state_store with a 10-minute TTL, then returns
+ * the full authorization endpoint URL with all required query parameters.
+ */
+ async generateAuthorizationUrl(): Promise<{ url: string; state: string }> {
+ const state = randomBytes(32).toString('hex');
+ const nonce = randomBytes(32).toString('hex');
+ const codeVerifier = this.generateCodeVerifier(64);
+ const codeChallenge = this.computeCodeChallenge(codeVerifier);
+
+ const now = new Date();
+ const createdAt = now.toISOString();
+ const expiresAt = new Date(now.getTime() + 10 * 60 * 1000).toISOString();
+
+ await this.db.execute(
+ `INSERT INTO oauth_state_store (state, nonce, code_verifier, created_at, expires_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ [state, nonce, codeVerifier, createdAt, expiresAt],
+ );
+
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: this.config.clientId,
+ redirect_uri: this.config.redirectUri,
+ scope: this.config.scopes.join(' '),
+ state,
+ nonce,
+ code_challenge: codeChallenge,
+ code_challenge_method: 'S256',
+ });
+
+ const baseUrl = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/authorize`;
+ const url = `${baseUrl}?${params.toString()}`;
+
+ this.logger.info('Generated authorization URL', {
+ component: 'EntraIdService',
+ operation: 'generateAuthorizationUrl',
+ metadata: { tenantId: this.config.tenantId },
+ });
+
+ return { url, state };
+ }
+
+ /**
+ * Delete expired entries from oauth_state_store.
+ *
+ * @returns Number of deleted rows
+ */
+ async cleanupExpiredState(): Promise {
+ const now = new Date().toISOString();
+ const result = await this.db.execute(
+ `DELETE FROM oauth_state_store WHERE expires_at < ?`,
+ [now],
+ );
+
+ const deleted = result.changes;
+
+ if (deleted > 0) {
+ this.logger.info(`Cleaned up ${String(deleted)} expired OAuth state entries`, {
+ component: 'EntraIdService',
+ operation: 'cleanupExpiredState',
+ metadata: { deletedCount: deleted },
+ });
+ }
+
+ return deleted;
+ }
+
+ /**
+ * Return provider info for the discovery endpoint.
+ */
+ getProviderInfo(): { enabled: true; name: string } {
+ return { enabled: true, name: 'Microsoft Entra ID' };
+ }
+
+ /**
+ * Handle the OAuth callback: validate state, exchange code for tokens,
+ * validate the ID token, provision/lookup user, sync roles, issue session.
+ *
+ * Returns an AuthCodeEntry containing the single-use authorization code
+ * that the frontend will exchange for the actual JWT pair.
+ */
+ async handleCallback(code: string, state: string): Promise {
+ const logMeta = { component: 'EntraIdService', operation: 'handleCallback' };
+
+ // 1. Look up state entry
+ const stateEntry = await this.db.queryOne<{
+ state: string;
+ nonce: string;
+ code_verifier: string;
+ created_at: string;
+ expires_at: string;
+ }>(
+ `SELECT state, nonce, code_verifier, created_at, expires_at
+ FROM oauth_state_store WHERE state = ?`,
+ [state],
+ );
+
+ // 2. Delete state entry immediately (one-time use, even on failure)
+ await this.db.execute(
+ `DELETE FROM oauth_state_store WHERE state = ?`,
+ [state],
+ );
+
+ // 3. Validate state exists
+ if (!stateEntry) {
+ this.logger.warn('OAuth callback received with invalid state', logMeta);
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_STATE,
+ 'State parameter missing or mismatched',
+ );
+ }
+
+ // 4. Verify state not expired
+ const now = new Date();
+ const expiresAt = new Date(stateEntry.expires_at);
+ if (now > expiresAt) {
+ this.logger.warn('OAuth callback received with expired state', logMeta);
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_STATE,
+ 'Authentication session expired',
+ );
+ }
+
+ // 5. Exchange authorization code for tokens
+ const tokenResponse = await this.exchangeCodeForTokens(
+ code,
+ stateEntry.code_verifier,
+ );
+
+ // 6. Fetch JWKS keys (cached)
+ const jwksKeys = await this.getJwksKeys();
+
+ // 7. Verify ID token
+ const claims = this.verifyIdToken(
+ tokenResponse.id_token,
+ jwksKeys,
+ stateEntry.nonce,
+ );
+
+ this.logger.info('ID token validated successfully', {
+ ...logMeta,
+ metadata: { sub: claims.sub },
+ });
+
+ // 8. Provision or look up the user
+ const user = await this.provisionUser(claims);
+
+ // 9. Sync group-to-role mapping
+ await this.syncGroupRoles(user.id, claims.groups);
+
+ // 10. Issue session tokens and generate auth code
+ const authCodeEntry = await this.issueSessionTokens(
+ user,
+ tokenResponse.id_token,
+ );
+
+ return authCodeEntry;
+ }
+
+ /**
+ * Provision or locate a user based on validated ID token claims.
+ *
+ * Flow:
+ * 1. Reject if email AND preferred_username are both missing
+ * 2. Look up federated_identities by (provider='entra-id', subject=sub)
+ * 3. If found: return existing user without updating profile (immutability)
+ * 4. If not found: check users by email
+ * 5. If email match: link federated identity to existing account
+ * 6. If no match: create new federated user
+ *
+ * @param claims - Validated ID token claims
+ * @returns The provisioned or existing user
+ */
+ async provisionUser(claims: IdTokenClaims): Promise {
+ const logMeta = { component: 'EntraIdService', operation: 'provisionUser' };
+
+ // 1. Reject if both email and preferred_username are missing
+ if (!claims.email && !claims.preferred_username) {
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.MISSING_CLAIMS,
+ 'Required identity claims absent: email and preferred_username',
+ );
+ }
+
+ // 2. Look up by federated identity
+ const existingUser = await this.userService.findByFederatedIdentity(
+ 'entra-id',
+ claims.sub,
+ );
+
+ if (existingUser) {
+ // 3. Returning user — do NOT update profile claims (immutability)
+ this.logger.info('Returning federated user found', {
+ ...logMeta,
+ metadata: { userId: existingUser.id, sub: claims.sub },
+ });
+ return existingUser;
+ }
+
+ // 4. No federated identity — check by email
+ if (claims.email) {
+ const emailMatch = await this.userService.findByEmail(claims.email);
+
+ if (emailMatch) {
+ // 5. Email match — link federated identity to existing account
+ try {
+ await this.userService.linkFederatedIdentity(
+ emailMatch.id,
+ 'entra-id',
+ claims.sub,
+ claims.iss,
+ claims.email,
+ );
+
+ this.logger.info('Linked federated identity to existing account', {
+ ...logMeta,
+ metadata: { userId: emailMatch.id, sub: claims.sub },
+ });
+
+ return emailMatch;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ this.logger.error('Failed to link federated identity', {
+ ...logMeta,
+ metadata: { error: message },
+ });
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED,
+ 'Account creation failed',
+ );
+ }
+ }
+ }
+
+ // 6. No match — create new federated user
+ try {
+ const newUser = await this.userService.createFederatedUser(claims);
+
+ this.logger.info('New federated user created', {
+ ...logMeta,
+ metadata: { userId: newUser.id, sub: claims.sub },
+ });
+
+ return newUser;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ this.logger.error('Failed to create federated user', {
+ ...logMeta,
+ metadata: { error: message },
+ });
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED,
+ 'Account creation failed',
+ );
+ }
+ }
+
+ /**
+ * Issue Pabawi JWT session tokens and generate a single-use authorization code.
+ *
+ * Steps:
+ * 1. Generate access + refresh tokens via AuthenticationService
+ * 2. Generate a crypto-random single-use authorization code
+ * 3. Store the code in oauth_auth_codes with 60s TTL
+ * 4. Update user's last_login_at timestamp
+ * 5. Record audit log (AUTH, LOGIN_SUCCESS, method=entra-id)
+ *
+ * @returns AuthCodeEntry for the frontend to exchange
+ */
+ private async issueSessionTokens(
+ user: User,
+ idToken: string,
+ ): Promise {
+ const logMeta = { component: 'EntraIdService', operation: 'issueSessionTokens' };
+
+ // 1. Generate Pabawi JWT tokens
+ const accessToken = await this.authService.generateToken(user);
+ const refreshToken = await this.authService.generateRefreshToken(user);
+
+ // 2. Generate single-use authorization code (32 bytes of entropy)
+ const authCode = randomBytes(32).toString('hex');
+
+ // 3. Store in oauth_auth_codes with 60-second TTL
+ const now = new Date();
+ const createdAt = now.toISOString();
+ const expiresAt = new Date(now.getTime() + 60 * 1000).toISOString();
+
+ await this.db.execute(
+ `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
+ [authCode, accessToken, refreshToken, user.id, idToken, 'entra-id', createdAt, expiresAt],
+ );
+
+ // 4. Update user's last_login_at
+ const loginTime = now.toISOString();
+ await this.db.execute(
+ `UPDATE users SET last_login_at = ? WHERE id = ?`,
+ [loginTime, user.id],
+ );
+
+ // 5. Record audit log
+ await this.auditLogger.logAuthenticationAttempt(
+ user.username,
+ true,
+ user.id,
+ undefined,
+ undefined,
+ 'method=entra-id',
+ );
+
+ this.logger.info('Session tokens issued for SSO user', {
+ ...logMeta,
+ metadata: { userId: user.id },
+ });
+
+ return {
+ code: authCode,
+ accessToken,
+ refreshToken,
+ userId: user.id,
+ idToken,
+ authMethod: 'entra-id',
+ createdAt,
+ expiresAt,
+ };
+ }
+
+ /**
+ * Exchange a single-use authorization code for Pabawi JWT tokens + user DTO.
+ *
+ * Validates that the code exists, is not expired, and has not been exchanged.
+ * Marks the code as exchanged atomically. Returns the stored tokens and user.
+ *
+ * @throws EntraIdError with INVALID_AUTH_CODE if code is invalid, expired, or already used.
+ */
+ async exchangeAuthCode(code: string): Promise<{
+ accessToken: string;
+ refreshToken: string;
+ user: UserDTO;
+ }> {
+ const logMeta = { component: 'EntraIdService', operation: 'exchangeAuthCode' };
+
+ // 1. Look up the code
+ const entry = await this.db.queryOne<{
+ code: string;
+ accessToken: string;
+ refreshToken: string;
+ userId: string;
+ idToken: string;
+ authMethod: string;
+ createdAt: string;
+ expiresAt: string;
+ exchanged: number;
+ }>(
+ `SELECT code,
+ access_token AS "accessToken",
+ refresh_token AS "refreshToken",
+ user_id AS "userId",
+ id_token AS "idToken",
+ auth_method AS "authMethod",
+ created_at AS "createdAt",
+ expires_at AS "expiresAt",
+ exchanged
+ FROM oauth_auth_codes WHERE code = ?`,
+ [code],
+ );
+
+ // 2. Verify code exists
+ if (!entry) {
+ this.logger.warn('Auth code exchange attempted with invalid code', logMeta);
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE,
+ 'Authorization code invalid',
+ );
+ }
+
+ // 3. Verify not already exchanged
+ if (entry.exchanged === 1) {
+ this.logger.warn('Auth code exchange attempted with already-used code', logMeta);
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE,
+ 'Authorization code invalid',
+ );
+ }
+
+ // 4. Verify not expired
+ const now = new Date();
+ const expiresAt = new Date(entry.expiresAt);
+ if (now > expiresAt) {
+ this.logger.warn('Auth code exchange attempted with expired code', logMeta);
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE,
+ 'Authorization code invalid',
+ );
+ }
+
+ // 5. Mark as exchanged
+ await this.db.execute(
+ `UPDATE oauth_auth_codes SET exchanged = 1 WHERE code = ?`,
+ [code],
+ );
+
+ // 6. Look up the user
+ const user = await this.userService.getUserById(entry.userId);
+ if (!user) {
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE,
+ 'Authorization code invalid',
+ );
+ }
+
+ this.logger.info('Auth code exchanged successfully', {
+ ...logMeta,
+ metadata: { userId: user.id },
+ });
+
+ return {
+ accessToken: entry.accessToken,
+ refreshToken: entry.refreshToken,
+ user: this.userService.toUserDTO(user),
+ };
+ }
+
+ /**
+ * Exchange authorization code at the Entra ID token endpoint.
+ * Uses AbortSignal.timeout(10000) for 10-second timeout.
+ */
+ private async exchangeCodeForTokens(
+ code: string,
+ codeVerifier: string,
+ ): Promise<{ id_token: string; access_token: string }> {
+ const logMeta = { component: 'EntraIdService', operation: 'exchangeCodeForTokens' };
+ const tokenUrl = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
+
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: this.config.clientId,
+ client_secret: this.config.clientSecret,
+ code,
+ redirect_uri: this.config.redirectUri,
+ code_verifier: codeVerifier,
+ });
+
+ let response: Response;
+ try {
+ response = await fetch(tokenUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ signal: AbortSignal.timeout(10000),
+ });
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ this.logger.error('Token exchange network failure', {
+ ...logMeta,
+ metadata: { error: message },
+ });
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.TOKEN_EXCHANGE_FAILED,
+ 'Token endpoint unreachable',
+ );
+ }
+
+ if (!response.ok) {
+ this.logger.error('Token exchange returned non-2xx', {
+ ...logMeta,
+ metadata: { status: response.status },
+ });
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.TOKEN_EXCHANGE_FAILED,
+ 'Could not exchange authorization code',
+ );
+ }
+
+ const data = await response.json() as { id_token?: string; access_token?: string };
+
+ if (!data.id_token || !data.access_token) {
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.TOKEN_EXCHANGE_FAILED,
+ 'Token response missing required fields',
+ );
+ }
+
+ return { id_token: data.id_token, access_token: data.access_token };
+ }
+
+ /**
+ * Fetch JWKS keys from the Entra ID discovery endpoint.
+ * Caches keys with configurable TTL. Falls back to cache on failure.
+ */
+ private async getJwksKeys(): Promise {
+ const logMeta = { component: 'EntraIdService', operation: 'getJwksKeys' };
+ const ttl = this.config.jwksCacheTtlMs;
+
+ // Return cached keys if still valid
+ if (this.jwksCache && (Date.now() - this.jwksCache.fetchedAt) < ttl) {
+ return this.jwksCache.keys;
+ }
+
+ const jwksUrl = `https://login.microsoftonline.com/${this.config.tenantId}/discovery/v2.0/keys`;
+
+ try {
+ const response = await fetch(jwksUrl, {
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!response.ok) {
+ throw new Error(`JWKS endpoint returned ${String(response.status)}`);
+ }
+
+ const data = await response.json() as { keys: JwksKey[] };
+ this.jwksCache = { keys: data.keys, fetchedAt: Date.now() };
+ return data.keys;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ this.logger.warn('JWKS fetch failed, attempting cache fallback', {
+ ...logMeta,
+ metadata: { error: message },
+ });
+
+ // Fallback to stale cache if available
+ if (this.jwksCache) {
+ this.logger.warn('Using stale JWKS cache', logMeta);
+ return this.jwksCache.keys;
+ }
+
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.JWKS_UNAVAILABLE,
+ 'Cannot verify token signatures',
+ );
+ }
+ }
+
+ /**
+ * Verify the ID token: signature (RS256 via JWKS), nonce, aud, iss, exp.
+ */
+ private verifyIdToken(
+ idToken: string,
+ jwksKeys: JwksKey[],
+ expectedNonce: string,
+ ): IdTokenClaims {
+ // Decode header to find kid
+ const decoded = jwt.decode(idToken, { complete: true });
+ if (!decoded || typeof decoded === 'string') {
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN,
+ 'Token could not be decoded',
+ );
+ }
+
+ const kid = decoded.header.kid;
+ const key = jwksKeys.find((k) => k.kid === kid);
+ if (!key) {
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN,
+ 'Token signature verification failed - key not found',
+ );
+ }
+
+ // Convert JWK to PEM
+ const pem = this.jwkToPem(key);
+
+ // Verify signature, exp (with 5min clockTolerance), aud, iss
+ const expectedIssuer = `https://login.microsoftonline.com/${this.config.tenantId}/v2.0`;
+
+ let payload: jwt.JwtPayload;
+ try {
+ payload = jwt.verify(idToken, pem, {
+ algorithms: ['RS256'],
+ audience: this.config.clientId,
+ issuer: expectedIssuer,
+ clockTolerance: 300, // 5 minutes in seconds
+ }) as jwt.JwtPayload;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Token verification failed';
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN,
+ message,
+ );
+ }
+
+ // Validate nonce
+ if (payload.nonce !== expectedNonce) {
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN,
+ 'Token nonce validation failed',
+ );
+ }
+
+ // Validate required claims presence
+ const missingClaims: string[] = [];
+ if (!payload.sub) missingClaims.push('sub');
+ if (!payload.email && !payload.preferred_username) {
+ missingClaims.push('email or preferred_username');
+ }
+
+ if (missingClaims.length > 0) {
+ throw new EntraIdError(
+ ENTRA_ID_ERROR_CODES.MISSING_CLAIMS,
+ `Required identity claims absent: ${missingClaims.join(', ')}`,
+ );
+ }
+
+ return {
+ sub: payload.sub!,
+ email: String(payload.email ?? ''),
+ preferred_username: String(payload.preferred_username ?? ''),
+ given_name: String(payload.given_name ?? ''),
+ family_name: String(payload.family_name ?? ''),
+ nonce: payload.nonce as string,
+ aud: (Array.isArray(payload.aud) ? payload.aud[0] : payload.aud)!,
+ iss: payload.iss!,
+ exp: payload.exp!,
+ groups: payload.groups as string[] | undefined,
+ };
+ }
+
+ /**
+ * Convert a JWK RSA key to PEM format using Node.js crypto.
+ */
+ private jwkToPem(key: JwksKey): string {
+ const publicKey = createPublicKey({
+ key: {
+ kty: key.kty,
+ n: key.n,
+ e: key.e,
+ },
+ format: 'jwk',
+ });
+
+ return publicKey.export({ type: 'spki', format: 'pem' }) as string;
+ }
+
+ /**
+ * Synchronize Entra ID group memberships to Pabawi roles.
+ *
+ * Algorithm:
+ * 1. If groupMapping is null or groups is undefined → skip (preserve existing roles)
+ * 2. Get all Pabawi roles to validate mapping targets
+ * 3. Get user's current roles
+ * 4. Determine which mapped roles the user should have (based on groups claim)
+ * 5. Add roles that are in "should have" but not currently assigned
+ * 6. Remove roles that are currently assigned via mapping but no longer in "should have"
+ * 7. Never touch roles that are not part of the mapping (manually assigned roles preserved)
+ */
+ async syncGroupRoles(userId: string, groups: string[] | undefined): Promise {
+ const logMeta = { component: 'EntraIdService', operation: 'syncGroupRoles' };
+
+ // Skip sync if no mapping configured or no groups claim present
+ if (!this.config.groupMapping || groups === undefined) {
+ this.logger.info('Skipping group-to-role sync (no mapping or no groups claim)', logMeta);
+ return;
+ }
+
+ const groupMapping = this.config.groupMapping;
+
+ // Get all available Pabawi roles
+ const allRolesResult = await this.roleService.listRoles({ limit: 1000, offset: 0 });
+ const allRoles = allRolesResult.items;
+ const rolesByName = new Map(allRoles.map((r) => [r.name.toLowerCase(), r]));
+
+ // Get user's current roles
+ const currentRoles = await this.userService.getUserRoles(userId);
+ const currentRoleIds = new Set(currentRoles.map((r) => r.id));
+
+ // Normalize groups claim to lowercase for case-insensitive comparison
+ const normalizedGroups = new Set(groups.map((g) => g.toLowerCase()));
+
+ // Determine which role IDs are managed by the mapping (all valid mapping targets)
+ const managedRoleIds = new Set();
+ // Determine which role IDs the user should have based on current groups
+ const shouldHaveRoleIds = new Set();
+
+ for (const [groupId, roleName] of Object.entries(groupMapping)) {
+ const role = rolesByName.get(roleName.toLowerCase());
+
+ if (!role) {
+ this.logger.warn(`Group mapping references non-existent role "${roleName}", skipping`, {
+ ...logMeta,
+ metadata: { groupId, roleName },
+ });
+ continue;
+ }
+
+ managedRoleIds.add(role.id);
+
+ // Case-insensitive UUID comparison
+ if (normalizedGroups.has(groupId.toLowerCase())) {
+ shouldHaveRoleIds.add(role.id);
+ }
+ }
+
+ // Assign roles that user should have but doesn't
+ let assigned = 0;
+ for (const roleId of shouldHaveRoleIds) {
+ if (!currentRoleIds.has(roleId)) {
+ try {
+ await this.userService.assignRoleToUser(userId, roleId);
+ assigned++;
+ } catch (err: unknown) {
+ // Role may already be assigned (race condition) — log and continue
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ this.logger.warn(`Failed to assign role during group sync: ${message}`, logMeta);
+ }
+ }
+ }
+
+ // Revoke managed roles that user currently has but should no longer have
+ let revoked = 0;
+ for (const roleId of managedRoleIds) {
+ if (currentRoleIds.has(roleId) && !shouldHaveRoleIds.has(roleId)) {
+ try {
+ await this.userService.removeRoleFromUser(userId, roleId);
+ revoked++;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ this.logger.warn(`Failed to revoke role during group sync: ${message}`, logMeta);
+ }
+ }
+ }
+
+ this.logger.info('Group-to-role sync completed', {
+ ...logMeta,
+ metadata: { userId, assigned, revoked, groupCount: groups.length },
+ });
+ }
+
+ /**
+ * Build the Entra ID end-session URL for single sign-out.
+ *
+ * Constructs the logout URL with:
+ * - post_logout_redirect_uri: from config (or fallback to base URL)
+ * - id_token_hint: the user's stored ID token
+ *
+ * @param idToken - The ID token stored from the user's SSO session
+ * @returns Full Entra ID logout URL
+ */
+ buildLogoutUrl(idToken: string): string {
+ const baseLogoutUrl =
+ `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/logout`;
+
+ const postLogoutRedirectUri = this.config.postLogoutRedirectUri ?? this.config.redirectUri;
+
+ const params = new URLSearchParams({
+ post_logout_redirect_uri: postLogoutRedirectUri,
+ id_token_hint: idToken,
+ });
+
+ return `${baseLogoutUrl}?${params.toString()}`;
+ }
+
+ /**
+ * Generate a code_verifier per RFC 7636 Section 4.1.
+ * Uses unreserved characters [A-Z, a-z, 0-9, "-", ".", "_", "~"].
+ */
+ private generateCodeVerifier(length: number): string {
+ const unreserved = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
+ const bytes = randomBytes(length);
+ let verifier = '';
+ for (let i = 0; i < length; i++) {
+ verifier += unreserved[bytes[i] % unreserved.length];
+ }
+ return verifier;
+ }
+
+ /**
+ * Compute code_challenge from code_verifier using S256 method.
+ * SHA-256 hash → base64url encoding (no padding).
+ */
+ private computeCodeChallenge(codeVerifier: string): string {
+ const hash = createHash('sha256').update(codeVerifier).digest();
+ return hash.toString('base64url');
+ }
+}
diff --git a/backend/src/services/UserService.ts b/backend/src/services/UserService.ts
index 1fd51651..ca255b4a 100644
--- a/backend/src/services/UserService.ts
+++ b/backend/src/services/UserService.ts
@@ -3,6 +3,7 @@ import { randomUUID } from 'crypto';
import type { AuthenticationService } from './AuthenticationService';
import { SetupService } from './SetupService';
import { validatePassword } from '../utils/passwordValidation';
+import type { IdTokenClaims } from './EntraIdService';
/**
* User model from database
@@ -48,6 +49,16 @@ const ROLE_COLUMNS = `id, name, description,
created_at AS "createdAt",
updated_at AS "updatedAt"`;
+const FEDERATED_IDENTITY_COLUMNS = `id,
+ user_id AS "userId",
+ provider,
+ subject,
+ issuer,
+ email,
+ id_token AS "idToken",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt"`;
+
/**
* User data transfer object (without password)
*/
@@ -133,6 +144,21 @@ export interface Role {
updatedAt: string;
}
+/**
+ * Federated identity link — maps an external IdP subject to a Pabawi user
+ */
+export interface FederatedIdentity {
+ id: string;
+ userId: string;
+ provider: string;
+ subject: string;
+ issuer: string;
+ email: string | null;
+ idToken: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
/**
* User service for managing user accounts, profiles, and user-group/role relationships
*
@@ -635,4 +661,193 @@ export class UserService {
};
}
+ /**
+ * Derive a valid username from IdToken claims.
+ *
+ * If preferred_username matches ^[a-zA-Z0-9_]{3,50}$, use it directly.
+ * Otherwise, derive from the email local-part by replacing disallowed
+ * characters with underscores and truncating to 50 characters.
+ */
+ private deriveUsername(claims: IdTokenClaims): string {
+ const validUsernamePattern = /^[a-zA-Z0-9_]{3,50}$/;
+
+ if (claims.preferred_username && validUsernamePattern.test(claims.preferred_username)) {
+ return claims.preferred_username;
+ }
+
+ const localPart = claims.email.split('@')[0];
+ const sanitized = localPart.replace(/[^a-zA-Z0-9_]/g, '_');
+ return sanitized.slice(0, 50);
+ }
+
+ /**
+ * Create a new user from federated identity (SSO) claims.
+ *
+ * Creates the user with null password_hash, is_active=1, assigns the
+ * default viewer role, and creates a federated_identities record linking
+ * the user to the external IdP.
+ *
+ * @param claims - Validated ID token claims from the identity provider
+ * @returns Created user
+ * @throws Error if username uniqueness constraint is violated
+ */
+ public async createFederatedUser(claims: IdTokenClaims): Promise {
+ const username = this.deriveUsername(claims);
+
+ // Use a transaction to ensure atomicity — no partial records
+ return this.db.withTransaction(async () => {
+ // Check username uniqueness
+ const existingUsername = await this.getUserByUsername(username);
+ if (existingUsername) {
+ throw new Error(
+ `Account creation failed: derived username "${username}" already exists`
+ );
+ }
+
+ // Check email uniqueness (a separate user with this email should not exist;
+ // email-match linking is handled by the caller before invoking this method)
+ const existingEmail = await this.getUserByEmail(claims.email);
+ if (existingEmail) {
+ throw new Error(
+ `Account creation failed: email "${claims.email}" already exists`
+ );
+ }
+
+ const userId = randomUUID();
+ const now = new Date().toISOString();
+
+ // Insert user with null password_hash (federation-only)
+ await this.db.execute(
+ `INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, NULL, ?, ?, 1, 0, ?, ?)`,
+ [
+ userId,
+ username,
+ claims.email,
+ claims.given_name || '',
+ claims.family_name || '',
+ now,
+ now
+ ]
+ );
+
+ // Assign default viewer role
+ const defaultRoleId = await this.setupService.getDefaultNewUserRole();
+ if (defaultRoleId) {
+ await this.db.execute(
+ `INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)`,
+ [userId, defaultRoleId, now]
+ );
+ }
+
+ // Create federated identity record
+ const federatedId = randomUUID();
+ await this.db.execute(
+ `INSERT INTO federated_identities (id, user_id, provider, subject, issuer, email, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ federatedId,
+ userId,
+ 'entra-id',
+ claims.sub,
+ claims.iss,
+ claims.email,
+ now,
+ now
+ ]
+ );
+
+ const user = await this.getUserById(userId);
+ if (!user) {
+ throw new Error('Failed to create federated user');
+ }
+
+ return user;
+ });
+ }
+
+ /**
+ * Link an existing user account to a federated identity.
+ *
+ * Used when a local user with the same email is found during SSO login —
+ * preserves the existing password_hash so local login remains available.
+ *
+ * @param userId - Existing Pabawi user ID
+ * @param provider - Identity provider name (e.g. 'entra-id')
+ * @param subject - The IdP subject claim (unique per tenant+user)
+ * @param issuer - Token issuer URL
+ * @param email - Email from the IdP (informational)
+ */
+ public async linkFederatedIdentity(
+ userId: string,
+ provider: string,
+ subject: string,
+ issuer: string,
+ email: string | null,
+ ): Promise {
+ const id = randomUUID();
+ const now = new Date().toISOString();
+
+ try {
+ await this.db.execute(
+ `INSERT INTO federated_identities (id, user_id, provider, subject, issuer, email, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [id, userId, provider, subject, issuer, email, now, now]
+ );
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ if (message.includes('UNIQUE') || message.includes('unique') || message.includes('duplicate')) {
+ throw new Error(
+ `Federated identity already linked: provider=${provider}, subject=${subject}`
+ );
+ }
+ throw err;
+ }
+
+ const identity = await this.db.queryOne(
+ `SELECT ${FEDERATED_IDENTITY_COLUMNS} FROM federated_identities WHERE id = ?`,
+ [id]
+ );
+ if (!identity) {
+ throw new Error('Failed to create federated identity link');
+ }
+
+ return identity;
+ }
+
+ /**
+ * Look up a user by their federated identity (provider + subject).
+ *
+ * @param provider - Identity provider name (e.g. 'entra-id')
+ * @param subject - The IdP subject claim
+ * @returns The linked user, or null if no matching federated identity exists
+ */
+ public async findByFederatedIdentity(
+ provider: string,
+ subject: string,
+ ): Promise {
+ const identity = await this.db.queryOne(
+ `SELECT ${FEDERATED_IDENTITY_COLUMNS} FROM federated_identities
+ WHERE provider = ? AND subject = ?`,
+ [provider, subject]
+ );
+
+ if (!identity) {
+ return null;
+ }
+
+ return this.getUserById(identity.userId);
+ }
+
+ /**
+ * Public wrapper around the private getUserByEmail method.
+ * Used by EntraIdService for email-match account linking during SSO.
+ *
+ * @param email - Email address to search for
+ * @returns User or null if not found
+ */
+ public async findByEmail(email: string): Promise {
+ return this.getUserByEmail(email);
+ }
+
}
diff --git a/backend/test/database/migration-integration.test.ts b/backend/test/database/migration-integration.test.ts
index ff123007..c9cc449a 100644
--- a/backend/test/database/migration-integration.test.ts
+++ b/backend/test/database/migration-integration.test.ts
@@ -28,8 +28,8 @@ describe('Migration Integration Test', () => {
it('should apply all migrations on initialization', async () => {
const status = await dbService.getMigrationStatus();
- // Should have applied all migrations (000 through 015, no 012 in source)
- expect(status.applied).toHaveLength(15);
+ // Should have applied all migrations (000 through 017, no 012 in source)
+ expect(status.applied).toHaveLength(17);
expect(status.applied[0].id).toBe('000');
expect(status.applied[1].id).toBe('001');
expect(status.applied[2].id).toBe('002');
@@ -45,6 +45,8 @@ describe('Migration Integration Test', () => {
expect(status.applied[12].id).toBe('013');
expect(status.applied[13].id).toBe('014');
expect(status.applied[14].id).toBe('015');
+ expect(status.applied[15].id).toBe('016');
+ expect(status.applied[16].id).toBe('017');
expect(status.pending).toHaveLength(0);
});
@@ -85,8 +87,8 @@ describe('Migration Integration Test', () => {
const status = await dbService2.getMigrationStatus();
- // Should still have 15 applied, 0 pending
- expect(status.applied).toHaveLength(15);
+ // Should still have 17 applied, 0 pending
+ expect(status.applied).toHaveLength(17);
expect(status.pending).toHaveLength(0);
await dbService2.close();
diff --git a/backend/test/database/rbac-schema.test.ts b/backend/test/database/rbac-schema.test.ts
index 6dc7b1de..1b30ae6a 100644
--- a/backend/test/database/rbac-schema.test.ts
+++ b/backend/test/database/rbac-schema.test.ts
@@ -32,7 +32,7 @@ describe('RBAC Database Schema', () => {
expect(result.sql).toContain('id TEXT PRIMARY KEY');
expect(result.sql).toContain('username TEXT NOT NULL UNIQUE');
expect(result.sql).toContain('email TEXT NOT NULL UNIQUE');
- expect(result.sql).toContain('password_hash TEXT NOT NULL');
+ expect(result.sql).toContain('password_hash TEXT');
expect(result.sql).toContain('is_active INTEGER NOT NULL DEFAULT 1');
expect(result.sql).toContain('is_admin INTEGER NOT NULL DEFAULT 0');
});
diff --git a/backend/test/integration/EntraIdAuthFlow.test.ts b/backend/test/integration/EntraIdAuthFlow.test.ts
new file mode 100644
index 00000000..e7f103e5
--- /dev/null
+++ b/backend/test/integration/EntraIdAuthFlow.test.ts
@@ -0,0 +1,639 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import express, { type Express } from 'express';
+import request from 'supertest';
+import crypto from 'crypto';
+import jwt from 'jsonwebtoken';
+
+import { createEntraIdAuthRouter } from '../../src/routes/entraIdAuth';
+import { createAuthRouter } from '../../src/routes/auth';
+import { DatabaseService } from '../../src/database/DatabaseService';
+import { EntraIdService } from '../../src/services/EntraIdService';
+import { AuthenticationService } from '../../src/services/AuthenticationService';
+import { UserService } from '../../src/services/UserService';
+import { RoleService } from '../../src/services/RoleService';
+import { AuditLoggingService } from '../../src/services/AuditLoggingService';
+import { LoggerService } from '../../src/services/LoggerService';
+import { DIContainer } from '../../src/container/DIContainer';
+import { ConfigService } from '../../src/config/ConfigService';
+import type { EntraIdConfig } from '../../src/config/schema';
+
+// --- Test RSA key pair for signing ID tokens ---
+const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+});
+
+const TEST_KID = 'test-key-id-001';
+
+function pemToJwk(pem: string, kid: string): {
+ kty: string; use: string; kid: string; n: string; e: string;
+} {
+ const keyObj = crypto.createPublicKey(pem);
+ const jwk = keyObj.export({ format: 'jwk' });
+ return {
+ kty: 'RSA',
+ use: 'sig',
+ kid,
+ n: jwk.n as string,
+ e: jwk.e as string,
+ };
+}
+
+const TEST_JWK = pemToJwk(publicKey, TEST_KID);
+
+// --- Test Entra ID configuration ---
+const TEST_TENANT_ID = 'test-tenant-id-12345';
+const TEST_CLIENT_ID = 'test-client-id-67890';
+const TEST_CLIENT_SECRET = 'test-client-secret-abcdef'; // pragma: allowlist secret
+const TEST_REDIRECT_URI = 'http://localhost:3000/api/auth/entra-id/callback';
+
+const testEntraIdConfig: EntraIdConfig = {
+ enabled: true,
+ tenantId: TEST_TENANT_ID,
+ clientId: TEST_CLIENT_ID,
+ clientSecret: TEST_CLIENT_SECRET, // pragma: allowlist secret
+ redirectUri: TEST_REDIRECT_URI,
+ scopes: ['openid', 'profile', 'email'],
+ groupMapping: null,
+ postLogoutRedirectUri: 'http://localhost:3000',
+ jwksCacheTtlMs: 86400000,
+};
+
+/**
+ * Sign a test ID token with our test private key.
+ * Note: Do NOT include `exp` in claims if using `expiresIn` option (jsonwebtoken rejects both).
+ */
+function signTestIdToken(claims: Record): string {
+ return jwt.sign(claims, privateKey, {
+ algorithm: 'RS256',
+ keyid: TEST_KID,
+ });
+}
+
+/**
+ * Create a valid test ID token with standard claims.
+ */
+function createValidIdToken(overrides: Partial> = {}, nonce?: string): string {
+ const now = Math.floor(Date.now() / 1000);
+ const claims = {
+ sub: 'entra-user-sub-12345',
+ email: 'testuser@example.com',
+ preferred_username: 'testuser',
+ given_name: 'Test',
+ family_name: 'User',
+ nonce: nonce ?? 'test-nonce',
+ aud: TEST_CLIENT_ID,
+ iss: `https://login.microsoftonline.com/${TEST_TENANT_ID}/v2.0`,
+ iat: now,
+ exp: now + 3600,
+ ...overrides,
+ };
+ return signTestIdToken(claims);
+}
+
+/**
+ * Integration Tests for the full Entra ID OAuth Flow
+ *
+ * Tests the complete authorization URL generation → callback → token exchange
+ * flow with mocked Entra ID endpoints.
+ *
+ * Validates: Requirements 2.1, 3.1, 7.1, 7.2, 7.3, 7.4, 9.4, 9.8
+ */
+describe('Entra ID Auth Flow Integration Tests', () => {
+ let app: Express;
+ let databaseService: DatabaseService;
+ let entraIdService: EntraIdService;
+ let container: DIContainer;
+ let fetchSpy: ReturnType;
+
+ beforeEach(async () => {
+ process.env.JWT_SECRET = 'test-secret-key-for-entra-id-integration'; // pragma: allowlist secret
+ process.env.ENTRA_ID_ENABLED = 'true';
+ process.env.ENTRA_ID_TENANT_ID = TEST_TENANT_ID;
+ process.env.ENTRA_ID_CLIENT_ID = TEST_CLIENT_ID;
+ process.env.ENTRA_ID_CLIENT_SECRET = TEST_CLIENT_SECRET; // pragma: allowlist secret
+ process.env.ENTRA_ID_REDIRECT_URI = TEST_REDIRECT_URI;
+
+ databaseService = new DatabaseService(':memory:');
+ await databaseService.initialize();
+
+ const db = databaseService.getAdapter();
+ const logger = new LoggerService();
+ const auditLogger = new AuditLoggingService(db);
+ const jwtSecret = process.env.JWT_SECRET;
+ const authService = new AuthenticationService(db, jwtSecret, auditLogger, 4);
+ const userService = new UserService(db, authService);
+ const roleService = new RoleService(db);
+
+ entraIdService = new EntraIdService(
+ db,
+ testEntraIdConfig,
+ authService,
+ userService,
+ roleService,
+ auditLogger,
+ logger,
+ );
+
+ container = new DIContainer();
+ container.register('logger', logger);
+
+ const configService = new ConfigService();
+ container.register('config', configService);
+
+ const { ExpertModeService } = await import('../../src/services/ExpertModeService');
+ container.register('expertMode', new ExpertModeService());
+ container.register('entraId', entraIdService);
+
+ app = express();
+ app.use(express.json());
+ app.use('/api/auth/entra-id', createEntraIdAuthRouter(databaseService, container));
+ app.use('/api/auth', createAuthRouter(databaseService, container));
+
+ // Mock global fetch for external Entra ID calls
+ fetchSpy = vi.spyOn(globalThis, 'fetch');
+ });
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await databaseService.close();
+ delete process.env.ENTRA_ID_ENABLED;
+ delete process.env.ENTRA_ID_TENANT_ID;
+ delete process.env.ENTRA_ID_CLIENT_ID;
+ delete process.env.ENTRA_ID_CLIENT_SECRET;
+ delete process.env.ENTRA_ID_REDIRECT_URI;
+ });
+
+ describe('Full happy path: auth URL → callback → token exchange', () => {
+ it('should complete the full OAuth flow', async () => {
+ // Step 1: Get the authorization URL via /login endpoint
+ const loginResponse = await request(app)
+ .get('/api/auth/entra-id/login')
+ .expect(302);
+
+ const redirectUrl = new URL(loginResponse.headers.location);
+ expect(redirectUrl.hostname).toBe('login.microsoftonline.com');
+ expect(redirectUrl.pathname).toContain(TEST_TENANT_ID);
+ expect(redirectUrl.searchParams.get('response_type')).toBe('code');
+ expect(redirectUrl.searchParams.get('client_id')).toBe(TEST_CLIENT_ID);
+ expect(redirectUrl.searchParams.get('code_challenge_method')).toBe('S256');
+
+ const state = redirectUrl.searchParams.get('state');
+ const nonce = redirectUrl.searchParams.get('nonce');
+ expect(state).toBeTruthy();
+ expect(nonce).toBeTruthy();
+
+ // Step 2: Simulate callback from Entra ID
+ // We need to get the stored nonce from the DB to create a valid ID token
+ const stateEntry = await databaseService.getAdapter().queryOne<{
+ nonce: string;
+ code_verifier: string;
+ }>(
+ `SELECT nonce, code_verifier FROM oauth_state_store WHERE state = ?`,
+ [state!],
+ );
+ expect(stateEntry).not.toBeNull();
+
+ const validIdToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce);
+
+ // Mock the token endpoint
+ fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input.toString();
+
+ if (url.includes('/oauth2/v2.0/token')) {
+ return new Response(
+ JSON.stringify({
+ id_token: validIdToken,
+ access_token: 'mock-entra-access-token',
+ token_type: 'Bearer',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+
+ if (url.includes('/discovery/v2.0/keys')) {
+ return new Response(
+ JSON.stringify({ keys: [TEST_JWK] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+
+ return new Response('Not found', { status: 404 });
+ });
+
+ // Call the callback endpoint
+ const callbackResponse = await request(app)
+ .get('/api/auth/entra-id/callback')
+ .query({ code: 'mock-auth-code', state: state! })
+ .expect(302);
+
+ // Should redirect to frontend with an auth code
+ const frontendRedirect = new URL(callbackResponse.headers.location);
+ const authCode = frontendRedirect.searchParams.get('code');
+ expect(authCode).toBeTruthy();
+ expect(authCode!.length).toBeGreaterThan(0);
+
+ // Step 3: Exchange the auth code for tokens
+ const tokenResponse = await request(app)
+ .post('/api/auth/entra-id/token')
+ .send({ code: authCode })
+ .expect(200);
+
+ expect(tokenResponse.body).toHaveProperty('token');
+ expect(tokenResponse.body).toHaveProperty('refreshToken');
+ expect(tokenResponse.body).toHaveProperty('user');
+ expect(tokenResponse.body.user.username).toBe('testuser');
+ expect(tokenResponse.body.user.email).toBe('testuser@example.com');
+
+ // Step 4: Verify the auth code cannot be reused (single-use)
+ const replayResponse = await request(app)
+ .post('/api/auth/entra-id/token')
+ .send({ code: authCode })
+ .expect(400);
+
+ expect(replayResponse.body.error.code).toBe('INVALID_AUTH_CODE');
+ });
+ });
+
+ describe('JWKS cache fallback on endpoint failure', () => {
+ it('should use cached JWKS keys when endpoint fails on second request', async () => {
+ // First: generate auth URL to get state
+ const loginRes = await request(app)
+ .get('/api/auth/entra-id/login')
+ .expect(302);
+
+ const redirectUrl = new URL(loginRes.headers.location);
+ const state = redirectUrl.searchParams.get('state')!;
+
+ const stateEntry = await databaseService.getAdapter().queryOne<{
+ nonce: string;
+ code_verifier: string;
+ }>(
+ `SELECT nonce, code_verifier FROM oauth_state_store WHERE state = ?`,
+ [state],
+ );
+
+ const validIdToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce);
+
+ let jwksFetchCount = 0;
+
+ fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input.toString();
+
+ if (url.includes('/oauth2/v2.0/token')) {
+ return new Response(
+ JSON.stringify({
+ id_token: validIdToken,
+ access_token: 'mock-access-token',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+
+ if (url.includes('/discovery/v2.0/keys')) {
+ jwksFetchCount++;
+ if (jwksFetchCount === 1) {
+ // First call succeeds — populates the cache
+ return new Response(
+ JSON.stringify({ keys: [TEST_JWK] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ // Subsequent calls fail — should use cache
+ return new Response('Service unavailable', { status: 503 });
+ }
+
+ return new Response('Not found', { status: 404 });
+ });
+
+ // First callback: should succeed and cache JWKS keys
+ const callbackRes1 = await request(app)
+ .get('/api/auth/entra-id/callback')
+ .query({ code: 'auth-code-1', state })
+ .expect(302);
+
+ const authCode1 = new URL(callbackRes1.headers.location).searchParams.get('code')!;
+ await request(app).post('/api/auth/entra-id/token').send({ code: authCode1 }).expect(200);
+
+ // Now force cache to be stale by manipulating the service internals
+ // The jwksCache has a fetchedAt that we need to backdating.
+ // Since the cache TTL is 24h, the second request within the test will use
+ // the in-memory cache anyway. Let's verify via a second full flow.
+
+ // Generate a new authorization URL for a second login
+ const loginRes2 = await request(app).get('/api/auth/entra-id/login').expect(302);
+ const state2 = new URL(loginRes2.headers.location).searchParams.get('state')!;
+
+ const stateEntry2 = await databaseService.getAdapter().queryOne<{
+ nonce: string;
+ }>(
+ `SELECT nonce FROM oauth_state_store WHERE state = ?`,
+ [state2],
+ );
+
+ const validIdToken2 = createValidIdToken(
+ { nonce: stateEntry2!.nonce, sub: 'returning-user-sub' },
+ stateEntry2!.nonce,
+ );
+
+ // Update fetch mock to return the second token
+ fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input.toString();
+ if (url.includes('/oauth2/v2.0/token')) {
+ return new Response(
+ JSON.stringify({ id_token: validIdToken2, access_token: 'mock-access-2' }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ if (url.includes('/discovery/v2.0/keys')) {
+ // JWKS endpoint fails completely
+ return new Response('Service unavailable', { status: 503 });
+ }
+ return new Response('Not found', { status: 404 });
+ });
+
+ // Force cache expiry by manipulating the internal cache timestamp
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (entraIdService as any).jwksCache.fetchedAt = 0;
+
+ // Second callback: JWKS endpoint fails but cache should serve
+ const callbackRes2 = await request(app)
+ .get('/api/auth/entra-id/callback')
+ .query({ code: 'auth-code-2', state: state2 })
+ .expect(302);
+
+ const authCode2 = new URL(callbackRes2.headers.location).searchParams.get('code')!;
+ const tokenRes2 = await request(app)
+ .post('/api/auth/entra-id/token')
+ .send({ code: authCode2 })
+ .expect(200);
+
+ expect(tokenRes2.body.user.email).toBe('testuser@example.com');
+ });
+ });
+
+ describe('Token exchange timeout behavior (>10s)', () => {
+ it('should return TOKEN_EXCHANGE_FAILED when token endpoint times out', async () => {
+ const loginRes = await request(app).get('/api/auth/entra-id/login').expect(302);
+ const state = new URL(loginRes.headers.location).searchParams.get('state')!;
+
+ // Mock the token endpoint to abort (simulating a timeout via AbortError)
+ fetchSpy.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input.toString();
+
+ if (url.includes('/oauth2/v2.0/token')) {
+ // Simulate AbortSignal.timeout(10000) firing
+ const error = new DOMException('The operation was aborted', 'AbortError');
+ if (init?.signal) {
+ // Check if the signal is already aborted
+ if (init.signal.aborted) {
+ throw error;
+ }
+ }
+ throw error;
+ }
+
+ return new Response('Not found', { status: 404 });
+ });
+
+ const callbackRes = await request(app)
+ .get('/api/auth/entra-id/callback')
+ .query({ code: 'timeout-code', state })
+ .expect(401);
+
+ expect(callbackRes.body.error.code).toBe('TOKEN_EXCHANGE_FAILED');
+ expect(callbackRes.body.error.message).toContain('unreachable');
+ });
+ });
+
+ describe('Database failure during provisioning (atomicity)', () => {
+ it('should reject with PROVISIONING_FAILED and leave no partial state', async () => {
+ const loginRes = await request(app).get('/api/auth/entra-id/login').expect(302);
+ const state = new URL(loginRes.headers.location).searchParams.get('state')!;
+
+ const stateEntry = await databaseService.getAdapter().queryOne<{ nonce: string }>(
+ `SELECT nonce FROM oauth_state_store WHERE state = ?`,
+ [state],
+ );
+
+ const idToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce);
+
+ fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input.toString();
+ if (url.includes('/oauth2/v2.0/token')) {
+ return new Response(
+ JSON.stringify({ id_token: idToken, access_token: 'mock-access' }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ if (url.includes('/discovery/v2.0/keys')) {
+ return new Response(
+ JSON.stringify({ keys: [TEST_JWK] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ return new Response('Not found', { status: 404 });
+ });
+
+ // Spy on the UserService.createFederatedUser to throw a DB error
+ const createFederatedUserSpy = vi.spyOn(
+ entraIdService.userService,
+ 'createFederatedUser',
+ );
+ createFederatedUserSpy.mockRejectedValueOnce(new Error('SQLITE_CONSTRAINT: UNIQUE'));
+
+ const callbackRes = await request(app)
+ .get('/api/auth/entra-id/callback')
+ .query({ code: 'db-fail-code', state })
+ .expect(500);
+
+ expect(callbackRes.body.error.code).toBe('PROVISIONING_FAILED');
+
+ // Verify no user was partially created
+ const users = await databaseService.getAdapter().query<{ username: string }>(
+ `SELECT username FROM users WHERE email = 'testuser@example.com'`,
+ [],
+ );
+ expect(users).toHaveLength(0);
+
+ // Verify no federated identity was created
+ const identities = await databaseService.getAdapter().query<{ id: string }>(
+ `SELECT id FROM federated_identities WHERE subject = 'entra-user-sub-12345'`,
+ [],
+ );
+ expect(identities).toHaveLength(0);
+ });
+ });
+
+ describe('Audit logging verification', () => {
+ it('should record audit log entry after successful SSO login', async () => {
+ const loginRes = await request(app).get('/api/auth/entra-id/login').expect(302);
+ const state = new URL(loginRes.headers.location).searchParams.get('state')!;
+
+ const stateEntry = await databaseService.getAdapter().queryOne<{ nonce: string }>(
+ `SELECT nonce FROM oauth_state_store WHERE state = ?`,
+ [state],
+ );
+
+ const idToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce);
+
+ fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input.toString();
+ if (url.includes('/oauth2/v2.0/token')) {
+ return new Response(
+ JSON.stringify({ id_token: idToken, access_token: 'mock-access' }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ if (url.includes('/discovery/v2.0/keys')) {
+ return new Response(
+ JSON.stringify({ keys: [TEST_JWK] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ return new Response('Not found', { status: 404 });
+ });
+
+ const callbackRes = await request(app)
+ .get('/api/auth/entra-id/callback')
+ .query({ code: 'audit-code', state })
+ .expect(302);
+
+ const authCode = new URL(callbackRes.headers.location).searchParams.get('code')!;
+ await request(app).post('/api/auth/entra-id/token').send({ code: authCode }).expect(200);
+
+ // Verify audit log entry exists
+ const auditLogs = await databaseService.getAdapter().query<{
+ eventType: string;
+ action: string;
+ details: string;
+ result: string;
+ }>(
+ `SELECT event_type AS "eventType", "action", details, result
+ FROM audit_logs
+ WHERE event_type = 'auth' AND "action" = 'login_success'
+ ORDER BY timestamp DESC LIMIT 1`,
+ [],
+ );
+
+ expect(auditLogs).toHaveLength(1);
+ expect(auditLogs[0].result).toBe('success');
+
+ const details = JSON.parse(auditLogs[0].details);
+ expect(details.username).toBe('testuser');
+ expect(details.reason).toBe('method=entra-id');
+ });
+ });
+
+ describe('Federation-only account local login rejection', () => {
+ it('should reject local login with HTTP 401 for user with null password_hash', async () => {
+ // First: complete an SSO login to create a federation-only user
+ const loginRes = await request(app).get('/api/auth/entra-id/login').expect(302);
+ const state = new URL(loginRes.headers.location).searchParams.get('state')!;
+
+ const stateEntry = await databaseService.getAdapter().queryOne<{ nonce: string }>(
+ `SELECT nonce FROM oauth_state_store WHERE state = ?`,
+ [state],
+ );
+
+ const idToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce);
+
+ fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input.toString();
+ if (url.includes('/oauth2/v2.0/token')) {
+ return new Response(
+ JSON.stringify({ id_token: idToken, access_token: 'mock-access' }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ if (url.includes('/discovery/v2.0/keys')) {
+ return new Response(
+ JSON.stringify({ keys: [TEST_JWK] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ return new Response('Not found', { status: 404 });
+ });
+
+ const callbackRes = await request(app)
+ .get('/api/auth/entra-id/callback')
+ .query({ code: 'fed-only-code', state })
+ .expect(302);
+
+ const authCode = new URL(callbackRes.headers.location).searchParams.get('code')!;
+ await request(app).post('/api/auth/entra-id/token').send({ code: authCode }).expect(200);
+
+ // Verify user exists and has null password_hash
+ const user = await databaseService.getAdapter().queryOne<{
+ passwordHash: string | null;
+ }>(
+ `SELECT password_hash AS "passwordHash" FROM users WHERE username = 'testuser'`,
+ [],
+ );
+ expect(user).not.toBeNull();
+ expect(user!.passwordHash).toBeNull();
+
+ // Attempt local login with this federation-only user
+ const localLoginRes = await request(app)
+ .post('/api/auth/login')
+ .send({ username: 'testuser', password: 'AnyPassword123!' })
+ .expect(401);
+
+ expect(localLoginRes.body.error).toBeDefined();
+ });
+ });
+
+ describe('Coexistence: local auth continues working when Entra ID enabled', () => {
+ it('should allow local user registration and login while Entra ID is enabled', async () => {
+ // Verify the providers endpoint shows both
+ const providersRes = await request(app)
+ .get('/api/auth/providers')
+ .expect(200);
+
+ expect(providersRes.body.local).toBe(true);
+ expect(providersRes.body.entraId).toBeDefined();
+ expect(providersRes.body.entraId.enabled).toBe(true);
+ expect(providersRes.body.entraId.name).toBe('Microsoft Entra ID');
+
+ // Enable self-registration for the test
+ await databaseService.getAdapter().execute(
+ `INSERT INTO config (key, value, updated_at)
+ VALUES ('allow_self_registration', 'true', datetime('now'))
+ ON CONFLICT(key) DO UPDATE SET value = 'true'`,
+ [],
+ );
+
+ // Register a local user
+ const registerRes = await request(app)
+ .post('/api/auth/register')
+ .send({
+ username: 'localuser',
+ email: 'localuser@example.com',
+ password: 'SecurePass123!',
+ firstName: 'Local',
+ lastName: 'User',
+ })
+ .expect(201);
+
+ expect(registerRes.body.user.username).toBe('localuser');
+
+ // Login with local credentials
+ const localLoginRes = await request(app)
+ .post('/api/auth/login')
+ .send({ username: 'localuser', password: 'SecurePass123!' })
+ .expect(200);
+
+ expect(localLoginRes.body).toHaveProperty('token');
+ expect(localLoginRes.body).toHaveProperty('refreshToken');
+ expect(localLoginRes.body.user.username).toBe('localuser');
+
+ // Simultaneously, Entra ID endpoints are available
+ const entraLoginRes = await request(app)
+ .get('/api/auth/entra-id/login')
+ .expect(302);
+
+ expect(entraLoginRes.headers.location).toContain('login.microsoftonline.com');
+ });
+ });
+});
diff --git a/backend/test/properties/EntraIdAuthCode.property.test.ts b/backend/test/properties/EntraIdAuthCode.property.test.ts
new file mode 100644
index 00000000..e512e715
--- /dev/null
+++ b/backend/test/properties/EntraIdAuthCode.property.test.ts
@@ -0,0 +1,377 @@
+/**
+ * Property-Based Tests for EntraIdService — Authorization Code Single-Use and TTL (Property 16)
+ *
+ * **Validates: Requirements 6.2, 6.3, 6.4**
+ *
+ * Tests the correctness property from the design document:
+ * - Property 16: Authorization code single-use and TTL
+ *
+ * For any successfully generated auth code, the code SHALL have expires_at ≤ 60
+ * seconds from creation. After a successful exchange, any subsequent exchange
+ * attempt with the same code SHALL be rejected. After the code expires, exchange
+ * SHALL also be rejected.
+ */
+
+// Feature: azure-entra-id-auth, Property 16: Authorization code single-use and TTL
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fc from 'fast-check';
+import { randomUUID } from 'crypto';
+
+import { SQLiteAdapter } from '../../src/database/SQLiteAdapter';
+import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter';
+import { initializeTestSchema } from '../helpers/schema';
+import {
+ EntraIdService,
+ EntraIdError,
+ ENTRA_ID_ERROR_CODES,
+} from '../../src/services/EntraIdService';
+import type { EntraIdConfig } from '../../src/config/schema';
+import type { AuthenticationService } from '../../src/services/AuthenticationService';
+import type { UserService } from '../../src/services/UserService';
+import type { RoleService } from '../../src/services/RoleService';
+import type { AuditLoggingService } from '../../src/services/AuditLoggingService';
+import type { LoggerService } from '../../src/services/LoggerService';
+
+// --- Mock Factories ---
+
+function createMockConfig(): EntraIdConfig {
+ return {
+ enabled: true,
+ tenantId: 'test-tenant-id-000',
+ clientId: 'test-client-id-111',
+ clientSecret: 'test-client-secret-222', // pragma: allowlist secret
+ redirectUri: 'http://localhost:3000/api/auth/entra-id/callback',
+ scopes: ['openid', 'profile', 'email'],
+ groupMapping: null,
+ jwksCacheTtlMs: 86400000,
+ };
+}
+
+function createMockLogger(): LoggerService {
+ return {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ shouldLog: vi.fn().mockReturnValue(true),
+ formatMessage: vi.fn().mockReturnValue(''),
+ getLevel: vi.fn().mockReturnValue('info'),
+ setLogBuffer: vi.fn(),
+ getLogBuffer: vi.fn().mockReturnValue(null),
+ } as unknown as LoggerService;
+}
+
+const TEST_USER_ID = 'mock-user-id-001';
+
+const mockUser = {
+ id: TEST_USER_ID,
+ username: 'testuser',
+ email: 'testuser@example.com',
+ passwordHash: '',
+ firstName: 'Test',
+ lastName: 'User',
+ isActive: 1,
+ isAdmin: 0,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ lastLoginAt: null,
+};
+
+// --- Test Suite ---
+
+describe('EntraIdService — Property 16: Authorization code single-use and TTL', () => {
+ let db: DatabaseAdapter;
+ let service: EntraIdService;
+
+ beforeEach(async () => {
+ vi.restoreAllMocks();
+
+ db = new SQLiteAdapter(':memory:');
+ await db.initialize();
+ await initializeTestSchema(db);
+
+ // Insert the test user
+ await db.execute(
+ `INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ mockUser.id, mockUser.username, mockUser.email, '',
+ mockUser.firstName, mockUser.lastName, mockUser.isActive, mockUser.isAdmin,
+ mockUser.createdAt, mockUser.updatedAt,
+ ],
+ );
+
+ const mockAuthService = {
+ generateToken: vi.fn().mockResolvedValue('mock-access-token'),
+ generateRefreshToken: vi.fn().mockResolvedValue('mock-refresh-token'),
+ } as unknown as AuthenticationService;
+
+ const mockUserService = {
+ findByFederatedIdentity: vi.fn().mockResolvedValue(null),
+ findByEmail: vi.fn().mockResolvedValue(null),
+ createFederatedUser: vi.fn().mockResolvedValue(mockUser),
+ getUserById: vi.fn().mockResolvedValue(mockUser),
+ toUserDTO: vi.fn().mockReturnValue({ id: mockUser.id, username: mockUser.username }),
+ getUserRoles: vi.fn().mockResolvedValue([]),
+ } as unknown as UserService;
+
+ const mockRoleService = {
+ listRoles: vi.fn().mockResolvedValue({ items: [], total: 0 }),
+ } as unknown as RoleService;
+
+ const mockAuditLogger = {
+ logAuthenticationAttempt: vi.fn().mockResolvedValue(undefined),
+ } as unknown as AuditLoggingService;
+
+ service = new EntraIdService(
+ db,
+ createMockConfig(),
+ mockAuthService,
+ mockUserService,
+ mockRoleService,
+ mockAuditLogger,
+ createMockLogger(),
+ );
+ });
+
+ afterEach(async () => {
+ await db.close();
+ });
+
+ /**
+ * Test 1: Auth code TTL is always ≤ 60 seconds
+ *
+ * For any successfully generated auth code, expires_at - created_at ≤ 60000ms.
+ */
+ describe('Auth code TTL ≤ 60 seconds', () => {
+ it('generated auth codes always have expires_at ≤ 60s from created_at', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.constant(null),
+ async () => {
+ const code = randomUUID();
+ const now = new Date();
+ const createdAt = now.toISOString();
+ const expiresAt = new Date(now.getTime() + 60 * 1000).toISOString();
+
+ await db.execute(
+ `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
+ [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt],
+ );
+
+ const row = await db.queryOne<{ createdAt: string; expiresAt: string }>(
+ `SELECT created_at AS "createdAt", expires_at AS "expiresAt"
+ FROM oauth_auth_codes WHERE code = ?`,
+ [code],
+ );
+
+ expect(row).not.toBeNull();
+ const created = new Date(row!.createdAt).getTime();
+ const expires = new Date(row!.expiresAt).getTime();
+ const ttlMs = expires - created;
+
+ expect(ttlMs).toBeLessThanOrEqual(60 * 1000);
+ expect(ttlMs).toBeGreaterThan(0);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('exchangeAuthCode succeeds when TTL has not elapsed', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.integer({ min: 1, max: 59 }),
+ async (secondsRemaining) => {
+ const code = randomUUID();
+ const now = new Date();
+ const createdAt = new Date(now.getTime() - (60 - secondsRemaining) * 1000).toISOString();
+ const expiresAt = new Date(now.getTime() + secondsRemaining * 1000).toISOString();
+
+ await db.execute(
+ `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
+ [code, 'access-tok', 'refresh-tok', TEST_USER_ID, 'id-tok', 'entra-id', createdAt, expiresAt],
+ );
+
+ const result = await service.exchangeAuthCode(code);
+ expect(result.accessToken).toBe('access-tok');
+ expect(result.refreshToken).toBe('refresh-tok');
+ expect(result.user).toBeDefined();
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ /**
+ * Test 2: After successful exchange, second exchange with same code throws INVALID_AUTH_CODE
+ *
+ * Single-use guarantee: once a code has been exchanged, any subsequent attempt
+ * SHALL be rejected.
+ */
+ describe('Single-use enforcement', () => {
+ it('rejects second exchange attempt after successful first exchange', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.constant(null),
+ async () => {
+ const code = randomUUID();
+ const now = new Date();
+ const createdAt = now.toISOString();
+ const expiresAt = new Date(now.getTime() + 60 * 1000).toISOString();
+
+ await db.execute(
+ `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
+ [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt],
+ );
+
+ // First exchange succeeds
+ const result = await service.exchangeAuthCode(code);
+ expect(result.accessToken).toBe('at');
+
+ // Second exchange must fail
+ try {
+ await service.exchangeAuthCode(code);
+ expect.fail('Second exchange should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('rejects all subsequent exchanges (N > 1) after first successful exchange', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.integer({ min: 2, max: 5 }),
+ async (attempts) => {
+ const code = randomUUID();
+ const now = new Date();
+ const createdAt = now.toISOString();
+ const expiresAt = new Date(now.getTime() + 60 * 1000).toISOString();
+
+ await db.execute(
+ `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
+ [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt],
+ );
+
+ // First exchange succeeds
+ await service.exchangeAuthCode(code);
+
+ // All subsequent attempts fail
+ for (let i = 0; i < attempts; i++) {
+ try {
+ await service.exchangeAuthCode(code);
+ expect.fail(`Exchange attempt ${i + 2} should have thrown`);
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE);
+ }
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ /**
+ * Test 3: After code expires, exchange throws INVALID_AUTH_CODE
+ *
+ * Expired codes SHALL not be exchangeable regardless of whether they were
+ * previously used.
+ */
+ describe('Expired code rejection', () => {
+ it('rejects exchange of expired codes', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.integer({ min: 1, max: 3600 }),
+ async (secondsExpiredAgo) => {
+ const code = randomUUID();
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() - secondsExpiredAgo * 1000).toISOString();
+ const createdAt = new Date(now.getTime() - secondsExpiredAgo * 1000 - 60000).toISOString();
+
+ await db.execute(
+ `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
+ [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt],
+ );
+
+ try {
+ await service.exchangeAuthCode(code);
+ expect.fail('Expired code exchange should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('rejects expired codes even when never previously exchanged', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.integer({ min: 1, max: 300 }),
+ async (secondsExpired) => {
+ const code = randomUUID();
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() - secondsExpired * 1000).toISOString();
+ const createdAt = new Date(now.getTime() - secondsExpired * 1000 - 60000).toISOString();
+
+ await db.execute(
+ `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
+ [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt],
+ );
+
+ try {
+ await service.exchangeAuthCode(code);
+ expect.fail('Should have thrown for expired code');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ /**
+ * Test 4: Code that was never stored throws INVALID_AUTH_CODE
+ *
+ * Any code not present in the database SHALL be rejected.
+ */
+ describe('Non-existent code rejection', () => {
+ it('rejects codes that were never stored', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.stringMatching(/^[a-f0-9]{16,64}$/),
+ async (randomCode) => {
+ try {
+ await service.exchangeAuthCode(randomCode);
+ expect.fail('Non-existent code should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+});
diff --git a/backend/test/properties/EntraIdCallback.property.test.ts b/backend/test/properties/EntraIdCallback.property.test.ts
new file mode 100644
index 00000000..63faadd0
--- /dev/null
+++ b/backend/test/properties/EntraIdCallback.property.test.ts
@@ -0,0 +1,637 @@
+/**
+ * Property-Based Tests for EntraIdService — Callback Validation (Properties 7–11)
+ *
+ * **Validates: Requirements 3.2, 3.3, 3.4, 3.5, 3.6, 3.10, 9.1, 9.2**
+ *
+ * Tests the five correctness properties from the design document:
+ * - Property 7: State mismatch rejects callback
+ * - Property 8: ID token signature validation
+ * - Property 9: Nonce mismatch rejects token
+ * - Property 10: Audience and issuer validation
+ * - Property 11: State entries deleted after callback processing
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import * as fc from 'fast-check';
+import { generateKeyPairSync, createPublicKey } from 'crypto';
+import jwt from 'jsonwebtoken';
+
+import {
+ EntraIdService,
+ EntraIdError,
+ ENTRA_ID_ERROR_CODES,
+} from '../../src/services/EntraIdService';
+import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter';
+import type { EntraIdConfig } from '../../src/config/schema';
+import type { AuthenticationService } from '../../src/services/AuthenticationService';
+import type { UserService } from '../../src/services/UserService';
+import type { RoleService } from '../../src/services/RoleService';
+import type { AuditLoggingService } from '../../src/services/AuditLoggingService';
+import type { LoggerService } from '../../src/services/LoggerService';
+
+// --- Test RSA Key Pairs ---
+
+function generateTestKeyPair(): { privateKey: string; publicKey: string; kid: string } {
+ const { privateKey, publicKey } = generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ });
+ const kid = `test-kid-${Math.random().toString(36).slice(2, 10)}`;
+ return { privateKey, publicKey, kid };
+}
+
+function pemToJwkComponents(pem: string): { n: string; e: string } {
+ const keyObject = createPublicKey(pem);
+ const jwk = keyObject.export({ format: 'jwk' }) as { n: string; e: string };
+ return { n: jwk.n, e: jwk.e };
+}
+
+// --- Mock Factories ---
+
+function createMockDb(): DatabaseAdapter {
+ return {
+ query: vi.fn().mockResolvedValue([]),
+ queryOne: vi.fn().mockResolvedValue(null),
+ execute: vi.fn().mockResolvedValue({ changes: 0 }),
+ beginTransaction: vi.fn().mockResolvedValue(undefined),
+ commit: vi.fn().mockResolvedValue(undefined),
+ rollback: vi.fn().mockResolvedValue(undefined),
+ withTransaction: vi.fn(),
+ initialize: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ isConnected: vi.fn().mockReturnValue(true),
+ getDialect: vi.fn().mockReturnValue('sqlite' as const),
+ };
+}
+
+function createMockConfig(): EntraIdConfig {
+ return {
+ enabled: true,
+ tenantId: 'test-tenant-id-000',
+ clientId: 'test-client-id-111',
+ clientSecret: 'test-client-secret-222', // pragma: allowlist secret
+ redirectUri: 'http://localhost:3000/api/auth/entra-id/callback',
+ scopes: ['openid', 'profile', 'email'],
+ groupMapping: null,
+ jwksCacheTtlMs: 86400000,
+ };
+}
+
+function createMockLogger(): LoggerService {
+ return {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ shouldLog: vi.fn().mockReturnValue(true),
+ formatMessage: vi.fn().mockReturnValue(''),
+ getLevel: vi.fn().mockReturnValue('info'),
+ setLogBuffer: vi.fn(),
+ getLogBuffer: vi.fn().mockReturnValue(null),
+ } as unknown as LoggerService;
+}
+
+// Pre-generate key pairs (expensive, do once)
+const primaryKey = generateTestKeyPair();
+const primaryJwk = pemToJwkComponents(primaryKey.publicKey);
+
+function buildIdToken(
+ config: EntraIdConfig,
+ nonce: string,
+ overrides: Record = {},
+ signingKey: string = primaryKey.privateKey,
+ kid: string = primaryKey.kid,
+): string {
+ const now = Math.floor(Date.now() / 1000);
+ const payload = {
+ sub: 'test-subject-001',
+ email: 'user@example.com',
+ preferred_username: 'testuser',
+ given_name: 'Test',
+ family_name: 'User',
+ nonce,
+ aud: config.clientId,
+ iss: `https://login.microsoftonline.com/${config.tenantId}/v2.0`,
+ exp: now + 3600,
+ iat: now,
+ ...overrides,
+ };
+
+ return jwt.sign(payload, signingKey, {
+ algorithm: 'RS256',
+ header: { alg: 'RS256', kid, typ: 'JWT' },
+ });
+}
+
+function mockFetchForToken(idToken: string): void {
+ const jwksResponse = {
+ keys: [{
+ kty: 'RSA',
+ use: 'sig',
+ kid: primaryKey.kid,
+ n: primaryJwk.n,
+ e: primaryJwk.e,
+ }],
+ };
+
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
+ if (url.includes('/oauth2/v2.0/token')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({
+ id_token: idToken,
+ access_token: 'mock-access-token',
+ }),
+ });
+ }
+ if (url.includes('/discovery/v2.0/keys')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(jwksResponse),
+ });
+ }
+ return Promise.reject(new Error(`Unexpected fetch: ${url}`));
+ }));
+}
+
+function mockFetchThatTracksTokenCalls(): { fetchMock: ReturnType; getTokenCalls: () => unknown[][] } {
+ const fetchMock = vi.fn().mockImplementation((url: string) => {
+ if (url.includes('/oauth2/v2.0/token')) {
+ // Return a valid-looking response to not throw before we check
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ id_token: 'x', access_token: 'y' }),
+ });
+ }
+ if (url.includes('/discovery/v2.0/keys')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ keys: [] }),
+ });
+ }
+ return Promise.reject(new Error(`Unexpected fetch: ${url}`));
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ return {
+ fetchMock,
+ getTokenCalls: () => fetchMock.mock.calls.filter(
+ (call: unknown[]) => String(call[0]).includes('/oauth2/v2.0/token'),
+ ),
+ };
+}
+
+// --- Arbitraries ---
+const hexStringArb = fc.stringMatching(/^[a-f0-9]{16,64}$/);
+const codeArb = fc.stringMatching(/^[a-f0-9]{8,32}$/);
+
+describe('EntraIdService — Callback Validation Properties', () => {
+ let db: DatabaseAdapter;
+ let config: EntraIdConfig;
+ let logger: LoggerService;
+ let service: EntraIdService;
+
+ const mockUser = {
+ id: 'mock-user-id-001',
+ username: 'testuser',
+ email: 'testuser@example.com',
+ passwordHash: '',
+ firstName: 'Test',
+ lastName: 'User',
+ isActive: 1,
+ isAdmin: 0,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ lastLoginAt: null,
+ };
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ db = createMockDb();
+ config = createMockConfig();
+ logger = createMockLogger();
+
+ const mockAuthService = {
+ generateToken: vi.fn().mockResolvedValue('mock-access-token'),
+ generateRefreshToken: vi.fn().mockResolvedValue('mock-refresh-token'),
+ } as unknown as AuthenticationService;
+
+ const mockUserService = {
+ findByFederatedIdentity: vi.fn().mockResolvedValue(null),
+ findByEmail: vi.fn().mockResolvedValue(null),
+ createFederatedUser: vi.fn().mockResolvedValue(mockUser),
+ getUserById: vi.fn().mockResolvedValue(mockUser),
+ toUserDTO: vi.fn().mockReturnValue({ id: mockUser.id, username: mockUser.username }),
+ getUserRoles: vi.fn().mockResolvedValue([]),
+ } as unknown as UserService;
+
+ const mockRoleService = {
+ listRoles: vi.fn().mockResolvedValue({ items: [], total: 0 }),
+ } as unknown as RoleService;
+
+ const mockAuditLogger = {
+ logAuthenticationAttempt: vi.fn().mockResolvedValue(undefined),
+ } as unknown as AuditLoggingService;
+
+ service = new EntraIdService(
+ db,
+ config,
+ mockAuthService,
+ mockUserService,
+ mockRoleService,
+ mockAuditLogger,
+ logger,
+ );
+ });
+
+ // Feature: azure-entra-id-auth, Property 7: State mismatch rejects callback
+ /**
+ * Property 7: State mismatch rejects callback
+ *
+ * **Validates: Requirements 3.2, 3.6, 9.1**
+ *
+ * For any callback request where the state query parameter does not exactly
+ * match the stored state value (including missing, empty, or expired state),
+ * the service SHALL reject the request with INVALID_STATE without contacting
+ * the token endpoint.
+ */
+ describe('Property 7: State mismatch rejects callback', () => {
+ it('rejects with INVALID_STATE when state is not found in store', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ hexStringArb,
+ codeArb,
+ async (state, code) => {
+ (db.queryOne as ReturnType).mockResolvedValue(null);
+ const { getTokenCalls } = mockFetchThatTracksTokenCalls();
+
+ try {
+ await service.handleCallback(code, state);
+ expect.fail('Should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_STATE);
+ }
+
+ // Token endpoint must NOT have been called
+ expect(getTokenCalls()).toHaveLength(0);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('rejects with INVALID_STATE when state entry is expired', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ hexStringArb,
+ codeArb,
+ fc.integer({ min: 1, max: 60 }),
+ async (state, code, minutesAgo) => {
+ const now = new Date();
+ const expiredAt = new Date(now.getTime() - minutesAgo * 60 * 1000);
+
+ (db.queryOne as ReturnType).mockResolvedValue({
+ state,
+ nonce: 'test-nonce',
+ code_verifier: 'test-verifier-value-that-is-long-enough',
+ created_at: new Date(expiredAt.getTime() - 10 * 60 * 1000).toISOString(),
+ expires_at: expiredAt.toISOString(),
+ });
+
+ const { getTokenCalls } = mockFetchThatTracksTokenCalls();
+
+ try {
+ await service.handleCallback(code, state);
+ expect.fail('Should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_STATE);
+ }
+
+ // Token endpoint must NOT have been called
+ expect(getTokenCalls()).toHaveLength(0);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ // Feature: azure-entra-id-auth, Property 8: ID token signature validation
+ /**
+ * Property 8: ID token signature validation
+ *
+ * **Validates: Requirements 3.3**
+ *
+ * For any JWT signed with a key present in the JWKS key set, signature
+ * validation SHALL pass. For any JWT signed with a key NOT in the JWKS key
+ * set, signature validation SHALL fail with INVALID_ID_TOKEN.
+ */
+ describe('Property 8: ID token signature validation', () => {
+ const storedNonce = 'stored-nonce-value-for-sig-test';
+
+ function setupValidStateEntry(): void {
+ const future = new Date(Date.now() + 5 * 60 * 1000).toISOString();
+ (db.queryOne as ReturnType).mockResolvedValue({
+ state: 'valid-state',
+ nonce: storedNonce,
+ code_verifier: 'valid-code-verifier-value-here-long',
+ created_at: new Date().toISOString(),
+ expires_at: future,
+ });
+ }
+
+ it('accepts tokens signed with a key present in JWKS', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ codeArb,
+ async (code) => {
+ setupValidStateEntry();
+ const idToken = buildIdToken(config, storedNonce);
+ mockFetchForToken(idToken);
+
+ const result = await service.handleCallback(code, 'valid-state');
+ expect(result.userId).toBe('mock-user-id-001');
+ expect(result.authMethod).toBe('entra-id');
+ expect(result.code).toBeDefined();
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('rejects tokens signed with a key NOT in JWKS', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ codeArb,
+ async (code) => {
+ setupValidStateEntry();
+
+ // Sign with a different key not in JWKS
+ const wrongKey = generateTestKeyPair();
+ const idToken = buildIdToken(
+ config,
+ storedNonce,
+ {},
+ wrongKey.privateKey,
+ wrongKey.kid,
+ );
+ mockFetchForToken(idToken);
+
+ try {
+ await service.handleCallback(code, 'valid-state');
+ expect.fail('Should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ // Feature: azure-entra-id-auth, Property 9: Nonce mismatch rejects token
+ /**
+ * Property 9: Nonce mismatch rejects token
+ *
+ * **Validates: Requirements 3.4, 9.2**
+ *
+ * For any ID token where the nonce claim does not match the stored nonce
+ * value, the service SHALL reject with INVALID_ID_TOKEN.
+ */
+ describe('Property 9: Nonce mismatch rejects token', () => {
+ const noncePairArb = fc
+ .tuple(
+ fc.stringMatching(/^[a-f0-9]{16,64}$/),
+ fc.stringMatching(/^[a-f0-9]{16,64}$/),
+ )
+ .filter(([a, b]) => a !== b);
+
+ it('rejects when token nonce does not match stored nonce', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ noncePairArb,
+ codeArb,
+ async ([storedNonce, tokenNonce], code) => {
+ const future = new Date(Date.now() + 5 * 60 * 1000).toISOString();
+ (db.queryOne as ReturnType).mockResolvedValue({
+ state: 'valid-state',
+ nonce: storedNonce,
+ code_verifier: 'valid-code-verifier-value-here-long',
+ created_at: new Date().toISOString(),
+ expires_at: future,
+ });
+
+ // Valid signature but wrong nonce in token
+ const idToken = buildIdToken(config, tokenNonce);
+ mockFetchForToken(idToken);
+
+ try {
+ await service.handleCallback(code, 'valid-state');
+ expect.fail('Should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ // Feature: azure-entra-id-auth, Property 10: Audience and issuer validation
+ /**
+ * Property 10: Audience and issuer validation
+ *
+ * **Validates: Requirements 3.5**
+ *
+ * For any ID token where aud ≠ clientId OR iss ≠ expected issuer URL, the
+ * service SHALL reject with INVALID_ID_TOKEN.
+ */
+ describe('Property 10: Audience and issuer validation', () => {
+ const storedNonce = 'stored-nonce-for-aud-iss-test';
+
+ function setupValidState(): void {
+ const future = new Date(Date.now() + 5 * 60 * 1000).toISOString();
+ (db.queryOne as ReturnType).mockResolvedValue({
+ state: 'valid-state',
+ nonce: storedNonce,
+ code_verifier: 'valid-code-verifier-value-here-long',
+ created_at: new Date().toISOString(),
+ expires_at: future,
+ });
+ }
+
+ const wrongAudienceArb = fc
+ .stringMatching(/^[a-z0-9-]{8,40}$/)
+ .filter((s) => s !== 'test-client-id-111');
+
+ const wrongTenantArb = fc
+ .stringMatching(/^[a-z0-9-]{8,40}$/)
+ .filter((s) => s !== 'test-tenant-id-000');
+
+ it('rejects when audience does not match configured clientId', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ wrongAudienceArb,
+ codeArb,
+ async (wrongAud, code) => {
+ setupValidState();
+ const idToken = buildIdToken(config, storedNonce, { aud: wrongAud });
+ mockFetchForToken(idToken);
+
+ try {
+ await service.handleCallback(code, 'valid-state');
+ expect.fail('Should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('rejects when issuer does not match expected tenant URL', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ wrongTenantArb,
+ codeArb,
+ async (wrongTenant, code) => {
+ setupValidState();
+ const wrongIss = `https://login.microsoftonline.com/${wrongTenant}/v2.0`;
+ const idToken = buildIdToken(config, storedNonce, { iss: wrongIss });
+ mockFetchForToken(idToken);
+
+ try {
+ await service.handleCallback(code, 'valid-state');
+ expect.fail('Should have thrown EntraIdError');
+ } catch (err) {
+ expect(err).toBeInstanceOf(EntraIdError);
+ expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ // Feature: azure-entra-id-auth, Property 11: State entries deleted after callback processing
+ /**
+ * Property 11: State entries deleted after callback processing
+ *
+ * **Validates: Requirements 3.10**
+ *
+ * For any callback execution (whether successful or failed), the
+ * oauth_state_store entry SHALL be deleted.
+ */
+ describe('Property 11: State entries deleted after callback processing', () => {
+ const storedNonce = 'stored-nonce-for-deletion-test';
+
+ function assertStateDeleted(state: string): void {
+ const executeCalls = (db.execute as ReturnType).mock.calls;
+ const deleteCalls = executeCalls.filter(
+ (call: unknown[]) => String(call[0]).includes('DELETE FROM oauth_state_store'),
+ );
+ expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
+ const stateDeleteCall = deleteCalls.find(
+ (call: unknown[]) => (call[1] as unknown[]).includes(state),
+ );
+ expect(stateDeleteCall).toBeDefined();
+ }
+
+ it('deletes state entry on successful callback', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ hexStringArb,
+ codeArb,
+ async (state, code) => {
+ const future = new Date(Date.now() + 5 * 60 * 1000).toISOString();
+ (db.queryOne as ReturnType).mockResolvedValue({
+ state,
+ nonce: storedNonce,
+ code_verifier: 'valid-code-verifier-value-here-long',
+ created_at: new Date().toISOString(),
+ expires_at: future,
+ });
+
+ const idToken = buildIdToken(config, storedNonce);
+ mockFetchForToken(idToken);
+
+ await service.handleCallback(code, state);
+ assertStateDeleted(state);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('deletes state entry even when state is not found (failed callback)', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ hexStringArb,
+ codeArb,
+ async (state, code) => {
+ (db.queryOne as ReturnType).mockResolvedValue(null);
+ mockFetchThatTracksTokenCalls();
+
+ try {
+ await service.handleCallback(code, state);
+ } catch {
+ // Expected INVALID_STATE
+ }
+
+ assertStateDeleted(state);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('deletes state entry when token validation fails', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ hexStringArb,
+ codeArb,
+ async (state, code) => {
+ const future = new Date(Date.now() + 5 * 60 * 1000).toISOString();
+ (db.queryOne as ReturnType).mockResolvedValue({
+ state,
+ nonce: storedNonce,
+ code_verifier: 'valid-code-verifier-value-here-long',
+ created_at: new Date().toISOString(),
+ expires_at: future,
+ });
+
+ // Sign with wrong key → INVALID_ID_TOKEN
+ const wrongKey = generateTestKeyPair();
+ const idToken = buildIdToken(
+ config,
+ storedNonce,
+ {},
+ wrongKey.privateKey,
+ wrongKey.kid,
+ );
+ mockFetchForToken(idToken);
+
+ try {
+ await service.handleCallback(code, state);
+ } catch {
+ // Expected INVALID_ID_TOKEN
+ }
+
+ assertStateDeleted(state);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+});
diff --git a/backend/test/properties/EntraIdGroupSync.property.test.ts b/backend/test/properties/EntraIdGroupSync.property.test.ts
new file mode 100644
index 00000000..39bfbae6
--- /dev/null
+++ b/backend/test/properties/EntraIdGroupSync.property.test.ts
@@ -0,0 +1,462 @@
+/**
+ * Property-Based Test for EntraIdService — Group-to-Role Synchronization (Property 15)
+ *
+ * Feature: azure-entra-id-auth, Property 15: Group-to-role synchronization correctness
+ *
+ * **Validates: Requirements 5.1, 5.2, 5.3**
+ *
+ * For any group mapping configuration and any groups claim array (with UUIDs
+ * in any case), the user SHALL end up with exactly the Pabawi roles whose
+ * group IDs are present in both the mapping keys (case-insensitive comparison)
+ * and the groups claim, plus any roles that were not part of the mapping
+ * (manually assigned). Roles previously assigned by the mapping whose group
+ * IDs are no longer in the claim SHALL be revoked.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import * as fc from 'fast-check';
+
+import { EntraIdService } from '../../src/services/EntraIdService';
+import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter';
+import type { EntraIdConfig } from '../../src/config/schema';
+import type { AuthenticationService } from '../../src/services/AuthenticationService';
+import type { UserService } from '../../src/services/UserService';
+import type { RoleService } from '../../src/services/RoleService';
+import type { Role } from '../../src/services/RoleService';
+import type { AuditLoggingService } from '../../src/services/AuditLoggingService';
+import type { LoggerService } from '../../src/services/LoggerService';
+
+// --- Mock Factories ---
+
+function createMockDb(): DatabaseAdapter {
+ return {
+ query: vi.fn().mockResolvedValue([]),
+ queryOne: vi.fn().mockResolvedValue(null),
+ execute: vi.fn().mockResolvedValue({ changes: 0 }),
+ beginTransaction: vi.fn().mockResolvedValue(undefined),
+ commit: vi.fn().mockResolvedValue(undefined),
+ rollback: vi.fn().mockResolvedValue(undefined),
+ withTransaction: vi.fn(),
+ initialize: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ isConnected: vi.fn().mockReturnValue(true),
+ getDialect: vi.fn().mockReturnValue('sqlite' as const),
+ };
+}
+
+function createMockLogger(): LoggerService {
+ return {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ shouldLog: vi.fn().mockReturnValue(true),
+ formatMessage: vi.fn().mockReturnValue(''),
+ getLevel: vi.fn().mockReturnValue('info'),
+ setLogBuffer: vi.fn(),
+ getLogBuffer: vi.fn().mockReturnValue(null),
+ } as unknown as LoggerService;
+}
+
+function makeRole(id: string, name: string): Role {
+ return {
+ id,
+ name,
+ description: `Role: ${name}`,
+ isBuiltIn: 0,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ };
+}
+
+// --- Arbitraries ---
+
+/** Generate a UUID string (lowercase by default from fc.uuid()) */
+const uuidArb = fc.uuid();
+
+/** Role name arbitrary */
+const roleNameArb = fc.stringMatching(/^[a-z][a-z0-9_]{2,15}$/);
+
+/**
+ * Generates a complete test scenario for group-to-role sync:
+ * - A set of available Pabawi roles (with unique names and IDs)
+ * - A group mapping: groupId → roleName (referencing some of the available roles, and possibly some non-existent ones)
+ * - A groups claim: list of group UUIDs (some matching mapping keys, some not) in random case
+ * - Existing user roles: some from the mapping, some manual (not in mapping)
+ */
+interface SyncScenario {
+ /** All roles that exist in Pabawi */
+ availableRoles: Role[];
+ /** The group mapping config: groupId → roleName */
+ groupMapping: Record;
+ /** The groups claim from the ID token (UUIDs in mixed case) */
+ groupsClaim: string[];
+ /** Roles currently assigned to the user */
+ existingUserRoles: Role[];
+ /** Expected final role set (role IDs) after sync */
+ expectedFinalRoleIds: Set;
+}
+
+const syncScenarioArb: fc.Arbitrary = fc.tuple(
+ // Generate 2–8 available roles
+ fc.integer({ min: 2, max: 8 }),
+ // Generate 1–6 mapping entries
+ fc.integer({ min: 1, max: 6 }),
+ // Seed for randomizing which mapping entries have valid roles
+ fc.infiniteStream(fc.boolean()),
+ // Seed for randomizing which groups appear in claim
+ fc.infiniteStream(fc.boolean()),
+ // Seed for randomizing which mapped roles user currently has
+ fc.infiniteStream(fc.boolean()),
+ // Seed for randomizing which non-mapped roles user currently has
+ fc.infiniteStream(fc.boolean()),
+ // UUIDs for group IDs
+ fc.array(uuidArb, { minLength: 8, maxLength: 14 }),
+ // Role names
+ fc.array(roleNameArb, { minLength: 10, maxLength: 14 }),
+ // Booleans for case randomization
+ fc.infiniteStream(fc.boolean()),
+).map(([
+ numRoles,
+ numMappings,
+ validRoleStream,
+ groupInClaimStream,
+ userHasMappedRoleStream,
+ userHasManualRoleStream,
+ uuids,
+ roleNames,
+ caseStream,
+]) => {
+ // Deduplicate role names
+ const uniqueRoleNames = [...new Set(roleNames)].slice(0, numRoles);
+ if (uniqueRoleNames.length < 2) {
+ uniqueRoleNames.push('fallback_role_a', 'fallback_role_b');
+ }
+
+ // Create available roles
+ const availableRoles: Role[] = uniqueRoleNames.map((name, i) =>
+ makeRole(`role-id-${String(i)}`, name),
+ );
+
+ // Create group mapping
+ const groupMapping: Record = {};
+ const mappingGroupIds: string[] = [];
+ const validRoleIterator = validRoleStream[Symbol.iterator]();
+ const actualNumMappings = Math.min(numMappings, uuids.length);
+
+ for (let i = 0; i < actualNumMappings; i++) {
+ const groupId = uuids[i];
+ const useValidRole = validRoleIterator.next().value;
+ if (useValidRole && availableRoles.length > 0) {
+ // Map to an existing role
+ const roleIdx = i % availableRoles.length;
+ groupMapping[groupId] = availableRoles[roleIdx].name;
+ } else {
+ // Map to a non-existent role (should be skipped with warning)
+ groupMapping[groupId] = `nonexistent_role_${String(i)}`;
+ }
+ mappingGroupIds.push(groupId);
+ }
+
+ // Create groups claim — include some mapping group IDs (with random case) and some extra
+ const groupsClaim: string[] = [];
+ const groupInClaimIterator = groupInClaimStream[Symbol.iterator]();
+ const caseIterator = caseStream[Symbol.iterator]();
+
+ for (const gid of mappingGroupIds) {
+ const includeInClaim = groupInClaimIterator.next().value;
+ if (includeInClaim) {
+ // Apply random case to the group ID
+ const casedGid = gid.split('').map((ch) => {
+ const upper = caseIterator.next().value;
+ return upper ? ch.toUpperCase() : ch.toLowerCase();
+ }).join('');
+ groupsClaim.push(casedGid);
+ }
+ }
+ // Add some extra UUIDs not in the mapping
+ for (let i = actualNumMappings; i < uuids.length && i < actualNumMappings + 3; i++) {
+ groupsClaim.push(uuids[i]);
+ }
+
+ // Determine which role IDs are managed by the mapping (only valid ones)
+ const rolesByNameLower = new Map(availableRoles.map((r) => [r.name.toLowerCase(), r]));
+ const managedRoleIds = new Set();
+ const shouldHaveRoleIds = new Set();
+ const normalizedClaim = new Set(groupsClaim.map((g) => g.toLowerCase()));
+
+ for (const [groupId, roleName] of Object.entries(groupMapping)) {
+ const role = rolesByNameLower.get(roleName.toLowerCase());
+ if (!role) continue; // Non-existent role, skipped
+ managedRoleIds.add(role.id);
+ if (normalizedClaim.has(groupId.toLowerCase())) {
+ shouldHaveRoleIds.add(role.id);
+ }
+ }
+
+ // Create existing user roles — mix of mapped and manual
+ const existingUserRoles: Role[] = [];
+ const userHasMappedIterator = userHasMappedRoleStream[Symbol.iterator]();
+ const userHasManualIterator = userHasManualRoleStream[Symbol.iterator]();
+
+ // Add some mapped roles (simulates previously synced roles)
+ for (const roleId of managedRoleIds) {
+ if (userHasMappedIterator.next().value) {
+ const role = availableRoles.find((r) => r.id === roleId);
+ if (role) existingUserRoles.push(role);
+ }
+ }
+
+ // Add some manual roles (not in the mapping)
+ for (const role of availableRoles) {
+ if (!managedRoleIds.has(role.id) && userHasManualIterator.next().value) {
+ existingUserRoles.push(role);
+ }
+ }
+
+ // Compute expected final role set:
+ // = (manual roles not managed by mapping) ∪ (mapped roles the user should have)
+ const manualRoleIds = new Set(
+ existingUserRoles
+ .filter((r) => !managedRoleIds.has(r.id))
+ .map((r) => r.id),
+ );
+
+ const expectedFinalRoleIds = new Set([...manualRoleIds, ...shouldHaveRoleIds]);
+
+ return {
+ availableRoles,
+ groupMapping,
+ groupsClaim,
+ existingUserRoles,
+ expectedFinalRoleIds,
+ };
+});
+
+describe('EntraIdService — Group-to-Role Sync Property', () => {
+ let db: DatabaseAdapter;
+ let logger: LoggerService;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ db = createMockDb();
+ logger = createMockLogger();
+ });
+
+ // Feature: azure-entra-id-auth, Property 15: Group-to-role synchronization correctness
+ /**
+ * Property 15: Group-to-role synchronization correctness
+ *
+ * **Validates: Requirements 5.1, 5.2, 5.3**
+ *
+ * For any group mapping configuration and any groups claim array (with UUIDs
+ * in any case), the user SHALL end up with exactly the Pabawi roles whose
+ * group IDs are present in both the mapping keys (case-insensitive comparison)
+ * and the groups claim, plus any roles that were not part of the mapping
+ * (manually assigned). Roles previously assigned by the mapping whose group
+ * IDs are no longer in the claim SHALL be revoked.
+ */
+ describe('Property 15: Group-to-role synchronization correctness', () => {
+ it('after sync, user roles = (manual roles not in mapping) ∪ (mapped roles for matching groups)', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ syncScenarioArb,
+ async (scenario) => {
+ const { availableRoles, groupMapping, groupsClaim, existingUserRoles, expectedFinalRoleIds } = scenario;
+
+ // Track role mutations
+ const assignedRoleIds = new Set();
+ const removedRoleIds = new Set();
+
+ const mockUserService = {
+ getUserRoles: vi.fn().mockResolvedValue(existingUserRoles),
+ assignRoleToUser: vi.fn().mockImplementation((_userId: string, roleId: string) => {
+ assignedRoleIds.add(roleId);
+ return Promise.resolve();
+ }),
+ removeRoleFromUser: vi.fn().mockImplementation((_userId: string, roleId: string) => {
+ removedRoleIds.add(roleId);
+ return Promise.resolve();
+ }),
+ } as unknown as UserService;
+
+ const mockRoleService = {
+ listRoles: vi.fn().mockResolvedValue({
+ items: availableRoles,
+ total: availableRoles.length,
+ }),
+ } as unknown as RoleService;
+
+ const config: EntraIdConfig = {
+ enabled: true,
+ tenantId: 'test-tenant',
+ clientId: 'test-client',
+ clientSecret: 'test-secret', // pragma: allowlist secret
+ redirectUri: 'http://localhost:3000/callback',
+ scopes: ['openid', 'profile', 'email'],
+ groupMapping,
+ jwksCacheTtlMs: 86400000,
+ };
+
+ const service = new EntraIdService(
+ db,
+ config,
+ {} as AuthenticationService,
+ mockUserService,
+ mockRoleService,
+ {} as AuditLoggingService,
+ logger,
+ );
+
+ const userId = 'test-user-id';
+ await service.syncGroupRoles(userId, groupsClaim);
+
+ // Compute actual final role set by applying mutations to existing roles
+ const existingRoleIds = new Set(existingUserRoles.map((r) => r.id));
+ const finalRoleIds = new Set();
+
+ // Start with existing roles
+ for (const id of existingRoleIds) {
+ if (!removedRoleIds.has(id)) {
+ finalRoleIds.add(id);
+ }
+ }
+ // Add newly assigned roles
+ for (const id of assignedRoleIds) {
+ finalRoleIds.add(id);
+ }
+
+ // Assert final role set matches expected
+ expect(finalRoleIds).toEqual(expectedFinalRoleIds);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('does not assign roles the user already has', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ syncScenarioArb,
+ async (scenario) => {
+ const { availableRoles, groupMapping, groupsClaim, existingUserRoles } = scenario;
+
+ const assignCalls: Array<{ userId: string; roleId: string }> = [];
+
+ const mockUserService = {
+ getUserRoles: vi.fn().mockResolvedValue(existingUserRoles),
+ assignRoleToUser: vi.fn().mockImplementation((userId: string, roleId: string) => {
+ assignCalls.push({ userId, roleId });
+ return Promise.resolve();
+ }),
+ removeRoleFromUser: vi.fn().mockResolvedValue(undefined),
+ } as unknown as UserService;
+
+ const mockRoleService = {
+ listRoles: vi.fn().mockResolvedValue({
+ items: availableRoles,
+ total: availableRoles.length,
+ }),
+ } as unknown as RoleService;
+
+ const config: EntraIdConfig = {
+ enabled: true,
+ tenantId: 'test-tenant',
+ clientId: 'test-client',
+ clientSecret: 'test-secret', // pragma: allowlist secret
+ redirectUri: 'http://localhost:3000/callback',
+ scopes: ['openid', 'profile', 'email'],
+ groupMapping,
+ jwksCacheTtlMs: 86400000,
+ };
+
+ const service = new EntraIdService(
+ db,
+ config,
+ {} as AuthenticationService,
+ mockUserService,
+ mockRoleService,
+ {} as AuditLoggingService,
+ logger,
+ );
+
+ await service.syncGroupRoles('test-user-id', groupsClaim);
+
+ // No assign call should be for a role the user already has
+ const existingRoleIds = new Set(existingUserRoles.map((r) => r.id));
+ for (const call of assignCalls) {
+ expect(existingRoleIds.has(call.roleId)).toBe(false);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('does not remove roles that are not managed by the mapping', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ syncScenarioArb,
+ async (scenario) => {
+ const { availableRoles, groupMapping, groupsClaim, existingUserRoles } = scenario;
+
+ const removeCalls: Array<{ userId: string; roleId: string }> = [];
+
+ const mockUserService = {
+ getUserRoles: vi.fn().mockResolvedValue(existingUserRoles),
+ assignRoleToUser: vi.fn().mockResolvedValue(undefined),
+ removeRoleFromUser: vi.fn().mockImplementation((userId: string, roleId: string) => {
+ removeCalls.push({ userId, roleId });
+ return Promise.resolve();
+ }),
+ } as unknown as UserService;
+
+ const mockRoleService = {
+ listRoles: vi.fn().mockResolvedValue({
+ items: availableRoles,
+ total: availableRoles.length,
+ }),
+ } as unknown as RoleService;
+
+ const config: EntraIdConfig = {
+ enabled: true,
+ tenantId: 'test-tenant',
+ clientId: 'test-client',
+ clientSecret: 'test-secret', // pragma: allowlist secret
+ redirectUri: 'http://localhost:3000/callback',
+ scopes: ['openid', 'profile', 'email'],
+ groupMapping,
+ jwksCacheTtlMs: 86400000,
+ };
+
+ const service = new EntraIdService(
+ db,
+ config,
+ {} as AuthenticationService,
+ mockUserService,
+ mockRoleService,
+ {} as AuditLoggingService,
+ logger,
+ );
+
+ await service.syncGroupRoles('test-user-id', groupsClaim);
+
+ // Determine which role IDs are managed by the mapping
+ const rolesByNameLower = new Map(availableRoles.map((r) => [r.name.toLowerCase(), r]));
+ const managedRoleIds = new Set();
+ for (const roleName of Object.values(groupMapping)) {
+ const role = rolesByNameLower.get(roleName.toLowerCase());
+ if (role) managedRoleIds.add(role.id);
+ }
+
+ // Every removed role must be a managed role
+ for (const call of removeCalls) {
+ expect(managedRoleIds.has(call.roleId)).toBe(true);
+ }
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+});
diff --git a/backend/test/properties/EntraIdProviders.property.test.ts b/backend/test/properties/EntraIdProviders.property.test.ts
new file mode 100644
index 00000000..161e63ac
--- /dev/null
+++ b/backend/test/properties/EntraIdProviders.property.test.ts
@@ -0,0 +1,223 @@
+/**
+ * Property-Based Tests for Providers Endpoint — Property 17
+ *
+ * **Validates: Requirements 11.2**
+ *
+ * Tests the correctness property from the design document:
+ * - Property 17: Providers endpoint always includes local authentication
+ *
+ * For any application configuration state (Entra ID enabled or disabled, any
+ * combination of integrations), the `GET /api/auth/providers` response SHALL
+ * always contain `{ "local": true }`.
+ */
+
+// Feature: azure-entra-id-auth, Property 17: Providers endpoint always includes local authentication
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fc from 'fast-check';
+import express from 'express';
+import request from 'supertest';
+
+import { createAuthRouter } from '../../src/routes/auth.ts';
+import { SQLiteAdapter } from '../../src/database/SQLiteAdapter';
+import { DatabaseService } from '../../src/database/DatabaseService';
+import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter';
+import { DIContainer } from '../../src/container/DIContainer';
+import { LoggerService } from '../../src/services/LoggerService';
+import { ExpertModeService } from '../../src/services/ExpertModeService';
+import { ConfigService } from '../../src/config/ConfigService';
+import { initializeTestSchema } from '../helpers/schema';
+
+/**
+ * Arbitrary that generates a configuration state for the test.
+ * - entraIdEnabled: whether an EntraIdService mock is registered
+ * - extraServices: random additional service keys registered on the container (noise)
+ */
+interface ConfigState {
+ entraIdEnabled: boolean;
+ extraServiceKeys: string[];
+ providerName: string;
+}
+
+const configStateArb: fc.Arbitrary = fc.record({
+ entraIdEnabled: fc.boolean(),
+ extraServiceKeys: fc.array(
+ fc.stringMatching(/^[a-z][a-zA-Z0-9]{2,20}$/),
+ { minLength: 0, maxLength: 5 },
+ ),
+ providerName: fc.constantFrom(
+ 'Microsoft Entra ID',
+ 'Azure AD',
+ 'Custom SSO Provider',
+ ),
+});
+
+describe('Providers Endpoint — Property 17: Providers endpoint always includes local authentication', () => {
+ let db: DatabaseAdapter;
+ let databaseService: DatabaseService;
+
+ beforeEach(async () => {
+ vi.restoreAllMocks();
+
+ // Minimal env for ConfigService
+ process.env.JWT_SECRET = 'test-jwt-secret-for-property-tests-minimum-32chars!!'; // pragma: allowlist secret
+ process.env.HOST = 'localhost';
+ process.env.PORT = '3000';
+
+ db = new SQLiteAdapter(':memory:');
+ await db.initialize();
+ await initializeTestSchema(db);
+
+ databaseService = {
+ getAdapter: () => db,
+ } as unknown as DatabaseService;
+ });
+
+ afterEach(async () => {
+ await db.close();
+ delete process.env.JWT_SECRET;
+ delete process.env.HOST;
+ delete process.env.PORT;
+ });
+
+ /**
+ * Build a container with the given configuration state.
+ * Optionally registers a mock EntraIdService on the "entraId" key.
+ */
+ function buildContainer(state: ConfigState): DIContainer {
+ const container = new DIContainer();
+ container.register('logger', new LoggerService());
+ container.register('expertMode', new ExpertModeService());
+ container.register('config', new ConfigService());
+
+ // Access internal service map to register additional keys (simulates other integrations)
+ const services = (container as unknown as { services: Map }).services;
+
+ // Register noise services (random integrations that should not affect providers)
+ for (const key of state.extraServiceKeys) {
+ services.set(key, { name: key });
+ }
+
+ // Conditionally register EntraIdService mock
+ if (state.entraIdEnabled) {
+ services.set('entraId', {
+ getProviderInfo: () => ({ enabled: true as const, name: state.providerName }),
+ });
+ }
+
+ return container;
+ }
+
+ /**
+ * Build an Express app with the auth router mounted at /api/auth.
+ */
+ function buildApp(container: DIContainer): express.Application {
+ const app = express();
+ app.use(express.json());
+ const router = createAuthRouter(databaseService, container);
+ app.use('/api/auth', router);
+ return app;
+ }
+
+ /**
+ * Property: For ANY configuration state, `GET /api/auth/providers`
+ * response always contains `{ local: true }`.
+ */
+ it('response always contains local: true regardless of configuration', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ configStateArb,
+ async (state) => {
+ const container = buildContainer(state);
+ const app = buildApp(container);
+
+ const res = await request(app)
+ .get('/api/auth/providers')
+ .expect(200);
+
+ // Core invariant: local is always true
+ expect(res.body.local).toBe(true);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ /**
+ * Property: When Entra ID service is present, response also contains entraId info.
+ */
+ it('includes entraId provider info when EntraIdService is registered', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ configStateArb.filter((s) => s.entraIdEnabled),
+ async (state) => {
+ const container = buildContainer(state);
+ const app = buildApp(container);
+
+ const res = await request(app)
+ .get('/api/auth/providers')
+ .expect(200);
+
+ // local is still present
+ expect(res.body.local).toBe(true);
+
+ // entraId info is present
+ expect(res.body.entraId).toBeDefined();
+ expect(res.body.entraId.enabled).toBe(true);
+ expect(res.body.entraId.name).toBe(state.providerName);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ /**
+ * Property: When Entra ID service is absent, response does NOT contain entraId key.
+ */
+ it('omits entraId key when EntraIdService is not registered', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ configStateArb.filter((s) => !s.entraIdEnabled),
+ async (state) => {
+ const container = buildContainer(state);
+ const app = buildApp(container);
+
+ const res = await request(app)
+ .get('/api/auth/providers');
+
+ expect(res.status).toBe(200);
+
+ // local is always present
+ expect(res.body.local).toBe(true);
+
+ // entraId key should be absent
+ expect(res.body.entraId).toBeUndefined();
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ /**
+ * Property: Endpoint is accessible without authentication (no auth header needed).
+ */
+ it('endpoint responds 200 without any auth header for any config state', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ configStateArb,
+ async (state) => {
+ const container = buildContainer(state);
+ const app = buildApp(container);
+
+ // No Authorization header at all — endpoint must not require auth
+ const res = await request(app)
+ .get('/api/auth/providers');
+
+ expect(res.status).toBe(200);
+ expect(res.body.local).toBe(true);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+});
diff --git a/backend/test/properties/EntraIdProvisioning.property.test.ts b/backend/test/properties/EntraIdProvisioning.property.test.ts
new file mode 100644
index 00000000..86b0c855
--- /dev/null
+++ b/backend/test/properties/EntraIdProvisioning.property.test.ts
@@ -0,0 +1,519 @@
+/**
+ * Property-Based Tests for EntraIdService — User Provisioning (Properties 12–14)
+ *
+ * **Validates: Requirements 4.1, 4.2, 4.3, 4.5, 4.7**
+ *
+ * Tests the three correctness properties from the design document:
+ * - Property 12: New federated user provisioning invariant
+ * - Property 13: Existing federated user profile immutability
+ * - Property 14: Username derivation from invalid preferred_username
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import * as fc from 'fast-check';
+
+import {
+ EntraIdService,
+ type IdTokenClaims,
+} from '../../src/services/EntraIdService';
+import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter';
+import type { EntraIdConfig } from '../../src/config/schema';
+import type { AuthenticationService } from '../../src/services/AuthenticationService';
+import type { UserService, User } from '../../src/services/UserService';
+import type { RoleService } from '../../src/services/RoleService';
+import type { AuditLoggingService } from '../../src/services/AuditLoggingService';
+import type { LoggerService } from '../../src/services/LoggerService';
+
+// --- Mock Factories ---
+
+function createMockDb(): DatabaseAdapter {
+ return {
+ query: vi.fn().mockResolvedValue([]),
+ queryOne: vi.fn().mockResolvedValue(null),
+ execute: vi.fn().mockResolvedValue({ changes: 0 }),
+ beginTransaction: vi.fn().mockResolvedValue(undefined),
+ commit: vi.fn().mockResolvedValue(undefined),
+ rollback: vi.fn().mockResolvedValue(undefined),
+ withTransaction: vi.fn(),
+ initialize: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ isConnected: vi.fn().mockReturnValue(true),
+ getDialect: vi.fn().mockReturnValue('sqlite' as const),
+ };
+}
+
+function createMockConfig(): EntraIdConfig {
+ return {
+ enabled: true,
+ tenantId: 'test-tenant-id-000',
+ clientId: 'test-client-id-111',
+ clientSecret: 'test-client-secret-222', // pragma: allowlist secret
+ redirectUri: 'http://localhost:3000/api/auth/entra-id/callback',
+ scopes: ['openid', 'profile', 'email'],
+ groupMapping: null,
+ jwksCacheTtlMs: 86400000,
+ };
+}
+
+function createMockLogger(): LoggerService {
+ return {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ shouldLog: vi.fn().mockReturnValue(true),
+ formatMessage: vi.fn().mockReturnValue(''),
+ getLevel: vi.fn().mockReturnValue('info'),
+ setLogBuffer: vi.fn(),
+ getLogBuffer: vi.fn().mockReturnValue(null),
+ } as unknown as LoggerService;
+}
+
+function createMockUser(overrides: Partial = {}): User {
+ const now = new Date().toISOString();
+ return {
+ id: 'user-id-001',
+ username: 'testuser',
+ email: 'user@example.com',
+ passwordHash: '',
+ firstName: 'Test',
+ lastName: 'User',
+ isActive: 1,
+ isAdmin: 0,
+ createdAt: now,
+ updatedAt: now,
+ lastLoginAt: null,
+ ...overrides,
+ };
+}
+
+// --- Arbitraries ---
+
+/** Generates a valid Entra ID subject identifier (opaque string) */
+const subArb = fc.stringMatching(/^[a-zA-Z0-9_-]{10,40}$/);
+
+/** Generates a valid email address */
+const emailArb = fc.tuple(
+ fc.stringMatching(/^[a-z][a-z0-9._]{2,15}$/),
+ fc.stringMatching(/^[a-z]{3,10}\.[a-z]{2,4}$/),
+).map(([local, domain]) => `${local}@${domain}`);
+
+/** Generates a valid preferred_username (matches ^[a-zA-Z0-9_]{3,50}$) */
+const validUsernameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_]{2,49}$/);
+
+/** Generates first/last names */
+const nameArb = fc.stringMatching(/^[A-Z][a-z]{1,19}$/);
+
+/** Generates a complete valid IdTokenClaims set */
+const validClaimsArb = fc.record({
+ sub: subArb,
+ email: emailArb,
+ preferred_username: validUsernameArb,
+ given_name: nameArb,
+ family_name: nameArb,
+ nonce: fc.stringMatching(/^[a-f0-9]{32,64}$/),
+ aud: fc.constant('test-client-id-111'),
+ iss: fc.constant('https://login.microsoftonline.com/test-tenant-id-000/v2.0'),
+ exp: fc.constant(Math.floor(Date.now() / 1000) + 3600),
+}) as fc.Arbitrary;
+
+/**
+ * Generates a preferred_username that does NOT match ^[a-zA-Z0-9_]{3,50}$
+ * (contains special chars, spaces, dots, hyphens, or is too short/long)
+ */
+const invalidUsernameArb = fc.oneof(
+ // Contains disallowed characters (dots, hyphens, spaces, @)
+ fc.stringMatching(/^[a-z]{3,10}[.\-@ ][a-z]{3,10}$/),
+ // Too short (1-2 chars)
+ fc.stringMatching(/^[a-z]{1,2}$/),
+ // Contains special characters
+ fc.stringMatching(/^[a-z]{3,8}[!#$%]{1,3}[a-z]{3,8}$/),
+);
+
+describe('EntraIdService — User Provisioning Properties', () => {
+ let db: DatabaseAdapter;
+ let config: EntraIdConfig;
+ let logger: LoggerService;
+ let mockUserService: UserService;
+ let mockRoleService: RoleService;
+ let service: EntraIdService;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ db = createMockDb();
+ config = createMockConfig();
+ logger = createMockLogger();
+
+ mockUserService = {
+ findByFederatedIdentity: vi.fn().mockResolvedValue(null),
+ findByEmail: vi.fn().mockResolvedValue(null),
+ createFederatedUser: vi.fn().mockImplementation(async (claims: IdTokenClaims) => {
+ return createMockUser({
+ id: `new-user-${claims.sub}`,
+ username: claims.preferred_username || claims.email.split('@')[0],
+ email: claims.email,
+ passwordHash: '',
+ firstName: claims.given_name,
+ lastName: claims.family_name,
+ isActive: 1,
+ });
+ }),
+ linkFederatedIdentity: vi.fn().mockResolvedValue({
+ id: 'fed-id-001',
+ userId: 'user-id-001',
+ provider: 'entra-id',
+ subject: 'test-sub',
+ issuer: 'test-issuer',
+ email: 'user@example.com',
+ idToken: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ }),
+ getUserRoles: vi.fn().mockResolvedValue([]),
+ assignRoleToUser: vi.fn().mockResolvedValue(undefined),
+ removeRoleFromUser: vi.fn().mockResolvedValue(undefined),
+ } as unknown as UserService;
+
+ mockRoleService = {
+ listRoles: vi.fn().mockResolvedValue({ items: [] }),
+ } as unknown as RoleService;
+
+ service = new EntraIdService(
+ db,
+ config,
+ {} as AuthenticationService,
+ mockUserService,
+ mockRoleService,
+ {} as AuditLoggingService,
+ logger,
+ );
+ });
+
+ // Feature: azure-entra-id-auth, Property 12: New federated user provisioning invariant
+ /**
+ * Property 12: New federated user provisioning invariant
+ *
+ * **Validates: Requirements 4.1, 4.2, 4.5**
+ *
+ * For any valid ID token claims (sub, email, preferred_username/derived username,
+ * given_name, family_name) where no federated identity exists with that sub:
+ * the service SHALL create a user with is_active=1, null password_hash,
+ * a federated_identities record with provider='entra-id' and subject=sub,
+ * and SHALL assign the default viewer role.
+ */
+ describe('Property 12: New federated user provisioning invariant', () => {
+ it('creates a new user via createFederatedUser when no federated identity exists', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ validClaimsArb,
+ async (claims) => {
+ // Reset mocks for each iteration
+ vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null);
+ vi.mocked(mockUserService.findByEmail).mockResolvedValue(null);
+ vi.mocked(mockUserService.createFederatedUser).mockResolvedValue(
+ createMockUser({
+ id: `new-user-${claims.sub}`,
+ username: claims.preferred_username,
+ email: claims.email,
+ passwordHash: '',
+ firstName: claims.given_name,
+ lastName: claims.family_name,
+ isActive: 1,
+ }),
+ );
+
+ const result = await service.provisionUser(claims);
+
+ // Service looked up federated identity first
+ expect(mockUserService.findByFederatedIdentity).toHaveBeenCalledWith(
+ 'entra-id',
+ claims.sub,
+ );
+
+ // No existing identity → called createFederatedUser with the claims
+ expect(mockUserService.createFederatedUser).toHaveBeenCalledWith(claims);
+
+ // Returned user has is_active=1 and empty passwordHash
+ expect(result.isActive).toBe(1);
+ expect(result.passwordHash).toBe('');
+
+ // linkFederatedIdentity should NOT be called (createFederatedUser does it internally)
+ expect(mockUserService.linkFederatedIdentity).not.toHaveBeenCalled();
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('the created user has correct profile fields from claims', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ validClaimsArb,
+ async (claims) => {
+ vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null);
+ vi.mocked(mockUserService.findByEmail).mockResolvedValue(null);
+
+ const createdUser = createMockUser({
+ id: `new-user-${claims.sub}`,
+ username: claims.preferred_username,
+ email: claims.email,
+ passwordHash: '',
+ firstName: claims.given_name,
+ lastName: claims.family_name,
+ isActive: 1,
+ isAdmin: 0,
+ });
+ vi.mocked(mockUserService.createFederatedUser).mockResolvedValue(createdUser);
+
+ const result = await service.provisionUser(claims);
+
+ expect(result.email).toBe(claims.email);
+ expect(result.firstName).toBe(claims.given_name);
+ expect(result.lastName).toBe(claims.family_name);
+ expect(result.isAdmin).toBe(0);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ // Feature: azure-entra-id-auth, Property 13: Existing federated user profile immutability
+ /**
+ * Property 13: Existing federated user profile immutability
+ *
+ * **Validates: Requirements 4.3**
+ *
+ * For any returning user (federated identity already linked), calling the
+ * provisioning flow with different claim values (name, email) SHALL NOT
+ * modify the existing user record's first_name, last_name, or email fields.
+ */
+ describe('Property 13: Existing federated user profile immutability', () => {
+ it('returns existing user unchanged when federated identity is already linked', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ validClaimsArb,
+ nameArb,
+ nameArb,
+ emailArb,
+ async (claims, differentFirst, differentLast, differentEmail) => {
+ // The existing user has different profile data than the incoming claims
+ const existingUser = createMockUser({
+ id: 'existing-user-id',
+ username: 'existing_username',
+ email: differentEmail,
+ firstName: differentFirst,
+ lastName: differentLast,
+ passwordHash: 'hashed-password-value',
+ isActive: 1,
+ });
+
+ vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(existingUser);
+
+ const result = await service.provisionUser(claims);
+
+ // The returned user is the existing user, not modified
+ expect(result.id).toBe('existing-user-id');
+ expect(result.email).toBe(differentEmail);
+ expect(result.firstName).toBe(differentFirst);
+ expect(result.lastName).toBe(differentLast);
+
+ // createFederatedUser and linkFederatedIdentity must NOT be called
+ expect(mockUserService.createFederatedUser).not.toHaveBeenCalled();
+ expect(mockUserService.linkFederatedIdentity).not.toHaveBeenCalled();
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('does not update the existing user password hash', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ validClaimsArb,
+ async (claims) => {
+ const existingUser = createMockUser({
+ id: 'existing-user-id',
+ passwordHash: 'original-bcrypt-hash-value',
+ isActive: 1,
+ });
+
+ vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(existingUser);
+
+ const result = await service.provisionUser(claims);
+
+ // Password hash remains unchanged
+ expect(result.passwordHash).toBe('original-bcrypt-hash-value');
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+
+ // Feature: azure-entra-id-auth, Property 14: Username derivation from invalid preferred_username
+ /**
+ * Property 14: Username derivation from invalid preferred_username
+ *
+ * **Validates: Requirements 4.7**
+ *
+ * For any preferred_username that does not match ^[a-zA-Z0-9_]{3,50}$,
+ * the service SHALL derive the username from the email local-part by
+ * replacing all characters not in [a-zA-Z0-9_] with underscores and
+ * truncating to 50 characters.
+ */
+ describe('Property 14: Username derivation from invalid preferred_username', () => {
+ it('derives username from email local-part when preferred_username is invalid', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ invalidUsernameArb,
+ emailArb,
+ nameArb,
+ nameArb,
+ subArb,
+ async (invalidUsername, email, firstName, lastName, sub) => {
+ const claims: IdTokenClaims = {
+ sub,
+ email,
+ preferred_username: invalidUsername,
+ given_name: firstName,
+ family_name: lastName,
+ nonce: 'test-nonce-value',
+ aud: config.clientId,
+ iss: `https://login.microsoftonline.com/${config.tenantId}/v2.0`,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ };
+
+ vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null);
+ vi.mocked(mockUserService.findByEmail).mockResolvedValue(null);
+
+ // Capture what createFederatedUser receives to verify username derivation
+ let receivedClaims: IdTokenClaims | null = null;
+ vi.mocked(mockUserService.createFederatedUser).mockImplementation(
+ async (c: IdTokenClaims) => {
+ receivedClaims = c;
+ return createMockUser({
+ id: `new-user-${c.sub}`,
+ email: c.email,
+ // UserService.createFederatedUser internally derives the username
+ username: c.email.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 50),
+ passwordHash: '',
+ isActive: 1,
+ });
+ },
+ );
+
+ const result = await service.provisionUser(claims);
+
+ // createFederatedUser was called with original claims
+ // (UserService.deriveUsername handles the derivation internally)
+ expect(receivedClaims).not.toBeNull();
+ expect(receivedClaims!.preferred_username).toBe(invalidUsername);
+ expect(receivedClaims!.email).toBe(email);
+
+ // The returned username must be derived from email local-part:
+ // replace non-[a-zA-Z0-9_] with underscores, truncate to 50
+ const expectedUsername = email.split('@')[0]
+ .replace(/[^a-zA-Z0-9_]/g, '_')
+ .slice(0, 50);
+ expect(result.username).toBe(expectedUsername);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('derived username is at most 50 characters', async () => {
+ // Generate emails with long local parts
+ const longLocalPartEmail = fc.tuple(
+ fc.stringMatching(/^[a-z][a-z0-9.]{50,80}$/),
+ fc.constant('example.com'),
+ ).map(([local, domain]) => `${local}@${domain}`);
+
+ await fc.assert(
+ fc.asyncProperty(
+ longLocalPartEmail,
+ subArb,
+ async (email, sub) => {
+ const claims: IdTokenClaims = {
+ sub,
+ email,
+ preferred_username: 'ab', // Too short → invalid
+ given_name: 'Test',
+ family_name: 'User',
+ nonce: 'test-nonce',
+ aud: config.clientId,
+ iss: `https://login.microsoftonline.com/${config.tenantId}/v2.0`,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ };
+
+ vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null);
+ vi.mocked(mockUserService.findByEmail).mockResolvedValue(null);
+ vi.mocked(mockUserService.createFederatedUser).mockImplementation(
+ async (c: IdTokenClaims) => {
+ const username = c.email.split('@')[0]
+ .replace(/[^a-zA-Z0-9_]/g, '_')
+ .slice(0, 50);
+ return createMockUser({
+ id: `new-user-${c.sub}`,
+ email: c.email,
+ username,
+ passwordHash: '',
+ isActive: 1,
+ });
+ },
+ );
+
+ const result = await service.provisionUser(claims);
+ expect(result.username.length).toBeLessThanOrEqual(50);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+
+ it('derived username only contains [a-zA-Z0-9_]', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ invalidUsernameArb,
+ emailArb,
+ subArb,
+ async (invalidUsername, email, sub) => {
+ const claims: IdTokenClaims = {
+ sub,
+ email,
+ preferred_username: invalidUsername,
+ given_name: 'Test',
+ family_name: 'User',
+ nonce: 'test-nonce',
+ aud: config.clientId,
+ iss: `https://login.microsoftonline.com/${config.tenantId}/v2.0`,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ };
+
+ vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null);
+ vi.mocked(mockUserService.findByEmail).mockResolvedValue(null);
+ vi.mocked(mockUserService.createFederatedUser).mockImplementation(
+ async (c: IdTokenClaims) => {
+ const username = c.email.split('@')[0]
+ .replace(/[^a-zA-Z0-9_]/g, '_')
+ .slice(0, 50);
+ return createMockUser({
+ id: `new-user-${c.sub}`,
+ email: c.email,
+ username,
+ passwordHash: '',
+ isActive: 1,
+ });
+ },
+ );
+
+ const result = await service.provisionUser(claims);
+ expect(result.username).toMatch(/^[a-zA-Z0-9_]+$/);
+ },
+ ),
+ { numRuns: 100 },
+ );
+ });
+ });
+});
diff --git a/backend/test/unit/services/EntraIdService.test.ts b/backend/test/unit/services/EntraIdService.test.ts
new file mode 100644
index 00000000..8615b240
--- /dev/null
+++ b/backend/test/unit/services/EntraIdService.test.ts
@@ -0,0 +1,656 @@
+/**
+ * Unit tests for EntraIdService — authorization URL generation, state cleanup,
+ * and provider info.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { EntraIdService, EntraIdError, ENTRA_ID_ERROR_CODES } from '../../../src/services/EntraIdService';
+import type { IdTokenClaims } from '../../../src/services/EntraIdService';
+import type { DatabaseAdapter } from '../../../src/database/DatabaseAdapter';
+import type { EntraIdConfig } from '../../../src/config/schema';
+import type { AuthenticationService } from '../../../src/services/AuthenticationService';
+import type { UserService, User } from '../../../src/services/UserService';
+import type { RoleService } from '../../../src/services/RoleService';
+import type { AuditLoggingService } from '../../../src/services/AuditLoggingService';
+import type { LoggerService } from '../../../src/services/LoggerService';
+
+function createMockDb(): DatabaseAdapter {
+ return {
+ query: vi.fn().mockResolvedValue([]),
+ queryOne: vi.fn().mockResolvedValue(null),
+ execute: vi.fn().mockResolvedValue({ changes: 0 }),
+ beginTransaction: vi.fn().mockResolvedValue(undefined),
+ commit: vi.fn().mockResolvedValue(undefined),
+ rollback: vi.fn().mockResolvedValue(undefined),
+ withTransaction: vi.fn(),
+ initialize: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ isConnected: vi.fn().mockReturnValue(true),
+ getDialect: vi.fn().mockReturnValue('sqlite' as const),
+ };
+}
+
+function createMockConfig(): EntraIdConfig {
+ return {
+ enabled: true,
+ tenantId: 'test-tenant-id-000',
+ clientId: 'test-client-id-111',
+ clientSecret: 'test-client-secret-222', // pragma: allowlist secret
+ redirectUri: 'http://localhost:3000/api/auth/entra-id/callback',
+ scopes: ['openid', 'profile', 'email'],
+ groupMapping: null,
+ jwksCacheTtlMs: 86400000,
+ };
+}
+
+function createMockLogger(): LoggerService {
+ return {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ shouldLog: vi.fn().mockReturnValue(true),
+ formatMessage: vi.fn().mockReturnValue(''),
+ getLevel: vi.fn().mockReturnValue('info'),
+ setLogBuffer: vi.fn(),
+ getLogBuffer: vi.fn().mockReturnValue(null),
+ } as unknown as LoggerService;
+}
+
+describe('EntraIdService', () => {
+ let db: DatabaseAdapter;
+ let config: EntraIdConfig;
+ let logger: LoggerService;
+ let service: EntraIdService;
+
+ beforeEach(() => {
+ db = createMockDb();
+ config = createMockConfig();
+ logger = createMockLogger();
+ service = new EntraIdService(
+ db,
+ config,
+ {} as AuthenticationService,
+ {} as UserService,
+ {} as RoleService,
+ {} as AuditLoggingService,
+ logger,
+ );
+ });
+
+ describe('generateAuthorizationUrl()', () => {
+ it('returns a URL targeting the correct Entra ID authorize endpoint', async () => {
+ const { url } = await service.generateAuthorizationUrl();
+ expect(url).toContain(
+ `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize`,
+ );
+ });
+
+ it('includes response_type=code', async () => {
+ const { url } = await service.generateAuthorizationUrl();
+ const params = new URL(url).searchParams;
+ expect(params.get('response_type')).toBe('code');
+ });
+
+ it('includes the configured client_id', async () => {
+ const { url } = await service.generateAuthorizationUrl();
+ const params = new URL(url).searchParams;
+ expect(params.get('client_id')).toBe(config.clientId);
+ });
+
+ it('includes the configured redirect_uri', async () => {
+ const { url } = await service.generateAuthorizationUrl();
+ const params = new URL(url).searchParams;
+ expect(params.get('redirect_uri')).toBe(config.redirectUri);
+ });
+
+ it('includes space-separated scopes', async () => {
+ const { url } = await service.generateAuthorizationUrl();
+ const params = new URL(url).searchParams;
+ expect(params.get('scope')).toBe('openid profile email');
+ });
+
+ it('includes a state parameter with at least 32 bytes of entropy (64 hex chars)', async () => {
+ const { url, state } = await service.generateAuthorizationUrl();
+ const params = new URL(url).searchParams;
+ expect(params.get('state')).toBe(state);
+ expect(state).toHaveLength(64); // 32 bytes = 64 hex chars
+ expect(state).toMatch(/^[0-9a-f]+$/);
+ });
+
+ it('includes a nonce parameter with at least 32 bytes of entropy', async () => {
+ const { url } = await service.generateAuthorizationUrl();
+ const params = new URL(url).searchParams;
+ const nonce = params.get('nonce');
+ expect(nonce).toHaveLength(64);
+ expect(nonce).toMatch(/^[0-9a-f]+$/);
+ });
+
+ it('includes code_challenge and code_challenge_method=S256', async () => {
+ const { url } = await service.generateAuthorizationUrl();
+ const params = new URL(url).searchParams;
+ expect(params.get('code_challenge_method')).toBe('S256');
+ const challenge = params.get('code_challenge');
+ expect(challenge).toBeTruthy();
+ // base64url: no +, /, or = characters
+ expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
+ });
+
+ it('stores state, nonce, code_verifier in oauth_state_store with 10-minute TTL', async () => {
+ const before = Date.now();
+ await service.generateAuthorizationUrl();
+ const after = Date.now();
+
+ expect(db.execute).toHaveBeenCalledTimes(1);
+ const [sql, params] = (db.execute as ReturnType).mock.calls[0];
+ expect(sql).toContain('INSERT INTO oauth_state_store');
+
+ const [state, nonce, codeVerifier, createdAt, expiresAt] = params as string[];
+ expect(state).toHaveLength(64);
+ expect(nonce).toHaveLength(64);
+ // code_verifier: 64 chars from unreserved charset
+ expect(codeVerifier).toHaveLength(64);
+ expect(codeVerifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
+
+ // Verify 10-minute TTL
+ const createdMs = new Date(createdAt).getTime();
+ const expiresMs = new Date(expiresAt).getTime();
+ expect(createdMs).toBeGreaterThanOrEqual(before);
+ expect(createdMs).toBeLessThanOrEqual(after);
+ expect(expiresMs - createdMs).toBe(10 * 60 * 1000);
+ });
+
+ it('generates unique state values on each call', async () => {
+ const r1 = await service.generateAuthorizationUrl();
+ const r2 = await service.generateAuthorizationUrl();
+ expect(r1.state).not.toBe(r2.state);
+ });
+
+ it('logs the generation without exposing secrets', async () => {
+ await service.generateAuthorizationUrl();
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Generated authorization URL',
+ expect.objectContaining({
+ component: 'EntraIdService',
+ operation: 'generateAuthorizationUrl',
+ }),
+ );
+ });
+ });
+
+ describe('cleanupExpiredState()', () => {
+ it('deletes expired entries and returns the count', async () => {
+ (db.execute as ReturnType).mockResolvedValue({ changes: 3 });
+ const deleted = await service.cleanupExpiredState();
+ expect(deleted).toBe(3);
+ expect(db.execute).toHaveBeenCalledWith(
+ expect.stringContaining('DELETE FROM oauth_state_store WHERE expires_at < ?'),
+ expect.any(Array),
+ );
+ });
+
+ it('returns 0 when no expired entries exist', async () => {
+ (db.execute as ReturnType).mockResolvedValue({ changes: 0 });
+ const deleted = await service.cleanupExpiredState();
+ expect(deleted).toBe(0);
+ });
+
+ it('logs only when entries are actually deleted', async () => {
+ (db.execute as ReturnType).mockResolvedValue({ changes: 0 });
+ await service.cleanupExpiredState();
+ expect(logger.info).not.toHaveBeenCalled();
+
+ (db.execute as ReturnType).mockResolvedValue({ changes: 2 });
+ await service.cleanupExpiredState();
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('2'),
+ expect.objectContaining({
+ component: 'EntraIdService',
+ operation: 'cleanupExpiredState',
+ }),
+ );
+ });
+ });
+
+ describe('getProviderInfo()', () => {
+ it('returns enabled=true and name "Microsoft Entra ID"', () => {
+ const info = service.getProviderInfo();
+ expect(info).toEqual({ enabled: true, name: 'Microsoft Entra ID' });
+ });
+ });
+
+ describe('provisionUser()', () => {
+ let mockUserService: {
+ findByFederatedIdentity: ReturnType;
+ findByEmail: ReturnType;
+ createFederatedUser: ReturnType;
+ linkFederatedIdentity: ReturnType;
+ };
+ let provisionService: EntraIdService;
+
+ const baseUser: User = {
+ id: 'user-123',
+ username: 'testuser',
+ email: 'test@example.com',
+ passwordHash: 'hashed',
+ firstName: 'Test',
+ lastName: 'User',
+ isActive: 1,
+ isAdmin: 0,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ lastLoginAt: null,
+ };
+
+ const baseClaims: IdTokenClaims = {
+ sub: 'entra-sub-abc123',
+ email: 'test@example.com',
+ preferred_username: 'testuser',
+ given_name: 'Test',
+ family_name: 'User',
+ nonce: 'nonce-value',
+ aud: 'test-client-id-111',
+ iss: 'https://login.microsoftonline.com/test-tenant-id-000/v2.0',
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ };
+
+ beforeEach(() => {
+ mockUserService = {
+ findByFederatedIdentity: vi.fn().mockResolvedValue(null),
+ findByEmail: vi.fn().mockResolvedValue(null),
+ createFederatedUser: vi.fn().mockResolvedValue(baseUser),
+ linkFederatedIdentity: vi.fn().mockResolvedValue({
+ id: 'fed-id-1',
+ userId: baseUser.id,
+ provider: 'entra-id',
+ subject: baseClaims.sub,
+ issuer: baseClaims.iss,
+ email: baseClaims.email,
+ idToken: null,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ }),
+ };
+
+ provisionService = new EntraIdService(
+ db,
+ config,
+ {} as AuthenticationService,
+ mockUserService as unknown as UserService,
+ {} as RoleService,
+ {} as AuditLoggingService,
+ logger,
+ );
+ });
+
+ it('throws MISSING_CLAIMS when both email and preferred_username are absent', async () => {
+ const claims: IdTokenClaims = {
+ ...baseClaims,
+ email: '',
+ preferred_username: '',
+ };
+
+ await expect(provisionService.provisionUser(claims)).rejects.toThrow(EntraIdError);
+ await expect(provisionService.provisionUser(claims)).rejects.toMatchObject({
+ code: ENTRA_ID_ERROR_CODES.MISSING_CLAIMS,
+ });
+ });
+
+ it('returns existing user when federated identity is found (no profile update)', async () => {
+ mockUserService.findByFederatedIdentity.mockResolvedValue(baseUser);
+
+ const result = await provisionService.provisionUser(baseClaims);
+
+ expect(result).toBe(baseUser);
+ expect(mockUserService.findByFederatedIdentity).toHaveBeenCalledWith(
+ 'entra-id',
+ baseClaims.sub,
+ );
+ // Must not call create or link
+ expect(mockUserService.createFederatedUser).not.toHaveBeenCalled();
+ expect(mockUserService.linkFederatedIdentity).not.toHaveBeenCalled();
+ expect(mockUserService.findByEmail).not.toHaveBeenCalled();
+ });
+
+ it('links federated identity to existing email-match user', async () => {
+ mockUserService.findByFederatedIdentity.mockResolvedValue(null);
+ mockUserService.findByEmail.mockResolvedValue(baseUser);
+
+ const result = await provisionService.provisionUser(baseClaims);
+
+ expect(result).toBe(baseUser);
+ expect(mockUserService.linkFederatedIdentity).toHaveBeenCalledWith(
+ baseUser.id,
+ 'entra-id',
+ baseClaims.sub,
+ baseClaims.iss,
+ baseClaims.email,
+ );
+ expect(mockUserService.createFederatedUser).not.toHaveBeenCalled();
+ });
+
+ it('creates new federated user when no match found', async () => {
+ mockUserService.findByFederatedIdentity.mockResolvedValue(null);
+ mockUserService.findByEmail.mockResolvedValue(null);
+
+ const newUser = { ...baseUser, id: 'new-user-456' };
+ mockUserService.createFederatedUser.mockResolvedValue(newUser);
+
+ const result = await provisionService.provisionUser(baseClaims);
+
+ expect(result).toBe(newUser);
+ expect(mockUserService.createFederatedUser).toHaveBeenCalledWith(baseClaims);
+ });
+
+ it('wraps createFederatedUser errors as PROVISIONING_FAILED', async () => {
+ mockUserService.findByFederatedIdentity.mockResolvedValue(null);
+ mockUserService.findByEmail.mockResolvedValue(null);
+ mockUserService.createFederatedUser.mockRejectedValue(
+ new Error('UNIQUE constraint failed: users.username'),
+ );
+
+ await expect(provisionService.provisionUser(baseClaims)).rejects.toMatchObject({
+ code: ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED,
+ message: 'Account creation failed',
+ });
+ });
+
+ it('wraps linkFederatedIdentity errors as PROVISIONING_FAILED', async () => {
+ mockUserService.findByFederatedIdentity.mockResolvedValue(null);
+ mockUserService.findByEmail.mockResolvedValue(baseUser);
+ mockUserService.linkFederatedIdentity.mockRejectedValue(
+ new Error('DB connection lost'),
+ );
+
+ await expect(provisionService.provisionUser(baseClaims)).rejects.toMatchObject({
+ code: ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED,
+ message: 'Account creation failed',
+ });
+ });
+
+ it('proceeds to createFederatedUser when email is present but no email match', async () => {
+ mockUserService.findByFederatedIdentity.mockResolvedValue(null);
+ mockUserService.findByEmail.mockResolvedValue(null);
+
+ await provisionService.provisionUser(baseClaims);
+
+ expect(mockUserService.findByEmail).toHaveBeenCalledWith(baseClaims.email);
+ expect(mockUserService.createFederatedUser).toHaveBeenCalledWith(baseClaims);
+ });
+
+ it('skips email lookup and creates user when email is empty but preferred_username is present', async () => {
+ const claims: IdTokenClaims = {
+ ...baseClaims,
+ email: '',
+ preferred_username: 'someuser',
+ };
+
+ mockUserService.findByFederatedIdentity.mockResolvedValue(null);
+
+ await provisionService.provisionUser(claims);
+
+ // findByEmail should not be called with empty string
+ expect(mockUserService.findByEmail).not.toHaveBeenCalled();
+ expect(mockUserService.createFederatedUser).toHaveBeenCalledWith(claims);
+ });
+
+ it('logs provisioning actions without exposing tokens or secrets', async () => {
+ mockUserService.findByFederatedIdentity.mockResolvedValue(null);
+ mockUserService.findByEmail.mockResolvedValue(null);
+
+ await provisionService.provisionUser(baseClaims);
+
+ expect(logger.info).toHaveBeenCalledWith(
+ 'New federated user created',
+ expect.objectContaining({
+ component: 'EntraIdService',
+ operation: 'provisionUser',
+ }),
+ );
+ });
+ });
+
+ describe('syncGroupRoles()', () => {
+ let mockUserService: {
+ findByFederatedIdentity: ReturnType;
+ findByEmail: ReturnType;
+ createFederatedUser: ReturnType;
+ linkFederatedIdentity: ReturnType;
+ getUserRoles: ReturnType;
+ assignRoleToUser: ReturnType;
+ removeRoleFromUser: ReturnType;
+ };
+ let mockRoleService: {
+ listRoles: ReturnType;
+ };
+ let syncService: EntraIdService;
+
+ const allRoles = [
+ { id: 'role-admin', name: 'Administrator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ { id: 'role-operator', name: 'Operator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ { id: 'role-viewer', name: 'Viewer', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ { id: 'role-deploy', name: 'Deployer', description: '', isBuiltIn: 0, createdAt: '', updatedAt: '' },
+ ];
+
+ beforeEach(() => {
+ mockUserService = {
+ findByFederatedIdentity: vi.fn(),
+ findByEmail: vi.fn(),
+ createFederatedUser: vi.fn(),
+ linkFederatedIdentity: vi.fn(),
+ getUserRoles: vi.fn().mockResolvedValue([]),
+ assignRoleToUser: vi.fn().mockResolvedValue(undefined),
+ removeRoleFromUser: vi.fn().mockResolvedValue(undefined),
+ };
+
+ mockRoleService = {
+ listRoles: vi.fn().mockResolvedValue({ items: allRoles, total: allRoles.length, limit: 1000, offset: 0 }),
+ };
+ });
+
+ function createSyncService(groupMapping: Record | null): EntraIdService {
+ const cfg = { ...createMockConfig(), groupMapping };
+ syncService = new EntraIdService(
+ db,
+ cfg,
+ {} as AuthenticationService,
+ mockUserService as unknown as UserService,
+ mockRoleService as unknown as RoleService,
+ {} as AuditLoggingService,
+ logger,
+ );
+ return syncService;
+ }
+
+ it('skips sync when groupMapping is null', async () => {
+ createSyncService(null);
+ await syncService.syncGroupRoles('user-1', ['group-a']);
+ expect(mockRoleService.listRoles).not.toHaveBeenCalled();
+ expect(mockUserService.getUserRoles).not.toHaveBeenCalled();
+ });
+
+ it('skips sync when groups is undefined', async () => {
+ createSyncService({ 'group-a': 'Operator' });
+ await syncService.syncGroupRoles('user-1', undefined);
+ expect(mockRoleService.listRoles).not.toHaveBeenCalled();
+ expect(mockUserService.getUserRoles).not.toHaveBeenCalled();
+ });
+
+ it('assigns roles for matched group IDs', async () => {
+ createSyncService({
+ 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator',
+ 'bbbbbbbb-1111-2222-3333-444444444444': 'Deployer',
+ });
+ mockUserService.getUserRoles.mockResolvedValue([]);
+
+ await syncService.syncGroupRoles('user-1', [
+ 'aaaaaaaa-1111-2222-3333-444444444444',
+ 'bbbbbbbb-1111-2222-3333-444444444444',
+ ]);
+
+ expect(mockUserService.assignRoleToUser).toHaveBeenCalledWith('user-1', 'role-operator');
+ expect(mockUserService.assignRoleToUser).toHaveBeenCalledWith('user-1', 'role-deploy');
+ expect(mockUserService.assignRoleToUser).toHaveBeenCalledTimes(2);
+ });
+
+ it('performs case-insensitive UUID matching', async () => {
+ createSyncService({
+ 'AAAAAAAA-1111-2222-3333-444444444444': 'Operator',
+ });
+ mockUserService.getUserRoles.mockResolvedValue([]);
+
+ await syncService.syncGroupRoles('user-1', [
+ 'aaaaaaaa-1111-2222-3333-444444444444',
+ ]);
+
+ expect(mockUserService.assignRoleToUser).toHaveBeenCalledWith('user-1', 'role-operator');
+ });
+
+ it('revokes mapped roles whose group IDs are no longer in the claim', async () => {
+ createSyncService({
+ 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator',
+ 'bbbbbbbb-1111-2222-3333-444444444444': 'Deployer',
+ });
+ // User currently has Operator and Deployer
+ mockUserService.getUserRoles.mockResolvedValue([
+ { id: 'role-operator', name: 'Operator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ { id: 'role-deploy', name: 'Deployer', description: '', isBuiltIn: 0, createdAt: '', updatedAt: '' },
+ ]);
+
+ // Only group-a is in the claim now (Operator stays, Deployer revoked)
+ await syncService.syncGroupRoles('user-1', [
+ 'aaaaaaaa-1111-2222-3333-444444444444',
+ ]);
+
+ expect(mockUserService.removeRoleFromUser).toHaveBeenCalledWith('user-1', 'role-deploy');
+ expect(mockUserService.removeRoleFromUser).toHaveBeenCalledTimes(1);
+ expect(mockUserService.assignRoleToUser).not.toHaveBeenCalled();
+ });
+
+ it('preserves roles assigned independently of the mapping (manually assigned)', async () => {
+ createSyncService({
+ 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator',
+ });
+ // User has Viewer (manual) and Administrator (manual) — neither in mapping
+ mockUserService.getUserRoles.mockResolvedValue([
+ { id: 'role-viewer', name: 'Viewer', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ { id: 'role-admin', name: 'Administrator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ ]);
+
+ // No groups match the mapping
+ await syncService.syncGroupRoles('user-1', []);
+
+ // Should NOT remove Viewer or Administrator (they are not managed by this mapping)
+ expect(mockUserService.removeRoleFromUser).not.toHaveBeenCalled();
+ expect(mockUserService.assignRoleToUser).not.toHaveBeenCalled();
+ });
+
+ it('logs warning and skips mapping entry when role does not exist in Pabawi', async () => {
+ createSyncService({
+ 'aaaaaaaa-1111-2222-3333-444444444444': 'NonExistentRole',
+ 'bbbbbbbb-1111-2222-3333-444444444444': 'Operator',
+ });
+ mockUserService.getUserRoles.mockResolvedValue([]);
+
+ await syncService.syncGroupRoles('user-1', [
+ 'aaaaaaaa-1111-2222-3333-444444444444',
+ 'bbbbbbbb-1111-2222-3333-444444444444',
+ ]);
+
+ // Should log warning about NonExistentRole
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('NonExistentRole'),
+ expect.objectContaining({ component: 'EntraIdService' }),
+ );
+ // Should still assign the valid Operator role
+ expect(mockUserService.assignRoleToUser).toHaveBeenCalledWith('user-1', 'role-operator');
+ expect(mockUserService.assignRoleToUser).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not assign roles the user already has', async () => {
+ createSyncService({
+ 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator',
+ });
+ // User already has Operator
+ mockUserService.getUserRoles.mockResolvedValue([
+ { id: 'role-operator', name: 'Operator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ ]);
+
+ await syncService.syncGroupRoles('user-1', [
+ 'aaaaaaaa-1111-2222-3333-444444444444',
+ ]);
+
+ expect(mockUserService.assignRoleToUser).not.toHaveBeenCalled();
+ expect(mockUserService.removeRoleFromUser).not.toHaveBeenCalled();
+ });
+
+ it('handles empty groups claim by revoking all mapped roles', async () => {
+ createSyncService({
+ 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator',
+ 'bbbbbbbb-1111-2222-3333-444444444444': 'Deployer',
+ });
+ // User has both mapped roles
+ mockUserService.getUserRoles.mockResolvedValue([
+ { id: 'role-operator', name: 'Operator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ { id: 'role-deploy', name: 'Deployer', description: '', isBuiltIn: 0, createdAt: '', updatedAt: '' },
+ { id: 'role-viewer', name: 'Viewer', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' },
+ ]);
+
+ // Empty groups array — all mapped roles should be revoked, Viewer preserved
+ await syncService.syncGroupRoles('user-1', []);
+
+ expect(mockUserService.removeRoleFromUser).toHaveBeenCalledWith('user-1', 'role-operator');
+ expect(mockUserService.removeRoleFromUser).toHaveBeenCalledWith('user-1', 'role-deploy');
+ expect(mockUserService.removeRoleFromUser).toHaveBeenCalledTimes(2);
+ // Viewer not touched
+ expect(mockUserService.assignRoleToUser).not.toHaveBeenCalled();
+ });
+
+ it('logs sync completion with counts', async () => {
+ createSyncService({
+ 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator',
+ });
+ mockUserService.getUserRoles.mockResolvedValue([]);
+
+ await syncService.syncGroupRoles('user-1', [
+ 'aaaaaaaa-1111-2222-3333-444444444444',
+ ]);
+
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Group-to-role sync completed',
+ expect.objectContaining({
+ component: 'EntraIdService',
+ operation: 'syncGroupRoles',
+ metadata: expect.objectContaining({
+ userId: 'user-1',
+ assigned: 1,
+ revoked: 0,
+ groupCount: 1,
+ }),
+ }),
+ );
+ });
+
+ it('continues gracefully when assignRoleToUser throws (race condition)', async () => {
+ createSyncService({
+ 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator',
+ 'bbbbbbbb-1111-2222-3333-444444444444': 'Deployer',
+ });
+ mockUserService.getUserRoles.mockResolvedValue([]);
+ mockUserService.assignRoleToUser
+ .mockRejectedValueOnce(new Error('Role is already assigned to this user'))
+ .mockResolvedValueOnce(undefined);
+
+ // Should not throw — the error is swallowed with a log
+ await expect(syncService.syncGroupRoles('user-1', [
+ 'aaaaaaaa-1111-2222-3333-444444444444',
+ 'bbbbbbbb-1111-2222-3333-444444444444',
+ ])).resolves.toBeUndefined();
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to assign role'),
+ expect.any(Object),
+ );
+ });
+ });
+});
diff --git a/docs/api.md b/docs/api.md
index 44c07d5f..54052dca 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -583,8 +583,44 @@ Require `AUTH_ENABLED=true`. All endpoints require JWT auth and appropriate RBAC
| Method | Endpoint | Description |
|---|---|---|
| `POST` | `/api/auth/login` | Login (returns JWT) |
-| `POST` | `/api/auth/logout` | Logout |
+| `POST` | `/api/auth/logout` | Logout (includes `entraIdLogoutUrl` for SSO sessions) |
| `GET` | `/api/auth/me` | Current user info |
+| `GET` | `/api/auth/providers` | Available auth methods (public, no auth required) |
+
+### Azure Entra ID SSO
+
+Available when `ENTRA_ID_ENABLED=true`. Returns 404 otherwise.
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `GET` | `/api/auth/entra-id/login` | Redirects (302) to Microsoft login |
+| `GET` | `/api/auth/entra-id/callback` | OAuth callback — exchanges code, redirects to frontend |
+| `POST` | `/api/auth/entra-id/token` | Exchange single-use auth code for JWT pair |
+
+**`GET /api/auth/providers` response:**
+
+```json
+{
+ "local": true,
+ "entraId": { "enabled": true, "name": "Microsoft Entra ID" }
+}
+```
+
+**`POST /api/auth/entra-id/token` request:**
+
+```json
+{ "code": "" }
+```
+
+**`POST /api/auth/entra-id/token` response:**
+
+```json
+{
+ "token": "",
+ "refreshToken": "",
+ "user": { "id": "...", "username": "...", "email": "..." }
+}
+```
---
diff --git a/docs/architecture.md b/docs/architecture.md
index 0b12a974..d484955e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -155,6 +155,7 @@ backend/src/
│ ├── CommandWhitelistService.ts security: allowed commands
│ ├── DatabaseService.ts SQLite, migrations
│ ├── AuthenticationService.ts JWT auth
+│ ├── EntraIdService.ts Azure Entra ID SSO (OIDC, PKCE, user provisioning)
│ ├── BatchExecutionService.ts multi-node execution
│ ├── UserService.ts
│ ├── RoleService.ts
@@ -181,6 +182,7 @@ frontend/src/
└── lib/
├── router.svelte.ts client-side router (Svelte 5 runes)
├── auth.svelte.ts JWT auth state
+ ├── entraIdAuth.svelte.ts Entra ID SSO state (provider discovery, callback handling)
├── api.ts HTTP infrastructure (get, post, put, del, error handling)
├── proxmoxApi.ts Proxmox provisioning API functions
├── awsApi.ts AWS EC2 API functions
@@ -208,6 +210,7 @@ Schema is managed by sequential migration files in `database/migrations/`. A mig
- **Command whitelisting** — `CommandWhitelistService` validates every command before execution. Set `COMMAND_WHITELIST_ALLOW_ALL=false` in production.
- **JWT authentication** — all API routes behind auth middleware when `AUTH_ENABLED=true`.
+- **Azure Entra ID SSO** — optional federated authentication via OpenID Connect (OAuth 2.0 Authorization Code + PKCE). Coexists with local auth. See [integrations/entra-id.md](integrations/entra-id.md).
- **RBAC** — role-based access control via `UserService`, `RoleService`, `PermissionService`. See [permissions-rbac.md](./permissions-rbac.md).
- **Rate limiting** — applied at middleware level.
- **Security headers** — helmet middleware.
diff --git a/docs/configuration.md b/docs/configuration.md
index 4cf06ceb..ea57d5f1 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -73,6 +73,24 @@ DATABASE_URL=postgres://pabawi:pabawi@postgres:5432/pabawi
| `JWT_SECRET` | **required** | Secret key for JWT token signing. Must be ≥ 32 chars of random entropy and not a placeholder (e.g. `your-secure-random-secret-here`, `change-me`). Generate with `openssl rand -base64 32`. The server refuses to start otherwise. Tokens are issued/verified with `iss=pabawi` / `aud=pabawi`. |
| `PABAWI_LIFECYCLE_TOKEN` | _(empty)_ | Bearer token required for inventory lifecycle endpoints (`POST /api/nodes/:id/action`, `DELETE /api/inventory/:id`). When unset, those endpoints return 500 (`LIFECYCLE_AUTH_MISCONFIGURED`). |
+### Azure Entra ID SSO
+
+Optional federated authentication via OpenID Connect. When enabled, the login page shows "Sign in with Microsoft" alongside local login. See [integrations/entra-id.md](./integrations/entra-id.md) for Azure portal setup.
+
+| Variable | Default | Description |
+|---|---|---|
+| `ENTRA_ID_ENABLED` | `false` | Set to `"true"` to enable Entra ID SSO. All other `ENTRA_ID_*` vars are ignored unless this is `"true"`. |
+| `ENTRA_ID_TENANT_ID` | **required** | Azure tenant (directory) ID |
+| `ENTRA_ID_CLIENT_ID` | **required** | Application (client) ID from the app registration |
+| `ENTRA_ID_CLIENT_SECRET` | **required** | Client secret value |
+| `ENTRA_ID_REDIRECT_URI` | **required** | OAuth callback URL (must match Azure app registration). Format: `https://your-host/api/auth/entra-id/callback` |
+| `ENTRA_ID_SCOPES` | `openid,profile,email` | Comma-separated OAuth scopes. Empty entries are discarded. |
+| `ENTRA_ID_GROUP_MAPPING` | _(none)_ | JSON object mapping Azure group IDs to Pabawi role names. Example: `{"uuid-1":"administrator","uuid-2":"operator"}` |
+| `ENTRA_ID_POST_LOGOUT_REDIRECT_URI` | _(app base URL)_ | Where Microsoft redirects after SSO logout |
+| `ENTRA_ID_JWKS_CACHE_TTL_MS` | `86400000` | How long to cache JWKS signing keys (ms). Default: 24 hours. |
+
+When `ENTRA_ID_ENABLED=true`, all four required variables must be set or the server refuses to start with a validation error listing the missing ones.
+
## Bolt
| Variable | Default | Description |
diff --git a/docs/integrations/entra-id.md b/docs/integrations/entra-id.md
new file mode 100644
index 00000000..1ce205f2
--- /dev/null
+++ b/docs/integrations/entra-id.md
@@ -0,0 +1,149 @@
+# Azure Entra ID Authentication
+
+Pabawi supports Azure Entra ID (formerly Azure AD) as a federated authentication provider via OpenID Connect. Users authenticate through their organization's Azure tenant and are automatically provisioned on first login. Group memberships can be mapped to Pabawi roles for centralized access control.
+
+## Prerequisites
+
+- Azure Entra ID tenant
+- App registration in the Azure portal with:
+ - A client secret
+ - Redirect URI configured (e.g. `https://pabawi.example.com/api/auth/entra-id/callback`)
+ - `openid`, `profile`, and `email` permissions granted
+- (Optional) Group claims configured in the token if using group-to-role mapping
+
+## Configuration
+
+```bash
+ENTRA_ID_ENABLED=true
+ENTRA_ID_TENANT_ID=12345678-abcd-efgh-ijkl-123456789012
+ENTRA_ID_CLIENT_ID=abcdef01-2345-6789-abcd-ef0123456789
+ENTRA_ID_CLIENT_SECRET=your-client-secret-value
+ENTRA_ID_REDIRECT_URI=https://pabawi.example.com/api/auth/entra-id/callback
+
+# Optional
+# ENTRA_ID_SCOPES=openid,profile,email
+# ENTRA_ID_GROUP_MAPPING={"group-uuid-1":"administrator","group-uuid-2":"operator"}
+# ENTRA_ID_POST_LOGOUT_REDIRECT_URI=https://pabawi.example.com
+# ENTRA_ID_JWKS_CACHE_TTL_MS=86400000
+```
+
+See [configuration.md](../configuration.md#azure-entra-id-sso) for the full variable reference.
+
+## Azure Portal Setup
+
+### 1. Register an Application
+
+1. Go to **Azure Portal → Microsoft Entra ID → App registrations → New registration**
+2. Name: e.g. "Pabawi SSO"
+3. Supported account types: "Accounts in this organizational directory only" (single tenant)
+4. Redirect URI: Web → `https://your-pabawi-host/api/auth/entra-id/callback`
+5. Click **Register**
+
+### 2. Configure Client Secret
+
+1. In the app registration → **Certificates & secrets → New client secret**
+2. Set a description and expiry
+3. Copy the **Value** (shown only once) → use as `ENTRA_ID_CLIENT_SECRET`
+
+### 3. Configure API Permissions
+
+1. Go to **API permissions → Add a permission → Microsoft Graph → Delegated permissions**
+2. Add: `openid`, `profile`, `email`
+3. If using group-to-role mapping, also add `GroupMember.Read.All` (requires admin consent)
+4. Click **Grant admin consent**
+
+### 4. Configure Token Claims (Optional)
+
+For group-to-role mapping:
+
+1. Go to **Token configuration → Add groups claim**
+2. Select "Security groups" and/or "All groups"
+3. Under "ID token", ensure "Group ID" is selected
+
+### 5. Note Required Values
+
+From the app registration's **Overview** page:
+
+- **Application (client) ID** → `ENTRA_ID_CLIENT_ID`
+- **Directory (tenant) ID** → `ENTRA_ID_TENANT_ID`
+
+## Authentication Flow
+
+```
+User → "Sign in with Microsoft" → Pabawi backend → 302 redirect to Microsoft login
+Microsoft login → user authenticates → callback to Pabawi with authorization code
+Pabawi → exchanges code for ID token → validates token → provisions user → issues Pabawi JWT
+```
+
+The flow uses OAuth 2.0 Authorization Code with PKCE (S256). State, nonce, and code verifier are stored server-side with a 10-minute TTL.
+
+## User Provisioning
+
+On first SSO login:
+
+1. If a Pabawi user with the same email already exists, the Entra ID identity is **linked** to that account. The existing password remains valid for local login.
+2. If no matching user exists, a new account is created with:
+ - Username derived from `preferred_username` or email local-part
+ - No password (federation-only — cannot use local login)
+ - Default viewer role assigned
+ - Active status
+
+On subsequent logins, the existing account is used without modifying stored profile data.
+
+## Group-to-Role Mapping
+
+Map Azure group object IDs to Pabawi role names:
+
+```bash
+ENTRA_ID_GROUP_MAPPING={"e5f3a1b2-...":"administrator","c7d8e9f0-...":"operator"}
+```
+
+Behavior:
+
+- Groups present in the token claim → corresponding Pabawi roles are assigned
+- Groups removed since last login → corresponding mapped roles are revoked
+- Roles assigned outside the mapping (manually) → preserved unchanged
+- Mapping references a non-existent Pabawi role → warning logged, entry skipped
+- No `groups` claim in token → no role changes made
+
+Group IDs are matched case-insensitively (UUIDs).
+
+## Logout
+
+When a user who authenticated via Entra ID logs out:
+
+1. Pabawi revokes the access and refresh tokens
+2. The logout response includes an `entraIdLogoutUrl`
+3. The frontend redirects to that URL for single sign-out at Microsoft
+4. After Microsoft logout, the browser redirects to `ENTRA_ID_POST_LOGOUT_REDIRECT_URI`
+
+## Coexistence with Local Auth
+
+Both authentication methods work simultaneously:
+
+- The login page shows "Sign in with Microsoft" alongside the local login form
+- Users with linked accounts can use either method
+- Federation-only users (no password) must use SSO
+- JWT tokens are identical regardless of auth method — middleware sees no difference
+
+## Security
+
+- PKCE (S256) on every authorization request
+- State and nonce validated on every callback
+- ID token signatures verified against JWKS keys (cached 24h by default)
+- Single-use authorization codes (60s TTL) for frontend token delivery
+- Clock skew tolerance: 5 minutes for token expiry
+- Client secret, authorization codes, and tokens are never logged
+
+## Troubleshooting
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| 404 on `/api/auth/entra-id/login` | `ENTRA_ID_ENABLED` not set to `"true"` | Check `.env` value is exactly `true` |
+| `INVALID_STATE` on callback | State expired (>10 min) or browser cookies issue | Retry login; check server clock |
+| `TOKEN_EXCHANGE_FAILED` | Network issue or invalid client secret | Verify `ENTRA_ID_CLIENT_SECRET`; check connectivity to `login.microsoftonline.com` |
+| `INVALID_ID_TOKEN` | Tenant/client ID mismatch or clock skew | Verify `ENTRA_ID_TENANT_ID` and `ENTRA_ID_CLIENT_ID` match the app registration |
+| `MISSING_CLAIMS` | App registration missing `email` or `profile` scope | Add permissions in Azure portal and grant admin consent |
+| `JWKS_UNAVAILABLE` | Cannot reach Microsoft's key endpoint | Check outbound HTTPS; keys are cached so transient failures are tolerated |
+| Group roles not syncing | No `groups` claim in token | Configure group claims in Token configuration (Azure portal) |
+| Config validation error at startup | Missing required variables | Set all of: `TENANT_ID`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI` |
diff --git a/docs/permissions-rbac.md b/docs/permissions-rbac.md
index 899b7d76..82d2f37a 100644
--- a/docs/permissions-rbac.md
+++ b/docs/permissions-rbac.md
@@ -2,6 +2,27 @@
Pabawi uses Role-Based Access Control (RBAC) when `AUTH_ENABLED=true`. Users are assigned roles. Roles contain permissions. Permissions gate specific actions.
+## Authentication Methods
+
+Pabawi supports two authentication methods that can work simultaneously:
+
+- **Local authentication** — username/password login, always available
+- **Azure Entra ID SSO** — federated login via OpenID Connect (optional, see [integrations/entra-id.md](./integrations/entra-id.md))
+
+Both methods issue identical Pabawi JWT tokens. The RBAC middleware makes no distinction between authentication origins — permissions are determined by the user's assigned roles regardless of how they logged in.
+
+### Federated Users
+
+Users who authenticate via Entra ID for the first time are automatically provisioned:
+
+- If a local user with the same email exists, the Entra ID identity is linked to that account
+- Otherwise, a new account is created with federation-only access (no local password)
+- The default viewer role is assigned to new federated users
+
+### Group-to-Role Mapping
+
+When `ENTRA_ID_GROUP_MAPPING` is configured, Pabawi synchronizes roles at each SSO login based on the user's Azure group memberships. Manually assigned roles are preserved. See [integrations/entra-id.md](./integrations/entra-id.md#group-to-role-mapping) for details.
+
## Permission Format
```
diff --git a/frontend/src/components/EntraIdLoginButton.svelte b/frontend/src/components/EntraIdLoginButton.svelte
new file mode 100644
index 00000000..6ae22e11
--- /dev/null
+++ b/frontend/src/components/EntraIdLoginButton.svelte
@@ -0,0 +1,32 @@
+
+
+
diff --git a/frontend/src/components/EntraIdLoginButton.test.ts b/frontend/src/components/EntraIdLoginButton.test.ts
new file mode 100644
index 00000000..d7bd8ab4
--- /dev/null
+++ b/frontend/src/components/EntraIdLoginButton.test.ts
@@ -0,0 +1,68 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/svelte';
+import EntraIdLoginButton from './EntraIdLoginButton.svelte';
+
+describe('EntraIdLoginButton', () => {
+ let originalLocation: Location;
+
+ beforeEach(() => {
+ originalLocation = window.location;
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { ...originalLocation, href: '' },
+ });
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: originalLocation,
+ });
+ });
+
+ it('renders with Microsoft logo SVG', () => {
+ render(EntraIdLoginButton);
+
+ const svg = document.querySelector('svg');
+ expect(svg).not.toBeNull();
+ expect(svg?.getAttribute('aria-hidden')).toBe('true');
+ // Microsoft logo has 4 colored rectangles
+ const rects = svg?.querySelectorAll('rect');
+ expect(rects?.length).toBe(4);
+ });
+
+ it('renders default "Sign in with Microsoft" text', () => {
+ render(EntraIdLoginButton);
+
+ expect(screen.getByText('Sign in with Microsoft')).toBeTruthy();
+ });
+
+ it('renders custom provider name when passed', () => {
+ render(EntraIdLoginButton, { props: { providerName: 'Contoso' } });
+
+ expect(screen.getByText('Sign in with Contoso')).toBeTruthy();
+ });
+
+ it('has correct aria-label with default provider name', () => {
+ render(EntraIdLoginButton);
+
+ const button = screen.getByRole('button');
+ expect(button.getAttribute('aria-label')).toBe('Sign in with Microsoft');
+ });
+
+ it('has correct aria-label with custom provider name', () => {
+ render(EntraIdLoginButton, { props: { providerName: 'Contoso' } });
+
+ const button = screen.getByRole('button');
+ expect(button.getAttribute('aria-label')).toBe('Sign in with Contoso');
+ });
+
+ it('redirects to /api/auth/entra-id/login on click', async () => {
+ render(EntraIdLoginButton);
+
+ const button = screen.getByRole('button');
+ await fireEvent.click(button);
+
+ expect(window.location.href).toBe('/api/auth/entra-id/login');
+ });
+});
diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts
index b9cce630..85b7c6b5 100644
--- a/frontend/src/lib/auth.svelte.ts
+++ b/frontend/src/lib/auth.svelte.ts
@@ -160,19 +160,23 @@ class AuthManager {
}
/**
- * Logout and clear all auth data
- * Requirement: 1.6, 6.4
+ * Logout and clear all auth data.
+ * If the session was established via Entra ID, the backend returns an
+ * `entraIdLogoutUrl` — redirect the browser there for single sign-out.
+ * Requirement: 1.6, 6.4, 8.4
*/
async logout(): Promise {
logger.info('Auth', 'logout', 'Logging out user', {
userId: this._user?.id,
});
+ let entraIdLogoutUrl: string | undefined;
+
// Call logout endpoint to revoke tokens — pass both access + refresh so
// the backend can revoke them as a pair (C1 refresh-token rotation).
if (this._token) {
try {
- await fetch(`${API_BASE_URL}/auth/logout`, {
+ const response = await fetch(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this._token}`,
@@ -182,6 +186,11 @@ class AuthManager {
? JSON.stringify({ refreshToken: this._refreshToken })
: undefined,
});
+
+ if (response.ok) {
+ const data = await response.json() as { entraIdLogoutUrl?: string };
+ entraIdLogoutUrl = data.entraIdLogoutUrl;
+ }
} catch (error) {
logger.warn('Auth', 'logout', 'Logout API call failed', {
error: error instanceof Error ? error.message : 'Unknown error',
@@ -192,6 +201,13 @@ class AuthManager {
this.clearAuthData();
+ // Redirect to Entra ID end-session endpoint for SSO single sign-out
+ if (entraIdLogoutUrl) {
+ logger.info('Auth', 'logout', 'Redirecting to Entra ID logout');
+ window.location.href = entraIdLogoutUrl;
+ return;
+ }
+
logger.info('Auth', 'logout', 'Logout complete');
}
@@ -272,6 +288,14 @@ class AuthManager {
this._error = null;
}
+ /**
+ * Set auth data from an external SSO flow (Entra ID token exchange).
+ * Public wrapper around setAuthData for use by entraIdAuth module.
+ */
+ setAuthDataFromSso(data: AuthResponse): void {
+ this.setAuthData(data);
+ }
+
// Private methods
private setAuthData(data: AuthResponse): void {
diff --git a/frontend/src/lib/entraIdAuth.svelte.test.ts b/frontend/src/lib/entraIdAuth.svelte.test.ts
new file mode 100644
index 00000000..9527577c
--- /dev/null
+++ b/frontend/src/lib/entraIdAuth.svelte.test.ts
@@ -0,0 +1,180 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+// Mock the api module before importing the module under test
+vi.mock('./api', () => ({
+ get: vi.fn(),
+ post: vi.fn(),
+}));
+
+// Mock the auth module
+vi.mock('./auth.svelte', () => ({
+ authManager: {
+ setAuthDataFromSso: vi.fn(),
+ isAuthenticated: false,
+ },
+}));
+
+// Mock the router module
+vi.mock('./router.svelte', () => ({
+ router: {
+ navigate: vi.fn(),
+ },
+}));
+
+// Mock the toast module
+vi.mock('./toast.svelte', () => ({
+ showError: vi.fn(),
+}));
+
+import * as api from './api';
+import { authManager } from './auth.svelte';
+import { router } from './router.svelte';
+import { entraIdAuth } from './entraIdAuth.svelte';
+
+describe('EntraIdAuthStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset store state between tests
+ entraIdAuth.isEntraIdEnabled = false;
+ entraIdAuth.entraIdProviderName = 'Microsoft Entra ID';
+ entraIdAuth.providerDiscoveryError = false;
+ entraIdAuth.isExchangingCode = false;
+ entraIdAuth.exchangeError = null;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('discoverProviders', () => {
+ it('sets isEntraIdEnabled to true when API returns entraId enabled', async () => {
+ vi.mocked(api.get).mockResolvedValue({
+ local: true,
+ entraId: { enabled: true, name: 'Contoso SSO' },
+ });
+
+ await entraIdAuth.discoverProviders();
+
+ expect(entraIdAuth.isEntraIdEnabled).toBe(true);
+ expect(entraIdAuth.entraIdProviderName).toBe('Contoso SSO');
+ expect(entraIdAuth.providerDiscoveryError).toBe(false);
+ });
+
+ it('sets isEntraIdEnabled to false when entraId is not in response', async () => {
+ vi.mocked(api.get).mockResolvedValue({ local: true });
+
+ await entraIdAuth.discoverProviders();
+
+ expect(entraIdAuth.isEntraIdEnabled).toBe(false);
+ expect(entraIdAuth.providerDiscoveryError).toBe(false);
+ });
+
+ it('sets providerDiscoveryError on timeout/failure', async () => {
+ vi.mocked(api.get).mockRejectedValue(new Error('Request timed out'));
+
+ await entraIdAuth.discoverProviders();
+
+ expect(entraIdAuth.providerDiscoveryError).toBe(true);
+ expect(entraIdAuth.isEntraIdEnabled).toBe(false);
+ });
+
+ it('calls /api/auth/providers with 5s timeout', async () => {
+ vi.mocked(api.get).mockResolvedValue({ local: true });
+
+ await entraIdAuth.discoverProviders();
+
+ expect(api.get).toHaveBeenCalledWith('/api/auth/providers', {
+ timeout: 5000,
+ maxRetries: 0,
+ showRetryNotifications: false,
+ });
+ });
+ });
+
+ describe('handleSsoCallback', () => {
+ let originalLocation: Location;
+
+ beforeEach(() => {
+ originalLocation = window.location;
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: {
+ ...originalLocation,
+ search: '',
+ pathname: '/login',
+ },
+ });
+
+ // Mock window.history.replaceState
+ vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: originalLocation,
+ });
+ });
+
+ it('returns false when no code in URL', async () => {
+ window.location.search = '';
+
+ const result = await entraIdAuth.handleSsoCallback();
+
+ expect(result).toBe(false);
+ expect(api.post).not.toHaveBeenCalled();
+ });
+
+ it('posts code and stores tokens on success', async () => {
+ window.location.search = '?code=test-auth-code';
+
+ const mockResponse = {
+ token: 'access-token-123',
+ refreshToken: 'refresh-token-456',
+ user: { id: '1', username: 'testuser', email: 'test@example.com' },
+ };
+ vi.mocked(api.post).mockResolvedValue(mockResponse);
+
+ const result = await entraIdAuth.handleSsoCallback();
+
+ expect(result).toBe(true);
+ expect(api.post).toHaveBeenCalledWith(
+ '/api/auth/entra-id/token',
+ { code: 'test-auth-code' },
+ { maxRetries: 0, showRetryNotifications: false },
+ );
+ expect(authManager.setAuthDataFromSso).toHaveBeenCalledWith(mockResponse);
+ expect(router.navigate).toHaveBeenCalledWith('/');
+ });
+
+ it('sets exchangeError on token exchange failure', async () => {
+ window.location.search = '?code=invalid-code';
+ vi.mocked(api.post).mockRejectedValue(new Error('Token exchange failed'));
+
+ const result = await entraIdAuth.handleSsoCallback();
+
+ expect(result).toBe(false);
+ expect(entraIdAuth.exchangeError).toBe('Token exchange failed');
+ });
+
+ it('cleans the URL after successful exchange', async () => {
+ window.location.search = '?code=test-code';
+ vi.mocked(api.post).mockResolvedValue({
+ token: 'tok', refreshToken: 'ref', user: {},
+ });
+
+ await entraIdAuth.handleSsoCallback();
+
+ expect(window.history.replaceState).toHaveBeenCalledWith({}, '', '/login');
+ });
+
+ it('cleans the URL after failed exchange', async () => {
+ window.location.search = '?code=bad-code';
+ vi.mocked(api.post).mockRejectedValue(new Error('failed'));
+
+ await entraIdAuth.handleSsoCallback();
+
+ expect(window.history.replaceState).toHaveBeenCalledWith({}, '', '/login');
+ });
+ });
+});
diff --git a/frontend/src/lib/entraIdAuth.svelte.ts b/frontend/src/lib/entraIdAuth.svelte.ts
new file mode 100644
index 00000000..fd114310
--- /dev/null
+++ b/frontend/src/lib/entraIdAuth.svelte.ts
@@ -0,0 +1,127 @@
+/**
+ * Entra ID (Azure AD) authentication state management using Svelte 5 runes
+ *
+ * Handles:
+ * - Provider discovery via /api/auth/providers
+ * - SSO callback code extraction and token exchange
+ * - Error state for discovery and token exchange failures
+ *
+ * Requirements: 10.1, 10.2, 10.5, 10.6, 10.7
+ */
+
+import { get, post } from './api';
+import { authManager } from './auth.svelte';
+import type { AuthResponse } from './auth.svelte';
+import { router } from './router.svelte';
+import { showError } from './toast.svelte';
+
+const PROVIDER_DISCOVERY_TIMEOUT_MS = 5000;
+
+interface ProvidersResponse {
+ local: boolean;
+ entraId?: {
+ enabled: boolean;
+ name: string;
+ };
+}
+
+class EntraIdAuthStore {
+ isEntraIdEnabled = $state(false);
+ entraIdProviderName = $state('Microsoft Entra ID');
+ providerDiscoveryError = $state(false);
+ isDiscovering = $state(false);
+ isExchangingCode = $state(false);
+ exchangeError = $state(null);
+
+ /**
+ * Discover available authentication providers.
+ * Calls GET /api/auth/providers with a 5s timeout.
+ * On failure or timeout, sets providerDiscoveryError = true and
+ * leaves isEntraIdEnabled = false (show only local login).
+ *
+ * Requirement: 10.1, 10.2, 10.7
+ */
+ async discoverProviders(): Promise {
+ this.isDiscovering = true;
+ this.providerDiscoveryError = false;
+
+ try {
+ const response = await get('/api/auth/providers', {
+ timeout: PROVIDER_DISCOVERY_TIMEOUT_MS,
+ maxRetries: 0,
+ showRetryNotifications: false,
+ });
+
+ if (response.entraId?.enabled) {
+ this.isEntraIdEnabled = true;
+ this.entraIdProviderName = response.entraId.name || 'Microsoft Entra ID';
+ } else {
+ this.isEntraIdEnabled = false;
+ }
+ } catch {
+ this.providerDiscoveryError = true;
+ this.isEntraIdEnabled = false;
+ } finally {
+ this.isDiscovering = false;
+ }
+ }
+
+ /**
+ * Handle the SSO callback redirect.
+ * Extracts `code` from the current URL query parameters, POSTs it to
+ * /api/auth/entra-id/token, stores tokens in auth state, and navigates
+ * to the landing page.
+ *
+ * Returns true if a code was present and exchange was attempted,
+ * false if no code was found (not a callback).
+ *
+ * Requirement: 10.5, 10.6
+ */
+ async handleSsoCallback(): Promise {
+ const params = new URLSearchParams(window.location.search);
+ const code = params.get('code');
+
+ if (!code) {
+ return false;
+ }
+
+ this.isExchangingCode = true;
+ this.exchangeError = null;
+
+ try {
+ const response = await post('/api/auth/entra-id/token', { code }, {
+ maxRetries: 0,
+ showRetryNotifications: false,
+ });
+
+ // Store tokens in auth state using the same method as local login
+ authManager.setAuthDataFromSso(response);
+
+ // Clean the URL (remove ?code= parameter) and navigate to landing page
+ window.history.replaceState({}, '', window.location.pathname);
+ router.navigate('/');
+
+ return true;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'SSO authentication failed';
+ this.exchangeError = message;
+ showError('SSO authentication failed', message);
+
+ // Clean the URL to remove the code parameter but stay on login page
+ window.history.replaceState({}, '', window.location.pathname);
+
+ return false;
+ } finally {
+ this.isExchangingCode = false;
+ }
+ }
+
+ /**
+ * Clear exchange error state
+ */
+ clearExchangeError(): void {
+ this.exchangeError = null;
+ }
+}
+
+export const entraIdAuth = new EntraIdAuthStore();
diff --git a/frontend/src/pages/LoginPage.svelte b/frontend/src/pages/LoginPage.svelte
index 63878a95..7f058f55 100644
--- a/frontend/src/pages/LoginPage.svelte
+++ b/frontend/src/pages/LoginPage.svelte
@@ -2,9 +2,10 @@
import { authManager } from '../lib/auth.svelte';
import { router } from '../lib/router.svelte';
import { showError, showSuccess } from '../lib/toast.svelte';
+ import { entraIdAuth } from '../lib/entraIdAuth.svelte';
import LoadingSpinner from '../components/LoadingSpinner.svelte';
+ import EntraIdLoginButton from '../components/EntraIdLoginButton.svelte';
import { get } from '../lib/api';
- import { onMount } from 'svelte';
let username = $state('');
let password = $state(''); // pragma: allowlist secret
@@ -12,6 +13,7 @@
let validationErrors = $state<{ username?: string; password?: string }>({});
let selfRegistrationAllowed = $state(false);
let checkingConfig = $state(true);
+ let initialized = false;
// Redirect if already authenticated
$effect(() => {
@@ -20,18 +22,38 @@
}
});
- // Check if self-registration is allowed
- onMount(async () => {
+ // On mount: check for SSO callback code, then discover providers and config
+ $effect(() => {
+ if (initialized) return;
+ initialized = true;
+ void initializeLoginPage();
+ });
+
+ async function initializeLoginPage(): Promise {
+ // First, check if this is an SSO callback with ?code= parameter
+ const handled = await entraIdAuth.handleSsoCallback();
+ if (handled) {
+ // Callback was handled (code was exchanged or failed) — don't proceed with discovery
+ return;
+ }
+
+ // Discover available auth providers and check self-registration in parallel
+ await Promise.all([
+ entraIdAuth.discoverProviders(),
+ checkSelfRegistration(),
+ ]);
+ }
+
+ async function checkSelfRegistration(): Promise {
try {
const status = await get<{ config: { allowSelfRegistration: boolean } | null }>('/api/setup/status');
selfRegistrationAllowed = status.config?.allowSelfRegistration ?? false;
- } catch (error) {
- console.error('Failed to check self-registration status:', error);
+ } catch {
selfRegistrationAllowed = false;
} finally {
checkingConfig = false;
}
- });
+ }
function validateForm(): boolean {
const errors: { username?: string; password?: string } = {};
@@ -66,7 +88,6 @@
if (success) {
showSuccess('Login successful', `Welcome back, ${username}!`);
- // Redirect to intended path or home
router.navigateToIntendedOrDefault('/');
} else {
showError('Login failed', authManager.error?.message || 'Invalid credentials');
@@ -98,57 +119,18 @@
-