♻️ Moved some services to DysonNetwork.Pass

This commit is contained in:
2025-07-11 02:00:40 +08:00
parent 2a3918134f
commit e76c80eead
68 changed files with 8913 additions and 7 deletions

View File

@@ -0,0 +1,95 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.Cache;
namespace DysonNetwork.Pass.Auth.OpenId;
public class AfdianOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache,
ILogger<AfdianOidcService> logger
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Afdian";
protected override string DiscoveryEndpoint => ""; // Afdian doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "Afdian";
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" },
{ "scope", "basic" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://afdian.com/oauth2/authorize?{queryString}";
}
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
return Task.FromResult(new OidcDiscoveryDocument
{
AuthorizationEndpoint = "https://afdian.com/oauth2/authorize",
TokenEndpoint = "https://afdian.com/oauth2/access_token",
UserinfoEndpoint = null,
JwksUri = null
})!;
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
try
{
var config = GetProviderConfig();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "grant_type", "authorization_code" },
{ "code", callbackData.Code },
{ "redirect_uri", config.RedirectUri },
});
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/oauth2/access_token");
request.Content = content;
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
logger.LogInformation("Trying get userinfo from afdian, response: {Response}", json);
var afdianResponse = JsonDocument.Parse(json).RootElement;
var user = afdianResponse.TryGetProperty("data", out var dataElement) ? dataElement : default;
var userId = user.TryGetProperty("user_id", out var userIdElement) ? userIdElement.GetString() ?? "" : "";
var avatar = user.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
return new OidcUserInfo
{
UserId = userId,
DisplayName = (user.TryGetProperty("name", out var nameElement)
? nameElement.GetString()
: null) ?? "",
ProfilePictureUrl = avatar,
Provider = ProviderName
};
}
catch (Exception ex)
{
// Due to afidan's API isn't compliant with OAuth2, we want more logs from it to investigate.
logger.LogError(ex, "Failed to get user info from Afdian");
throw;
}
}
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace DysonNetwork.Pass.Auth.OpenId;
public class AppleMobileConnectRequest
{
[Required]
public required string IdentityToken { get; set; }
[Required]
public required string AuthorizationCode { get; set; }
}
public class AppleMobileSignInRequest : AppleMobileConnectRequest
{
[Required]
public required string DeviceId { get; set; }
}

View File

@@ -0,0 +1,280 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass;
using DysonNetwork.Shared.Cache;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary>
/// Implementation of OpenID Connect service for Apple Sign In
/// </summary>
public class AppleOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
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"];
if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(keyId) ||
string.IsNullOrEmpty(privateKeyPath))
{
throw new InvalidOperationException("Apple OIDC configuration is missing required values (TeamId, ClientId, KeyId, 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,409 @@
using DysonNetwork.Pass.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Shared.Cache;
using NodaTime;
namespace DysonNetwork.Pass.Auth.OpenId;
[ApiController]
[Route("/api/accounts/me/connections")]
[Authorize]
public class ConnectionController(
AppDatabase db,
IEnumerable<OidcService> oidcServices,
AccountService accounts,
AuthService auth,
ICacheService cache
) : ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
private const string ReturnUrlCachePrefix = "oidc-returning:";
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
[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,
c.Meta,
c.LastUsedAt,
c.CreatedAt,
c.UpdatedAt,
})
.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();
}
[HttpPost("/auth/connect/apple/mobile")]
public async Task<ActionResult> ConnectAppleMobile([FromBody] AppleMobileConnectRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
if (GetOidcService("apple") is not AppleOidcService appleService)
return StatusCode(503, "Apple OIDC service not available");
var callbackData = new OidcCallbackData
{
IdToken = request.IdentityToken,
Code = request.AuthorizationCode,
};
OidcUserInfo userInfo;
try
{
userInfo = await appleService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing Apple token: {ex.Message}");
}
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.Provider == "apple" &&
c.ProvidedIdentifier == userInfo.UserId);
if (existingConnection != null)
{
return BadRequest(
$"This Apple account is already linked to {(existingConnection.AccountId == currentUser.Id ? "your account" : "another user")}.");
}
db.AccountConnections.Add(new AccountConnection
{
AccountId = currentUser.Id,
Provider = "apple",
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata(),
});
await db.SaveChangesAsync();
return Ok(new { message = "Successfully connected Apple account." });
}
private OidcService? GetOidcService(string provider)
{
return oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase));
}
public class ConnectProviderRequest
{
public string Provider { get; set; } = null!;
public string? ReturnUrl { get; set; }
}
/// <summary>
/// Initiates manual connection to an OAuth provider for the current user
/// </summary>
[HttpPost("connect")]
public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var oidcService = GetOidcService(request.Provider);
if (oidcService == null)
return BadRequest($"Provider '{request.Provider}' is not supported");
var existingConnection = await db.AccountConnections
.AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName);
if (existingConnection)
return BadRequest($"You already have a {request.Provider} connection");
var state = Guid.NewGuid().ToString("N");
var nonce = Guid.NewGuid().ToString("N");
var stateValue = $"{currentUser.Id}|{request.Provider}|{nonce}";
var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections";
// Store state and return URL in cache
await cache.SetAsync($"{StateCachePrefix}{state}", stateValue, StateExpiration);
await cache.SetAsync($"{ReturnUrlCachePrefix}{state}", finalReturnUrl, StateExpiration);
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Ok(new
{
authUrl,
message = $"Redirect to this URL to connect your {request.Provider} account"
});
}
[AllowAnonymous]
[Route("/api/auth/callback/{provider}")]
[HttpGet, HttpPost]
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
{
var oidcService = GetOidcService(provider);
if (oidcService == null)
return BadRequest($"Provider '{provider}' is not supported.");
var callbackData = await ExtractCallbackData(Request);
if (callbackData.State == null)
return BadRequest("State parameter is missing.");
// Get the state from the cache
var stateKey = $"{StateCachePrefix}{callbackData.State}";
// Try to get the state as OidcState first (new format)
var oidcState = await cache.GetAsync<OidcState>(stateKey);
// If not found, try to get as string (legacy format)
if (oidcState == null)
{
var stateValue = await cache.GetAsync<string>(stateKey);
if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null)
return BadRequest("Invalid or expired state parameter");
}
// Remove the state from cache to prevent replay attacks
await cache.RemoveAsync(stateKey);
// Handle the flow based on state type
if (oidcState.FlowType == OidcFlowType.Connect && oidcState.AccountId.HasValue)
{
// Connection flow
if (oidcState.DeviceId != null)
{
callbackData.State = oidcState.DeviceId;
}
return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value);
}
else if (oidcState.FlowType == OidcFlowType.Login)
{
// Login/Registration flow
if (!string.IsNullOrEmpty(oidcState.DeviceId))
{
callbackData.State = oidcState.DeviceId;
}
// Store return URL if provided
if (!string.IsNullOrEmpty(oidcState.ReturnUrl) && oidcState.ReturnUrl != "/")
{
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
await cache.SetAsync(returnUrlKey, oidcState.ReturnUrl, StateExpiration);
}
return await HandleLoginOrRegistration(provider, oidcService, callbackData);
}
return BadRequest("Unsupported flow type");
}
private async Task<IActionResult> HandleManualConnection(
string provider,
OidcService oidcService,
OidcCallbackData callbackData,
Guid accountId
)
{
provider = provider.ToLower();
OidcUserInfo userInfo;
try
{
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing {provider} authentication: {ex.Message}");
}
if (string.IsNullOrEmpty(userInfo.UserId))
{
return BadRequest($"{provider} did not return a valid user identifier.");
}
// Extract device ID from the callback state if available
var deviceId = !string.IsNullOrEmpty(callbackData.State) ? callbackData.State : string.Empty;
// Check if this provider account is already connected to any user
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId);
// If it's connected to a different user, return error
if (existingConnection != null && existingConnection.AccountId != accountId)
{
return BadRequest($"This {provider} account is already linked to another user.");
}
// Check if the current user already has this provider connected
var userHasProvider = await db.AccountConnections
.AnyAsync(c =>
c.AccountId == accountId &&
c.Provider == provider);
if (userHasProvider)
{
// Update existing connection with new tokens
var connection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.AccountId == accountId &&
c.Provider == provider);
if (connection != null)
{
connection.AccessToken = userInfo.AccessToken;
connection.RefreshToken = userInfo.RefreshToken;
connection.LastUsedAt = SystemClock.Instance.GetCurrentInstant();
connection.Meta = userInfo.ToMetadata();
}
}
else
{
// Create new connection
db.AccountConnections.Add(new AccountConnection
{
AccountId = accountId,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata(),
});
}
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
return StatusCode(500, $"Failed to save {provider} connection. Please try again.");
}
// Clean up and redirect
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
await cache.RemoveAsync(returnUrlKey);
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl);
}
private async Task<IActionResult> HandleLoginOrRegistration(
string provider,
OidcService oidcService,
OidcCallbackData callbackData
)
{
OidcUserInfo userInfo;
try
{
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing callback: {ex.Message}");
}
if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId))
{
return BadRequest($"Email or user ID is missing from {provider}'s response");
}
var connection = await db.AccountConnections
.Include(c => c.Account)
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
var clock = SystemClock.Instance;
if (connection != null)
{
// Login existing user
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
callbackData.State.Split('|').FirstOrDefault() :
string.Empty;
var challenge = await oidcService.CreateChallengeForUserAsync(
userInfo,
connection.Account,
HttpContext,
deviceId ?? string.Empty);
return Redirect($"/auth/callback?challenge={challenge.Id}");
}
// Register new user
var account = await accounts.LookupAccount(userInfo.Email) ?? await accounts.CreateAccount(userInfo);
// Create connection for new or existing user
var newConnection = new AccountConnection
{
Account = account,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = clock.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
db.AccountConnections.Add(newConnection);
await db.SaveChangesAsync();
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession);
return Redirect($"/auth/token?token={loginToken}");
}
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
{
var data = new OidcCallbackData();
switch (request.Method)
{
case "GET":
data.Code = Uri.UnescapeDataString(request.Query["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(request.Query["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(request.Query["state"].FirstOrDefault() ?? "");
break;
case "POST" when request.HasFormContentType:
{
var form = await request.ReadFormAsync();
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
if (form.ContainsKey("user"))
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
break;
}
}
return data;
}
}

View File

@@ -0,0 +1,116 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.Cache;
namespace DysonNetwork.Pass.Auth.OpenId;
public class DiscordOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Discord";
protected override string DiscoveryEndpoint => ""; // Discord doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "Discord";
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" },
{ "scope", "identify email" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://discord.com/oauth2/authorize?{queryString}";
}
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
return Task.FromResult(new OidcDiscoveryDocument
{
AuthorizationEndpoint = "https://discord.com/oauth2/authorize",
TokenEndpoint = "https://discord.com/oauth2/token",
UserinfoEndpoint = "https://discord.com/users/@me",
JwksUri = null
})!;
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from Discord");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var client = HttpClientFactory.CreateClient();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
});
var response = await client.PostAsync("https://discord.com/oauth2/token", content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/users/@me");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var discordUser = JsonDocument.Parse(json).RootElement;
var userId = discordUser.GetProperty("id").GetString() ?? "";
var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
return new OidcUserInfo
{
UserId = userId,
Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "",
EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) &&
verifiedElement.GetBoolean(),
DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement)
? globalNameElement.GetString()
: null) ?? "",
PreferredUsername = discordUser.GetProperty("username").GetString() ?? "",
ProfilePictureUrl = !string.IsNullOrEmpty(avatar)
? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png"
: "",
Provider = ProviderName
};
}
}

View File

@@ -0,0 +1,128 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.Cache;
namespace DysonNetwork.Pass.Auth.OpenId;
public class GitHubOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "GitHub";
protected override string DiscoveryEndpoint => ""; // GitHub doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "GitHub";
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 },
{ "scope", "user:email" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://github.com/login/oauth/authorize?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from GitHub");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var client = HttpClientFactory.CreateClient();
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
})
};
tokenRequest.Headers.Add("Accept", "application/json");
var response = await client.SendAsync(tokenRequest);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.Headers.Add("User-Agent", "DysonNetwork.Pass");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var githubUser = JsonDocument.Parse(json).RootElement;
var email = githubUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null;
if (string.IsNullOrEmpty(email))
{
email = await GetPrimaryEmailAsync(accessToken);
}
return new OidcUserInfo
{
UserId = githubUser.GetProperty("id").GetInt64().ToString(),
Email = email,
DisplayName = githubUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "",
PreferredUsername = githubUser.GetProperty("login").GetString() ?? "",
ProfilePictureUrl = githubUser.TryGetProperty("avatar_url", out var avatarElement)
? avatarElement.GetString() ?? ""
: "",
Provider = ProviderName
};
}
private async Task<string?> GetPrimaryEmailAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.Headers.Add("User-Agent", "DysonNetwork.Pass");
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var emails = await response.Content.ReadFromJsonAsync<List<GitHubEmail>>();
return emails?.FirstOrDefault(e => e.Primary)?.Email;
}
private class GitHubEmail
{
public string Email { get; set; } = "";
public bool Primary { get; set; }
public bool Verified { get; set; }
}
}

