Skip to content

Commit edab5ca

Browse files
committed
fix: address CodeQL and code-quality scan findings
1 parent 93ae23c commit edab5ca

File tree

5 files changed

+65
-28
lines changed

5 files changed

+65
-28
lines changed

src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,16 @@ public async Task<IActionResult> GetConfig(
5050
[FromQuery] bool stepUp = false,
5151
CancellationToken cancellationToken = default)
5252
{
53+
// Validate and normalize the route parameter against the server-side allowlist.
54+
// After this point, stateCode is a known-good value — not raw user input.
5355
if (!stateAllowlist.Contains(code))
5456
{
5557
logger.LogWarning(
5658
"OIDC GetConfig rejected: stateCode {StateCode} not in allowlist (reason=unknown_state)",
5759
SanitizeForLog(code));
5860
return BadRequest(new ErrorResponse("Unknown or unsupported stateCode."));
5961
}
62+
var stateCode = code.ToLowerInvariant();
6063

6164
var authorizationEndpoint = stepUp
6265
? config["Oidc:StepUp:AuthorizationEndpoint"]
@@ -69,7 +72,7 @@ public async Task<IActionResult> GetConfig(
6972
{
7073
logger.LogWarning(
7174
"OIDC config missing for stateCode {StateCode} (reason=oidc_not_configured)",
72-
SanitizeForLog(code));
75+
stateCode);
7376
var hint = environment.IsDevelopment()
7477
? "Set Oidc:AuthorizationEndpoint, Oidc:ClientId, and Oidc:CallbackRedirectUri in appsettings."
7578
: "";
@@ -83,7 +86,7 @@ public async Task<IActionResult> GetConfig(
8386

8487
// Create the pre-auth session and set the cookie.
8588
var session = await sessionStore.CreateAsync(
86-
code.ToLowerInvariant(), state, codeVerifier, redirectUri, stepUp, cancellationToken);
89+
stateCode, state, codeVerifier, redirectUri, stepUp, cancellationToken);
8790
OidcSessionCookie.Set(Response, session.Id);
8891

8992
var languageParam = config["Oidc:LanguageParam"] ?? "en";
@@ -118,11 +121,15 @@ public async Task<IActionResult> Callback(
118121
if (body == null || string.IsNullOrEmpty(body.Code) || string.IsNullOrEmpty(body.StateCode))
119122
return BadRequest(new ErrorResponse("Missing code or stateCode."));
120123

121-
if (!stateAllowlist.Contains(body.StateCode))
124+
// Bind to locals after null checks so downstream code uses validated references.
125+
var requestedCode = body.Code;
126+
var requestedStateCode = body.StateCode;
127+
128+
if (!stateAllowlist.Contains(requestedStateCode))
122129
{
123130
logger.LogWarning(
124131
"OIDC Callback rejected: stateCode {StateCode} not in allowlist (reason=unknown_state)",
125-
SanitizeForLog(body.StateCode));
132+
SanitizeForLog(requestedStateCode));
126133
return BadRequest(new ErrorResponse("Unknown or unsupported stateCode."));
127134
}
128135

@@ -150,7 +157,7 @@ public async Task<IActionResult> Callback(
150157
}
151158

152159
// --- Validate stateCode matches stored value (prevents tenant switching) ---
153-
if (!string.Equals(body.StateCode, session.StateCode, StringComparison.OrdinalIgnoreCase))
160+
if (!string.Equals(requestedStateCode, session.StateCode, StringComparison.OrdinalIgnoreCase))
154161
{
155162
logger.LogWarning(
156163
"OIDC Callback rejected: stateCode mismatch (reason=mismatched_stateCode, SessionId={SessionId})", sessionId);
@@ -168,7 +175,7 @@ public async Task<IActionResult> Callback(
168175

169176
// --- Exchange code using the stored code_verifier (never from the body) ---
170177
var result = await exchangeService.ExchangeCodeAsync(
171-
body.Code,
178+
requestedCode,
172179
session.CodeVerifier,
173180
session.RedirectUri,
174181
session.IsStepUp,
@@ -213,11 +220,15 @@ public async Task<IActionResult> CompleteLogin(
213220
if (body == null || string.IsNullOrEmpty(body.StateCode) || string.IsNullOrEmpty(body.CallbackToken))
214221
return BadRequest(new ErrorResponse("Missing stateCode or callbackToken."));
215222

216-
if (!stateAllowlist.Contains(body.StateCode))
223+
// Validate and bind to local variables so downstream code uses validated values.
224+
var requestedStateCode = body.StateCode;
225+
var callbackToken = body.CallbackToken;
226+
227+
if (!stateAllowlist.Contains(requestedStateCode))
217228
{
218229
logger.LogWarning(
219230
"OIDC CompleteLogin rejected: stateCode {StateCode} not in allowlist (reason=unknown_state)",
220-
SanitizeForLog(body.StateCode));
231+
SanitizeForLog(requestedStateCode));
221232
return BadRequest(new ErrorResponse("Unknown or unsupported stateCode."));
222233
}
223234

@@ -230,7 +241,7 @@ public async Task<IActionResult> CompleteLogin(
230241
}
231242

232243
// --- Verify the callback token matches this session and hasn't been consumed ---
233-
var tokenHash = IPreAuthSessionStore.HashCallbackToken(body.CallbackToken);
244+
var tokenHash = IPreAuthSessionStore.HashCallbackToken(callbackToken);
234245
var advanced = await sessionStore.TryAdvanceToLoginCompletedAsync(sessionId, tokenHash, cancellationToken);
235246
if (!advanced)
236247
{
@@ -244,7 +255,7 @@ public async Task<IActionResult> CompleteLogin(
244255
OidcSessionCookie.Clear(Response);
245256
await sessionStore.RemoveAsync(sessionId, cancellationToken);
246257

247-
var stateKey = body.StateCode.ToLowerInvariant();
258+
var stateKey = requestedStateCode.ToLowerInvariant();
248259
var signingKey = config["Oidc:CompleteLoginSigningKey"];
249260
if (string.IsNullOrEmpty(signingKey))
250261
{
@@ -274,12 +285,12 @@ public async Task<IActionResult> CompleteLogin(
274285
ClaimsPrincipal principal;
275286
try
276287
{
277-
principal = handler.ValidateToken(body.CallbackToken, validationParams, out _);
288+
principal = handler.ValidateToken(callbackToken, validationParams, out _);
278289
}
279290
catch (Exception ex)
280291
{
281292
logger.LogWarning(ex, "Invalid or expired callback token for state {StateCode}",
282-
SanitizeForLog(body.StateCode));
293+
SanitizeForLog(requestedStateCode));
283294
return BadRequest(new ErrorResponse("Invalid or expired callback token."));
284295
}
285296

src/SEBT.Portal.Api/Services/OidcExchangeService.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ public async Task<OidcExchangeResult> ExchangeCodeAsync(
163163

164164
// --- Exchange authorization code for tokens ---
165165
using var client = _httpFactory.CreateClient();
166-
var tokenParams = new FormUrlEncodedContent(new Dictionary<string, string>
166+
using var tokenParams = new FormUrlEncodedContent(new Dictionary<string, string>
167167
{
168168
["grant_type"] = "authorization_code",
169169
["code"] = code,
@@ -332,7 +332,15 @@ void TrySet(string jsonKey, string claimName)
332332

333333
TrySet("sub", "sub");
334334
TrySet("email", "email");
335-
TrySet("preferred_username", "email"); // fallback for IdPs that put email here
335+
// Some IdPs put email in preferred_username — only use it as email if it looks like one.
336+
if (!claims.ContainsKey("email")
337+
&& doc.RootElement.TryGetProperty("preferred_username", out var pu)
338+
&& pu.ValueKind == JsonValueKind.String
339+
&& !string.IsNullOrEmpty(pu.GetString())
340+
&& pu.GetString()!.Contains('@'))
341+
{
342+
claims.TryAdd("email", pu.GetString()!);
343+
}
336344
TrySet("phone", "phone");
337345
TrySet("phone_number", "phone_number");
338346
TrySet("given_name", "givenName");

src/SEBT.Portal.Api/Services/PreAuthSessionStore.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ public async Task<PreAuthSession> CreateAsync(
123123
KnownSessionIds.TryAdd(sessionId, 0);
124124
_logger.LogInformation(
125125
"Pre-auth session created: SessionId={SessionId}, StateCode={StateCode} (reason=session_created)",
126-
sessionId, stateCode);
126+
sessionId, SanitizeForLog(stateCode));
127127
return session;
128128
}
129129

@@ -141,6 +141,14 @@ public async Task<PreAuthSession> CreateAsync(
141141
_ => ValueTask.FromResult<PreAuthSession?>(null),
142142
CacheOptions,
143143
cancellationToken: cancellationToken);
144+
145+
// Session expired in cache — clean up tracking dictionaries so they don't grow unbounded.
146+
if (result == null)
147+
{
148+
KnownSessionIds.TryRemove(sessionId, out _);
149+
SessionLocks.TryRemove(sessionId, out _);
150+
}
151+
144152
return result;
145153
}
146154

@@ -231,6 +239,16 @@ public async Task RemoveAsync(string sessionId, CancellationToken cancellationTo
231239

232240
private static string CacheKey(string sessionId) => $"{CacheKeyPrefix}{sessionId}";
233241

242+
private static string SanitizeForLog(string? value)
243+
{
244+
if (string.IsNullOrEmpty(value)) return string.Empty;
245+
return value
246+
.Replace("\r", string.Empty, StringComparison.Ordinal)
247+
.Replace("\n", string.Empty, StringComparison.Ordinal)
248+
.Replace("\u2028", string.Empty, StringComparison.Ordinal)
249+
.Replace("\u2029", string.Empty, StringComparison.Ordinal);
250+
}
251+
234252
private static string GenerateSessionId()
235253
{
236254
Span<byte> bytes = stackalloc byte[32];

test/SEBT.Portal.Tests/Integration/AuthCookieAuthenticationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public async Task GetStatus_WithExpiredSessionCookie_ReturnsUnauthorized()
7070
private async Task<HttpResponseMessage> GetStatusWithCookie(string? token)
7171
{
7272
var client = _factory.CreateClient();
73-
var request = new HttpRequestMessage(HttpMethod.Get, "/api/auth/status");
73+
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/auth/status");
7474
if (token != null)
7575
request.Headers.Add("Cookie", $"{AuthCookies.AuthCookieName}={token}");
7676
return await client.SendAsync(request);

test/SEBT.Portal.Tests/Integration/OidcPreAuthSecurityTests.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public async Task V01_T03_CompleteLogin_WithoutSessionCookie_Returns403()
4343
{
4444
var client = _factory.CreateClient();
4545
var callbackToken = MintCallbackToken("user@example.com");
46-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
46+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
4747
{
4848
Content = JsonContent.Create(new { stateCode = "co", callbackToken }),
4949
Headers = { { "Origin", "http://localhost:3000" } }
@@ -60,7 +60,7 @@ public async Task V01_T03_CompleteLogin_WithSpoofedOrigin_Returns403()
6060
{
6161
var client = _factory.CreateClient();
6262
var callbackToken = MintCallbackToken("user@example.com");
63-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
63+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
6464
{
6565
Content = JsonContent.Create(new { stateCode = "co", callbackToken }),
6666
Headers = { { "Origin", "https://attacker.example.com" } }
@@ -77,7 +77,7 @@ public async Task V01_T03_CompleteLogin_WithMissingOrigin_Returns403()
7777
{
7878
var client = _factory.CreateClient();
7979
var callbackToken = MintCallbackToken("user@example.com");
80-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
80+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
8181
{
8282
Content = JsonContent.Create(new { stateCode = "co", callbackToken })
8383
// No Origin header at all — curl from command line
@@ -119,7 +119,7 @@ public async Task V02_T02_CompleteLogin_SecondCallWithConsumedSession_Returns403
119119
await sessionStore.TryAdvanceToLoginCompletedAsync(session.Id, tokenHash);
120120

121121
// Replay attempt: exact same token + cookie → must fail
122-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
122+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
123123
{
124124
Content = JsonContent.Create(new { stateCode = "co", callbackToken }),
125125
Headers =
@@ -146,7 +146,7 @@ public async Task V03_T04_Callback_WithTamperedState_Returns400()
146146
var session = await sessionStore.CreateAsync("co", "real-state-value", "verifier1", "http://localhost:3000/callback", false);
147147

148148
var client = _factory.CreateClient();
149-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
149+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
150150
{
151151
Content = JsonContent.Create(new
152152
{
@@ -187,7 +187,7 @@ public async Task V05_T07a_CompleteLogin_WithInvalidStateCode_Returns400(string
187187
{
188188
var client = _factory.CreateClient();
189189
var callbackToken = MintCallbackToken("user@example.com");
190-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
190+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/complete-login")
191191
{
192192
Content = JsonContent.Create(new { stateCode, callbackToken }),
193193
Headers = { { "Origin", "http://localhost:3000" } }
@@ -204,7 +204,7 @@ public async Task V05_T07a_CompleteLogin_WithInvalidStateCode_Returns400(string
204204
public async Task V05_T07a_Callback_WithInvalidStateCode_Returns400(string stateCode)
205205
{
206206
var client = _factory.CreateClient();
207-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
207+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
208208
{
209209
Content = JsonContent.Create(new { code = "code", state = "state", stateCode }),
210210
Headers = { { "Origin", "http://localhost:3000" } }
@@ -225,7 +225,7 @@ public async Task V05_T07a_Callback_WithInvalidStateCode_Returns400(string state
225225
public async Task V06_T08aA_Callback_WithoutSessionCookie_Returns403()
226226
{
227227
var client = _factory.CreateClient();
228-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
228+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
229229
{
230230
Content = JsonContent.Create(new
231231
{
@@ -246,7 +246,7 @@ public async Task V06_T08aA_Callback_WithoutSessionCookie_Returns403()
246246
public async Task V06_T08aA_Callback_WithSpoofedOrigin_Returns403()
247247
{
248248
var client = _factory.CreateClient();
249-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
249+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
250250
{
251251
Content = JsonContent.Create(new
252252
{
@@ -274,7 +274,7 @@ public async Task V06_T08aB_Callback_WithValidSessionButWrongState_Returns400()
274274
var session = await sessionStore.CreateAsync("co", "correct-state", "verifier", "http://localhost:3000/callback", false);
275275

276276
var client = _factory.CreateClient();
277-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
277+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
278278
{
279279
Content = JsonContent.Create(new
280280
{
@@ -308,7 +308,7 @@ public async Task V06_T08aE_Callback_WithValidSessionButWrongStateCode_Returns40
308308
var client = _factory.CreateClient();
309309
// Session was created for "co" but we send "az" (stateCode mismatch)
310310
// Note: "az" isn't in the allowlist either, so this gets caught at both layers
311-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
311+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
312312
{
313313
Content = JsonContent.Create(new
314314
{
@@ -344,7 +344,7 @@ public async Task SessionReplay_CallbackOnAlreadyUsedSession_Returns400()
344344

345345
var client = _factory.CreateClient();
346346
// Try to use the same session for another callback — should fail
347-
var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
347+
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/oidc/callback")
348348
{
349349
Content = JsonContent.Create(new
350350
{

0 commit comments

Comments
 (0)