elsa-core/specs/012-external-authentication/contracts/rest-api.md
2026-07-29 12:13:06 +02:00

25 KiB

REST API Contract: External Authentication

All paths are relative to Elsa's configured API route prefix (for example, /elsa/api). JSON uses camel case. Timestamps use RFC 3339 UTC. IDs are opaque strings.

Common Response Rules

Error

{
  "error": "flow_expired",
  "message": "The sign-in attempt expired. Start again.",
  "correlationId": "01JZ..."
}

Public categories:

  • invalid_request
  • method_unavailable
  • authentication_failed
  • identity_unlinked
  • flow_expired
  • flow_changed
  • access_denied
  • rate_limited
  • temporarily_unavailable
  • server_error

Management APIs may additionally use not_found, validation_failed, conflict, forbidden, and precondition_failed, with a safe details object containing field errors or current revision. Provider response bodies and tenant/user existence details never appear.

Status Codes

Status Use
200 Successful read, update, action, or token response
201 Created resource
204 Successful action with no body
302 / 303 Browser redirect from authorization/logout flow
400 Invalid request or validation
401 Missing/invalid client or Elsa authentication
403 Authenticated caller lacks permission
404 Resource or safe public method not available
409 Uniqueness, lifecycle, or concurrency conflict
412 If-Match revision does not match
429 Rate limited; includes Retry-After
503 Safe temporary provider/store failure

Concurrency

Connection detail responses include:

ETag: "17"

Every database-owned mutation requires If-Match: "17". Configuration-owned resources reject mutation with 403 and category forbidden.

Anonymous and Broker APIs

Discover Login Methods

GET /external-authentication/login-methods?clientId=elsa-studio-wasm

The server derives tenant context from trusted host middleware, route context, invitation, or registered client context—not an arbitrary tenant query parameter.

{
  "methods": [
    {
      "id": "local",
      "key": "local",
      "kind": "local",
      "displayName": "Elsa account",
      "iconId": "elsa",
      "order": 0,
      "isPreferred": false,
      "initiationUrl": "/elsa/api/external-authentication/local/authorize"
    },
    {
      "key": "contoso",
      "kind": "external",
      "displayName": "Contoso",
      "iconId": "building",
      "order": 10,
      "isPreferred": true,
      "initiationUrl": "/elsa/api/external-authentication/authorize/contoso"
    }
  ]
}

Response contains no authority, adapter type, upstream client identifier, tenant identifier, health, secret, or remote asset URL. Cache-Control: no-store. isPreferred affects ordering/emphasis only; clients MUST NOT automatically redirect.

Initiate External Sign-in

GET /external-authentication/authorize/{connectionKey}
    ?client_id=elsa-studio-wasm
    &redirect_uri=https%3A%2F%2Fstudio.example%2Fauthentication%2Fexternal%2Fcallback
    &response_type=code
    &code_challenge={base64url}
    &code_challenge_method=S256
    &return_path=%2Fworkflows

Success: 302 to the provider. Elsa stores protected correlation state and never forwards return_path to the provider except inside server-owned state.

Failures redirect only to the exact registered client callback with safe error and correlation_id when the request is sufficiently valid to trust that callback; otherwise return JSON error.

Initiate Broker-local Sign-in

POST /external-authentication/local/authorize
Content-Type: application/json
{
  "clientId": "elsa-studio-wasm",
  "redirectUri": "https://studio.example/authentication/external/callback",
  "responseType": "code",
  "codeChallenge": "{base64url}",
  "codeChallengeMethod": "S256",
  "returnPath": "/workflows",
  "username": "admin",
  "password": "..."
}

Success: 200.

{
  "redirectUri": "https://studio.example/authentication/external/callback?code={opaque}&state={clientState}"
}

The client navigates only after confirming the returned URI matches its own registered origin/path. Invalid username, invalid password, missing Local Credential, or unknown user all return 401 authentication_failed.

This route is additive. Existing /identity/login remains unchanged.

Provider Callback

GET /external-authentication/callback/{connectionKey}?code={providerCode}&state={opaqueState}

Adapters MAY register a POST callback variant when their protocol requires it. The route's immutable Connection Key resolves to the record ID protected in broker state; both must match. Success: 302 to the exact Authentication Client callback:

https://studio.example/authentication/external/callback?code={elsaCode}&state={clientState}

No provider or Elsa token appears in the URL.

Exchange Authorization Code

POST /external-authentication/token
Content-Type: application/x-www-form-urlencoded
Origin: https://studio.example
grant_type=authorization_code
&code={opaque}
&client_id=elsa-studio-wasm
&redirect_uri=https%3A%2F%2Fstudio.example%2Fauthentication%2Fexternal%2Fcallback
&code_verifier={verifier}

