Bring OIDC back

This commit is contained in:
2025-08-25 02:44:44 +08:00
parent 4684550ebf
commit 8c748fd57a
7 changed files with 583 additions and 78 deletions

View File

@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json.Serialization;
using System.Web;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
@@ -19,10 +21,199 @@ public class OidcProviderController(
AppDatabase db,
OidcProviderService oidcService,
IConfiguration configuration,
IOptions<OidcProviderOptions> options
)
: ControllerBase
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderController> logger
) : ControllerBase
{
[HttpGet("authorize")]
[Produces("application/json")]
public async Task<IActionResult> Authorize(
[FromQuery(Name = "client_id")] string clientId,
[FromQuery(Name = "response_type")] string responseType,
[FromQuery(Name = "redirect_uri")] string? redirectUri = null,
[FromQuery] string? scope = null,
[FromQuery] string? state = null,
[FromQuery(Name = "response_mode")] string? responseMode = null,
[FromQuery] string? nonce = null,
[FromQuery] string? display = null,
[FromQuery] string? prompt = null,
[FromQuery(Name = "code_challenge")] string? codeChallenge = null,
[FromQuery(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (string.IsNullOrEmpty(clientId))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "client_id is required"
});
}
var client = await oidcService.FindClientBySlugAsync(clientId);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "unauthorized_client",
ErrorDescription = "Client not found"
});
}
// Validate response_type
if (string.IsNullOrEmpty(responseType))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "response_type is required"
});
}
// Check if the client is allowed to use the requested response type
var allowedResponseTypes = new[] { "code", "token", "id_token" };
var requestedResponseTypes = responseType.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (requestedResponseTypes.Any(rt => !allowedResponseTypes.Contains(rt)))
{
return BadRequest(new ErrorResponse
{
Error = "unsupported_response_type",
ErrorDescription = "The requested response type is not supported"
});
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirectUri) &&
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client.Id), redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
// Return client information
var clientInfo = new ClientInfoResponse
{
ClientId = Guid.Parse(client.Id),
Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null,
Background = client.Background is not null
? CloudFileReferenceObject.FromProtoValue(client.Background)
: null,
ClientName = client.Name,
HomeUri = client.Links.HomePage,
PolicyUri = client.Links.PrivacyPolicy,
TermsOfServiceUri = client.Links.TermsOfService,
ResponseTypes = responseType,
Scopes = scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
State = state,
Nonce = nonce,
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod
};
return Ok(clientInfo);
}
[HttpPost("authorize")]
[Consumes("application/x-www-form-urlencoded")]
[Authorize]
public async Task<IActionResult> HandleAuthorizationResponse(
[FromForm(Name = "authorize")] string? authorize,
[FromForm(Name = "client_id")] string clientId,
[FromForm(Name = "redirect_uri")] string? redirectUri = null,
[FromForm] string? scope = null,
[FromForm] string? state = null,
[FromForm(Name = "response_type")] string? responseType = null,
[FromForm] string? nonce = null,
[FromForm(Name = "code_challenge")] string? codeChallenge = null,
[FromForm(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account account)
return Unauthorized();
// Find the client
var client = await oidcService.FindClientBySlugAsync(clientId);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "unauthorized_client",
ErrorDescription = "Client not found"
});
}
// If user denied the request
if (string.IsNullOrEmpty(authorize) || !bool.TryParse(authorize, out var isAuthorized) || !isAuthorized)
{
var errorUri = new UriBuilder(redirectUri ?? client.Links?.HomePage ?? "https://example.com");
var queryParams = HttpUtility.ParseQueryString(errorUri.Query);
queryParams["error"] = "access_denied";
queryParams["error_description"] = "The user denied the authorization request";
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
errorUri.Query = queryParams.ToString();
return Redirect(errorUri.Uri.ToString());
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirectUri) &&
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client!.Id), redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
// Default to client's first redirect URI if not provided
redirectUri ??= client.OauthConfig?.RedirectUris?.FirstOrDefault();
if (string.IsNullOrEmpty(redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "No valid redirect_uri available"
});
}
try
{
// Generate authorization code and create session
var authorizationCode = await oidcService.GenerateAuthorizationCodeAsync(
Guid.Parse(client.Id),
account.Id,
redirectUri,
scope?.Split(' ') ?? [],
codeChallenge,
codeChallengeMethod,
nonce);
// Build the redirect URI with the authorization code
var redirectBuilder = new UriBuilder(redirectUri);
var queryParams = HttpUtility.ParseQueryString(redirectBuilder.Query);
queryParams["code"] = authorizationCode;
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
redirectBuilder.Query = queryParams.ToString();
return Redirect(redirectBuilder.Uri.ToString());
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing authorization request");
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse
{
Error = "server_error",
ErrorDescription = "An error occurred while processing your request"
});
}
}
[HttpPost("token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token([FromForm] TokenRequest request)
@@ -35,74 +226,74 @@ public class OidcProviderController(
case "authorization_code" when request.Code == null:
return BadRequest("Authorization code is required");
case "authorization_code":
{
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
return BadRequest(new ErrorResponse
{
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
return BadRequest(new ErrorResponse
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: request.ClientId.Value,
authorizationCode: request.Code!,
redirectUri: request.RedirectUri,
codeVerifier: request.CodeVerifier
);
// Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: Guid.Parse(client.Id),
authorizationCode: request.Code!,
redirectUri: request.RedirectUri,
codeVerifier: request.CodeVerifier
);
return Ok(tokenResponse);
}
return Ok(tokenResponse);
}
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
return BadRequest(new ErrorResponse
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
case "refresh_token":
{
try
{
try
{
// Decode the base64 refresh token to get the session ID
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
var sessionId = new Guid(sessionIdBytes);
// Decode the base64 refresh token to get the session ID
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
var sessionId = new Guid(sessionIdBytes);
// Find the session and related data
var session = await oidcService.FindSessionByIdAsync(sessionId);
var now = SystemClock.Instance.GetCurrentInstant();
if (session?.AppId is null || session.ExpiredAt < now)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid or expired refresh token"
});
}
// Get the client
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_client",
ErrorDescription = "Client not found"
});
}
// Generate new tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: session.AppId!.Value,
sessionId: session.Id
);
return Ok(tokenResponse);
}
catch (FormatException)
// Find the session and related data
var session = await oidcService.FindSessionByIdAsync(sessionId);
var now = SystemClock.Instance.GetCurrentInstant();
if (session?.AppId is null || session.ExpiredAt < now)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid refresh token format"
ErrorDescription = "Invalid or expired refresh token"
});
}
// Get the client
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_client",
ErrorDescription = "Client not found"
});
}
// Generate new tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: session.AppId!.Value,
sessionId: session.Id
);
return Ok(tokenResponse);
}
catch (FormatException)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid refresh token format"
});
}
}
default:
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
}
@@ -150,10 +341,10 @@ public class OidcProviderController(
return Ok(new
{
issuer = issuer,
issuer,
authorization_endpoint = $"{baseUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/auth/open/token",
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
token_endpoint = $"{baseUrl}/api/auth/open/token",
userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[]
@@ -220,7 +411,7 @@ public class TokenRequest
[JsonPropertyName("client_id")]
[FromForm(Name = "client_id")]
public Guid? ClientId { get; set; }
public string? ClientId { get; set; }
[JsonPropertyName("client_secret")]
[FromForm(Name = "client_secret")]
@@ -237,4 +428,4 @@ public class TokenRequest
[JsonPropertyName("code_verifier")]
[FromForm(Name = "code_verifier")]
public string? CodeVerifier { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class ClientInfoResponse
{
public Guid ClientId { get; set; }
public CloudFileReferenceObject? Picture { get; set; }
public CloudFileReferenceObject? Background { get; set; }
public string? ClientName { get; set; }
public string? HomeUri { get; set; }
public string? PolicyUri { get; set; }
public string? TermsOfServiceUri { get; set; }
public string? ResponseTypes { get; set; }
public string[]? Scopes { get; set; }
public string? State { get; set; }
public string? Nonce { get; set; }
public string? CodeChallenge { get; set; }
public string? CodeChallengeMethod { get; set; }
}

View File

@@ -31,6 +31,12 @@ public class OidcProviderService(
return resp.App ?? null;
}
public async Task<CustomApp?> FindClientBySlugAsync(string slug)
{
var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug });
return resp.App ?? null;
}
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
{
var now = SystemClock.Instance.GetCurrentInstant();
@@ -40,6 +46,7 @@ public class OidcProviderService(
.Where(s => s.AccountId == accountId &&
s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge != null &&
s.Challenge.Type == ChallengeType.OAuth)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
@@ -56,6 +63,76 @@ public class OidcProviderService(
return resp.Valid;
}
public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri)
{
if (string.IsNullOrEmpty(redirectUri))
return false;
var client = await FindClientByIdAsync(clientId);
if (client?.Status != CustomAppStatus.Production)
return true;
if (client?.OauthConfig?.RedirectUris == null)
return false;
// Check if the redirect URI matches any of the allowed URIs
// For exact match
if (client.OauthConfig.RedirectUris.Contains(redirectUri))
return true;
// Check for wildcard matches (e.g., https://*.example.com/*)
foreach (var allowedUri in client.OauthConfig.RedirectUris)
{
if (string.IsNullOrEmpty(allowedUri))
continue;
// Handle wildcard in domain
if (allowedUri.Contains("*.") && allowedUri.StartsWith("http"))
{
try
{
var allowedUriObj = new Uri(allowedUri);
var redirectUriObj = new Uri(redirectUri);
if (allowedUriObj.Scheme != redirectUriObj.Scheme ||
allowedUriObj.Port != redirectUriObj.Port)
{
continue;
}
// Check if the domain matches the wildcard pattern
var allowedDomain = allowedUriObj.Host;
var redirectDomain = redirectUriObj.Host;
if (allowedDomain.StartsWith("*."))
{
var baseDomain = allowedDomain[2..]; // Remove the "*." prefix
if (redirectDomain == baseDomain || redirectDomain.EndsWith($".{baseDomain}"))
{
// Check path
var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
if (string.IsNullOrEmpty(allowedPath) ||
redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
}
catch (UriFormatException)
{
// Invalid URI format in allowed URIs, skip
continue;
}
}
}
return false;
}
public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId,
string? authorizationCode = null,