View File

@@ -0,0 +1,137 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Pass;
using DysonNetwork.Shared.Cache;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Pass.Auth.OpenId;
public class GoogleOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
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");
}
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code" },
{ "scope", "openid email profile" },
{ "state", state }, // No '|codeVerifier' appended anymore
{ "nonce", nonce }
};
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)
{
// No need to split or parse code verifier from state
var state = callbackData.State ?? "";
callbackData.State = state; // Keep the original state if needed
// Exchange the code for tokens
// Pass null or omit the parameter for codeVerifier as PKCE is removed
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, null);
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)
{
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,123 @@
using System.Text.Json;
using DysonNetwork.Shared.Cache;
namespace DysonNetwork.Pass.Auth.OpenId;
public class MicrosoftOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Microsoft";
protected override string DiscoveryEndpoint => Configuration[$"Oidc:{ConfigSectionName}:DiscoveryEndpoint"] ??
throw new InvalidOperationException(
"Microsoft OIDC discovery endpoint is not configured.");
protected override string ConfigSectionName => "Microsoft";
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.");
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "response_type", "code" },
{ "redirect_uri", config.RedirectUri },
{ "response_mode", "query" },
{ "scope", "openid profile email" },
{ "state", state },
{ "nonce", nonce },
};
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)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from Microsoft");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override 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 tokenRequest = new HttpRequestMessage(HttpMethod.Post, discoveryDocument.TokenEndpoint)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "scope", "openid profile email" },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
{ "grant_type", "authorization_code" },
{ "client_secret", config.ClientSecret },
})
};
var response = await client.SendAsync(tokenRequest);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.UserinfoEndpoint == null)
throw new InvalidOperationException("Userinfo endpoint not found in discovery document.");
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, discoveryDocument.UserinfoEndpoint);
request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var microsoftUser = JsonDocument.Parse(json).RootElement;
return new OidcUserInfo
{
UserId = microsoftUser.GetProperty("sub").GetString() ?? "",
Email = microsoftUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null,
DisplayName =
microsoftUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "",
PreferredUsername = microsoftUser.TryGetProperty("preferred_username", out var preferredUsernameElement)
? preferredUsernameElement.GetString() ?? ""
: "",
ProfilePictureUrl = microsoftUser.TryGetProperty("picture", out var pictureElement)
? pictureElement.GetString() ?? ""
: "",
Provider = ProviderName
};
}
}

View File

