♻️ Refactor OpenID: Phase 2: Security Hardening - PKCE Implementation

- Added GenerateCodeVerifier() and GenerateCodeChallenge() methods to base OidcService
- Implemented PKCE (Proof Key for Code Exchange) for Google OAuth flow:
  * Generate cryptographically secure code verifier (256-bit random)
  * Create SHA-256 code challenge for authorization request
  * Cache code verifier with 15-minute expiration for token exchange
  * Validate and remove code verifier during callback to prevent replay attacks
- Enhances security by protecting against authorization code interception attacks
- Uses S256 (SHA-256) code challenge method as per RFC 7636
This commit is contained in:
2025-11-02 14:55:15 +08:00
parent 4bd59f107b
commit 74a9ca98ad
2 changed files with 60 additions and 4 deletions

View File

@@ -29,6 +29,10 @@ public class GoogleOidcService(
throw new InvalidOperationException("Authorization endpoint not found in discovery document"); throw new InvalidOperationException("Authorization endpoint not found in discovery document");
} }
// Generate PKCE code verifier and challenge for enhanced security
var codeVerifier = GenerateCodeVerifier();
var codeChallenge = GenerateCodeChallenge(codeVerifier);
var queryParams = BuildAuthorizationParameters( var queryParams = BuildAuthorizationParameters(
config.ClientId, config.ClientId,
config.RedirectUri, config.RedirectUri,
@@ -38,19 +42,36 @@ public class GoogleOidcService(
nonce nonce
); );
// Add PKCE parameters
queryParams["code_challenge"] = codeChallenge;
queryParams["code_challenge_method"] = "S256";
// Store code verifier in cache for later token exchange
var codeVerifierKey = $"pkce:{state}";
cache.SetAsync(codeVerifierKey, codeVerifier, TimeSpan.FromMinutes(15)).GetAwaiter().GetResult();
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}"; return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
} }
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{ {
// No need to split or parse code verifier from state
var state = callbackData.State ?? ""; var state = callbackData.State ?? "";
callbackData.State = state; // Keep the original state if needed callbackData.State = state; // Keep the original state if needed
// Exchange the code for tokens // Retrieve PKCE code verifier from cache
// Pass null or omit the parameter for codeVerifier as PKCE is removed var codeVerifierKey = $"pkce:{state}";
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, null); var (found, codeVerifier) = await cache.GetAsyncWithStatus<string>(codeVerifierKey);
if (!found || string.IsNullOrEmpty(codeVerifier))
{
throw new InvalidOperationException("PKCE code verifier not found or expired");
}
// Remove the code verifier from cache to prevent replay attacks
await cache.RemoveAsync(codeVerifierKey);
// Exchange the code for tokens using PKCE
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, codeVerifier);
if (tokenResponse?.IdToken == null) if (tokenResponse?.IdToken == null)
{ {
throw new InvalidOperationException("Failed to obtain ID token from Google"); throw new InvalidOperationException("Failed to obtain ID token from Google");

View File

@@ -1,4 +1,7 @@
using System;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
@@ -84,6 +87,38 @@ public abstract class OidcService(
}; };
} }
/// <summary>
/// Generates a cryptographically secure random code verifier for PKCE
/// </summary>
protected static string GenerateCodeVerifier()
{
// Generate a 32-byte (256-bit) random byte array
var randomBytes = new byte[32];
RandomNumberGenerator.Fill(randomBytes);
// Convert to URL-safe base64 (no padding)
return Convert.ToBase64String(randomBytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
/// <summary>
/// Generates the code challenge from a code verifier using S256 method
/// </summary>
protected static string GenerateCodeChallenge(string codeVerifier)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(codeVerifier);
var hash = sha256.ComputeHash(bytes);
// Convert to URL-safe base64 (no padding)
return Convert.ToBase64String(hash)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
/// <summary> /// <summary>
/// Retrieves the OpenID Connect discovery document /// Retrieves the OpenID Connect discovery document
/// </summary> /// </summary>