From 93297b6764c6df4b2a65dfeb96e1cb69eefd9abd Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 13 Jan 2023 22:55:08 +0100 Subject: [PATCH] Refresh tokens (#3626) * Remove elastic hosted service for alias/rollover * Fix dependency issues * Implement token refresh * Update appsettings --- .../Elsa.WorkflowServer.Web/appsettings.json | 3 +- .../src/data/auth-store.tsx | 11 +++- .../src/modules/flowchart/flowchart.tsx | 1 - .../modules/login/components/login-page.tsx | 6 +- .../src/modules/login/models.ts | 1 + .../src/modules/login/plugin.tsx | 27 ++++++-- .../src/modules/login/services.ts | 23 +++++-- .../components/editor.tsx | 1 - .../src/services/api-client/api-client.ts | 2 - .../src/services/auth.ts | 21 ++++++- .../src/utils/graph.ts | 3 +- .../Features/ElasticsearchFeature.cs | 11 ---- .../ConfigureAliasesHostedService.cs | 62 ------------------- ...oreWorkflowManagementPersistenceFeature.cs | 2 +- .../Modules/Runtime/Extensions.cs | 11 ---- .../Elsa.Identity/Endpoints/Login/Endpoint.cs | 15 +++-- .../Elsa.Identity/Endpoints/Login/Models.cs | 12 ---- .../Endpoints/RefreshToken/Endpoint.cs | 37 +++++++++++ .../Extensions/ModuleExtensions.cs | 2 +- .../DefaultAccessTokenIssuer.cs | 16 +++-- .../Elsa.Identity/Models/LoginResponse.cs | 15 +++++ .../Options/IdentityTokenOptions.cs | 22 +++++-- .../Services/IAccessTokenIssuer.cs | 3 +- .../Features/WorkflowManagementFeature.cs | 1 + .../Extensions/ModuleExtensions.cs | 6 ++ .../AsyncWorkflowStateExporterFeature.cs | 26 ++++++++ .../Features/WorkflowRuntimeFeature.cs | 4 +- .../Program.cs | 2 +- .../Program.cs | 2 +- .../Program.cs | 2 +- .../Elsa.Samples.TelnyxIntegration/Program.cs | 2 +- 31 files changed, 206 insertions(+), 146 deletions(-) delete mode 100644 src/modules/Elsa.Elasticsearch/HostedServices/ConfigureAliasesHostedService.cs create mode 100644 src/modules/Elsa.Identity/Endpoints/RefreshToken/Endpoint.cs create mode 100644 src/modules/Elsa.Identity/Models/LoginResponse.cs create mode 100644 src/modules/Elsa.Workflows.Runtime/Features/AsyncWorkflowStateExporterFeature.cs diff --git a/src/bundles/Elsa.WorkflowServer.Web/appsettings.json b/src/bundles/Elsa.WorkflowServer.Web/appsettings.json index 28cc21ee6..1dd6e4426 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/appsettings.json +++ b/src/bundles/Elsa.WorkflowServer.Web/appsettings.json @@ -14,7 +14,8 @@ "CreateDefaultAdmin": true, "Tokens": { "SigningKey": "secret-signing-key", - "Lifetime": "8:00:00" + "AccessTokenLifetime": "0:10:00", + "RefreshTokenLifetime": "1:00:00" } } } diff --git a/src/designer/elsa-workflows-designer/src/data/auth-store.tsx b/src/designer/elsa-workflows-designer/src/data/auth-store.tsx index 4ba65a3ee..f409844b7 100644 --- a/src/designer/elsa-workflows-designer/src/data/auth-store.tsx +++ b/src/designer/elsa-workflows-designer/src/data/auth-store.tsx @@ -1,8 +1,17 @@ import {h} from '@stencil/core'; import {createStore} from '@stencil/store'; -const {state, onChange} = createStore({ +export interface AuthStore { + accessToken: string; + refreshToken: string; + name: string; + permissions: Array; + signedIn: boolean; +} + +const {state, onChange} = createStore({ accessToken: null, + refreshToken: null, name: null, permissions: [], signedIn: false diff --git a/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx b/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx index e4f2efd23..90445db13 100644 --- a/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx +++ b/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx @@ -115,7 +115,6 @@ export class FlowchartComponent { @Method() async autoLayout(direction: LayoutDirection) { - debugger; const dagreLayout = new DagreLayout({ type: 'dagre', rankdir: direction, diff --git a/src/designer/elsa-workflows-designer/src/modules/login/components/login-page.tsx b/src/designer/elsa-workflows-designer/src/modules/login/components/login-page.tsx index 892cedb55..c9f72e1a6 100644 --- a/src/designer/elsa-workflows-designer/src/modules/login/components/login-page.tsx +++ b/src/designer/elsa-workflows-designer/src/modules/login/components/login-page.tsx @@ -41,11 +41,9 @@ export class LoginPage { this.signedIn.emit(); const accessToken = loginResponse.accessToken; - const claims = jwt_decode(accessToken); - const permissions = claims.permissions || []; - const name = claims.name || ''; + const refreshToken = loginResponse.refreshToken; const authContext = Container.get(AuthContext); - await authContext.signIn(name, permissions, accessToken, rememberMe); + await authContext.signinTokens(accessToken, refreshToken, rememberMe); } private renderError = () => { diff --git a/src/designer/elsa-workflows-designer/src/modules/login/models.ts b/src/designer/elsa-workflows-designer/src/modules/login/models.ts index 385083c10..50ba794b4 100644 --- a/src/designer/elsa-workflows-designer/src/modules/login/models.ts +++ b/src/designer/elsa-workflows-designer/src/modules/login/models.ts @@ -1,6 +1,7 @@ export interface LoginResponse { isAuthenticated: boolean; accessToken: string; + refreshToken: string; } export interface SignedInArgs { diff --git a/src/designer/elsa-workflows-designer/src/modules/login/plugin.tsx b/src/designer/elsa-workflows-designer/src/modules/login/plugin.tsx index 9f2a0b36e..69bbdedd8 100644 --- a/src/designer/elsa-workflows-designer/src/modules/login/plugin.tsx +++ b/src/designer/elsa-workflows-designer/src/modules/login/plugin.tsx @@ -6,6 +6,8 @@ import {Container, Service} from "typedi"; import {StudioService, AuthContext, EventBus, ElsaClient, ElsaApiClientProvider} from "../../services"; import descriptorsStore from '../../data/descriptors-store'; import {SignedInArgs} from "./models"; +import {AxiosInstance} from "axios"; +import {LoginApi} from "./services"; @Service() export class LoginPlugin implements Plugin { @@ -48,7 +50,7 @@ export class LoginPlugin implements Plugin { private onHttpClientCreated = async (e) => { const service: MiddlewareService = e.service; - const studioService = this.studioService; + const loginApi = Container.get(LoginApi); service.register({ async onRequest(request) { @@ -56,16 +58,33 @@ export class LoginPlugin implements Plugin { const token = authContext.getAccessToken(); if (!!token) - request.headers = {...request.headers, 'Authorization': `Bearer ${token}`}; + request.headers = {...request.headers, Authorization: `Bearer ${token}`}; return request; }, async onResponseError(error) { - if (error.response.status !== 401) + if (error.response.status !== 401 || error.response.config.hasRetriedRequest) return; - studioService.show(() => ); + const authContext = Container.get(AuthContext); + const loginResponse = await loginApi.refreshAccessToken(authContext.getRefreshToken()); + + if (loginResponse.isAuthenticated) { + + await authContext.signinTokens(loginResponse.accessToken, loginResponse.refreshToken, true); + + const t = await service.http({ + ...error.config, + hasRetriedRequest: true, + headers: { + ...error.config.headers, + Authorization: `Bearer ${loginResponse.accessToken}` + } + }); + + return t; + } } }); }; diff --git a/src/designer/elsa-workflows-designer/src/modules/login/services.ts b/src/designer/elsa-workflows-designer/src/modules/login/services.ts index 02664c484..c3d0a3310 100644 --- a/src/designer/elsa-workflows-designer/src/modules/login/services.ts +++ b/src/designer/elsa-workflows-designer/src/modules/login/services.ts @@ -1,14 +1,14 @@ import 'reflect-metadata'; -import {Service} from "typedi"; -import {ElsaApiClientProvider} from "../../services"; +import {Container, Service} from "typedi"; +import {ElsaApiClientProvider, EventBus, ServerSettings} from "../../services"; import {LoginResponse} from "./models"; -import {AxiosError} from "axios"; +import axios, {AxiosError, AxiosRequestConfig} from "axios"; +import {EventTypes} from "../../models"; @Service() export class LoginApi { - private provider: ElsaApiClientProvider; - constructor(provider: ElsaApiClientProvider) { + constructor(private provider: ElsaApiClientProvider, private serverSettings: ServerSettings) { this.provider = provider; } @@ -17,4 +17,17 @@ export class LoginApi { const response = await httpClient.post(`identity/login`, {username, password},); return response.data; } + + async refreshAccessToken(refreshToken: string): Promise { + const config: AxiosRequestConfig = { + baseURL: this.serverSettings.baseAddress, + headers: { + Authorization: `Bearer ${refreshToken}` + } + }; + + const httpClient = axios.create(config); + const response = await httpClient.post(`identity/refresh-token`); + return response.data; + } } diff --git a/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/editor.tsx b/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/editor.tsx index 40d9e655f..54a3b660c 100644 --- a/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/editor.tsx +++ b/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/editor.tsx @@ -228,7 +228,6 @@ export class WorkflowDefinitionEditor { private onZoomToFit = async () => await this.flowchart.zoomToFit(); private onAutoLayout = async (direction: LayoutDirection) => { - debugger; await this.flowchart.autoLayout(direction); }; diff --git a/src/designer/elsa-workflows-designer/src/services/api-client/api-client.ts b/src/designer/elsa-workflows-designer/src/services/api-client/api-client.ts index c905a6cf3..f87ddb97a 100644 --- a/src/designer/elsa-workflows-designer/src/services/api-client/api-client.ts +++ b/src/designer/elsa-workflows-designer/src/services/api-client/api-client.ts @@ -51,10 +51,8 @@ async function createHttpClient(baseAddress: string): Promise { const eventBus = Container.get(EventBus); await eventBus.emit(EventTypes.HttpClient.ConfigCreated, this, {config}); - const httpClient = axios.create(config); const middlewareService = new MiddlewareService(httpClient); - await eventBus.emit(EventTypes.HttpClient.ClientCreated, this, {service: middlewareService, httpClient}); return httpClient; diff --git a/src/designer/elsa-workflows-designer/src/services/auth.ts b/src/designer/elsa-workflows-designer/src/services/auth.ts index 181289eb2..7703987c5 100644 --- a/src/designer/elsa-workflows-designer/src/services/auth.ts +++ b/src/designer/elsa-workflows-designer/src/services/auth.ts @@ -4,6 +4,7 @@ import cookies from 'js-cookie'; import authStore from "../data/auth-store"; import {EventBus} from "./event-bus"; import {EventTypes} from "../models"; +import jwt_decode from "jwt-decode"; export const AuthEventTypes = { Unauthorized: 'auth:unauthorized' @@ -24,14 +25,23 @@ export class AuthContext { authStore.permissions = authData.permissions; authStore.signedIn = authData.signedIn; authStore.accessToken = authData.accessToken; + authStore.refreshToken = authData.refreshToken; } } - async signIn(name: string, permissions: Array, accessToken: string, createPersistentCookie: boolean) { + async signinTokens(accessToken: string, refreshToken: string, createPersistentCookie: boolean){ + const claims = jwt_decode(accessToken); + const permissions = claims.permissions || []; + const name = claims.name || ''; + await this.signIn(name, permissions, accessToken, refreshToken, createPersistentCookie); + } + + async signIn(name: string, permissions: Array, accessToken: string, refreshToken: string, createPersistentCookie: boolean) { authStore.name = name; authStore.permissions = permissions; authStore.signedIn = true; authStore.accessToken = accessToken; + authStore.refreshToken = refreshToken; const data = JSON.stringify(authStore); sessionStorage.setItem('dashboard-session', data); @@ -47,7 +57,10 @@ export class AuthContext { authStore.name = null; authStore.permissions = []; authStore.signedIn = false; - authStore.accessToken = false; + authStore.accessToken = null; + authStore.refreshToken = null; + sessionStorage.clear(); + cookies.remove('dashboard-session'); await this.eventBus.emit(EventTypes.Auth.SignedOut) } @@ -58,4 +71,8 @@ export class AuthContext { getAccessToken() { return authStore.accessToken; } + + getRefreshToken() { + return authStore.refreshToken; + } } diff --git a/src/designer/elsa-workflows-designer/src/utils/graph.ts b/src/designer/elsa-workflows-designer/src/utils/graph.ts index 0b1431385..7bcfd6c6d 100644 --- a/src/designer/elsa-workflows-designer/src/utils/graph.ts +++ b/src/designer/elsa-workflows-designer/src/utils/graph.ts @@ -92,7 +92,6 @@ function updatePortsAndEdgeOfNodeCouple(graph: Graph, sourceNode: Node outgoingEdges.map(edge => edge.data.target).includes(node.id)); const nodeCouplesWithPositions = calculatePositionsForInflexibleNode(sourceNode, targetNodes); @@ -102,7 +101,7 @@ function updatePortsAndEdgeOfNodeCouple(graph: Graph, sourceNode: Node public Action Options { get; set; } = _ => { }; - /// - public override void ConfigureHostedServices() - { - Module.ConfigureHostedService(-2); - } - /// public override void Apply() { diff --git a/src/modules/Elsa.Elasticsearch/HostedServices/ConfigureAliasesHostedService.cs b/src/modules/Elsa.Elasticsearch/HostedServices/ConfigureAliasesHostedService.cs deleted file mode 100644 index 761e01baa..000000000 --- a/src/modules/Elsa.Elasticsearch/HostedServices/ConfigureAliasesHostedService.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Elastic.Clients.Elasticsearch; -using Elastic.Clients.Elasticsearch.IndexManagement; -using Elsa.Elasticsearch.Options; -using Elsa.Elasticsearch.Services; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; - -namespace Elsa.Elasticsearch.HostedServices; - -/// -/// Configures aliases. -/// -public class ConfigureAliasesHostedService : IHostedService -{ - private readonly ElasticsearchClient _client; - private readonly ElasticsearchOptions _options; - private readonly IEnumerable _configurations; - - /// - /// Constructor. - /// - public ConfigureAliasesHostedService(ElasticsearchClient client, IOptions options, IEnumerable configurations) - { - _client = client; - _options = options.Value; - _configurations = configurations; - } - - /// - public async Task StartAsync(CancellationToken cancellationToken) - { - await ConfigureClientAsync(cancellationToken); - } - - /// - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - private async Task ConfigureClientAsync(CancellationToken cancellationToken) - { - foreach (var configuration in _configurations) - await configuration.ConfigureClientAsync(_client, cancellationToken); - - foreach (var configuration in _configurations) - { - var alias = _options.GetIndexNameFor(configuration.DocumentType); - var indexName = configuration.IndexNamingStrategy.GenerateName(alias); - var indexExists = (await _client.Indices.ExistsAsync(indexName, cancellationToken)).Exists; - - if (indexExists) - continue; - - var response = await _client.Indices.CreateAsync(indexName, c => c - .Aliases(a => a.Add(alias, new Alias { IsWriteIndex = true })), cancellationToken); - - if (response.IsValidResponse) - continue; - - if (response.TryGetOriginalException(out var exception)) - throw exception!; - } - } -} \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreWorkflowManagementPersistenceFeature.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreWorkflowManagementPersistenceFeature.cs index 0f84f93a6..06be22263 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreWorkflowManagementPersistenceFeature.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreWorkflowManagementPersistenceFeature.cs @@ -33,6 +33,6 @@ public class EFCoreWorkflowManagementPersistenceFeature : PersistenceFeatureBase base.Apply(); AddStore(); - AddStore(); + AddStore(); } } \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/Extensions.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/Extensions.cs index 42805a759..40beb7b49 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/Extensions.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/Extensions.cs @@ -1,6 +1,4 @@ using Elsa.Workflows.Runtime.Features; -using Elsa.Workflows.Runtime.Implementations; -using Microsoft.Extensions.DependencyInjection; namespace Elsa.EntityFrameworkCore.Modules.Runtime; @@ -26,13 +24,4 @@ public static class Extensions feature.Module.Configure(configure); return feature; } - - /// - /// Configures the workflow runtime to send workflow state to the . - /// - public static WorkflowRuntimeFeature UseAsyncWorkflowStateExporter(this WorkflowRuntimeFeature feature) - { - feature.WorkflowStateExporter = sp => ActivatorUtilities.CreateInstance(sp); - return feature; - } } \ No newline at end of file diff --git a/src/modules/Elsa.Identity/Endpoints/Login/Endpoint.cs b/src/modules/Elsa.Identity/Endpoints/Login/Endpoint.cs index 7eb09ab7e..e33ec5233 100644 --- a/src/modules/Elsa.Identity/Endpoints/Login/Endpoint.cs +++ b/src/modules/Elsa.Identity/Endpoints/Login/Endpoint.cs @@ -1,9 +1,10 @@ -using Elsa.Identity.Services; +using Elsa.Identity.Models; +using Elsa.Identity.Services; using FastEndpoints; namespace Elsa.Identity.Endpoints.Login; -public class Login : Endpoint +public class Login : Endpoint { private readonly ICredentialsValidator _credentialsValidator; private readonly IAccessTokenIssuer _tokenIssuer; @@ -14,21 +15,23 @@ public class Login : Endpoint _tokenIssuer = tokenIssuer; } + /// public override void Configure() { Post("/identity/login"); AllowAnonymous(); } - public override async Task ExecuteAsync(Request request, CancellationToken cancellationToken) + /// + public override async Task ExecuteAsync(Request request, CancellationToken cancellationToken) { var user = await _credentialsValidator.ValidateAsync(request.Username.Trim(), request.Password.Trim(), cancellationToken); if (user == null) - return new Response(false, null); + return new LoginResponse(false, null, null); - var token = await _tokenIssuer.IssueTokenAsync(user, cancellationToken); + var tokens = await _tokenIssuer.IssueTokensAsync(user, cancellationToken); - return new Response(true, token); + return new LoginResponse(true, tokens.AccessToken, tokens.RefreshToken); } } \ No newline at end of file diff --git a/src/modules/Elsa.Identity/Endpoints/Login/Models.cs b/src/modules/Elsa.Identity/Endpoints/Login/Models.cs index 9ea1cbcfb..2cd05356c 100644 --- a/src/modules/Elsa.Identity/Endpoints/Login/Models.cs +++ b/src/modules/Elsa.Identity/Endpoints/Login/Models.cs @@ -4,16 +4,4 @@ public class Request { public string Username { get; set; } = default!; public string Password { get; set; } = default!; -} - -public class Response -{ - public Response(bool isAuthenticated, string? accessToken) - { - IsAuthenticated = isAuthenticated; - AccessToken = accessToken; - } - - public bool IsAuthenticated { get; } - public string? AccessToken { get; } } \ No newline at end of file diff --git a/src/modules/Elsa.Identity/Endpoints/RefreshToken/Endpoint.cs b/src/modules/Elsa.Identity/Endpoints/RefreshToken/Endpoint.cs new file mode 100644 index 000000000..9c9f71165 --- /dev/null +++ b/src/modules/Elsa.Identity/Endpoints/RefreshToken/Endpoint.cs @@ -0,0 +1,37 @@ +using Elsa.Identity.Models; +using Elsa.Identity.Services; +using FastEndpoints; + +namespace Elsa.Identity.Endpoints.RefreshToken; + +public class RefreshToken : EndpointWithoutRequest +{ + private readonly IUserStore _userStore; + private readonly IAccessTokenIssuer _tokenIssuer; + + /// + public RefreshToken(IUserStore userStore, IAccessTokenIssuer tokenIssuer) + { + _userStore = userStore; + _tokenIssuer = tokenIssuer; + } + + /// + public override void Configure() + { + Post("/identity/refresh-token"); + } + + /// + public override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var user = await _userStore.FindAsync(User.Identity!.Name!, cancellationToken); + + if (user == null) + return new LoginResponse(false, null, null); + + var tokens = await _tokenIssuer.IssueTokensAsync(user, cancellationToken); + + return new LoginResponse(true, tokens.AccessToken, tokens.RefreshToken); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs index bd716a0b9..929db5d93 100644 --- a/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs @@ -28,7 +28,7 @@ public static class ModuleExtensions { Audience = audience, Issuer = issuer, - Lifetime = tokenLifetime ?? TimeSpan.FromHours(1), + AccessTokenLifetime = tokenLifetime ?? TimeSpan.FromHours(1), SigningKey = signingKey }); return module; diff --git a/src/modules/Elsa.Identity/Implementations/DefaultAccessTokenIssuer.cs b/src/modules/Elsa.Identity/Implementations/DefaultAccessTokenIssuer.cs index cdb32d91d..1b277a980 100644 --- a/src/modules/Elsa.Identity/Implementations/DefaultAccessTokenIssuer.cs +++ b/src/modules/Elsa.Identity/Implementations/DefaultAccessTokenIssuer.cs @@ -20,10 +20,10 @@ public class DefaultAccessTokenIssuer : IAccessTokenIssuer _identityOptions = identityOptions.Value; } - public ValueTask IssueTokenAsync(User user, CancellationToken cancellationToken = default) + public ValueTask IssueTokensAsync(User user, CancellationToken cancellationToken = default) { var permissions = user.Roles.SelectMany(x => x.Permissions).ToList(); - var (signingKey, issuer, audience, lifetime) = _identityOptions; + var (signingKey, issuer, audience, accessTokenLifetime, refreshTokenLifetime) = _identityOptions; if (string.IsNullOrWhiteSpace(signingKey)) throw new Exception("No signing key configured"); if (string.IsNullOrWhiteSpace(issuer)) throw new Exception("No issuer configured"); @@ -32,9 +32,13 @@ public class DefaultAccessTokenIssuer : IAccessTokenIssuer var nameClaim = new Claim(JwtRegisteredClaimNames.Name, user.Name); var claims = new[] { nameClaim }; - var expiresAt = lifetime != null ? _systemClock.UtcNow.Add(lifetime.Value) : default(DateTimeOffset?); - var token = JWTBearer.CreateToken(signingKey, expiresAt?.DateTime, permissions, issuer: issuer, audience: audience, claims: claims); + var accessTokenExpiresAt = _systemClock.UtcNow.Add(accessTokenLifetime); + var refreshTokenExpiresAt = _systemClock.UtcNow.Add(refreshTokenLifetime); + var accessToken = JWTBearer.CreateToken(signingKey, accessTokenExpiresAt.UtcDateTime, permissions, issuer: issuer, audience: audience, claims: claims); + var refreshToken = JWTBearer.CreateToken(signingKey, refreshTokenExpiresAt.UtcDateTime, permissions, issuer: issuer, audience: audience, claims: claims); - return new(token); + return new (new IssuedTokens(accessToken, refreshToken)); } -} \ No newline at end of file +} + +public record IssuedTokens(string AccessToken, string RefreshToken); \ No newline at end of file diff --git a/src/modules/Elsa.Identity/Models/LoginResponse.cs b/src/modules/Elsa.Identity/Models/LoginResponse.cs new file mode 100644 index 000000000..c584b1929 --- /dev/null +++ b/src/modules/Elsa.Identity/Models/LoginResponse.cs @@ -0,0 +1,15 @@ +namespace Elsa.Identity.Models; + +public class LoginResponse +{ + public LoginResponse(bool isAuthenticated, string? accessToken, string? refreshToken) + { + IsAuthenticated = isAuthenticated; + AccessToken = accessToken; + RefreshToken = refreshToken; + } + + public bool IsAuthenticated { get; } + public string? AccessToken { get; } + public string? RefreshToken { get; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs b/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs index 7f1e076e1..2a6ab0e16 100644 --- a/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs +++ b/src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs @@ -1,3 +1,4 @@ +using System.IdentityModel.Tokens.Jwt; using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; @@ -9,7 +10,8 @@ public class IdentityTokenOptions public string SigningKey { get; set; } public string Issuer { get; set; } = "http://elsa.api"; public string Audience { get; set; } = "http://elsa.api"; - public TimeSpan? Lifetime { get; set; } = TimeSpan.FromHours(1); + public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1); + public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromHours(2); public SecurityKey CreateSecurityKey() => new SymmetricSecurityKey(Encoding.ASCII.GetBytes(SigningKey)); @@ -18,18 +20,27 @@ public class IdentityTokenOptions { IssuerSigningKey = CreateSecurityKey(), ValidAudience = Audience, - ValidIssuer = Issuer + ValidIssuer = Issuer, + ValidateLifetime = true, + LifetimeValidator = ValidateLifetime, + NameClaimType = JwtRegisteredClaimNames.Name }; + private bool ValidateLifetime(DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters) + { + return expires != null && expires > DateTime.UtcNow; + } + /// /// Deconstructor. /// - internal void Deconstruct(out string signingKey, out string issuer, out string audience, out TimeSpan? lifetime) + internal void Deconstruct(out string signingKey, out string issuer, out string audience, out TimeSpan accessTokenLifetime, out TimeSpan refreshTokenLifetime) { signingKey = SigningKey; issuer = Issuer; audience = Audience; - lifetime = Lifetime; + accessTokenLifetime = AccessTokenLifetime; + refreshTokenLifetime = RefreshTokenLifetime; } internal void CopyFrom(IdentityTokenOptions identityOptions) @@ -37,6 +48,7 @@ public class IdentityTokenOptions SigningKey = identityOptions.SigningKey; Audience = identityOptions.Audience; Issuer = identityOptions.Issuer; - Lifetime = identityOptions.Lifetime; + AccessTokenLifetime = identityOptions.AccessTokenLifetime; + RefreshTokenLifetime = identityOptions.RefreshTokenLifetime; } } \ No newline at end of file diff --git a/src/modules/Elsa.Identity/Services/IAccessTokenIssuer.cs b/src/modules/Elsa.Identity/Services/IAccessTokenIssuer.cs index 4fa507063..41c27835a 100644 --- a/src/modules/Elsa.Identity/Services/IAccessTokenIssuer.cs +++ b/src/modules/Elsa.Identity/Services/IAccessTokenIssuer.cs @@ -1,8 +1,9 @@ using Elsa.Identity.Entities; +using Elsa.Identity.Implementations; namespace Elsa.Identity.Services; public interface IAccessTokenIssuer { - ValueTask IssueTokenAsync(User user, CancellationToken cancellationToken = default); + ValueTask IssueTokensAsync(User user, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 9fc1782a0..0c089e079 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -29,6 +29,7 @@ namespace Elsa.Workflows.Management.Features; [DependsOn(typeof(MediatorFeature))] [DependsOn(typeof(SystemClockFeature))] [DependsOn(typeof(WorkflowsFeature))] +[DependsOn(typeof(WorkflowDefinitionsFeature))] public class WorkflowManagementFeature : FeatureBase { private const string PrimitivesCategory = "Primitives"; diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/ModuleExtensions.cs index 42d09d2cc..3a95c04ce 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/ModuleExtensions.cs @@ -39,4 +39,10 @@ public static class ModuleExtensions feature.Module.Configure(configure); return feature; } + + public static WorkflowRuntimeFeature UseAsyncWorkflowStateExporter(this WorkflowRuntimeFeature feature, Action? configure = default) + { + feature.Module.Configure(configure); + return feature; + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Features/AsyncWorkflowStateExporterFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/AsyncWorkflowStateExporterFeature.cs new file mode 100644 index 000000000..a38e7f300 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Features/AsyncWorkflowStateExporterFeature.cs @@ -0,0 +1,26 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.Workflows.Management.Features; +using Elsa.Workflows.Runtime.Implementations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.Runtime.Features; + +/// +/// Configures and enables . +/// +[DependsOn(typeof(WorkflowInstancesFeature))] +public class AsyncWorkflowStateExporterFeature : FeatureBase +{ + /// + public AsyncWorkflowStateExporterFeature(IModule module) : base(module) + { + } + + /// + public override void Configure() + { + Module.Configure(workflowRuntime => workflowRuntime.WorkflowStateExporter = sp => ActivatorUtilities.CreateInstance(sp)); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index b2de895d1..8ceefaee4 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -72,7 +72,7 @@ public class WorkflowRuntimeFeature : FeatureBase /// /// A factory that instantiates an . /// - public Func WorkflowStateExporter { get; set; } = sp => sp.GetRequiredService(); + public Func WorkflowStateExporter { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance; /// /// A factory that instantiates an . @@ -145,8 +145,6 @@ public class WorkflowRuntimeFeature : FeatureBase .AddWorkflowDefinitionProvider() // Workflow state exporter. - .AddSingleton() - .AddSingleton() .AddSingleton(WorkflowStateExporter) // Domain handlers. diff --git a/src/samples/aspnet/Elsa.Samples.AzureServiceBusActivities/Program.cs b/src/samples/aspnet/Elsa.Samples.AzureServiceBusActivities/Program.cs index 5da8aa7a8..09cd8230d 100644 --- a/src/samples/aspnet/Elsa.Samples.AzureServiceBusActivities/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.AzureServiceBusActivities/Program.cs @@ -37,7 +37,7 @@ builder.Services.AddElsa(elsa => { identity.IdentityOptions.CreateDefaultAdmin = builder.Environment.IsDevelopment(); identity.TokenOptions.SigningKey = "secret-token-signing-key"; - identity.TokenOptions.Lifetime = TimeSpan.FromDays(1); + identity.TokenOptions.AccessTokenLifetime = TimeSpan.FromDays(1); }); // Use default authentication (JWT). diff --git a/src/samples/aspnet/Elsa.Samples.ElasticsearchStorage/Program.cs b/src/samples/aspnet/Elsa.Samples.ElasticsearchStorage/Program.cs index 9689f0d1e..d6ae8392c 100644 --- a/src/samples/aspnet/Elsa.Samples.ElasticsearchStorage/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.ElasticsearchStorage/Program.cs @@ -45,7 +45,7 @@ builder.Services.AddElsa(elsa => { identity.IdentityOptions.CreateDefaultAdmin = builder.Environment.IsDevelopment(); identity.TokenOptions.SigningKey = "secret-token-signing-key"; - identity.TokenOptions.Lifetime = TimeSpan.FromDays(1); + identity.TokenOptions.AccessTokenLifetime = TimeSpan.FromDays(1); }); // Use default authentication (JWT). diff --git a/src/samples/aspnet/Elsa.Samples.MassTransitActivities/Program.cs b/src/samples/aspnet/Elsa.Samples.MassTransitActivities/Program.cs index b896cd6b9..d4c963473 100644 --- a/src/samples/aspnet/Elsa.Samples.MassTransitActivities/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.MassTransitActivities/Program.cs @@ -40,7 +40,7 @@ builder.Services.AddElsa(elsa => { identity.IdentityOptions.CreateDefaultAdmin = builder.Environment.IsDevelopment(); identity.TokenOptions.SigningKey = "secret-token-signing-key"; - identity.TokenOptions.Lifetime = TimeSpan.FromDays(1); + identity.TokenOptions.AccessTokenLifetime = TimeSpan.FromDays(1); }); // Use default authentication (JWT). diff --git a/src/samples/aspnet/Elsa.Samples.TelnyxIntegration/Program.cs b/src/samples/aspnet/Elsa.Samples.TelnyxIntegration/Program.cs index eee300b04..024e942f2 100644 --- a/src/samples/aspnet/Elsa.Samples.TelnyxIntegration/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.TelnyxIntegration/Program.cs @@ -63,7 +63,7 @@ services runtime.UseDefaultRuntime(dr => dr.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))); runtime.UseExecutionLogRecords(d => d.UseEntityFrameworkCore(ef => ef.UseSqlite())); runtime.UseExecutionLogRecords(e => e.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))); - runtime.WorkflowStateExporter = sp => sp.GetRequiredService(); + runtime.UseAsyncWorkflowStateExporter(); }) .UseActivityDefinitions(feature => feature.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))) .UseJobs(jobs => jobs.ConfigureOptions = options => options.WorkerCount = 10)