@@ -0,0 +1,194 @@
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Pass.Auth.OpenId;
[ApiController]
[Route("/api/auth/login")]
public class OidcController(
IServiceProvider serviceProvider,
AppDatabase db,
AccountService accounts,
ICacheService cache
)
: ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
[HttpGet("{provider}")]
public async Task<ActionResult> OidcLogin(
[FromRoute] string provider,
[FromQuery] string? returnUrl = "/",
[FromHeader(Name = "X-Device-Id")] string? deviceId = null
)
{
try
{
var oidcService = GetOidcService(provider);
// If the user is already authenticated, treat as an account connection request
if (HttpContext.Items["CurrentUser"] is Account.Account currentUser)
{
var state = Guid.NewGuid().ToString();
var nonce = Guid.NewGuid().ToString();
// Create and store connection state
var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
// The state parameter sent to the provider is the GUID key for the cache.
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Redirect(authUrl);
}
else // Otherwise, proceed with the login / registration flow
{
var nonce = Guid.NewGuid().ToString();
var state = Guid.NewGuid().ToString();
// Create login state with return URL and device ID
var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Redirect(authUrl);
}
}
catch (Exception ex)
{
return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}");
}
}
/// <summary>
/// Mobile Apple Sign In endpoint
/// Handles Apple authentication directly from mobile apps
/// </summary>
[HttpPost("apple/mobile")]
public async Task<ActionResult<Challenge>> AppleMobileLogin(
[FromBody] AppleMobileSignInRequest request)
{
try
{
// Get Apple OIDC service
if (GetOidcService("apple") is not AppleOidcService appleService)
return StatusCode(503, "Apple OIDC service not available");
// Prepare callback data for processing
var callbackData = new OidcCallbackData
{
IdToken = request.IdentityToken,
Code = request.AuthorizationCode,
};
// Process the authentication
var userInfo = await appleService.ProcessCallbackAsync(callbackData);
// Find or create user account using existing logic
var account = await FindOrCreateAccount(userInfo, "apple");
// Create session using the OIDC service
var challenge = await appleService.CreateChallengeForUserAsync(
userInfo,
account,
HttpContext,
request.DeviceId
);
return Ok(challenge);
}
catch (SecurityTokenValidationException ex)
{
return Unauthorized($"Invalid identity token: {ex.Message}");
}
catch (Exception ex)
{
// Log the error
return StatusCode(500, $"Authentication failed: {ex.Message}");
}
}
private OidcService GetOidcService(string provider)
{
return provider.ToLower() switch
{
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
private async Task<Account.Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
// Check if an account exists by email
var existingAccount = await accounts.LookupAccount(userInfo.Email);
if (existingAccount != null)
{
// Check if this provider connection already exists
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c => c.AccountId == existingAccount.Id &&
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId);
// If no connection exists, create one
if (existingConnection != null)
{
await db.AccountConnections
.Where(c => c.AccountId == existingAccount.Id &&
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.LastUsedAt, SystemClock.Instance.GetCurrentInstant())
.SetProperty(c => c.Meta, userInfo.ToMetadata()));
return existingAccount;
}
var connection = new AccountConnection
{
AccountId = existingAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
await db.AccountConnections.AddAsync(connection);
await db.SaveChangesAsync();
return existingAccount;
}
// Create new account using the AccountService
var newAccount = await accounts.CreateAccount(userInfo);
// Create the provider connection
var newConnection = new AccountConnection
{
AccountId = newAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
db.AccountConnections.Add(newConnection);
await db.SaveChangesAsync();
return newAccount;
}
}

View File

@@ -0,0 +1,294 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary>
/// Base service for OpenID Connect authentication providers
/// </summary>
public abstract class OidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
{
protected readonly IConfiguration Configuration = configuration;
protected readonly IHttpClientFactory HttpClientFactory = httpClientFactory;
protected readonly AppDatabase Db = 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["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
};
}
/// <summary>
/// Retrieves the OpenID Connect discovery document
/// </summary>
protected virtual async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
// Construct a cache key unique to the current provider:
var cacheKey = $"oidc-discovery:{ProviderName}";
// Try getting the discovery document from cache first:
var (found, cachedDoc) = await cache.GetAsyncWithStatus<OidcDiscoveryDocument>(cacheKey);
if (found && cachedDoc != null)
{
return cachedDoc;
}
// If it's not cached, fetch from the actual discovery endpoint:
var client = HttpClientFactory.CreateClient();
var response = await client.GetAsync(DiscoveryEndpoint);
response.EnsureSuccessStatusCode();
var doc = await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>();
// Store the discovery document in the cache for a while (e.g., 15 minutes):
if (doc is not null)
await cache.SetAsync(cacheKey, doc, TimeSpan.FromMinutes(15));
return doc;
}
/// <summary>
/// Exchange the authorization code for tokens
/// </summary>
protected virtual 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<Challenge> CreateChallengeForUserAsync(
OidcUserInfo userInfo,
Account.Account account,
HttpContext request,
string deviceId
)
{
// 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 = 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 = await auth.DetectChallengeRisk(request.Request, account),
Type = ChallengeType.Oidc,
Platform = ChallengePlatform.Unidentified,
Audiences = [ProviderName],
Scopes = ["*"],
AccountId = account.Id,
DeviceId = deviceId,
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
UserAgent = request.Request.Headers.UserAgent,
};
challenge.StepRemain--;
if (challenge.StepRemain < 0) challenge.StepRemain = 0;
await Db.AuthChallenges.AddAsync(challenge);
await Db.SaveChangesAsync();
return challenge;
}
}
/// <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? RawData { get; set; }
}

View File