Confidential clients additionally authenticate with HTTP Basic client credentials resolved from deployment configuration. Public clients must not send or be required to hold a client secret.

{
  "accessToken": "{elsa-jwt}",
  "tokenType": "Bearer",
  "expiresIn": 3600,
  "refreshToken": "{opaque}",
  "refreshExpiresIn": 7200,
  "externalSessionExpiresIn": 28800
}

The completion code is consumed atomically whether exchange succeeds or fails after validation. Replay returns 400 invalid_request.

Refresh External Session

POST /external-authentication/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token={opaque}
&client_id=elsa-studio-wasm

Response has the same token shape and always rotates refreshToken. Reuse of a superseded refresh token revokes the External Authentication Session and returns 401 access_denied.

Begin Logout

POST /external-authentication/logout
Authorization: Bearer {elsa-access-token}
Content-Type: application/json
{
  "clientId": "elsa-studio-wasm",
  "postLogoutRedirectUri": "https://studio.example/authentication/external/logout-callback",
  "mode": "local"
}

mode is local or upstream. Upstream is allowed only by adapter capability and connection mode.

{
  "completed": false,
  "navigationUrl": "/elsa/api/external-authentication/logout/continue/{opaqueHandle}",
  "redirectUri": null
}

When local logout is complete with no provider navigation, completed is true and redirectUri is the exact registered post-logout callback. The one-time continuation owns provider redirect. Provider callback:

GET /external-authentication/logout/callback/{connectionKey}?state={opaqueState}

It redirects only to the exact registered post-logout callback.

Descriptor APIs

All descriptor APIs require external-authentication:connections:read.

GET /external-authentication/descriptors/adapters
GET /external-authentication/descriptors/policies
GET /external-authentication/descriptors/user-matchers
GET /external-authentication/role-options?search=&cursor=&pageSize=50

Field descriptor:

{
  "name": "discoveryUrl",
  "displayName": "Discovery URL",
  "description": "Exact OpenID Connect discovery-document URL.",
  "valueType": "uri",
  "uiHint": "uri",
  "isRequired": true,
  "isSecretBinding": false,
  "isUnsafe": false,
  "defaultValue": null,
  "allowedValues": [],
  "visibleWhen": null,
  "validation": {
    "maximumLength": 2048,
    "pattern": null
  }
}

Adapter descriptor:

{
  "type": "openid-connect",
  "displayName": "OpenID Connect",
  "description": "Authenticate through an OpenID Connect provider.",
  "settingsVersion": 1,
  "fields": [],
  "capabilities": ["test", "preview", "upstream-logout"],
  "customEditor": null
}

customEditor, when present, contains key and contractVersion. A missing/incompatible editor falls back to generic fields.

Connection Management

List

GET /external-authentication/connections
    ?search=
    &source=configuration|database
    &adapterType=
    &enabled=
    &valid=
    &shadowed=
    &archived=
    &cursor=
    &pageSize=100

Requires external-authentication:connections:read. Maximum pageSize is 100.

{
  "items": [
    {
      "id": "01JZCONNECTION",
      "key": "contoso",
      "source": "database",
      "overridesConfigurationConnection": true,
      "canCreateOverride": false,
      "canPromoteToConfigurationOverride": false,
      "adapterType": "openid-connect",
      "callbackUri": "https://elsa.example/elsa/api/external-authentication/callback/contoso",
      "previewCallbackUri": "https://elsa.example/elsa/api/external-authentication/previews/callback/01JZCONNECTION",
      "displayName": "Contoso",
      "iconId": "building",
      "order": 10,
      "isPreferred": true,
      "enabledIntent": true,
      "effectivelyEnabled": true,
      "validity": "valid",
      "shadowed": false,
      "archived": false,
      "revision": 17,
      "materialRevision": "m-9",
      "latestObservation": {
        "status": "succeeded",
        "observedAt": "2026-07-24T12:00:00Z",
        "testedMaterialRevision": "m-9",
        "isStale": false,
        "category": "reachable",
        "summary": "Discovery and signing keys were validated."
      }
    }
  ],
  "nextCursor": null
}

Create

POST /external-authentication/connections

Requires external-authentication:connections:create.

{
  "key": "contoso",
  "scope": {"kind":"host"},
  "adapterType": "openid-connect",
  "displayName": "Contoso",
  "iconId": "building",
  "order": 10,
  "isPreferred": false,
  "adapterSettingsVersion": 1,
  "adapterSettings": {},
  "overridesConfigurationConnection": false,
  "unlinkedPolicy": {"type":"reject","settingsVersion":1,"settings":{}},
  "permissionGrantSources": [],
  "claimProjection": {
    "allowedClaimTypes": ["name", "email", "groups"],
    "redactedClaimTypes": ["email"]
  },
  "upstreamLogoutMode": "disabled"
}

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

