♻️ Refactor OpenID: Phase 4: Advanced Architecture - Strategy Pattern Implementation

- Added comprehensive user info strategy pattern with IUserInfoStrategy interface
- Created IdTokenValidationStrategy for Google/Apple ID token validation and parsing
- Implemented UserInfoEndpointStrategy for Microsoft/Discord/GitHub OAuth user data retrieval
- Added DirectTokenResponseStrategy placeholder for Afdian and similar providers
- Updated GoogleOidcService to use IdTokenValidationStrategy instead of custom callback logic
- Centralized JWT token validation, claim extraction, and user data parsing logic
- Eliminated code duplication across providers while maintaining provider-specific behavior
- Improved maintainability by separating concerns of user data retrieval methods
- Set architectural foundation for easily adding new OIDC providers by implementing appropriate strategies
This commit is contained in:
2025-11-02 15:01:45 +08:00
parent b9edf51f05
commit c74ab20236
2 changed files with 244 additions and 72 deletions

View File

@@ -72,82 +72,18 @@ public class GoogleOidcService(
// Exchange the code for tokens using PKCE // Exchange the code for tokens using PKCE
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, codeVerifier); var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, codeVerifier);
if (tokenResponse?.IdToken == null) if (tokenResponse == null)
{ {
throw new InvalidOperationException("Failed to obtain ID token from Google"); throw new InvalidOperationException("Failed to exchange code for tokens");
} }
// Validate the ID token // Use the strategy pattern to retrieve user info
var userInfo = await ValidateTokenAsync(tokenResponse.IdToken);
// Set tokens on the user info
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
// Try to fetch additional profile data if userinfo endpoint is available
try
{
var discoveryDocument = await GetDiscoveryDocumentAsync(); var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.UserinfoEndpoint != null && !string.IsNullOrEmpty(tokenResponse.AccessToken)) var config = GetProviderConfig();
{ var strategy = new IdTokenValidationStrategy(_httpClientFactory);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
var userInfoResponse = return await strategy.GetUserInfoAsync(tokenResponse, discoveryDocument, config.ClientId, ProviderName);
await client.GetFromJsonAsync<Dictionary<string, object>>(discoveryDocument.UserinfoEndpoint);
if (userInfoResponse != null)
{
if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null)
{
userInfo.ProfilePictureUrl = picture.ToString();
}
}
}
}
catch
{
// Ignore errors when fetching additional profile data
} }
return userInfo;
}
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.JwksUri == null)
{
throw new InvalidOperationException("JWKS URI not found in discovery document");
}
var client = _httpClientFactory.CreateClient();
var jwksResponse = await client.GetFromJsonAsync<JsonWebKeySet>(discoveryDocument.JwksUri);
if (jwksResponse == null)
{
throw new InvalidOperationException("Failed to retrieve JWKS from Google");
}
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(idToken);
var kid = jwtToken.Header.Kid;
var signingKey = jwksResponse.Keys.FirstOrDefault(k => k.Kid == kid);
if (signingKey == null)
{
throw new SecurityTokenValidationException("Unable to find matching key in Google's JWKS");
}
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://accounts.google.com",
ValidateAudience = true,
ValidAudience = GetProviderConfig().ClientId,
ValidateLifetime = true,
IssuerSigningKey = signingKey
};
return ValidateAndExtractIdToken(idToken, validationParameters);
}
} }

View File

