From 8c748fd57a51d08447631a711c03673171580d2f Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 25 Aug 2025 02:44:44 +0800 Subject: [PATCH] :sparkles: Bring OIDC back --- DysonNetwork.Develop/Identity/CustomApp.cs | 39 ++- .../Controllers/OidcProviderController.cs | 313 ++++++++++++++---- .../Responses/ClientInfoResponse.cs | 21 ++ .../Services/OidcProviderService.cs | 77 +++++ DysonNetwork.Pass/Client/src/router/index.ts | 6 + .../Client/src/views/authorize.vue | 191 +++++++++++ DysonNetwork.Shared/Proto/develop.proto | 14 +- 7 files changed, 583 insertions(+), 78 deletions(-) create mode 100644 DysonNetwork.Pass/Auth/OidcProvider/Responses/ClientInfoResponse.cs diff --git a/DysonNetwork.Develop/Identity/CustomApp.cs b/DysonNetwork.Develop/Identity/CustomApp.cs index ae32340..444dcf8 100644 --- a/DysonNetwork.Develop/Identity/CustomApp.cs +++ b/DysonNetwork.Develop/Identity/CustomApp.cs @@ -32,7 +32,7 @@ public class CustomApp : ModelBase, IIdentifiedResource [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { 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 CustomAppLinks? Links { get; set; } @@ -62,17 +62,22 @@ public class CustomApp : ModelBase, IIdentifiedResource CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended, _ => Shared.Proto.CustomAppStatus.Unspecified }, - Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)), - Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)), - Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)), - Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)), + Picture = Picture?.ToProtoValue(), + Background = Background?.ToProtoValue(), + Verification = Verification?.ToProtoValue(), + 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 { ClientUri = OauthConfig.ClientUri ?? string.Empty, - RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty() }, - PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty() }, - AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty() }, - AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty() }, + RedirectUris = { OauthConfig.RedirectUris ?? [] }, + PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] }, + AllowedScopes = { OauthConfig.AllowedScopes ?? [] }, + AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] }, RequirePkce = OauthConfig.RequirePkce, AllowOfflineAccess = OauthConfig.AllowOfflineAccess }, @@ -99,10 +104,18 @@ public class CustomApp : ModelBase, IIdentifiedResource ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId); CreatedAt = p.CreatedAt.ToInstant(); UpdatedAt = p.UpdatedAt.ToInstant(); - if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize(p.Picture.ToStringUtf8()); - if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize(p.Background.ToStringUtf8()); - if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize(p.Verification.ToStringUtf8()); - if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize(p.Links.ToStringUtf8()); + if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture); + if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background); + if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification); + 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; } } diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs b/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs index 18eb626..0377e44 100644 --- a/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs +++ b/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs @@ -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 options -) - : ControllerBase + IOptions options, + ILogger logger +) : ControllerBase { + [HttpGet("authorize")] + [Produces("application/json")] + public async Task 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 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 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; } -} +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Responses/ClientInfoResponse.cs b/DysonNetwork.Pass/Auth/OidcProvider/Responses/ClientInfoResponse.cs new file mode 100644 index 0000000..b8db785 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Responses/ClientInfoResponse.cs @@ -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; } +} diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs b/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs index f135968..aa417b2 100644 --- a/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs +++ b/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs @@ -31,6 +31,12 @@ public class OidcProviderService( return resp.App ?? null; } + public async Task FindClientBySlugAsync(string slug) + { + var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug }); + return resp.App ?? null; + } + public async Task 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 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 GenerateTokenResponseAsync( Guid clientId, string? authorizationCode = null, diff --git a/DysonNetwork.Pass/Client/src/router/index.ts b/DysonNetwork.Pass/Client/src/router/index.ts index 62d10fb..9d6418b 100644 --- a/DysonNetwork.Pass/Client/src/router/index.ts +++ b/DysonNetwork.Pass/Client/src/router/index.ts @@ -60,6 +60,12 @@ const router = createRouter({ name: 'authCallback', component: () => import('../views/callback.vue'), }, + { + path: '/auth/authorize', + name: 'authAuthorize', + component: () => import('../views/authorize.vue'), + meta: { requiresAuth: true }, + }, { path: '/:notFound(.*)', name: 'errorNotFound', diff --git a/DysonNetwork.Pass/Client/src/views/authorize.vue b/DysonNetwork.Pass/Client/src/views/authorize.vue index e69de29..170566c 100644 --- a/DysonNetwork.Pass/Client/src/views/authorize.vue +++ b/DysonNetwork.Pass/Client/src/views/authorize.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/DysonNetwork.Shared/Proto/develop.proto b/DysonNetwork.Shared/Proto/develop.proto index 11e5e40..834b16c 100644 --- a/DysonNetwork.Shared/Proto/develop.proto +++ b/DysonNetwork.Shared/Proto/develop.proto @@ -19,6 +19,12 @@ message CustomAppOauthConfig { bool allow_offline_access = 7; } +message CustomAppLinks { + string home_page = 1; + string privacy_policy = 2; + string terms_of_service = 3; +} + enum CustomAppStatus { CUSTOM_APP_STATUS_UNSPECIFIED = 0; DEVELOPING = 1; @@ -35,10 +41,10 @@ message CustomApp { CustomAppStatus status = 5; // jsonb columns represented as bytes - bytes picture = 6; - bytes background = 7; - bytes verification = 8; - bytes links = 9; + CloudFile picture = 6; + CloudFile background = 7; + VerificationMark verification = 8; + CustomAppLinks links = 9; CustomAppOauthConfig oauth_config = 13; string project_id = 10;