✨ Bring OIDC back
This commit is contained in:
@@ -32,7 +32,7 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
|||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")] public DysonNetwork.Shared.Data.VerificationMark? Verification { get; set; }
|
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
|
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
||||||
|
|
||||||
@@ -62,17 +62,22 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
|||||||
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
|
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
|
||||||
_ => Shared.Proto.CustomAppStatus.Unspecified
|
_ => Shared.Proto.CustomAppStatus.Unspecified
|
||||||
},
|
},
|
||||||
Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)),
|
Picture = Picture?.ToProtoValue(),
|
||||||
Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)),
|
Background = Background?.ToProtoValue(),
|
||||||
Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)),
|
Verification = Verification?.ToProtoValue(),
|
||||||
Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)),
|
Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
|
||||||
|
{
|
||||||
|
HomePage = Links.HomePage ?? string.Empty,
|
||||||
|
PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
|
||||||
|
TermsOfService = Links.TermsOfService ?? string.Empty
|
||||||
|
},
|
||||||
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
|
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
|
||||||
{
|
{
|
||||||
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
||||||
RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() },
|
RedirectUris = { OauthConfig.RedirectUris ?? [] },
|
||||||
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() },
|
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
|
||||||
AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() },
|
AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
|
||||||
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() },
|
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
|
||||||
RequirePkce = OauthConfig.RequirePkce,
|
RequirePkce = OauthConfig.RequirePkce,
|
||||||
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
||||||
},
|
},
|
||||||
@@ -99,10 +104,18 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
|||||||
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
|
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
|
||||||
CreatedAt = p.CreatedAt.ToInstant();
|
CreatedAt = p.CreatedAt.ToInstant();
|
||||||
UpdatedAt = p.UpdatedAt.ToInstant();
|
UpdatedAt = p.UpdatedAt.ToInstant();
|
||||||
if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8());
|
if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||||
if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8());
|
if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
|
||||||
if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8());
|
if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
|
||||||
if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8());
|
if (p.Links is not null)
|
||||||
|
{
|
||||||
|
Links = new CustomAppLinks
|
||||||
|
{
|
||||||
|
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
|
||||||
|
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
|
||||||
|
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
|
||||||
|
};
|
||||||
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Web;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -19,10 +21,199 @@ public class OidcProviderController(
|
|||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
OidcProviderService oidcService,
|
OidcProviderService oidcService,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IOptions<OidcProviderOptions> options
|
IOptions<OidcProviderOptions> options,
|
||||||
)
|
ILogger<OidcProviderController> logger
|
||||||
: ControllerBase
|
) : 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")]
|
[HttpPost("token")]
|
||||||
[Consumes("application/x-www-form-urlencoded")]
|
[Consumes("application/x-www-form-urlencoded")]
|
||||||
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||||
@@ -36,15 +227,15 @@ public class OidcProviderController(
|
|||||||
return BadRequest("Authorization code is required");
|
return BadRequest("Authorization code is required");
|
||||||
case "authorization_code":
|
case "authorization_code":
|
||||||
{
|
{
|
||||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
|
||||||
if (client == null ||
|
if (client == null ||
|
||||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
|
||||||
return BadRequest(new ErrorResponse
|
return BadRequest(new ErrorResponse
|
||||||
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
clientId: request.ClientId.Value,
|
clientId: Guid.Parse(client.Id),
|
||||||
authorizationCode: request.Code!,
|
authorizationCode: request.Code!,
|
||||||
redirectUri: request.RedirectUri,
|
redirectUri: request.RedirectUri,
|
||||||
codeVerifier: request.CodeVerifier
|
codeVerifier: request.CodeVerifier
|
||||||
@@ -150,10 +341,10 @@ public class OidcProviderController(
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
issuer = issuer,
|
issuer,
|
||||||
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||||
token_endpoint = $"{baseUrl}/auth/open/token",
|
token_endpoint = $"{baseUrl}/api/auth/open/token",
|
||||||
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
|
userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
|
||||||
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||||
scopes_supported = new[] { "openid", "profile", "email" },
|
scopes_supported = new[] { "openid", "profile", "email" },
|
||||||
response_types_supported = new[]
|
response_types_supported = new[]
|
||||||
@@ -220,7 +411,7 @@ public class TokenRequest
|
|||||||
|
|
||||||
[JsonPropertyName("client_id")]
|
[JsonPropertyName("client_id")]
|
||||||
[FromForm(Name = "client_id")]
|
[FromForm(Name = "client_id")]
|
||||||
public Guid? ClientId { get; set; }
|
public string? ClientId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("client_secret")]
|
[JsonPropertyName("client_secret")]
|
||||||
[FromForm(Name = "client_secret")]
|
[FromForm(Name = "client_secret")]
|
||||||
|
@@ -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; }
|
||||||
|
}
|
@@ -31,6 +31,12 @@ public class OidcProviderService(
|
|||||||
return resp.App ?? null;
|
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)
|
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
|
||||||
{
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
@@ -40,6 +46,7 @@ public class OidcProviderService(
|
|||||||
.Where(s => s.AccountId == accountId &&
|
.Where(s => s.AccountId == accountId &&
|
||||||
s.AppId == clientId &&
|
s.AppId == clientId &&
|
||||||
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||||
|
s.Challenge != null &&
|
||||||
s.Challenge.Type == ChallengeType.OAuth)
|
s.Challenge.Type == ChallengeType.OAuth)
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
@@ -56,6 +63,76 @@ public class OidcProviderService(
|
|||||||
return resp.Valid;
|
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(
|
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
string? authorizationCode = null,
|
string? authorizationCode = null,
|
||||||
|
@@ -60,6 +60,12 @@ const router = createRouter({
|
|||||||
name: 'authCallback',
|
name: 'authCallback',
|
||||||
component: () => import('../views/callback.vue'),
|
component: () => import('../views/callback.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/authorize',
|
||||||
|
name: 'authAuthorize',
|
||||||
|
component: () => import('../views/authorize.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:notFound(.*)',
|
path: '/:notFound(.*)',
|
||||||
name: 'errorNotFound',
|
name: 'errorNotFound',
|
||||||
|
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center h-full p-4">
|
||||||
|
<n-card class="w-full max-w-md" title="Authorize Application">
|
||||||
|
<n-spin :show="isLoading">
|
||||||
|
<div v-if="error" class="mb-4">
|
||||||
|
<n-alert type="error" :title="error" closable @close="error = null" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App Info Section -->
|
||||||
|
<div v-if="clientInfo" class="mb-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<n-avatar
|
||||||
|
v-if="clientInfo.picture"
|
||||||
|
:src="clientInfo.picture.url"
|
||||||
|
:alt="clientInfo.client_name"
|
||||||
|
size="large"
|
||||||
|
class="mr-3"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
{{ clientInfo.client_name || 'Unknown Application' }}
|
||||||
|
</h2>
|
||||||
|
<span v-if="isNewApp">wants to access your Solar Network account</span>
|
||||||
|
<span v-else>wants to access your account</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requested Permissions -->
|
||||||
|
<n-card size="small" class="mt-4">
|
||||||
|
<h3 class="font-medium mb-2">
|
||||||
|
This will allow {{ clientInfo.client_name || 'the app' }} to:
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li v-for="scope in requestedScopes" :key="scope" class="flex items-start">
|
||||||
|
<n-icon :component="CheckBoxFilled" class="mt-1 mr-2" />
|
||||||
|
<span>{{ scope }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div class="flex gap-3 mt-4">
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:loading="isAuthorizing"
|
||||||
|
@click="handleAuthorize"
|
||||||
|
class="flex-grow-1 w-1/2"
|
||||||
|
>
|
||||||
|
Authorize
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
type="tertiary"
|
||||||
|
:disabled="isAuthorizing"
|
||||||
|
@click="handleDeny"
|
||||||
|
class="flex-grow-1 w-1/2"
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-xs text-gray-500 text-center">
|
||||||
|
By authorizing, you agree to the
|
||||||
|
<n-button text type="primary" size="tiny" @click="openTerms" class="px-1">
|
||||||
|
Terms of Service
|
||||||
|
</n-button>
|
||||||
|
and
|
||||||
|
<n-button text type="primary" size="tiny" @click="openPrivacy" class="px-1">
|
||||||
|
Privacy Policy
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { NCard, NButton, NSpin, NAlert, NAvatar, NIcon } from 'naive-ui'
|
||||||
|
import { CheckBoxFilled } from '@vicons/material'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const isAuthorizing = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const clientInfo = ref<{
|
||||||
|
client_name?: string
|
||||||
|
home_uri?: string
|
||||||
|
picture?: { url: string }
|
||||||
|
terms_of_service_uri?: string
|
||||||
|
privacy_policy_uri?: string
|
||||||
|
scopes?: string[]
|
||||||
|
} | null>(null)
|
||||||
|
const isNewApp = ref(false)
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const requestedScopes = computed(() => {
|
||||||
|
return clientInfo.value?.scopes || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
async function fetchClientInfo() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/auth/open/authorize?${window.location.search.slice(1)}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error_description || 'Failed to load authorization request')
|
||||||
|
}
|
||||||
|
clientInfo.value = await response.json()
|
||||||
|
checkIfNewApp()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'An error occurred while loading the authorization request'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIfNewApp() {
|
||||||
|
// In a real app, you might want to check if this is the first time authorizing this app
|
||||||
|
// For now, we'll just set it to false
|
||||||
|
isNewApp.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthorize() {
|
||||||
|
isAuthorizing.value = true
|
||||||
|
try {
|
||||||
|
// In a real implementation, you would submit the authorization
|
||||||
|
const response = await fetch('/api/auth/open/authorize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
...route.query,
|
||||||
|
authorize: 'true',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error_description || 'Authorization failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.redirect_uri) {
|
||||||
|
window.open(data.redirect_uri, '_self')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'An error occurred during authorization'
|
||||||
|
} finally {
|
||||||
|
isAuthorizing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeny() {
|
||||||
|
// Redirect back to the client with an error
|
||||||
|
// Ensure redirect_uri is always a string (not an array)
|
||||||
|
const redirectUriStr = Array.isArray(route.query.redirect_uri)
|
||||||
|
? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/'
|
||||||
|
: route.query.redirect_uri || clientInfo.value?.home_uri || '/'
|
||||||
|
const redirectUri = new URL(redirectUriStr)
|
||||||
|
// Ensure state is always a string (not an array)
|
||||||
|
const state = Array.isArray(route.query.state)
|
||||||
|
? route.query.state[0] || ''
|
||||||
|
: route.query.state || ''
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
error: 'access_denied',
|
||||||
|
error_description: 'The user denied the authorization request',
|
||||||
|
state: state,
|
||||||
|
})
|
||||||
|
window.location.href = `${redirectUri}?${params}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTerms() {
|
||||||
|
window.open(clientInfo.value?.terms_of_service_uri || 'https://example.com/terms', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPrivacy() {
|
||||||
|
window.open(clientInfo.value?.privacy_policy_uri || 'https://example.com/privacy', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchClientInfo()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Add any custom styles here */
|
||||||
|
</style>
|
||||||
|
@@ -19,6 +19,12 @@ message CustomAppOauthConfig {
|
|||||||
bool allow_offline_access = 7;
|
bool allow_offline_access = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CustomAppLinks {
|
||||||
|
string home_page = 1;
|
||||||
|
string privacy_policy = 2;
|
||||||
|
string terms_of_service = 3;
|
||||||
|
}
|
||||||
|
|
||||||
enum CustomAppStatus {
|
enum CustomAppStatus {
|
||||||
CUSTOM_APP_STATUS_UNSPECIFIED = 0;
|
CUSTOM_APP_STATUS_UNSPECIFIED = 0;
|
||||||
DEVELOPING = 1;
|
DEVELOPING = 1;
|
||||||
@@ -35,10 +41,10 @@ message CustomApp {
|
|||||||
CustomAppStatus status = 5;
|
CustomAppStatus status = 5;
|
||||||
|
|
||||||
// jsonb columns represented as bytes
|
// jsonb columns represented as bytes
|
||||||
bytes picture = 6;
|
CloudFile picture = 6;
|
||||||
bytes background = 7;
|
CloudFile background = 7;
|
||||||
bytes verification = 8;
|
VerificationMark verification = 8;
|
||||||
bytes links = 9;
|
CustomAppLinks links = 9;
|
||||||
CustomAppOauthConfig oauth_config = 13;
|
CustomAppOauthConfig oauth_config = 13;
|
||||||
|
|
||||||
string project_id = 10;
|
string project_id = 10;
|
||||||
|
Reference in New Issue
Block a user