Create Database Override

POST /external-authentication/connections

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.

Connection responses expose canCreateOverride for configuration-owned connections when deployment policy permits creating a database override. They expose canPromoteToConfigurationOverride for an unarchived, shadowed database-owned connection when the same policy permits promotion. Clients promote that existing record by updating its ordinary document with "overridesConfigurationConnection": true; this preserves its ID, secret bindings, and enabled lifecycle instead of creating another database record. A promotion that would remove the final normal sign-in path returns 409 conflict with error="conflict" and details.code="final_login_path_guard".

Detail and Update

GET /external-authentication/connections/{connectionId}
PUT /external-authentication/connections/{connectionId}

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

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:

{
  "secretBindings": {
    "clientSecret": {
      "ownership": "managed",
      "resolverType": null,
      "reference": null,
      "isConfigured": true,
      "isResolvable": true
    }
  }
}

No generation fingerprint or value is returned.

Advanced OpenID Connect Provider Trust

For an OpenID Connect connection, adapterSettings MAY include explicit overrides for discovery-derived trust inputs:

{
  "advancedTrustOverrides": {
    "issuer": "https://login.contoso.example",
    "authorizationEndpoint": "https://login.contoso.example/oauth2/authorize",
    "tokenEndpoint": "https://login.contoso.example/oauth2/token",
    "signingKeys": {
      "keys": []
    }
  }
}

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.

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.

Secret Binding Replacement/Removal

PUT /external-authentication/connections/{connectionId}/secret-bindings/{fieldName}/managed
DELETE /external-authentication/connections/{connectionId}/secret-bindings/{fieldName}

Requires external-authentication:connections:update and If-Match.

{
  "resolverType": "elsa-secrets",
  "value": "write-only-secret-value"
}

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.

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.

Lifecycle Actions

POST /external-authentication/connections/{connectionId}/enable
POST /external-authentication/connections/{connectionId}/disable?confirmFinalLoginPathOverride=false&revokeActiveSessions=false
DELETE /external-authentication/connections/{connectionId}?confirmFinalLoginPathOverride=false
POST /external-authentication/connections/{connectionId}/restore

All require If-Match.

revokeActiveSessions=true additionally requires external-authentication:sessions:revoke and emits an aggregate, redacted session-revocation security notification.

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.

Action Permission
Enable/disable external-authentication:connections:update
Archive/restore external-authentication:connections:archive

Enable returns 400 validation_failed unless structurally valid with resolvable required bindings. Restore returns disabled.

Validate

POST /external-authentication/connections/{connectionId}/validate

Requires read permission. Performs local structural and binding-state validation without provider traffic.

{
  "valid": false,
  "errors": [
    {"field": "secretBindings.clientSecret", "code": "required", "message": "Client secret is required."}
  ],
  "warnings": []
}

Test

POST /external-authentication/connections/{connectionId}/test

Requires external-authentication:connections:test and If-Match. Runs provider traffic using the exact revision and upserts the latest shared observation.

Preview

POST /external-authentication/connections/{connectionId}/preview
GET /external-authentication/previews/{previewHandle}/authorize
GET /external-authentication/previews/{previewHandle}
GET /external-authentication/previews/callback/{connectionId}?code={providerCode}&state={opaqueState}

Requires external-authentication:connections:preview. POST requires If-Match and returns:

{
  "navigationUrl": "/elsa/api/external-authentication/previews/{previewHandle}/authorize",
  "expiresAt": "2026-07-24T12:10:00Z"
}

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.

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.

Bounded User Lookup

GET /external-authentication/user-options?search=&cursor=&pageSize=25

Requires external-authentication:links:manage. Tenant comes from authenticated context. Maximum page size is 50.

{
  "items": [
    {"id": "user-1", "displayName": "workflow-admin"}
  ],
  "nextCursor": null
}

No password, role, permission, external-link, or cross-tenant data is returned.

List/Create/Replace/Delete

GET /external-authentication/identity-links?userId=&connectionKey=&cursor=&pageSize=100
POST /external-authentication/identity-links
POST /external-authentication/identity-links/{linkId}/replace
DELETE /external-authentication/identity-links/{linkId}

Requires external-authentication:links:manage.

Create:

{
  "userId": "user-1",
  "connectionKey": "contoso",
  "issuer": "https://login.contoso.example",
  "subject": "00u1abcd..."
}

