π CONDOR Authentication & Authorization
Last updated: March 2026 Applies to: Server v2.x with Entra ID SSO support
Table of Contentsβ
- Overview
- Architecture Diagram
- Authentication Methods
- Auth Resolution Order
- CSRF Protection
- Session Lifecycle & Redis Storage
- Token Bootstrap Flow (Entra SSO)
- JWT Validation Pipeline
- Error Handling
- API Endpoints Reference
- Configuration Reference
- Infrastructure & Secrets Management
- Security Design Decisions
Overviewβ
CONDOR supports three authentication methods, resolved in priority order on every request:
| Priority | Method | Credential | Use Case |
|---|---|---|---|
| 1 | Entra ID (SSO) | JWT via ?token= URL param β session cookie | Users arriving from the Original App |
| 2 | Session Cookie | session httpOnly cookie | Browser sessions (login or SSO) |
| 3 | API Key Bearer | Authorization: Bearer <key> header | Machine-to-machine / scripts |
All three methods ultimately resolve to either a Session or EntraUser object that routes consume via Depends(SessionService.require_session).
Architecture Diagramβ
Authentication Methodsβ
1. Entra ID Single Sign-On (SSO)β
The primary authentication method for users accessing CONDOR from the Original App.
How It Worksβ
- User clicks "CONDOR" in the Original App
- Original App redirects to
https://condor.example.com/?token=<JWT> - CONDOR validates the JWT once (signature, expiry, issuer, audience,
azp, roles) - Validated
EntraUser+ raw JWT cached in Redis with TTL matching JWTexp - Session cookie set on a 302 redirect to
/(clean URL) - All subsequent requests use the session cookie β cached
EntraUserloaded from Redis - No JWT re-validation on API calls β Redis TTL handles expiry
JWT Claims Usedβ
| Claim | Purpose | Example |
|---|---|---|
aud | Must match ENTRA_CLIENT_ID | 6999a97c-c1cf-4809-b14c-05bc596bb0c5 |
iss | Must match tenant issuer URL | https://{tenant}.ciamlogin.com/{tenant}/v2.0 |
azp | Must be in ENTRA_ALLOWED_CLIENT_IDS | 64abed69-d2d8-402e-9a04-e83c2cc8bde2 |
exp | Token expiry β session cookie TTL | Unix timestamp |
roles | RBAC β must include Condor.Write | ["Condor.Write", "Camera.Operate"] |
oid | User's Object ID in Azure AD | UUID |
name | Display name | Sharath Gaddameedi |
preferred_username | user@live.com | |
tid | Tenant ID | UUID |
Role Enforcementβ
Condor.Writeβ Required at bootstrap time to create a session. Without this role, the user sees an error on the login page.- Route-level roles β Individual routes can enforce additional roles using
EntraAuthService.require_roles("RoleName").
2. Username / Password Loginβ
Traditional form-based login for direct access without SSO.
Session Cookie Propertiesβ
| Property | Value | Reason |
|---|---|---|
httpOnly | true | Prevents JavaScript access (XSS protection) |
secure | true | HTTPS only (localhost exempt in browsers) |
samesite | lax | CSRF protection for cross-origin requests |
max_age | Not set (session cookie) | Dies with browser tab |
3. API Key Bearer Tokenβ
For machine-to-machine access (scripts, automated exports, health checks).
- Stateless β No Redis session; a new
Sessionobject is created per request - No CSRF β Bearer tokens are not vulnerable to CSRF
- No TTL refresh β No session to extend
- Key format:
SESSION_API_KEYS='{"api-key-value": "username"}'
Auth Resolution Orderβ
Every protected route uses Depends(SessionService.require_session), which calls get_session() internally:
Key insight: Entra-bootstrapped sessions (via /?token=JWT) resolve through the cookie path on subsequent requests. The cookie is looked up in Redis, which returns the cached EntraUser β no JWT re-validation occurs. Redis TTL auto-expires the cached user when the JWT would have expired.
CSRF Protectionβ
CONDOR uses the Signed Double-Submit Cookie pattern:
CSRF Token = random_value | HMAC-SHA256(SESSION_CSRF_SECRET, session_id:random_value)
When enforced: POST, PUT, DELETE, PATCH requests using cookie-based auth only.
Not enforced: Bearer token requests (API keys, direct Entra JWT) β not vulnerable to CSRF.
Session Lifecycle & Redis Storageβ
Redis Key Layoutβ
For a session with ID abc123:
| Key | Value | TTL |
|---|---|---|
session:abc123 | Interaction count (int) | SESSION_LIFETIME_SECONDS or JWT exp |
session:abc123:entra_user | Serialized EntraUser JSON | JWT exp |
session:abc123:entra_token | Raw JWT string | JWT exp |
Expiry Behaviorβ
| Session Type | TTL Source | Refreshed on Activity? |
|---|---|---|
| Username/Password | SESSION_LIFETIME_SECONDS (default: 7 days) | Yes β each interaction refreshes TTL |
| Entra SSO | JWT exp claim (typically 60-90 min) | No β fixed lifetime, expires with JWT |
| API Key | N/A (stateless) | N/A |
Token Bootstrap Flow (Entra SSO)β
Detailed step-by-step of what happens when a user arrives at /?token=JWT:
JWT Validation Pipelineβ
What EntraAuthService.validate_token() checks, in order:
Security: Fail-closed on missing ENTRA_ALLOWED_CLIENT_IDS
If ENTRA_ALLOWED_CLIENT_IDS is empty or not set, all tokens are rejected with a 403 error. This prevents misconfiguration from accidentally allowing any client app's token.
Error Handlingβ
When token bootstrap fails, users are redirected to /?error=<code> which displays a human-readable message:
| Error Code | Message | Cause |
|---|---|---|
not_enabled | Single sign-on is not configured on this server. | ENTRA_ENABLED=false or missing tenant/client config |
expired | Your session has expired. Please sign in again from the application. | JWT exp is in the past |
invalid_audience | This token was not issued for this application. | JWT aud β ENTRA_CLIENT_ID |
invalid_issuer | This token was issued by an untrusted authority. | JWT iss β expected tenant issuer |
invalid_signature | The token signature could not be verified. | JWKS key mismatch or tampered token |
invalid_token | The authentication token is invalid. | Malformed JWT or non-JWT string |
unauthorized_app | Access is only allowed from an authorized application. | JWT azp β ENTRA_ALLOWED_CLIENT_IDS |
missing_role | Your account does not have the required 'Condor.Write' role. Contact your administrator. | roles claim missing Condor.Write |
The error message is injected into the login page's existing #login-error element, and the ?error= param is cleaned from the URL via history.replaceState.
API Endpoints Referenceβ
| Endpoint | Method | Auth Required | Description |
|---|---|---|---|
/ | GET | Optional | Serves login page (public) or SPA (authenticated) |
/?token=<JWT> | GET | JWT in URL | Entra SSO token bootstrap |
/api/login | POST | None (form data) | Username/password authentication |
/api/logout | POST | Session cookie | Clear session and cookies |
/api/me | GET | Any auth | Returns current user info |
/api/camera | GET | require_session | List cameras |
/api/camera/event | GET | require_session | List camera events |
/api/event/{id} | GET | require_session | Get event details |
/api/event/{id}/annotate | POST | require_session + CSRF | Annotate an event |
/api/me Response Shapesβ
Entra-authenticated user:
{
"auth_method": "entra",
"oid": "1bd08d5e-dd0f-4cda-b2be-36f1cf1e91f2",
"name": "Sharath Gaddameedi",
"email": "sharathgoud@live.com",
"roles": ["Condor.Write", "Camera.Operate"]
}
Session-authenticated user:
{
"auth_method": "session",
"interaction_count": 42
}
Configuration Referenceβ
Entra ID Settings (ENTRA_ prefix)β
| Variable | Required | Default | Description |
|---|---|---|---|
ENTRA_ENABLED | Yes | false | Enable Entra ID authentication |
ENTRA_TENANT_ID | If enabled | "" | Azure AD tenant ID |
ENTRA_CLIENT_ID | If enabled | "" | CONDOR's App Registration client ID (JWT aud) |
ENTRA_ALLOWED_CLIENT_IDS | If enabled | "" | Comma-separated client IDs allowed to call CONDOR (JWT azp). Empty = reject all (fail-closed) |
ENTRA_CIAM | No | false | Use CIAM tenant ({tenant}.ciamlogin.com instead of login.microsoftonline.com) |
Session Settings (SESSION_ prefix)β
| Variable | Required | Description |
|---|---|---|
SESSION_USERNAME | Yes | Login username for password auth |
SESSION_PASSWORD | Yes | Login password for password auth |
SESSION_API_KEYS | Yes | JSON map of API keys to usernames: {"key": "username"} |
SESSION_CSRF_SECRET | Yes | HMAC secret for CSRF token signing |
SESSION_LIFETIME_SECONDS | Yes | Session TTL in seconds (default: 604800 = 7 days) |
Infrastructure & Secrets Managementβ
Secrets Flowβ
Key Vault Secrets (ai4gl-condor-uat-kv)β
These secrets are written at deploy time for backup, audit, and rotation:
| Secret Name | Source Parameter |
|---|---|
session-username | SERVER_SESSION_USERNAME |
session-password | SERVER_SESSION_PASSWORD |
session-api-keys | SERVER_SESSION_API_KEYS |
session-csrf-secret | SERVER_SESSION_CSRF_SECRET |
entra-allowed-client-ids | SERVER_ENTRA_ALLOWED_CLIENT_IDS |
postgres-password | POSTGRES_PASSWORD |
redis-password | REDIS_PASSWORD |
vm-username | vmUsername |
vm-password | vmPassword |
VM Bootstrap: write_env_fileβ
The vm-post-setup.sh script strips the SERVER_ prefix when writing to server/.env:
SERVER_ENTRA_ENABLED=true β ENTRA_ENABLED=true
SERVER_ENTRA_TENANT_ID=... β ENTRA_TENANT_ID=...
SERVER_SESSION_USERNAME=... β SESSION_USERNAME=...
Security Design Decisionsβ
Why store the JWT server-side instead of in the browser?β
| Approach | Risk | CONDOR's Choice |
|---|---|---|
JWT in sessionStorage | XSS can steal it; lost on tab close; visible in DevTools | β Removed |
JWT in localStorage | XSS can steal it; persists indefinitely | β Never used |
| JWT in httpOnly cookie | Cookie too large (>4KB); sent on every request | β Not used |
| JWT in Redis, session cookie as handle | Cookie is opaque, small, httpOnly; JWT never in browser | β Current design |
Why validate the JWT only once (at bootstrap)?β
- Performance: JWKS fetch + RSA signature verification on every request adds ~50-200ms latency
- Reliability: JWKS endpoint downtime would break all requests
- Correctness: Redis TTL mirrors JWT
expβ the cachedEntraUserexpires at exactly the same time the JWT would - Trade-off: A revoked role takes effect only after the JWT (and thus the cached session) expires (typically 60-90 min)
Why fail-closed on empty ENTRA_ALLOWED_CLIENT_IDS?β
If ENTRA_ALLOWED_CLIENT_IDS is not configured:
- Old behavior: The
azpcheck was skipped, meaning any Entra app's token was accepted - New behavior: All tokens are rejected with
403 Server misconfiguration - Rationale: Defense-in-depth β misconfiguration should deny access, not grant it
Why redirect instead of inline serving after bootstrap?β
After successful /?token=JWT validation:
- Old approach: Serve the private SPA HTML directly with cookies set on the same response
- Problem: Browsers handle
Set-Cookieon HTML responses inconsistently; the JWT token remained visible in the URL bar - New approach:
302 Redirect to /with cookies on the redirect response β browser stores cookies, follows redirect, sends cookies on the second request β clean URL, reliable cookie storage
File Referenceβ
| File | Purpose |
|---|---|
server/services/entra_auth_service.py | JWT validation, JWKS client, RBAC dependency factories |
server/services/session_service.py | Auth resolution (get_session, require_session), login/logout, CSRF |
server/routers/files_router.py | Token bootstrap (/?token=), error display, SPA serving |
server/routers/session_router.py | /api/login, /api/logout, /api/me endpoints |
server/database/redis_session_database.py | Redis session storage, Entra user caching |
server/models/entra_user.py | EntraUser Pydantic model with role helpers |
server/settings/entra_settings.py | EntraSettings β tenant, client, CIAM, JWKS URI |
server/settings/session_settings.py | SessionSettings β username, password, API keys, CSRF secret |
server/tests/test_entra_auth.py | Unit tests for JWT validation, RBAC, token extraction |
azure/infrastructure/main.bicep | Infrastructure β passes Entra params to modules |
azure/infrastructure/modules/keyvault.bicep | Stores secrets in Key Vault at deploy time |
azure/infrastructure/scripts/vm-post-setup.sh | Writes server/.env from deploy params |