Skip to content

Commit 128569f

Browse files
committed
Harden external authentication contracts and flows
1 parent 95b2ce8 commit 128569f

49 files changed

Lines changed: 1131 additions & 206 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

specs/012-external-authentication/contracts/rest-api.md

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ Adapter descriptor:
293293
```http
294294
GET /external-authentication/connections
295295
?search=
296-
&source=configuration|studio|studioOverride
296+
&source=configuration|database
297297
&adapterType=
298298
&enabled=
299299
&valid=
@@ -311,9 +311,11 @@ Requires `external-authentication:connections:read`. Maximum `pageSize` is 100.
311311
{
312312
"id": "01JZCONNECTION",
313313
"key": "contoso",
314-
"source": "studioOverride",
315-
"overridesConfiguration": true,
314+
"source": "database",
315+
"overridesConfigurationConnection": true,
316316
"adapterType": "openid-connect",
317+
"callbackUri": "https://elsa.example/elsa/api/external-authentication/callback/contoso",
318+
"previewCallbackUri": "https://elsa.example/elsa/api/external-authentication/previews/callback/01JZCONNECTION",
317319
"displayName": "Contoso",
318320
"iconId": "building",
319321
"order": 10,
@@ -350,17 +352,17 @@ Requires `external-authentication:connections:create`.
350352
```json
351353
{
352354
"key": "contoso",
355+
"scope": {"kind":"host"},
353356
"adapterType": "openid-connect",
354357
"displayName": "Contoso",
355358
"iconId": "building",
356359
"order": 10,
357360
"isPreferred": false,
358361
"adapterSettingsVersion": 1,
359362
"adapterSettings": {},
360-
"secretBindings": {},
363+
"overridesConfigurationConnection": false,
361364
"unlinkedPolicy": {"type":"reject","settingsVersion":1,"settings":{}},
362-
"defaultRoleIds": [],
363-
"matcherPolicy": null,
365+
"permissionGrantSources": [],
364366
"claimProjection": {
365367
"allowedClaimTypes": ["name", "email", "groups"],
366368
"redactedClaimTypes": ["email"]
@@ -371,13 +373,13 @@ Requires `external-authentication:connections:create`.
371373

372374
Creates a disabled draft. Success: `201`, `Location` header, `ETag: "1"`, and detail body.
373375

374-
### Create Studio Override
376+
### Create Database Override
375377

376378
```http
377-
POST /external-authentication/connections/{connectionId}/override
379+
POST /external-authentication/connections
378380
```
379381

380-
Requires create/update authorization and an explicit confirmation payload. The server returns a complete editable copy with `source=studioOverride`; subsequent saves send the whole document. No inherited field markers or partial patch semantics exist. A disabled override continues shadowing. Archiving it reveals configuration; restoring it resumes shadowing in disabled state.
382+
Requires `external-authentication:connections:create`. Studio starts with a complete editable copy of the configuration-owned connection, preserves its immutable logical `key`, and submits the ordinary create document with `"overridesConfigurationConnection": true`. The server creates a distinct database record with `source=database`; subsequent saves send the whole document to the ordinary update endpoint. No inherited field markers or partial patch semantics exist. A disabled database override continues shadowing the configuration-owned connection. Archiving it reveals configuration; restoring it resumes shadowing in disabled state.
381383

382384
### Detail and Update
383385

@@ -388,15 +390,15 @@ PUT /external-authentication/connections/{connectionId}
388390

389391
Read requires `external-authentication:connections:read`; update requires `external-authentication:connections:update` and `If-Match`.
390392

391-
Detail includes the create fields plus lifecycle, validation, shadow/conflict diagnostics, effective policy, resolved extension availability, and secret binding state:
393+
Detail includes the create fields plus lifecycle, validation, shadow/conflict diagnostics, effective policy, resolved extension availability, and secret binding state. `callbackUri` and `previewCallbackUri` are deployment-derived, read-only values that must be registered exactly with strict providers when their respective normal and administrator-preview flows are used:
392394

393395
```json
394396
{
395397
"secretBindings": {
396398
"clientSecret": {
397-
"resolverType": "elsa-secrets",
398399
"ownership": "managed",
399-
"reference": "contoso-oidc-secret",
400+
"resolverType": null,
401+
"reference": null,
400402
"isConfigured": true,
401403
"isResolvable": true
402404
}
@@ -423,42 +425,43 @@ For an OpenID Connect connection, `adapterSettings` MAY include explicit overrid
423425
}
424426
```
425427

426-
The safe default is to omit `advancedTrustOverrides` and use the exact `discoveryUrl`. Creating or updating a connection with any advanced trust override requires both the normal create/update permission and `external-authentication:provider-trust:unsafe`; deployment policy must also allow the operation. The command MUST include the non-persisted field `"confirmUnsafeProviderTrust": true`; omission or `false` is rejected. Acceptance emits a security notification containing the connection identity, changed field names, actor, and revision, but not signing-key bodies or secret material. Authorized detail responses return the configured override values and identify that Advanced trust is active so Studio can keep its warning visible; `confirmUnsafeProviderTrust` is never returned or persisted.
428+
The safe default is to omit `advancedTrustOverrides` and use the exact `discoveryUrl`. Creating or updating a connection with any advanced trust override requires both the normal create/update permission and `external-authentication:provider-trust:unsafe`; deployment policy must also allow the operation. The command MUST include the non-persisted field `"confirmUnsafeSettings": true`; omission or `false` is rejected. Acceptance emits a security notification containing the connection identity, changed field names, actor, and revision, but not signing-key bodies or secret material. Authorized detail responses return the configured override values and identify that Advanced trust is active so Studio can keep its warning visible; `confirmUnsafeSettings` is never returned or persisted.
427429

428430
These fields replace only the corresponding discovery-derived inputs. They cannot change Elsa-owned callback routing, confidential-client requirements, mandatory S256 PKCE, or state, correlation, nonce, signature, issuer, audience/authorized-party, expiry, and callback-error validation.
429431

430432
### Secret Binding Replacement/Removal
431433

432434
```http
433-
PUT /external-authentication/connections/{connectionId}/secret-bindings/{fieldName}
435+
PUT /external-authentication/connections/{connectionId}/secret-bindings/{fieldName}/managed
434436
DELETE /external-authentication/connections/{connectionId}/secret-bindings/{fieldName}
435437
```
436438

437439
Requires `external-authentication:connections:update` and `If-Match`.
438440

439441
```json
440442
{
441-
"ownership": "managed",
442443
"resolverType": "elsa-secrets",
443-
"reference": "contoso-oidc-secret",
444-
"expectedType": "text",
445-
"expectedScope": "external-authentication"
444+
"value": "write-only-secret-value"
446445
}
447446
```
448447

449-
If the Elsa Secrets bridge supports inline creation/replacement, its secret value is submitted to the Secrets API, not returned or embedded in the connection response. External bindings use `ownership=external` and a deployment resolver such as `configuration`; their value cannot be replaced or removed through these endpoints.
448+
The managed writer stages a new secret reference, publishes it only if the connection revision compare-and-swap succeeds, and removes staged material after any definitive failure. If a store failure has an ambiguous commit outcome and the persisted binding cannot be verified, Elsa retains the staged material rather than risk deleting a live secret and records an operational warning. Neither the value nor the managed reference is returned. General create/update connection documents cannot supply secret bindings.
449+
450+
External bindings use `ownership=external` and a deployment resolver such as `configuration`. They are deployment-owned, may be declared only by configuration connections, and cannot be created, replaced, or removed through management endpoints.
450451

451452
### Lifecycle Actions
452453

453454
```http
454455
POST /external-authentication/connections/{connectionId}/enable
455-
POST /external-authentication/connections/{connectionId}/disable?confirmFinalLoginPathOverride=false
456+
POST /external-authentication/connections/{connectionId}/disable?confirmFinalLoginPathOverride=false&revokeActiveSessions=false
456457
DELETE /external-authentication/connections/{connectionId}?confirmFinalLoginPathOverride=false
457458
POST /external-authentication/connections/{connectionId}/restore
458459
```
459460

460461
All require `If-Match`.
461462

463+
`revokeActiveSessions=true` additionally requires `external-authentication:sessions:revoke` and emits an aggregate, redacted session-revocation security notification.
464+
462465
Disabling or archiving the final normal login method is rejected with `409 conflict` and `details.code` set to `final_login_path_guard` unless another normal/local method or deployment-owned break-glass method remains. A caller holding the deployment-configured privileged override permission may repeat the operation with `confirmFinalLoginPathOverride=true`; Studio requires a separate explicit recovery confirmation before sending it.
463466

464467
| Action | Permission |
@@ -514,6 +517,8 @@ Requires `external-authentication:connections:preview`. POST requires `If-Match`
514517

515518
The authorize route consumes the administrator-bound start state and redirects to the provider. The provider callback stores only a redacted result and returns safe completion status. Result GET is one-time, bound to the initiating administrator session, and returns the allowlisted Preview Result. It returns `410` after expiry/consumption and never produces a normal completion code.
516519

520+
The provider registration must include the exact read-only `previewCallbackUri` returned on the connection resource. This is distinct from the normal `callbackUri` because preview completion is isolated from user sign-in and cannot create a user, link, credential, or session.
521+
517522
## External Identity Links
518523

519524
### Bounded User Lookup

specs/012-external-authentication/data-model.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ Shared, protected, short-lived provider/local/preview correlation state.
184184
| `ExpiresAt` | DateTimeOffset | Default 10 minutes |
185185
| `ConsumedAt` | DateTimeOffset? | Atomic single-use marker |
186186

187+
EF persistence also stores the normalized `ExpiresAtUtcTicks` companion used in the same compare-and-swap predicate as `ConsumedAt`, because not every supported provider can order `DateTimeOffset` values directly.
188+
187189
The public handle contains no protected payload. Atomic take transitions pending to consumed; expired or mismatched state is never revived.
188190

189191
## AuthorizationGrant
@@ -202,6 +204,8 @@ Single-use Elsa completion code record.
202204
| `ExpiresAt` | DateTimeOffset | Default 60 seconds |
203205
| `ConsumedAt` | DateTimeOffset? | Atomic single use |
204206

207+
EF persistence uses the same normalized `ExpiresAtUtcTicks` companion for the atomic consume predicate.
208+
205209
## ExternalAuthenticationSession
206210

207211
| Field | Type | Rules |
@@ -262,6 +266,8 @@ An observation is stale when its tested revision differs from the current effect
262266
| `ExpiresAt` | DateTimeOffset | Default 10 minutes |
263267
| `ConsumedAt` | DateTimeOffset? | One-time read |
264268

269+
EF persistence uses the same normalized `ExpiresAtUtcTicks` companion for the atomic consume predicate.
270+
265271
## OpenIdConnectAdapterSettings v2
266272

267273
| Field | Type | Rules |

specs/012-external-authentication/quickstart.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,13 @@ Authentication__ExternalAuthentication__ClientSecret={strong-random-value}
106106
```json
107107
{
108108
"ExternalAuthentication": {
109+
"Redirects": {
110+
"ExternalCallbackBaseUri": "https://elsa.example/elsa/api/"
111+
},
109112
"Connections": [
110113
{
111114
"id": "01JZCONTOSOOIDC000000000001",
112-
"connectionKey": "contoso-workforce",
115+
"key": "contoso-workforce",
113116
"adapterType": "openid-connect",
114117
"displayName": "Contoso",
115118
"iconId": "building",
@@ -135,9 +138,10 @@ Authentication__ExternalAuthentication__ClientSecret={strong-random-value}
135138
"unlinkedPolicy": {
136139
"type": "create-user",
137140
"settingsVersion": 1,
138-
"settings": {}
141+
"settings": {
142+
"defaultRoleIds": ["workflow-user"]
143+
}
139144
},
140-
"defaultRoleIds": ["workflow-user"],
141145
"claimProjection": {
142146
"allowedClaimTypes": ["name", "email", "groups"],
143147
"redactedClaimTypes": ["email"],
@@ -173,9 +177,10 @@ Elsa derives the provider callback from its deployment-owned external base addre
173177

174178
```text
175179
https://elsa.example/elsa/api/external-authentication/callback/contoso-workforce
180+
https://elsa.example/elsa/api/external-authentication/previews/callback/01JZCONTOSOOIDC000000000001
176181
```
177182

178-
Register that exact callback with the provider. The callback, confidential-client requirement, S256 PKCE, and validation steps are immutable. Discovery-derived issuer, authorization/token endpoints, and signing keys appear only under **Advanced** when deployment policy enables unsafe provider trust and the caller has the dedicated permission; saving them requires explicit confirmation and leaves a persistent warning. The configuration-first example intentionally uses discovery without overrides.
183+
Register both exact callbacks with the provider when administrators will use Preview. The first handles normal user sign-in and is keyed by the immutable logical Connection Key; the second handles administrator previews and is keyed by the stable connection record ID. Both are shown read-only in management responses and Studio. The callbacks, confidential-client requirement, S256 PKCE, and validation steps are immutable. Discovery-derived issuer, authorization/token endpoints, and signing keys appear only under **Advanced** when deployment policy enables unsafe provider trust and the caller has the dedicated permission; saving them requires explicit confirmation and leaves a persistent warning. The configuration-first example intentionally uses discovery without overrides.
179184

180185
The role IDs in `defaultRoleIds` must exist, and the actor applying persisted equivalents must be authorized to assign them. They apply only when `create-user` creates a new user. The optional matcher-based policy selects one deployed `IExternalUserMatcher`; v1 ships no Elsa verified-email matcher, and matchers never select roles or permissions.
181186

src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/Connections/Contracts/IExternalAuthenticationConnectionsApi.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,37 @@ public interface IExternalAuthenticationConnectionsApi
2626
Task<ExternalAuthenticationConnection> EnableAsync(string connectionId, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);
2727

2828
[Post("/external-authentication/connections/{connectionId}/disable")]
29-
Task<ExternalAuthenticationConnection> DisableAsync(string connectionId, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);
29+
Task<ExternalAuthenticationConnection> DisableAsync(
30+
string connectionId,
31+
[Header("If-Match")] string ifMatch,
32+
[Query] bool confirmFinalLoginPathOverride = false,
33+
[Query] bool revokeActiveSessions = false,
34+
CancellationToken cancellationToken = default);
3035

3136
[Delete("/external-authentication/connections/{connectionId}")]
32-
Task<ExternalAuthenticationConnection> ArchiveAsync(string connectionId, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);
37+
Task<ExternalAuthenticationConnection> ArchiveAsync(
38+
string connectionId,
39+
[Header("If-Match")] string ifMatch,
40+
[Query] bool confirmFinalLoginPathOverride = false,
41+
CancellationToken cancellationToken = default);
3342

3443
[Post("/external-authentication/connections/{connectionId}/restore")]
3544
Task<ExternalAuthenticationConnection> RestoreAsync(string connectionId, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);
3645

3746
[Post("/external-authentication/connections/{connectionId}/validate")]
3847
Task<ValidateExternalAuthenticationConnectionResponse> ValidateAsync(string connectionId, CancellationToken cancellationToken = default);
3948

40-
[Put("/external-authentication/connections/{connectionId}/secret-bindings/{fieldName}")]
41-
Task<ExternalAuthenticationConnection> ReplaceSecretBindingAsync(string connectionId, string fieldName, [Body] SaveExternalAuthenticationSecretBindingRequest request, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);
49+
[Post("/external-authentication/connections/{connectionId}/test")]
50+
Task<TestExternalAuthenticationConnectionResponse> TestAsync(string connectionId, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);
51+
52+
[Post("/external-authentication/connections/{connectionId}/preview")]
53+
Task<InitiateExternalAuthenticationPreviewResponse> InitiatePreviewAsync(string connectionId, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);
54+
55+
[Get("/external-authentication/previews/{previewHandle}")]
56+
Task<ExternalAuthenticationPreviewResult> GetPreviewResultAsync(string previewHandle, CancellationToken cancellationToken = default);
57+
58+
[Put("/external-authentication/connections/{connectionId}/secret-bindings/{fieldName}/managed")]
59+
Task<ExternalAuthenticationConnection> ReplaceManagedSecretAsync(string connectionId, string fieldName, [Body] SaveManagedExternalAuthenticationSecretRequest request, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);
4260

4361
[Delete("/external-authentication/connections/{connectionId}/secret-bindings/{fieldName}")]
4462
Task<ExternalAuthenticationConnection> RemoveSecretBindingAsync(string connectionId, string fieldName, [Header("If-Match")] string ifMatch, CancellationToken cancellationToken = default);

src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/Connections/Models/ExternalAuthenticationConnection.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public sealed class ExternalAuthenticationConnection
1313
public ExternalAuthenticationConnectionScope Scope { get; set; } = new();
1414
public string AdapterType { get; set; } = "";
1515
public Uri? CallbackUri { get; set; }
16+
public Uri? PreviewCallbackUri { get; set; }
1617
public int AdapterSettingsVersion { get; set; }
1718
public JsonElement AdapterSettings { get; set; }
1819
public Dictionary<string, ExternalAuthenticationSecretBindingState> SecretBindings { get; set; } = new(StringComparer.Ordinal);
@@ -21,6 +22,7 @@ public sealed class ExternalAuthenticationConnection
2122
public int Order { get; set; }
2223
public bool IsPreferred { get; set; }
2324
public bool OverridesConfigurationConnection { get; set; }
25+
public bool CanCreateOverride { get; set; }
2426
public bool EnabledIntent { get; set; }
2527
public bool EffectivelyEnabled { get; set; }
2628
public string Validity { get; set; } = "";
@@ -37,16 +39,15 @@ public sealed class ExternalAuthenticationConnection
3739

3840
public sealed class ExternalAuthenticationConnectionScope
3941
{
40-
public string Kind { get; set; } = "tenant";
42+
public string Kind { get; set; } = "host";
4143
public string? TenantId { get; set; }
4244
}
4345

4446
public sealed class ExternalAuthenticationSecretBindingState
4547
{
46-
public string ResolverType { get; set; } = "";
47-
public string Reference { get; set; } = "";
48-
public string? ExpectedType { get; set; }
49-
public string? ExpectedScope { get; set; }
48+
public string Ownership { get; set; } = "";
49+
public string? ResolverType { get; set; }
50+
public string? Reference { get; set; }
5051
public bool IsConfigured { get; set; }
5152
public bool IsResolvable { get; set; }
5253
}

0 commit comments

Comments
 (0)