Replace accepts the same body. It atomically removes the identified tenant-scoped link and creates a new link with a new ID and createdAt; lastSignedInAt is reset to null. If the original link or a requested user/connection cannot be resolved, it returns 404 with {"error":"not_found","message":"The requested resource was not found."}. If the requested tuple belongs to any other link, including one for the same user, it returns 409 conflict. Validation, not-found, and conflict responses leave the original link unchanged.

Manual create and replace operations accept effective, nonarchived connections even when they are disabled or invalid. Shadowed, archived, and cross-tenant definitions remain unavailable.

The subject is accepted only over TLS, normalized and immediately transformed to the stored keyed hash; it is never returned. Duplicate tuple-to-same-user is idempotent 200; tuple-to-different-user is 409 conflict. A successful replace returns 201 with the new link resource. Delete requires explicit Studio confirmation and returns 204.

Role Lifecycle Guard

Elsa Identity remains the owner of Role deletion. Its ordinary endpoint:

DELETE /identity/roles/{roleId}

MUST consult installed Role-deletion dependency contributors. If any CreateUser or matcher no-match CreateUser defaultRoleIds reference remains, including in disabled, archived, shadowed, or ineffective definitions, it returns 409 conflict with details.code=role_referenced_by_jit_policy, includes the current deletion-impact model in details.deletionImpact, and does not delete the Role.

Authorized clients can inspect the same stable dependency snapshot:

GET /identity/roles/{roleId}/deletion-impact
{
  "roleId": "workflow-user",
  "dependencyVersion": "opaque-role-dependencies-17",
  "executionMode": "bestEffort",
  "canDelete": false,
  "canRemediate": false,
  "configurationReferences": [
    {
      "connectionKey": "contoso-workforce",
      "policyBranch": "matcher-no-match-create-user",
      "configurationPath": "Elsa:ExternalAuthentication:Connections:0:DefaultRoleIds:0",
      "removesLastDefaultRole": false
    }
  ],
  "editableReferences": [
    {
      "connectionId": "01JZCONNECTION",
      "connectionKey": "partner-login",
      "policyBranch": "create-user",
      "revision": 17,
      "removesLastDefaultRole": true
    }
  ],
  "warnings": ["removes_last_default_role"]
}

Configuration paths are sanitized metadata, never values. Configuration references make canRemediate=false and cannot be auto-mutated. The response includes every database/configuration definition, not only the effective registry entry.

When preflight reports only editable references, the client MAY invoke:

POST /identity/roles/{roleId}/remove-from-jit-policies-and-delete
{
  "expectedDependencyVersion": "opaque-role-dependencies-17",
  "confirmRemoveFromEditableJitPolicies": true,
  "confirmEmptyDefaultRoles": true,
  "confirmBestEffort": false
}

Both endpoints require delete:role; remediation additionally requires external-authentication:connections:update and external-authentication:roles:assign, with authorization against every affected connection. The confirmation to remove references is always required. confirmEmptyDefaultRoles=true is required if any affected CreateUser branch would retain no default Role. Preflight returns executionMode=bestEffort when the stores cannot share a transaction, in which case confirmBestEffort=true is also required.

The command revalidates the dependency version, connection revisions, permissions, and configuration blockers before mutation. Atomic mode removes all editable references and deletes the Role in one unit of work. Best-effort mode removes references first and deletes the Role only after current reinspection reports zero dependencies. If any removal or reinspection fails, the Role remains; the API returns 409 conflict with details.code=role_remediation_incomplete, changed/remaining connection IDs, current dependency version, and no policy values. The operation is idempotently retryable. Success returns 204 and emits a redacted security notification.

External Sessions

Available only when session administration is enabled.

GET /external-authentication/sessions?userId=&connectionKey=&status=&cursor=&pageSize=100
DELETE /external-authentication/sessions/{sessionId}

Read requires external-authentication:sessions:read; revoke requires external-authentication:sessions:revoke. Responses contain session ID, user ID, tenant, Connection Key, started/last refreshed/expires/revoked times, and safe status—never token hashes, subject, or claim snapshot.

Revoke accepts an optional JSON body such as {"reason":"administrator_revoked"}. The server bounds the safe reason and returns 204; Studio uses the stable administrator-revoked category.

Permission Names

  • external-authentication:connections:read
  • external-authentication:connections:create
  • external-authentication:connections:update
  • external-authentication:connections:archive
  • external-authentication:connections:test
  • external-authentication:connections:preview
  • external-authentication:provider-trust:unsafe
  • external-authentication:policies:manage
  • external-authentication:roles:assign
  • external-authentication:links:manage
  • external-authentication:sessions:read
  • external-authentication:sessions:revoke
  • delete:role (Identity-owned Role deletion and deletion-impact inspection)

API authorization is authoritative. Studio menu and disabled-state logic are usability affordances only.