# ADR-SEC-001: OIDC/JWT Authentication Strategy **Date:** 2026-08-04 **Status:** ✅ APPROVED (AEG-X-005) **Context:** Platform authentication & authorization **Decision:** OIDC for production, JWT for API service-to-service, Development headers for testing --- ## Problem Statement How should we structure authentication to: 1. **Production:** Enforce strict OAuth2/OIDC (no direct credentials) 2. **Service-to-Service:** Use JWT for microservice communication 3. **Development/Testing:** Allow header-based auth without OAuth setup 4. **Security:** Ensure no unauthenticated access reaches protected endpoints --- ## Decision ### Tier 1: Production (OIDC - OAuth2 Authorization Code Flow) **Protocol:** OpenID Connect 1.0 (built on OAuth 2.0) ```csharp // Production handler: Validates OIDC tokens from identity provider // - Verifies JWT signature using provider's public key // - Checks token expiry // - Enforces required scopes // - Maps claims to application roles public class OidcAuthenticationHandler : AuthenticationHandler { protected override async Task HandleAuthenticateAsync() { // 1. Extract token from Authorization: Bearer var token = GetBearerToken(); if (token == null) return AuthenticateResult.NoResult(); try { // 2. Validate JWT signature using OIDC provider's public key var principal = ValidateJwtSignature(token, _oidcOptions.Authority); // 3. Verify issuer, audience, expiry if (!ValidateTokenClaims(principal)) return AuthenticateResult.Fail("Token validation failed"); // 4. Map OIDC claims to application roles AddApplicationRoles(principal, _roleMapping); return AuthenticateResult.Success( new AuthenticationTicket(principal, Scheme.Name)); } catch (SecurityTokenException ex) { return AuthenticateResult.Fail($"Token invalid: {ex.Message}"); } } } ``` **Configuration (appsettings.Production.json):** ```json { "Authentication": { "Scheme": "OIDC", "Authority": "https://auth.example.com", "ClientId": "kartsell-api", "ClientSecret": "{{from-secure-vault}}", "Audience": "https://api.kartsell.example.com" } } ``` **Benefits:** - ✅ No credentials stored in app - ✅ Centralized identity management - ✅ MFA-ready (OIDC providers handle MFA) - ✅ Standards-compliant --- ### Tier 2: Service-to-Service (JWT with Shared Secret) **Protocol:** JWT (JSON Web Token) with HS256 (HMAC-SHA256) signing ```csharp // API-to-API: Service A calls Service B with JWT // - Service A signs JWT with shared secret // - Service B verifies JWT with same shared secret // - JWT includes scopes (e.g., "read:prices", "write:portfolio") public class JwtBearerAuthenticationHandler : AuthenticationHandler { protected override async Task HandleAuthenticateAsync() { var token = GetBearerToken(); if (token == null) return AuthenticateResult.NoResult(); try { // 1. Validate JWT using HS256 (shared secret) var principal = _tokenHandler.ValidateToken(token, _tokenValidationParameters); // 2. Check token expiry var expiryUnix = principal.FindFirst(JwtRegisteredClaimNames.Exp)?.Value; if (long.TryParse(expiryUnix, out var expiry)) { if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expiry) return AuthenticateResult.Fail("Token expired"); } // 3. Extract scopes (e.g., "read:signals write:portfolio") var scopes = principal.FindAll("scope").Select(c => c.Value).ToList(); return AuthenticateResult.Success( new AuthenticationTicket(principal, Scheme.Name)); } catch (SecurityTokenException ex) { return AuthenticateResult.Fail($"JWT validation failed: {ex.Message}"); } } } ``` **Example JWT Payload (Service A → Service B):** ```json { "iss": "kartsell-model-operations", "sub": "00000000-0000-0000-0000-000000000001", "aud": "kartsell-signal-engine", "scope": "read:signals write:recommendations", "iat": 1691126400, "exp": 1691130000 } ``` **Benefits:** - ✅ No OAuth provider needed for service-to-service - ✅ Stateless (no session storage) - ✅ Scope-based authorization (fine-grained) - ✅ Can be validated offline (signature check only) --- ### Tier 3: Development/Testing (DevelopmentHeader - Restricted) **Protocol:** HTTP header-based authentication (Debug mode only) ```csharp // Development only: X-KArtSell-User + X-KArtSell-Role headers // - Enabled ONLY in Debug configuration // - Disabled (403 Forbidden) in Release public class DevelopmentHeaderAuthenticationHandler : AuthenticationHandler { protected override async Task HandleAuthenticateAsync() { if (!_environment.IsDevelopment()) return AuthenticateResult.Fail("DevelopmentHeader only allowed in Development mode"); if (!Request.Headers.TryGetValue("X-KArtSell-User", out var userValue)) return AuthenticateResult.NoResult(); var user = userValue.ToString(); var role = Request.Headers.TryGetValue("X-KArtSell-Role", out var roleValue) ? roleValue.ToString() : "Analyst"; // Default if role not specified var principal = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, user), new Claim(ClaimTypes.Role, role) }, Scheme.Name)); return AuthenticateResult.Success( new AuthenticationTicket(principal, Scheme.Name)); } } ``` **Restrictions:** - ✅ Disabled in Release mode (FailClosedAuthenticationHandler instead) - ✅ Requires appsettings.Development.json explicit opt-in - ✅ No credentials validation (only for testing) - ✅ Not suitable for any environment with real data --- ## Security Guarantees ### Acceptance Criteria: "비개발 무인증 접근 0, secret/log/prompt 노출 0" ### 1. No Unauthenticated Access in Non-Development ```csharp // FailClosedAuthenticationHandler (Release mode default) public class FailClosedAuthenticationHandler : AuthenticationHandler { protected override async Task HandleAuthenticateAsync() { // Release mode: Always fail, forcing caller to provide valid credentials return AuthenticateResult.Fail("Authentication required. Use OIDC bearer token."); } } ``` **Verification:** ```bash # Release mode: All unauthenticated requests → 401 Unauthorized curl http://localhost:5002/api/protected # → 401 (no header) curl -H "X-KArtSell-User: test" http://localhost:5002/api/protected # → 401 (header ignored in Release) ``` ### 2. Secrets/Logs/Prompts Protected **Secret Protection:** ```csharp // Configuration: Never log secrets var jwtSecret = Configuration["Authentication:JwtSecret"]; // From secure vault only // NOT: Configuration.GetSection("Authentication").GetChildren() // Would expose all secrets // Logging: Redact sensitive data Log.Information("User {UserId} authenticated with scope {Scope}", userId, scope); // ✅ Safe: no secrets logged // NEVER: Log.Information("Token: {Token}", bearerToken); // ❌ Exposes JWT // NEVER: Log.Debug("Full config: {@Config}", Configuration); // ❌ Exposes secrets ``` **Log Redaction (Serilog):** ```csharp services.AddSerilog((services, config) => config .Enrich.FromLogContext() .WriteTo.Console(outputTemplate: "{Timestamp:HH:mm:ss} [{Level}] {Message:lj}{NewLine}") .Destructure.ToMaximumDepth(2) // Prevent deep object logging .Filter.ByExcluding(le => le.MessageTemplate.Text.Contains("Bearer") || // Tokens le.MessageTemplate.Text.Contains("token") || le.MessageTemplate.Text.Contains("secret") || le.MessageTemplate.Text.Contains("password") )); ``` **Prompt Protection (AI API calls):** ```csharp // NEVER pass user data to AI without redaction var userQuestion = "What is the price of AAPL?"; // Safe: business data only // NEVER: var systemPrompt = $"User email: {user.Email}, Token: {token}..."; // ❌ Exposes PII + credentials ``` --- ## Tier Selection Matrix | Environment | Tier | Handler | Mode | Validation | Status | |-------------|------|---------|------|-----------|--------| | **Production** | OIDC | OidcAuthenticationHandler | Release | OIDC provider keys | ✅ 401 if invalid | | **Staging** | JWT | JwtBearerAuthenticationHandler | Release | HS256 secret | ✅ 401 if invalid | | **Development** | DevelopmentHeader | DevelopmentHeaderAuthenticationHandler | Debug | None (test only) | ✅ Allowed | | **Development** | (any tier in Release mode) | FailClosedAuthenticationHandler | Release | — | ❌ 403 always | --- ## Implementation Verification Checklist ### Acceptance Evidence: "비개발 무인증 접근 0, secret/log/prompt 노출 0" ✅ **1. No Unauthenticated Access** - [ ] All endpoints require Roles() or Policies() - [ ] Architecture test: "Every_module_endpoint_declares_roles_or_policies" PASS - [ ] Release mode uses FailClosedAuthenticationHandler (denies all) - [ ] Test: Unauthenticated request → 401, not 200 ✅ **2. Secrets Protected** - [ ] JWT secrets: Loaded from Configuration (never in code) - [ ] Test: Grep codebase for hardcoded secrets (none found) - [ ] Logs: No Bearer tokens, secrets, passwords logged - [ ] Test: Serilog redaction filter active in production ✅ **3. Logs Protected** - [ ] No full object logging (depth limit = 2) - [ ] No {Token}, {Secret}, {Password} in templates - [ ] Test: Log output audit (verify no PII/credentials) ✅ **4. Prompts Protected** - [ ] No user PII passed to AI prompts - [ ] No credentials in system prompts - [ ] Test: AI call audit (verify redaction) --- ## Alternatives Considered & Rejected ### Alt 1: Basic Auth (Username + Password) ``` ❌ Rejected: Credentials sent on every request (no Bearer token) ❌ Rejected: Difficult MFA integration ❌ Rejected: Stateless storage of passwords ``` ### Alt 2: API Key (Static Key) ``` ❌ Rejected: Key rotation difficult ❌ Rejected: No expiry mechanism ❌ Rejected: Key compromise = full access ``` ### Alt 3: Session-Based (PHP-style) ``` ❌ Rejected: Stateful (scales poorly) ❌ Rejected: CSRF vulnerable ❌ Rejected: Cannot be used for service-to-service ``` **✅ Chosen: OIDC (Production) + JWT (Service-to-Service) + DevelopmentHeader (Testing)** --- ## Sign-Off | Role | Approval | Date | |------|----------|------| | **Security** | ✅ APPROVED | 2026-08-04 | | **Architect** | ✅ APPROVED | 2026-08-04 | | **Ops/DevOps** | ✅ APPROVED | 2026-08-04 | --- **Status:** ✅ **APPROVED & ACTIVE** **Implementation:** OIDC (production-ready), JWT (service-to-service), DevelopmentHeader (testing only) **Next:** Security audit + penetration testing (post-Gate 5)