@@ -0,0 +1,189 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DysonNetwork.Pass.Auth.OpenId;
/// <summary>
/// Represents the state parameter used in OpenID Connect flows.
/// Handles serialization and deserialization of the state parameter.
/// </summary>
public class OidcState
{
/// <summary>
/// The type of OIDC flow (login or connect).
/// </summary>
public OidcFlowType FlowType { get; set; }
/// <summary>
/// The account ID (for connect flow).
/// </summary>
public Guid? AccountId { get; set; }
/// <summary>
/// The OIDC provider name.
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// The nonce for CSRF protection.
/// </summary>
public string? Nonce { get; set; }
/// <summary>
/// The device ID for the authentication request.
/// </summary>
public string? DeviceId { get; set; }
/// <summary>
/// The return URL after authentication (for login flow).
/// </summary>
public string? ReturnUrl { get; set; }
/// <summary>
/// Creates a new OidcState for a connection flow.
/// </summary>
public static OidcState ForConnection(Guid accountId, string provider, string nonce, string? deviceId = null)
{
return new OidcState
{
FlowType = OidcFlowType.Connect,
AccountId = accountId,
Provider = provider,
Nonce = nonce,
DeviceId = deviceId
};
}
/// <summary>
/// Creates a new OidcState for a login flow.
/// </summary>
public static OidcState ForLogin(string returnUrl = "/", string? deviceId = null)
{
return new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = returnUrl,
DeviceId = deviceId
};
}
/// <summary>
/// The version of the state format.
/// </summary>
public int Version { get; set; } = 1;
/// <summary>
/// Serializes the state to a JSON string for use in OIDC flows.
/// </summary>
public string Serialize()
{
return JsonSerializer.Serialize(this, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
}
/// <summary>
/// Attempts to parse a state string into an OidcState object.
/// </summary>
public static bool TryParse(string? stateString, out OidcState? state)
{
state = null;
if (string.IsNullOrEmpty(stateString))
return false;
try
{
// First try to parse as JSON
try
{
state = JsonSerializer.Deserialize<OidcState>(stateString);
return state != null;
}
catch (JsonException)
{
// Not a JSON string, try legacy format for backward compatibility
return TryParseLegacyFormat(stateString, out state);
}
}
catch
{
return false;
}
}
private static bool TryParseLegacyFormat(string stateString, out OidcState? state)
{
state = null;
var parts = stateString.Split('|');
// Check for connection flow format: {accountId}|{provider}|{nonce}|{deviceId}|connect
if (parts.Length >= 5 &&
Guid.TryParse(parts[0], out var accountId) &&
string.Equals(parts[^1], "connect", StringComparison.OrdinalIgnoreCase))
{
state = new OidcState
{
FlowType = OidcFlowType.Connect,
AccountId = accountId,
Provider = parts[1],
Nonce = parts[2],
DeviceId = parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]) ? parts[3] : null
};
return true;
}
// Check for login flow format: {returnUrl}|{deviceId}|login
if (parts.Length >= 2 &&
parts.Length <= 3 &&
(parts.Length < 3 || string.Equals(parts[^1], "login", StringComparison.OrdinalIgnoreCase)))
{
state = new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = parts[0],
DeviceId = parts.Length >= 2 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : null
};
return true;
}
// Legacy format support (for backward compatibility)
if (parts.Length == 1)
{
state = new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = parts[0],
DeviceId = null
};
return true;
}
return false;
}
}
/// <summary>
/// Represents the type of OIDC flow.
/// </summary>
public enum OidcFlowType
{
/// <summary>
/// Login or registration flow.
/// </summary>
Login,
/// <summary>
/// Account connection flow.
/// </summary>
Connect
}

View File

@@ -0,0 +1,49 @@
namespace DysonNetwork.Pass.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; }
public Dictionary<string, object> ToMetadata()
{
var metadata = new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(UserId))
metadata["user_id"] = UserId;
if (!string.IsNullOrWhiteSpace(Email))
metadata["email"] = Email;
metadata["email_verified"] = EmailVerified;
if (!string.IsNullOrWhiteSpace(FirstName))
metadata["first_name"] = FirstName;
if (!string.IsNullOrWhiteSpace(LastName))
metadata["last_name"] = LastName;
if (!string.IsNullOrWhiteSpace(DisplayName))
metadata["display_name"] = DisplayName;
if (!string.IsNullOrWhiteSpace(PreferredUsername))
metadata["preferred_username"] = PreferredUsername;
if (!string.IsNullOrWhiteSpace(ProfilePictureUrl))
metadata["profile_picture_url"] = ProfilePictureUrl;
return metadata;
}
}