diff --git a/AZURE_AD_BLAZOR_WASM_AUTH_PLAN.md b/AZURE_AD_BLAZOR_WASM_AUTH_PLAN.md new file mode 100644 index 000000000..0f1f4ac1d --- /dev/null +++ b/AZURE_AD_BLAZOR_WASM_AUTH_PLAN.md @@ -0,0 +1,538 @@ +# Azure AD Authentication for Blazor WASM - Implementation Plan + +## Problem Statement + +The current Blazor WASM OpenID Connect authentication implementation encounters multiple issues when using Azure AD (Microsoft Entra ID) with API scopes: + +### Issues Identified + +1. **Missing scope in token exchange**: Azure AD requires the `scope` parameter in both authorization and token endpoint requests, but Blazor WASM's authentication framework only sends scopes during authorization. + +2. **Multi-resource limitation**: Azure AD v2.0 only allows requesting scopes for ONE resource per token request. Requesting both Microsoft Graph (`https://graph.microsoft.com/User.Read`) and a custom API (`api://xxx/scope`) results in error AADSTS28000. + +3. **UserInfo endpoint mismatch**: Azure AD's userinfo endpoint is at `graph.microsoft.com` which requires a Graph API token, but the access token received is for the custom API, causing "Invalid audience" errors. + +4. **Authentication state not persisted**: Even when token exchange succeeds, the authentication state isn't stored in sessionStorage, causing users to be redirected to `/authentication/login-failed`. + +5. **Framework limitations**: Microsoft's `Microsoft.AspNetCore.Components.WebAssembly.Authentication` library doesn't expose configuration options to: + - Add parameters to token endpoint requests + - Disable userinfo endpoint calls + - Configure multi-resource token acquisition + +## Current Workarounds (Fragile) + +The current implementation uses JavaScript interception (`auth-interop.js`) to: +- Intercept `XMLHttpRequest.send()` to add scope parameters to token requests +- Intercept userinfo requests to prevent invalid audience errors + +**Problems with this approach:** +- Complex and fragile +- Difficult to maintain +- Doesn't solve the root issue of authentication state not being persisted +- May break with framework updates + +## Proposed Solutions + +### Option 1: Custom RemoteAuthenticationService (Recommended) + +**Goal**: Implement a custom authentication service that properly handles Azure AD's requirements. + +**Implementation Steps:** + +1. **Create custom OIDC client wrapper** + - File: `src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Services/AzureAdAuthenticationService.cs` + - Inherit from `RemoteAuthenticationService` + - Override token acquisition methods to inject scope parameter + - Handle multi-resource token acquisition + +2. **Implement custom token endpoint handler** + ```csharp + public class AzureAdTokenEndpointHandler : DelegatingHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // Intercept token endpoint requests + if (request.RequestUri.PathAndQuery.Contains("/token")) + { + // Add scope parameter to request body + var content = await request.Content.ReadAsStringAsync(); + if (!content.Contains("scope=")) + { + var scopes = // get from configuration + content += $"&scope={Uri.EscapeDataString(scopes)}"; + request.Content = new StringContent(content, + Encoding.UTF8, + "application/x-www-form-urlencoded"); + } + } + return await base.SendAsync(request, cancellationToken); + } + } + ``` + +3. **Create Azure AD-specific options** + ```csharp + public class AzureAdOptions : OidcOptions + { + /// + /// The primary resource for initial authentication (your API). + /// + public string PrimaryResource { get; set; } = string.Empty; + + /// + /// Additional resources that may be accessed (e.g., MS Graph). + /// Tokens for these will be acquired on-demand. + /// + public List AdditionalResources { get; set; } = new(); + + /// + /// Whether to skip userinfo endpoint call. + /// Set to true for Azure AD when using API scopes. + /// + public bool SkipUserInfo { get; set; } = true; + } + ``` + +4. **Implement on-demand token acquisition** + ```csharp + public interface IAzureAdTokenService + { + /// + /// Get access token for the primary resource (from initial auth). + /// + Task GetPrimaryAccessTokenAsync(); + + /// + /// Acquire token for additional resource using token exchange. + /// + Task GetAccessTokenForResourceAsync(string resource); + } + ``` + +5. **Configure metadata to skip userinfo** + - Override metadata retrieval to remove userinfo_endpoint + - Or implement custom `IAccountClaimsPrincipalFactory` that doesn't call userinfo + +6. **Update service registration** + ```csharp + services.AddElsaAzureAdAuthentication(options => + { + options.Authority = "https://login.microsoftonline.com/{tenant}/v2.0"; + options.ClientId = "{client-id}"; + options.PrimaryResource = "api://{api-id}/api-scope"; + options.AdditionalResources = new List + { + "https://graph.microsoft.com/.default" + }; + options.SkipUserInfo = true; + }); + ``` + +**Pros:** +- Clean, maintainable solution +- Follows framework patterns +- Can handle multi-resource scenarios +- No JavaScript hacks + +**Cons:** +- Significant development effort +- Requires deep understanding of RemoteAuthenticationService internals +- May need to replicate some framework functionality + +**Estimated Effort**: 3-5 days + +--- + +### Option 2: Backend-for-Frontend (BFF) Pattern + +**Goal**: Move authentication concerns to a backend API that handles token acquisition. + +**Architecture:** +``` +Browser (Blazor WASM) + ↓ cookie-based auth +Backend API (BFF) + ↓ OAuth/OIDC with Azure AD +Azure AD + Your API + MS Graph +``` + +**Implementation Steps:** + +1. **Create BFF API project** + - ASP.NET Core Web API + - Add `Microsoft.Identity.Web` package + - Configure Azure AD authentication with API and Graph scopes + +2. **Implement token proxy endpoints** + ```csharp + [ApiController] + [Route("api/auth")] + [Authorize] + public class AuthController : ControllerBase + { + private readonly ITokenAcquisition _tokenAcquisition; + + [HttpGet("token/{resource}")] + public async Task GetToken(string resource) + { + var scopes = resource switch + { + "api" => new[] { "api://{id}/.default" }, + "graph" => new[] { "https://graph.microsoft.com/.default" }, + _ => throw new ArgumentException("Unknown resource") + }; + + var token = await _tokenAcquisition + .GetAccessTokenForUserAsync(scopes); + return Ok(new { access_token = token }); + } + } + ``` + +3. **Update Blazor WASM to use cookie authentication** + - Remove OIDC configuration + - Use simple cookie-based auth with BFF + - Request tokens from BFF as needed + +4. **Add token caching in BFF** + - Use `IDistributedCache` for token storage + - Handle token refresh automatically + - Return cached tokens when valid + +**Pros:** +- Tokens never exposed to browser +- Can handle complex multi-resource scenarios +- Centralized authentication logic +- Works with any SPA framework + +**Cons:** +- Requires additional backend service +- More infrastructure to maintain +- Latency for token requests +- Session management complexity + +**Estimated Effort**: 2-3 days for BFF + 1 day for WASM updates + +--- + +### Option 3: Hybrid Approach - Blazor Server for Auth + +**Goal**: Use Blazor Server for authentication pages, WASM for main app. + +**Architecture:** +- Login/callback pages: Blazor Server (can use Microsoft.Identity.Web properly) +- Main application: Blazor WASM (receives tokens from Server) + +**Implementation Steps:** + +1. **Create Blazor Server authentication module** + - Separate Blazor Server project for `/authentication/*` routes + - Use `Microsoft.Identity.Web` for proper Azure AD integration + - After authentication, serialize tokens to pass to WASM + +2. **Token transfer mechanism** + - Server writes tokens to secure cookie or session + - WASM reads tokens on load + - Or use SignalR to push tokens to WASM + +3. **Update routing** + - All `/authentication/*` routes → Blazor Server + - All other routes → Blazor WASM + +**Pros:** +- Leverages proper Azure AD libraries for auth +- Main app remains WASM (offline capable) +- Proven pattern + +**Cons:** +- Complex architecture mixing Server and WASM +- Requires Server hosting (can't be static) +- Token transfer security concerns + +**Estimated Effort**: 3-4 days + +--- + +### Option 4: Simplify Scope Requirements + +**Goal**: Restructure to avoid multi-resource tokens altogether. + +**Approach A: Backend handles external APIs** +- WASM only authenticates user (openid, profile, offline_access) +- Backend API uses On-Behalf-Of flow to access Graph/other APIs +- WASM never needs Graph tokens + +**Approach B: Separate authentication contexts** +- User authentication: openid/profile only +- API access: client credentials flow (backend) +- Graph access: handled by backend + +**Pros:** +- Simplest from WASM perspective +- Clear separation of concerns +- Most secure (backend controls API access) + +**Cons:** +- Backend must proxy all external API calls +- May not fit all architectural requirements + +**Estimated Effort**: 1-2 days (if architecture permits) + +--- + +## Recommended Approach + +**Primary Recommendation: Option 4 (Simplify) + Option 1 (Custom Service) for future** + +### Phase 1: Immediate Fix (Simplify) +1. Remove API scopes from Blazor WASM authentication +2. Use only `openid profile offline_access` for user authentication +3. Backend API uses its own credentials or OBO flow for API access +4. This unblocks current development + +### Phase 2: Proper Implementation (Custom Service) +1. Implement custom `RemoteAuthenticationService` for Azure AD +2. Support single-resource tokens with proper scope injection +3. Add on-demand token acquisition for additional resources +4. Provide clear documentation and examples + +This approach: +- ✅ Unblocks immediately +- ✅ Provides long-term robust solution +- ✅ Maintains WASM benefits +- ✅ Follows security best practices + +--- + +## Technical Details for Option 1 Implementation + +### Files to Create/Modify + +1. **New Files:** + ``` + src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/ + ├── Services/ + │ ├── AzureAdAuthenticationService.cs + │ ├── AzureAdTokenService.cs + │ └── AzureAdAccountClaimsPrincipalFactory.cs + ├── Handlers/ + │ └── AzureAdTokenEndpointHandler.cs + ├── Models/ + │ └── AzureAdOptions.cs + └── Extensions/ + └── AzureAdServiceCollectionExtensions.cs + ``` + +2. **Modify Files:** + ``` + src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/ + └── Extensions/ServiceCollectionExtensions.cs (keep for generic OIDC) + + src/hosts/Elsa.Studio.Host.Wasm/ + └── Program.cs (update to use Azure AD-specific registration) + ``` + +3. **Remove Files:** + ``` + src/hosts/Elsa.Studio.Host.Wasm/wwwroot/ + └── auth-interop.js (JavaScript workarounds no longer needed) + ``` + +### Key Classes + +#### AzureAdAuthenticationService +```csharp +public class AzureAdAuthenticationService : RemoteAuthenticationService< + RemoteAuthenticationState, + RemoteUserAccount, + OidcProviderOptions> +{ + private readonly AzureAdOptions _options; + + protected override async Task> + SignInAsync(RemoteAuthenticationContext context) + { + // Override to inject scope parameter into token request + // Handle userinfo skipping + // Process multi-resource scenarios + } +} +``` + +#### AzureAdTokenService +```csharp +public class AzureAdTokenService : IAzureAdTokenService +{ + private readonly IAccessTokenProvider _tokenProvider; + private readonly ITokenAcquisition _tokenAcquisition; // custom impl + + public async Task GetPrimaryAccessTokenAsync() + { + // Return access token from authentication + } + + public async Task GetAccessTokenForResourceAsync(string resource) + { + // Use refresh token to get token for additional resource + // Implement token exchange for multi-resource scenarios + } +} +``` + +#### AzureAdAccountClaimsPrincipalFactory +```csharp +public class AzureAdAccountClaimsPrincipalFactory : + AccountClaimsPrincipalFactory +{ + protected override async ValueTask CreateUserAsync( + RemoteUserAccount account, + RemoteAuthenticationUserOptions options) + { + // Skip userinfo endpoint call + // Extract claims from ID token only + // Azure AD ID tokens contain all necessary user claims + } +} +``` + +### Configuration Example + +```csharp +// Program.cs +builder.Services.AddElsaAzureAdAuthentication(options => +{ + // Basic OIDC settings + options.Authority = configuration["Authentication:OpenIdConnect:Authority"]; + options.ClientId = configuration["Authentication:OpenIdConnect:ClientId"]; + options.AppBaseUrl = configuration["Authentication:OpenIdConnect:AppBaseUrl"]; + + // Azure AD-specific settings + options.PrimaryResource = "api://dda3270c-997e-413a-9175-36b70134547c/elsa-server-api"; + options.AdditionalResources = new List + { + "https://graph.microsoft.com/User.Read" + }; + options.SkipUserInfo = true; // Don't call userinfo endpoint +}); + +// Usage in components +@inject IAzureAdTokenService TokenService + +private async Task CallApiAsync() +{ + var token = await TokenService.GetPrimaryAccessTokenAsync(); + // Use token for API calls +} + +private async Task CallGraphAsync() +{ + var graphToken = await TokenService.GetAccessTokenForResourceAsync( + "https://graph.microsoft.com/User.Read"); + // Use token for Graph calls +} +``` + +--- + +## Testing Plan + +1. **Unit Tests:** + - Token endpoint handler adds scope parameter + - Multi-resource token acquisition logic + - Claims extraction from ID token + +2. **Integration Tests:** + - Full authentication flow with Azure AD test tenant + - Token refresh scenarios + - Multi-resource token acquisition + +3. **Manual Testing:** + - Login/logout flows + - Token expiration and refresh + - Network offline scenarios + - Browser back/forward navigation + - Deep linking to protected routes + +--- + +## Documentation Requirements + +1. **Azure AD App Registration Guide:** + - Required API permissions + - Redirect URI configuration + - Token configuration (optional claims) + +2. **Configuration Guide:** + - appsettings.json structure + - Environment-specific settings + - Multi-resource configuration + +3. **Migration Guide:** + - Updating from generic OIDC to Azure AD-specific + - Breaking changes + - JavaScript workaround removal + +4. **Troubleshooting Guide:** + - Common error codes (AADSTS28000, AADSTS28003, etc.) + - Token acquisition failures + - Scope configuration issues + +--- + +## Security Considerations + +1. **Token Storage:** + - Blazor WASM stores tokens in browser sessionStorage + - Risk: XSS attacks can access tokens + - Mitigation: Strict CSP, regular security audits + +2. **Scope Validation:** + - Backend API must validate access token scopes + - Never trust client to enforce authorization + +3. **Token Lifetime:** + - Configure appropriate token lifetimes + - Implement proper refresh token rotation + - Handle token revocation + +4. **PKCE:** + - Always use PKCE for public clients + - Already implemented in current code + +--- + +## References + +- [Microsoft Identity Platform documentation](https://learn.microsoft.com/en-us/entra/identity-platform/) +- [Azure AD OAuth2 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) +- [Secure ASP.NET Core Blazor WebAssembly](https://learn.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/) +- [IETF RFC 8252 - OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252) + +--- + +## Open Questions + +1. **Token Exchange**: Should we implement RFC 8693 token exchange for multi-resource scenarios, or use refresh token to get new access tokens? + +2. **Fallback**: Should we maintain generic OIDC support alongside Azure AD-specific implementation? + +3. **Backend Integration**: How should the backend API validate tokens for multiple potential audiences? + +4. **Graph SDK**: Should we provide a pre-configured Graph SDK client that uses the token service? + +5. **Offline Support**: How should token refresh work when the app is offline (PWA scenario)? + +--- + +## Success Criteria + +- ✅ User can authenticate with Azure AD +- ✅ Access token for primary API is acquired and usable +- ✅ No JavaScript workarounds required +- ✅ Authentication state persists across page refreshes +- ✅ Tokens refresh automatically before expiration +- ✅ Clear error messages for configuration issues +- ✅ Documentation covers common scenarios +- ✅ Works with both single-resource and multi-resource scenarios (via on-demand acquisition) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4e69b186d..e40e470c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,6 +26,7 @@ + @@ -41,6 +42,7 @@ + @@ -56,6 +58,7 @@ + diff --git a/Elsa.Studio.sln b/Elsa.Studio.sln index 7a1763445..92c66728e 100644 --- a/Elsa.Studio.sln +++ b/Elsa.Studio.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.11.35327.3 @@ -88,126 +89,470 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Labels", "src\m EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.OpenIdConnect", "src\modules\Elsa.Studio.Authentication.OpenIdConnect\Elsa.Studio.Authentication.OpenIdConnect.csproj", "{E88C478A-6B8C-46F3-941C-BEBD798ECD06}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm", "src\modules\Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm\Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.csproj", "{AED216D2-620D-4446-931F-BDEF357DA805}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.Abstractions", "src\modules\Elsa.Studio.Authentication.Abstractions\Elsa.Studio.Authentication.Abstractions.csproj", "{09E284E0-7F8E-4346-962F-90F3FBA8837D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.OpenIdConnect.BlazorServer", "src\modules\Elsa.Studio.Authentication.OpenIdConnect.BlazorServer\Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.csproj", "{410041C1-5429-4D42-BFD3-C4AA343959FB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.ElsaAuth", "src\modules\Elsa.Studio.Authentication.ElsaAuth\Elsa.Studio.Authentication.ElsaAuth.csproj", "{EFDD8E80-B369-4FAD-B797-3D63B3535432}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.ElsaAuth.BlazorServer", "src\modules\Elsa.Studio.Authentication.ElsaAuth.BlazorServer\Elsa.Studio.Authentication.ElsaAuth.BlazorServer.csproj", "{E7EB0A92-C8CE-406B-AF7C-47AD21A43042}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.ElsaAuth.BlazorWasm", "src\modules\Elsa.Studio.Authentication.ElsaAuth.BlazorWasm\Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.csproj", "{5E8F930A-AEE0-4742-955A-A741B1CE93F6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "authentication", "authentication", "{8A157018-5A25-434A-9990-7FA5C3B057B6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "localization", "localization", "{5A2833A5-D3D8-479B-9ED7-5D5DE4FCC8F9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{CFC0C2A5-0013-4767-A743-BF0CEC0DEA25}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deprecated", "deprecated", "{75420DD0-D636-499A-A2F3-31BDB21B7240}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{6B3C5B39-0F8A-471F-9F0B-6CE31F0782F7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dashboard", "dashboard", "{AEFE5B4E-5306-4EA3-9579-9F9A4BF75BBE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.ElsaAuth.UI", "src\modules\Elsa.Studio.Authentication.ElsaAuth.UI\Elsa.Studio.Authentication.ElsaAuth.UI.csproj", "{9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{60B07BD6-80AE-496A-B5C4-55EBB12EF3BC}" + ProjectSection(SolutionItems) = preProject + doc\AUTHENTICATION_ARCHITECTURE.md = doc\AUTHENTICATION_ARCHITECTURE.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B61B3A06-5AF3-4007-9D22-18A319549156}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B61B3A06-5AF3-4007-9D22-18A319549156}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B61B3A06-5AF3-4007-9D22-18A319549156}.Debug|x64.ActiveCfg = Debug|Any CPU + {B61B3A06-5AF3-4007-9D22-18A319549156}.Debug|x64.Build.0 = Debug|Any CPU + {B61B3A06-5AF3-4007-9D22-18A319549156}.Debug|x86.ActiveCfg = Debug|Any CPU + {B61B3A06-5AF3-4007-9D22-18A319549156}.Debug|x86.Build.0 = Debug|Any CPU {B61B3A06-5AF3-4007-9D22-18A319549156}.Release|Any CPU.ActiveCfg = Release|Any CPU {B61B3A06-5AF3-4007-9D22-18A319549156}.Release|Any CPU.Build.0 = Release|Any CPU + {B61B3A06-5AF3-4007-9D22-18A319549156}.Release|x64.ActiveCfg = Release|Any CPU + {B61B3A06-5AF3-4007-9D22-18A319549156}.Release|x64.Build.0 = Release|Any CPU + {B61B3A06-5AF3-4007-9D22-18A319549156}.Release|x86.ActiveCfg = Release|Any CPU + {B61B3A06-5AF3-4007-9D22-18A319549156}.Release|x86.Build.0 = Release|Any CPU {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Debug|x64.Build.0 = Debug|Any CPU + {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Debug|x86.Build.0 = Debug|Any CPU {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Release|Any CPU.Build.0 = Release|Any CPU + {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Release|x64.ActiveCfg = Release|Any CPU + {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Release|x64.Build.0 = Release|Any CPU + {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Release|x86.ActiveCfg = Release|Any CPU + {CE71F7A2-3AB3-4475-B7F4-CF5128D0A4F6}.Release|x86.Build.0 = Release|Any CPU {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Debug|x64.Build.0 = Debug|Any CPU + {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Debug|x86.Build.0 = Debug|Any CPU {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Release|Any CPU.ActiveCfg = Release|Any CPU {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Release|Any CPU.Build.0 = Release|Any CPU + {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Release|x64.ActiveCfg = Release|Any CPU + {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Release|x64.Build.0 = Release|Any CPU + {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Release|x86.ActiveCfg = Release|Any CPU + {7F1322EB-7314-47A7-9F03-7D8CB31097C8}.Release|x86.Build.0 = Release|Any CPU {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Debug|x64.ActiveCfg = Debug|Any CPU + {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Debug|x64.Build.0 = Debug|Any CPU + {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Debug|x86.ActiveCfg = Debug|Any CPU + {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Debug|x86.Build.0 = Debug|Any CPU {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Release|Any CPU.ActiveCfg = Release|Any CPU {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Release|Any CPU.Build.0 = Release|Any CPU + {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Release|x64.ActiveCfg = Release|Any CPU + {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Release|x64.Build.0 = Release|Any CPU + {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Release|x86.ActiveCfg = Release|Any CPU + {B15E4EAF-62C3-4D3B-B12D-4119D517728C}.Release|x86.Build.0 = Release|Any CPU {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Debug|x64.ActiveCfg = Debug|Any CPU + {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Debug|x64.Build.0 = Debug|Any CPU + {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Debug|x86.ActiveCfg = Debug|Any CPU + {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Debug|x86.Build.0 = Debug|Any CPU {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Release|Any CPU.ActiveCfg = Release|Any CPU {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Release|Any CPU.Build.0 = Release|Any CPU + {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Release|x64.ActiveCfg = Release|Any CPU + {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Release|x64.Build.0 = Release|Any CPU + {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Release|x86.ActiveCfg = Release|Any CPU + {21B6A662-8DB2-452E-95E7-3E4227D1544A}.Release|x86.Build.0 = Release|Any CPU {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Debug|x64.Build.0 = Debug|Any CPU + {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Debug|x86.Build.0 = Debug|Any CPU {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Release|Any CPU.ActiveCfg = Release|Any CPU {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Release|Any CPU.Build.0 = Release|Any CPU + {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Release|x64.ActiveCfg = Release|Any CPU + {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Release|x64.Build.0 = Release|Any CPU + {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Release|x86.ActiveCfg = Release|Any CPU + {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D}.Release|x86.Build.0 = Release|Any CPU {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Debug|x64.Build.0 = Debug|Any CPU + {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Debug|x86.Build.0 = Debug|Any CPU {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Release|Any CPU.ActiveCfg = Release|Any CPU {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Release|Any CPU.Build.0 = Release|Any CPU + {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Release|x64.ActiveCfg = Release|Any CPU + {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Release|x64.Build.0 = Release|Any CPU + {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Release|x86.ActiveCfg = Release|Any CPU + {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8}.Release|x86.Build.0 = Release|Any CPU {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Debug|x64.ActiveCfg = Debug|Any CPU + {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Debug|x64.Build.0 = Debug|Any CPU + {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Debug|x86.ActiveCfg = Debug|Any CPU + {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Debug|x86.Build.0 = Debug|Any CPU {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Release|Any CPU.ActiveCfg = Release|Any CPU {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Release|Any CPU.Build.0 = Release|Any CPU + {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Release|x64.ActiveCfg = Release|Any CPU + {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Release|x64.Build.0 = Release|Any CPU + {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Release|x86.ActiveCfg = Release|Any CPU + {B831EE86-F713-4466-B91D-FA66EDCC2E30}.Release|x86.Build.0 = Release|Any CPU {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Debug|x64.ActiveCfg = Debug|Any CPU + {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Debug|x64.Build.0 = Debug|Any CPU + {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Debug|x86.ActiveCfg = Debug|Any CPU + {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Debug|x86.Build.0 = Debug|Any CPU {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Release|Any CPU.ActiveCfg = Release|Any CPU {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Release|Any CPU.Build.0 = Release|Any CPU + {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Release|x64.ActiveCfg = Release|Any CPU + {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Release|x64.Build.0 = Release|Any CPU + {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Release|x86.ActiveCfg = Release|Any CPU + {C5D998F4-4523-49AF-9F0F-7BCDACA58790}.Release|x86.Build.0 = Release|Any CPU {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Debug|x64.ActiveCfg = Debug|Any CPU + {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Debug|x64.Build.0 = Debug|Any CPU + {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Debug|x86.ActiveCfg = Debug|Any CPU + {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Debug|x86.Build.0 = Debug|Any CPU {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Release|Any CPU.ActiveCfg = Release|Any CPU {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Release|Any CPU.Build.0 = Release|Any CPU + {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Release|x64.ActiveCfg = Release|Any CPU + {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Release|x64.Build.0 = Release|Any CPU + {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Release|x86.ActiveCfg = Release|Any CPU + {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48}.Release|x86.Build.0 = Release|Any CPU {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Debug|x64.ActiveCfg = Debug|Any CPU + {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Debug|x64.Build.0 = Debug|Any CPU + {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Debug|x86.ActiveCfg = Debug|Any CPU + {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Debug|x86.Build.0 = Debug|Any CPU {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Release|Any CPU.ActiveCfg = Release|Any CPU {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Release|Any CPU.Build.0 = Release|Any CPU + {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Release|x64.ActiveCfg = Release|Any CPU + {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Release|x64.Build.0 = Release|Any CPU + {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Release|x86.ActiveCfg = Release|Any CPU + {565B61D8-C67A-449B-BAE6-BEAC95E52B8F}.Release|x86.Build.0 = Release|Any CPU {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Debug|x64.ActiveCfg = Debug|Any CPU + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Debug|x64.Build.0 = Debug|Any CPU + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Debug|x86.Build.0 = Debug|Any CPU {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Release|Any CPU.ActiveCfg = Release|Any CPU {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Release|Any CPU.Build.0 = Release|Any CPU + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Release|x64.ActiveCfg = Release|Any CPU + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Release|x64.Build.0 = Release|Any CPU + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Release|x86.ActiveCfg = Release|Any CPU + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165}.Release|x86.Build.0 = Release|Any CPU {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Debug|x64.ActiveCfg = Debug|Any CPU + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Debug|x64.Build.0 = Debug|Any CPU + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Debug|x86.ActiveCfg = Debug|Any CPU + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Debug|x86.Build.0 = Debug|Any CPU {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Release|Any CPU.ActiveCfg = Release|Any CPU {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Release|Any CPU.Build.0 = Release|Any CPU + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Release|x64.ActiveCfg = Release|Any CPU + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Release|x64.Build.0 = Release|Any CPU + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Release|x86.ActiveCfg = Release|Any CPU + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93}.Release|x86.Build.0 = Release|Any CPU {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Debug|x64.Build.0 = Debug|Any CPU + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Debug|x86.Build.0 = Debug|Any CPU {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Release|Any CPU.ActiveCfg = Release|Any CPU {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Release|Any CPU.Build.0 = Release|Any CPU + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Release|x64.ActiveCfg = Release|Any CPU + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Release|x64.Build.0 = Release|Any CPU + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Release|x86.ActiveCfg = Release|Any CPU + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD}.Release|x86.Build.0 = Release|Any CPU {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Debug|x64.ActiveCfg = Debug|Any CPU + {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Debug|x64.Build.0 = Debug|Any CPU + {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Debug|x86.ActiveCfg = Debug|Any CPU + {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Debug|x86.Build.0 = Debug|Any CPU {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Release|Any CPU.Build.0 = Release|Any CPU + {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Release|x64.ActiveCfg = Release|Any CPU + {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Release|x64.Build.0 = Release|Any CPU + {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Release|x86.ActiveCfg = Release|Any CPU + {AC76C31B-75F0-473C-8F96-DE77AFF76536}.Release|x86.Build.0 = Release|Any CPU {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Debug|x64.Build.0 = Debug|Any CPU + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Debug|x86.Build.0 = Debug|Any CPU {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Release|Any CPU.Build.0 = Release|Any CPU + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Release|x64.ActiveCfg = Release|Any CPU + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Release|x64.Build.0 = Release|Any CPU + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Release|x86.ActiveCfg = Release|Any CPU + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F}.Release|x86.Build.0 = Release|Any CPU {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Debug|x64.Build.0 = Debug|Any CPU + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Debug|x86.Build.0 = Debug|Any CPU {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Release|Any CPU.Build.0 = Release|Any CPU + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Release|x64.ActiveCfg = Release|Any CPU + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Release|x64.Build.0 = Release|Any CPU + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Release|x86.ActiveCfg = Release|Any CPU + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD}.Release|x86.Build.0 = Release|Any CPU {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Debug|x64.ActiveCfg = Debug|Any CPU + {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Debug|x64.Build.0 = Debug|Any CPU + {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Debug|x86.ActiveCfg = Debug|Any CPU + {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Debug|x86.Build.0 = Debug|Any CPU {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Release|Any CPU.Build.0 = Release|Any CPU + {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Release|x64.ActiveCfg = Release|Any CPU + {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Release|x64.Build.0 = Release|Any CPU + {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Release|x86.ActiveCfg = Release|Any CPU + {435D5AF5-D06C-47A6-94B0-9B31016250DC}.Release|x86.Build.0 = Release|Any CPU {C15D23AC-26CA-447D-B441-75407FD79A6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C15D23AC-26CA-447D-B441-75407FD79A6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C15D23AC-26CA-447D-B441-75407FD79A6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {C15D23AC-26CA-447D-B441-75407FD79A6A}.Debug|x64.Build.0 = Debug|Any CPU + {C15D23AC-26CA-447D-B441-75407FD79A6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {C15D23AC-26CA-447D-B441-75407FD79A6A}.Debug|x86.Build.0 = Debug|Any CPU {C15D23AC-26CA-447D-B441-75407FD79A6A}.Release|Any CPU.ActiveCfg = Release|Any CPU {C15D23AC-26CA-447D-B441-75407FD79A6A}.Release|Any CPU.Build.0 = Release|Any CPU + {C15D23AC-26CA-447D-B441-75407FD79A6A}.Release|x64.ActiveCfg = Release|Any CPU + {C15D23AC-26CA-447D-B441-75407FD79A6A}.Release|x64.Build.0 = Release|Any CPU + {C15D23AC-26CA-447D-B441-75407FD79A6A}.Release|x86.ActiveCfg = Release|Any CPU + {C15D23AC-26CA-447D-B441-75407FD79A6A}.Release|x86.Build.0 = Release|Any CPU {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Debug|x64.Build.0 = Debug|Any CPU + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Debug|x86.Build.0 = Debug|Any CPU {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Release|Any CPU.ActiveCfg = Release|Any CPU {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Release|Any CPU.Build.0 = Release|Any CPU + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Release|x64.ActiveCfg = Release|Any CPU + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Release|x64.Build.0 = Release|Any CPU + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Release|x86.ActiveCfg = Release|Any CPU + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4}.Release|x86.Build.0 = Release|Any CPU {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Debug|x64.ActiveCfg = Debug|Any CPU + {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Debug|x64.Build.0 = Debug|Any CPU + {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Debug|x86.Build.0 = Debug|Any CPU {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Release|Any CPU.ActiveCfg = Release|Any CPU {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Release|Any CPU.Build.0 = Release|Any CPU + {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Release|x64.ActiveCfg = Release|Any CPU + {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Release|x64.Build.0 = Release|Any CPU + {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Release|x86.ActiveCfg = Release|Any CPU + {AF48A447-22AF-4C94-8C5D-2B47FD90484C}.Release|x86.Build.0 = Release|Any CPU {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Debug|x64.ActiveCfg = Debug|Any CPU + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Debug|x64.Build.0 = Debug|Any CPU + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Debug|x86.ActiveCfg = Debug|Any CPU + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Debug|x86.Build.0 = Debug|Any CPU {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Release|Any CPU.Build.0 = Release|Any CPU + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Release|x64.ActiveCfg = Release|Any CPU + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Release|x64.Build.0 = Release|Any CPU + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Release|x86.ActiveCfg = Release|Any CPU + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E}.Release|x86.Build.0 = Release|Any CPU {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Debug|x64.ActiveCfg = Debug|Any CPU + {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Debug|x64.Build.0 = Debug|Any CPU + {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Debug|x86.Build.0 = Debug|Any CPU {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Release|Any CPU.ActiveCfg = Release|Any CPU {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Release|Any CPU.Build.0 = Release|Any CPU + {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Release|x64.ActiveCfg = Release|Any CPU + {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Release|x64.Build.0 = Release|Any CPU + {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Release|x86.ActiveCfg = Release|Any CPU + {F3C53AFF-EBE5-447D-AF4D-136F18761733}.Release|x86.Build.0 = Release|Any CPU {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Debug|x64.Build.0 = Debug|Any CPU + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Debug|x86.Build.0 = Debug|Any CPU {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Release|Any CPU.ActiveCfg = Release|Any CPU {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Release|Any CPU.Build.0 = Release|Any CPU + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Release|x64.ActiveCfg = Release|Any CPU + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Release|x64.Build.0 = Release|Any CPU + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Release|x86.ActiveCfg = Release|Any CPU + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5}.Release|x86.Build.0 = Release|Any CPU {DE57FD2C-3874-486A-89B1-D982726A1189}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DE57FD2C-3874-486A-89B1-D982726A1189}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE57FD2C-3874-486A-89B1-D982726A1189}.Debug|x64.ActiveCfg = Debug|Any CPU + {DE57FD2C-3874-486A-89B1-D982726A1189}.Debug|x64.Build.0 = Debug|Any CPU + {DE57FD2C-3874-486A-89B1-D982726A1189}.Debug|x86.ActiveCfg = Debug|Any CPU + {DE57FD2C-3874-486A-89B1-D982726A1189}.Debug|x86.Build.0 = Debug|Any CPU {DE57FD2C-3874-486A-89B1-D982726A1189}.Release|Any CPU.ActiveCfg = Release|Any CPU {DE57FD2C-3874-486A-89B1-D982726A1189}.Release|Any CPU.Build.0 = Release|Any CPU + {DE57FD2C-3874-486A-89B1-D982726A1189}.Release|x64.ActiveCfg = Release|Any CPU + {DE57FD2C-3874-486A-89B1-D982726A1189}.Release|x64.Build.0 = Release|Any CPU + {DE57FD2C-3874-486A-89B1-D982726A1189}.Release|x86.ActiveCfg = Release|Any CPU + {DE57FD2C-3874-486A-89B1-D982726A1189}.Release|x86.Build.0 = Release|Any CPU {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Debug|x64.ActiveCfg = Debug|Any CPU + {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Debug|x64.Build.0 = Debug|Any CPU + {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Debug|x86.ActiveCfg = Debug|Any CPU + {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Debug|x86.Build.0 = Debug|Any CPU {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Release|Any CPU.ActiveCfg = Release|Any CPU {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Release|Any CPU.Build.0 = Release|Any CPU + {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Release|x64.ActiveCfg = Release|Any CPU + {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Release|x64.Build.0 = Release|Any CPU + {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Release|x86.ActiveCfg = Release|Any CPU + {76C60D97-FA22-4023-BDB3-6BC47D097E40}.Release|x86.Build.0 = Release|Any CPU {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Debug|x64.ActiveCfg = Debug|Any CPU + {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Debug|x64.Build.0 = Debug|Any CPU + {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Debug|x86.ActiveCfg = Debug|Any CPU + {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Debug|x86.Build.0 = Debug|Any CPU {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Release|Any CPU.ActiveCfg = Release|Any CPU {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Release|Any CPU.Build.0 = Release|Any CPU + {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Release|x64.ActiveCfg = Release|Any CPU + {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Release|x64.Build.0 = Release|Any CPU + {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Release|x86.ActiveCfg = Release|Any CPU + {25BA3052-4F17-4D24-9AE9-01FBD75E8804}.Release|x86.Build.0 = Release|Any CPU {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Debug|x64.ActiveCfg = Debug|Any CPU + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Debug|x64.Build.0 = Debug|Any CPU + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Debug|x86.ActiveCfg = Debug|Any CPU + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Debug|x86.Build.0 = Debug|Any CPU {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Release|Any CPU.ActiveCfg = Release|Any CPU {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Release|Any CPU.Build.0 = Release|Any CPU + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Release|x64.ActiveCfg = Release|Any CPU + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Release|x64.Build.0 = Release|Any CPU + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Release|x86.ActiveCfg = Release|Any CPU + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1}.Release|x86.Build.0 = Release|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Debug|x64.ActiveCfg = Debug|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Debug|x64.Build.0 = Debug|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Debug|x86.ActiveCfg = Debug|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Debug|x86.Build.0 = Debug|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Release|Any CPU.Build.0 = Release|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Release|x64.ActiveCfg = Release|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Release|x64.Build.0 = Release|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Release|x86.ActiveCfg = Release|Any CPU + {AED216D2-620D-4446-931F-BDEF357DA805}.Release|x86.Build.0 = Release|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Debug|x64.ActiveCfg = Debug|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Debug|x64.Build.0 = Debug|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Debug|x86.ActiveCfg = Debug|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Debug|x86.Build.0 = Debug|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Release|Any CPU.Build.0 = Release|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Release|x64.ActiveCfg = Release|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Release|x64.Build.0 = Release|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Release|x86.ActiveCfg = Release|Any CPU + {09E284E0-7F8E-4346-962F-90F3FBA8837D}.Release|x86.Build.0 = Release|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Debug|x64.ActiveCfg = Debug|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Debug|x64.Build.0 = Debug|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Debug|x86.ActiveCfg = Debug|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Debug|x86.Build.0 = Debug|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Release|Any CPU.Build.0 = Release|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Release|x64.ActiveCfg = Release|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Release|x64.Build.0 = Release|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Release|x86.ActiveCfg = Release|Any CPU + {410041C1-5429-4D42-BFD3-C4AA343959FB}.Release|x86.Build.0 = Release|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Debug|x64.ActiveCfg = Debug|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Debug|x64.Build.0 = Debug|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Debug|x86.ActiveCfg = Debug|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Debug|x86.Build.0 = Debug|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Release|Any CPU.Build.0 = Release|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Release|x64.ActiveCfg = Release|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Release|x64.Build.0 = Release|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Release|x86.ActiveCfg = Release|Any CPU + {EFDD8E80-B369-4FAD-B797-3D63B3535432}.Release|x86.Build.0 = Release|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Debug|x64.Build.0 = Debug|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Debug|x86.Build.0 = Debug|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Release|Any CPU.Build.0 = Release|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Release|x64.ActiveCfg = Release|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Release|x64.Build.0 = Release|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Release|x86.ActiveCfg = Release|Any CPU + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042}.Release|x86.Build.0 = Release|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Debug|x64.Build.0 = Debug|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Debug|x86.Build.0 = Debug|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Release|Any CPU.Build.0 = Release|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Release|x64.ActiveCfg = Release|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Release|x64.Build.0 = Release|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Release|x86.ActiveCfg = Release|Any CPU + {5E8F930A-AEE0-4742-955A-A741B1CE93F6}.Release|x86.Build.0 = Release|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Debug|x64.ActiveCfg = Debug|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Debug|x64.Build.0 = Debug|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Debug|x86.ActiveCfg = Debug|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Debug|x86.Build.0 = Debug|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Release|Any CPU.Build.0 = Release|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Release|x64.ActiveCfg = Release|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Release|x64.Build.0 = Release|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Release|x86.ActiveCfg = Release|Any CPU + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}.Release|x86.Build.0 = Release|Any CPU {E88C478A-6B8C-46F3-941C-BEBD798ECD06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E88C478A-6B8C-46F3-941C-BEBD798ECD06}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E88C478A-6B8C-46F3-941C-BEBD798ECD06}.Debug|Any CPU.Deploy.0 = Debug|Any CPU {E88C478A-6B8C-46F3-941C-BEBD798ECD06}.Release|Any CPU.ActiveCfg = Release|Any CPU {E88C478A-6B8C-46F3-941C-BEBD798ECD06}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection @@ -226,28 +571,41 @@ Global {6BF88129-D74E-46FA-89A4-29B9FBAEC241} = {875A7E2E-4B7C-4AF0-A71E-3980B73AF363} {6029D546-A5DB-4B75-8DE4-BFB83EB2A24D} = {6BF88129-D74E-46FA-89A4-29B9FBAEC241} {8A9DBE4A-A03F-44C9-9AB2-74AA973D72A8} = {2AA1AEE9-017E-4F8B-B5FC-2BEA37E83514} - {B831EE86-F713-4466-B91D-FA66EDCC2E30} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {B831EE86-F713-4466-B91D-FA66EDCC2E30} = {CFC0C2A5-0013-4767-A743-BF0CEC0DEA25} {C5D998F4-4523-49AF-9F0F-7BCDACA58790} = {C5288F1B-F4E5-423C-AEE8-049996613668} {2FC3CE4B-02D3-4808-828E-F4D84FFD1E48} = {C5288F1B-F4E5-423C-AEE8-049996613668} {565B61D8-C67A-449B-BAE6-BEAC95E52B8F} = {C5288F1B-F4E5-423C-AEE8-049996613668} - {5DE5EFD1-00C0-4797-BFC3-8989ACF52165} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {5161EB5E-9301-4A43-A7CC-CDC665CD7B93} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {7A30797E-A1BF-4128-BB23-D8B00CDD59FD} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {AC76C31B-75F0-473C-8F96-DE77AFF76536} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {B5CF2E45-29A0-4101-90C3-2E6C31142F8F} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {435D5AF5-D06C-47A6-94B0-9B31016250DC} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {C15D23AC-26CA-447D-B441-75407FD79A6A} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {F85F11D4-8D22-4119-B212-2CD19DDFB0B4} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {AF48A447-22AF-4C94-8C5D-2B47FD90484C} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {E55917F6-388C-47FB-922B-ADE3D1AF5D6E} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {F3C53AFF-EBE5-447D-AF4D-136F18761733} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {DE57FD2C-3874-486A-89B1-D982726A1189} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {5DE5EFD1-00C0-4797-BFC3-8989ACF52165} = {6B3C5B39-0F8A-471F-9F0B-6CE31F0782F7} + {5161EB5E-9301-4A43-A7CC-CDC665CD7B93} = {AEFE5B4E-5306-4EA3-9579-9F9A4BF75BBE} + {7A30797E-A1BF-4128-BB23-D8B00CDD59FD} = {75420DD0-D636-499A-A2F3-31BDB21B7240} + {AC76C31B-75F0-473C-8F96-DE77AFF76536} = {75420DD0-D636-499A-A2F3-31BDB21B7240} + {B5CF2E45-29A0-4101-90C3-2E6C31142F8F} = {75420DD0-D636-499A-A2F3-31BDB21B7240} + {5A3139DC-0FFB-4B44-8BF1-38F29C6133CD} = {75420DD0-D636-499A-A2F3-31BDB21B7240} + {435D5AF5-D06C-47A6-94B0-9B31016250DC} = {75420DD0-D636-499A-A2F3-31BDB21B7240} + {C15D23AC-26CA-447D-B441-75407FD79A6A} = {CFC0C2A5-0013-4767-A743-BF0CEC0DEA25} + {F85F11D4-8D22-4119-B212-2CD19DDFB0B4} = {CFC0C2A5-0013-4767-A743-BF0CEC0DEA25} + {AF48A447-22AF-4C94-8C5D-2B47FD90484C} = {CFC0C2A5-0013-4767-A743-BF0CEC0DEA25} + {E55917F6-388C-47FB-922B-ADE3D1AF5D6E} = {CFC0C2A5-0013-4767-A743-BF0CEC0DEA25} + {F3C53AFF-EBE5-447D-AF4D-136F18761733} = {5A2833A5-D3D8-479B-9ED7-5D5DE4FCC8F9} + {F5BBEF8A-D75E-442E-A2E7-EEF5F04335A5} = {5A2833A5-D3D8-479B-9ED7-5D5DE4FCC8F9} + {DE57FD2C-3874-486A-89B1-D982726A1189} = {5A2833A5-D3D8-479B-9ED7-5D5DE4FCC8F9} {76C60D97-FA22-4023-BDB3-6BC47D097E40} = {C5288F1B-F4E5-423C-AEE8-049996613668} {25BA3052-4F17-4D24-9AE9-01FBD75E8804} = {2AA1AEE9-017E-4F8B-B5FC-2BEA37E83514} - {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1} = {D66B9A40-8608-46F3-9868-625C50EACE43} - {E88C478A-6B8C-46F3-941C-BEBD798ECD06} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {F6F4CD65-8E0C-5401-A668-108C6C0E8CD1} = {75420DD0-D636-499A-A2F3-31BDB21B7240} + {E88C478A-6B8C-46F3-941C-BEBD798ECD06} = {8A157018-5A25-434A-9990-7FA5C3B057B6} + {AED216D2-620D-4446-931F-BDEF357DA805} = {8A157018-5A25-434A-9990-7FA5C3B057B6} + {09E284E0-7F8E-4346-962F-90F3FBA8837D} = {8A157018-5A25-434A-9990-7FA5C3B057B6} + {410041C1-5429-4D42-BFD3-C4AA343959FB} = {8A157018-5A25-434A-9990-7FA5C3B057B6} + {EFDD8E80-B369-4FAD-B797-3D63B3535432} = {8A157018-5A25-434A-9990-7FA5C3B057B6} + {E7EB0A92-C8CE-406B-AF7C-47AD21A43042} = {8A157018-5A25-434A-9990-7FA5C3B057B6} + {5E8F930A-AEE0-4742-955A-A741B1CE93F6} = {8A157018-5A25-434A-9990-7FA5C3B057B6} + {8A157018-5A25-434A-9990-7FA5C3B057B6} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {5A2833A5-D3D8-479B-9ED7-5D5DE4FCC8F9} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {CFC0C2A5-0013-4767-A743-BF0CEC0DEA25} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {75420DD0-D636-499A-A2F3-31BDB21B7240} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {6B3C5B39-0F8A-471F-9F0B-6CE31F0782F7} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {AEFE5B4E-5306-4EA3-9579-9F9A4BF75BBE} = {D66B9A40-8608-46F3-9868-625C50EACE43} + {9953E8DA-2ADD-42CA-957F-1DFDB284BEFD} = {8A157018-5A25-434A-9990-7FA5C3B057B6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5B8719CC-CF87-45E1-BE1A-13842F951B28} diff --git a/doc/AUTHENTICATION_ARCHITECTURE.md b/doc/AUTHENTICATION_ARCHITECTURE.md new file mode 100644 index 000000000..4a7ff02bb --- /dev/null +++ b/doc/AUTHENTICATION_ARCHITECTURE.md @@ -0,0 +1,335 @@ +# Elsa Studio Authentication Architecture + +This document provides an overview of the authentication architecture in Elsa Studio, including how different authentication providers integrate with the framework. + +## Overview + +Elsa Studio supports multiple authentication providers through a flexible, extensible architecture. The system is designed to: + +1. Support multiple authentication mechanisms (OIDC, OAuth2, JWT, etc.) +2. Work across different Blazor hosting models (Server and WebAssembly) +3. Provide automatic token management and refresh +4. Integrate seamlessly with backend API calls and SignalR connections + +## Architecture Layers + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Elsa Studio Application │ +│ (Workflows, Dashboard, etc. - uses IAuthenticationProviderManager) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Elsa.Studio.Core │ +│ • IAuthenticationProvider - Gets tokens for the app │ +│ • IAuthenticationProviderManager - Manages multiple providers │ +│ • TokenNames - Standard token name constants │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Elsa.Studio.Authentication.Abstractions │ +│ • ITokenAccessor - Provider-agnostic token access │ +│ • AuthenticationOptions - Base configuration │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┬─────────────────┐ + ▼ ▼ ▼ +┌──────────────────────┐ ┌──────────────────┐ ┌─────────────┐ +│ OIDC Provider │ │ OAuth2 Provider │ │ Future │ +│ • IOidcTokenAccessor│ │ (Future) │ │ Providers │ +│ • OidcOptions │ │ │ │ (JWT, SAML) │ +│ • Server & WASM │ │ │ │ │ +└──────────────────────┘ └──────────────────┘ └─────────────┘ +``` + +## Core Concepts + +### 1. Token Flow + +``` +Application Request + ↓ +IAuthenticationProviderManager.GetAuthenticationTokenAsync() + ↓ +[Iterates through registered IAuthenticationProvider instances] + ↓ +IAuthenticationProvider.GetAccessTokenAsync() + ↓ +ITokenAccessor.GetTokenAsync() + ↓ +[Provider-specific token retrieval] + ↓ +Token returned to application +``` + +### 2. Provider Registration + +Multiple authentication providers can be registered simultaneously: + +```csharp +// Example: Register OIDC for API calls, JWT for specific endpoints +services.AddOidcAuthentication(options => { /* OIDC config */ }); +services.AddJwtAuthentication(options => { /* JWT config */ }); + +// The manager will try each provider until a valid token is found +``` + +### 3. Hosting Model Differences + +#### Blazor Server +- Uses ASP.NET Core authentication middleware +- Tokens stored server-side in authentication properties +- Accessed via `HttpContext.GetTokenAsync()` +- Cookie-based session management +- No client-side token exposure + +#### Blazor WebAssembly +- Uses `Microsoft.AspNetCore.Components.WebAssembly.Authentication` +- Tokens managed by browser-based authentication framework +- Accessed via `IAccessTokenProvider` +- Automatic token refresh before expiry +- Secure token storage in browser + +## Standard Interfaces + +### IAuthenticationProvider (Core) + +The main interface used by Elsa Studio applications. + +```csharp +public interface IAuthenticationProvider +{ + Task GetAccessTokenAsync(string tokenName, CancellationToken cancellationToken = default); +} +``` + +**Purpose**: Provides tokens to the application for API calls and SignalR connections. + +### IAuthenticationProviderManager (Core) + +Manages multiple authentication providers. + +```csharp +public interface IAuthenticationProviderManager +{ + Task GetAuthenticationTokenAsync(string? tokenName, CancellationToken cancellationToken = default); +} +``` + +**Purpose**: Iterates through registered providers to find a valid token. + +### ITokenAccessor (Abstractions) + +Provider-agnostic interface for token retrieval. + +```csharp +public interface ITokenAccessor +{ + Task GetTokenAsync(string tokenName, CancellationToken cancellationToken = default); +} +``` + +**Purpose**: Allows authentication providers to implement token access in their own way. + +## Token Names + +Standard token names are defined in `TokenNames` class: + +- `TokenNames.AccessToken` - Access token for API authentication +- `TokenNames.IdToken` - Identity token (may not be available in all providers/hosting models) +- `TokenNames.RefreshToken` - Refresh token (may not be available in all providers/hosting models) + +## Authentication Providers + +### Current Providers + +#### 1. Elsa.Studio.Login (Legacy) +- **Location**: `src/modules/Elsa.Studio.Login` +- **Supports**: OIDC, OAuth2, Elsa Identity +- **Status**: Maintained for backward compatibility +- **Note**: Tight coupling with general login functionality + +#### 2. Elsa.Studio.Authentication.OpenIdConnect (New) +- **Location**: `src/modules/Elsa.Studio.Authentication.OpenIdConnect` +- **Supports**: OpenID Connect +- **Hosting**: Separate packages for Server and WASM +- **Features**: + - Uses Microsoft's built-in OIDC handlers + - Automatic token refresh + - PKCE support + - Cookie-based auth (Server) or framework-managed (WASM) + +### Future Providers (Examples) + +- `Elsa.Studio.Authentication.OAuth2` - Pure OAuth2 without OIDC +- `Elsa.Studio.Authentication.Jwt` - JWT bearer token authentication +- `Elsa.Studio.Authentication.Saml` - SAML authentication +- `Elsa.Studio.Authentication.AzureAD` - Azure AD specific optimizations +- Custom implementations for proprietary auth systems + +## Integration Points + +### 1. API Calls + +The `AuthenticatingApiHttpMessageHandler` automatically adds authentication tokens to API requests: + +```csharp +// In Elsa.Studio.Login +public class AuthenticatingApiHttpMessageHandler : DelegatingHandler +{ + protected override async Task SendAsync(...) + { + var token = await jwtAccessor.ReadTokenAsync(TokenNames.AccessToken); + request.Headers.Authorization = new("Bearer", token); + // ... handle 401 with token refresh + } +} +``` + +### 2. SignalR Connections + +The `WorkflowInstanceObserverFactory` retrieves tokens for SignalR hub connections: + +```csharp +var token = await authenticationProviderManager + .GetAuthenticationTokenAsync(TokenNames.AccessToken, cancellationToken); + +var connection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.AccessTokenProvider = () => Task.FromResult(token); + }) + .Build(); +``` + +### 3. Authorization State + +Blazor's `AuthenticationStateProvider` is used for UI authorization: + +```razor +@attribute [Authorize] + + + + + + + + + +``` + +## Security Considerations + +### Server Hosting +- ✅ Tokens never exposed to client browser +- ✅ Cookie-based authentication with HTTP-only cookies +- ✅ Secure server-side session management +- ✅ HTTPS-only cookies in production + +### WASM Hosting +- ✅ Tokens managed by authentication framework +- ✅ Automatic token expiry and renewal +- ✅ Access tokens available, but refresh tokens hidden +- ✅ Uses standard browser security features + +### General +- ✅ PKCE enabled by default for OIDC +- ✅ HTTPS required for metadata endpoints +- ✅ Token refresh on 401 responses +- ✅ Secure token storage per hosting model + +## Implementation Guide + +### Creating a New Authentication Provider + +See `src/modules/Elsa.Studio.Authentication.Abstractions/README.md` for detailed guidance. + +**Quick Steps**: + +1. Create provider-specific options extending `AuthenticationOptions` +2. Implement `ITokenAccessor` for your provider +3. Implement `IAuthenticationProvider` using your token accessor +4. Create hosting-specific implementations if needed (Server vs WASM) +5. Register services in DI container + +### Using an Authentication Provider + +**Blazor Server**: +```csharp +builder.Services.AddOidcAuthentication(options => +{ + options.Authority = "https://identity-server.com"; + options.ClientId = "elsa-studio"; + options.ClientSecret = "secret"; + options.Scopes = new[] { "openid", "profile", "elsa_api" }; +}); + +app.UseAuthentication(); +app.UseAuthorization(); +``` + +**Blazor WASM**: +```csharp +builder.Services.AddOidcAuthentication(options => +{ + options.Authority = "https://identity-server.com"; + options.ClientId = "elsa-studio-wasm"; + options.Scopes = new[] { "openid", "profile", "elsa_api" }; +}); +``` + +## Migration Path + +### From Elsa.Studio.Login to New Providers + +The new authentication providers are designed to coexist with the legacy `Elsa.Studio.Login`: + +1. **Phase 1**: Add new provider alongside existing Login module +2. **Phase 2**: Test new provider with your identity server +3. **Phase 3**: Switch to new provider by removing Login module registration +4. **Phase 4**: Remove Login module dependency once stable + +No breaking changes to existing applications. + +## Troubleshooting + +### Common Issues + +1. **Multiple providers registered, wrong one used** + - `IAuthenticationProviderManager` returns first valid token + - Check registration order + - Ensure only desired provider is registered + +2. **Tokens not available in WASM** + - Only access tokens directly accessible + - Refresh/ID tokens managed by framework + +3. **401 errors on API calls** + - Check token scopes match API requirements + - Verify `AuthenticatingApiHttpMessageHandler` is registered + - Check identity server returns correct audience + +4. **SignalR connections fail** + - Ensure `offline_access` scope for refresh tokens + - Verify token provider returns valid token + - Check SignalR hub authentication configuration + +## Resources + +- [Elsa.Studio.Authentication.Abstractions README](../modules/Elsa.Studio.Authentication.Abstractions/README.md) +- [Elsa.Studio.Authentication.OpenIdConnect README](../modules/Elsa.Studio.Authentication.OpenIdConnect/README.md) +- [Microsoft Authentication Documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/security/) + +## Future Enhancements + +Potential future additions: + +- Automatic provider discovery from configuration +- Multi-tenant authentication support +- Authentication caching and performance optimizations +- Enhanced token refresh strategies +- Authentication event hooks and middleware +- Support for additional identity providers (Auth0, Okta, etc.) diff --git a/src/framework/Elsa.Studio.Core/Contracts/IAnonymousBackendApiClientProvider.cs b/src/framework/Elsa.Studio.Core/Contracts/IAnonymousBackendApiClientProvider.cs new file mode 100644 index 000000000..44ae9890d --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Contracts/IAnonymousBackendApiClientProvider.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Elsa.Studio.Contracts; + +/// +/// Provides API clients to the backend for anonymous (non-authenticated) calls. +/// +/// +/// This provider is intended for endpoints like /identity/login where attaching an access token is not required +/// and can even be harmful (e.g., stale tokens, circular dependencies during sign-in). +/// +public interface IAnonymousBackendApiClientProvider +{ + /// + /// Gets the URL to the backend. + /// + Uri Url { get; } + + /// + /// Gets an API client that does not attach access tokens. + /// + /// The API client type. + ValueTask GetApiAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(CancellationToken cancellationToken = default) where T : class; +} diff --git a/src/framework/Elsa.Studio.Core/Contracts/IBackendApiClientProvider.cs b/src/framework/Elsa.Studio.Core/Contracts/IBackendApiClientProvider.cs index 0e699cc69..b1f69e02f 100644 --- a/src/framework/Elsa.Studio.Core/Contracts/IBackendApiClientProvider.cs +++ b/src/framework/Elsa.Studio.Core/Contracts/IBackendApiClientProvider.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Elsa.Studio.Contracts; /// @@ -15,5 +17,5 @@ public interface IBackendApiClientProvider /// /// The API client type. /// The API client. - ValueTask GetApiAsync(CancellationToken cancellationToken = default) where T : class; + ValueTask GetApiAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(CancellationToken cancellationToken = default) where T : class; } \ No newline at end of file diff --git a/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs b/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs index 870ed98a1..335890a1c 100644 --- a/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs @@ -60,6 +60,7 @@ public static IServiceCollection AddRemoteBackend(this IServiceCollection servic services.AddDefaultApiClients(config?.ConfigureHttpClientBuilder); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/framework/Elsa.Studio.Core/Services/ApiClientFactory.cs b/src/framework/Elsa.Studio.Core/Services/ApiClientFactory.cs new file mode 100644 index 000000000..50b3cb2a0 --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Services/ApiClientFactory.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; +using Elsa.Api.Client.Extensions; + +namespace Elsa.Studio.Services; + +/// +/// Bridges calls to Elsa.Api.Client API client creation methods while preserving trimming annotations. +/// +internal static class ApiClientFactory +{ + internal static T Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(IServiceProvider serviceProvider, Uri backendUrl) where T : class + => serviceProvider.CreateApi(backendUrl); +} + diff --git a/src/framework/Elsa.Studio.Core/Services/BlazorScopedProxyApi.cs b/src/framework/Elsa.Studio.Core/Services/BlazorScopedProxyApi.cs index e9bc00a70..cd3a7f59a 100644 --- a/src/framework/Elsa.Studio.Core/Services/BlazorScopedProxyApi.cs +++ b/src/framework/Elsa.Studio.Core/Services/BlazorScopedProxyApi.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Elsa.Studio.Contracts; @@ -6,7 +7,7 @@ namespace Elsa.Studio.Services; /// /// Decorates an API client with a Blazor service accessor, ensuring that the service provider is available to the API client when calling DI-resolved delegating handlers. /// -public class BlazorScopedProxyApi : DispatchProxy +public class BlazorScopedProxyApi<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> : DispatchProxy { private T _decoratedApi = default!; private IBlazorServiceAccessor _blazorServiceAccessor = null!; diff --git a/src/framework/Elsa.Studio.Core/Services/DefaultAnonymousBackendApiClientProvider.cs b/src/framework/Elsa.Studio.Core/Services/DefaultAnonymousBackendApiClientProvider.cs new file mode 100644 index 000000000..f131045ea --- /dev/null +++ b/src/framework/Elsa.Studio.Core/Services/DefaultAnonymousBackendApiClientProvider.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Elsa.Studio.Contracts; + +namespace Elsa.Studio.Services; + +/// +/// Default implementation of . +/// +public class DefaultAnonymousBackendApiClientProvider( + IRemoteBackendAccessor remoteBackendAccessor, + IBlazorServiceAccessor blazorServiceAccessor, + IServiceProvider serviceProvider) : IAnonymousBackendApiClientProvider +{ + /// + public Uri Url => remoteBackendAccessor.RemoteBackend.Url; + + /// + public ValueTask GetApiAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(CancellationToken cancellationToken = default) where T : class + { + var backendUrl = remoteBackendAccessor.RemoteBackend.Url; + + // Create a raw API client. This bypasses DI-resolved delegating handlers (like AuthenticatingApiHttpMessageHandler). + var client = ApiClientFactory.Create(serviceProvider, backendUrl); + + // Keep the Blazor scoped-service-provider behavior consistent. + var decorator = DispatchProxy.Create>(); + (decorator as BlazorScopedProxyApi)!.Initialize(client, blazorServiceAccessor, serviceProvider); + + return new(decorator); + } +} diff --git a/src/framework/Elsa.Studio.Core/Services/DefaultBackendApiClientProvider.cs b/src/framework/Elsa.Studio.Core/Services/DefaultBackendApiClientProvider.cs index dc91bf646..fb014b046 100644 --- a/src/framework/Elsa.Studio.Core/Services/DefaultBackendApiClientProvider.cs +++ b/src/framework/Elsa.Studio.Core/Services/DefaultBackendApiClientProvider.cs @@ -1,5 +1,5 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; -using Elsa.Api.Client.Extensions; using Elsa.Studio.Contracts; namespace Elsa.Studio.Services; @@ -13,10 +13,10 @@ public class DefaultBackendApiClientProvider(IRemoteBackendAccessor remoteBacken public Uri Url => remoteBackendAccessor.RemoteBackend.Url; /// - public ValueTask GetApiAsync(CancellationToken cancellationToken) where T : class + public ValueTask GetApiAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(CancellationToken cancellationToken = default) where T : class { var backendUrl = remoteBackendAccessor.RemoteBackend.Url; - var client = serviceProvider.CreateApi(backendUrl); + var client = ApiClientFactory.Create(serviceProvider, backendUrl); var decorator = DispatchProxy.Create>(); (decorator as BlazorScopedProxyApi)!.Initialize(client, blazorServiceAccessor, serviceProvider); return new(decorator); diff --git a/src/framework/Elsa.Studio.Shell/App.razor b/src/framework/Elsa.Studio.Shell/App.razor index be6d73f12..cc7a2575f 100644 --- a/src/framework/Elsa.Studio.Shell/App.razor +++ b/src/framework/Elsa.Studio.Shell/App.razor @@ -29,4 +29,4 @@ Sorry, there's nothing at this address. - \ No newline at end of file + diff --git a/src/hosts/Elsa.Studio.Host.HostedWasm/appsettings.json b/src/hosts/Elsa.Studio.Host.HostedWasm/appsettings.json index d6c905b41..94af61a91 100644 --- a/src/hosts/Elsa.Studio.Host.HostedWasm/appsettings.json +++ b/src/hosts/Elsa.Studio.Host.HostedWasm/appsettings.json @@ -8,5 +8,13 @@ "AllowedHosts": "*", "Hosting": { "ApiUrl": "https://localhost:5001/elsa/api" + }, + "Authentication": { + "Provider": "OpenIdConnect", + "OpenIdConnect": { + "Authority": "https://login.microsoftonline.com/{tenantIdOrVerifiedDomain}/v2.0", + "ClientId": "", + "ClientSecret": "" + } } } diff --git a/src/hosts/Elsa.Studio.Host.Server/Elsa.Studio.Host.Server.csproj b/src/hosts/Elsa.Studio.Host.Server/Elsa.Studio.Host.Server.csproj index 7fd87aac5..bfefe23e4 100644 --- a/src/hosts/Elsa.Studio.Host.Server/Elsa.Studio.Host.Server.csproj +++ b/src/hosts/Elsa.Studio.Host.Server/Elsa.Studio.Host.Server.csproj @@ -14,11 +14,15 @@ + + + + + + - - diff --git a/src/hosts/Elsa.Studio.Host.Server/Pages/_Host.cshtml b/src/hosts/Elsa.Studio.Host.Server/Pages/_Host.cshtml index f1f14f710..2acfa9f48 100644 --- a/src/hosts/Elsa.Studio.Host.Server/Pages/_Host.cshtml +++ b/src/hosts/Elsa.Studio.Host.Server/Pages/_Host.cshtml @@ -12,6 +12,7 @@ + Elsa Studio @@ -45,7 +46,6 @@ - \ No newline at end of file diff --git a/src/hosts/Elsa.Studio.Host.Server/Program.cs b/src/hosts/Elsa.Studio.Host.Server/Program.cs index 0c9578db6..4f9c27485 100644 --- a/src/hosts/Elsa.Studio.Host.Server/Program.cs +++ b/src/hosts/Elsa.Studio.Host.Server/Program.cs @@ -1,3 +1,8 @@ +using Elsa.Studio.Authentication.Abstractions.HttpMessageHandlers; +using Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Extensions; +using Elsa.Studio.Authentication.ElsaAuth.UI.Extensions; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; using Elsa.Studio.Branding; using Elsa.Studio.Contracts; using Elsa.Studio.Core.BlazorServer.Extensions; @@ -9,9 +14,6 @@ using Elsa.Studio.Localization.Options; using Elsa.Studio.Localization.Time; using Elsa.Studio.Localization.Time.Providers; -using Elsa.Studio.Login.BlazorServer.Extensions; -using Elsa.Studio.Login.Extensions; -using Elsa.Studio.Login.HttpMessageHandlers; using Elsa.Studio.Models; using Elsa.Studio.Shell.Extensions; using Elsa.Studio.Translations; @@ -67,15 +69,39 @@ builder.Services.AddCore().Replace(new(typeof(IBrandingProvider), typeof(StudioBrandingProvider), ServiceLifetime.Scoped)); builder.Services.AddShell(options => configuration.GetSection("Shell").Bind(options)); builder.Services.AddRemoteBackend(backendApiConfig); -builder.Services.AddLoginModule(); -//builder.Services.UseElsaIdentity(); -// builder.Services.UseOAuth2(options => -// { -// options.ClientId = "ElsaStudio"; -// options.TokenEndpoint = "https://localhost:44366/connect/token"; -// options.Scope = "YourSite offline_access"; -// }); -builder.Services.UseOpenIdConnect(openid => configuration.GetSection("Authentication:OpenIdConnect").Bind(openid)); + +// Choose authentication provider. +// Supported values: "OpenIdConnect" (default) or "ElsaAuth". +var authProvider = configuration["Authentication:Provider"]; +if (string.IsNullOrWhiteSpace(authProvider)) + authProvider = "OpenIdConnect"; + +authProvider = authProvider.Trim(); + +if (authProvider.Equals("ElsaAuth", StringComparison.OrdinalIgnoreCase)) +{ + // Elsa Identity (username/password against Elsa backend) + login UI at /login. + builder.Services.AddElsaAuth(); + builder.Services.AddElsaAuthUI(); +} +else if (authProvider.Equals("OpenIdConnect", StringComparison.OrdinalIgnoreCase)) +{ + // OpenID Connect. + builder.Services.AddOidcAuthentication(options => + { + configuration.GetSection("Authentication:OpenIdConnect").Bind(options); + + // If you see a 401 from the OIDC handler while calling the "userinfo" endpoint, + // either disable UserInfo retrieval (recommended for most setups), or configure your IdP/app registration + // to allow calling userinfo with the issued access token. + // options.GetClaimsFromUserInfoEndpoint = false; + }); +} +else +{ + throw new InvalidOperationException($"Unsupported Authentication:Provider value '{authProvider}'. Supported values are 'OpenIdConnect' and 'ElsaAuth'."); +} + builder.Services.AddDashboardModule(); builder.Services.AddWorkflowsModule(); builder.Services.AddLocalizationModule(localizationConfig); @@ -121,8 +147,9 @@ app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapBlazorHub(); app.MapFallbackToPage("/_Host"); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/src/hosts/Elsa.Studio.Host.Server/appsettings.json b/src/hosts/Elsa.Studio.Host.Server/appsettings.json index f5e2a3cdd..99c63899c 100644 --- a/src/hosts/Elsa.Studio.Host.Server/appsettings.json +++ b/src/hosts/Elsa.Studio.Host.Server/appsettings.json @@ -20,14 +20,24 @@ ] }, "Authentication": { + "Provider": "OpenIdConnect", "OpenIdConnect": { + "Authority": "https://login.microsoftonline.com/{tenantIdOrVerifiedDomain}/v2.0", "ClientId": "", "ClientSecret": "", - "AuthEndpoint": "https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize", - "EndSessionEndpoint": "https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/logout", - "TokenEndpoint": "https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token", - "Scopes": ["openid", "profile", "offline_access"], - "UsePkce": false + "Scopes": [ + "openid", + "profile", + "offline_access" + ], + "SaveTokens": true, + "TokenRefresh": { + "Strategy": "Persisted", + "Ping": { + "RefreshEndpointPath": "/authentication/refresh", + "Interval": "00:01:00" + } + } } } } diff --git a/src/hosts/Elsa.Studio.Host.Wasm/Elsa.Studio.Host.Wasm.csproj b/src/hosts/Elsa.Studio.Host.Wasm/Elsa.Studio.Host.Wasm.csproj index 10fda851d..a387b8ad2 100644 --- a/src/hosts/Elsa.Studio.Host.Wasm/Elsa.Studio.Host.Wasm.csproj +++ b/src/hosts/Elsa.Studio.Host.Wasm/Elsa.Studio.Host.Wasm.csproj @@ -21,9 +21,15 @@ + + + + + + + - diff --git a/src/hosts/Elsa.Studio.Host.Wasm/Program.cs b/src/hosts/Elsa.Studio.Host.Wasm/Program.cs index 7b3d43bc5..2d9ff4d99 100644 --- a/src/hosts/Elsa.Studio.Host.Wasm/Program.cs +++ b/src/hosts/Elsa.Studio.Host.Wasm/Program.cs @@ -1,3 +1,5 @@ +using Elsa.Studio.Authentication.Abstractions.HttpMessageHandlers; +using Elsa.Studio.Authentication.Abstractions.Models; using Elsa.Studio.Dashboard.Extensions; using Elsa.Studio.Shell; using Elsa.Studio.Shell.Extensions; @@ -7,15 +9,13 @@ using Elsa.Studio.Extensions; using Elsa.Studio.Localization.Time; using Elsa.Studio.Localization.Time.Providers; -using Elsa.Studio.Login.BlazorWasm.Extensions; -using Elsa.Studio.Login.HttpMessageHandlers; using Elsa.Studio.Models; using Elsa.Studio.Workflows.Designer.Extensions; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Elsa.Studio.Localization.Models; using Elsa.Studio.Localization.BlazorWasm.Extensions; -using Elsa.Studio.Login.Extensions; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Extensions; // Build the host. var builder = WebAssemblyHostBuilder.CreateDefault(args); @@ -41,8 +41,36 @@ builder.Services.AddCore(); builder.Services.AddShell(); builder.Services.AddRemoteBackend(backendApiConfig); -builder.Services.AddLoginModule(); -builder.Services.UseElsaIdentity(); + +// Configure token purposes for scope-aware token acquisition +builder.Services.Configure(configuration.GetSection("Authentication:TokenPurposes")); + +// Choose authentication provider. +// Supported values: "OpenIdConnect" (default) or "ElsaAuth". +var authProvider = configuration["Authentication:Provider"]; +if (string.IsNullOrWhiteSpace(authProvider)) + authProvider = "OpenIdConnect"; + +authProvider = authProvider.Trim(); + +if (authProvider.Equals("ElsaAuth", StringComparison.OrdinalIgnoreCase)) +{ + // Elsa Identity (username/password against Elsa backend) + login UI at /login. + Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.Extensions.ServiceCollectionExtensions.AddElsaAuth(builder.Services); + Elsa.Studio.Authentication.ElsaAuth.UI.Extensions.ServiceCollectionExtensions.AddElsaAuthUI(builder.Services); +} +else if (authProvider.Equals("OpenIdConnect", StringComparison.OrdinalIgnoreCase)) +{ + builder.Services.AddElsaOidcAuthentication(options => + { + configuration.GetSection("Authentication:OpenIdConnect").Bind(options); + }); +} +else +{ + throw new InvalidOperationException($"Unsupported Authentication:Provider value '{authProvider}'. Supported values are 'OpenIdConnect' and 'ElsaAuth'."); +} + builder.Services.AddDashboardModule(); builder.Services.AddWorkflowsModule(); builder.Services.AddLocalizationModule(localizationConfig); @@ -60,4 +88,5 @@ await startupTaskRunner.RunStartupTasksAsync(); // Run the application. -await app.RunAsync(); \ No newline at end of file +await app.RunAsync(); + diff --git a/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/appsettings.json b/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/appsettings.json index c0c96b6cf..f1bc12a97 100644 --- a/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/appsettings.json +++ b/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/appsettings.json @@ -7,5 +7,27 @@ }, "Backend": { "Url": "https://localhost:5001/elsa/api" + }, + "Authentication": { + "Provider": "OpenIdConnect", + "OpenIdConnect": { + "AppBaseUrl": "https://localhost:7052", + "Authority": "https://login.microsoftonline.com/f35bcd45-7991-4e24-84f1-e964394501ad/v2.0", + "ClientId": "0078a6b1-54d9-438b-9223-e26f07191949", + "Scopes": [ + "openid", + "profile", + "offline_access", + "https://graph.microsoft.com/User.Read" + ] + }, + "TokenPurposes": { + "BackendApiPurpose": "backend_api", + "ScopesByPurpose": { + "backend_api": [ + "api://dda3270c-997e-413a-9175-36b70134547c/elsa-server-api" + ] + } + } } } diff --git a/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html b/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html index 903ed6b22..3ef353a86 100644 --- a/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html +++ b/src/hosts/Elsa.Studio.Host.Wasm/wwwroot/index.html @@ -34,6 +34,10 @@
Loading... + + + + diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/IScopedAccessTokenProvider.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/IScopedAccessTokenProvider.cs new file mode 100644 index 000000000..5e6c3ea2f --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/IScopedAccessTokenProvider.cs @@ -0,0 +1,23 @@ +namespace Elsa.Studio.Authentication.Abstractions.Contracts; + +/// +/// Provides access tokens with specific scopes for different purposes. +/// +/// +/// This interface extends authentication providers to support scope-aware token acquisition, +/// allowing different tokens for different API audiences (e.g., Graph vs. backend API). +/// +public interface IScopedAccessTokenProvider +{ + /// + /// Gets an access token for the specified token name and scopes. + /// + /// The name of the token to retrieve (e.g., "access_token"). + /// The specific scopes to request for this token. If null or empty, uses default scopes. + /// Cancellation token. + /// The access token, or null if not available. + Task GetAccessTokenAsync( + string tokenName, + IEnumerable? scopes, + CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenAccessor.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenAccessor.cs new file mode 100644 index 000000000..2059ec6fe --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenAccessor.cs @@ -0,0 +1,17 @@ +namespace Elsa.Studio.Authentication.Abstractions.Contracts; + +/// +/// Provides access to authentication tokens stored by an authentication provider. +/// This abstraction allows different authentication providers (OIDC, JWT, OAuth2, etc.) +/// to implement token retrieval in their own way. +/// +public interface ITokenAccessor +{ + /// + /// Retrieves an authentication token by name. + /// + /// The name of the token to retrieve (e.g., "access_token", "id_token", "refresh_token"). + /// A cancellation token. + /// The token value, or null if not available. + Task GetTokenAsync(string tokenName, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenRefreshCoordinator.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenRefreshCoordinator.cs new file mode 100644 index 000000000..acee4f660 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenRefreshCoordinator.cs @@ -0,0 +1,13 @@ +namespace Elsa.Studio.Authentication.Abstractions.Contracts; + +/// +/// Coordinates token refresh operations to prevent multiple concurrent refresh attempts. +/// +public interface ITokenRefreshCoordinator +{ + /// + /// Ensures only one refresh operation runs at a time for the current scope. + /// + Task RunAsync(Func> action, CancellationToken cancellationToken); +} + diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Elsa.Studio.Authentication.Abstractions.csproj b/src/modules/Elsa.Studio.Authentication.Abstractions/Elsa.Studio.Authentication.Abstractions.csproj new file mode 100644 index 000000000..543729478 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Elsa.Studio.Authentication.Abstractions.csproj @@ -0,0 +1,12 @@ + + + + Shared abstractions for authentication providers in Elsa Studio. + elsa studio authentication abstractions + + + + + + + diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..3ed389392 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Elsa.Studio.Authentication.Abstractions.HttpMessageHandlers; +using Elsa.Studio.Authentication.Abstractions.Services; +using Elsa.Studio.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Elsa.Studio.Authentication.Abstractions.Contracts; + +namespace Elsa.Studio.Authentication.Abstractions.Extensions; + +/// +/// Extension methods for registering shared authentication infrastructure. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds shared authentication infrastructure services. + /// + public static IServiceCollection AddAuthenticationInfrastructure(this IServiceCollection services) + { + // Used by API clients to attach access tokens. + services.TryAddScoped(); + + // Used by modules (e.g. Workflows) to retrieve tokens without depending on a specific auth provider. + services.TryAddScoped(); + + // Coordinates refresh operations to prevent refresh storms under parallel requests. + services.TryAddScoped(); + + return services; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/HttpMessageHandlers/AuthenticatingApiHttpMessageHandler.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/HttpMessageHandlers/AuthenticatingApiHttpMessageHandler.cs new file mode 100644 index 000000000..08664c81d --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/HttpMessageHandlers/AuthenticatingApiHttpMessageHandler.cs @@ -0,0 +1,58 @@ +using Elsa.Studio.Authentication.Abstractions.Contracts; +using Elsa.Studio.Authentication.Abstractions.Models; +using Elsa.Studio.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.Abstractions.HttpMessageHandlers; + +/// +/// An that attaches an access token (if available) to outgoing HTTP requests. +/// +/// +/// This handler is authentication-provider-agnostic and does not attempt to refresh tokens itself. +/// Token acquisition/refresh is the responsibility of the active implementation +/// (e.g. OIDC via MSAL/RemoteAuthenticationService, ElsaAuth via stored JWTs, etc.). +/// +public class AuthenticatingApiHttpMessageHandler(IBlazorServiceAccessor blazorServiceAccessor) : DelegatingHandler +{ + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var sp = blazorServiceAccessor.Services; + var authenticationProvider = sp.GetService(); + + if (authenticationProvider == null) + return await base.SendAsync(request, cancellationToken); + + string? accessToken; + + // Check if the provider supports scoped token requests (OIDC providers) + if (authenticationProvider is IScopedAccessTokenProvider scopedProvider) + { + // Get token purpose configuration + var purposeOptions = sp.GetService>()?.Value; + + string[]? scopes = null; + + // Get scopes for the backend API purpose + purposeOptions?.ScopesByPurpose.TryGetValue(purposeOptions.BackendApiPurpose, out scopes); + + // Request token with backend API scopes if configured + accessToken = await scopedProvider.GetAccessTokenAsync(TokenNames.AccessToken, scopes, cancellationToken); + } + else + { + // Non-scoped providers (e.g., ElsaAuth JWT provider) + // These use a single token for all backend calls + accessToken = await authenticationProvider.GetAccessTokenAsync(TokenNames.AccessToken, cancellationToken); + } + + if (string.IsNullOrWhiteSpace(accessToken)) + request.Headers.Authorization = null; + else + request.Headers.Authorization = new("Bearer", accessToken); + + return await base.SendAsync(request, cancellationToken); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Models/AuthenticationOptions.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Models/AuthenticationOptions.cs new file mode 100644 index 000000000..f0258a6b0 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Models/AuthenticationOptions.cs @@ -0,0 +1,18 @@ +namespace Elsa.Studio.Authentication.Abstractions.Models; + +/// +/// Base configuration options for authentication providers. +/// Authentication providers can extend this class to add provider-specific options. +/// +public abstract class AuthenticationOptions +{ + /// + /// Gets or sets the scopes to request. + /// + public string[] Scopes { get; set; } = Array.Empty(); + + /// + /// Gets or sets whether to require HTTPS for metadata endpoints. + /// + public bool RequireHttpsMetadata { get; set; } = true; +} diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Models/TokenPurposeOptions.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Models/TokenPurposeOptions.cs new file mode 100644 index 000000000..7dc91a20f --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Models/TokenPurposeOptions.cs @@ -0,0 +1,23 @@ +namespace Elsa.Studio.Authentication.Abstractions.Models; + +/// +/// Configuration options for token purposes, allowing different scopes for different use cases. +/// +public sealed class TokenPurposeOptions +{ + /// + /// Maps purpose names to their required scopes. + /// + /// + /// { + /// "backend_api": ["api://my-api/scope"], + /// "graph": ["https://graph.microsoft.com/User.Read"] + /// } + /// + public Dictionary ScopesByPurpose { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Which purpose the API handler should use by default when calling backend APIs. + /// + public string BackendApiPurpose { get; set; } = "backend_api"; +} diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/README.md b/src/modules/Elsa.Studio.Authentication.Abstractions/README.md new file mode 100644 index 000000000..ec4f0e7fb --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/README.md @@ -0,0 +1,164 @@ +# Elsa Studio Authentication Abstractions + +Shared abstractions for authentication providers in Elsa Studio. + +## Overview + +This package provides common interfaces and base classes that can be shared across different authentication provider implementations (OIDC, OAuth2, JWT, SAML, etc.). It promotes consistency and reusability across authentication modules. + +## Purpose + +The abstractions package allows: + +1. **Multiple Authentication Providers**: Support various authentication mechanisms without duplicating code +2. **Consistent Patterns**: Provide a common token access pattern across all providers +3. **Extensibility**: Make it easy to add new authentication providers +4. **Decoupling**: Keep provider-specific code separate while maintaining shared contracts + +## Abstractions + +### ITokenAccessor + +The core abstraction for retrieving authentication tokens from any authentication provider. + +```csharp +public interface ITokenAccessor +{ + Task GetTokenAsync(string tokenName, CancellationToken cancellationToken = default); +} +``` + +**Usage**: Authentication providers implement this interface to provide access to tokens stored in their specific context (HTTP context, browser storage, etc.). + +### AuthenticationOptions + +A base class for authentication configuration that can be extended by specific providers. + +```csharp +public abstract class AuthenticationOptions +{ + public string[] Scopes { get; set; } + public bool RequireHttpsMetadata { get; set; } +} +``` + +**Usage**: Provider-specific options classes inherit from this to add their own configuration properties. + +## Example: Implementing a New Authentication Provider + +### 1. Create Provider-Specific Options + +```csharp +using Elsa.Studio.Authentication.Abstractions.Models; + +public class CustomAuthOptions : AuthenticationOptions +{ + public string Authority { get; set; } + public string ClientId { get; set; } + // Add provider-specific properties +} +``` + +### 2. Implement ITokenAccessor + +```csharp +using Elsa.Studio.Authentication.Abstractions.Contracts; + +public class CustomTokenAccessor : ITokenAccessor +{ + public async Task GetTokenAsync(string tokenName, CancellationToken cancellationToken) + { + // Retrieve token from your provider's storage/context + return await GetTokenFromCustomSource(tokenName); + } +} +``` + +### 3. Implement IAuthenticationProvider + +```csharp +using Elsa.Studio.Contracts; +using Elsa.Studio.Authentication.Abstractions.Contracts; + +public class CustomAuthenticationProvider : IAuthenticationProvider +{ + private readonly ITokenAccessor _tokenAccessor; + + public CustomAuthenticationProvider(ITokenAccessor tokenAccessor) + { + _tokenAccessor = tokenAccessor; + } + + public async Task GetAccessTokenAsync(string tokenName, CancellationToken cancellationToken) + { + // Map standard token names to provider-specific names if needed + return await _tokenAccessor.GetTokenAsync(tokenName, cancellationToken); + } +} +``` + +### 4. Register Services + +```csharp +services.AddScoped(); +services.AddScoped(); +``` + +## Existing Implementations + +The following authentication providers use these abstractions: + +- **Elsa.Studio.Authentication.OpenIdConnect** - OpenID Connect authentication + - `IOidcTokenAccessor : ITokenAccessor` - OIDC-specific token accessor + - `OidcOptions : AuthenticationOptions` - OIDC configuration + - Implementations for Blazor Server and WebAssembly + +## Design Philosophy + +### Why Abstractions? + +1. **Separation of Concerns**: Core token access logic is separated from provider-specific implementations +2. **Testability**: Easy to mock token accessors for unit testing +3. **Flexibility**: New authentication providers can be added without modifying existing code +4. **Future-Proof**: Changes to one provider don't affect others + +### Why Not More Abstractions? + +We intentionally keep the abstraction layer minimal: + +- Different authentication providers have significantly different flows +- Over-abstraction can make implementations harder to understand +- Provider-specific optimizations should not be constrained by abstractions +- The `IAuthenticationProvider` interface in `Elsa.Studio.Core` is already very flexible + +## Integration with Elsa Studio Core + +These abstractions work alongside the existing authentication infrastructure: + +``` +Elsa.Studio.Core +├── IAuthenticationProvider ← Called by Elsa Studio +│ └── Implemented by providers ← Uses ITokenAccessor internally +│ +Elsa.Studio.Authentication.Abstractions +├── ITokenAccessor ← Provider-agnostic token access +└── AuthenticationOptions ← Shared configuration +``` + +## Relationship with IAuthenticationProviderManager + +The `IAuthenticationProviderManager` (from `Elsa.Studio.Core`) iterates through registered `IAuthenticationProvider` implementations to find a valid token. This allows multiple authentication providers to coexist, with the manager selecting the first one that returns a token. + +## Future Extensions + +Potential additions to the abstractions: + +- `ITokenRefreshHandler` - For providers that support token refresh +- `IAuthenticationStateProvider` - For providers that need custom authentication state +- `IAuthenticationEventHandler` - For handling sign-in/sign-out events + +These would be added only when multiple providers require the same pattern. + +## License + +This package is part of Elsa Studio and follows the same license terms. diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Services/DefaultAuthenticationProviderManager.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Services/DefaultAuthenticationProviderManager.cs new file mode 100644 index 000000000..b08249f24 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Services/DefaultAuthenticationProviderManager.cs @@ -0,0 +1,26 @@ +using Elsa.Studio.Contracts; + +namespace Elsa.Studio.Authentication.Abstractions.Services; + +/// +/// Default implementation of that queries registered instances. +/// +public class DefaultAuthenticationProviderManager(IEnumerable authenticationProviders) : IAuthenticationProviderManager +{ + /// + public async Task GetAuthenticationTokenAsync(string? tokenName, CancellationToken cancellationToken = default) + { + var effectiveTokenName = tokenName ?? TokenNames.AccessToken; + + foreach (var authenticationProvider in authenticationProviders) + { + var token = await authenticationProvider.GetAccessTokenAsync(effectiveTokenName, cancellationToken); + + if (!string.IsNullOrWhiteSpace(token)) + return token; + } + + return null; + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Services/TokenRefreshCoordinator.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Services/TokenRefreshCoordinator.cs new file mode 100644 index 000000000..53d70870f --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Services/TokenRefreshCoordinator.cs @@ -0,0 +1,27 @@ +using Elsa.Studio.Authentication.Abstractions.Contracts; + +namespace Elsa.Studio.Authentication.Abstractions.Services; + +/// +/// Default implementation of . +/// +public class TokenRefreshCoordinator : ITokenRefreshCoordinator +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + + /// + public async Task RunAsync(Func> action, CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); + + try + { + return await action(cancellationToken); + } + finally + { + _semaphore.Release(); + } + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Elsa.Studio.Authentication.ElsaAuth.BlazorServer.csproj b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Elsa.Studio.Authentication.ElsaAuth.BlazorServer.csproj new file mode 100644 index 000000000..643c88bdd --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Elsa.Studio.Authentication.ElsaAuth.BlazorServer.csproj @@ -0,0 +1,17 @@ + + + + Elsa Studio ElsaAuth module for Blazor Server apps. + elsa studio authentication elsa auth blazor server + + + + + + + + + + + + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..484f6a0b4 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Blazored.LocalStorage; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.Extensions; +using Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Extensions; + +/// +/// Service registrations for ElsaAuth in Blazor Server. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds ElsaAuth services with Blazor Server implementations. + /// + public static IServiceCollection AddElsaAuth(this IServiceCollection services) + { + services.AddElsaAuthCore(); + + services.AddHttpContextAccessor(); + services.AddBlazoredLocalStorage(); + + services.AddSingleton(); + services.AddScoped(); + + return services; + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtAccessor.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtAccessor.cs new file mode 100644 index 000000000..197a681e8 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtAccessor.cs @@ -0,0 +1,46 @@ +using Blazored.LocalStorage; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Services; + +/// +/// Implements the interface for server-side Blazor. +/// +public class BlazorServerJwtAccessor : IJwtAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILocalStorageService _localStorageService; + + /// + /// Initializes a new instance of the class. + /// + public BlazorServerJwtAccessor(IHttpContextAccessor httpContextAccessor, ILocalStorageService localStorageService) + { + _httpContextAccessor = httpContextAccessor; + _localStorageService = localStorageService; + } + + private bool IsPrerendering() => _httpContextAccessor.HttpContext?.Response.HasStarted == false; + + /// + public async ValueTask WriteTokenAsync(string name, string token) => await _localStorageService.SetItemAsStringAsync(name, token); + + /// + public async ValueTask ReadTokenAsync(string name) + { + if (IsPrerendering()) + return null; + + return await _localStorageService.GetItemAsync(name); + } + + /// + public async ValueTask ClearTokenAsync(string name) + { + if (IsPrerendering()) + return; + + await _localStorageService.RemoveItemAsync(name); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtParser.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtParser.cs new file mode 100644 index 000000000..1e842e719 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtParser.cs @@ -0,0 +1,81 @@ +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using System.Security.Claims; +using System.Text; +using System.Text.Json; + +namespace Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Services; + +/// +public class BlazorServerJwtParser : IJwtParser +{ + /// + public IEnumerable Parse(string jwt) + { + if (string.IsNullOrWhiteSpace(jwt)) + return Array.Empty(); + + var parts = jwt.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + return Array.Empty(); + + var payloadJson = DecodeBase64UrlToString(parts[1]); + + using var document = JsonDocument.Parse(payloadJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return Array.Empty(); + + var claims = new List(); + + foreach (var property in document.RootElement.EnumerateObject()) + AddClaimsFromJson(property.Name, property.Value, claims); + + return claims; + } + + private static void AddClaimsFromJson(string type, JsonElement value, ICollection claims) + { + switch (value.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return; + + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) + AddClaimsFromJson(type, item, claims); + return; + + case JsonValueKind.Object: + // For nested objects, store the raw JSON. + claims.Add(new Claim(type, value.GetRawText(), ClaimValueTypes.String)); + return; + + case JsonValueKind.True: + case JsonValueKind.False: + claims.Add(new Claim(type, value.GetBoolean() ? "true" : "false", ClaimValueTypes.Boolean)); + return; + + case JsonValueKind.Number: + // Preserve as string; callers can interpret. + claims.Add(new Claim(type, value.GetRawText(), ClaimValueTypes.String)); + return; + + case JsonValueKind.String: + claims.Add(new Claim(type, value.GetString() ?? string.Empty, ClaimValueTypes.String)); + return; + + default: + claims.Add(new Claim(type, value.ToString(), ClaimValueTypes.String)); + return; + } + } + + private static string DecodeBase64UrlToString(string base64Url) + { + // base64url -> base64 + var padded = base64Url.Replace('-', '+').Replace('_', '/'); + padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + var bytes = Convert.FromBase64String(padded); + return Encoding.UTF8.GetString(bytes); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.csproj b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.csproj new file mode 100644 index 000000000..d1c9a3b87 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.csproj @@ -0,0 +1,13 @@ + + + + Elsa Studio ElsaAuth module for Blazor WebAssembly apps. + elsa studio authentication elsa auth blazor wasm + + + + + + + + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..83582aae9 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Blazored.LocalStorage; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.Extensions; +using Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.Extensions; + +/// +/// Service registrations for ElsaAuth in Blazor WebAssembly. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds ElsaAuth services with Blazor WebAssembly implementations. + /// + public static IServiceCollection AddElsaAuth(this IServiceCollection services) + { + services.AddElsaAuthCore(); + + services.AddBlazoredLocalStorage(); + + services.AddSingleton(); + services.AddScoped(); + + return services; + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtAccessor.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtAccessor.cs new file mode 100644 index 000000000..4b52ba62f --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtAccessor.cs @@ -0,0 +1,21 @@ +using Blazored.LocalStorage; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; + +namespace Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.Services; + +/// +public class BlazorWasmJwtAccessor : IJwtAccessor +{ + private readonly ILocalStorageService _localStorageService; + + public BlazorWasmJwtAccessor(ILocalStorageService localStorageService) => _localStorageService = localStorageService; + + /// + public async ValueTask ReadTokenAsync(string name) => await _localStorageService.GetItemAsync(name); + + /// + public async ValueTask WriteTokenAsync(string name, string token) => await _localStorageService.SetItemAsStringAsync(name, token); + + /// + public async ValueTask ClearTokenAsync(string name) => await _localStorageService.RemoveItemAsync(name); +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtParser.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtParser.cs new file mode 100644 index 000000000..fdf81ba39 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtParser.cs @@ -0,0 +1,76 @@ +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using System.Security.Claims; +using System.Text; +using System.Text.Json.Nodes; + +namespace Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.Services; + +/// +public class BlazorWasmJwtParser : IJwtParser +{ + /// + public IEnumerable Parse(string jwt) + { + if (string.IsNullOrWhiteSpace(jwt)) + return Array.Empty(); + + var parts = jwt.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + return Array.Empty(); + + JsonNode? root; + + try + { + var payloadJson = DecodeBase64UrlToString(parts[1]); + root = JsonNode.Parse(payloadJson); + } + catch + { + return Array.Empty(); + } + + if (root is not JsonObject obj) + return Array.Empty(); + + var claims = new List(); + + foreach (var (key, value) in obj) + { + if (string.IsNullOrWhiteSpace(key) || value == null) + continue; + + switch (value) + { + case JsonArray array: + foreach (var item in array) + { + if (item != null) + claims.Add(new Claim(key, item.ToString())); + } + + break; + + case JsonObject nestedObj: + // Preserve nested objects as JSON. + claims.Add(new Claim(key, nestedObj.ToJsonString())); + break; + + default: + claims.Add(new Claim(key, value.ToString())); + break; + } + } + + return claims; + } + + private static string DecodeBase64UrlToString(string base64Url) + { + // base64url -> base64 + var padded = base64Url.Replace('-', '+').Replace('_', '/'); + padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + var bytes = Convert.FromBase64String(padded); + return Encoding.UTF8.GetString(bytes); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/ComponentProviders/RedirectToLoginUnauthorizedComponentProvider.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/ComponentProviders/RedirectToLoginUnauthorizedComponentProvider.cs new file mode 100644 index 000000000..8d13ec24a --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/ComponentProviders/RedirectToLoginUnauthorizedComponentProvider.cs @@ -0,0 +1,14 @@ +using Elsa.Studio.Contracts; +using Elsa.Studio.Extensions; +using Elsa.Studio.Authentication.ElsaAuth.UI.Components; +using Microsoft.AspNetCore.Components; + +namespace Elsa.Studio.Authentication.ElsaAuth.UI.ComponentProviders; + +/// +public class RedirectToLoginUnauthorizedComponentProvider : IUnauthorizedComponentProvider +{ + /// + public RenderFragment GetUnauthorizedComponent() => builder => builder.CreateComponent(); +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Components/LoginState.razor b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Components/LoginState.razor new file mode 100644 index 000000000..ab5ab0a40 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Components/LoginState.razor @@ -0,0 +1,9 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Authorization +@using Variant = MudBlazor.Variant +@inject AuthenticationStateProvider AuthenticationStateProvider + + + Login + + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Components/RedirectToLogin.razor b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Components/RedirectToLogin.razor new file mode 100644 index 000000000..4caaa9fba --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Components/RedirectToLogin.razor @@ -0,0 +1,14 @@ +@using Microsoft.AspNetCore.Components +@inject NavigationManager NavigationManager + +@code { + protected override void OnAfterRender(bool firstRender) + { + if (!firstRender) + return; + + var returnUrl = Uri.EscapeDataString(NavigationManager.ToBaseRelativePath(NavigationManager.Uri)); + NavigationManager.NavigateTo($"/login?returnUrl={returnUrl}", true); + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Elsa.Studio.Authentication.ElsaAuth.UI.csproj b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Elsa.Studio.Authentication.ElsaAuth.UI.csproj new file mode 100644 index 000000000..da0b2672c --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Elsa.Studio.Authentication.ElsaAuth.UI.csproj @@ -0,0 +1,26 @@ + + + + Login UI for Elsa Studio ElsaAuth (Elsa Identity) authentication. + elsa studio authentication elsa auth ui + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..1d6f40a2d --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Elsa.Studio.Authentication.ElsaAuth.UI.ComponentProviders; +using Elsa.Studio.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Studio.Authentication.ElsaAuth.UI.Extensions; + +/// +/// Service registration extensions for the ElsaAuth UI module. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds Elsa Identity login UI (route: /login) and an unauthorized redirect behavior. + /// + public static IServiceCollection AddElsaAuthUI(this IServiceCollection services) + { + // Provide a default unauthorized UI for Elsa Identity. + services.AddScoped(); + + // Optional shell feature (adds app-bar login state UI). + services.AddScoped(); + + return services; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/LoginFeature.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/LoginFeature.cs new file mode 100644 index 000000000..480938e50 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/LoginFeature.cs @@ -0,0 +1,18 @@ +using Elsa.Studio.Abstractions; +using Elsa.Studio.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.UI.Components; + +namespace Elsa.Studio.Authentication.ElsaAuth.UI; + +/// +/// Adds a login app-bar component. +/// +public class LoginFeature(IAppBarService appBarService) : FeatureBase +{ + /// + public override ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { + appBarService.AddComponent(); + return base.InitializeAsync(cancellationToken); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor new file mode 100644 index 000000000..aa516a749 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor @@ -0,0 +1,57 @@ +@page "/login" +@using Elsa.Studio.Branding +@using Elsa.Studio.Layouts +@using Elsa.Studio.Localization +@inherits Elsa.Studio.Components.StudioComponentBase +@inject ILocalizer Localizer +@inject IBrandingProvider BrandingProvider +@layout BasicLayout + + + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor.cs new file mode 100644 index 000000000..fd18385b5 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor.cs @@ -0,0 +1,73 @@ +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.Services; +using Elsa.Studio.Contracts; +using Elsa.Studio.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.WebUtilities; +using MudBlazor; +using Radzen; + +namespace Elsa.Studio.Authentication.ElsaAuth.UI.Pages.Login; + +/// +/// The login page. +/// +[AllowAnonymous] +public partial class Login +{ + [Inject] private IJwtAccessor JwtAccessor { get; set; } = null!; + [Inject] private ICredentialsValidator CredentialsValidator { get; set; } = null!; + [Inject] private NavigationManager NavigationManager { get; set; } = null!; + [Inject] private AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!; + [Inject] private IClientInformationProvider ClientInformationProvider { get; set; } = null!; + [Inject] private IServerInformationProvider ServerInformationProvider { get; set; } = null!; + [Inject] private IUserMessageService UserMessageService { get; set; } = null!; + + private string ClientVersion { get; set; } = "3.0.0"; + private string ServerVersion { get; set; } = "3.0.0"; + + /// + protected override async Task OnInitializedAsync() + { + var clientInformation = await ClientInformationProvider.GetInfoAsync(); + var serverInformation = await ServerInformationProvider.GetInfoAsync(); + ClientVersion = clientInformation.PackageVersion; + ServerVersion = string.Join('.', serverInformation.PackageVersion.Split('.').Take(2)); + } + + private async Task TryLogin(LoginArgs args) + { + var isValid = await ValidateCredentials(args.Username, args.Password); + if (!isValid) + { + UserMessageService.ShowSnackbarTextMessage("Invalid credentials. Please try again", Severity.Error); + return; + } + + if (AuthenticationStateProvider is AccessTokenAuthenticationStateProvider tokenProvider) + tokenProvider.NotifyAuthenticationStateChanged(); + + var uri = new Uri(NavigationManager.Uri); + if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("returnUrl", out var returnUrl)) + NavigationManager.NavigateTo(returnUrl.FirstOrDefault() ?? string.Empty, true); + else + NavigationManager.NavigateTo(string.Empty, true); + } + + private async Task ValidateCredentials(string username, string password) + { + if (string.IsNullOrEmpty(username) && string.IsNullOrEmpty(password)) + return false; + + var result = await CredentialsValidator.ValidateCredentialsAsync(username, password); + + if (!result.IsValid) + return false; + + await JwtAccessor.WriteTokenAsync(TokenNames.AccessToken, result.AccessToken!); + await JwtAccessor.WriteTokenAsync(TokenNames.RefreshToken, result.RefreshToken!); + return true; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/README.md b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/README.md new file mode 100644 index 000000000..8cc869d63 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/README.md @@ -0,0 +1,33 @@ +# Elsa.Studio.Authentication.ElsaAuth.UI + +Provides the Elsa Identity login UI for Elsa Studio. + +## What you get +- A Blazor login page at `/login` +- A default unauthorized component provider that redirects to `/login?returnUrl=...` +- A simple app-bar login component (optional) + +## Usage +### 1) Configure ElsaAuth (Elsa Identity) +This UI module assumes you are using **Elsa Identity** (username/password against the Elsa backend API). + +In your host (Server or WASM), register ElsaAuth and the identity flow, then add the UI: + +```csharp +// Platform services: +builder.Services.AddElsaAuth(); + +// Core + Elsa Identity flow: +builder.Services.AddElsaAuthCore().UseElsaIdentityAuth(); + +// UI (this package): +builder.Services.AddElsaAuthUI(); +``` + +### 2) Switching providers (hosts) +Elsa Studio hosts can switch providers using configuration: + +- `Authentication:Provider` = `OpenIdConnect` or `ElsaAuth` +- `Authentication:OpenIdConnect` contains OIDC settings when using `OpenIdConnect` + +> Note: You still need to configure the backend URL to point to your Elsa API. diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor new file mode 100644 index 000000000..bd1015a97 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor @@ -0,0 +1,8 @@ +@using Elsa.Studio.Abstractions +@using Elsa.Studio.Contracts +@using Elsa.Studio.Components +@using Elsa.Studio.Layouts +@using Elsa.Studio.Services +@using MudBlazor +@using Radzen +@using Radzen.Blazor diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/ComponentProviders/DefaultUnauthorizedComponentProvider.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/ComponentProviders/DefaultUnauthorizedComponentProvider.cs new file mode 100644 index 000000000..a6bbb0f72 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/ComponentProviders/DefaultUnauthorizedComponentProvider.cs @@ -0,0 +1,20 @@ +using Elsa.Studio.Components; +using Elsa.Studio.Contracts; +using Microsoft.AspNetCore.Components; + +namespace Elsa.Studio.Authentication.ElsaAuth.ComponentProviders; + +/// +/// A safe default unauthorized component provider for ElsaAuth (renders the generic Unauthorized component). +/// Hosts can override this registration to provide custom unauthorized UX. +/// +public class DefaultUnauthorizedComponentProvider : IUnauthorizedComponentProvider +{ + /// + public RenderFragment GetUnauthorizedComponent() => builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }; +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IAuthorizationService.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IAuthorizationService.cs new file mode 100644 index 000000000..e48437d98 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IAuthorizationService.cs @@ -0,0 +1,18 @@ +namespace Elsa.Studio.Authentication.ElsaAuth.Contracts; + +/// +/// Performs authentication redirects and receives authorization codes when applicable. +/// +public interface IAuthorizationService +{ + /// + /// Redirects to the authorization server or login page. + /// + Task RedirectToAuthorizationServer(); + + /// + /// Receives an authorization code (used by legacy in-app OIDC code flow). + /// + Task ReceiveAuthorizationCode(string code, string? state, CancellationToken cancellationToken); +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/ICredentialsValidator.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/ICredentialsValidator.cs new file mode 100644 index 000000000..1cea3d690 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/ICredentialsValidator.cs @@ -0,0 +1,15 @@ +using Elsa.Studio.Authentication.ElsaAuth.Models; + +namespace Elsa.Studio.Authentication.ElsaAuth.Contracts; + +/// +/// Validates end-user credentials and returns tokens. +/// +public interface ICredentialsValidator +{ + /// + /// Validates credentials. + /// + ValueTask ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken = default); +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IEndSessionService.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IEndSessionService.cs new file mode 100644 index 000000000..fbbd3e0fd --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IEndSessionService.cs @@ -0,0 +1,13 @@ +namespace Elsa.Studio.Authentication.ElsaAuth.Contracts; + +/// +/// Ends the current session (logout). +/// +public interface IEndSessionService +{ + /// + /// Signs out. + /// + Task EndSessionAsync(CancellationToken cancellationToken = default); +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtAccessor.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtAccessor.cs new file mode 100644 index 000000000..fa95da1f5 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtAccessor.cs @@ -0,0 +1,22 @@ +namespace Elsa.Studio.Authentication.ElsaAuth.Contracts; + +/// +/// Reads and writes tokens to storage (e.g. cookies, local storage, etc.). +/// +public interface IJwtAccessor +{ + /// + /// Reads a token by name. + /// + ValueTask ReadTokenAsync(string name); + + /// + /// Writes a token by name. + /// + ValueTask WriteTokenAsync(string name, string token); + + /// + /// Removes a token from storage. + /// + ValueTask ClearTokenAsync(string name); +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtParser.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtParser.cs new file mode 100644 index 000000000..52573af76 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtParser.cs @@ -0,0 +1,15 @@ +using System.Security.Claims; + +namespace Elsa.Studio.Authentication.ElsaAuth.Contracts; + +/// +/// Parses JWT tokens into claims. +/// +public interface IJwtParser +{ + /// + /// Parses the specified JWT and returns claims. + /// + IEnumerable Parse(string token); +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IRefreshTokenService.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IRefreshTokenService.cs new file mode 100644 index 000000000..d01560c53 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IRefreshTokenService.cs @@ -0,0 +1,15 @@ +using Elsa.Api.Client.Resources.Identity.Responses; + +namespace Elsa.Studio.Authentication.ElsaAuth.Contracts; + +/// +/// Refreshes access tokens when the backend issues refresh tokens. +/// +public interface IRefreshTokenService +{ + /// + /// Refreshes the current token. + /// + Task RefreshTokenAsync(CancellationToken cancellationToken); +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Elsa.Studio.Authentication.ElsaAuth.csproj b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Elsa.Studio.Authentication.ElsaAuth.csproj new file mode 100644 index 000000000..f2bd5ba66 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Elsa.Studio.Authentication.ElsaAuth.csproj @@ -0,0 +1,22 @@ + + + + Elsa Studio authentication module for Elsa Identity (username/password + JWT token storage). + elsa studio authentication elsa auth + + + + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..a7eb16d8f --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,41 @@ +using Elsa.Studio.Authentication.Abstractions.Extensions; +using Elsa.Studio.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.ComponentProviders; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.Models; +using Elsa.Studio.Authentication.ElsaAuth.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.ElsaAuth.Extensions; + +/// +/// Service registration extensions for the ElsaAuth authentication module. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the ElsaAuth core services (provider-agnostic); call one of the Use* methods to select an auth flow. + /// + public static IServiceCollection AddElsaAuthCore(this IServiceCollection services) + { + services + .AddOptions() + .AddAuthorizationCore() + .AddAuthenticationInfrastructure() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + + services.AddHttpClient(ElsaIdentityRefreshTokenService.AnonymousClientName); + services.AddScoped(); + + // Default token claims mapping. + services.TryAddSingleton, DefaultIdentityTokenOptionsSetup>(); + + return services; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Models/IdentityTokenOptions.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Models/IdentityTokenOptions.cs new file mode 100644 index 000000000..0e63f4e08 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Models/IdentityTokenOptions.cs @@ -0,0 +1,18 @@ +namespace Elsa.Studio.Authentication.ElsaAuth.Models; + +/// +/// Options used by when creating the authenticated identity. +/// +public class IdentityTokenOptions +{ + /// + /// The claim type to use for the user's name. + /// + public string NameClaimType { get; set; } = "name"; + + /// + /// The claim type to use for the user's roles. + /// + public string RoleClaimType { get; set; } = "role"; +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Models/ValidateCredentialsResult.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Models/ValidateCredentialsResult.cs new file mode 100644 index 000000000..8e8409ec6 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Models/ValidateCredentialsResult.cs @@ -0,0 +1,7 @@ +namespace Elsa.Studio.Authentication.ElsaAuth.Models; + +/// +/// Result of validating credentials. +/// +public record ValidateCredentialsResult(bool IsValid, string? AccessToken, string? RefreshToken); + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/AccessTokenAuthenticationStateProvider.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/AccessTokenAuthenticationStateProvider.cs new file mode 100644 index 000000000..03ac81af8 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/AccessTokenAuthenticationStateProvider.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.Models; +using Elsa.Studio.Extensions; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.ElsaAuth.Services; + +/// +/// Provides the authentication state for the current user based on a JWT token. +/// +public class AccessTokenAuthenticationStateProvider( + IJwtAccessor jwtAccessor, + IJwtParser jwtParser, + IOptions options) + : AuthenticationStateProvider +{ + /// + public override async Task GetAuthenticationStateAsync() + { + var token = await jwtAccessor.ReadTokenAsync(TokenNames.IdToken) + ?? await jwtAccessor.ReadTokenAsync(TokenNames.AccessToken); + + if (string.IsNullOrWhiteSpace(token)) + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + + var claims = jwtParser.Parse(token).ToList(); + + if (claims.IsExpired()) + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + + var identity = new ClaimsIdentity(claims, "jwt", options.Value.NameClaimType, options.Value.RoleClaimType); + var user = new ClaimsPrincipal(identity); + + return new AuthenticationState(user); + } + + /// + /// Notifies the authentication state has changed. + /// + public void NotifyAuthenticationStateChanged() => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/DefaultIdentityTokenOptionsSetup.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/DefaultIdentityTokenOptionsSetup.cs new file mode 100644 index 000000000..af0c3365c --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/DefaultIdentityTokenOptionsSetup.cs @@ -0,0 +1,18 @@ +using Elsa.Studio.Authentication.ElsaAuth.Models; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.ElsaAuth.Services; + +/// +/// Provides default values for . +/// +public class DefaultIdentityTokenOptionsSetup : IConfigureOptions +{ + /// + public void Configure(IdentityTokenOptions options) + { + options.NameClaimType ??= "name"; + options.RoleClaimType ??= "role"; + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityAuthorizationService.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityAuthorizationService.cs new file mode 100644 index 000000000..1ffe51a3f --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityAuthorizationService.cs @@ -0,0 +1,22 @@ +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Microsoft.AspNetCore.Components; + +namespace Elsa.Studio.Authentication.ElsaAuth.Services; + +/// +public class ElsaIdentityAuthorizationService(NavigationManager navigationManager) : IAuthorizationService +{ + /// + public Task RedirectToAuthorizationServer() + { + var returnUrl = navigationManager.ToBaseRelativePath(navigationManager.Uri); + var loginUrl = string.IsNullOrWhiteSpace(returnUrl) ? "/login" : $"/login?returnUrl={returnUrl}"; + navigationManager.NavigateTo(loginUrl, true); + + return Task.CompletedTask; + } + + /// + public Task ReceiveAuthorizationCode(string code, string? state, CancellationToken cancellationToken) => throw new NotSupportedException(); +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityCredentialsValidator.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityCredentialsValidator.cs new file mode 100644 index 000000000..6c7566dab --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityCredentialsValidator.cs @@ -0,0 +1,22 @@ +using Elsa.Api.Client.Resources.Identity.Contracts; +using Elsa.Api.Client.Resources.Identity.Requests; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.Models; +using Elsa.Studio.Contracts; + +namespace Elsa.Studio.Authentication.ElsaAuth.Services; + +/// +/// An implementation of that consumes endpoints from Elsa.Identity. +/// +public class ElsaIdentityCredentialsValidator(IAnonymousBackendApiClientProvider backendApiClientProvider) : ICredentialsValidator +{ + /// + public async ValueTask ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken = default) + { + var api = await backendApiClientProvider.GetApiAsync(cancellationToken); + var request = new LoginRequest(username, password); + var response = await api.LoginAsync(request, cancellationToken); + return new ValidateCredentialsResult(response.IsAuthenticated, response.AccessToken, response.RefreshToken); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityEndSessionService.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityEndSessionService.cs new file mode 100644 index 000000000..2aab3a9af --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityEndSessionService.cs @@ -0,0 +1,16 @@ +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Microsoft.AspNetCore.Components; + +namespace Elsa.Studio.Authentication.ElsaAuth.Services; + +/// +public class ElsaIdentityEndSessionService(NavigationManager navigationManager) : IEndSessionService +{ + /// + public Task EndSessionAsync(CancellationToken cancellationToken = default) + { + navigationManager.NavigateTo("/logout", forceLoad: true); + return Task.CompletedTask; + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityRefreshTokenService.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityRefreshTokenService.cs new file mode 100644 index 000000000..d2f190f07 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityRefreshTokenService.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Net.Http.Json; +using Elsa.Api.Client.Resources.Identity.Responses; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; +using Elsa.Studio.Contracts; + +namespace Elsa.Studio.Authentication.ElsaAuth.Services; + +/// +public class ElsaIdentityRefreshTokenService(IRemoteBackendAccessor remoteBackendAccessor, IJwtAccessor jwtAccessor, IHttpClientFactory httpClientFactory) : IRefreshTokenService +{ + internal const string AnonymousClientName = "Elsa.Studio.Authentication.ElsaAuth.Anonymous"; + + /// + public async Task RefreshTokenAsync(CancellationToken cancellationToken) + { + // Get refresh token. + var refreshToken = await jwtAccessor.ReadTokenAsync(TokenNames.RefreshToken); + + if (string.IsNullOrWhiteSpace(refreshToken)) + return new(false, null, null); + + // Setup request to get new tokens. + var url = remoteBackendAccessor.RemoteBackend.Url + "/identity/refresh-token"; + var refreshRequestMessage = new HttpRequestMessage(HttpMethod.Post, url); + refreshRequestMessage.Headers.Authorization = new("Bearer", refreshToken); + + // IMPORTANT: Use an anonymous HttpClient (no AuthenticatingApiHttpMessageHandler) to avoid recursion. + var httpClient = httpClientFactory.CreateClient(AnonymousClientName); + + // Send request. + var response = await httpClient.SendAsync(refreshRequestMessage, cancellationToken); + + // If the refresh token is invalid, we can't do anything. + if (response.StatusCode == HttpStatusCode.Unauthorized) + return new(false, null, null); + + // Parse response into tokens. + var tokens = (await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken))!; + + // Store tokens. + if (!string.IsNullOrWhiteSpace(tokens.RefreshToken)) + await jwtAccessor.WriteTokenAsync(TokenNames.RefreshToken, tokens.RefreshToken); + + if (!string.IsNullOrWhiteSpace(tokens.AccessToken)) + await jwtAccessor.WriteTokenAsync(TokenNames.AccessToken, tokens.AccessToken); + + // Return tokens. + return tokens; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/JwtAuthenticationProvider.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/JwtAuthenticationProvider.cs new file mode 100644 index 000000000..f66a8c4d6 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/JwtAuthenticationProvider.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using System.Security.Claims; +using Elsa.Studio.Authentication.Abstractions.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.Models; +using Elsa.Studio.Extensions; +using Elsa.Studio.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.Contracts; + +namespace Elsa.Studio.Authentication.ElsaAuth.Services; + +/// +public class JwtAuthenticationProvider( + IJwtAccessor jwtAccessor, + IJwtParser jwtParser, + ITokenRefreshCoordinator refreshCoordinator, + IRefreshTokenService refreshTokenService) : IAuthenticationProvider +{ + private static readonly TimeSpan RefreshSkew = TimeSpan.FromMinutes(2); + + /// + public async Task GetAccessTokenAsync(string tokenName, CancellationToken cancellationToken = default) + { + // Only the access token participates in refresh. + if (!string.Equals(tokenName, TokenNames.AccessToken, StringComparison.Ordinal)) + return await jwtAccessor.ReadTokenAsync(tokenName); + + var accessToken = await jwtAccessor.ReadTokenAsync(TokenNames.AccessToken); + + if (string.IsNullOrWhiteSpace(accessToken)) + return null; + + if (!IsExpiredOrNearExpiry(accessToken)) + return accessToken; + + // Single-flight refresh: multiple concurrent API calls shouldn't trigger multiple refresh requests. + var refreshResponse = await refreshCoordinator.RunAsync(refreshTokenService.RefreshTokenAsync, cancellationToken); + + if (!refreshResponse.IsAuthenticated) + { + // Refresh failed: clear local tokens so the app can transition to unauthenticated state. + await jwtAccessor.ClearTokenAsync(TokenNames.AccessToken); + await jwtAccessor.ClearTokenAsync(TokenNames.RefreshToken); + await jwtAccessor.ClearTokenAsync(TokenNames.IdToken); + return null; + } + + return await jwtAccessor.ReadTokenAsync(TokenNames.AccessToken); + } + + private bool IsExpiredOrNearExpiry(string jwt) + { + try + { + var claims = jwtParser.Parse(jwt).ToList(); + var expString = claims.FirstOrDefault(x => x.Type == "exp")?.Value.Trim(); + if (string.IsNullOrWhiteSpace(expString) || !long.TryParse(expString, out var exp)) + return false; + + var expiresAt = DateTimeOffset.FromUnixTimeSeconds(exp); + return expiresAt <= DateTimeOffset.UtcNow.Add(RefreshSkew); + } + catch + { + // If parsing fails, don't attempt refresh here. + return false; + } + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/ComponentProviders/OidcUnauthorizedComponentProvider.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/ComponentProviders/OidcUnauthorizedComponentProvider.cs new file mode 100644 index 000000000..bd8043c0a --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/ComponentProviders/OidcUnauthorizedComponentProvider.cs @@ -0,0 +1,16 @@ +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Components; +using Elsa.Studio.Contracts; +using Elsa.Studio.Extensions; +using Microsoft.AspNetCore.Components; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.ComponentProviders; + +/// +/// Provides an unauthorized component that initiates an OpenID Connect challenge. +/// +public class OidcUnauthorizedComponentProvider : IUnauthorizedComponentProvider +{ + /// + public RenderFragment GetUnauthorizedComponent() => builder => builder.CreateComponent(); +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Components/ChallengeToLogin.razor b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Components/ChallengeToLogin.razor new file mode 100644 index 000000000..23fd9c914 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Components/ChallengeToLogin.razor @@ -0,0 +1,13 @@ +@using Microsoft.AspNetCore.Components +@inject NavigationManager NavigationManager +@code { + protected override void OnInitialized() + { + // Delegate to a server-side endpoint that triggers an OpenID Connect challenge. + // This keeps us aligned with ASP.NET Core authentication best practices. + var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + var url = $"/authentication/login?returnUrl={Uri.EscapeDataString("/" + returnUrl)}"; + NavigationManager.NavigateTo(url, forceLoad: true); + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Components/OidcPersistedRefreshPing.razor b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Components/OidcPersistedRefreshPing.razor new file mode 100644 index 000000000..430d8ef29 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Components/OidcPersistedRefreshPing.razor @@ -0,0 +1,11 @@ +@using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services +@inject BrowserRefreshPingService PingService + +@code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + await PingService.StartAsync(); + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Contracts/IScopedTokenCache.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Contracts/IScopedTokenCache.cs new file mode 100644 index 000000000..7ea6b0110 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Contracts/IScopedTokenCache.cs @@ -0,0 +1,45 @@ +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Contracts; + +/// +/// Cache for storing scope-specific access tokens. +/// +/// +/// This allows different tokens for different API audiences (e.g., Graph vs. backend API) +/// without overwriting the cookie's primary access token. +/// +public interface IScopedTokenCache +{ + /// + /// Gets a cached token for the specified user and scope set. + /// + /// User identifier (e.g., "sub" or "oid" claim). + /// Normalized scope key (sorted, hashed). + /// Cancellation token. + /// Cached token information, or null if not found or expired. + Task GetAsync(string userKey, string scopeKey, CancellationToken cancellationToken = default); + + /// + /// Stores a token for the specified user and scope set. + /// + /// User identifier (e.g., "sub" or "oid" claim). + /// Normalized scope key (sorted, hashed). + /// Token information to cache. + /// Cancellation token. + Task SetAsync(string userKey, string scopeKey, CachedToken token, CancellationToken cancellationToken = default); +} + +/// +/// Represents a cached token with its expiration. +/// +public sealed class CachedToken +{ + /// + /// The access token value. + /// + public required string AccessToken { get; init; } + + /// + /// When the token expires. + /// + public required DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Controllers/AuthenticationController.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Controllers/AuthenticationController.cs new file mode 100644 index 000000000..e5e9cbcc5 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Controllers/AuthenticationController.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Mvc; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Controllers; + +/// +/// Authentication entry points for initiating an OpenID Connect challenge/sign-out. +/// +[Route("authentication")] +public class AuthenticationController : Controller +{ + /// + /// Triggers an OpenID Connect challenge. + /// + [HttpGet("login")] + public IActionResult Login([FromQuery] string? returnUrl = null) + { + returnUrl = string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl; + return Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, OpenIdConnectDefaults.AuthenticationScheme); + } + + /// + /// Signs out from both the local cookie and the OpenID Connect provider. + /// + [HttpGet("logout")] + public IActionResult Logout([FromQuery] string? returnUrl = null) + { + returnUrl = string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl; + + return SignOut( + new AuthenticationProperties { RedirectUri = returnUrl }, + CookieAuthenticationDefaults.AuthenticationScheme, + OpenIdConnectDefaults.AuthenticationScheme); + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Controllers/TokenRefreshController.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Controllers/TokenRefreshController.cs new file mode 100644 index 000000000..cafef3cbb --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Controllers/TokenRefreshController.cs @@ -0,0 +1,27 @@ +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Controllers; + +/// +/// Endpoint used to perform persisted silent refresh (renew auth cookie) in a context where headers can be written. +/// +[Route("authentication")] +public class TokenRefreshController(OidcCookieTokenRefresher refresher, IOptions options) : Controller +{ + /// + /// Refreshes the access token (if needed) and renews the authentication cookie. + /// + [HttpPost("refresh")] + public async Task Refresh(CancellationToken cancellationToken) + { + if (options.Value.Strategy != OidcTokenRefreshStrategy.Persisted) + return NoContent(); + + var refreshed = await refresher.TryRefreshAndRenewCookieAsync(HttpContext, cancellationToken); + return refreshed ? Ok() : NoContent(); + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.csproj b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.csproj new file mode 100644 index 000000000..067a04ec7 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.csproj @@ -0,0 +1,21 @@ + + + + Provides OpenID Connect authentication for Elsa Studio with Blazor Server. + elsa studio authentication oidc blazor server + + + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Extensions/OidcPersistedRefreshServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Extensions/OidcPersistedRefreshServiceCollectionExtensions.cs new file mode 100644 index 000000000..27d2c6ce2 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Extensions/OidcPersistedRefreshServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions; + +/// +/// Extension methods for enabling persisted OIDC refresh behavior. +/// +public static class OidcPersistedRefreshServiceCollectionExtensions +{ + /// + /// Enables a background ping to the refresh endpoint so the auth cookie can be renewed on a normal HTTP request. + /// + public static IServiceCollection AddOidcPersistedRefreshBackgroundPing(this IServiceCollection services, Action? configure = null) + { + if (configure != null) + services.Configure(configure); + else + services.AddOptions(); + + services.AddHttpClient(OidcPersistedRefreshBackgroundService.ClientName); + services.AddHostedService(); + + return services; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..6402f0e13 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,116 @@ +using Elsa.Studio.Authentication.Abstractions.Extensions; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Contracts; +using Elsa.Studio.Authentication.OpenIdConnect.Contracts; +using Elsa.Studio.Authentication.OpenIdConnect.Models; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.ComponentProviders; +using Elsa.Studio.Contracts; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.DependencyInjection; +using OidcAuthProvider = Elsa.Studio.Authentication.OpenIdConnect.Services.OidcAuthenticationProvider; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions; + +/// +/// Extension methods for configuring OpenID Connect authentication in Blazor Server. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds OpenID Connect authentication services for Blazor Server. + /// + /// The service collection. + /// Configuration callback for OIDC options. + /// The service collection for chaining. + public static IServiceCollection AddOidcAuthentication( + this IServiceCollection services, + Action configure) + { + var options = new OidcOptions(); + configure(options); + + // Set Blazor Server defaults for callback paths if not explicitly specified. + options.CallbackPath ??= "/signin-oidc"; + options.SignedOutCallbackPath ??= "/signout-callback-oidc"; + + // Register the token accessor and cache + services.AddHttpContextAccessor(); + services.AddMemoryCache(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddOptions(); + services.AddScoped(); + + // Configure ASP.NET Core authentication with cookie and OIDC + services.AddAuthentication(authOptions => + { + authOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + authOptions.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, cookieOptions => + { + cookieOptions.Cookie.Name = "ElsaStudio.Auth"; + cookieOptions.Cookie.HttpOnly = true; + cookieOptions.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; + cookieOptions.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax; + cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8); + cookieOptions.SlidingExpiration = true; + }) + .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, oidcOptions => + { + oidcOptions.Authority = options.Authority; + oidcOptions.ClientId = options.ClientId; + oidcOptions.ClientSecret = options.ClientSecret; + oidcOptions.ResponseType = options.ResponseType; + oidcOptions.UsePkce = options.UsePkce; + oidcOptions.SaveTokens = options.SaveTokens; + oidcOptions.CallbackPath = options.CallbackPath; + oidcOptions.SignedOutCallbackPath = options.SignedOutCallbackPath; + oidcOptions.RequireHttpsMetadata = options.RequireHttpsMetadata; + oidcOptions.GetClaimsFromUserInfoEndpoint = options.GetClaimsFromUserInfoEndpoint; + + // Configure scopes + oidcOptions.Scope.Clear(); + foreach (var scope in options.Scopes) + { + oidcOptions.Scope.Add(scope); + } + + // Map token response properties to enable token refresh + oidcOptions.MapInboundClaims = false; + + if (!string.IsNullOrWhiteSpace(options.MetadataAddress)) + { + oidcOptions.MetadataAddress = options.MetadataAddress; + } + + // Configure token validation parameters + oidcOptions.TokenValidationParameters = new() + { + NameClaimType = "name", + RoleClaimType = "role", + ValidateIssuer = true + }; + }); + + // Add authorization services + services.AddAuthorizationCore(); + + // Use an OIDC-aware unauthorized component that initiates a challenge. + services.AddScoped(); + + // Shared auth infrastructure (e.g. delegating handlers). + services.AddAuthenticationInfrastructure(); + + services.AddOptions(); + services.AddHttpClient("Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Anonymous"); + + return services; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcRefreshConfiguration.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcRefreshConfiguration.cs new file mode 100644 index 000000000..8bf1152d7 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcRefreshConfiguration.cs @@ -0,0 +1,11 @@ +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; + +/// +/// Effective configuration to use for performing a refresh-token grant. +/// +public class OidcRefreshConfiguration(string tokenEndpoint, string clientId, string? clientSecret) +{ + public string TokenEndpoint { get; } = tokenEndpoint; + public string ClientId { get; } = clientId; + public string? ClientSecret { get; } = clientSecret; +} \ No newline at end of file diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcTokenRefreshOptions.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcTokenRefreshOptions.cs new file mode 100644 index 000000000..2e569430a --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcTokenRefreshOptions.cs @@ -0,0 +1,38 @@ +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; + +/// +/// Options for server-side OpenID Connect access-token refresh. +/// +public class OidcTokenRefreshOptions +{ + /// + /// Enables silent refresh using the refresh token stored in the auth cookie (requires SaveTokens=true + /// and requesting offline_access so a refresh token is issued). + /// + public bool EnableRefreshTokens { get; set; } = true; + + /// + /// How long before expiry we attempt refresh. + /// + public TimeSpan RefreshSkew { get; set; } = TimeSpan.FromMinutes(2); + + /// + /// Optional override for the token endpoint. If not set, it will be discovered via OIDC metadata. + /// + public string? TokenEndpoint { get; set; } + + /// + /// Optional override for the client ID. If not set, uses the configured OIDC client id. + /// + public string? ClientId { get; set; } + + /// + /// Optional override for the client secret. + /// + public string? ClientSecret { get; set; } + + /// + /// Determines how the module performs access-token refresh in Blazor Server. + /// + public OidcTokenRefreshStrategy Strategy { get; set; } = OidcTokenRefreshStrategy.BestEffort; +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcTokenRefreshStrategy.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcTokenRefreshStrategy.cs new file mode 100644 index 000000000..6ba33ee46 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcTokenRefreshStrategy.cs @@ -0,0 +1,20 @@ +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; + +/// +/// Determines how Blazor Server should perform access-token refresh. +/// +public enum OidcTokenRefreshStrategy +{ + /// + /// Best-effort refresh. The module will only refresh when it can also renew the auth cookie + /// (i.e., when HTTP response headers can still be written). + /// + BestEffort = 0, + + /// + /// Persist tokens by renewing the auth cookie via a dedicated refresh endpoint. + /// This can maintain long-lived sessions without interactive re-authentication. + /// + Persisted = 1 +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/BrowserRefreshPingService.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/BrowserRefreshPingService.cs new file mode 100644 index 000000000..41b78d7db --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/BrowserRefreshPingService.cs @@ -0,0 +1,19 @@ +using Microsoft.JSInterop; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// Starts a per-user browser-side timer that periodically POSTs to the refresh endpoint. +/// The browser request carries the authenticated cookie, enabling persisted token refresh. +/// +public class BrowserRefreshPingService(IJSRuntime jsRuntime, IOptions options) +{ + /// + /// Starts the refresh ping loop. + /// Safe to call multiple times. + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + => await jsRuntime.InvokeVoidAsync("elsaStudioOidcRefresh.start", cancellationToken, options.Value.RefreshEndpointPath, (int)options.Value.Interval.TotalMilliseconds); +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/DefaultOidcRefreshConfigurationProvider.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/DefaultOidcRefreshConfigurationProvider.cs new file mode 100644 index 000000000..8d5663b76 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/DefaultOidcRefreshConfigurationProvider.cs @@ -0,0 +1,47 @@ +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// Default implementation that resolves the token endpoint from the configured +/// and OIDC metadata, with optional overrides from . +/// +public class DefaultOidcRefreshConfigurationProvider( + IOptionsMonitor oidcOptionsMonitor, + IOptions refreshOptions) : IOidcRefreshConfigurationProvider +{ + /// + public async ValueTask GetAsync(CancellationToken cancellationToken = default) + { + var options = oidcOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme); + var overrides = refreshOptions.Value; + + var clientId = overrides.ClientId ?? options.ClientId; + var clientSecret = overrides.ClientSecret ?? options.ClientSecret; + + if (string.IsNullOrWhiteSpace(clientId)) + return null; + + // Determine the token endpoint. + var tokenEndpoint = overrides.TokenEndpoint; + + if (string.IsNullOrWhiteSpace(tokenEndpoint)) + { + // Best practice: use the handler's configuration manager to fetch metadata. + var configurationManager = options.ConfigurationManager; + + if (configurationManager != null) + { + var config = await configurationManager.GetConfigurationAsync(cancellationToken); + tokenEndpoint = config?.TokenEndpoint; + } + } + + if (string.IsNullOrWhiteSpace(tokenEndpoint)) + return null; + + return new(tokenEndpoint, clientId, clientSecret); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/IOidcRefreshConfigurationProvider.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/IOidcRefreshConfigurationProvider.cs new file mode 100644 index 000000000..4dddf9404 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/IOidcRefreshConfigurationProvider.cs @@ -0,0 +1,14 @@ +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// Resolves the effective token endpoint and client credentials to use for server-side OIDC refresh. +/// +public interface IOidcRefreshConfigurationProvider +{ + /// + /// Gets the effective refresh configuration or null if refresh is not possible. + /// + ValueTask GetAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/MemoryScopedTokenCache.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/MemoryScopedTokenCache.cs new file mode 100644 index 000000000..f7710ccef --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/MemoryScopedTokenCache.cs @@ -0,0 +1,92 @@ +using System.Security.Cryptography; +using System.Text; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Contracts; +using Microsoft.Extensions.Caching.Memory; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// In-memory implementation of . +/// +/// +/// For production scenarios with multiple servers, consider implementing +/// a distributed cache version (e.g., using IDistributedCache). +/// +public class MemoryScopedTokenCache : IScopedTokenCache +{ + private readonly IMemoryCache _cache; + private static readonly TimeSpan DefaultSkew = TimeSpan.FromMinutes(5); + + /// + /// Initializes a new instance of the class. + /// + public MemoryScopedTokenCache(IMemoryCache cache) + { + _cache = cache; + } + + /// + public Task GetAsync(string userKey, string scopeKey, CancellationToken cancellationToken = default) + { + var cacheKey = GetCacheKey(userKey, scopeKey); + + if (_cache.TryGetValue(cacheKey, out var token)) + { + // Check if token is still valid (with skew) + if (token.ExpiresAt > DateTimeOffset.UtcNow.Add(DefaultSkew)) + { + return Task.FromResult(token); + } + + // Token expired, remove from cache + _cache.Remove(cacheKey); + } + + return Task.FromResult(null); + } + + /// + public Task SetAsync(string userKey, string scopeKey, CachedToken token, CancellationToken cancellationToken = default) + { + var cacheKey = GetCacheKey(userKey, scopeKey); + + // Cache until token expiration + var cacheExpiration = token.ExpiresAt - DateTimeOffset.UtcNow; + if (cacheExpiration > TimeSpan.Zero) + { + _cache.Set(cacheKey, token, cacheExpiration); + } + + return Task.CompletedTask; + } + + /// + /// Generates a cache key from user and scope identifiers. + /// + private static string GetCacheKey(string userKey, string scopeKey) + { + return $"scoped_token:{userKey}:{scopeKey}"; + } + + /// + /// Normalizes scopes into a stable key (sorted, space-separated, hashed). + /// + public static string NormalizeScopeKey(IEnumerable scopes) + { + var sortedScopes = scopes + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (sortedScopes.Count == 0) + return "default"; + + var scopeString = string.Join(" ", sortedScopes); + + // Hash for consistent key length + using var sha256 = SHA256.Create(); + var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(scopeString)); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcCookieTokenRefresher.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcCookieTokenRefresher.cs new file mode 100644 index 000000000..6046357c2 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcCookieTokenRefresher.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using System.Text.Json; +using Elsa.Studio.Authentication.Abstractions.Contracts; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// Refreshes the OIDC access token and persists it by renewing the auth cookie. +/// Intended to be invoked from a normal HTTP endpoint where headers can still be written. +/// +public class OidcCookieTokenRefresher( + ITokenRefreshCoordinator refreshCoordinator, + IOidcRefreshConfigurationProvider refreshConfigurationProvider, + IHttpClientFactory httpClientFactory, + IOptions refreshOptions) +{ + private const string AnonymousClientName = "Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Anonymous"; + + /// + /// Attempts to refresh tokens and renew the cookie if needed. + /// Returns true if tokens were refreshed and persisted; otherwise false. + /// + public async Task TryRefreshAndRenewCookieAsync(HttpContext httpContext, CancellationToken cancellationToken = default) + { + var options = refreshOptions.Value; + + if (!options.EnableRefreshTokens) + return false; + + if (httpContext.User.Identity?.IsAuthenticated != true) + return false; + + // Must be able to write cookies. + if (httpContext.Response.HasStarted) + return false; + + var refreshToken = await httpContext.GetTokenAsync("refresh_token"); + var expiresAtString = await httpContext.GetTokenAsync("expires_at"); + + if (string.IsNullOrWhiteSpace(refreshToken) || string.IsNullOrWhiteSpace(expiresAtString)) + return false; + + if (!DateTimeOffset.TryParse(expiresAtString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var expiresAt)) + return false; + + if (expiresAt > DateTimeOffset.UtcNow.Add(options.RefreshSkew)) + return false; + + var didRefresh = false; + + await refreshCoordinator.RunAsync(async ct => + { + // Check again under the lock. + var currentExpiresAtString = await httpContext.GetTokenAsync("expires_at"); + if (!DateTimeOffset.TryParse(currentExpiresAtString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var currentExpiresAt)) + return 0; + + if (currentExpiresAt > DateTimeOffset.UtcNow.Add(options.RefreshSkew)) + return 0; + + var refreshConfig = await refreshConfigurationProvider.GetAsync(ct); + if (refreshConfig == null) + return 0; + + var httpClient = httpClientFactory.CreateClient(AnonymousClientName); + + using var request = new HttpRequestMessage(HttpMethod.Post, refreshConfig.TokenEndpoint); + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = refreshConfig.ClientId, + ["refresh_token"] = refreshToken, + ["client_secret"] = refreshConfig.ClientSecret ?? string.Empty + }.Where(x => !string.IsNullOrWhiteSpace(x.Value))); + + var response = await httpClient.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + return 0; + + var payload = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(payload); + + var newAccessToken = doc.RootElement.TryGetProperty("access_token", out var at) ? at.GetString() : null; + var newRefreshToken = doc.RootElement.TryGetProperty("refresh_token", out var rt) ? rt.GetString() : null; + var expiresInSeconds = doc.RootElement.TryGetProperty("expires_in", out var exp) ? exp.GetInt32() : 0; + + if (string.IsNullOrWhiteSpace(newAccessToken) || expiresInSeconds <= 0) + return 0; + + var newExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds); + + var authResult = await httpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (!authResult.Succeeded) + return 0; + + authResult.Properties.UpdateTokenValue("access_token", newAccessToken); + authResult.Properties.UpdateTokenValue("expires_at", newExpiresAt.ToString("o", CultureInfo.InvariantCulture)); + + if (!string.IsNullOrWhiteSpace(newRefreshToken)) + authResult.Properties.UpdateTokenValue("refresh_token", newRefreshToken); + + await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, authResult.Principal!, authResult.Properties); + + didRefresh = true; + return 0; + }, cancellationToken); + + return didRefresh; + } +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcPersistedRefreshBackgroundService.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcPersistedRefreshBackgroundService.cs new file mode 100644 index 000000000..95c7efcc1 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcPersistedRefreshBackgroundService.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// Periodically calls the persisted refresh endpoint so cookies can be renewed on a normal HTTP request. +/// NOTE: This service does not have access to per-user authentication cookies and therefore cannot reliably +/// trigger persisted refresh for signed-in users. Prefer invoking the refresh endpoint from the browser (per-user) +/// or from a request that carries the user's auth cookie. +/// +public class OidcPersistedRefreshBackgroundService( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger, + IOptions refreshOptions) : BackgroundService +{ + internal const string ClientName = "Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.PersistedRefresh"; + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // This background service runs process-wide and cannot carry user cookies. + // To avoid giving a false sense of security, we only run when the strategy is BestEffort. + if (refreshOptions.Value.Strategy == OidcTokenRefreshStrategy.Persisted) + { + logger.LogWarning("OidcPersistedRefreshBackgroundService is not effective for Persisted refresh because it cannot send per-user auth cookies. Use a browser-side ping to POST {Path} instead.", options.Value.RefreshEndpointPath); + return; + } + + var settings = options.Value; + + // Delay loop. + while (!stoppingToken.IsCancellationRequested) + { + try + { + var client = httpClientFactory.CreateClient(ClientName); + + // POST because it potentially mutates auth cookie. + using var request = new HttpRequestMessage(HttpMethod.Post, settings.RefreshEndpointPath); + + var response = await client.SendAsync(request, stoppingToken); + + // Ignore failures; the next interactive request will handle auth. + if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NoContent) + logger.LogDebug("Persisted OIDC refresh ping returned status code {StatusCode}", response.StatusCode); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Shutting down. + } + catch (Exception ex) + { + logger.LogDebug(ex, "Persisted OIDC refresh ping failed"); + } + + await Task.Delay(settings.Interval, stoppingToken); + } + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcPersistedRefreshClientOptions.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcPersistedRefreshClientOptions.cs new file mode 100644 index 000000000..442b31f48 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcPersistedRefreshClientOptions.cs @@ -0,0 +1,18 @@ +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// Client-side options for invoking the persisted refresh endpoint. +/// +public class OidcPersistedRefreshClientOptions +{ + /// + /// The relative URL of the refresh endpoint. + /// + public string RefreshEndpointPath { get; set; } = "/authentication/refresh"; + + /// + /// How often the client should ping the refresh endpoint. + /// + public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(1); +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcTokenRefreshOptionsAccessor.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcTokenRefreshOptionsAccessor.cs new file mode 100644 index 000000000..9d988909a --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/OidcTokenRefreshOptionsAccessor.cs @@ -0,0 +1,16 @@ +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; +using Microsoft.Extensions.Options; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// Helps razor components determine whether persisted refresh is enabled. +/// +public class OidcTokenRefreshOptionsAccessor(IOptions options) +{ + /// + /// Gets the configured refresh strategy. + /// + public OidcTokenRefreshStrategy Strategy => options.Value.Strategy; +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/ServerOidcTokenAccessor.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/ServerOidcTokenAccessor.cs new file mode 100644 index 000000000..772a022ae --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/ServerOidcTokenAccessor.cs @@ -0,0 +1,265 @@ +using System.Globalization; +using System.Security.Claims; +using System.Text.Json; +using Elsa.Studio.Authentication.Abstractions.Contracts; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Contracts; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; +using Elsa.Studio.Authentication.OpenIdConnect.Contracts; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Services; + +/// +/// Blazor Server implementation of that retrieves tokens from the authenticated HTTP context. +/// +public class ServerOidcTokenAccessor : IOidcTokenAccessorWithScopes +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ITokenRefreshCoordinator _refreshCoordinator; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IOptions _refreshOptions; + private readonly IOidcRefreshConfigurationProvider _refreshConfigurationProvider; + private readonly OidcCookieTokenRefresher _cookieTokenRefresher; + private readonly IScopedTokenCache _scopedTokenCache; + + /// + /// Initializes a new instance of the class. + /// + public ServerOidcTokenAccessor( + IHttpContextAccessor httpContextAccessor, + ITokenRefreshCoordinator refreshCoordinator, + IHttpClientFactory httpClientFactory, + IOptions refreshOptions, + IOidcRefreshConfigurationProvider refreshConfigurationProvider, + OidcCookieTokenRefresher cookieTokenRefresher, + IScopedTokenCache scopedTokenCache) + { + _httpContextAccessor = httpContextAccessor; + _refreshCoordinator = refreshCoordinator; + _httpClientFactory = httpClientFactory; + _refreshOptions = refreshOptions; + _refreshConfigurationProvider = refreshConfigurationProvider; + _cookieTokenRefresher = cookieTokenRefresher; + _scopedTokenCache = scopedTokenCache; + } + + /// + public Task GetTokenAsync(string tokenName, CancellationToken cancellationToken = default) + { + // Forward to scoped overload with null scopes (use default cookie token) + return GetTokenAsync(tokenName, scopes: null, cancellationToken); + } + + /// + public async Task GetTokenAsync(string tokenName, IEnumerable? scopes, CancellationToken cancellationToken = default) + { + var httpContext = _httpContextAccessor.HttpContext; + + if (httpContext?.User.Identity?.IsAuthenticated != true) + return null; + + // If scopes are provided and token is access_token, acquire scope-specific token + var scopeArray = scopes?.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray(); + if (scopeArray?.Length > 0 && string.Equals(tokenName, "access_token", StringComparison.Ordinal)) + { + return await GetScopedAccessTokenAsync(httpContext, scopeArray, cancellationToken); + } + + // Otherwise, use existing cookie token refresh flow + if (string.Equals(tokenName, "access_token", StringComparison.Ordinal)) + { + var options = _refreshOptions.Value; + + if (options.EnableRefreshTokens && options.Strategy == OidcTokenRefreshStrategy.Persisted) + { + // In Persisted mode, try to renew the cookie if this is a normal HTTP request. + // During Blazor circuit activity, Response.HasStarted is typically true and renewal will be skipped. + await _cookieTokenRefresher.TryRefreshAndRenewCookieAsync(httpContext, cancellationToken); + } + else + { + await TryRefreshAccessTokenAsync(httpContext, cancellationToken); + } + } + + // Retrieve the token from the authentication properties. + return await httpContext.GetTokenAsync(tokenName); + } + + /// + /// Acquires a scope-specific access token using refresh token grant with explicit scopes. + /// + private async Task GetScopedAccessTokenAsync(HttpContext httpContext, string[] scopes, CancellationToken cancellationToken) + { + // Get user identifier for cache key + var userKey = httpContext.User.FindFirstValue("sub") ?? httpContext.User.FindFirstValue("oid") ?? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userKey)) + return null; + + // Generate scope key for cache + var scopeKey = MemoryScopedTokenCache.NormalizeScopeKey(scopes); + + // Check cache first + var cachedToken = await _scopedTokenCache.GetAsync(userKey, scopeKey, cancellationToken); + if (cachedToken != null) + return cachedToken.AccessToken; + + // Acquire new token with specific scopes + var refreshToken = await httpContext.GetTokenAsync("refresh_token"); + if (string.IsNullOrWhiteSpace(refreshToken)) + return null; + + var refreshConfig = await _refreshConfigurationProvider.GetAsync(cancellationToken); + if (refreshConfig == null) + return null; + + // Use coordinator to prevent concurrent requests for same scope set + var lockKey = $"{userKey}:{scopeKey}"; + string? newToken = null; + + await _refreshCoordinator.RunAsync(async ct => + { + // Re-check cache after acquiring lock + var cachedAfterLock = await _scopedTokenCache.GetAsync(userKey, scopeKey, ct); + if (cachedAfterLock != null) + { + newToken = cachedAfterLock.AccessToken; + return 0; + } + + // Request token with explicit scopes + var httpClient = _httpClientFactory.CreateClient("Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Anonymous"); + + using var request = new HttpRequestMessage(HttpMethod.Post, refreshConfig.TokenEndpoint); + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = refreshConfig.ClientId, + ["refresh_token"] = refreshToken, + ["scope"] = string.Join(" ", scopes), + ["client_secret"] = refreshConfig.ClientSecret ?? string.Empty + }.Where(x => !string.IsNullOrWhiteSpace(x.Value))); + + var response = await httpClient.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + return 0; + + var payload = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(payload); + + var accessToken = doc.RootElement.TryGetProperty("access_token", out var at) ? at.GetString() : null; + var expiresInSeconds = doc.RootElement.TryGetProperty("expires_in", out var exp) ? exp.GetInt32() : 0; + + if (string.IsNullOrWhiteSpace(accessToken) || expiresInSeconds <= 0) + return 0; + + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds); + + // Cache the token + await _scopedTokenCache.SetAsync(userKey, scopeKey, new CachedToken + { + AccessToken = accessToken, + ExpiresAt = expiresAt + }, ct); + + newToken = accessToken; + return 0; + }, cancellationToken); + + return newToken; + } + + private async Task TryRefreshAccessTokenAsync(HttpContext httpContext, CancellationToken cancellationToken) + { + var options = _refreshOptions.Value; + + if (!options.EnableRefreshTokens) + return; + + // BestEffort refresh can only persist tokens by renewing the cookie. + // In Persisted mode, cookie renewal is performed via the /authentication/refresh endpoint. + if (options.Strategy != OidcTokenRefreshStrategy.BestEffort) + return; + + // If headers are already sent, we cannot renew the cookie without throwing. + if (httpContext.Response.HasStarted) + return; + + // SaveTokens must be enabled or tokens won't be in the auth cookie. + var accessToken = await httpContext.GetTokenAsync("access_token"); + var refreshToken = await httpContext.GetTokenAsync("refresh_token"); + var expiresAtString = await httpContext.GetTokenAsync("expires_at"); + + if (string.IsNullOrWhiteSpace(accessToken) || string.IsNullOrWhiteSpace(refreshToken) || string.IsNullOrWhiteSpace(expiresAtString)) + return; + + if (!DateTimeOffset.TryParse(expiresAtString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var expiresAt)) + return; + + if (expiresAt > DateTimeOffset.UtcNow.Add(options.RefreshSkew)) + return; // still valid + + await _refreshCoordinator.RunAsync(async ct => + { + // Re-check after acquiring the lock. + var currentExpiresAtString = await httpContext.GetTokenAsync("expires_at"); + if (!DateTimeOffset.TryParse(currentExpiresAtString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var currentExpiresAt)) + return 0; + + if (currentExpiresAt > DateTimeOffset.UtcNow.Add(options.RefreshSkew)) + return 0; + + var refreshConfig = await _refreshConfigurationProvider.GetAsync(ct); + if (refreshConfig == null) + return 0; + + var httpClient = _httpClientFactory.CreateClient("Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Anonymous"); + + using var request = new HttpRequestMessage(HttpMethod.Post, refreshConfig.TokenEndpoint); + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = refreshConfig.ClientId, + ["refresh_token"] = refreshToken, + // client_secret is optional depending on provider/client type. + ["client_secret"] = refreshConfig.ClientSecret ?? string.Empty + }.Where(x => !string.IsNullOrWhiteSpace(x.Value))); + + var response = await httpClient.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + return 0; + + var payload = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(payload); + + var newAccessToken = doc.RootElement.TryGetProperty("access_token", out var at) ? at.GetString() : null; + var newRefreshToken = doc.RootElement.TryGetProperty("refresh_token", out var rt) ? rt.GetString() : null; + var expiresInSeconds = doc.RootElement.TryGetProperty("expires_in", out var exp) ? exp.GetInt32() : 0; + + if (string.IsNullOrWhiteSpace(newAccessToken) || expiresInSeconds <= 0) + return 0; + + var newExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds); + + var authResult = await httpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (!authResult.Succeeded) + return 0; + + authResult.Properties.UpdateTokenValue("access_token", newAccessToken); + authResult.Properties.UpdateTokenValue("expires_at", newExpiresAt.ToString("o", CultureInfo.InvariantCulture)); + + if (!string.IsNullOrWhiteSpace(newRefreshToken)) + authResult.Properties.UpdateTokenValue("refresh_token", newRefreshToken); + + // Re-issue the cookie with the updated tokens. + await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, authResult.Principal!, authResult.Properties); + + return 0; + }, cancellationToken); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/ComponentProviders/OidcUnauthorizedComponentProvider.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/ComponentProviders/OidcUnauthorizedComponentProvider.cs new file mode 100644 index 000000000..fc5aaa1e2 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/ComponentProviders/OidcUnauthorizedComponentProvider.cs @@ -0,0 +1,16 @@ +using Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Components; +using Elsa.Studio.Contracts; +using Elsa.Studio.Extensions; +using Microsoft.AspNetCore.Components; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.ComponentProviders; + +/// +/// Provides an unauthorized component that navigates to the built-in WASM OIDC login endpoint. +/// +public class OidcUnauthorizedComponentProvider : IUnauthorizedComponentProvider +{ + /// + public RenderFragment GetUnauthorizedComponent() => builder => builder.CreateComponent(); +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Components/NavigateToLogin.razor b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Components/NavigateToLogin.razor new file mode 100644 index 000000000..f93017058 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Components/NavigateToLogin.razor @@ -0,0 +1,11 @@ +@inject NavigationManager NavigationManager + +@code { + protected override void OnInitialized() + { + // The WASM host must provide the /authentication/{action} route hosting RemoteAuthenticatorView. + // Use an absolute path and force a full page load to avoid issues when we're in a nested route. + var url = "/authentication/login"; + NavigationManager.NavigateTo(url, forceLoad: true); + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.csproj b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.csproj new file mode 100644 index 000000000..964b45131 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.csproj @@ -0,0 +1,21 @@ + + + + Provides OpenID Connect authentication for Elsa Studio with Blazor WebAssembly. + elsa studio authentication oidc blazor wasm webassembly + + + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c4cb5032b --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,88 @@ +using Elsa.Studio.Authentication.OpenIdConnect.Contracts; +using Elsa.Studio.Authentication.OpenIdConnect.Models; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Services; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.ComponentProviders; +using Elsa.Studio.Contracts; +using Microsoft.Extensions.DependencyInjection; +using OidcAuthProvider = Elsa.Studio.Authentication.OpenIdConnect.Services.OidcAuthenticationProvider; +using Elsa.Studio.Authentication.Abstractions.Extensions; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Extensions; + +/// +/// Extension methods for configuring OpenID Connect authentication in Blazor WebAssembly. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds OpenID Connect authentication services for Blazor WebAssembly. + /// + /// + /// Named to avoid ambiguity with Microsoft.Extensions.DependencyInjection.WebAssemblyAuthenticationServiceCollectionExtensions.AddOidcAuthentication. + /// + /// The service collection. + /// Configuration callback for OIDC options. + /// The service collection for chaining. + public static IServiceCollection AddElsaOidcAuthentication( + this IServiceCollection services, + Action configure) + { + var options = new OidcOptions(); + configure(options); + + // Set Blazor WASM defaults for callback paths if not explicitly specified. + options.CallbackPath ??= "/authentication/login-callback"; + options.SignedOutCallbackPath ??= "/authentication/logout-callback"; + + // Register options for access by services + services.AddSingleton(options); + + // Register the token accessor + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Configure WASM authentication using the built-in framework. + // Note: Entra ID requires absolute redirect URIs. + services.AddOidcAuthentication(wasmOptions => + { + wasmOptions.ProviderOptions.Authority = options.Authority; + wasmOptions.ProviderOptions.ClientId = options.ClientId; + wasmOptions.ProviderOptions.ResponseType = options.ResponseType; + + var scopes = options.Scopes + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Ensure we always request at least the OIDC basics. + if (scopes.Count == 0) + scopes.AddRange(["openid", "profile"]); + + // Clear any default scopes that might have been added by the framework + wasmOptions.ProviderOptions.DefaultScopes.Clear(); + + foreach (var scope in scopes) + wasmOptions.ProviderOptions.DefaultScopes.Add(scope); + + if (!string.IsNullOrWhiteSpace(options.MetadataAddress)) + wasmOptions.ProviderOptions.MetadataUrl = options.MetadataAddress; + + // Only override redirect URIs when AppBaseUrl is provided. Otherwise, let the framework infer absolute URIs. + if (!string.IsNullOrWhiteSpace(options.AppBaseUrl)) + { + wasmOptions.ProviderOptions.RedirectUri = $"{options.AppBaseUrl.TrimEnd('/')}{options.CallbackPath}"; + wasmOptions.ProviderOptions.PostLogoutRedirectUri = $"{options.AppBaseUrl.TrimEnd('/')}{options.SignedOutCallbackPath}"; + } + }); + + // Provide an OIDC-aware unauthorized component. + services.AddScoped(); + + // Shared auth infrastructure (e.g. delegating handlers). + services.AddAuthenticationInfrastructure(); + + return services; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/OpenIdConnectBlazorWasmFeature.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/OpenIdConnectBlazorWasmFeature.cs new file mode 100644 index 000000000..c8c47dd5b --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/OpenIdConnectBlazorWasmFeature.cs @@ -0,0 +1,17 @@ +using Elsa.Studio.Abstractions; +using JetBrains.Annotations; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm; + +/// +/// Represents the OpenID Connect feature specific to Blazor WebAssembly authentication +/// within the Elsa Studio platform. +/// +/// +/// This feature integrates OpenID Connect authentication capabilities into the +/// Blazor WebAssembly context, allowing for secure user authentication in a +/// distributed environment. It derives from the class, +/// which provides a framework for modules extending the Elsa Studio dashboard. +/// +[UsedImplicitly] +public class OpenIdConnectBlazorWasmFeature : FeatureBase; diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Pages/Authentication.razor b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Pages/Authentication.razor new file mode 100644 index 000000000..0e27f8796 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Pages/Authentication.razor @@ -0,0 +1,10 @@ +@page "/authentication/{action}" +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication + + + +@code { + [Parameter] public string? Action { get; set; } + +} + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Services/WasmOidcTokenAccessor.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Services/WasmOidcTokenAccessor.cs new file mode 100644 index 000000000..99937ab75 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/Services/WasmOidcTokenAccessor.cs @@ -0,0 +1,63 @@ +using Elsa.Studio.Authentication.OpenIdConnect.Contracts; +using Microsoft.AspNetCore.Components.WebAssembly.Authentication; + +namespace Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Services; + +/// +/// Blazor WASM implementation of that uses the built-in token provider. +/// +/// +/// This accessor supports scope-aware token requests, enabling incremental consent scenarios +/// where different tokens are needed for different API audiences (e.g., Graph vs. backend API). +/// +public class WasmOidcTokenAccessor : IOidcTokenAccessorWithScopes +{ + private readonly IAccessTokenProvider _tokenProvider; + + /// + /// Initializes a new instance of the class. + /// + public WasmOidcTokenAccessor(IAccessTokenProvider tokenProvider) + { + _tokenProvider = tokenProvider; + } + + /// + public Task GetTokenAsync(string tokenName, CancellationToken cancellationToken = default) + { + // Forward to scoped overload with null scopes (use default behavior) + return GetTokenAsync(tokenName, scopes: null, cancellationToken); + } + + /// + public async Task GetTokenAsync(string tokenName, IEnumerable? scopes, CancellationToken cancellationToken = default) + { + // For WASM, we use the IAccessTokenProvider to get the current access token + // The framework handles token refresh automatically + + // Map token names to what the framework expects + if (string.Equals(tokenName, "access_token", StringComparison.OrdinalIgnoreCase) || + string.Equals(tokenName, "accessToken", StringComparison.OrdinalIgnoreCase)) + { + // If specific scopes are requested, use them (e.g., for backend API calls) + // Otherwise, request with default scopes (e.g., for Graph/userinfo calls) + var requestedScopes = scopes?.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray(); + + var tokenResult = requestedScopes?.Length > 0 + ? await _tokenProvider.RequestAccessToken(new AccessTokenRequestOptions + { + Scopes = requestedScopes + }) + : await _tokenProvider.RequestAccessToken(); + + if (tokenResult.TryGetToken(out var token)) + { + return token.Value; + } + } + + // For other token types (id_token, refresh_token), we can't directly access them + // in WASM for security reasons - they're managed by the authentication framework + return null; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Class1.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Class1.cs deleted file mode 100644 index 9682c1493..000000000 --- a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Elsa.Studio.Authentication.OpenIdConnect; - -public class Class1 -{ -} \ No newline at end of file diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Contracts/IOidcTokenAccessor.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Contracts/IOidcTokenAccessor.cs new file mode 100644 index 000000000..5f5f5d073 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Contracts/IOidcTokenAccessor.cs @@ -0,0 +1,12 @@ +using Elsa.Studio.Authentication.Abstractions.Contracts; + +namespace Elsa.Studio.Authentication.OpenIdConnect.Contracts; + +/// +/// Provides access to OIDC tokens stored in the authentication context. +/// Extends with OIDC-specific functionality if needed. +/// +public interface IOidcTokenAccessor : ITokenAccessor +{ + // Can add OIDC-specific methods here in the future if needed +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Contracts/IOidcTokenAccessorWithScopes.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Contracts/IOidcTokenAccessorWithScopes.cs new file mode 100644 index 000000000..23f77794a --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Contracts/IOidcTokenAccessorWithScopes.cs @@ -0,0 +1,23 @@ +namespace Elsa.Studio.Authentication.OpenIdConnect.Contracts; + +/// +/// Extended OIDC token accessor that supports scope-aware token acquisition. +/// +/// +/// This interface extends to support requesting tokens +/// with specific scopes, enabling incremental consent and multi-audience scenarios. +/// +public interface IOidcTokenAccessorWithScopes : IOidcTokenAccessor +{ + /// + /// Gets a token with specific scopes. + /// + /// The name of the token to retrieve. + /// The specific scopes to request. If null or empty, uses default behavior. + /// Cancellation token. + /// The token value, or null if not available. + Task GetTokenAsync( + string tokenName, + IEnumerable? scopes, + CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Elsa.Studio.Authentication.OpenIdConnect.csproj b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Elsa.Studio.Authentication.OpenIdConnect.csproj index 9f382c830..d6d4aacb6 100644 --- a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Elsa.Studio.Authentication.OpenIdConnect.csproj +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Elsa.Studio.Authentication.OpenIdConnect.csproj @@ -15,6 +15,7 @@ + diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Models/OidcOptions.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Models/OidcOptions.cs new file mode 100644 index 000000000..c713d43c2 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Models/OidcOptions.cs @@ -0,0 +1,97 @@ +using Elsa.Studio.Authentication.Abstractions.Models; + +namespace Elsa.Studio.Authentication.OpenIdConnect.Models; + +/// +/// Configuration options for OpenID Connect authentication. +/// +public class OidcOptions : AuthenticationOptions +{ + /// + /// Gets or sets the authority URL of the OpenID Connect provider. + /// + public string Authority { get; set; } = default!; + + /// + /// Gets or sets the client ID registered with the OpenID Connect provider. + /// + public string ClientId { get; set; } = default!; + + /// + /// Gets or sets the client secret (optional, typically not used with public clients like WASM). + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the response type for the authentication request. + /// + public string ResponseType { get; set; } = "code"; + + /// + /// Gets or sets whether to use PKCE (Proof Key for Code Exchange). + /// + public bool UsePkce { get; set; } = true; + + /// + /// Gets or sets whether to save tokens in the authentication properties (Server only). + /// + public bool SaveTokens { get; set; } = true; + + /// + /// Gets or sets the callback path for handling the authentication response. + /// + /// + /// + /// Blazor Server typically uses /signin-oidc. + /// Blazor WebAssembly uses /authentication/login-callback. + /// + /// When using the Blazor WebAssembly authentication stack, the identity provider expects an absolute redirect_uri. + /// The framework will convert these paths into absolute URIs based on the current base URI. + /// + public string? CallbackPath { get; set; } + + /// + /// Gets or sets the sign-out callback path. + /// + /// + /// + /// Blazor Server typically uses /signout-callback-oidc. + /// Blazor WebAssembly uses /authentication/logout-callback. + /// + /// + public string? SignedOutCallbackPath { get; set; } + + /// + /// Gets or sets whether to get claims from the user info endpoint. + /// + /// + /// When enabled, the OIDC handler calls the provider's userinfo endpoint after authentication. + /// Some providers or app registrations may return 401 from userinfo unless specific permissions/scopes + /// are configured. + /// + public bool GetClaimsFromUserInfoEndpoint { get; set; } = false; + + /// + /// Gets or sets the metadata address (optional, auto-discovered from Authority if not set). + /// + public string? MetadataAddress { get; set; } + + /// + /// Gets or sets the base URL of the application (primarily for Blazor WebAssembly) used to build absolute redirect URIs. + /// + /// + /// Microsoft Entra ID requires redirect_uri to be an absolute URI. In many cases the framework can infer the base URI, + /// but if your host setup or reverse proxying causes relative redirect URIs to be sent, set this to the app origin, e.g. + /// https://localhost:9009. + /// + public string AppBaseUrl { get; set; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + public OidcOptions() + { + // Set default OIDC scopes + Scopes = ["openid", "profile", "offline_access"]; + } +} diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/README.md b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/README.md new file mode 100644 index 000000000..ebffb00cb --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/README.md @@ -0,0 +1,465 @@ +# Elsa Studio Authentication - OpenID Connect + +A modern, best-practices OpenID Connect (OIDC) authentication module for Elsa Studio that leverages Microsoft's built-in authentication infrastructure. + +## Overview + +This module provides a clean, decoupled alternative to the OIDC implementation in `Elsa.Studio.Login`. It uses Microsoft's native authentication packages for automatic token management, PKCE support, and proper integration with ASP.NET Core and Blazor frameworks. + +**Note**: This is one of potentially many authentication providers for Elsa Studio. It extends the shared `Elsa.Studio.Authentication.Abstractions` to provide OIDC-specific functionality. + +### Key Benefits + +- **Automatic Token Management**: Tokens are automatically refreshed by the framework +- **Built-in PKCE Support**: Uses Microsoft's built-in Proof Key for Code Exchange implementation +- **Proper Middleware Integration**: Integrates with ASP.NET Core authentication pipeline +- **Hosting Model Optimized**: Separate implementations for Blazor Server and WebAssembly +- **Clean Architecture**: No browser storage manipulation, uses framework-managed authentication state +- **Security Best Practices**: Cookie-based sessions for Server, secure token provider for WASM +- **Shared Abstractions**: Uses common patterns from `Elsa.Studio.Authentication.Abstractions` + +## Architecture + +### Project Structure + +``` +Elsa.Studio.Authentication.Abstractions/ +├── Contracts/ +│ └── ITokenAccessor.cs # Shared token accessor abstraction +└── Models/ + └── AuthenticationOptions.cs # Base authentication options + +Elsa.Studio.Authentication.OpenIdConnect/ +├── Contracts/ +│ └── IOidcTokenAccessor.cs # OIDC-specific token accessor (extends ITokenAccessor) +├── Models/ +│ └── OidcOptions.cs # OIDC configuration (extends AuthenticationOptions) +└── Services/ + └── OidcAuthenticationProvider.cs # IAuthenticationProvider implementation + +Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/ +├── Extensions/ +│ └── ServiceCollectionExtensions.cs # Server DI setup +└── Services/ + └── ServerOidcTokenAccessor.cs # Server-side token accessor + +Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm/ +├── Extensions/ +│ └── ServiceCollectionExtensions.cs # WASM DI setup +└── Services/ + └── WasmOidcTokenAccessor.cs # WASM-side token accessor +``` + +### Design Decisions + +#### Blazor Server Implementation +- Uses `Microsoft.AspNetCore.Authentication.OpenIdConnect` middleware +- Cookie-based authentication with secure session management +- Tokens stored in authentication properties (server-side only) +- Retrieved via `HttpContext.GetTokenAsync()` - no client storage + +#### Blazor WebAssembly Implementation +- Uses `Microsoft.AspNetCore.Components.WebAssembly.Authentication` +- Leverages built-in `IAccessTokenProvider` for automatic token management +- Framework handles token refresh, expiry, and renewal automatically +- Tokens never exposed directly to application code (security by design) + +## Installation & Usage + +### Blazor Server + +1. **Add Package References** (via project references or NuGet): + ```xml + + ``` + +2. **Configure in `Program.cs`**: + ```csharp + using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions; + + // Configure OIDC authentication + builder.Services.AddOidcAuthentication(options => + { + options.Authority = "https://your-identity-server.com"; + options.ClientId = "elsa-studio"; + options.ClientSecret = "your-client-secret"; // Optional for confidential clients + options.Scopes = new[] { "openid", "profile", "elsa_api", "offline_access" }; + options.UsePkce = true; // Recommended + }); + ``` + +3. **Add Authentication Middleware**: + ```csharp + // Before app.UseRouting() + app.UseAuthentication(); + app.UseAuthorization(); + ``` + +4. **Protect Pages** (optional): + ```csharp + // In _Imports.razor or individual pages + @attribute [Authorize] + ``` + +### Blazor WebAssembly + +1. **Add Package References**: + ```xml + + ``` + +2. **Configure in `Program.cs`**: + ```csharp + using Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Extensions; + + // Configure OIDC authentication + builder.Services.AddElsaOidcAuthentication(options => + { + options.Authority = "https://your-identity-server.com"; + options.ClientId = "elsa-studio-wasm"; + options.Scopes = new[] { "openid", "profile", "elsa_api", "offline_access" }; + options.ResponseType = "code"; + options.CallbackPath = "/authentication/login-callback"; + options.SignedOutCallbackPath = "/authentication/logout-callback"; + }); + ``` + + > **Note**: Use `AddElsaOidcAuthentication` instead of `AddOidcAuthentication` to avoid ambiguity with Microsoft's extension method. + +3. **Add Authentication Components** in `App.razor`: + ```razor + + + + + + + + + + + + ``` +3. **Authentication Routes** + + This module ships the required `/authentication/{action}` route (hosting `RemoteAuthenticatorView`) as part of the `Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm` assembly. + + That means integrators **do not** need to add an `Authentication.razor` file to their host project, as long as they use Elsa Studio's shell router that includes module assemblies (which the default Elsa Studio hosts do). + +### Azure AD / Microsoft Entra ID (Blazor WebAssembly) + +Azure AD has specific requirements for Blazor WebAssembly applications: + +1. **App Registration Setup**: + - Register your application in Azure AD (Azure Portal > Microsoft Entra ID > App registrations) + - Set "Supported account types" based on your needs (single/multi-tenant) + - Add a redirect URI for SPA: `https://your-app.com/authentication/login-callback` + - Enable "Access tokens" and "ID tokens" under "Implicit grant and hybrid flows" + - Create an API scope for your backend API (e.g., `api://your-api-id/elsa-server-api`) + +2. **Scopes Configuration**: + ```csharp + using Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm.Extensions; + + builder.Services.AddElsaOidcAuthentication(options => + { + options.Authority = "https://login.microsoftonline.com/{tenant-id}/v2.0"; + options.ClientId = "{client-id}"; + + // IMPORTANT: Only include API scopes for your backend + // Do NOT mix Microsoft Graph scopes with custom API scopes + // Azure AD v2.0 only allows one resource per token request + options.Scopes = new[] + { + "openid", // Required for OIDC + "profile", // User profile claims + "offline_access", // Refresh tokens + "api://{your-api-id}/elsa-server-api" // Your API scope + }; + + options.ResponseType = "code"; + options.CallbackPath = "/authentication/login-callback"; + options.SignedOutCallbackPath = "/authentication/logout-callback"; + }); + ``` + +3. **Key Considerations**: + - **Single Resource per Token**: Azure AD v2.0 only allows scopes for ONE resource per token. Don't mix Graph API scopes (`https://graph.microsoft.com/.default`) with your custom API scopes. + - **Scope Format**: Use the full scope URI format: `api://{application-id}/{scope-name}` + - **Standard Scopes**: The framework automatically filters standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`) and only passes resource scopes during token requests. + - **UserInfo Endpoint**: If you encounter 401 errors from the userinfo endpoint, set `options.GetClaimsFromUserInfoEndpoint = false` (most Azure AD setups don't require this). + +4. **Backend API Configuration**: + Your backend API must accept tokens from Azure AD: + ```csharp + // In your API's Program.cs + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); + ``` + +5. **Troubleshooting Azure AD**: + - **AADSTS28000** (Multi-resource error): Remove Graph scopes from `options.Scopes`, only include your API scope + - **AADSTS28003** (Scope not found): Verify the API scope is exposed in your app registration + - **401 from userinfo**: Set `GetClaimsFromUserInfoEndpoint = false` in options + - **Login succeeds but redirects to /login-failed**: Ensure your API scope is correctly configured and the token audience matches your API's expected audience + +## Configuration Options + +### OidcOptions + +`OidcOptions` extends `AuthenticationOptions` from `Elsa.Studio.Authentication.Abstractions`. + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `Authority` | `string` | OIDC provider authority URL | Required | +| `ClientId` | `string` | Client ID registered with provider | Required | +| `ClientSecret` | `string?` | Client secret (Server only, optional) | `null` | +| `ResponseType` | `string` | OAuth2 response type | `"code"` | +| `Scopes` | `string[]` | Requested scopes (inherited) | `["openid", "profile", "offline_access"]` | +| `UsePkce` | `bool` | Enable PKCE | `true` | +| `SaveTokens` | `bool` | Save tokens in auth properties (Server) | `true` | +| `CallbackPath` | `string` | Authentication callback path | `"/signin-oidc"` | +| `SignedOutCallbackPath` | `string` | Sign-out callback path | `"/signout-callback-oidc"` | +| `RequireHttpsMetadata` | `bool` | Require HTTPS for metadata (inherited) | `true` | +| `GetClaimsFromUserInfoEndpoint` | `bool` | Fetch claims from UserInfo | `true` | +| `MetadataAddress` | `string?` | Custom metadata address | Auto-discovered | + +## Token Access + +Both implementations provide access to tokens via the standard `IAuthenticationProvider` interface: + +```csharp +@inject IAuthenticationProviderManager AuthProviderManager + +var accessToken = await AuthProviderManager.GetAuthenticationTokenAsync(TokenNames.AccessToken); +``` + +### Token Names + +- `TokenNames.AccessToken` - Access token for API calls +- `TokenNames.IdToken` - ID token (Server only) +- `TokenNames.RefreshToken` - Refresh token (Server only, if available) + +> **Note**: In Blazor WASM, only access tokens are directly accessible. ID and refresh tokens are managed internally by the framework for security. + +## SignalR Integration + +The module works seamlessly with `WorkflowInstanceObserverFactory` for SignalR connections: + +```csharp +// Token is automatically retrieved and applied to SignalR hub connections +var observer = await observerFactory.CreateAsync(workflowInstanceId); +``` + +The `IAuthenticationProviderManager` automatically retrieves the access token from the appropriate source (HTTP context for Server, token provider for WASM). + +## Migration from Elsa.Studio.Login + +If you're currently using the OIDC implementation in `Elsa.Studio.Login`, here's how to migrate: + +### Before (Blazor Server): +```csharp +builder.Services.AddLoginModule(); +builder.Services.UseOpenIdConnect(options => +{ + options.AuthEndpoint = "https://identity-server.com/connect/authorize"; + options.TokenEndpoint = "https://identity-server.com/connect/token"; + options.EndSessionEndpoint = "https://identity-server.com/connect/endsession"; + options.ClientId = "elsa-studio"; + options.ClientSecret = "secret"; + options.Scopes = new[] { "openid", "profile", "elsa_api" }; +}); +``` + +### After (Blazor Server): +```csharp +builder.Services.AddOidcAuthentication(options => +{ + options.Authority = "https://identity-server.com"; // Auto-discovers endpoints + options.ClientId = "elsa-studio"; + options.ClientSecret = "secret"; + options.Scopes = new[] { "openid", "profile", "elsa_api", "offline_access" }; +}); + +// Add middleware +app.UseAuthentication(); +app.UseAuthorization(); +``` + +## Differences from Legacy Implementation + +| Feature | Legacy (Elsa.Studio.Login) | New (This Module) | +|---------|---------------------------|-------------------| +| Token Storage | Browser LocalStorage/SessionStorage | Server: Auth properties (server-side only)
WASM: Framework-managed | +| Token Refresh | Manual with custom service | Automatic via framework | +| PKCE | Manual implementation | Built-in framework support | +| Middleware | Custom authorization redirect | Standard ASP.NET Core auth pipeline | +| Token Access | Direct storage access | Via `IAuthenticationProvider` abstraction | +| Security | Tokens exposed in browser storage | Server: Session cookies only
WASM: Framework-secured | + +## Troubleshooting + +### Common Issues + +1. **"SaveTokens must be true" error**: + - Ensure `SaveTokens = true` in Server configuration + - This is required for token retrieval via `HttpContext.GetTokenAsync()` + +2. **Tokens not available in WASM**: + - Only access tokens are directly accessible in WASM + - ID and refresh tokens are managed by the framework for security + +3. **SignalR connections fail**: + - Ensure the `offline_access` scope is requested for refresh tokens + - Verify the identity provider returns tokens with appropriate audience + +4. **Pre-rendering issues in Server**: + - Tokens are not available during pre-rendering + - Use `@attribute [Authorize]` to ensure authentication before render + +## Silent access token refresh (Blazor Server) + +On Blazor Server, Elsa Studio uses the standard ASP.NET Core Cookie + OpenID Connect handler. +When you set `SaveTokens = true`, the handler stores `access_token`, `refresh_token` (if issued) and `expires_at` in the authentication cookie properties. + +This module can **silently refresh the access token** when it is about to expire by: +- reading `expires_at` / `refresh_token` from the auth cookie +- calling the OIDC provider's `token_endpoint` using the `refresh_token` grant +- updating the tokens stored in the auth cookie (via `SignInAsync`) + +### Flip-a-switch configuration + +The only thing you need to do to enable silent refresh is to keep refresh tokens enabled and request `offline_access`: + +```csharp +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions; +using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Models; + +builder.Services.AddOidcAuthentication(options => +{ + options.Authority = "https://login.microsoftonline.com/{tenantId}/v2.0"; // or your provider + options.ClientId = "..."; + + // Required if you want the handler to store tokens in the auth cookie. + options.SaveTokens = true; + + // Required if you want refresh tokens. + // Note: some providers/app registrations might not issue a refresh token even if requested. + options.Scopes = new[] { "openid", "profile", "offline_access" }; +}); + +// Optional: control refresh behavior. +builder.Services.Configure(options => +{ + options.EnableRefreshTokens = true; // default + options.RefreshSkew = TimeSpan.FromMinutes(2); // default +}); +``` + +### Prerequisites and behavior + +- `SaveTokens` must be `true` (otherwise no tokens are available in the auth cookie). +- `offline_access` should be requested (otherwise a refresh token is typically not issued). +- **Strategy**: + - `BestEffort` (default): the module only renews the auth cookie when response headers can still be written. + - `Persisted`: the module renews the auth cookie via the dedicated `POST /authentication/refresh` endpoint. +- **Blazor Server note**: once the initial page load is complete, most calls happen over the SignalR circuit where HTTP headers have already been sent. In that context, cookies cannot be updated. + - With `BestEffort`, this means the app will eventually fall back to a normal OIDC re-authentication when the access token expires. + - With `Persisted`, you should periodically call the refresh endpoint (e.g., a background ping) so renewal happens in a non-circuit HTTP request. +- If no refresh token is available, or refresh fails, the module does not throw; the next API call will typically result in a normal auth challenge. + +### Microsoft Entra ID notes + +For Microsoft Entra ID (Azure AD), the authority usually looks like: +- Tenant-specific: `https://login.microsoftonline.com/{tenantId}/v2.0` +- Or common endpoint (multi-tenant apps): `https://login.microsoftonline.com/common/v2.0` + +Refresh tokens depend on: +- requesting `offline_access` +- your app registration configuration / consent +- Entra token policies (token lifetimes, session policies) + +If you don't receive a refresh token, the app will still work, but access-token renewal will require re-authentication. + +### Advanced overrides (rarely needed) + +By default, the module auto-discovers the `token_endpoint` from OIDC metadata and uses the configured `ClientId`/`ClientSecret` from `AddOidcAuthentication`. + +You can override any of these via `OidcTokenRefreshOptions`: + +```csharp +builder.Services.Configure(options => +{ + options.EnableRefreshTokens = true; + + // Override token endpoint discovery. + options.TokenEndpoint = "https://issuer.example.com/oauth2/v2.0/token"; + + // Override client credentials. + options.ClientId = "..."; + options.ClientSecret = "..."; // optional +}); +``` + +### Host configuration example (appsettings.json) + +In the Blazor Server host, you can enable persisted silent refresh purely via configuration: + +```json +{ + "Authentication": { + "Provider": "OpenIdConnect", + "OpenIdConnect": { + "Authority": "https://login.microsoftonline.com/{tenantId}/v2.0", + "ClientId": "...", + "ClientSecret": "...", + "Scopes": ["openid", "profile", "offline_access"], + "SaveTokens": true, + "TokenRefresh": { + "Strategy": "Persisted", + "Ping": { + "RefreshEndpointPath": "/authentication/refresh", + "Interval": "00:01:00" + } + } + } + } +} +``` + +> Set `TokenRefresh:Strategy` to `BestEffort` (or omit it) to disable persisted refresh. + +### Microsoft Entra ID: `graph.microsoft.com/oidc/userinfo` returns 401 + +On Blazor WebAssembly, Microsoft's built-in OIDC stack may call the OIDC UserInfo endpoint. +With Microsoft Entra ID, this endpoint is hosted on Microsoft Graph (e.g. `https://graph.microsoft.com/oidc/userinfo`). + +If you see a login failure with a browser console error like: + +- `graph.microsoft.com/oidc/userinfo: 401 (Unauthorized)` + +add a Microsoft Graph delegated permission scope such as `User.Read`. + +#### Example (`appsettings.json`) + +```json +{ + "Authentication": { + "Provider": "OpenIdConnect", + "OpenIdConnect": { + "Scopes": [ + "openid", + "profile", + "offline_access", + "https://graph.microsoft.com/User.Read", + "api://{your-api-app-id}/elsa-server-api" + ] + } + } +} +``` + +Then, in the Entra app registration: + +- Add Microsoft Graph → **Delegated permissions** → `User.Read` +- Grant admin consent (or ensure user consent is allowed in your tenant) diff --git a/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Services/OidcAuthenticationProvider.cs b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Services/OidcAuthenticationProvider.cs new file mode 100644 index 000000000..b712441e7 --- /dev/null +++ b/src/modules/Elsa.Studio.Authentication.OpenIdConnect/Services/OidcAuthenticationProvider.cs @@ -0,0 +1,43 @@ +using Elsa.Studio.Authentication.Abstractions.Contracts; +using Elsa.Studio.Authentication.OpenIdConnect.Contracts; +using Elsa.Studio.Contracts; + +namespace Elsa.Studio.Authentication.OpenIdConnect.Services; + +/// +/// Implementation of that retrieves tokens from OIDC authentication. +/// +public class OidcAuthenticationProvider(IOidcTokenAccessor tokenAccessor) : IAuthenticationProvider, IScopedAccessTokenProvider +{ + /// + public async Task GetAccessTokenAsync(string tokenName, CancellationToken cancellationToken = default) + { + // Map the token name to OIDC token name conventions + var oidcTokenName = tokenName switch + { + TokenNames.AccessToken => "access_token", + TokenNames.IdToken => "id_token", + TokenNames.RefreshToken => "refresh_token", + _ => tokenName + }; + + return await tokenAccessor.GetTokenAsync(oidcTokenName, cancellationToken); + } + + /// + public async Task GetAccessTokenAsync(string tokenName, IEnumerable? scopes, CancellationToken cancellationToken = default) + { + // Map the token name to OIDC token name conventions + var oidcTokenName = tokenName switch + { + TokenNames.AccessToken => "access_token", + TokenNames.IdToken => "id_token", + TokenNames.RefreshToken => "refresh_token", + _ => tokenName + }; + + // All OIDC token accessors now support scoped requests + var scopedAccessor = (IOidcTokenAccessorWithScopes)tokenAccessor; + return await scopedAccessor.GetTokenAsync(oidcTokenName, scopes, cancellationToken); + } +} diff --git a/src/modules/Elsa.Studio.Login.BlazorServer/Elsa.Studio.Login.BlazorServer.csproj b/src/modules/Elsa.Studio.Login.BlazorServer/Elsa.Studio.Login.BlazorServer.csproj index 3a4a7076e..7ff5ddbf3 100644 --- a/src/modules/Elsa.Studio.Login.BlazorServer/Elsa.Studio.Login.BlazorServer.csproj +++ b/src/modules/Elsa.Studio.Login.BlazorServer/Elsa.Studio.Login.BlazorServer.csproj @@ -11,6 +11,7 @@ + diff --git a/src/modules/Elsa.Studio.Login.BlazorServer/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Login.BlazorServer/Extensions/ServiceCollectionExtensions.cs index 5cae93bc1..92ca1a29d 100644 --- a/src/modules/Elsa.Studio.Login.BlazorServer/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Studio.Login.BlazorServer/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,4 @@ -using Blazored.LocalStorage; -using Elsa.Studio.Login.BlazorServer.Services; -using Elsa.Studio.Login.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Extensions; using Elsa.Studio.Login.Extensions; using Elsa.Studio.Login.Models; using Microsoft.Extensions.DependencyInjection; @@ -10,25 +8,20 @@ namespace Elsa.Studio.Login.BlazorServer.Extensions; /// /// Contains extension methods for the interface. /// +[Obsolete("Elsa.Studio.Login.* is obsolete. Use Elsa.Studio.Authentication.ElsaAuth.* (or Elsa.Studio.Authentication.OpenIdConnect.*) instead.")] public static class ServiceCollectionExtensions { /// /// Adds login services with Blazor Server implementations. /// + [Obsolete("Elsa.Studio.Login.* is obsolete. Use services.AddElsaAuth() from Elsa.Studio.Authentication.ElsaAuth.BlazorServer and optionally add Elsa.Studio.Login UI separately.")] public static IServiceCollection AddLoginModule(this IServiceCollection services) { - // Add the login module. + // Legacy UI + feature registrations. services.AddLoginModuleCore(); - // Register HttpContextAccessor. - services.AddHttpContextAccessor(); - - // Register Blazored LocalStorage. - services.AddBlazoredLocalStorage(); - - // Register JWT services. - services.AddSingleton(); - services.AddScoped(); + // Replace the old platform auth plumbing with ElsaAuth. + Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Extensions.ServiceCollectionExtensions.AddElsaAuth(services); return services; } diff --git a/src/modules/Elsa.Studio.Login.BlazorServer/Services/BlazorServerJwtAccessor.cs b/src/modules/Elsa.Studio.Login.BlazorServer/Services/BlazorServerJwtAccessor.cs index 8fa7bef21..c3f262612 100644 --- a/src/modules/Elsa.Studio.Login.BlazorServer/Services/BlazorServerJwtAccessor.cs +++ b/src/modules/Elsa.Studio.Login.BlazorServer/Services/BlazorServerJwtAccessor.cs @@ -7,6 +7,7 @@ namespace Elsa.Studio.Login.BlazorServer.Services; /// /// Implements the interface for server-side Blazor. /// +[Obsolete("Elsa.Studio.Login.* is obsolete. Use Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Services.BlazorServerJwtAccessor instead.")] public class BlazorServerJwtAccessor : IJwtAccessor { private readonly IHttpContextAccessor _httpContextAccessor; diff --git a/src/modules/Elsa.Studio.Login.BlazorServer/Services/BlazorServerJwtParser.cs b/src/modules/Elsa.Studio.Login.BlazorServer/Services/BlazorServerJwtParser.cs index 32162e05e..433fc9ef0 100644 --- a/src/modules/Elsa.Studio.Login.BlazorServer/Services/BlazorServerJwtParser.cs +++ b/src/modules/Elsa.Studio.Login.BlazorServer/Services/BlazorServerJwtParser.cs @@ -1,17 +1,80 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; using Elsa.Studio.Login.Contracts; +using System.Security.Claims; +using System.Text; +using System.Text.Json; namespace Elsa.Studio.Login.BlazorServer.Services; /// +[Obsolete("Elsa.Studio.Login.* is obsolete. Use Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Services.BlazorServerJwtParser instead.")] public class BlazorServerJwtParser : IJwtParser { /// public IEnumerable Parse(string jwt) { - var handler = new JwtSecurityTokenHandler(); - var jwtSecurityToken = handler.ReadJwtToken(jwt); - return jwtSecurityToken.Claims; + if (string.IsNullOrWhiteSpace(jwt)) + return Array.Empty(); + + var parts = jwt.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + return Array.Empty(); + + var payloadJson = DecodeBase64UrlToString(parts[1]); + + using var document = JsonDocument.Parse(payloadJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return Array.Empty(); + + var claims = new List(); + + foreach (var property in document.RootElement.EnumerateObject()) + AddClaimsFromJson(property.Name, property.Value, claims); + + return claims; + } + + private static void AddClaimsFromJson(string type, JsonElement value, ICollection claims) + { + switch (value.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return; + + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) + AddClaimsFromJson(type, item, claims); + return; + + case JsonValueKind.Object: + // For nested objects, store the raw JSON. + claims.Add(new Claim(type, value.GetRawText(), ClaimValueTypes.String)); + return; + + case JsonValueKind.True: + case JsonValueKind.False: + claims.Add(new Claim(type, value.GetBoolean() ? "true" : "false", ClaimValueTypes.Boolean)); + return; + + case JsonValueKind.Number: + claims.Add(new Claim(type, value.GetRawText(), ClaimValueTypes.String)); + return; + + case JsonValueKind.String: + claims.Add(new Claim(type, value.GetString() ?? string.Empty, ClaimValueTypes.String)); + return; + + default: + claims.Add(new Claim(type, value.ToString(), ClaimValueTypes.String)); + return; + } + } + + private static string DecodeBase64UrlToString(string base64Url) + { + var padded = base64Url.Replace('-', '+').Replace('_', '/'); + padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + var bytes = Convert.FromBase64String(padded); + return Encoding.UTF8.GetString(bytes); } } \ No newline at end of file diff --git a/src/modules/Elsa.Studio.Login.BlazorWasm/Elsa.Studio.Login.BlazorWasm.csproj b/src/modules/Elsa.Studio.Login.BlazorWasm/Elsa.Studio.Login.BlazorWasm.csproj index b0d890e15..853d96f37 100644 --- a/src/modules/Elsa.Studio.Login.BlazorWasm/Elsa.Studio.Login.BlazorWasm.csproj +++ b/src/modules/Elsa.Studio.Login.BlazorWasm/Elsa.Studio.Login.BlazorWasm.csproj @@ -6,6 +6,7 @@ + diff --git a/src/modules/Elsa.Studio.Login.BlazorWasm/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Login.BlazorWasm/Extensions/ServiceCollectionExtensions.cs index d9c0a278a..6cd659e35 100644 --- a/src/modules/Elsa.Studio.Login.BlazorWasm/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Studio.Login.BlazorWasm/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,4 @@ -using Blazored.LocalStorage; -using Elsa.Studio.Login.BlazorWasm.Services; -using Elsa.Studio.Login.Contracts; +using Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.Extensions; using Elsa.Studio.Login.Extensions; using Elsa.Studio.Login.Models; using Microsoft.Extensions.DependencyInjection; @@ -10,33 +8,31 @@ namespace Elsa.Studio.Login.BlazorWasm.Extensions; /// /// Contains extension methods for the interface. /// +[Obsolete("Elsa.Studio.Login.* is obsolete. Use Elsa.Studio.Authentication.ElsaAuth.* (or Elsa.Studio.Authentication.OpenIdConnect.*) instead.")] public static class ServiceCollectionExtensions { /// /// Adds login services with Blazor Server implementations. /// + [Obsolete("Elsa.Studio.Login.* is obsolete. Use services.AddElsaAuth() from Elsa.Studio.Authentication.ElsaAuth.BlazorWasm and optionally add Elsa.Studio.Login UI separately.")] public static IServiceCollection AddLoginModule(this IServiceCollection services) { - // Add the login module. + // Legacy UI + feature registrations. services.AddLoginModuleCore(); - - // Register Blazored LocalStorage. - services.AddBlazoredLocalStorage(); - - // Register JWT services. - services.AddSingleton(); - services.AddScoped(); - + + // Replace the old platform auth plumbing with ElsaAuth. + services.AddElsaAuth(); + return services; } - + /// /// Configures the login module to use OpenIdConnect (OIDC) /// + [Obsolete("Elsa.Studio.Login.* is obsolete. Prefer Elsa.Studio.Authentication.OpenIdConnect.*. This legacy in-app OIDC flow can be configured via Elsa.Studio.Authentication.ElsaAuth (UseLegacyOidcCodeFlowAuth).")] public static IServiceCollection UseOpenIdConnect(this IServiceCollection services, Action configure) { return services - .UseOpenIdConnectCore(configure) - ; + .UseOpenIdConnectCore(configure); } } \ No newline at end of file diff --git a/src/modules/Elsa.Studio.Login.BlazorWasm/Services/BlazorWasmJwtAccessor.cs b/src/modules/Elsa.Studio.Login.BlazorWasm/Services/BlazorWasmJwtAccessor.cs index f63855eab..ee830c7f8 100644 --- a/src/modules/Elsa.Studio.Login.BlazorWasm/Services/BlazorWasmJwtAccessor.cs +++ b/src/modules/Elsa.Studio.Login.BlazorWasm/Services/BlazorWasmJwtAccessor.cs @@ -4,6 +4,7 @@ namespace Elsa.Studio.Login.BlazorWasm.Services; /// +[Obsolete("Elsa.Studio.Login.* is obsolete. Use Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.Services.BlazorWasmJwtAccessor instead.")] public class BlazorWasmJwtAccessor : IJwtAccessor { private readonly ILocalStorageService _localStorageService; diff --git a/src/modules/Elsa.Studio.Login.BlazorWasm/Services/BlazorWasmJwtParser.cs b/src/modules/Elsa.Studio.Login.BlazorWasm/Services/BlazorWasmJwtParser.cs index d7589f1e6..d60bc1849 100644 --- a/src/modules/Elsa.Studio.Login.BlazorWasm/Services/BlazorWasmJwtParser.cs +++ b/src/modules/Elsa.Studio.Login.BlazorWasm/Services/BlazorWasmJwtParser.cs @@ -7,6 +7,7 @@ namespace Elsa.Studio.Login.BlazorWasm.Services; /// +[Obsolete("Elsa.Studio.Login.* is obsolete. Use Elsa.Studio.Authentication.ElsaAuth.BlazorWasm.Services.BlazorWasmJwtParser instead.")] public class BlazorWasmJwtParser : IJwtParser { // Taken and adapted from: https://trystanwilcock.com/2022/09/28/net-6-0-blazor-webassembly-jwt-token-authentication-from-scratch-c-sharp-wasm-tutorial/ diff --git a/src/modules/Elsa.Studio.Login/Contracts/IOpenIdConnectPkceStateService.cs b/src/modules/Elsa.Studio.Login/Contracts/IOpenIdConnectPkceStateService.cs new file mode 100644 index 000000000..df52d02e3 --- /dev/null +++ b/src/modules/Elsa.Studio.Login/Contracts/IOpenIdConnectPkceStateService.cs @@ -0,0 +1,18 @@ +namespace Elsa.Studio.Login.Contracts +{ + /// + /// Manages PKCE (Proof Key for Code Exchange) state for the authorization code flow. + /// + public interface IOpenIdConnectPkceStateService + { + /// + /// Generates and returns a PKCE code challedge. The associated code verifier is stored in session storage. + /// + Task<(string CodeChallenge, string Method)> GeneratePkceCodeChallenge(); + + /// + /// Retrieves the code verifier for the current session. + /// + Task GetPkceCodeVerifier(); + } +} diff --git a/src/modules/Elsa.Studio.Login/Elsa.Studio.Login.csproj b/src/modules/Elsa.Studio.Login/Elsa.Studio.Login.csproj index 0720a8571..a21f422f3 100644 --- a/src/modules/Elsa.Studio.Login/Elsa.Studio.Login.csproj +++ b/src/modules/Elsa.Studio.Login/Elsa.Studio.Login.csproj @@ -19,4 +19,8 @@ + + + + diff --git a/src/modules/Elsa.Studio.Login/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Login/Extensions/ServiceCollectionExtensions.cs index 5987a61b0..6428057f3 100644 --- a/src/modules/Elsa.Studio.Login/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Studio.Login/Extensions/ServiceCollectionExtensions.cs @@ -26,9 +26,7 @@ public static IServiceCollection AddLoginModuleCore(this IServiceCollection serv .AddAuthorizationCore() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() - ; + .AddScoped(); } /// @@ -41,8 +39,8 @@ public static IServiceCollection UseElsaIdentity(this IServiceCollection service .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped(); - ; } /// @@ -51,37 +49,40 @@ public static IServiceCollection UseElsaIdentity(this IServiceCollection service public static IServiceCollection UseOAuth2(this IServiceCollection services, Action configure) { services.Configure(configure); - - services.AddHttpClient(httpClient => + + services.AddHttpClient((sp, httpClient) => { - var options = services.BuildServiceProvider().GetRequiredService>().Value; + var options = sp.GetRequiredService>().Value; httpClient.BaseAddress = new(options.TokenEndpoint); }); - + return services .AddScoped() .AddScoped() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() - ; + .AddScoped(); } /// - /// Configures the login module to use OpenIdConnect (OIDC) + /// Configures the login module to use OpenIdConnect (OIDC). /// - public static IServiceCollection UseOpenIdConnectCore(this IServiceCollection services, Action configure) + public static IServiceCollection UseOpenIdConnect(this IServiceCollection services, Action configure) { services.Configure(configure); return services .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped(); + .AddScoped(); + } - ; + /// + /// Configures the login module to use OpenIdConnect (OIDC). + /// + [Obsolete("Elsa.Studio.Login.* is obsolete. Prefer Elsa.Studio.Authentication.OpenIdConnect.*. UseOpenIdConnectCore is kept for backwards compatibility.")] + public static IServiceCollection UseOpenIdConnectCore(this IServiceCollection services, Action configure) + { + return services.UseOpenIdConnect(configure); } } \ No newline at end of file diff --git a/src/modules/Elsa.Studio.Login/Models/OpenIdConnectConfiguration.cs b/src/modules/Elsa.Studio.Login/Models/OpenIdConnectConfiguration.cs index d6795186c..7aa725bce 100644 --- a/src/modules/Elsa.Studio.Login/Models/OpenIdConnectConfiguration.cs +++ b/src/modules/Elsa.Studio.Login/Models/OpenIdConnectConfiguration.cs @@ -24,11 +24,6 @@ public class OpenIdConnectConfiguration /// The client_id as which this application is registered with the authorization server /// public required string ClientId { get; set; } - - /// - /// The client_secret as which this application is registered with the authorization server. - /// - public string? ClientSecret { get; set; } /// /// The scopes to request, defaulting to: openid profile offline_access @@ -38,5 +33,5 @@ public class OpenIdConnectConfiguration /// /// Enables PKCE (Proof Key for Code Exchange) for the authorization code flow. /// - public bool UsePkce { get; set; } + public bool UsePkce { get; set; } = false; } diff --git a/src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs b/src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs index 93b2d5e53..2c1ba319b 100644 --- a/src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs +++ b/src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs @@ -3,142 +3,72 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Options; +using System.Net; using System.Net.Http.Json; -using System.Security.Cryptography; -using Microsoft.Extensions.Logging; -using Microsoft.JSInterop; +using System.Text; namespace Elsa.Studio.Login.Services; /// -public class OpenIdConnectAuthorizationService( - IJwtAccessor jwtAccessor, - IOptions configuration, - NavigationManager navigationManager, - HttpClient httpClient, - IOpenIdConnectPkceService pkceService, - IOidcBrowserStateStore browserState, - ILogger logger) : IAuthorizationService +public class OpenIdConnectAuthorizationService(IJwtAccessor jwtAccessor, IOptions configuration, NavigationManager navigationManager, HttpClient httpClient, IOpenIdConnectPkceStateService pkceStateService) : IAuthorizationService { - static string CryptoRandom(int bytes = 32) => - WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(bytes)); - /// public async Task RedirectToAuthorizationServer() { var config = configuration.Value; var redirectUri = new Uri(navigationManager.Uri).GetLeftPart(UriPartial.Authority) + "/signin-oidc"; - - var returnUrl = navigationManager.ToBaseRelativePath(navigationManager.Uri); - if (string.IsNullOrWhiteSpace(returnUrl) || returnUrl == "/") - returnUrl = "/"; - - var state = CryptoRandom(); - var nonce = CryptoRandom(); - - await browserState.SetAsync($"state:{state}", returnUrl); - await browserState.SetAsync($"nonce:{state}", nonce); // tie nonce to state - - var query = new Dictionary - { - ["client_id"] = config.ClientId, - ["redirect_uri"] = redirectUri, - ["response_type"] = "code", - ["response_mode"] = "query", - ["scope"] = string.Join(' ', config.Scopes), - ["state"] = state, - ["nonce"] = nonce - }; - + string url = config.AuthEndpoint + $"?client_id={WebUtility.UrlEncode(config.ClientId)}&redirect_uri={WebUtility.UrlEncode(redirectUri)}&response_type=code&scope={WebUtility.UrlEncode(String.Join(' ', config.Scopes))}"; if (config.UsePkce) { - // IMPORTANT: your PKCE service should return BOTH verifier + challenge. - var pkce = await pkceService.GeneratePkceAsync(); // see note below - await browserState.SetAsync($"pkce:{state}", pkce.CodeVerifier); - - query["code_challenge"] = pkce.CodeChallenge; - query["code_challenge_method"] = pkce.Method; + var generated = await pkceStateService.GeneratePkceCodeChallenge(); + url += $"&code_challenge={generated.CodeChallenge}&code_challenge_method={generated.Method}"; + } + if (navigationManager.ToBaseRelativePath(navigationManager.Uri) is { } returnUrl and not "/") + { + url += "&state=" + WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(returnUrl)); } - var url = QueryHelpers.AddQueryString(config.AuthEndpoint, query); navigationManager.NavigateTo(url, true); } - /// public async Task ReceiveAuthorizationCode(string code, string? state, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(state)) - throw new InvalidOperationException("Missing state."); - var config = configuration.Value; - var returnUrl = await browserState.TakeAsync($"state:{state}") ?? "/"; - var codeVerifier = config.UsePkce ? await browserState.TakeAsync($"pkce:{state}") : null; var redirectUri = new Uri(navigationManager.Uri).GetLeftPart(UriPartial.Authority) + "/signin-oidc"; var formValues = new List> { - new("client_id", config.ClientId), - new("grant_type", "authorization_code"), - new("code", code), - new("redirect_uri", redirectUri), - new("scope", string.Join(' ', config.Scopes)) // <-- key for getting API aud + new KeyValuePair("client_id", config.ClientId), + new KeyValuePair("code", code), + new KeyValuePair("grant_type", "authorization_code"), + new KeyValuePair("redirect_uri", redirectUri) }; - - if (!string.IsNullOrWhiteSpace(config.ClientSecret)) - formValues.Add(new("client_secret", config.ClientSecret)); - if (config.UsePkce) { - if (string.IsNullOrWhiteSpace(codeVerifier)) - throw new InvalidOperationException("Missing PKCE code_verifier."); - - formValues.Add(new("code_verifier", codeVerifier)); + var codeVerifier = await pkceStateService.GetPkceCodeVerifier(); + formValues.Add(new KeyValuePair("code_verifier", codeVerifier)); } - var response = await httpClient.PostAsync(config.TokenEndpoint, new FormUrlEncodedContent(formValues), cancellationToken); - response.EnsureSuccessStatusCode(); + var refreshRequestMessage = new HttpRequestMessage(HttpMethod.Post, config.TokenEndpoint) + { + Content = new FormUrlEncodedContent(formValues) + }; + + // Send request. + var response = await httpClient.SendAsync(refreshRequestMessage, cancellationToken); - var tokens = await response.Content.ReadFromJsonAsync(cancellationToken) - ?? throw new InvalidOperationException("Failed to read token response."); + var tokens = (await response.Content.ReadFromJsonAsync(cancellationToken))!; await jwtAccessor.WriteTokenAsync(TokenNames.RefreshToken, tokens.RefreshToken ?? ""); await jwtAccessor.WriteTokenAsync(TokenNames.AccessToken, tokens.AccessToken ?? ""); await jwtAccessor.WriteTokenAsync(TokenNames.IdToken, tokens.IdToken ?? ""); + string returnUrl = "/"; + if (!String.IsNullOrWhiteSpace(state)) + { + returnUrl = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(state)); + } navigationManager.NavigateTo(returnUrl, true); } - -} - -public interface IOidcBrowserStateStore -{ - ValueTask SetAsync(string key, string value); - ValueTask GetAsync(string key); - ValueTask RemoveAsync(string key); - ValueTask TakeAsync(string key); -} - -public class SessionStorageOidcStateStore : IOidcBrowserStateStore -{ - private readonly IJSRuntime _js; - - public SessionStorageOidcStateStore(IJSRuntime js) => _js = js; - - public ValueTask SetAsync(string key, string value) => - _js.InvokeVoidAsync("oidcState.set", key, value); - - public ValueTask GetAsync(string key) => - _js.InvokeAsync("oidcState.get", key); - - public ValueTask RemoveAsync(string key) => - _js.InvokeVoidAsync("oidcState.remove", key); - - public async ValueTask TakeAsync(string key) - { - var value = await GetAsync(key); - if (value != null) - await RemoveAsync(key); - return value; - } }