@@ -0,0 +1,236 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary>
/// Defines how to retrieve user information from an OIDC provider
/// </summary>
public interface IUserInfoStrategy
{
/// <summary>
/// Retrieves user information using the provided token response and discovery document
/// </summary>
Task<OidcUserInfo> GetUserInfoAsync(OidcTokenResponse tokenResponse, OidcDiscoveryDocument? discoveryDocument,
string clientId, string providerName);
}
/// <summary>
/// Strategy for validating and extracting user info from ID tokens (Google, Apple)
/// </summary>
public class IdTokenValidationStrategy : IUserInfoStrategy
{
private readonly IHttpClientFactory _httpClientFactory;
public IdTokenValidationStrategy(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<OidcUserInfo> GetUserInfoAsync(OidcTokenResponse tokenResponse, OidcDiscoveryDocument? discoveryDocument,
string clientId, string providerName)
{
if (string.IsNullOrEmpty(tokenResponse.IdToken))
throw new InvalidOperationException("ID token not found in response");
// Determine issuer and validation parameters based on provider
var (issuer, jwksUri) = providerName.ToLower() switch
{
"google" => ("https://accounts.google.com",
discoveryDocument?.JwksUri ?? "https://www.googleapis.com/oauth2/v3/certs"),
"apple" => ("https://appleid.apple.com",
"https://appleid.apple.com/auth/keys"),
_ => throw new NotSupportedException($"ID token validation not supported for provider: {providerName}")
};
// Get and validate the token
var jwksJson = await GetJwksAsync(jwksUri);
var userInfo = await ValidateIdTokenAsync(tokenResponse.IdToken, clientId, issuer, jwksJson, providerName);
// Set tokens on the user info
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
// For Google, try to fetch additional profile data
if (providerName.ToLower() == "google" && discoveryDocument?.UserinfoEndpoint != null
&& !string.IsNullOrEmpty(tokenResponse.AccessToken))
{
await FetchAdditionalProfileDataAsync(userInfo, discoveryDocument.UserinfoEndpoint,
tokenResponse.AccessToken);
}
// For Apple, parse additional user data if provided
if (providerName.ToLower() == "apple")
{
// Apple-specific handling would go here
}
return userInfo;
}
private async Task<string> GetJwksAsync(string jwksUri)
{
var client = _httpClientFactory.CreateClient();
var response = await client.GetAsync(jwksUri);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
private async Task<OidcUserInfo> ValidateIdTokenAsync(string idToken, string clientId, string issuer,
string jwksJson, string providerName)
{
var jwks = JsonSerializer.Deserialize<JsonWebKeySet>(jwksJson)
?? throw new InvalidOperationException("Failed to parse JWKS");
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(idToken);
var kid = jwtToken.Header.Kid;
var signingKey = jwks.Keys.FirstOrDefault(k => k.Kid == kid)
?? throw new SecurityTokenValidationException($"Unable to find key {kid} in JWKS");
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = clientId,
ValidateLifetime = true,
IssuerSigningKey = signingKey
};
handler.ValidateToken(idToken, validationParameters, out _);
return ExtractUserInfoFromJwt(jwtToken, providerName);
}
private OidcUserInfo ExtractUserInfoFromJwt(JwtSecurityToken jwtToken, string providerName)
{
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
var emailVerified = jwtToken.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value == "true";
var name = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
var givenName = jwtToken.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value;
var familyName = jwtToken.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value;
var preferredUsername = jwtToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
var picture = jwtToken.Claims.FirstOrDefault(c => c.Type == "picture")?.Value;
// Determine preferred username - try different options
var username = preferredUsername;
if (string.IsNullOrEmpty(username))
{
// Fall back to email local part if no preferred username
username = !string.IsNullOrEmpty(email) ? email.Split('@')[0] : null;
}
return new OidcUserInfo
{
UserId = userId,
Email = email,
EmailVerified = emailVerified,
FirstName = givenName ?? "",
LastName = familyName ?? "",
DisplayName = name ?? $"{givenName} {familyName}".Trim(),
PreferredUsername = username ?? "",
ProfilePictureUrl = picture,
Provider = providerName
};
}
private async Task FetchAdditionalProfileDataAsync(OidcUserInfo userInfo, string userinfoEndpoint, string accessToken)
{
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
var userInfoResponse = await client.GetFromJsonAsync<Dictionary<string, object>>(userinfoEndpoint);
if (userInfoResponse != null)
{
if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null)
{
userInfo.ProfilePictureUrl = picture.ToString();
}
}
}
catch
{
// Ignore errors when fetching additional profile data
}
}
}
/// <summary>
/// Strategy for fetching user info from OAuth 2.0 userinfo endpoints (Microsoft, Discord, GitHub)
/// </summary>
public class UserInfoEndpointStrategy : IUserInfoStrategy
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly Func<JsonElement, OidcUserInfo> _parseUserInfo;
private readonly string? _userAgent;
public UserInfoEndpointStrategy(IHttpClientFactory httpClientFactory,
Func<JsonElement, OidcUserInfo> parseUserInfo, string? userAgent = null)
{
_httpClientFactory = httpClientFactory;
_parseUserInfo = parseUserInfo;
_userAgent = userAgent;
}
public async Task<OidcUserInfo> GetUserInfoAsync(OidcTokenResponse tokenResponse, OidcDiscoveryDocument? discoveryDocument,
string clientId, string providerName)
{
if (string.IsNullOrEmpty(tokenResponse.AccessToken) || string.IsNullOrEmpty(discoveryDocument?.UserinfoEndpoint))
throw new InvalidOperationException("Access token or userinfo endpoint missing");
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, discoveryDocument.UserinfoEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
if (!string.IsNullOrEmpty(_userAgent))
request.Headers.Add("User-Agent", _userAgent);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var userElement = JsonDocument.Parse(json).RootElement;
var userInfo = _parseUserInfo(userElement);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
userInfo.Provider = providerName;
return userInfo;
}
}
/// <summary>
/// Strategy for extracting user info directly from token responses (Afdian)
/// </summary>
public class DirectTokenResponseStrategy : IUserInfoStrategy
{
public Task<OidcUserInfo> GetUserInfoAsync(OidcTokenResponse tokenResponse, OidcDiscoveryDocument? discoveryDocument,
string clientId, string providerName)
{
// Parse user info directly from token response data
// This would depend on how the specific provider returns user data
if (string.IsNullOrEmpty(tokenResponse.AccessToken))
throw new InvalidOperationException("Access token missing");
// For Afdian, the user data is embedded in the initial token response
// This strategy would need to know how to parse that specific format
var userInfo = new OidcUserInfo
{
AccessToken = tokenResponse.AccessToken,
RefreshToken = tokenResponse.RefreshToken,
Provider = providerName,
// Parse user data from token response content...
};
return Task.FromResult(userInfo);
}
}