|
83 | 83 | }); |
84 | 84 | }); |
85 | 85 |
|
86 | | - // Add Authentication with JWT Bearer (RSA-SHA256 via OIDC Discovery) |
87 | | - builder.Services.AddAuthentication(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme) |
88 | | - .AddJwtBearer(options => |
| 86 | + // Add Authentication with DUAL JWT Bearer support: |
| 87 | + // 1. SSO Broker tokens (RSA-SHA256 via OIDC Discovery) |
| 88 | + // 2. Local Login tokens (HMAC-SHA256 symmetric) |
| 89 | + builder.Services.AddAuthentication(options => |
| 90 | + { |
| 91 | + options.DefaultAuthenticateScheme = "MultiScheme"; |
| 92 | + options.DefaultChallengeScheme = "MultiScheme"; |
| 93 | + }) |
| 94 | + .AddJwtBearer("SsoBearer", options => |
89 | 95 | { |
90 | 96 | var authConfig = builder.Configuration.GetSection("Auth"); |
91 | 97 |
|
92 | | - // Disable claim type mapping to keep claims as-is (e.g. "sub" -> "sub") |
| 98 | + // Disable claim type mapping to keep claims as-is |
93 | 99 | options.MapInboundClaims = false; |
94 | 100 |
|
95 | 101 | // Point to SSO Broker for OIDC Discovery |
96 | 102 | options.Authority = authConfig["Authority"]; |
97 | 103 | options.Audience = authConfig["Audience"]; |
98 | 104 |
|
99 | | - // Enable HTTPS metadata discovery (set to false ONLY for localhost dev with self-signed certs) |
| 105 | + // Enable HTTPS metadata discovery (set to false ONLY for localhost dev) |
100 | 106 | options.RequireHttpsMetadata = false; // TODO: Set to true in production |
101 | 107 |
|
102 | 108 | // Token validation parameters |
103 | 109 | options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters |
104 | 110 | { |
105 | 111 | ValidateIssuer = true, |
106 | 112 | ValidIssuer = authConfig["Authority"], |
107 | | - |
108 | 113 | ValidateAudience = true, |
109 | 114 | ValidAudience = authConfig["Audience"], |
110 | | - |
111 | 115 | ValidateLifetime = true, |
112 | 116 | ClockSkew = TimeSpan.FromMinutes(5), |
113 | 117 | }; |
114 | 118 |
|
115 | | - // DEV ONLY: DIAGNOSTIC - Hardcode the key to bypass Discovery issues |
| 119 | + // DEV ONLY: Hardcode RSA key to bypass Discovery issues |
116 | 120 | if (builder.Environment.IsDevelopment()) |
117 | 121 | { |
118 | 122 | try |
119 | 123 | { |
120 | 124 | var rsa = System.Security.Cryptography.RSA.Create(); |
121 | 125 | rsa.ImportParameters(new System.Security.Cryptography.RSAParameters |
122 | 126 | { |
123 | | - // Values from https://localhost:7012/.well-known/jwks.json |
124 | 127 | Modulus = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes("rNH-ckvzkKRcqAKmb8CDdABZ-4_fUgI-vjSRoDfz-kCDtFdxTD69XvqUGP4NRyPXiSwI3ODh1_iBv-eg1RCBB8iA8eNLHuD5VbeMq4J5_ktCUjAUBQ783cs9R_7RKyLRrlW-Cq0EiZ-Z0I5vyWE9yzCN7Mf1MU2cn4GnAxMsJFlMwNEstbupqZWIgZXqLxrHcXcUpS-zpPkJULI4tDsUTjXMih8hU2ikrb_EltNYi0tcIBV6TfoBEc3OGiz8ao4mZ8UiKLBMwUi00qvQRtGl3xm0idh3sF2sGunIkTlRFtsBzjNpTqcAotyRXTNuQOTExX_dRL8C74eHUwd2J9quQQ"), |
125 | 128 | Exponent = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes("AQAB") |
126 | 129 | }); |
127 | 130 |
|
128 | 131 | var key = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "rsa-2025-11-27" }; |
129 | 132 |
|
130 | | - // FORCE THE KEY and DISABLE DISCOVERY |
131 | 133 | options.TokenValidationParameters.IssuerSigningKey = key; |
132 | 134 | options.Configuration = new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration |
133 | 135 | { |
134 | 136 | Issuer = authConfig["Authority"], |
135 | 137 | }; |
136 | 138 | options.Configuration.SigningKeys.Add(key); |
137 | 139 |
|
138 | | - Log.Warning("DEV MODE: Using Hardcoded RSA Key for Token Validation to bypass OIDC Discovery."); |
| 140 | + Log.Warning("DEV MODE: SsoBearer using Hardcoded RSA Key."); |
139 | 141 | } |
140 | 142 | catch (Exception ex) |
141 | 143 | { |
142 | | - Log.Error(ex, "Failed to configure hardcoded key."); |
| 144 | + Log.Error(ex, "Failed to configure SSO hardcoded key."); |
143 | 145 | } |
144 | 146 | } |
145 | | - else |
146 | | - { |
147 | | - // Production or other envs: Use standard discovery |
148 | | - // (The previous explicit config manager code was removed for this test, |
149 | | - // but in prod we would just rely on default behavior) |
150 | | - } |
151 | 147 |
|
152 | | - // JIT User Provisioning: Sync user from SSO on every token validation |
| 148 | + // JIT User Provisioning for SSO tokens |
153 | 149 | options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents |
154 | 150 | { |
155 | 151 | OnTokenValidated = async context => |
156 | 152 | { |
157 | | - // DEBUG: Log all claims to see what we are actually getting |
158 | 153 | var claims = context.Principal?.Claims.Select(c => $"{c.Type}: {c.Value}").ToList(); |
159 | 154 | var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>(); |
160 | 155 | logger.LogInformation("SSO Token Validated. Claims: {Claims}", string.Join(", ", claims ?? new List<string>())); |
161 | 156 |
|
162 | | - // Extract claims from the validated token |
163 | | - // Try standard short names first, then fallback to SOAP/XML names if needed |
164 | 157 | var subject = context.Principal?.FindFirst("sub")?.Value |
165 | 158 | ?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; |
166 | | - |
167 | 159 | var email = context.Principal?.FindFirst("email")?.Value |
168 | 160 | ?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; |
169 | | - |
170 | 161 | var name = context.Principal?.FindFirst("name")?.Value |
171 | | - ?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value |
172 | | - ?? email; |
173 | | - |
| 162 | + ?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? email; |
174 | 163 | var issuer = context.Principal?.FindFirst("iss")?.Value; |
175 | 164 |
|
176 | 165 | if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(email)) |
177 | 166 | { |
178 | | - logger.LogError("Token is missing required claims. Sub: {Sub}, Email: {Email}", subject, email); |
| 167 | + logger.LogError("SSO Token missing required claims. Sub: {Sub}, Email: {Email}", subject, email); |
179 | 168 | context.Fail("Token is missing required claims (sub, email)"); |
180 | 169 | return; |
181 | 170 | } |
182 | 171 |
|
183 | | - // Determine provider from issuer (e.g., "https://localhost:7012" -> "sso_broker") |
184 | 172 | var provider = issuer?.Contains("localhost") == true ? "sso_broker" : "azuread"; |
185 | 173 |
|
186 | 174 | try |
187 | 175 | { |
188 | | - // Resolve Identity Sync Service and sync the user |
189 | 176 | var syncService = context.HttpContext.RequestServices |
190 | 177 | .GetRequiredService<Rgt.Space.Core.Abstractions.Identity.IIdentitySyncService>(); |
191 | | - |
192 | | - // Sync user and get Local ID |
193 | 178 | var localUserId = await syncService.SyncOrGetUserAsync(provider, subject, email, name!, context.HttpContext.RequestAborted); |
194 | 179 |
|
195 | | - // Attach Local ID to Principal |
196 | 180 | var claimsIdentity = context.Principal?.Identity as System.Security.Claims.ClaimsIdentity; |
197 | 181 | claimsIdentity?.AddClaim(new System.Security.Claims.Claim("x-local-user-id", localUserId.ToString())); |
198 | 182 | } |
199 | 183 | catch (Exception ex) |
200 | 184 | { |
201 | | - // Log the error but don't fail authentication (user might already exist) |
202 | 185 | logger.LogError(ex, "Failed to sync user from SSO. Subject: {Subject}, Email: {Email}", subject, email); |
203 | | - |
204 | | - // Don't fail auth - user might already be provisioned |
205 | | - // context.Fail() would reject valid tokens which is worse than missing a sync |
206 | 186 | } |
207 | 187 | }, |
| 188 | + OnAuthenticationFailed = context => |
| 189 | + { |
| 190 | + var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>(); |
| 191 | + logger.LogDebug("SSO Authentication failed: {Exception}", context.Exception.Message); |
| 192 | + return Task.CompletedTask; |
| 193 | + } |
| 194 | + }; |
| 195 | + }) |
| 196 | + .AddJwtBearer("LocalBearer", options => |
| 197 | + { |
| 198 | + var localAuthConfig = builder.Configuration.GetSection("LocalAuth"); |
| 199 | + |
| 200 | + // Disable claim type mapping |
| 201 | + options.MapInboundClaims = false; |
208 | 202 |
|
| 203 | + // Local token validation parameters (HMAC-SHA256) |
| 204 | + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters |
| 205 | + { |
| 206 | + ValidateIssuer = true, |
| 207 | + ValidIssuer = localAuthConfig["Issuer"] ?? "rgt-space-portal", |
| 208 | + |
| 209 | + ValidateAudience = true, |
| 210 | + ValidAudience = localAuthConfig["Audience"] ?? "rgt-space-portal-api", |
| 211 | + |
| 212 | + ValidateLifetime = true, |
| 213 | + ClockSkew = TimeSpan.FromMinutes(5), |
| 214 | + |
| 215 | + ValidateIssuerSigningKey = true, |
| 216 | + IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey( |
| 217 | + System.Text.Encoding.UTF8.GetBytes( |
| 218 | + localAuthConfig["SigningKey"] ?? "YourSuperSecretLocalSigningKey_ChangeThisInProduction_MustBe32CharactersLong!")), |
| 219 | + }; |
| 220 | + |
| 221 | + options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents |
| 222 | + { |
| 223 | + OnTokenValidated = context => |
| 224 | + { |
| 225 | + var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>(); |
| 226 | + var claims = context.Principal?.Claims.Select(c => $"{c.Type}: {c.Value}").ToList(); |
| 227 | + logger.LogInformation("Local Token Validated. Claims: {Claims}", string.Join(", ", claims ?? new List<string>())); |
| 228 | + |
| 229 | + // For local tokens, the "sub" claim IS the local user ID |
| 230 | + var userId = context.Principal?.FindFirst("sub")?.Value; |
| 231 | + if (!string.IsNullOrEmpty(userId)) |
| 232 | + { |
| 233 | + var claimsIdentity = context.Principal?.Identity as System.Security.Claims.ClaimsIdentity; |
| 234 | + claimsIdentity?.AddClaim(new System.Security.Claims.Claim("x-local-user-id", userId)); |
| 235 | + } |
| 236 | + |
| 237 | + return Task.CompletedTask; |
| 238 | + }, |
209 | 239 | OnAuthenticationFailed = context => |
210 | 240 | { |
211 | | - var logger = context.HttpContext.RequestServices |
212 | | - .GetRequiredService<ILogger<Program>>(); |
213 | | - logger.LogWarning("JWT Authentication failed: {Exception}", context.Exception.Message); |
| 241 | + var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>(); |
| 242 | + logger.LogDebug("Local Authentication failed: {Exception}", context.Exception.Message); |
214 | 243 | return Task.CompletedTask; |
215 | 244 | } |
216 | 245 | }; |
| 246 | + }) |
| 247 | + .AddPolicyScheme("MultiScheme", "SSO or Local", options => |
| 248 | + { |
| 249 | + // Smart scheme selection: Try SSO first, then Local |
| 250 | + options.ForwardDefaultSelector = context => |
| 251 | + { |
| 252 | + // Check if there's a Bearer token |
| 253 | + var authHeader = context.Request.Headers["Authorization"].FirstOrDefault(); |
| 254 | + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) |
| 255 | + { |
| 256 | + return "LocalBearer"; // Default to local if no token |
| 257 | + } |
| 258 | + |
| 259 | + // Extract the token |
| 260 | + var token = authHeader.Substring("Bearer ".Length).Trim(); |
| 261 | + |
| 262 | + try |
| 263 | + { |
| 264 | + // Peek at the token header to determine type |
| 265 | + var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); |
| 266 | + if (handler.CanReadToken(token)) |
| 267 | + { |
| 268 | + var jwt = handler.ReadJwtToken(token); |
| 269 | + |
| 270 | + // If token has "kid" header, it's from SSO (RSA) |
| 271 | + // If issuer is "rgt-space-portal", it's local |
| 272 | + var issuer = jwt.Issuer; |
| 273 | + if (issuer == "rgt-space-portal") |
| 274 | + { |
| 275 | + return "LocalBearer"; |
| 276 | + } |
| 277 | + } |
| 278 | + } |
| 279 | + catch |
| 280 | + { |
| 281 | + // If we can't parse it, try SSO first |
| 282 | + } |
| 283 | + |
| 284 | + return "SsoBearer"; |
| 285 | + }; |
217 | 286 | }); |
218 | 287 |
|
| 288 | + |
219 | 289 | builder.Services.AddAuthorization(); |
220 | 290 |
|
221 | 291 | // Add API versioning |
|
0 commit comments