Fix external authentication review findings
This commit is contained in:
parent
a24a6fe267
commit
fe125ac336
|
|
@ -25,12 +25,13 @@ Reference the matching provider package — `Elsa.ExternalAuthentication.Persist
|
|||
|
||||
## Schema changes
|
||||
|
||||
The tables are unchanged in name, columns, and indexes, and still default to the `Elsa` schema — but they now belong to `ExternalAuthenticationElsaDbContext` with its own migration history. Since no release ever shipped the old migration, apply the new `Initial` migration directly; there is no baseline or history-rewriting step.
|
||||
The persistence objects still default to the `Elsa` schema, but now belong to `ExternalAuthenticationElsaDbContext` with its own migration history. Since no release ever shipped the old migration, apply the new `Initial` migration directly; there is no baseline or history-rewriting step.
|
||||
|
||||
Three deliberate differences from the pre-release schema:
|
||||
Deliberate differences from the pre-release schema:
|
||||
|
||||
- **`FK_ExternalIdentityLinks_Users_UserId` is gone**, along with the index EF generated for it. The two contexts can now target different databases, so a cross-aggregate foreign key is no longer expressible. `ExternalIdentityLinks.UserId` is a plain column covered by `IX_ExternalIdentityLink_TenantId_UserId`. **Consequence:** deleting a user with links is no longer blocked at the database level and leaves the links dangling. (The old constraint surfaced as an unhandled `DbUpdateException` → HTTP 500 from the user-delete endpoint, so it was never a usable guard.) A user-deletion dependency contributor, modelled on the existing `ExternalAuthenticationRoleDeletionDependencyContributor`, is the intended fix.
|
||||
- **`FK_ExternalIdentityLinks_Users_UserId` is gone**, along with the index EF generated for it. The two contexts can now target different databases, so a cross-aggregate foreign key is no longer expressible. `ExternalIdentityLinks.UserId` is a plain column covered by `IX_ExternalIdentityLink_TenantId_UserId`. User deletion is instead coordinated through `IUserDeletionDependencyContributor`; External Authentication blocks deletion while links remain and returns a conflict instead of relying on an unhandled database exception.
|
||||
- **The `ExternalAuthenticationClients` table is dropped.** It had no readers or writers; authentication clients come from `ExternalAuthenticationOptions`.
|
||||
- **An unissued refresh token is represented by no row.** `ExternalAuthenticationSessions.CurrentRefreshTokenHash` is replaced by the optional one-to-one `ExternalAuthenticationSessionRefreshTokens` table. Its non-null `Hash` remains uniquely indexed, so callback completion no longer has to persist a synthetic `unissued:*` value before the first token is minted.
|
||||
- **Oracle JSON and protected-payload columns are now `NCLOB`/`BLOB`.** They were previously inferred as `NVARCHAR2(2000)`/`RAW(2000)`, which a real OpenID Connect discovery document or a data-protected broker transaction overflows at runtime. Indexed columns are unchanged, since Oracle cannot index a LOB.
|
||||
|
||||
The regenerated migrations also take `IElsaDbContextSchema` and honour a configured schema name. The pre-release migrations hardcoded `schema: "Elsa"`, so a non-default `SchemaName` did not work.
|
||||
|
|
@ -40,7 +41,11 @@ The regenerated migrations also take `IElsaDbContextSchema` and honour a configu
|
|||
`EFCoreExternalIdentityProvisioner` now resolves users through `IUserProvider` and writes them through `IUserStore`, matching what `InMemoryExternalIdentityProvisioner` already did. Two effects:
|
||||
|
||||
- JIT provisioning works with any user directory, not only EF-backed Identity. It previously queried `IdentityElsaDbContext.Users` directly and failed for configuration-defined users.
|
||||
- User creation and link creation are no longer in one database transaction. The unique `IX_ExternalIdentityLink_Identity` index still guarantees at most one link per `(TenantId, ConnectionKey, Issuer, SubjectHash)`. A writer that loses that race converges on the winning link and deletes the user it had just created; cleanup failures are logged and never fail the sign-in. A process crash between the two writes can strand a credential-less user, which cannot authenticate by any path.
|
||||
- User creation and link creation are no longer represented as one database transaction. Provider-independent user resolution, role validation, generated-name collision handling, and compensation are shared by every persistence implementation. The unique `IX_ExternalIdentityLink_Identity` index guarantees at most one link per `(TenantId, ConnectionKey, Issuer, SubjectHash)`. A writer that loses the race or observes a failed link write removes the credential-less user it created; an observed cleanup failure fails the operation and issues no credentials. User deletion and link publication perform complementary post-write checks so either concurrent ordering removes the link or restores the User instead of leaving a dangling reference. Abrupt process termination between stores can leave a credential-less user, but never a usable authentication path or a second identity link.
|
||||
|
||||
## API compatibility
|
||||
|
||||
The unused `GET /external-authentication/descriptors/runtime` endpoint and its generated-client `ExternalAuthenticationRuntimeDescriptor` contract were removed. The endpoint duplicated deployment configuration, had no runtime consumer, and had not shipped in a stable release. Clients should use the specific adapter, policy, grant-source, matcher, and permission descriptor endpoints.
|
||||
|
||||
JIT provisioning remains meaningful only with `StoreBasedUserProvider`. With `ConfigurationBasedUserProvider` or `AdminUserProvider`, a created user is written to a store the provider never reads — pre-existing behaviour, unchanged here.
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ public interface IExternalIdentityProvisioner
|
|||
}
|
||||
```
|
||||
|
||||
The provisioner owns one transaction spanning generated-name reservation, credential-less User creation, unique link creation, and cleanup/convergence after a uniqueness race.
|
||||
The provisioner owns the operation-level invariant: credential-less User creation (including roles) completes before a link is returned, the link store atomically arbitrates the identity tuple, and every observed losing or failed link write compensates the User created by that writer. An observed compensation failure fails the operation and issues no credentials. User deletion and link publication perform complementary post-write checks: if deletion observes a concurrently published link it restores the User and reports a conflict; if publication observes a concurrently deleted User it removes the link and fails. User resolution, role validation, name generation, collision retry, and compensation are provider-independent; persistence providers implement only link storage and their native uniqueness/transaction behavior.
|
||||
|
||||
### Static Create-user Role Authorization
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ The existing `User` entity changes:
|
|||
|
||||
Credential-less users cannot authenticate through legacy or broker-local password validation. Existing password-backed rows require no data change.
|
||||
|
||||
JIT provisioning generates and atomically reserves a globally unique internal `User.Name`, leaves password fields null, creates the External Identity Link, and assigns authorized default/matcher roles in the same transaction.
|
||||
JIT provisioning generates a globally unique internal `User.Name`, leaves password fields null, and assigns authorized default/matcher roles in the User-store write. The link store independently arbitrates the external identity tuple; a losing or failed link writer removes only the credential-less User it created before returning or propagating the failure.
|
||||
|
||||
## AuthenticationClient
|
||||
|
||||
|
|
@ -223,12 +223,12 @@ EF persistence uses the same normalized `ExpiresAtUtcTicks` companion for the at
|
|||
| `LastRefreshedAt` | DateTimeOffset | Rotation time |
|
||||
| `ExpiresAt` | DateTimeOffset | Maximum session age; default eight hours |
|
||||
| `RefreshExpiresAt` | DateTimeOffset | Inactivity bound |
|
||||
| `CurrentRefreshTokenHash` | string | Keyed hash of current opaque token |
|
||||
| `CurrentRefreshTokenHash` | string? | Keyed hash of current opaque token; absent until the first refresh token is issued |
|
||||
| `RefreshGeneration` | long | Compare-and-swap rotation counter |
|
||||
| `RevokedAt` | DateTimeOffset? | Explicit or reuse-detection revocation |
|
||||
| `RevocationReason` | string? | Safe category |
|
||||
|
||||
Refresh atomically verifies current token hash and generation, rotates the token, and reevaluates current Elsa-owned role grants. It does not re-query upstream claims or mutate user roles. Reuse of a superseded token revokes the session.
|
||||
EF persistence stores the optional current hash in a one-to-one `ExternalAuthenticationSessionRefreshTokens` row so the unissued state requires no sentinel value. Refresh atomically verifies current token hash and generation, rotates the token, and reevaluates current Elsa-owned role grants. It does not re-query upstream claims or mutate user roles. Reuse of a superseded token revokes the session.
|
||||
|
||||
## ConnectionObservation
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
Add a server-owned External Authentication broker to Elsa 3. The broker composes configuration-owned connections, Studio-owned connections, and explicit full-shadow Studio Overrides host-wide within the connected environment. Record IDs identify management and transient broker state; immutable Connection Keys identify durable links and long-lived sessions. The broker dispatches to adapters, evaluates the selected unlinked policy/user matcher, assigns static authorized roles only to newly created users, and returns short-lived PKCE-bound completion codes.
|
||||
|
||||
V1 ships OpenID Connect as a separate adapter, Managed Secrets through Elsa Secrets, External Secrets through standard configuration, EF persistence integrated with the existing Identity transaction boundary, management and broker APIs, a generic `Elsa.Studio.Authentication.UI` shell, and paired Studio Server/WebAssembly clients. Existing local Identity and direct Studio OpenID Connect contracts remain compatible throughout Elsa 3.x.
|
||||
V1 ships OpenID Connect as a separate adapter, Managed Secrets through Elsa Secrets, External Secrets through standard configuration, independently enabled EF persistence with compensating cross-store JIT provisioning, management and broker APIs, a generic `Elsa.Studio.Authentication.UI` shell, and paired Studio Server/WebAssembly clients. Existing local Identity and direct Studio OpenID Connect contracts remain compatible throughout Elsa 3.x.
|
||||
|
||||
## Technical Context
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ src/modules/Elsa.Studio.ExternalAuthentication.Tests/
|
|||
tests/browser/ExternalAuthentication/
|
||||
```
|
||||
|
||||
**Structure Decision**: The Core broker remains protocol-neutral. OpenID Connect proves the adapter seam; Elsa Secrets and the configuration resolver cover Managed and External ownership. EF integration extends the Identity context so JIT User, link, and authorized role assignment are atomic. `Elsa.Studio.Authentication.UI` owns the generic shell; External Authentication contributes login behavior and connection administration. Host-specific credential handling remains split into Server and WebAssembly packages.
|
||||
**Structure Decision**: The Core broker remains protocol-neutral. OpenID Connect proves the adapter seam; Elsa Secrets and the configuration resolver cover Managed and External ownership. Provider-independent provisioning owns User resolution, authorized role assignment, collision handling, compensation, and cross-store convergence; persistence providers own durable unique-link arbitration. `Elsa.Studio.Authentication.UI` owns the generic shell; External Authentication contributes login behavior and connection administration. Host-specific credential handling remains split into Server and WebAssembly packages.
|
||||
|
||||
## Phase 0: Research
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ Resolved decisions include:
|
|||
|
||||
- Startup-installed adapter packages with runtime-managed connection settings.
|
||||
- Read-through merged registry with explicit full-shadow override semantics.
|
||||
- Atomic state/store contracts and EF Identity transaction integration.
|
||||
- Atomic state/store contracts and convergent cross-store identity provisioning.
|
||||
- Opaque completion/external refresh tokens with single-use/rotation.
|
||||
- Identity token issuance refactoring without breaking `IAccessTokenIssuer`.
|
||||
- OpenID Connect code-flow and validation through maintained protocol libraries.
|
||||
|
|
@ -205,7 +205,7 @@ No `NEEDS CLARIFICATION` markers remain.
|
|||
|
||||
### Milestone 2: Persisted Administration
|
||||
|
||||
1. Extend Identity EF context/migrations and implement atomic connection/link/session/state/observation stores.
|
||||
1. Implement the dedicated EF context/migrations and atomic connection/link/session/state/observation stores.
|
||||
2. Add management/descriptor/link/session APIs, explicit full-shadow overrides, permissions, ETags, archive/restore, and Managed/External Secret resolvers.
|
||||
3. Add connection list/editor, descriptor forms, lifecycle, test, and Preview Sign-in UI.
|
||||
4. Verify no-restart database changes and authoritative cross-node behavior.
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ V1 is complete when all three milestones meet their acceptance criteria.
|
|||
- **FR-074**: JIT provisioning MUST NOT generate placeholder passwords.
|
||||
- **FR-075**: Elsa's User persistence model MUST be migrated so Local Credentials are absent or separate rather than represented by placeholder password hashes.
|
||||
- **FR-076**: Local login for a credential-less user MUST fail with the same public result as other invalid credentials.
|
||||
- **FR-077**: JIT provisioning MUST atomically create a globally unique Elsa user name under the current identity-store contract; mutable provider profile attributes MUST NOT become identity keys. A future tenant-scoped user-name migration is outside this feature unless separately specified.
|
||||
- **FR-077**: JIT provisioning MUST create a globally unique Elsa user name under the current identity-store contract, retry a detected name collision, and compensate the User created by a losing or failed link writer. Mutable provider profile attributes MUST NOT become identity keys. A future tenant-scoped user-name migration is outside this feature unless separately specified.
|
||||
- **FR-078**: External Identity Links MUST be resolved by target tenant, immutable Connection Key, validated issuer namespace, and provider-stable subject.
|
||||
- **FR-079**: Built-in behavior MUST NOT link by email or user name.
|
||||
- **FR-080**: A custom Unlinked Identity Policy MAY deliberately implement deployment-specific linking behavior.
|
||||
|
|
@ -344,7 +344,7 @@ V1 is complete when all three milestones meet their acceptance criteria.
|
|||
- **FR-089**: Each connection MAY define static `defaultRoleIds` used only when `CreateUser` creates a new Elsa User, including the matcher policy's create-user no-match fallback.
|
||||
- **FR-090**: External User Matchers MUST NOT select, derive, or mutate roles or permissions.
|
||||
- **FR-091**: Saving `defaultRoleIds` MUST authorize the actor to assign every selected Role using Elsa's role-delegation rules.
|
||||
- **FR-092**: JIT provisioning MUST atomically assign authorized static default roles with user and link creation.
|
||||
- **FR-092**: JIT provisioning MUST assign authorized static default roles in the same User-store write as credential-less User creation and MUST NOT return success until the unique external identity link is durable.
|
||||
- **FR-093**: Matching an existing user MUST NOT change that user's roles.
|
||||
- **FR-094**: Existing linked users MUST retain their Elsa-managed role assignments; ordinary sign-in MUST NOT mutate their roles.
|
||||
- **FR-095**: V1 Studio MUST NOT expose claim-to-permission, group-to-permission, wildcard, pass-through, or claim-to-role mapping UI.
|
||||
|
|
@ -536,7 +536,7 @@ Given a configuration connection, when an authorized administrator creates a Stu
|
|||
|
||||
### D. JIT external-only user
|
||||
|
||||
Given a successful external identity with no link and an effective JIT policy, when the broker completes sign-in, then Elsa creates an Elsa User without local password material, creates the link, atomically assigns authorized default/matcher roles, and issues Elsa credentials from Elsa role permissions.
|
||||
Given a successful external identity with no link and an effective JIT policy, when the broker completes sign-in, then Elsa creates an Elsa User without local password material, assigns authorized default/matcher roles with that User write, publishes one durable link, compensates a failed publication, and issues Elsa credentials from Elsa role permissions only after both records exist.
|
||||
|
||||
### E. Pre-provisioned-only user
|
||||
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@ In the Studio repository, add:
|
|||
- `Elsa.Studio.ExternalAuthentication.BlazorServer` for confidential-client exchange, server-held tokens, cookie session, callback, refresh, and logout.
|
||||
- `Elsa.Studio.ExternalAuthentication.BlazorWasm` for public-client PKCE exchange, rotating refresh, and browser token access.
|
||||
|
||||
Persisted external-authentication entities live in a dedicated `ExternalAuthenticationElsaDbContext`, owned by `Elsa.ExternalAuthentication.Persistence.EFCore` and its provider packages, following the `Elsa.Secrets.Persistence.EFCore*` convention. JIT user creation goes through `IUserStore`/`IUserProvider` rather than a shared DbContext, so it no longer shares a database transaction with External Identity Link creation; the unique `IX_ExternalIdentityLink_Identity` index is the sole arbiter of the one-link-per-identity invariant, and a losing writer compensates by removing its stranded credential-less user.
|
||||
Persisted external-authentication entities live in a dedicated `ExternalAuthenticationElsaDbContext`, owned by `Elsa.ExternalAuthentication.Persistence.EFCore` and its provider packages, following the `Elsa.Secrets.Persistence.EFCore*` convention. JIT user creation goes through `IUserStore`/`IUserProvider` rather than a shared DbContext, so it no longer shares a database transaction with External Identity Link creation. The unique `IX_ExternalIdentityLink_Identity` index arbitrates the one-link-per-identity invariant. Provider-independent user creation and role validation live in one shared service; losing or failed link writers compensate by removing only the user they created.
|
||||
|
||||
**Rationale**: This matches the constitution's focused-module rule and existing Identity, Secrets, and Studio authentication package conventions. A separate adapter package proves the startup-installed extension boundary. Owning persistence in its own package keeps external-authentication durability independently enable-able instead of riding on whichever Identity persistence feature happens to be on.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Put everything in `Elsa.Identity`: rejected because provider brokering, connection management, and extension contracts form a distinct feature boundary.
|
||||
- A standalone external-authentication EF context: rejected for v1 because it cannot atomically create the existing Identity `User` and external link without cross-context transaction plumbing.
|
||||
- Keep external-authentication entities in `IdentityElsaDbContext`: rejected because persistence could not be enabled independently and could not work with another user directory. Cross-store provisioning therefore uses explicit convergence and compensation rather than claiming a transaction that the contracts cannot provide.
|
||||
- One Studio package: rejected because Server and WebAssembly have incompatible session and token-storage responsibilities.
|
||||
- Separate shared Studio authentication and management packages: rejected as premature separation because one shared Razor class library can own both broker UI and management while host adapters remain isolated.
|
||||
|
||||
|
|
@ -195,7 +195,7 @@ Administrator prelinking uses the same atomic link service as JIT. End-user self
|
|||
|
||||
**Decision**: The generic matcher-based Unlinked Identity Policy selects one deployed `IExternalUserMatcher`. The matcher declares required normalized claims; the policy supplies them ephemerally. One match proposes an existing user, no match follows configured Reject/CreateUser fallback, and ambiguous/error results reject. V1 ships no Elsa verified-email matcher.
|
||||
|
||||
`defaultRoleIds` are static and apply only to users newly created by CreateUser/no-match fallback. Save authorization reuses Elsa Role assignment checks; user, link, and role assignment commit atomically. Matched/existing users are not role-mutated. V1 Studio exposes no claim-to-role or claim-to-permission mapping.
|
||||
`defaultRoleIds` are static and apply only to users newly created by CreateUser/no-match fallback. Save authorization reuses Elsa Role assignment checks; roles commit with the User-store write, and credentials are not issued until the independently persisted link is durable. Matched/existing users are not role-mutated. V1 Studio exposes no claim-to-role or claim-to-permission mapping.
|
||||
|
||||
**Rationale**: Explicit matching enables deployment extensions without implicit email/name linking, while static create-user roles keep authorization under Elsa control.
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ An Elsa user selects an enabled external Login Method, authenticates with the pr
|
|||
|
||||
1. **Given** an enabled, valid connection and a linked identity, **When** a user completes provider authentication, **Then** Elsa resolves the link and returns a short-lived, single-use completion code to the registered client.
|
||||
2. **Given** an unknown identity and the reject policy, **When** provider authentication succeeds, **Then** Elsa denies access with a safe error and correlation identifier.
|
||||
3. **Given** an unknown identity and an allowed just-in-time policy, **When** provider authentication succeeds, **Then** Elsa atomically creates a credential-less Elsa User and link before issuing Elsa credentials.
|
||||
3. **Given** an unknown identity and an allowed just-in-time policy, **When** provider authentication succeeds, **Then** Elsa creates a credential-less Elsa User, publishes one durable identity link, compensates a failed publication, and issues no credentials until both records exist.
|
||||
4. **Given** a connection changes materially after initiation, **When** its callback arrives, **Then** Elsa rejects the flow rather than completing against the new settings.
|
||||
5. **Given** two callbacks for the same previously unknown External Identity arrive concurrently, **When** JIT provisioning runs, **Then** both converge on one tenant-scoped link and Elsa User or one safely retries after observing the winning transaction.
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ A deployment owner can keep the current direct Studio OpenID Connect mode or del
|
|||
- **FR-051**: Built-in behavior MUST NOT link by email, user name, or another mutable profile attribute.
|
||||
- **FR-052**: One Elsa User MAY have multiple links and MAY exist without Local Credentials.
|
||||
- **FR-053**: Credential-less users MUST contain no placeholder password material and local login MUST fail with the same public result as other invalid credentials.
|
||||
- **FR-054**: JIT provisioning MUST use an atomic create-link-or-get-existing contract that reserves a globally unique Elsa user name without making mutable provider attributes identity keys.
|
||||
- **FR-054**: JIT provisioning MUST use a convergent create-link-or-get-existing contract that retries generated-name collisions, compensates losing/failed link writers, and never makes mutable provider attributes identity keys.
|
||||
- **FR-055**: The External Identity Link tuple `(target tenant, connectionKey, issuer namespace, subject)` MUST have durable uniqueness. Concurrent JIT or prelink operations for the same tuple MUST converge on one link/user.
|
||||
- **FR-056**: JIT-created users MUST belong to the broker-resolved target tenant. Host-wide connection deployment does not remove Elsa User tenancy.
|
||||
- **FR-057**: The safe default Unlinked Identity Policy MUST reject access; v1 MUST also include an explicitly selectable JIT creation policy.
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@
|
|||
### Tests for User Story 2
|
||||
|
||||
- [x] T036 [P] [US2] Add CRUD, draft/enable, archive/restore, source-ownership, collision, ETag, and stale-registry contract tests in `test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs` covering FR-001–FR-016 and SC-002/SC-012.
|
||||
- [x] T037 [P] [US2] Add EF persistence and migration tests for all entities, unique indexes, concurrency tokens, atomic JIT/link transactions, and authoritative registry versions in `test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs` covering FR-011–FR-012, FR-040–FR-041, FR-055, and SC-006.
|
||||
- [x] T037 [P] [US2] Add EF persistence and migration tests for all entities, unique indexes, concurrency tokens, concurrent JIT convergence and compensation, and authoritative registry versions in `test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs` covering FR-011–FR-012, FR-040–FR-041, FR-055, and SC-006.
|
||||
- [x] T038 [P] [US2] Add Studio connection list/editor component tests for ownership, lifecycle, validation, secret configured-state, unsafe-setting warnings, and allowed actions in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.ExternalAuthentication.Tests/Connections/ConnectionEditorTests.cs` covering FR-014, FR-026–FR-030, FR-082–FR-085, and SC-002.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
|
@ -300,7 +300,7 @@
|
|||
|
||||
### Admission, roles, and sessions
|
||||
|
||||
- [x] T122 Implement per-connection policy selection and the generic matcher-based policy with one `IExternalUserMatcher`, descriptor-declared ephemeral required claims, single-match linking, Reject/CreateUser no-match fallback, ambiguous/error rejection, and no first-party verified-email matcher; implement static `defaultRoleIds` authorization/atomic assignment only for newly created users and add policy/privilege/concurrency tests covering FR-057–FR-064 and SC-008–SC-010.
|
||||
- [x] T122 Implement per-connection policy selection and the generic matcher-based policy with one `IExternalUserMatcher`, descriptor-declared ephemeral required claims, single-match linking, Reject/CreateUser no-match fallback, ambiguous/error rejection, and no first-party verified-email matcher; implement static `defaultRoleIds` authorization in the newly created User write and add policy/privilege/concurrency tests covering FR-057–FR-064 and SC-008–SC-010.
|
||||
- [x] T123 [P] Enforce Elsa-initiated login/logout only and minimal upstream token retention: discard upstream access/refresh tokens after callback/user-info, retain only protected adapter logout material when required, and purge it by external-session end; add leakage/lifecycle tests covering FR-048A–FR-048B, FR-065, FR-096, and SC-004.
|
||||
- [x] T124 Update REST/runtime/client contracts for record-ID management/transient records, Connection Key links/sessions, implicit host-wide environment, overrides, preferred state, user matcher descriptors/policy preview, static create-user roles, and Managed/External Secret state; remove v1 claim-role/permission mapping endpoints and add compatibility tests.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ namespace Elsa.Api.Client.Resources.ExternalAuthentication.Descriptors.Contracts
|
|||
/// </summary>
|
||||
public interface IExternalAuthenticationDescriptorsApi
|
||||
{
|
||||
[Get("/external-authentication/descriptors/runtime")]
|
||||
Task<ExternalAuthenticationRuntimeDescriptor> GetRuntimeDescriptorAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
[Get("/external-authentication/descriptors/adapters")]
|
||||
Task<ICollection<ExternalAuthenticationAdapterDescriptor>> ListAdaptersAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,6 @@ using System.Text.Json;
|
|||
|
||||
namespace Elsa.Api.Client.Resources.ExternalAuthentication.Descriptors.Models;
|
||||
|
||||
public sealed class ExternalAuthenticationRuntimeDescriptor
|
||||
{
|
||||
public int ManagementContractVersion { get; set; }
|
||||
public string ProductVersion { get; set; } = "";
|
||||
public string InformationalVersion { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ExternalAuthenticationAdapterDescriptor
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
|
|
|
|||
|
|
@ -210,10 +210,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("varchar(255)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
|
|
@ -266,16 +262,21 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId").HasColumnType("varchar(255)");
|
||||
b.Property<string>("Hash").IsRequired().HasColumnType("varchar(255)");
|
||||
b.HasKey("SessionId");
|
||||
b.HasIndex("Hash").IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -473,6 +474,14 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session").WithOne("RefreshToken").HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b => b.Navigation("RefreshToken"));
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,8 +205,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
LastRefreshedAt = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false),
|
||||
RefreshExpiresAt = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false),
|
||||
CurrentRefreshTokenHash = table.Column<string>(type: "varchar(255)", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
RefreshGeneration = table.Column<long>(type: "bigint", nullable: false),
|
||||
RevokedAt = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: true),
|
||||
RevocationReason = table.Column<string>(type: "longtext", nullable: true)
|
||||
|
|
@ -219,6 +217,23 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema,
|
||||
columns: table => new
|
||||
{
|
||||
SessionId = table.Column<string>(type: "varchar(255)", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Hash = table.Column<string>(type: "varchar(255)", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalAuthenticationSessionRefreshTokens", x => x.SessionId);
|
||||
table.ForeignKey("FK_ExternalAuthenticationSessionRefreshTokens_ExternalAuthenticationSessions_SessionId", x => x.SessionId, principalSchema: _schema.Schema, principalTable: "ExternalAuthenticationSessions", principalColumn: "Id", onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalIdentityLinks",
|
||||
schema: _schema.Schema,
|
||||
|
|
@ -318,10 +333,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
column: "ConnectionKey");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalAuthenticationSession_RefreshTokenHash",
|
||||
name: "IX_ExternalAuthenticationSessionRefreshToken_Hash",
|
||||
schema: _schema.Schema,
|
||||
table: "ExternalAuthenticationSessions",
|
||||
column: "CurrentRefreshTokenHash",
|
||||
table: "ExternalAuthenticationSessionRefreshTokens",
|
||||
column: "Hash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
|
|
@ -380,6 +395,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
name: "ExternalAuthenticationRegistryVersions",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessions",
|
||||
schema: _schema.Schema);
|
||||
|
|
|
|||
|
|
@ -207,10 +207,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("varchar(255)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
|
|
@ -263,16 +259,21 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId").HasColumnType("varchar(255)");
|
||||
b.Property<string>("Hash").IsRequired().HasColumnType("varchar(255)");
|
||||
b.HasKey("SessionId");
|
||||
b.HasIndex("Hash").IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -470,6 +471,14 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.MySql.Migrations.Extern
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session").WithOne("RefreshToken").HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b => b.Navigation("RefreshToken"));
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,10 +210,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
.IsRequired()
|
||||
.HasColumnType("NVARCHAR2(2000)");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("NVARCHAR2(450)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("TIMESTAMP(7) WITH TIME ZONE");
|
||||
|
||||
|
|
@ -266,16 +262,21 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId").HasColumnType("NVARCHAR2(450)");
|
||||
b.Property<string>("Hash").IsRequired().HasColumnType("NVARCHAR2(450)");
|
||||
b.HasKey("SessionId");
|
||||
b.HasIndex("Hash").IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -473,6 +474,14 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session").WithOne("RefreshToken").HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b => b.Navigation("RefreshToken"));
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
LastRefreshedAt = table.Column<DateTimeOffset>(type: "TIMESTAMP(7) WITH TIME ZONE", nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "TIMESTAMP(7) WITH TIME ZONE", nullable: false),
|
||||
RefreshExpiresAt = table.Column<DateTimeOffset>(type: "TIMESTAMP(7) WITH TIME ZONE", nullable: false),
|
||||
CurrentRefreshTokenHash = table.Column<string>(type: "NVARCHAR2(450)", nullable: false),
|
||||
RefreshGeneration = table.Column<long>(type: "NUMBER(19)", nullable: false),
|
||||
RevokedAt = table.Column<DateTimeOffset>(type: "TIMESTAMP(7) WITH TIME ZONE", nullable: true),
|
||||
RevocationReason = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
|
||||
|
|
@ -160,6 +159,20 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
table.PrimaryKey("PK_ExternalAuthenticationSessions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema,
|
||||
columns: table => new
|
||||
{
|
||||
SessionId = table.Column<string>(type: "NVARCHAR2(450)", nullable: false),
|
||||
Hash = table.Column<string>(type: "NVARCHAR2(450)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalAuthenticationSessionRefreshTokens", x => x.SessionId);
|
||||
table.ForeignKey("FK_ExternalAuthenticationSessionRefreshTokens_ExternalAuthenticationSessions_SessionId", x => x.SessionId, principalSchema: _schema.Schema, principalTable: "ExternalAuthenticationSessions", principalColumn: "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalIdentityLinks",
|
||||
schema: _schema.Schema,
|
||||
|
|
@ -238,10 +251,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
column: "ConnectionKey");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalAuthenticationSession_RefreshTokenHash",
|
||||
name: "IX_ExternalAuthenticationSessionRefreshToken_Hash",
|
||||
schema: _schema.Schema,
|
||||
table: "ExternalAuthenticationSessions",
|
||||
column: "CurrentRefreshTokenHash",
|
||||
table: "ExternalAuthenticationSessionRefreshTokens",
|
||||
column: "Hash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
|
|
@ -300,6 +313,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
name: "ExternalAuthenticationRegistryVersions",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessions",
|
||||
schema: _schema.Schema);
|
||||
|
|
|
|||
|
|
@ -207,10 +207,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
.IsRequired()
|
||||
.HasColumnType("NVARCHAR2(2000)");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("NVARCHAR2(450)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("TIMESTAMP(7) WITH TIME ZONE");
|
||||
|
||||
|
|
@ -263,16 +259,21 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId").HasColumnType("NVARCHAR2(450)");
|
||||
b.Property<string>("Hash").IsRequired().HasColumnType("NVARCHAR2(450)");
|
||||
b.HasKey("SessionId");
|
||||
b.HasIndex("Hash").IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -470,6 +471,14 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Oracle.Migrations.Exter
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session").WithOne("RefreshToken").HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b => b.Navigation("RefreshToken"));
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,10 +210,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
|
@ -266,16 +262,21 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId").HasColumnType("text");
|
||||
b.Property<string>("Hash").IsRequired().HasColumnType("text");
|
||||
b.HasKey("SessionId");
|
||||
b.HasIndex("Hash").IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -473,6 +474,14 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session").WithOne("RefreshToken").HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b => b.Navigation("RefreshToken"));
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
LastRefreshedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
RefreshExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CurrentRefreshTokenHash = table.Column<string>(type: "text", nullable: false),
|
||||
RefreshGeneration = table.Column<long>(type: "bigint", nullable: false),
|
||||
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
RevocationReason = table.Column<string>(type: "text", nullable: true),
|
||||
|
|
@ -161,6 +160,20 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
table.PrimaryKey("PK_ExternalAuthenticationSessions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema,
|
||||
columns: table => new
|
||||
{
|
||||
SessionId = table.Column<string>(type: "text", nullable: false),
|
||||
Hash = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalAuthenticationSessionRefreshTokens", x => x.SessionId);
|
||||
table.ForeignKey("FK_ExternalAuthenticationSessionRefreshTokens_ExternalAuthenticationSessions_SessionId", x => x.SessionId, principalSchema: _schema.Schema, principalTable: "ExternalAuthenticationSessions", principalColumn: "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalIdentityLinks",
|
||||
schema: _schema.Schema,
|
||||
|
|
@ -239,10 +252,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
column: "ConnectionKey");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalAuthenticationSession_RefreshTokenHash",
|
||||
name: "IX_ExternalAuthenticationSessionRefreshToken_Hash",
|
||||
schema: _schema.Schema,
|
||||
table: "ExternalAuthenticationSessions",
|
||||
column: "CurrentRefreshTokenHash",
|
||||
table: "ExternalAuthenticationSessionRefreshTokens",
|
||||
column: "Hash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
|
|
@ -301,6 +314,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
name: "ExternalAuthenticationRegistryVersions",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessions",
|
||||
schema: _schema.Schema);
|
||||
|
|
|
|||
|
|
@ -207,10 +207,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
|
@ -263,16 +259,21 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId").HasColumnType("text");
|
||||
b.Property<string>("Hash").IsRequired().HasColumnType("text");
|
||||
b.HasKey("SessionId");
|
||||
b.HasIndex("Hash").IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -470,6 +471,14 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.PostgreSql.Migrations.E
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session").WithOne("RefreshToken").HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b => b.Navigation("RefreshToken"));
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,10 +210,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
|
|
@ -266,16 +262,21 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId").HasColumnType("nvarchar(450)");
|
||||
b.Property<string>("Hash").IsRequired().HasColumnType("nvarchar(450)");
|
||||
b.HasKey("SessionId");
|
||||
b.HasIndex("Hash").IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -473,6 +474,14 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session").WithOne("RefreshToken").HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b => b.Navigation("RefreshToken"));
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
LastRefreshedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
RefreshExpiresAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CurrentRefreshTokenHash = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
RefreshGeneration = table.Column<long>(type: "bigint", nullable: false),
|
||||
RevokedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
RevocationReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
|
|
@ -160,6 +159,20 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
table.PrimaryKey("PK_ExternalAuthenticationSessions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema,
|
||||
columns: table => new
|
||||
{
|
||||
SessionId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
Hash = table.Column<string>(type: "nvarchar(450)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalAuthenticationSessionRefreshTokens", x => x.SessionId);
|
||||
table.ForeignKey("FK_ExternalAuthenticationSessionRefreshTokens_ExternalAuthenticationSessions_SessionId", x => x.SessionId, principalSchema: _schema.Schema, principalTable: "ExternalAuthenticationSessions", principalColumn: "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalIdentityLinks",
|
||||
schema: _schema.Schema,
|
||||
|
|
@ -238,10 +251,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
column: "ConnectionKey");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalAuthenticationSession_RefreshTokenHash",
|
||||
name: "IX_ExternalAuthenticationSessionRefreshToken_Hash",
|
||||
schema: _schema.Schema,
|
||||
table: "ExternalAuthenticationSessions",
|
||||
column: "CurrentRefreshTokenHash",
|
||||
table: "ExternalAuthenticationSessionRefreshTokens",
|
||||
column: "Hash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
|
|
@ -300,6 +313,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
name: "ExternalAuthenticationRegistryVersions",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessions",
|
||||
schema: _schema.Schema);
|
||||
|
|
|
|||
|
|
@ -207,10 +207,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
|
|
@ -263,16 +259,21 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId").HasColumnType("nvarchar(450)");
|
||||
b.Property<string>("Hash").IsRequired().HasColumnType("nvarchar(450)");
|
||||
b.HasKey("SessionId");
|
||||
b.HasIndex("Hash").IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -470,6 +471,14 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.SqlServer.Migrations.Ex
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session").WithOne("RefreshToken").HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b => b.Navigation("RefreshToken"));
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,10 +206,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExpiresAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
|
@ -266,16 +262,30 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SessionId");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -477,6 +487,22 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session")
|
||||
.WithOne("RefreshToken")
|
||||
.HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b =>
|
||||
{
|
||||
b.Navigation("RefreshToken");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,7 +148,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
LastRefreshedAt = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ExpiresAt = table.Column<string>(type: "TEXT", nullable: false),
|
||||
RefreshExpiresAt = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CurrentRefreshTokenHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
RefreshGeneration = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
RevokedAt = table.Column<string>(type: "TEXT", nullable: true),
|
||||
RevocationReason = table.Column<string>(type: "TEXT", nullable: true),
|
||||
|
|
@ -159,6 +158,20 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
table.PrimaryKey("PK_ExternalAuthenticationSessions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema,
|
||||
columns: table => new
|
||||
{
|
||||
SessionId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Hash = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalAuthenticationSessionRefreshTokens", x => x.SessionId);
|
||||
table.ForeignKey("FK_ExternalAuthenticationSessionRefreshTokens_ExternalAuthenticationSessions_SessionId", x => x.SessionId, principalSchema: _schema.Schema, principalTable: "ExternalAuthenticationSessions", principalColumn: "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalIdentityLinks",
|
||||
schema: _schema.Schema,
|
||||
|
|
@ -237,10 +250,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
column: "ConnectionKey");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalAuthenticationSession_RefreshTokenHash",
|
||||
name: "IX_ExternalAuthenticationSessionRefreshToken_Hash",
|
||||
schema: _schema.Schema,
|
||||
table: "ExternalAuthenticationSessions",
|
||||
column: "CurrentRefreshTokenHash",
|
||||
table: "ExternalAuthenticationSessionRefreshTokens",
|
||||
column: "Hash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
|
|
@ -299,6 +312,10 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
name: "ExternalAuthenticationRegistryVersions",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessionRefreshTokens",
|
||||
schema: _schema.Schema);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthenticationSessions",
|
||||
schema: _schema.Schema);
|
||||
|
|
|
|||
|
|
@ -203,10 +203,6 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentRefreshTokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExpiresAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
|
@ -263,16 +259,30 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
b.HasIndex("ConnectionKey")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
|
||||
b.HasIndex("CurrentRefreshTokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessions", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SessionId");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
|
||||
b.ToTable("ExternalAuthenticationSessionRefreshTokens", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalIdentityLink", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -474,6 +484,22 @@ namespace Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.Migrations.Exter
|
|||
|
||||
b.ToTable("ExternalAuthenticationPreviewResults", "Elsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", "Session")
|
||||
.WithOne("RefreshToken")
|
||||
.HasForeignKey("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationRefreshToken", "SessionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Elsa.ExternalAuthentication.Persistence.EFCore.PersistedExternalAuthenticationSession", b =>
|
||||
{
|
||||
b.Navigation("RefreshToken");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ internal sealed class Configurations :
|
|||
IEntityTypeConfiguration<PersistedBrokerTransaction>,
|
||||
IEntityTypeConfiguration<PersistedAuthorizationGrant>,
|
||||
IEntityTypeConfiguration<PersistedExternalAuthenticationSession>,
|
||||
IEntityTypeConfiguration<PersistedExternalAuthenticationRefreshToken>,
|
||||
IEntityTypeConfiguration<PersistedConnectionObservation>,
|
||||
IEntityTypeConfiguration<PersistedPreviewResult>,
|
||||
IEntityTypeConfiguration<ExternalAuthenticationRegistryVersion>
|
||||
|
|
@ -50,11 +51,18 @@ internal sealed class Configurations :
|
|||
{
|
||||
builder.ToTable("ExternalAuthenticationSessions");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.HasIndex(x => x.CurrentRefreshTokenHash).IsUnique().HasDatabaseName("IX_ExternalAuthenticationSession_RefreshTokenHash");
|
||||
builder.HasIndex(x => new { x.TenantId, x.UserId }).HasDatabaseName("IX_ExternalAuthenticationSession_TenantId_UserId");
|
||||
builder.HasIndex(x => x.ConnectionKey).HasDatabaseName("IX_ExternalAuthenticationSession_ConnectionKey");
|
||||
}
|
||||
|
||||
public void Configure(EntityTypeBuilder<PersistedExternalAuthenticationRefreshToken> builder)
|
||||
{
|
||||
builder.ToTable("ExternalAuthenticationSessionRefreshTokens");
|
||||
builder.HasKey(x => x.SessionId);
|
||||
builder.HasIndex(x => x.Hash).IsUnique().HasDatabaseName("IX_ExternalAuthenticationSessionRefreshToken_Hash");
|
||||
builder.HasOne(x => x.Session).WithOne(x => x.RefreshToken).HasForeignKey<PersistedExternalAuthenticationRefreshToken>(x => x.SessionId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
public void Configure(EntityTypeBuilder<PersistedConnectionObservation> builder)
|
||||
{
|
||||
builder.ToTable("ExternalAuthenticationConnectionObservations");
|
||||
|
|
|
|||
|
|
@ -90,11 +90,18 @@ public sealed class PersistedExternalAuthenticationSession
|
|||
public DateTimeOffset LastRefreshedAt { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset RefreshExpiresAt { get; set; }
|
||||
public string CurrentRefreshTokenHash { get; set; } = null!;
|
||||
public long RefreshGeneration { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevocationReason { get; set; }
|
||||
public byte[]? ProtectedUpstreamLogoutHint { get; set; }
|
||||
public PersistedExternalAuthenticationRefreshToken? RefreshToken { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PersistedExternalAuthenticationRefreshToken
|
||||
{
|
||||
public string SessionId { get; set; } = null!;
|
||||
public string Hash { get; set; } = null!;
|
||||
public PersistedExternalAuthenticationSession Session { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class PersistedConnectionObservation
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ public class ExternalAuthenticationElsaDbContext : ElsaDbContextBase
|
|||
/// </summary>
|
||||
public DbSet<PersistedExternalAuthenticationSession> ExternalAuthenticationSessions { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The refresh tokens currently issued for external authentication sessions.
|
||||
/// </summary>
|
||||
public DbSet<PersistedExternalAuthenticationRefreshToken> ExternalAuthenticationRefreshTokens { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The latest connection test observations.
|
||||
/// </summary>
|
||||
|
|
@ -62,6 +67,7 @@ public class ExternalAuthenticationElsaDbContext : ElsaDbContextBase
|
|||
modelBuilder.ApplyConfiguration<PersistedBrokerTransaction>(configurations);
|
||||
modelBuilder.ApplyConfiguration<PersistedAuthorizationGrant>(configurations);
|
||||
modelBuilder.ApplyConfiguration<PersistedExternalAuthenticationSession>(configurations);
|
||||
modelBuilder.ApplyConfiguration<PersistedExternalAuthenticationRefreshToken>(configurations);
|
||||
modelBuilder.ApplyConfiguration<PersistedConnectionObservation>(configurations);
|
||||
modelBuilder.ApplyConfiguration<PersistedPreviewResult>(configurations);
|
||||
modelBuilder.ApplyConfiguration<ExternalAuthenticationRegistryVersion>(configurations);
|
||||
|
|
|
|||
|
|
@ -147,14 +147,15 @@ internal static class ExternalAuthenticationPersistenceMapper
|
|||
LastRefreshedAt = session.LastRefreshedAt,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
RefreshExpiresAt = session.RefreshExpiresAt,
|
||||
CurrentRefreshTokenHash = session.CurrentRefreshTokenHash,
|
||||
RefreshGeneration = session.RefreshGeneration,
|
||||
RevokedAt = session.RevokedAt,
|
||||
RevocationReason = session.RevocationReason
|
||||
,ProtectedUpstreamLogoutHint = session.ProtectedUpstreamLogoutHint
|
||||
};
|
||||
|
||||
public static ExternalAuthenticationSession ToModel(this PersistedExternalAuthenticationSession session) => new()
|
||||
public static ExternalAuthenticationSession ToModel(this PersistedExternalAuthenticationSession session) => session.ToModel(session.RefreshToken?.Hash);
|
||||
|
||||
public static ExternalAuthenticationSession ToModel(this PersistedExternalAuthenticationSession session, string? currentRefreshTokenHash) => new()
|
||||
{
|
||||
Id = session.Id,
|
||||
AuthenticationClientId = session.AuthenticationClientId,
|
||||
|
|
@ -170,7 +171,7 @@ internal static class ExternalAuthenticationPersistenceMapper
|
|||
LastRefreshedAt = session.LastRefreshedAt,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
RefreshExpiresAt = session.RefreshExpiresAt,
|
||||
CurrentRefreshTokenHash = session.CurrentRefreshTokenHash,
|
||||
CurrentRefreshTokenHash = currentRefreshTokenHash,
|
||||
RefreshGeneration = session.RefreshGeneration,
|
||||
RevokedAt = session.RevokedAt,
|
||||
RevocationReason = session.RevocationReason
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ public sealed class EFCoreExternalAuthenticationSessionStore(ExternalAuthenticat
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
await using var lease = await dbContextFactory.CreateAsync(cancellationToken);
|
||||
var query = lease.DbContext.ExternalAuthenticationSessions.AsNoTracking()
|
||||
var query = lease.DbContext.ExternalAuthenticationSessions.AsNoTracking().Include(x => x.RefreshToken)
|
||||
.Where(x => x.TenantId == filter.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(filter.UserId))
|
||||
query = query.Where(x => x.UserId == filter.UserId);
|
||||
|
|
@ -29,25 +29,42 @@ public sealed class EFCoreExternalAuthenticationSessionStore(ExternalAuthenticat
|
|||
{
|
||||
await using var lease = await dbContextFactory.CreateAsync(cancellationToken);
|
||||
var dbContext = lease.DbContext;
|
||||
return (await dbContext.ExternalAuthenticationSessions.AsNoTracking().SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken))?.ToModel();
|
||||
return (await dbContext.ExternalAuthenticationSessions.AsNoTracking().Include(x => x.RefreshToken).SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken))?.ToModel();
|
||||
}
|
||||
|
||||
public async ValueTask<ExternalAuthenticationSession?> FindByRefreshTokenHashAsync(string refreshTokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshTokenHash))
|
||||
return null;
|
||||
|
||||
await using var lease = await dbContextFactory.CreateAsync(cancellationToken);
|
||||
var dbContext = lease.DbContext;
|
||||
return (await dbContext.ExternalAuthenticationSessions.AsNoTracking().SingleOrDefaultAsync(x => x.CurrentRefreshTokenHash == refreshTokenHash, cancellationToken))?.ToModel();
|
||||
var refreshToken = await dbContext.ExternalAuthenticationRefreshTokens.AsNoTracking().Include(x => x.Session).SingleOrDefaultAsync(x => x.Hash == refreshTokenHash, cancellationToken);
|
||||
return refreshToken is null ? null : refreshToken.Session.ToModel(refreshToken.Hash);
|
||||
}
|
||||
|
||||
public async ValueTask SaveAsync(ExternalAuthenticationSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var lease = await dbContextFactory.CreateAsync(cancellationToken);
|
||||
var dbContext = lease.DbContext;
|
||||
var existing = await dbContext.ExternalAuthenticationSessions.SingleOrDefaultAsync(x => x.Id == session.Id, cancellationToken);
|
||||
var existing = await dbContext.ExternalAuthenticationSessions.Include(x => x.RefreshToken).SingleOrDefaultAsync(x => x.Id == session.Id, cancellationToken);
|
||||
if (existing is null)
|
||||
dbContext.ExternalAuthenticationSessions.Add(session.ToPersisted());
|
||||
{
|
||||
var persisted = session.ToPersisted();
|
||||
if (session.CurrentRefreshTokenHash is not null)
|
||||
persisted.RefreshToken = new PersistedExternalAuthenticationRefreshToken { SessionId = session.Id, Hash = session.CurrentRefreshTokenHash };
|
||||
dbContext.ExternalAuthenticationSessions.Add(persisted);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.Entry(existing).CurrentValues.SetValues(session.ToPersisted());
|
||||
if (session.CurrentRefreshTokenHash is null && existing.RefreshToken is not null)
|
||||
dbContext.ExternalAuthenticationRefreshTokens.Remove(existing.RefreshToken);
|
||||
else if (session.CurrentRefreshTokenHash is not null && existing.RefreshToken is null)
|
||||
dbContext.ExternalAuthenticationRefreshTokens.Add(new PersistedExternalAuthenticationRefreshToken { SessionId = session.Id, Hash = session.CurrentRefreshTokenHash });
|
||||
else if (session.CurrentRefreshTokenHash is not null)
|
||||
existing.RefreshToken!.Hash = session.CurrentRefreshTokenHash;
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
|
@ -67,14 +84,21 @@ public sealed class EFCoreExternalAuthenticationSessionStore(ExternalAuthenticat
|
|||
return new ExternalAuthenticationSessionRotationResult.Expired();
|
||||
}
|
||||
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
var rotated = await dbContext.ExternalAuthenticationSessions
|
||||
.Where(x => x.Id == sessionId && x.RevokedAt == null && x.CurrentRefreshTokenHash == refreshTokenHash && x.RefreshGeneration == expectedGeneration)
|
||||
.Where(x => x.Id == sessionId && x.RevokedAt == null && x.RefreshGeneration == expectedGeneration)
|
||||
.ExecuteUpdateAsync(x => x
|
||||
.SetProperty(y => y.CurrentRefreshTokenHash, nextRefreshTokenHash)
|
||||
.SetProperty(y => y.RefreshGeneration, y => y.RefreshGeneration + 1)
|
||||
.SetProperty(y => y.LastRefreshedAt, refreshedAt), cancellationToken);
|
||||
if (rotated == 1)
|
||||
if (rotated == 1 && await dbContext.ExternalAuthenticationRefreshTokens
|
||||
.Where(x => x.SessionId == sessionId && x.Hash == refreshTokenHash)
|
||||
.ExecuteUpdateAsync(x => x.SetProperty(y => y.Hash, nextRefreshTokenHash), cancellationToken) == 1)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new ExternalAuthenticationSessionRotationResult.Rotated((await FindByIdAsync(sessionId, cancellationToken))!);
|
||||
}
|
||||
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
|
||||
var revoked = await dbContext.ExternalAuthenticationSessions.Where(x => x.Id == sessionId && x.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(x => x.SetProperty(y => y.RevokedAt, clock.UtcNow).SetProperty(y => y.RevocationReason, "refresh_token_reuse").SetProperty(y => y.ProtectedUpstreamLogoutHint, (byte[]?)null), cancellationToken);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
ISystemClock clock,
|
||||
ILogger<EFCoreExternalIdentityProvisioner> logger) : IExternalIdentityProvisioner, IExternalIdentityLinkManagementStore
|
||||
{
|
||||
private const int MaximumUserNameAttempts = 10;
|
||||
private readonly ExternalIdentityUserProvisioningService _userProvisioningService = new(userStore, userProvider, roleProvider, identityGenerator);
|
||||
|
||||
public async ValueTask<ExternalIdentityLink?> FindLinkAsync(string tenantId, string connectionKey, ExternalIdentity identity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
|
@ -51,7 +51,7 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
return new ProvisioningResult(existing.UserId, existing, false);
|
||||
|
||||
// Resolved outside the try so a user resolution failure is never mistaken for a link conflict.
|
||||
var (user, wasCreated) = await ResolveUserAsync(request, cancellationToken);
|
||||
var (user, wasCreated) = await _userProvisioningService.ResolveAsync(request, cancellationToken: cancellationToken);
|
||||
var link = new PersistedExternalIdentityLink
|
||||
{
|
||||
Id = identityGenerator.GenerateId(),
|
||||
|
|
@ -68,16 +68,19 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
dbContext.ExternalIdentityLinks.Add(link);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await EnsureLinkedUserStillExistsAsync(dbContext, link, user, wasCreated, cancellationToken);
|
||||
return new ProvisioningResult(user.Id, ToModel(link), wasCreated, true);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
catch (DbUpdateException linkException)
|
||||
{
|
||||
// IX_ExternalIdentityLink_Identity arbitrates concurrent first sign-ins for the same identity tuple.
|
||||
var winner = await FindLinkAsync(request.TenantId, request.ConnectionKey, request.Identity, cancellationToken);
|
||||
if (winner is null)
|
||||
throw; // Not a uniqueness conflict, so leave any just-created user in place.
|
||||
if (winner?.Id == link.Id)
|
||||
return new ProvisioningResult(user.Id, winner, wasCreated, true);
|
||||
if (wasCreated)
|
||||
await TryRemoveStrandedUserAsync(user, cancellationToken);
|
||||
await RemoveStrandedUserAsync(user, linkException, cancellationToken);
|
||||
if (winner is null)
|
||||
throw;
|
||||
return new ProvisioningResult(winner.UserId, winner, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -112,9 +115,9 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
|
||||
// The identity aggregate lives in its own store, so the target user is verified through its contract.
|
||||
// Checked here rather than up front to preserve the "unknown link wins over unknown user" result ordering.
|
||||
var user = await userProvider.FindAsync(new UserFilter { Id = request.UserId }, cancellationToken);
|
||||
if (user is null || !string.Equals(user.TenantId, request.TenantId, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("The requested Elsa user does not exist or is outside the target tenant.");
|
||||
var (user, _) = await _userProvisioningService.ResolveAsync(
|
||||
new ProvisioningRequest(request.TenantId, normalizedConnectionKey, request.Identity, null, request.UserId),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var deleted = await dbContext.ExternalIdentityLinks
|
||||
.Where(x => x.Id == request.LinkId && x.TenantId == request.TenantId)
|
||||
|
|
@ -135,6 +138,11 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
dbContext.ExternalIdentityLinks.Add(replacementEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
if (!await _userProvisioningService.ExistsAsync(user, false, cancellationToken))
|
||||
{
|
||||
await CompensateReplacementAsync(oldEntity, replacementEntity, cancellationToken);
|
||||
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being replaced.");
|
||||
}
|
||||
return new ExternalIdentityLinkReplaceResult.Success(oldLink, ToModel(replacementEntity));
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
|
|
@ -173,70 +181,91 @@ public sealed class EFCoreExternalIdentityProvisioner(
|
|||
.ExecuteDeleteAsync(cancellationToken) > 0;
|
||||
}
|
||||
|
||||
private async ValueTask<(User User, bool WasCreated)> ResolveUserAsync(ProvisioningRequest request, CancellationToken cancellationToken)
|
||||
private async ValueTask EnsureLinkedUserStillExistsAsync(
|
||||
ExternalAuthenticationElsaDbContext dbContext,
|
||||
PersistedExternalIdentityLink link,
|
||||
User user,
|
||||
bool wasCreated,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.ExistingUserId))
|
||||
{
|
||||
var existingUser = await userProvider.FindAsync(new UserFilter { Id = request.ExistingUserId }, cancellationToken)
|
||||
?? throw new InvalidOperationException("The requested Elsa user does not exist.");
|
||||
if (!string.Equals(existingUser.TenantId, request.TenantId, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("The requested Elsa user is outside the target tenant.");
|
||||
return (existingUser, false);
|
||||
if (await _userProvisioningService.ExistsAsync(user, wasCreated, cancellationToken))
|
||||
return;
|
||||
|
||||
await dbContext.ExternalIdentityLinks.Where(x => x.Id == link.Id).ExecuteDeleteAsync(cancellationToken);
|
||||
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being created.");
|
||||
}
|
||||
|
||||
var proposal = request.Proposal ?? throw new InvalidOperationException("A user creation proposal is required for an unlinked external identity.");
|
||||
var roleIds = await ResolveRoleIdsAsync(proposal.DefaultRoleIds, cancellationToken);
|
||||
var prefix = NormalizeUserNamePrefix(proposal.UserNamePrefix);
|
||||
for (var attempt = 0; attempt < MaximumUserNameAttempts; attempt++)
|
||||
{
|
||||
var name = $"{prefix}-{identityGenerator.GenerateId()}";
|
||||
if (await userProvider.FindAsync(new UserFilter { Name = name }, cancellationToken) is not null)
|
||||
continue;
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = identityGenerator.GenerateId(),
|
||||
Name = name,
|
||||
TenantId = request.TenantId,
|
||||
HashedPassword = null,
|
||||
HashedPasswordSalt = null,
|
||||
Roles = roleIds.ToList()
|
||||
};
|
||||
await userStore.SaveAsync(user, cancellationToken);
|
||||
return (user, true);
|
||||
}
|
||||
throw new InvalidOperationException("A unique Elsa user name could not be reserved for the external identity.");
|
||||
}
|
||||
|
||||
private async ValueTask TryRemoveStrandedUserAsync(User user, CancellationToken cancellationToken)
|
||||
private async ValueTask CompensateReplacementAsync(
|
||||
PersistedExternalIdentityLink oldLink,
|
||||
PersistedExternalIdentityLink replacementLink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await userStore.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
|
||||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
await dbContext.ExternalIdentityLinks.Where(x => x.Id == replacementLink.Id).ExecuteDeleteAsync(cancellationToken);
|
||||
dbContext.ExternalIdentityLinks.Add(new PersistedExternalIdentityLink
|
||||
{
|
||||
Id = oldLink.Id,
|
||||
TenantId = oldLink.TenantId,
|
||||
ConnectionKey = oldLink.ConnectionKey,
|
||||
Issuer = oldLink.Issuer,
|
||||
SubjectHash = oldLink.SubjectHash,
|
||||
SubjectHint = oldLink.SubjectHint,
|
||||
UserId = oldLink.UserId,
|
||||
CreatedAt = oldLink.CreatedAt,
|
||||
LastSignedInAt = oldLink.LastSignedInAt
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
var previousUser = new User { Id = oldLink.UserId, TenantId = oldLink.TenantId };
|
||||
if (!await _userProvisioningService.ExistsAsync(previousUser, false, cancellationToken))
|
||||
{
|
||||
await using var cleanupContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await cleanupContext.ExternalIdentityLinks.Where(x => x.Id == oldLink.Id).ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception compensationException)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var cleanupContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await cleanupContext.ExternalIdentityLinks
|
||||
.Where(x => x.Id == replacementLink.Id || x.Id == oldLink.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception cleanupException)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"A replacement-compensation link refers to a deleted user and could not be removed. No credentials were issued.",
|
||||
compensationException,
|
||||
cleanupException);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"The replacement link was removed after its target user was deleted, but the previous link could not be restored.",
|
||||
compensationException);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask RemoveStrandedUserAsync(User user, Exception linkException, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _userProvisioningService.RemoveAsync(user, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A credential-less user with no link cannot authenticate, so cleanup must never fail the sign-in.
|
||||
logger.LogWarning(exception, "Could not remove the just-in-time user {UserId} that lost the external identity link race", user.Id);
|
||||
logger.LogError(exception, "Could not remove the just-in-time user {UserId} after its external identity link failed", user.Id);
|
||||
throw new AggregateException(
|
||||
"External identity provisioning failed and its just-in-time user could not be removed. No credentials were issued.",
|
||||
linkException,
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static ExternalIdentityLink ToModel(PersistedExternalIdentityLink link) => new(link.Id, link.TenantId, link.ConnectionKey, link.Issuer, link.SubjectHash, link.SubjectHint, link.UserId, link.CreatedAt, link.LastSignedInAt);
|
||||
|
||||
private static string NormalizeUserNamePrefix(string prefix)
|
||||
{
|
||||
var normalized = new string((prefix ?? string.Empty).Trim().Where(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_').ToArray());
|
||||
return string.IsNullOrEmpty(normalized) ? "external" : normalized;
|
||||
}
|
||||
|
||||
private async ValueTask<IReadOnlyCollection<string>> ResolveRoleIdsAsync(IReadOnlyCollection<string>? roleIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var requested = (roleIds ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (requested.Length == 0)
|
||||
return [];
|
||||
var found = (await roleProvider.FindByIdsAsync(requested, cancellationToken)).Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
|
||||
if (!found.SetEquals(requested))
|
||||
throw new InvalidOperationException("A configured default role no longer exists.");
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ public interface IExternalIdentityProvisioner
|
|||
ValueTask<ExternalIdentityLink?> FindLinkAsync(string tenantId, string connectionKey, ExternalIdentity identity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically creates the requested link and, when requested, its credential-less user, or returns the winner of a concurrent operation.
|
||||
/// Creates the requested link and, when requested, its credential-less user; compensates a losing writer; or returns the winner of a concurrent operation.
|
||||
/// </summary>
|
||||
ValueTask<ProvisioningResult> CreateLinkOrGetExistingAsync(ProvisioningRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Elsa.ExternalAuthentication.Contracts;
|
||||
using Elsa.ExternalAuthentication.Models;
|
||||
using Elsa.ExternalAuthentication.Services;
|
||||
|
|
@ -29,7 +30,8 @@ internal sealed class ConnectionRequest
|
|||
public PolicySelection? UnlinkedPolicy { get; set; }
|
||||
public List<GrantSourceSelection>? PermissionGrantSources { get; set; }
|
||||
public ClaimProjectionRequest? ClaimProjection { get; set; }
|
||||
public string? UpstreamLogoutMode { get; set; }
|
||||
[JsonConverter(typeof(UpstreamLogoutModeJsonConverter))]
|
||||
public UpstreamLogoutMode UpstreamLogoutMode { get; set; }
|
||||
public bool ConfirmUnsafeSettings { get; set; }
|
||||
public bool ConfirmFinalLoginPathOverride { get; set; }
|
||||
|
||||
|
|
@ -52,15 +54,7 @@ internal sealed class ConnectionRequest
|
|||
UnlinkedPolicy = UnlinkedPolicy,
|
||||
PermissionGrantSources = PermissionGrantSources?.Select(x => new GrantSourceSelection(x.Type, x.SettingsVersion, x.Settings.ValueKind == JsonValueKind.Undefined ? default : x.Settings.Clone(), x.Order)).ToArray() ?? [],
|
||||
ClaimProjection = ClaimProjection?.ToProjection() ?? Elsa.ExternalAuthentication.Models.ClaimProjection.Empty,
|
||||
UpstreamLogoutMode = ParseUpstreamLogoutMode(UpstreamLogoutMode)
|
||||
};
|
||||
|
||||
private static UpstreamLogoutMode ParseUpstreamLogoutMode(string? value) => value?.ToLowerInvariant() switch
|
||||
{
|
||||
"disabled" or null => Elsa.ExternalAuthentication.Models.UpstreamLogoutMode.Disabled,
|
||||
"userchoice" or "user-choice" or "user_choice" => Elsa.ExternalAuthentication.Models.UpstreamLogoutMode.UserChoice,
|
||||
"always" => Elsa.ExternalAuthentication.Models.UpstreamLogoutMode.Always,
|
||||
_ => (Elsa.ExternalAuthentication.Models.UpstreamLogoutMode)(-1)
|
||||
UpstreamLogoutMode = UpstreamLogoutMode
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +113,8 @@ internal sealed class ConnectionResponse
|
|||
public PolicySelection? UnlinkedPolicy { get; init; }
|
||||
public IReadOnlyCollection<GrantSourceSelection> PermissionGrantSources { get; init; } = [];
|
||||
public ClaimProjection ClaimProjection { get; init; } = ClaimProjection.Empty;
|
||||
public string UpstreamLogoutMode { get; init; } = null!;
|
||||
[JsonConverter(typeof(UpstreamLogoutModeJsonConverter))]
|
||||
public UpstreamLogoutMode UpstreamLogoutMode { get; init; }
|
||||
public long Revision { get; init; }
|
||||
public string MaterialRevision { get; init; } = null!;
|
||||
public ConnectionObservationResponse? LatestObservation { get; init; }
|
||||
|
|
@ -171,7 +166,7 @@ internal sealed class ConnectionResponse
|
|||
UnlinkedPolicy = effective.Connection.UnlinkedPolicy,
|
||||
PermissionGrantSources = effective.Connection.PermissionGrantSources.ToArray(),
|
||||
ClaimProjection = effective.Connection.ClaimProjection,
|
||||
UpstreamLogoutMode = FormatUpstreamLogoutMode(effective.Connection.UpstreamLogoutMode),
|
||||
UpstreamLogoutMode = effective.Connection.UpstreamLogoutMode,
|
||||
Revision = effective.Connection.Revision,
|
||||
MaterialRevision = effective.Connection.MaterialRevision,
|
||||
LatestObservation = observation is null
|
||||
|
|
@ -186,15 +181,34 @@ internal sealed class ConnectionResponse
|
|||
};
|
||||
}
|
||||
|
||||
private static string FormatUpstreamLogoutMode(UpstreamLogoutMode mode) => mode switch
|
||||
}
|
||||
|
||||
internal sealed class UpstreamLogoutModeJsonConverter : JsonConverter<UpstreamLogoutMode>
|
||||
{
|
||||
Elsa.ExternalAuthentication.Models.UpstreamLogoutMode.Disabled => "disabled",
|
||||
Elsa.ExternalAuthentication.Models.UpstreamLogoutMode.UserChoice => "user-choice",
|
||||
Elsa.ExternalAuthentication.Models.UpstreamLogoutMode.Always => "always",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "The upstream logout mode is not supported.")
|
||||
public override UpstreamLogoutMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.String)
|
||||
throw new JsonException("The upstream logout mode must be a string.");
|
||||
|
||||
return reader.GetString()?.ToLowerInvariant() switch
|
||||
{
|
||||
"disabled" => UpstreamLogoutMode.Disabled,
|
||||
"userchoice" or "user-choice" or "user_choice" => UpstreamLogoutMode.UserChoice,
|
||||
"always" => UpstreamLogoutMode.Always,
|
||||
_ => throw new JsonException("The upstream logout mode is not supported.")
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, UpstreamLogoutMode value, JsonSerializerOptions options) =>
|
||||
writer.WriteStringValue(value switch
|
||||
{
|
||||
UpstreamLogoutMode.Disabled => "disabled",
|
||||
UpstreamLogoutMode.UserChoice => "user-choice",
|
||||
UpstreamLogoutMode.Always => "always",
|
||||
_ => throw new JsonException("The upstream logout mode is not supported.")
|
||||
});
|
||||
}
|
||||
|
||||
internal sealed record ConnectionReferenceResponse(string Id, string DisplayName, string Source)
|
||||
{
|
||||
public static ConnectionReferenceResponse From(IdentityProviderConnectionReference reference) =>
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
using System.Reflection;
|
||||
using Elsa.Abstractions;
|
||||
using Elsa.ExternalAuthentication.Features;
|
||||
using Elsa.ExternalAuthentication.Permissions;
|
||||
|
||||
namespace Elsa.ExternalAuthentication.Endpoints.Runtime;
|
||||
|
||||
/// <summary>Publishes safe runtime metadata used by management clients to diagnose backend/client contract mismatches.</summary>
|
||||
internal sealed class GetExternalAuthenticationRuntimeDescriptor : ElsaEndpointWithoutRequest<ExternalAuthenticationRuntimeDescriptor>
|
||||
{
|
||||
public const int ManagementContractVersion = 1;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/external-authentication/descriptors/runtime");
|
||||
ConfigurePermissions(ExternalAuthenticationPermissions.ConnectionsRead);
|
||||
}
|
||||
|
||||
public override Task<ExternalAuthenticationRuntimeDescriptor> ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var assembly = typeof(ExternalAuthenticationFeature).Assembly;
|
||||
var productVersion = assembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion.Split('+')[0]
|
||||
?? productVersion;
|
||||
|
||||
return Task.FromResult(new ExternalAuthenticationRuntimeDescriptor(ManagementContractVersion, productVersion, informationalVersion));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ExternalAuthenticationRuntimeDescriptor(int ManagementContractVersion, string ProductVersion, string InformationalVersion);
|
||||
|
|
@ -94,6 +94,7 @@ public static class ServiceCollectionExtensions
|
|||
services.TryAddScoped<IExternalAuthenticationBroker, ExternalAuthenticationBroker>();
|
||||
services.TryAddScoped<IdentityProviderConnectionManagementService>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Scoped<IRoleDeletionDependencyContributor, ExternalAuthenticationRoleDeletionDependencyContributor>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Scoped<IUserDeletionDependencyContributor, ExternalAuthenticationUserDeletionDependencyContributor>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPermissionDescriptorProvider, ExternalAuthenticationPermissionDescriptorProvider>());
|
||||
|
||||
return services;
|
||||
|
|
|
|||
|
|
@ -249,7 +249,8 @@ public sealed class ExternalAuthenticationSession
|
|||
public DateTimeOffset LastRefreshedAt { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset RefreshExpiresAt { get; set; }
|
||||
public string CurrentRefreshTokenHash { get; set; } = null!;
|
||||
/// <summary>The hash of the currently issued refresh token, or <see langword="null"/> until one is issued.</summary>
|
||||
public string? CurrentRefreshTokenHash { get; set; }
|
||||
public long RefreshGeneration { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevocationReason { get; set; }
|
||||
|
|
|
|||
|
|
@ -214,8 +214,7 @@ public sealed class ExternalAuthenticationBroker(
|
|||
StartedAt = clock.UtcNow,
|
||||
LastRefreshedAt = clock.UtcNow,
|
||||
ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge),
|
||||
RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge),
|
||||
CurrentRefreshTokenHash = CreateUnissuedRefreshTokenHash()
|
||||
RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge)
|
||||
};
|
||||
await sessionStore.SaveAsync(session, cancellationToken);
|
||||
var code = CreateOpaqueValue();
|
||||
|
|
@ -513,12 +512,6 @@ public sealed class ExternalAuthenticationBroker(
|
|||
private static bool VerifyPkce(string challenge, string? verifier) => !string.IsNullOrWhiteSpace(verifier) && string.Equals(challenge, Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))), StringComparison.Ordinal);
|
||||
private static string CreateOpaqueValue() => Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
/// <summary>
|
||||
/// Sessions are persisted at callback completion, before the token issuer mints the first refresh token. The column is
|
||||
/// required and uniquely indexed, so a per-session placeholder is stored until issuance rotates the real hash in. The
|
||||
/// prefix keeps the value outside the hex-encoded hash space, so it can never be matched by a refresh-token lookup.
|
||||
/// </summary>
|
||||
private static string CreateUnissuedRefreshTokenHash() => $"unissued:{CreateOpaqueValue()}";
|
||||
private string Hash(string value) => handleHasher.Hash(value);
|
||||
private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
private static Uri AppendCallbackParameters(Uri uri, string code, string? clientState)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
using Elsa.ExternalAuthentication.Contracts;
|
||||
using Elsa.ExternalAuthentication.Models;
|
||||
using Elsa.Identity.Contracts;
|
||||
using Elsa.Identity.Entities;
|
||||
using Elsa.Identity.Models;
|
||||
|
||||
namespace Elsa.ExternalAuthentication.Services;
|
||||
|
||||
/// <summary>Prevents deleting users that still own external identity links.</summary>
|
||||
public sealed class ExternalAuthenticationUserDeletionDependencyContributor(
|
||||
IExternalIdentityLinkManagementStore links) : IUserDeletionDependencyContributor
|
||||
{
|
||||
public const string SourceName = "external-authentication";
|
||||
public string Source => SourceName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<UserDeletionDependency?> InspectAsync(User user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var linksForUser = await links.FindAsync(new ExternalIdentityLinkFilter
|
||||
{
|
||||
TenantId = user.TenantId ?? string.Empty,
|
||||
UserId = user.Id
|
||||
}, cancellationToken);
|
||||
|
||||
return linksForUser.Items.Count == 0
|
||||
? null
|
||||
: new UserDeletionDependency(Source, "The user is referenced by one or more external identity links.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
using Elsa.ExternalAuthentication.Models;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Identity.Contracts;
|
||||
using Elsa.Identity.Entities;
|
||||
using Elsa.Identity.Models;
|
||||
using Elsa.Workflows;
|
||||
|
||||
namespace Elsa.ExternalAuthentication.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the provider-independent user resolution and creation policy used by external identity provisioners.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdentityUserProvisioningService(
|
||||
IUserStore userStore,
|
||||
IUserProvider userProvider,
|
||||
IRoleProvider roleProvider,
|
||||
IIdentityGenerator identityGenerator)
|
||||
{
|
||||
private const int MaximumUserNameAttempts = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an explicitly selected user or creates a credential-less user from the supplied proposal.
|
||||
/// </summary>
|
||||
public async ValueTask<(User User, bool WasCreated)> ResolveAsync(
|
||||
ProvisioningRequest request,
|
||||
Func<string, bool>? tryReserveUserName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.ExistingUserId))
|
||||
{
|
||||
var existingUser = await userProvider.FindAsync(new UserFilter { Id = request.ExistingUserId }, cancellationToken)
|
||||
?? throw new InvalidOperationException("The requested Elsa user does not exist.");
|
||||
if (!string.Equals(existingUser.TenantId, request.TenantId, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("The requested Elsa user is outside the target tenant.");
|
||||
|
||||
return (existingUser, false);
|
||||
}
|
||||
|
||||
var proposal = request.Proposal ?? throw new InvalidOperationException("A user creation proposal is required for an unlinked external identity.");
|
||||
var roleIds = await ResolveRoleIdsAsync(proposal.DefaultRoleIds, cancellationToken);
|
||||
var prefix = NormalizeUserNamePrefix(proposal.UserNamePrefix);
|
||||
for (var attempt = 0; attempt < MaximumUserNameAttempts; attempt++)
|
||||
{
|
||||
var name = $"{prefix}-{identityGenerator.GenerateId()}";
|
||||
if (tryReserveUserName is not null && !tryReserveUserName(name))
|
||||
continue;
|
||||
if (await userProvider.FindAsync(new UserFilter { Name = name }, cancellationToken) is not null)
|
||||
continue;
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = identityGenerator.GenerateId(),
|
||||
Name = name,
|
||||
TenantId = request.TenantId,
|
||||
HashedPassword = null,
|
||||
HashedPasswordSalt = null,
|
||||
Roles = roleIds.ToList()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await userStore.SaveAsync(user, cancellationToken);
|
||||
return (user, true);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
var persistedUser = await userProvider.FindAsync(new UserFilter { Id = user.Id }, cancellationToken);
|
||||
if (persistedUser is not null)
|
||||
return (persistedUser, true);
|
||||
if (await userProvider.FindAsync(new UserFilter { Name = name }, cancellationToken) is null)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("A unique Elsa user name could not be reserved for the external identity.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a user created by an operation that could not publish its external identity link.
|
||||
/// </summary>
|
||||
public Task RemoveAsync(User user, CancellationToken cancellationToken = default) =>
|
||||
userStore.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the resolved user still exists in the source that supplied it.
|
||||
/// </summary>
|
||||
public async ValueTask<bool> ExistsAsync(User user, bool wasCreated, CancellationToken cancellationToken = default) =>
|
||||
wasCreated
|
||||
? await userStore.FindAsync(new UserFilter { Id = user.Id }, cancellationToken) is not null
|
||||
: await userProvider.FindAsync(new UserFilter { Id = user.Id }, cancellationToken) is not null;
|
||||
|
||||
private static string NormalizeUserNamePrefix(string prefix)
|
||||
{
|
||||
var normalized = new string((prefix ?? string.Empty).Trim().Where(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_').ToArray());
|
||||
return string.IsNullOrEmpty(normalized) ? "external" : normalized;
|
||||
}
|
||||
|
||||
private async ValueTask<IReadOnlyCollection<string>> ResolveRoleIdsAsync(IReadOnlyCollection<string>? roleIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var requested = (roleIds ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (requested.Length == 0)
|
||||
return [];
|
||||
var found = (await roleProvider.FindByIdsAsync(requested, cancellationToken)).Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
|
||||
if (!found.SetEquals(requested))
|
||||
throw new InvalidOperationException("A configured default role no longer exists.");
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
IExternalAuthenticationHandleHasher handleHasher,
|
||||
InMemoryExternalIdentityProvisionerState state) : IExternalIdentityProvisioner, IExternalIdentityLinkManagementStore
|
||||
{
|
||||
private const int MaximumUserNameAttempts = 10;
|
||||
private readonly ExternalIdentityUserProvisioningService _userProvisioningService = new(userStore, userProvider, roleProvider, identityGenerator);
|
||||
|
||||
public async ValueTask<ExternalIdentityLink?> FindLinkAsync(string tenantId, string connectionKey, ExternalIdentity identity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
|
@ -54,7 +54,7 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
if (state.Links.TryGetValue(key, out var existingLink))
|
||||
return new ProvisioningResult(existingLink.UserId, existingLink, false);
|
||||
|
||||
var (user, wasCreated) = await ResolveUserAsync(request, cancellationToken);
|
||||
var (user, wasCreated) = await _userProvisioningService.ResolveAsync(request, state.ReservedUserNames.Add, cancellationToken);
|
||||
var link = new ExternalIdentityLink(
|
||||
identityGenerator.GenerateId(),
|
||||
request.TenantId,
|
||||
|
|
@ -66,6 +66,11 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
clock.UtcNow,
|
||||
null);
|
||||
state.Links[key] = link;
|
||||
if (!await _userProvisioningService.ExistsAsync(user, wasCreated, cancellationToken))
|
||||
{
|
||||
state.Links.Remove(key);
|
||||
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being created.");
|
||||
}
|
||||
return new ProvisioningResult(user.Id, link, wasCreated, true);
|
||||
}
|
||||
finally
|
||||
|
|
@ -94,9 +99,9 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
!string.Equals(conflictingLink.Id, oldEntry.Value.Id, StringComparison.Ordinal))
|
||||
return new ExternalIdentityLinkReplaceResult.Conflict(oldEntry.Value, conflictingLink);
|
||||
|
||||
var (user, _) = await ResolveUserAsync(
|
||||
var (user, _) = await _userProvisioningService.ResolveAsync(
|
||||
new ProvisioningRequest(request.TenantId, normalizedConnectionKey, request.Identity, null, request.UserId),
|
||||
cancellationToken);
|
||||
cancellationToken: cancellationToken);
|
||||
var replacement = new ExternalIdentityLink(
|
||||
identityGenerator.GenerateId(),
|
||||
request.TenantId,
|
||||
|
|
@ -109,6 +114,12 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
null);
|
||||
state.Links.Remove(oldEntry.Key);
|
||||
state.Links[replacementKey] = replacement;
|
||||
if (!await _userProvisioningService.ExistsAsync(user, false, cancellationToken))
|
||||
{
|
||||
state.Links.Remove(replacementKey);
|
||||
state.Links[oldEntry.Key] = oldEntry.Value;
|
||||
throw new InvalidOperationException("The Elsa user was deleted while its external identity link was being replaced.");
|
||||
}
|
||||
return new ExternalIdentityLinkReplaceResult.Success(oldEntry.Value, replacement);
|
||||
}
|
||||
finally
|
||||
|
|
@ -156,58 +167,4 @@ public sealed class InMemoryExternalIdentityProvisioner(
|
|||
}
|
||||
}
|
||||
|
||||
private async ValueTask<(User User, bool WasCreated)> ResolveUserAsync(ProvisioningRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.ExistingUserId))
|
||||
{
|
||||
var user = await userProvider.FindAsync(new UserFilter { Id = request.ExistingUserId }, cancellationToken)
|
||||
?? throw new InvalidOperationException("The requested Elsa user does not exist.");
|
||||
if (!string.Equals(user.TenantId, request.TenantId, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("The requested Elsa user is outside the target tenant.");
|
||||
|
||||
return (user, false);
|
||||
}
|
||||
|
||||
var proposal = request.Proposal ?? throw new InvalidOperationException("A user creation proposal is required for an unlinked external identity.");
|
||||
var roleIds = await ResolveRoleIdsAsync(proposal.DefaultRoleIds, cancellationToken);
|
||||
var userNamePrefix = NormalizeUserNamePrefix(proposal.UserNamePrefix);
|
||||
for (var attempt = 0; attempt < MaximumUserNameAttempts; attempt++)
|
||||
{
|
||||
var userName = $"{userNamePrefix}-{identityGenerator.GenerateId()}";
|
||||
if (!state.ReservedUserNames.Add(userName) || await userProvider.FindAsync(new UserFilter { Name = userName }, cancellationToken) is not null)
|
||||
continue;
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = identityGenerator.GenerateId(),
|
||||
Name = userName,
|
||||
TenantId = request.TenantId,
|
||||
HashedPassword = null,
|
||||
HashedPasswordSalt = null,
|
||||
Roles = roleIds.ToList()
|
||||
};
|
||||
await userStore.SaveAsync(user, cancellationToken);
|
||||
return (user, true);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("A unique Elsa user name could not be reserved for the external identity.");
|
||||
}
|
||||
|
||||
private static string NormalizeUserNamePrefix(string prefix)
|
||||
{
|
||||
var normalized = new string((prefix ?? string.Empty).Trim().Where(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_').ToArray());
|
||||
return string.IsNullOrEmpty(normalized) ? "external" : normalized;
|
||||
}
|
||||
|
||||
private async ValueTask<IReadOnlyCollection<string>> ResolveRoleIdsAsync(IReadOnlyCollection<string>? roleIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var requested = (roleIds ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (requested.Length == 0)
|
||||
return [];
|
||||
var found = (await roleProvider.FindByIdsAsync(requested, cancellationToken)).Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
|
||||
if (!found.SetEquals(requested))
|
||||
throw new InvalidOperationException("A configured default role no longer exists.");
|
||||
return requested;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ public sealed class InMemoryExternalAuthenticationSessionStore(ISystemClock cloc
|
|||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(refreshTokenHash))
|
||||
return ValueTask.FromResult<ExternalAuthenticationSession?>(null);
|
||||
|
||||
lock (_syncRoot)
|
||||
return ValueTask.FromResult(_sessions.Values.FirstOrDefault(x => string.Equals(x.CurrentRefreshTokenHash, refreshTokenHash, StringComparison.Ordinal)) is { } session ? Clone(session) : null);
|
||||
}
|
||||
|
|
@ -76,7 +79,7 @@ public sealed class InMemoryExternalAuthenticationSessionStore(ISystemClock cloc
|
|||
return ValueTask.FromResult<ExternalAuthenticationSessionRotationResult>(new ExternalAuthenticationSessionRotationResult.Expired());
|
||||
}
|
||||
|
||||
if (!string.Equals(session.CurrentRefreshTokenHash, refreshTokenHash, StringComparison.Ordinal) || session.RefreshGeneration != expectedGeneration)
|
||||
if (session.CurrentRefreshTokenHash is null || !string.Equals(session.CurrentRefreshTokenHash, refreshTokenHash, StringComparison.Ordinal) || session.RefreshGeneration != expectedGeneration)
|
||||
{
|
||||
session.RevokedAt = clock.UtcNow;
|
||||
session.RevocationReason = "refresh_token_reuse";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
using Elsa.Identity.Models;
|
||||
|
||||
namespace Elsa.Identity.Contracts;
|
||||
|
||||
/// <summary>Coordinates guarded user deletion across installed dependency contributors.</summary>
|
||||
public interface IUserDeletionCoordinator
|
||||
{
|
||||
ValueTask<UserDeletionOperationResult> DeleteAsync(string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using Elsa.Identity.Entities;
|
||||
using Elsa.Identity.Models;
|
||||
|
||||
namespace Elsa.Identity.Contracts;
|
||||
|
||||
/// <summary>Allows an installed module to prevent deletion of a user it still references.</summary>
|
||||
public interface IUserDeletionDependencyContributor
|
||||
{
|
||||
/// <summary>A stable contributor identifier.</summary>
|
||||
string Source { get; }
|
||||
|
||||
/// <summary>Returns the dependency that prevents deleting the user, or <see langword="null"/> when none exists.</summary>
|
||||
ValueTask<UserDeletionDependency?> InspectAsync(User user, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Identity.Contracts;
|
||||
using Elsa.Identity.Models;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Elsa.Identity.Endpoints.Users.Delete;
|
||||
|
||||
|
|
@ -8,7 +10,7 @@ namespace Elsa.Identity.Endpoints.Users.Delete;
|
|||
/// An endpoint that deletes a user by ID.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
internal class Delete(IUserStore userStore) : ElsaEndpointWithoutRequest
|
||||
internal class Delete(IUserDeletionCoordinator coordinator) : ElsaEndpointWithoutRequest
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
|
|
@ -22,17 +24,23 @@ internal class Delete(IUserStore userStore) : ElsaEndpointWithoutRequest
|
|||
{
|
||||
var id = Route<string>("id")!;
|
||||
|
||||
var user = await userStore.FindAsync(new()
|
||||
{ Id = id }, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
var result = await coordinator.DeleteAsync(id, cancellationToken);
|
||||
switch (result)
|
||||
{
|
||||
await Send.NotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await userStore.DeleteAsync(new()
|
||||
{ Id = id }, cancellationToken);
|
||||
case UserDeletionOperationResult.Deleted:
|
||||
await Send.NoContentAsync(cancellationToken);
|
||||
break;
|
||||
case UserDeletionOperationResult.NotFound:
|
||||
await Send.NotFoundAsync(cancellationToken);
|
||||
break;
|
||||
case UserDeletionOperationResult.Blocked blocked:
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||
await HttpContext.Response.WriteAsJsonAsync(
|
||||
new { error = "conflict", message = "The user is referenced by one or more installed modules.", dependencies = blocked.Dependencies },
|
||||
cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException("Unknown user-deletion operation result.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ public class IdentityFeature : FeatureBase
|
|||
.AddScoped<IRoleManager, RoleManager>()
|
||||
.AddScoped<IRoleAuthorizationService, RoleAuthorizationService>()
|
||||
.AddScoped<IRoleDeletionCoordinator, RoleDeletionCoordinator>()
|
||||
.AddScoped<IUserDeletionCoordinator, UserDeletionCoordinator>()
|
||||
.AddScoped<ISecretHasher, DefaultSecretHasher>()
|
||||
.AddScoped<IElsaTokenService, DefaultElsaTokenService>()
|
||||
.AddScoped<IAccessTokenIssuer>(sp => ActivatorUtilities.CreateInstance<DefaultAccessTokenIssuer>(sp))
|
||||
|
|
|
|||
16
src/modules/Elsa.Identity/Models/UserDeletionModels.cs
Normal file
16
src/modules/Elsa.Identity/Models/UserDeletionModels.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace Elsa.Identity.Models;
|
||||
|
||||
/// <summary>Describes a module-owned reference that prevents deleting a user.</summary>
|
||||
public sealed record UserDeletionDependency(string Source, string Description);
|
||||
|
||||
/// <summary>Represents the outcome of a guarded user-deletion attempt.</summary>
|
||||
public abstract record UserDeletionOperationResult
|
||||
{
|
||||
private UserDeletionOperationResult()
|
||||
{
|
||||
}
|
||||
|
||||
public sealed record Deleted : UserDeletionOperationResult;
|
||||
public sealed record NotFound : UserDeletionOperationResult;
|
||||
public sealed record Blocked(IReadOnlyCollection<UserDeletionDependency> Dependencies) : UserDeletionOperationResult;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using Elsa.Identity.Contracts;
|
||||
using Elsa.Identity.Entities;
|
||||
using Elsa.Identity.Models;
|
||||
|
||||
namespace Elsa.Identity.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed class UserDeletionCoordinator(
|
||||
IUserStore userStore,
|
||||
IEnumerable<IUserDeletionDependencyContributor> contributors) : IUserDeletionCoordinator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<UserDeletionOperationResult> DeleteAsync(string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await userStore.FindAsync(new() { Id = userId }, cancellationToken);
|
||||
if (user is null)
|
||||
return new UserDeletionOperationResult.NotFound();
|
||||
|
||||
var dependencies = await InspectDependenciesAsync(user, cancellationToken);
|
||||
|
||||
if (dependencies.Count > 0)
|
||||
return new UserDeletionOperationResult.Blocked(dependencies);
|
||||
|
||||
await userStore.DeleteAsync(new() { Id = user.Id }, cancellationToken);
|
||||
|
||||
// A link writer can race the first inspection when Identity and the contributing module use different stores.
|
||||
// Recheck after deletion and restore the aggregate if that writer committed first. Link writers perform the
|
||||
// complementary post-commit user check, so either ordering converges without a dangling reference.
|
||||
try
|
||||
{
|
||||
dependencies = await InspectDependenciesAsync(user, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await userStore.SaveAsync(user, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (dependencies.Count > 0)
|
||||
{
|
||||
await userStore.SaveAsync(user, cancellationToken);
|
||||
return new UserDeletionOperationResult.Blocked(dependencies);
|
||||
}
|
||||
|
||||
return new UserDeletionOperationResult.Deleted();
|
||||
}
|
||||
|
||||
private async ValueTask<List<UserDeletionDependency>> InspectDependenciesAsync(User user, CancellationToken cancellationToken)
|
||||
{
|
||||
var dependencies = new List<UserDeletionDependency>();
|
||||
foreach (var contributor in contributors)
|
||||
{
|
||||
var dependency = await contributor.InspectAsync(user, cancellationToken);
|
||||
if (dependency is not null)
|
||||
dependencies.Add(dependency);
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ public class IdentityFeature : IFastEndpointsShellFeature
|
|||
.AddScoped<IRoleManager, RoleManager>()
|
||||
.AddScoped<IRoleAuthorizationService, RoleAuthorizationService>()
|
||||
.AddScoped<IRoleDeletionCoordinator, RoleDeletionCoordinator>()
|
||||
.AddScoped<IUserDeletionCoordinator, UserDeletionCoordinator>()
|
||||
.AddScoped<ISecretHasher, DefaultSecretHasher>()
|
||||
.AddScoped<IElsaTokenService, DefaultElsaTokenService>()
|
||||
.AddScoped<IAccessTokenIssuer>(sp => ActivatorUtilities.CreateInstance<DefaultAccessTokenIssuer>(sp))
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Elsa.ExternalAuthentication.Features;
|
||||
using Elsa.ExternalAuthentication.Permissions;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.ExternalAuthentication.IntegrationTests.Descriptors;
|
||||
|
||||
[Collection(nameof(RuntimeDescriptorEndpointCollection))]
|
||||
public class RuntimeDescriptorEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _client;
|
||||
private bool _wasSecurityEnabled;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled;
|
||||
EndpointSecurityOptions.SecurityIsEnabled = true;
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddAuthentication(TestAuthenticationHandler.AuthenticationScheme)
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(TestAuthenticationHandler.AuthenticationScheme, _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddFastEndpoints(options =>
|
||||
{
|
||||
options.Assemblies = [typeof(ExternalAuthenticationFeature).Assembly];
|
||||
options.Filter = endpoint => endpoint.Namespace == "Elsa.ExternalAuthentication.Endpoints.Runtime";
|
||||
});
|
||||
|
||||
_app = builder.Build();
|
||||
_app.UseAuthentication();
|
||||
_app.UseAuthorization();
|
||||
_app.UseFastEndpoints();
|
||||
await _app.StartAsync();
|
||||
_client = _app.GetTestClient();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled;
|
||||
_client?.Dispose();
|
||||
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimeDescriptorRequiresReadPermissionAndReturnsSafeVersionMetadata()
|
||||
{
|
||||
using var forbidden = new HttpRequestMessage(HttpMethod.Get, "/external-authentication/descriptors/runtime");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client!.SendAsync(forbidden)).StatusCode);
|
||||
|
||||
using var authorized = new HttpRequestMessage(HttpMethod.Get, "/external-authentication/descriptors/runtime");
|
||||
authorized.Headers.Add(TestAuthenticationHandler.PermissionHeader, ExternalAuthenticationPermissions.ConnectionsRead);
|
||||
var response = await _client.SendAsync(authorized);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var descriptor = await response.Content.ReadFromJsonAsync<RuntimeDescriptor>();
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(1, descriptor.ManagementContractVersion);
|
||||
Assert.False(string.IsNullOrWhiteSpace(descriptor.ProductVersion));
|
||||
Assert.False(string.IsNullOrWhiteSpace(descriptor.InformationalVersion));
|
||||
}
|
||||
|
||||
private sealed record RuntimeDescriptor(int ManagementContractVersion, string ProductVersion, string InformationalVersion);
|
||||
|
||||
private sealed class TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string AuthenticationScheme = "runtime-descriptor-test";
|
||||
public const string PermissionHeader = "X-Test-Permissions";
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var permissions = Request.Headers[PermissionHeader]
|
||||
.SelectMany(x => x?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []);
|
||||
var identity = new ClaimsIdentity(permissions.Select(x => new Claim(PermissionNames.ClaimType, x)), AuthenticationScheme);
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), AuthenticationScheme)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(RuntimeDescriptorEndpointCollection), DisableParallelization = true)]
|
||||
public class RuntimeDescriptorEndpointCollection;
|
||||
|
|
@ -11,9 +11,11 @@ using Elsa.Identity.Entities;
|
|||
using Elsa.Identity.Models;
|
||||
using Elsa.Identity.Providers;
|
||||
using Elsa.Identity.Services;
|
||||
using Elsa.Persistence.EFCore;
|
||||
using Elsa.Workflows;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
|
@ -35,9 +37,10 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
await _connection.OpenAsync();
|
||||
_clock = new SystemClock();
|
||||
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
||||
.UseSqlite(_connection, sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName))
|
||||
.Options;
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>();
|
||||
optionsBuilder.UseElsaDbContextOptions(null);
|
||||
optionsBuilder.UseSqlite(_connection, sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName));
|
||||
var options = optionsBuilder.Options;
|
||||
_services = new ServiceCollection()
|
||||
.AddSingleton<IDbContextFactory<ExternalAuthenticationElsaDbContext>>(serviceProvider => new TestDbContextFactory(options, serviceProvider))
|
||||
.BuildServiceProvider();
|
||||
|
|
@ -55,10 +58,14 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
await _connection.DisposeAsync();
|
||||
}
|
||||
|
||||
private EFCoreExternalIdentityProvisioner CreateProvisioner(IExternalAuthenticationHandleHasher hasher, IDbContextFactory<ExternalAuthenticationElsaDbContext>? dbContextFactory = null, IUserStore? userStore = null) =>
|
||||
private EFCoreExternalIdentityProvisioner CreateProvisioner(
|
||||
IExternalAuthenticationHandleHasher hasher,
|
||||
IDbContextFactory<ExternalAuthenticationElsaDbContext>? dbContextFactory = null,
|
||||
IUserStore? userStore = null,
|
||||
IUserProvider? userProvider = null) =>
|
||||
new(dbContextFactory ?? _dbContextFactory,
|
||||
userStore ?? _userStore,
|
||||
new StoreBasedUserProvider(userStore ?? _userStore),
|
||||
userProvider ?? new StoreBasedUserProvider(userStore ?? _userStore),
|
||||
Substitute.For<IRoleProvider>(),
|
||||
hasher,
|
||||
new GuidIdentityGenerator(),
|
||||
|
|
@ -78,6 +85,7 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedBrokerTransaction));
|
||||
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedAuthorizationGrant));
|
||||
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedExternalAuthenticationSession));
|
||||
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedExternalAuthenticationRefreshToken));
|
||||
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedConnectionObservation));
|
||||
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(PersistedPreviewResult));
|
||||
Assert.Contains(model.GetEntityTypes(), x => x.ClrType == typeof(ExternalAuthenticationRegistryVersion));
|
||||
|
|
@ -87,6 +95,30 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
Assert.Contains(connection.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedIdentityProviderConnection.TenantId), nameof(PersistedIdentityProviderConnection.Key)]));
|
||||
var link = model.FindEntityType(typeof(PersistedExternalIdentityLink))!;
|
||||
Assert.Contains(link.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedExternalIdentityLink.TenantId), nameof(PersistedExternalIdentityLink.ConnectionKey), nameof(PersistedExternalIdentityLink.Issuer), nameof(PersistedExternalIdentityLink.SubjectHash)]));
|
||||
var refreshToken = model.FindEntityType(typeof(PersistedExternalAuthenticationRefreshToken))!;
|
||||
Assert.Contains(refreshToken.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual([nameof(PersistedExternalAuthenticationRefreshToken.Hash)]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SqliteInitialMigrationCreatesTheOptionalRefreshTokenTable()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>();
|
||||
optionsBuilder.UseElsaDbContextOptions(null);
|
||||
optionsBuilder.UseSqlite(connection, sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName));
|
||||
var options = optionsBuilder.Options;
|
||||
await using var services = new ServiceCollection().BuildServiceProvider();
|
||||
await using var dbContext = new ExternalAuthenticationElsaDbContext(options, services);
|
||||
|
||||
await dbContext.Database.MigrateAsync();
|
||||
|
||||
Assert.Single(await dbContext.Database.GetAppliedMigrationsAsync());
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'ExternalAuthenticationSessionRefreshTokens'";
|
||||
Assert.Equal(1L, (long)(await command.ExecuteScalarAsync())!);
|
||||
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('ExternalAuthenticationSessions') WHERE name = 'CurrentRefreshTokenHash'";
|
||||
Assert.Equal(0L, (long)(await command.ExecuteScalarAsync())!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -134,6 +166,8 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
var sessionStore = new EFCoreExternalAuthenticationSessionStore(durableDbContexts, _clock);
|
||||
await sessionStore.SaveAsync(CreateSession());
|
||||
Assert.IsType<ExternalAuthenticationSessionRotationResult.Rotated>(await sessionStore.TryRotateRefreshTokenAsync("session-a", "refresh-a", 0, "refresh-b", _clock.UtcNow));
|
||||
Assert.Null(await sessionStore.FindByRefreshTokenHashAsync("refresh-a"));
|
||||
Assert.Equal("session-a", (await sessionStore.FindByRefreshTokenHashAsync("refresh-b"))!.Id);
|
||||
Assert.IsType<ExternalAuthenticationSessionRotationResult.Reused>(await sessionStore.TryRotateRefreshTokenAsync("session-a", "refresh-a", 0, "refresh-c", _clock.UtcNow));
|
||||
|
||||
var firstNode = new EFCoreConnectionRegistryVersionStore(durableDbContexts);
|
||||
|
|
@ -218,23 +252,112 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
[Fact]
|
||||
public async Task ProvisionerRemovesTheJustInTimeUserThatLosesTheLinkRace()
|
||||
{
|
||||
var databasePath = Path.Combine(Path.GetTempPath(), $"elsa-external-identity-provisioning-{Guid.NewGuid():N}.db");
|
||||
await using var services = new ServiceCollection().BuildServiceProvider();
|
||||
try
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
||||
.UseSqlite($"Data Source={databasePath};Default Timeout=30")
|
||||
.Options;
|
||||
var factory = new TestDbContextFactory(options, services);
|
||||
await using (var dbContext = await factory.CreateDbContextAsync())
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
|
||||
var durableUsers = new MemoryUserStore(new MemoryStore<User>());
|
||||
var coordinatedUsers = new CoordinatedUserStore(durableUsers, 2);
|
||||
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
||||
var firstNode = CreateProvisioner(hasher);
|
||||
var secondNode = CreateProvisioner(hasher);
|
||||
var request = new ProvisioningRequest("tenant-a", "connection-a", new ExternalIdentity("https://issuer.example", "subject-race", new Dictionary<string, IReadOnlyCollection<string>>()), new UserCreationProposal("external"));
|
||||
var firstNode = CreateProvisioner(hasher, factory, coordinatedUsers);
|
||||
var secondNode = CreateProvisioner(hasher, factory, coordinatedUsers);
|
||||
var request = new ProvisioningRequest("tenant-a", "connection-a", new ExternalIdentity("https://issuer.example", "subject-race", EmptyClaims), new UserCreationProposal("external"));
|
||||
|
||||
// The winning node inserts the link; the losing node must converge on it and clean up its own stranded user.
|
||||
var winner = await firstNode.CreateLinkOrGetExistingAsync(request);
|
||||
var loser = await secondNode.CreateLinkOrGetExistingAsync(request);
|
||||
var results = await Task.WhenAll(
|
||||
firstNode.CreateLinkOrGetExistingAsync(request).AsTask(),
|
||||
secondNode.CreateLinkOrGetExistingAsync(request).AsTask());
|
||||
|
||||
Assert.True(winner.WasCreated);
|
||||
Assert.False(loser.WasCreated);
|
||||
Assert.Equal(winner.Link.Id, loser.Link.Id);
|
||||
Assert.Equal(winner.UserId, loser.UserId);
|
||||
var user = Assert.Single(await _userStore.FindManyAsync(new UserFilter()));
|
||||
Assert.Equal(winner.UserId, user.Id);
|
||||
Assert.Single(results, x => x.WasCreated);
|
||||
Assert.Single(results, x => !x.WasCreated);
|
||||
Assert.Single(results.Select(x => x.Link.Id).Distinct(StringComparer.Ordinal));
|
||||
var user = Assert.Single(await durableUsers.FindManyAsync(new UserFilter()));
|
||||
Assert.Equal(results[0].UserId, user.Id);
|
||||
Assert.Equal(results[1].UserId, user.Id);
|
||||
await using var verificationContext = await factory.CreateDbContextAsync();
|
||||
Assert.Single(await verificationContext.ExternalIdentityLinks.ToListAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
File.Delete(databasePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvisionerRemovesTheJustInTimeUserWhenLinkPersistenceFails()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.AddInterceptors(new FailingLinkSaveInterceptor())
|
||||
.Options;
|
||||
var provisioner = CreateProvisioner(
|
||||
new HmacExternalAuthenticationHandleHasher(),
|
||||
new TestDbContextFactory(options, _services));
|
||||
var request = new ProvisioningRequest(
|
||||
"tenant-a",
|
||||
"connection-a",
|
||||
new ExternalIdentity("https://issuer.example", "subject-link-failure", EmptyClaims),
|
||||
new UserCreationProposal("external"));
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
|
||||
|
||||
Assert.Empty(await _userStore.FindManyAsync(new UserFilter()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvisionerFailsWhenAJustInTimeUserCannotBeCompensated()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.AddInterceptors(new FailingLinkSaveInterceptor())
|
||||
.Options;
|
||||
var userStore = new DeleteFailingUserStore(new MemoryUserStore(new MemoryStore<User>()));
|
||||
var provisioner = CreateProvisioner(
|
||||
new HmacExternalAuthenticationHandleHasher(),
|
||||
new TestDbContextFactory(options, _services),
|
||||
userStore);
|
||||
var request = new ProvisioningRequest(
|
||||
"tenant-a",
|
||||
"connection-a",
|
||||
new ExternalIdentity("https://issuer.example", "subject-compensation-failure", EmptyClaims),
|
||||
new UserCreationProposal("external"));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<AggregateException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
|
||||
|
||||
Assert.Contains("No credentials were issued", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Single(await userStore.FindManyAsync(new UserFilter()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvisionerRemovesTheLinkWhenUserDeletionWinsTheRace()
|
||||
{
|
||||
var users = new MemoryUserStore(new MemoryStore<User>());
|
||||
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.AddInterceptors(new DeleteLinkedUserBeforeCommitInterceptor(users))
|
||||
.Options;
|
||||
var provisioner = CreateProvisioner(
|
||||
new HmacExternalAuthenticationHandleHasher(),
|
||||
new TestDbContextFactory(options, _services),
|
||||
users);
|
||||
var request = new ProvisioningRequest(
|
||||
"tenant-a",
|
||||
"connection-a",
|
||||
new ExternalIdentity("https://issuer.example", "subject-user-deletion-race", EmptyClaims),
|
||||
new UserCreationProposal("external"));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
|
||||
|
||||
Assert.Empty(await users.FindManyAsync(new UserFilter()));
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
||||
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -277,6 +400,59 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvisionerPreservesTheOldLinkWhenTargetUserDeletionWinsReplacementRace()
|
||||
{
|
||||
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
||||
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
||||
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
||||
var originalProvisioner = CreateProvisioner(hasher);
|
||||
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
||||
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
||||
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2);
|
||||
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
|
||||
new ExternalIdentityLinkReplaceRequest(
|
||||
"tenant-a",
|
||||
old.Id,
|
||||
"user-b",
|
||||
"contoso",
|
||||
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
|
||||
|
||||
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var durableLink = Assert.Single(await dbContext.ExternalIdentityLinks.ToListAsync());
|
||||
Assert.Equal(old.Id, durableLink.Id);
|
||||
Assert.Equal("user-a", durableLink.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvisionerLeavesNoLinkWhenBothReplacementUsersAreDeletedDuringCompensation()
|
||||
{
|
||||
await _userStore.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
||||
await _userStore.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
|
||||
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
||||
var originalProvisioner = CreateProvisioner(hasher);
|
||||
var old = (await originalProvisioner.CreateLinkOrGetExistingAsync(
|
||||
new ProvisioningRequest("tenant-a", "contoso", new ExternalIdentity("https://issuer.example", "subject-old", EmptyClaims), null, "user-a"))).Link;
|
||||
var racingProvider = new DeleteOnSelectedFindUserProvider(new StoreBasedUserProvider(_userStore), _userStore, 2, 3);
|
||||
var racingProvisioner = CreateProvisioner(hasher, userProvider: racingProvider);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => racingProvisioner.ReplaceAsync(
|
||||
new ExternalIdentityLinkReplaceRequest(
|
||||
"tenant-a",
|
||||
old.Id,
|
||||
"user-b",
|
||||
"contoso",
|
||||
new ExternalIdentity("https://issuer.example", "subject-new", EmptyClaims))).AsTask());
|
||||
|
||||
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-a" }));
|
||||
Assert.Null(await _userStore.FindAsync(new UserFilter { Id = "user-b" }));
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Assert.Empty(await dbContext.ExternalIdentityLinks.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DurableConcurrentReplacementUsesTheOldLinkIdAsAnAtomicGuard()
|
||||
{
|
||||
|
|
@ -340,8 +516,10 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
Assert.Null(result.Error);
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var session = Assert.Single(await dbContext.ExternalAuthenticationSessions.ToListAsync());
|
||||
Assert.False(string.IsNullOrEmpty(session.CurrentRefreshTokenHash));
|
||||
Assert.Equal("user-a", session.UserId);
|
||||
Assert.Empty(await dbContext.ExternalAuthenticationRefreshTokens.ToListAsync());
|
||||
Assert.Null((await new EFCoreExternalAuthenticationSessionStore(_leaseFactory, _clock).FindByIdAsync(session.Id))!.CurrentRefreshTokenHash);
|
||||
Assert.Null(await new EFCoreExternalAuthenticationSessionStore(_leaseFactory, _clock).FindByRefreshTokenHashAsync(null!));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
|
||||
|
|
@ -377,4 +555,71 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
|
|||
private int _index;
|
||||
public DateTimeOffset UtcNow => instants[Math.Min(_index++, instants.Length - 1)];
|
||||
}
|
||||
|
||||
private sealed class CoordinatedUserStore(IUserStore inner, int participantCount) : IUserStore
|
||||
{
|
||||
private readonly TaskCompletionSource _participantsReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _participants;
|
||||
|
||||
public async Task SaveAsync(User user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Increment(ref _participants) == participantCount)
|
||||
_participantsReady.TrySetResult();
|
||||
await _participantsReady.Task.WaitAsync(cancellationToken);
|
||||
await inner.SaveAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.DeleteAsync(filter, cancellationToken);
|
||||
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
|
||||
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class FailingLinkSaveInterceptor : SaveChangesInterceptor
|
||||
{
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData,
|
||||
InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new DbUpdateException("Simulated external identity link persistence failure.");
|
||||
}
|
||||
|
||||
private sealed class DeleteLinkedUserBeforeCommitInterceptor(IUserStore users) : SaveChangesInterceptor
|
||||
{
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData,
|
||||
InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userId = eventData.Context!.ChangeTracker.Entries<PersistedExternalIdentityLink>()
|
||||
.Single(x => x.State == EntityState.Added).Entity.UserId;
|
||||
await users.DeleteAsync(new UserFilter { Id = userId }, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DeleteFailingUserStore(IUserStore inner) : IUserStore
|
||||
{
|
||||
public Task SaveAsync(User user, CancellationToken cancellationToken = default) => inner.SaveAsync(user, cancellationToken);
|
||||
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Simulated user cleanup failure.");
|
||||
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindManyAsync(filter, cancellationToken);
|
||||
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class DeleteOnSelectedFindUserProvider(IUserProvider inner, IUserStore users, params int[] deletionCounts) : IUserProvider
|
||||
{
|
||||
private readonly HashSet<int> _deletionCounts = deletionCounts.ToHashSet();
|
||||
private int _findCount;
|
||||
|
||||
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await inner.FindAsync(filter, cancellationToken);
|
||||
if (user is not null && _deletionCounts.Contains(Interlocked.Increment(ref _findCount)))
|
||||
{
|
||||
await users.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
using Elsa.Common;
|
||||
using Elsa.Common.Models;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.ExternalAuthentication.Contracts;
|
||||
using Elsa.ExternalAuthentication.Models;
|
||||
using Elsa.ExternalAuthentication.Services;
|
||||
using Elsa.Identity.Contracts;
|
||||
using Elsa.Identity.Entities;
|
||||
using Elsa.Identity.Models;
|
||||
using Elsa.Identity.Services;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Elsa.ExternalAuthentication.UnitTests.Foundational;
|
||||
|
||||
public class ExternalAuthenticationUserDeletionDependencyContributorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UserWithAnExternalIdentityLinkCannotBeDeleted()
|
||||
{
|
||||
var users = new MemoryUserStore(new MemoryStore<User>());
|
||||
await users.SaveAsync(new User { Id = "external-user", Name = "external-user", TenantId = "tenant-a" });
|
||||
var links = Substitute.For<IExternalIdentityLinkManagementStore>();
|
||||
links.FindAsync(
|
||||
Arg.Is<ExternalIdentityLinkFilter>(x => x.TenantId == "tenant-a" && x.UserId == "external-user"),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(Page.Of<ExternalIdentityLink>([
|
||||
new ExternalIdentityLink("link-a", "tenant-a", "contoso", "https://issuer.example", "subject-hash", null, "external-user", DateTimeOffset.UtcNow, null)
|
||||
], 1)));
|
||||
var coordinator = new UserDeletionCoordinator(
|
||||
users,
|
||||
[new ExternalAuthenticationUserDeletionDependencyContributor(links)]);
|
||||
|
||||
var result = await coordinator.DeleteAsync("external-user");
|
||||
|
||||
var blocked = Assert.IsType<UserDeletionOperationResult.Blocked>(result);
|
||||
Assert.Contains(blocked.Dependencies, x => x.Source == ExternalAuthenticationUserDeletionDependencyContributor.SourceName);
|
||||
Assert.NotNull(await users.FindAsync(new UserFilter { Id = "external-user" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserWithoutAnExternalIdentityLinkCanBeDeleted()
|
||||
{
|
||||
var users = new MemoryUserStore(new MemoryStore<User>());
|
||||
await users.SaveAsync(new User { Id = "local-user", Name = "local-user", TenantId = "tenant-a" });
|
||||
var links = Substitute.For<IExternalIdentityLinkManagementStore>();
|
||||
links.FindAsync(
|
||||
Arg.Is<ExternalIdentityLinkFilter>(x => x.TenantId == "tenant-a" && x.UserId == "local-user"),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromResult(Page.Of<ExternalIdentityLink>([], 0)));
|
||||
var coordinator = new UserDeletionCoordinator(
|
||||
users,
|
||||
[new ExternalAuthenticationUserDeletionDependencyContributor(links)]);
|
||||
|
||||
var result = await coordinator.DeleteAsync("local-user");
|
||||
|
||||
Assert.IsType<UserDeletionOperationResult.Deleted>(result);
|
||||
Assert.Null(await users.FindAsync(new UserFilter { Id = "local-user" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserIsRestoredWhenAnExternalIdentityLinkAppearsDuringDeletion()
|
||||
{
|
||||
var users = new MemoryUserStore(new MemoryStore<User>());
|
||||
await users.SaveAsync(new User { Id = "racing-user", Name = "racing-user", TenantId = "tenant-a" });
|
||||
var links = Substitute.For<IExternalIdentityLinkManagementStore>();
|
||||
var inspectionCount = 0;
|
||||
links.FindAsync(Arg.Any<ExternalIdentityLinkFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => ValueTask.FromResult(Interlocked.Increment(ref inspectionCount) == 1
|
||||
? Page.Of<ExternalIdentityLink>([], 0)
|
||||
: Page.Of<ExternalIdentityLink>([
|
||||
new ExternalIdentityLink("link-a", "tenant-a", "contoso", "https://issuer.example", "subject-hash", null, "racing-user", DateTimeOffset.UtcNow, null)
|
||||
], 1)));
|
||||
var coordinator = new UserDeletionCoordinator(
|
||||
users,
|
||||
[new ExternalAuthenticationUserDeletionDependencyContributor(links)]);
|
||||
|
||||
var result = await coordinator.DeleteAsync("racing-user");
|
||||
|
||||
Assert.IsType<UserDeletionOperationResult.Blocked>(result);
|
||||
Assert.NotNull(await users.FindAsync(new UserFilter { Id = "racing-user" }));
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,21 @@ public class InMemoryExternalAuthenticationSessionStoreTests
|
|||
Assert.Equal(1, reloaded.RefreshGeneration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionWithoutAnIssuedRefreshTokenCannotBeFoundByARefreshTokenHash()
|
||||
{
|
||||
var store = new InMemoryExternalAuthenticationSessionStore(new TestSystemClock(_now));
|
||||
var session = ExternalAuthenticationTestData.CreateSession(_now);
|
||||
session.CurrentRefreshTokenHash = null!;
|
||||
await store.SaveAsync(session);
|
||||
|
||||
var persisted = await store.FindByIdAsync(session.Id);
|
||||
var matched = await store.FindByRefreshTokenHashAsync(null!);
|
||||
|
||||
Assert.Null(persisted!.CurrentRefreshTokenHash);
|
||||
Assert.Null(matched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReusingASupersededRefreshTokenRevokesTheSession()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
using Elsa.Common.Services;
|
||||
using Elsa.ExternalAuthentication.Models;
|
||||
using Elsa.ExternalAuthentication.Services;
|
||||
using Elsa.Identity.Contracts;
|
||||
using Elsa.Identity.Entities;
|
||||
using Elsa.Identity.Models;
|
||||
using Elsa.Identity.Providers;
|
||||
using Elsa.Identity.Services;
|
||||
using Elsa.Workflows;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Elsa.ExternalAuthentication.UnitTests.Foundational;
|
||||
|
||||
public class InMemoryExternalIdentityProvisionerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RemovesLinkWhenUserDeletionWinsThePublicationRace()
|
||||
{
|
||||
var users = new MemoryUserStore(new MemoryStore<User>());
|
||||
await users.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
|
||||
var provider = new DeleteAfterResolveUserProvider(new StoreBasedUserProvider(users), users);
|
||||
using var hasher = new HmacExternalAuthenticationHandleHasher();
|
||||
var provisioner = new InMemoryExternalIdentityProvisioner(
|
||||
users,
|
||||
provider,
|
||||
Substitute.For<IRoleProvider>(),
|
||||
new GuidIdentityGenerator(),
|
||||
new TestSystemClock(DateTimeOffset.UtcNow),
|
||||
hasher,
|
||||
new InMemoryExternalIdentityProvisionerState());
|
||||
var request = new ProvisioningRequest(
|
||||
"tenant-a",
|
||||
"contoso",
|
||||
new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary<string, IReadOnlyCollection<string>>()),
|
||||
null,
|
||||
"user-a");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => provisioner.CreateLinkOrGetExistingAsync(request).AsTask());
|
||||
|
||||
Assert.Null(await users.FindAsync(new UserFilter { Id = "user-a" }));
|
||||
Assert.Empty((await provisioner.FindAsync(new ExternalIdentityLinkFilter { TenantId = "tenant-a" })).Items);
|
||||
}
|
||||
|
||||
private sealed class DeleteAfterResolveUserProvider(IUserProvider inner, IUserStore users) : IUserProvider
|
||||
{
|
||||
private int _findCount;
|
||||
|
||||
public async Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await inner.FindAsync(filter, cancellationToken);
|
||||
if (user is not null && Interlocked.Increment(ref _findCount) == 1)
|
||||
await users.DeleteAsync(new UserFilter { Id = user.Id }, cancellationToken);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,26 @@ public class DefaultAccessTokenIssuerRegistrationTests
|
|||
AssertAccessTokenIssuerResolves(services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModuleFeatureRegistersUserDeletionCoordinator()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var module = Substitute.For<IModule>();
|
||||
module.Services.Returns(services);
|
||||
new ModuleIdentityFeature(module).Apply();
|
||||
|
||||
AssertUserDeletionCoordinatorResolves(services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShellFeatureRegistersUserDeletionCoordinator()
|
||||
{
|
||||
var services = CreateServices();
|
||||
new ShellIdentityFeature().ConfigureServices(services);
|
||||
|
||||
AssertUserDeletionCoordinatorResolves(services);
|
||||
}
|
||||
|
||||
private static ServiceCollection CreateServices()
|
||||
{
|
||||
return new ServiceCollection();
|
||||
|
|
@ -42,4 +62,11 @@ public class DefaultAccessTokenIssuerRegistrationTests
|
|||
using var scope = serviceProvider.CreateScope();
|
||||
Assert.IsType<DefaultAccessTokenIssuer>(scope.ServiceProvider.GetRequiredService<IAccessTokenIssuer>());
|
||||
}
|
||||
|
||||
private static void AssertUserDeletionCoordinatorResolves(IServiceCollection services)
|
||||
{
|
||||
using var serviceProvider = services.BuildServiceProvider();
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
Assert.IsType<UserDeletionCoordinator>(scope.ServiceProvider.GetRequiredService<IUserDeletionCoordinator>());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue