# 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<OidcOptions>
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // 1. Extract token from Authorization: Bearer <token>
        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<JwtBearerOptions>
{
    protected override async Task<AuthenticateResult> 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<AuthenticationSchemeOptions>
{
    protected override async Task<AuthenticateResult> 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<AuthenticationSchemeOptions>
{
    protected override async Task<AuthenticateResult> 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)
