🧱 OAuth login infra
This commit is contained in:
267
DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs
Normal file
267
DysonNetwork.Sphere/Auth/OpenId/AppleOidcService.cs
Normal file
@ -0,0 +1,267 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of OpenID Connect service for Apple Sign In
|
||||
/// </summary>
|
||||
public class AppleOidcService(
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AppDatabase db
|
||||
)
|
||||
: OidcService(configuration, httpClientFactory, db)
|
||||
{
|
||||
private readonly IConfiguration _configuration = configuration;
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
|
||||
public override string ProviderName => "apple";
|
||||
protected override string DiscoveryEndpoint => "https://appleid.apple.com/.well-known/openid-configuration";
|
||||
protected override string ConfigSectionName => "Apple";
|
||||
|
||||
public override string GetAuthorizationUrl(string state, string nonce)
|
||||
{
|
||||
var config = GetProviderConfig();
|
||||
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.ClientId },
|
||||
{ "redirect_uri", config.RedirectUri },
|
||||
{ "response_type", "code id_token" },
|
||||
{ "scope", "name email" },
|
||||
{ "response_mode", "form_post" },
|
||||
{ "state", state },
|
||||
{ "nonce", nonce }
|
||||
};
|
||||
|
||||
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||
return $"https://appleid.apple.com/auth/authorize?{queryString}";
|
||||
}
|
||||
|
||||
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||
{
|
||||
// Verify and decode the id_token
|
||||
var userInfo = await ValidateTokenAsync(callbackData.IdToken);
|
||||
|
||||
// If user data is provided in first login, parse it
|
||||
if (!string.IsNullOrEmpty(callbackData.RawData))
|
||||
{
|
||||
var userData = JsonSerializer.Deserialize<AppleUserData>(callbackData.RawData);
|
||||
if (userData?.Name != null)
|
||||
{
|
||||
userInfo.FirstName = userData.Name.FirstName ?? "";
|
||||
userInfo.LastName = userData.Name.LastName ?? "";
|
||||
userInfo.DisplayName = $"{userInfo.FirstName} {userInfo.LastName}".Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange authorization code for access token (optional, if you need the access token)
|
||||
if (string.IsNullOrEmpty(callbackData.Code)) return userInfo;
|
||||
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
|
||||
if (tokenResponse == null) return userInfo;
|
||||
userInfo.AccessToken = tokenResponse.AccessToken;
|
||||
userInfo.RefreshToken = tokenResponse.RefreshToken;
|
||||
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
|
||||
{
|
||||
// Get Apple's public keys
|
||||
var jwksJson = await GetAppleJwksAsync();
|
||||
var jwks = JsonSerializer.Deserialize<AppleJwks>(jwksJson) ?? new AppleJwks { Keys = new List<AppleKey>() };
|
||||
|
||||
// Parse the JWT header to get the key ID
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = handler.ReadJwtToken(idToken);
|
||||
var kid = jwtToken.Header.Kid;
|
||||
|
||||
// Find the matching key
|
||||
var key = jwks.Keys.FirstOrDefault(k => k.Kid == kid);
|
||||
if (key == null)
|
||||
{
|
||||
throw new SecurityTokenValidationException("Unable to find matching key in Apple's JWKS");
|
||||
}
|
||||
|
||||
// Create the validation parameters
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://appleid.apple.com",
|
||||
ValidateAudience = true,
|
||||
ValidAudience = GetProviderConfig().ClientId,
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKey = key.ToSecurityKey()
|
||||
};
|
||||
|
||||
return ValidateAndExtractIdToken(idToken, validationParameters);
|
||||
}
|
||||
|
||||
protected override Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
|
||||
string? codeVerifier)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.ClientId },
|
||||
{ "client_secret", GenerateClientSecret() },
|
||||
{ "code", code },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "redirect_uri", config.RedirectUri }
|
||||
};
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private async Task<string> GetAppleJwksAsync()
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var response = await client.GetAsync("https://appleid.apple.com/auth/keys");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a client secret for Apple Sign In using JWT
|
||||
/// </summary>
|
||||
private string GenerateClientSecret()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var teamId = _configuration["Oidc:Apple:TeamId"];
|
||||
var clientId = _configuration["Oidc:Apple:ClientId"];
|
||||
var keyId = _configuration["Oidc:Apple:KeyId"];
|
||||
var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"];
|
||||
|
||||
// Read the private key
|
||||
var privateKey = File.ReadAllText(privateKeyPath!);
|
||||
|
||||
// Create the JWT header
|
||||
var header = new Dictionary<string, object>
|
||||
{
|
||||
{ "alg", "ES256" },
|
||||
{ "kid", keyId }
|
||||
};
|
||||
|
||||
// Create the JWT payload
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
{ "iss", teamId },
|
||||
{ "iat", ToUnixTimeSeconds(now) },
|
||||
{ "exp", ToUnixTimeSeconds(now.AddMinutes(5)) },
|
||||
{ "aud", "https://appleid.apple.com" },
|
||||
{ "sub", clientId }
|
||||
};
|
||||
|
||||
// Convert header and payload to Base64Url
|
||||
var headerJson = JsonSerializer.Serialize(header);
|
||||
var payloadJson = JsonSerializer.Serialize(payload);
|
||||
var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
|
||||
var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
|
||||
|
||||
// Create the signature
|
||||
var dataToSign = $"{headerBase64}.{payloadBase64}";
|
||||
var signature = SignWithECDsa(dataToSign, privateKey);
|
||||
|
||||
// Combine all parts
|
||||
return $"{headerBase64}.{payloadBase64}.{signature}";
|
||||
}
|
||||
|
||||
private long ToUnixTimeSeconds(DateTime dateTime)
|
||||
{
|
||||
return new DateTimeOffset(dateTime).ToUnixTimeSeconds();
|
||||
}
|
||||
|
||||
private string SignWithECDsa(string dataToSign, string privateKey)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportFromPem(privateKey);
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(dataToSign);
|
||||
var signature = ecdsa.SignData(bytes, HashAlgorithmName.SHA256);
|
||||
|
||||
return Base64UrlEncode(signature);
|
||||
}
|
||||
|
||||
private string Base64UrlEncode(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
}
|
||||
|
||||
public class AppleUserData
|
||||
{
|
||||
[JsonPropertyName("name")] public AppleNameData? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("email")] public string? Email { get; set; }
|
||||
}
|
||||
|
||||
public class AppleNameData
|
||||
{
|
||||
[JsonPropertyName("firstName")] public string? FirstName { get; set; }
|
||||
|
||||
[JsonPropertyName("lastName")] public string? LastName { get; set; }
|
||||
}
|
||||
|
||||
public class AppleJwks
|
||||
{
|
||||
[JsonPropertyName("keys")] public List<AppleKey> Keys { get; set; } = new List<AppleKey>();
|
||||
}
|
||||
|
||||
public class AppleKey
|
||||
{
|
||||
[JsonPropertyName("kty")] public string? Kty { get; set; }
|
||||
|
||||
[JsonPropertyName("kid")] public string? Kid { get; set; }
|
||||
|
||||
[JsonPropertyName("use")] public string? Use { get; set; }
|
||||
|
||||
[JsonPropertyName("alg")] public string? Alg { get; set; }
|
||||
|
||||
[JsonPropertyName("n")] public string? N { get; set; }
|
||||
|
||||
[JsonPropertyName("e")] public string? E { get; set; }
|
||||
|
||||
public SecurityKey ToSecurityKey()
|
||||
{
|
||||
if (Kty != "RSA" || string.IsNullOrEmpty(N) || string.IsNullOrEmpty(E))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid key data");
|
||||
}
|
||||
|
||||
var parameters = new RSAParameters
|
||||
{
|
||||
Modulus = Base64UrlDecode(N),
|
||||
Exponent = Base64UrlDecode(E)
|
||||
};
|
||||
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportParameters(parameters);
|
||||
|
||||
return new RsaSecurityKey(rsa);
|
||||
}
|
||||
|
||||
private byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var output = input
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
switch (output.Length % 4)
|
||||
{
|
||||
case 0: break;
|
||||
case 2: output += "=="; break;
|
||||
case 3: output += "="; break;
|
||||
default: throw new InvalidOperationException("Invalid base64url string");
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(output);
|
||||
}
|
||||
}
|
43
DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs
Normal file
43
DysonNetwork.Sphere/Auth/OpenId/ConnectionController.cs
Normal file
@ -0,0 +1,43 @@
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/connections")]
|
||||
[Authorize]
|
||||
public class ConnectionController(AppDatabase db) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<AccountConnection>>> GetConnections()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var connections = await db.AccountConnections
|
||||
.Where(c => c.AccountId == currentUser.Id)
|
||||
.Select(c => new { c.Id, c.AccountId, c.Provider, c.ProvidedIdentifier })
|
||||
.ToListAsync();
|
||||
return Ok(connections);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<ActionResult> RemoveConnection(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var connection = await db.AccountConnections
|
||||
.Where(c => c.Id == id && c.AccountId == currentUser.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (connection == null)
|
||||
return NotFound();
|
||||
|
||||
db.AccountConnections.Remove(connection);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
184
DysonNetwork.Sphere/Auth/OpenId/GoogleOidcService.cs
Normal file
184
DysonNetwork.Sphere/Auth/OpenId/GoogleOidcService.cs
Normal file
@ -0,0 +1,184 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of OpenID Connect service for Google Sign In
|
||||
/// </summary>
|
||||
public class GoogleOidcService(
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AppDatabase db
|
||||
)
|
||||
: OidcService(configuration, httpClientFactory, db)
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
|
||||
public override string ProviderName => "google";
|
||||
protected override string DiscoveryEndpoint => "https://accounts.google.com/.well-known/openid-configuration";
|
||||
protected override string ConfigSectionName => "Google";
|
||||
|
||||
public override string GetAuthorizationUrl(string state, string nonce)
|
||||
{
|
||||
var config = GetProviderConfig();
|
||||
var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (discoveryDocument?.AuthorizationEndpoint == null)
|
||||
{
|
||||
throw new InvalidOperationException("Authorization endpoint not found in discovery document");
|
||||
}
|
||||
|
||||
// Generate code verifier and challenge for PKCE
|
||||
var codeVerifier = GenerateCodeVerifier();
|
||||
var codeChallenge = GenerateCodeChallenge(codeVerifier);
|
||||
|
||||
// Store code verifier in session or cache for later use
|
||||
// For simplicity, we'll append it to the state parameter in this example
|
||||
var combinedState = $"{state}|{codeVerifier}";
|
||||
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.ClientId },
|
||||
{ "redirect_uri", config.RedirectUri },
|
||||
{ "response_type", "code" },
|
||||
{ "scope", "openid email profile" },
|
||||
{ "state", combinedState },
|
||||
{ "nonce", nonce },
|
||||
{ "code_challenge", codeChallenge },
|
||||
{ "code_challenge_method", "S256" }
|
||||
};
|
||||
|
||||
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
|
||||
}
|
||||
|
||||
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||
{
|
||||
// Extract code verifier from state
|
||||
string? codeVerifier = null;
|
||||
var state = callbackData.State ?? "";
|
||||
|
||||
if (state.Contains('|'))
|
||||
{
|
||||
var parts = state.Split('|');
|
||||
state = parts[0];
|
||||
codeVerifier = parts.Length > 1 ? parts[1] : null;
|
||||
callbackData.State = state; // Set the clean state back
|
||||
}
|
||||
|
||||
// Exchange the code for tokens
|
||||
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, codeVerifier);
|
||||
if (tokenResponse?.IdToken == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to obtain ID token from Google");
|
||||
}
|
||||
|
||||
// Validate the ID token
|
||||
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();
|
||||
if (discoveryDocument?.UserinfoEndpoint != null && !string.IsNullOrEmpty(tokenResponse.AccessToken))
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
|
||||
|
||||
var userInfoResponse = await client.GetFromJsonAsync<Dictionary<string, object>>(discoveryDocument.UserinfoEndpoint);
|
||||
|
||||
if (userInfoResponse != null)
|
||||
{
|
||||
// Extract any additional fields that might be available
|
||||
if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null)
|
||||
{
|
||||
userInfo.ProfilePictureUrl = picture.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Get Google's signing keys
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var jwksResponse = await client.GetFromJsonAsync<JsonWebKeySet>(discoveryDocument.JwksUri);
|
||||
if (jwksResponse == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to retrieve JWKS from Google");
|
||||
}
|
||||
|
||||
// Parse the JWT to get the key ID
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = handler.ReadJwtToken(idToken);
|
||||
var kid = jwtToken.Header.Kid;
|
||||
|
||||
// Find the matching key
|
||||
var signingKey = jwksResponse.Keys.FirstOrDefault(k => k.Kid == kid);
|
||||
if (signingKey == null)
|
||||
{
|
||||
throw new SecurityTokenValidationException("Unable to find matching key in Google's JWKS");
|
||||
}
|
||||
|
||||
// Create validation parameters
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://accounts.google.com",
|
||||
ValidateAudience = true,
|
||||
ValidAudience = GetProviderConfig().ClientId,
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKey = signingKey
|
||||
};
|
||||
|
||||
return ValidateAndExtractIdToken(idToken, validationParameters);
|
||||
}
|
||||
|
||||
#region PKCE Support
|
||||
|
||||
public string GenerateCodeVerifier()
|
||||
{
|
||||
var randomBytes = new byte[32]; // 256 bits
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
rng.GetBytes(randomBytes);
|
||||
|
||||
return Convert.ToBase64String(randomBytes)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
public string GenerateCodeChallenge(string codeVerifier)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
|
||||
return Convert.ToBase64String(challengeBytes)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
48
DysonNetwork.Sphere/Auth/OpenId/OidcController.cs
Normal file
48
DysonNetwork.Sphere/Auth/OpenId/OidcController.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
||||
|
||||
[ApiController]
|
||||
[Route("/auth/login")]
|
||||
public class OidcController(
|
||||
IServiceProvider serviceProvider,
|
||||
AppDatabase db,
|
||||
Account.AccountService accountService,
|
||||
AuthService authService
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet("{provider}")]
|
||||
public ActionResult SignIn([FromRoute] string provider, [FromQuery] string? returnUrl = "/")
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the appropriate provider service
|
||||
var oidcService = GetOidcService(provider);
|
||||
|
||||
// Generate state (containing return URL) and nonce
|
||||
var state = returnUrl;
|
||||
var nonce = Guid.NewGuid().ToString();
|
||||
|
||||
// Get the authorization URL and redirect the user
|
||||
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
||||
return Redirect(authUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private OidcService GetOidcService(string provider)
|
||||
{
|
||||
return provider.ToLower() switch
|
||||
{
|
||||
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
|
||||
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
|
||||
// Add more providers as needed
|
||||
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
||||
};
|
||||
}
|
||||
}
|
268
DysonNetwork.Sphere/Auth/OpenId/OidcService.cs
Normal file
268
DysonNetwork.Sphere/Auth/OpenId/OidcService.cs
Normal file
@ -0,0 +1,268 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
||||
|
||||
/// <summary>
|
||||
/// Base service for OpenID Connect authentication providers
|
||||
/// </summary>
|
||||
public abstract class OidcService(IConfiguration configuration, IHttpClientFactory httpClientFactory, AppDatabase db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this provider
|
||||
/// </summary>
|
||||
public abstract string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OIDC discovery document endpoint
|
||||
/// </summary>
|
||||
protected abstract string DiscoveryEndpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets configuration section name for this provider
|
||||
/// </summary>
|
||||
protected abstract string ConfigSectionName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the authorization URL for initiating the authentication flow
|
||||
/// </summary>
|
||||
public abstract string GetAuthorizationUrl(string state, string nonce);
|
||||
|
||||
/// <summary>
|
||||
/// Process the callback from the OIDC provider
|
||||
/// </summary>
|
||||
public abstract Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider configuration
|
||||
/// </summary>
|
||||
protected ProviderConfiguration GetProviderConfig()
|
||||
{
|
||||
return new ProviderConfiguration
|
||||
{
|
||||
ClientId = configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
|
||||
ClientSecret = configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
|
||||
RedirectUri = configuration[$"Oidc:{ConfigSectionName}:RedirectUri"] ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the OpenID Connect discovery document
|
||||
/// </summary>
|
||||
protected async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
var response = await client.GetAsync(DiscoveryEndpoint);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exchange the authorization code for tokens
|
||||
/// </summary>
|
||||
protected async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, string? codeVerifier = null)
|
||||
{
|
||||
var config = GetProviderConfig();
|
||||
var discoveryDocument = await GetDiscoveryDocumentAsync();
|
||||
|
||||
if (discoveryDocument?.TokenEndpoint == null)
|
||||
{
|
||||
throw new InvalidOperationException("Token endpoint not found in discovery document");
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient();
|
||||
var content = new FormUrlEncodedContent(BuildTokenRequestParameters(code, config, codeVerifier));
|
||||
|
||||
var response = await client.PostAsync(discoveryDocument.TokenEndpoint, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the token request parameters
|
||||
/// </summary>
|
||||
protected virtual Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
|
||||
string? codeVerifier)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.ClientId },
|
||||
{ "code", code },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "redirect_uri", config.RedirectUri }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(config.ClientSecret))
|
||||
{
|
||||
parameters.Add("client_secret", config.ClientSecret);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(codeVerifier))
|
||||
{
|
||||
parameters.Add("code_verifier", codeVerifier);
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and extracts information from an ID token
|
||||
/// </summary>
|
||||
protected virtual OidcUserInfo ValidateAndExtractIdToken(string idToken,
|
||||
TokenValidationParameters validationParameters)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
handler.ValidateToken(idToken, validationParameters, out _);
|
||||
|
||||
var jwtToken = handler.ReadJwtToken(idToken);
|
||||
|
||||
// Extract standard claims
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a challenge and session for an authenticated user
|
||||
/// Also creates or updates the account connection
|
||||
/// </summary>
|
||||
public async Task<Session> CreateSessionForUserAsync(OidcUserInfo userInfo, Account.Account account)
|
||||
{
|
||||
// Create or update the account connection
|
||||
var connection = await db.AccountConnections
|
||||
.FirstOrDefaultAsync(c => c.Provider == ProviderName &&
|
||||
c.ProvidedIdentifier == userInfo.UserId &&
|
||||
c.AccountId == account.Id
|
||||
);
|
||||
|
||||
if (connection is null)
|
||||
{
|
||||
connection = new AccountConnection
|
||||
{
|
||||
Provider = ProviderName,
|
||||
ProvidedIdentifier = userInfo.UserId ?? "",
|
||||
AccessToken = userInfo.AccessToken,
|
||||
RefreshToken = userInfo.RefreshToken,
|
||||
LastUsedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(),
|
||||
AccountId = account.Id
|
||||
};
|
||||
await db.AccountConnections.AddAsync(connection);
|
||||
}
|
||||
|
||||
// Create a challenge that's already completed
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var challenge = new Challenge
|
||||
{
|
||||
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
||||
StepTotal = 1,
|
||||
StepRemain = 0, // Already verified by provider
|
||||
Platform = ChallengePlatform.Unidentified,
|
||||
Audiences = [ProviderName],
|
||||
Scopes = ["*"],
|
||||
AccountId = account.Id
|
||||
};
|
||||
|
||||
await db.AuthChallenges.AddAsync(challenge);
|
||||
|
||||
// Create a session
|
||||
var session = new Session
|
||||
{
|
||||
LastGrantedAt = now,
|
||||
Account = account,
|
||||
Challenge = challenge,
|
||||
};
|
||||
|
||||
await db.AuthSessions.AddAsync(session);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider configuration from app settings
|
||||
/// </summary>
|
||||
public class ProviderConfiguration
|
||||
{
|
||||
public string ClientId { get; set; } = "";
|
||||
public string ClientSecret { get; set; } = "";
|
||||
public string RedirectUri { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OIDC Discovery Document
|
||||
/// </summary>
|
||||
public class OidcDiscoveryDocument
|
||||
{
|
||||
[JsonPropertyName("authorization_endpoint")]
|
||||
public string? AuthorizationEndpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("userinfo_endpoint")]
|
||||
public string? UserinfoEndpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("jwks_uri")] public string? JwksUri { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from the token endpoint
|
||||
/// </summary>
|
||||
public class OidcTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }
|
||||
|
||||
[JsonPropertyName("token_type")] public string? TokenType { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
|
||||
|
||||
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; }
|
||||
|
||||
[JsonPropertyName("id_token")] public string? IdToken { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data received in the callback from an OIDC provider
|
||||
/// </summary>
|
||||
public class OidcCallbackData
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public string IdToken { get; set; } = "";
|
||||
public string? State { get; set; }
|
||||
public string? CodeVerifier { get; set; }
|
||||
public string? RawData { get; set; }
|
||||
}
|
19
DysonNetwork.Sphere/Auth/OpenId/OidcUserInfo.cs
Normal file
19
DysonNetwork.Sphere/Auth/OpenId/OidcUserInfo.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the user information from an OIDC provider
|
||||
/// </summary>
|
||||
public class OidcUserInfo
|
||||
{
|
||||
public string? UserId { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public bool EmailVerified { get; set; }
|
||||
public string FirstName { get; set; } = "";
|
||||
public string LastName { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string PreferredUsername { get; set; } = "";
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
public string Provider { get; set; } = "";
|
||||
public string? RefreshToken { get; set; }
|
||||
public string? AccessToken { get; set; }
|
||||
}
|
Reference in New Issue
Block a user