🧱 OAuth login infra

This commit is contained in:
2025-06-15 13:11:45 +08:00
parent d00917fb39
commit bf013a108b
14 changed files with 1314 additions and 47 deletions

View File

@ -0,0 +1,181 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc;
using DysonNetwork.Sphere.Auth.OpenId;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
/// <summary>
/// This controller is designed to handle the OAuth callback.
/// </summary>
[ApiController]
[Route("/auth/callback")]
public class AuthCallbackController(
AppDatabase db,
AccountService accounts,
MagicSpellService spells,
AuthService auth,
IServiceProvider serviceProvider,
Account.AccountUsernameService accountUsernameService
)
: ControllerBase
{
[HttpPost("apple")]
public async Task<ActionResult> AppleCallbackPost(
[FromForm] string code,
[FromForm(Name = "id_token")] string idToken,
[FromForm] string? state = null,
[FromForm] string? user = null)
{
return await ProcessOidcCallback("apple", new OidcCallbackData
{
Code = code,
IdToken = idToken,
State = state,
RawData = user
});
}
private async Task<ActionResult> ProcessOidcCallback(string provider, OidcCallbackData callbackData)
{
try
{
// Get the appropriate provider service
var oidcService = GetOidcService(provider);
// Process the callback
var userInfo = await oidcService.ProcessCallbackAsync(callbackData);
if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId))
{
return BadRequest($"Email or user ID is missing from {provider}'s response");
}
// First, check if we already have a connection with this provider ID
var existingConnection = await db.AccountConnections
.Include(c => c.Account)
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
if (existingConnection is not null)
return await CreateSessionAndRedirect(
oidcService,
userInfo,
existingConnection.Account,
callbackData.State
);
// If no existing connection, try to find an account by email
var account = await accounts.LookupAccount(userInfo.Email);
if (account == null)
{
// Generate username and display name from email
var username = await accountUsernameService.GenerateUsernameFromEmailAsync(userInfo.Email);
var displayName = await accountUsernameService.GenerateDisplayNameFromEmailAsync(userInfo.Email);
// Create a new account
account = new Account.Account
{
Name = username,
Nick = displayName,
Contacts = new List<AccountContact>
{
new()
{
Type = AccountContactType.Email,
Content = userInfo.Email,
VerifiedAt = userInfo.EmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
IsPrimary = true
}
},
Profile = new Profile()
};
// Save the account
await db.Accounts.AddAsync(account);
await db.SaveChangesAsync();
// Do the usual steps
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
},
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromDays(7))
);
await spells.NotifyMagicSpell(spell, true);
}
// Create a session for the user
var session = await oidcService.CreateSessionForUserAsync(userInfo, account);
// Generate token
var token = auth.CreateToken(session);
// Determine where to redirect
var redirectUrl = "/";
if (!string.IsNullOrEmpty(callbackData.State))
{
// Use state as redirect URL (should be validated in production)
redirectUrl = callbackData.State;
}
// Set the token as a cookie
Response.Cookies.Append(AuthConstants.TokenQueryParamName, token, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddDays(30)
});
return Redirect(redirectUrl);
}
catch (Exception ex)
{
return BadRequest($"Error processing {provider} Sign In: {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}")
};
}
/// <summary>
/// Creates a session and redirects the user with a token
/// </summary>
private async Task<ActionResult> CreateSessionAndRedirect(OidcService oidcService, OidcUserInfo userInfo,
Account.Account account, string? state)
{
// Create a session for the user
var session = await oidcService.CreateSessionForUserAsync(userInfo, account);
// Generate token
var token = auth.CreateToken(session);
// Determine where to redirect
var redirectUrl = "/";
if (!string.IsNullOrEmpty(state))
redirectUrl = state;
// Set the token as a cookie
Response.Cookies.Append(AuthConstants.TokenQueryParamName, token, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddDays(30)
});
return Redirect(redirectUrl);
}
}

View 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);
}
}

View 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();
}
}

View 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
}

View 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}")
};
}
}

View 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; }
}

View 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; }
}