Authentication
The one way into the platform: Dynapps single sign-on (Microsoft Entra ID over SAML 2.0 or OpenID Connect, with Entra groups deciding roles). Covers the SSO provider model, reason codes, the sign-in audit table, operator recovery from the CLI, sessions, JWTs, and route protection.
On this page
TL;DR: There is exactly one way into the platform in a browser, and it stores no password. Everyone signs in with Dynapps SSO (Microsoft Entra ID, over SAML 2.0 or OpenID Connect) and their Entra groups decide what they can do. There is no second login form, no self-registration, and no in-browser invite redemption; operator recovery happens from the CLI.
The directory is the authority. A Dynapps-Systemprompt-* group in Entra ID
is the entitlement; the platform reads it at every sign-in and never stores a
credential of its own for SSO users. The desktop bridge is not an exception:
linking a device sends you through the same browser sign-in, and the link code
it returns is only minted for a session SSO already created.
Being the authority means the directory can also take things away. A sign-in refused because the Entra group is gone does not merely fail — it revokes the sessions and bridge credentials that sign-in's owner already holds. See Deprovisioning.
Path 1 — Dynapps Users: Dynapps SSO
This is how everyone who is not a platform operator signs in.
The provider model
Sign-in is run by core's SSO driver (SsoDriver). A provider only
speaks its protocol: it builds the redirect to the identity provider and
validates what comes back. Everything around that — state correlation, replay,
the identity policy, provisioning, the session and the audit row — belongs to
the driver, so every provider gets the same guarantees and the same failure
codes.
Two providers are registered in this installation, both against Microsoft Entra ID. A provider whose config is disabled or incomplete is simply not registered; run one or the other against the same Entra groups.
| Provider id | Protocol | Config | Start | Callback | Metadata |
|---|---|---|---|---|---|
entra-saml |
SAML 2.0 (form-POST return) | services/web/config/adfs.yaml |
/admin/auth/adfs/start |
POST /admin/auth/adfs/acs |
/saml/metadata |
entra-oidc |
OpenID Connect, code flow with PKCE (query return) | services/web/config/entra-oidc.yaml |
/admin/auth/entra/start |
GET /admin/auth/entra/callback |
— |
Core also mounts every registered provider generically at
/api/v1/core/sso/{id}/start, /api/v1/core/sso/{id}/callback (query or
form-POST) and /api/v1/core/sso/{id}/metadata. The /admin/auth/* and
/saml/metadata paths above are aliases onto the same driver, kept because
they are the URLs the Entra ID applications were registered with. The
difference is only in failure handling: the aliases always send a refused or
unconfigured sign-in to /admin/login?sso=<reason>.
The login page shows Sign in with Dynapps SSO (Microsoft Entra ID) when
entra-saml is registered and Sign in with Microsoft when entra-oidc
is; a ?redirect= on the login page is carried through the start.
What happens on a sign-in
Start. The driver asks the provider for its redirect, then stores the
attempt server-side in sso_state_bindings: the provider, the post-login
destination, the protocol nonces (SAML request id; OIDC nonce and PKCE
verifier), and a hash of a fresh binder value. The raw state and binder
are never stored — both are keyed by HMAC-SHA-256 with the at-rest pepper. The
row lives 600 seconds and is single-use. The binder goes to the
browser as the sp_sso_bind cookie (HttpOnly, Path= the provider's
callback, Max-Age=600, Secure unless the API is served over plain
http). SAML uses SameSite=None, because Entra ID posts the ACS
cross-site; OIDC uses SameSite=Lax. The post-login destination must be a
same-origin relative path; anything else falls back to /admin.
Callback. The driver, for every provider:
- Correlates the return: the
state(OIDC) orRelayState(SAML) must match an unconsumed, unexpired row for this provider, and thesp_sso_bindcookie must match the row's binder. A return with no state is accepted only when the provider allows unsolicited sign-ins — SAML'sallow_idp_initiated(My Apps tile); OIDC never does. - Lets the provider validate the response. SAML: XML signature against the
tenant's signing certificate from its federation metadata, audience,
destination, validity window, and the request id it answers. OIDC: an
RS256
id_tokenfrom the tenant's JWKS, issuer, audience,tid, expiry and nonce. - Requires a subject, then claims the assertion id in the replay guard
(
sso_replay_guard, scoped per provider) until the assertion expires — one hour if the provider gives no expiry. A second sight of the same assertion is refused. - Requires an email and lowercases it.
- Applies the provider's identity policy (
SsoIdentityPolicy, the same for both protocols): email domain, then group overage, then the group requirement, then group → role mapping. See Groups as entitlements. - Resolves the local user by
(issuer, subject); if there is no link and the policy setslink_by_email(both shipped configs do), links a verified, allow-listed email to the active, interactive local account that already owns it, so an operator created withadmin users createsigns in without a duplicate account. Otherwise, ifauto_provisionis on, provisions one. An inactive or non-interactive (service) account is refused. - Writes the resolved roles onto the account (see below), mints a
session, and sets it as the
access_tokencookie. - Records the outcome in
sso_login_eventsand runs the installation's sign-in sink: theloginactivity and the directory group and project memberships (services/web/config/groups.yaml), replacing the directory-sourced rows from the last sign-in (source = 'adfs') and leaving dashboard-granted ones alone. An Entra group that maps to nothing leaves the person in the derivedunassignedgroup.
Roles are overwritten on every SSO sign-in. This installation's role
resolver hands core's driver the policy's roles — the mapped roles, or
default_roles ([user]) when no group maps — joined with the roles an admin
granted by hand in the dashboard (user_manual_roles), and the driver writes
that set to users.roles before minting the session. A role granted only by
systemprompt admin users role promote|assign is written to users.roles
alone, not to user_manual_roles, and is therefore replaced at that
person's next SSO sign-in (it survives only if the policy resolves no roles
at all, i.e. default_roles: [] and no group maps). A sign-in that removes
every admin-minting role revokes the account's older sessions and tokens
before the new session is minted. Grant a role
that must survive SSO from /admin/roles or the user's detail page, or map it
from an Entra group.
Admin is a permission, not just a role. The session token's permissions
come from permissions_for_roles: only the admin role mints the admin
permission (alongside user); every other role, platform_admin included,
mints user alone. Map platform_admin together with admin, as the shipped
configs do ([platform_admin, admin, user]).
Reason codes
Every refused sign-in lands on /admin/login?sso=<reason>. The code is
deliberately coarse; the finer reason is on the audit row.
?sso= |
Audit reason |
Cause | Message shown on the login page |
|---|---|---|---|
unavailable |
unavailable |
Provider not registered (disabled, incomplete config, OIDC client secret missing) or its IdP metadata/discovery is unreachable | Single sign-on is not configured on this instance yet. |
correlation_lost |
state_lost |
State missing, expired (600 s), already used, or the sp_sso_bind cookie does not match |
Sent a sign-in this platform did not start. Start again. |
correlation_lost |
unsolicited |
A return with no state, for a provider that does not accept unsolicited sign-ins | (same) |
invalid_assertion |
invalid_assertion |
Signature, audience, destination, issuer, tenant, nonce or validity failed — or the user cancelled at Entra ID | Refused or cancelled the sign-in, or returned a response that could not be verified. |
invalid_assertion |
replayed |
The assertion id was already used | (same) |
no_subject |
no_subject |
No subject (SAML NameID, OIDC sub) |
Did not identify your account. |
no_email |
no_email |
No email claim (and, for SAML, no email-format NameID; for OIDC, no address in preferred_username) |
Did not return an email address. |
forbidden |
forbidden |
Email domain not in allowed_email_domains |
Your account is not on an email domain this platform accepts. |
group_overage |
group_overage |
Entra ID sent an overage pointer instead of the group list | Your account is in too many Entra ID groups. |
no_group |
no_group |
No group at all and deny_without_group: true |
Your account is not in a Systemprompt-* Entra ID group. |
not_provisioned |
not_provisioned |
No linked account and auto_provision off, or the account is inactive or a service account |
No account exists for you yet. |
error |
error |
Anything else — the server log has the detail | Sign-in failed or the sign-in page expired. |
The messages live in storage/files/js/pages/admin-login.js; an unknown code
shows the error message.
The sign-in audit table
Every attempt — success or refusal — is one row in sso_login_events:
occurred_at, provider_id, issuer, outcome (success or refused), reason (refusals only, the
audit reason above), user_id (successes only), email, external_sub, ip
and groups_count. A refused sign-in is as visible as an accepted one.
-- Refusals in the last day, by provider and reason
SELECT provider_id, reason, count(*)
FROM sso_login_events
WHERE outcome = 'refused' AND occurred_at > now() - interval '1 day'
GROUP BY 1, 2 ORDER BY 3 DESC;
-- One person's recent attempts
SELECT occurred_at, provider_id, outcome, reason, ip, groups_count
FROM sso_login_events
WHERE email = 'jane@dynapps.es'
ORDER BY occurred_at DESC LIMIT 20;
Run them with systemprompt infra db query "<sql>" (see
systemprompt infra db --help).
Groups as entitlements
Two things read the Entra groups:
- Roles —
group_rolesin the provider's config (adfs.yamlorentra-oidc.yaml) maps a group toplatform_admin,admin,project_manager,developer,knowledge_workerand/oruser(group_role_patternsadds glob matches such asDynapps-Systemprompt-PM-*). The directory half of the role set is re-projected on every sign-in. What each role opens is on Create & Manage Users → Roles. - Membership —
services/web/config/groups.yamlmaps a group onto a platform group (who someone is, and what workspace they reach) and a project (what work their usage is attributed to). Both are database rows an admin can also edit from the dashboard.
Groups and projects are the two narrowest shared bands of the access-control
precedence ladder: person 0 → project 140 → group 150 → linked account 170 →
role 200. The narrowest band that names a person decides, and an entity with
rules is closed to everyone they do not name. Every grant — marketplaces
included — is declared in services/access-control/rules.yaml; see
Access Control.
A directory change is observed at the next sign-in, and until then an
existing session keeps what it was given, bounded by the session TTL
(security.jwt_access_token_expiration; 30 days on the production profile).
The exceptions are the ones that revoke: losing the group that grants admin
and a refused sign-in both tear down the person's credentials, and disabling
the account from the console is enforced on every JWT-bearing request within
about 30 seconds. The full table is in
Lifecycle.
There is no SCIM endpoint and no single logout, so nothing observes a
change between sign-ins.
Linking a desktop bridge is not a second login: the device-link approval runs inside an SSO session and mints a device credential from it.
Operator Recovery — CLI Only
SSO is the only browser door. That makes the directory a dependency: if Entra ID is unreachable, nobody signs in through the web UI. Recovery is therefore deliberately out-of-band and does not run through the login page at all.
Operators are created out-of-band and there is no way to self-provision one.
The self-registration endpoint and the public invite-redemption page have both
been removed — not disabled behind a flag, removed — so no browser request can
create an account. Accounts are provisioned by operators or by SSO when a signed assertion passes the configured domain and group-claim gates and auto_provision is enabled.
# Create the account and grant the admin role
systemprompt admin users create --name "Jane" --email jane@dynapps.es
systemprompt admin users role promote jane@dynapps.es admin
For access when SSO itself is down, mint a CLI admin session directly against
the database rather than through the web UI. This, systemprompt admin bridge issue-code, and a personal access token minted from an existing admin session
are the platform's break-glass credentials: they are retained deliberately
so an Entra ID outage is not a lock-out, they are operator tools rather than a
second user-facing login, and every one of them needs either database access or
an SSO session that already exists.
systemprompt admin session switch production # or the profile you are recovering
systemprompt admin session login
The WebAuthn credential tables and the CLI's
admin users webauthn generate-setup-token command still exist for
out-of-band credential enrolment, but no page in this installation presents a
passkey challenge — nothing in the browser consumes one. Treat passkeys as
dormant infrastructure, not as a live sign-in path.
Roles are not carried in the session token. They are read from the user record on every request through a 30-second cache, so promoting or demoting an operator takes effect within half a minute — no sign-out, no waiting for a token to refresh. The OAuth scope minted into the JWT is fixed at issue time, so a change that widens the scope itself still needs a fresh sign-in.
An operator who also signs in through SSO does not keep a role granted only
from the CLI: each SSO sign-in replaces the account's roles with what the
directory maps (or user) joined with only the roles granted by hand in the
dashboard. Grant a break-glass operator's admin on /admin/roles (or map it
from an Entra group) if that operator also signs in through SSO. See
The provider model.
Configuring SSO
Each provider has one config file, and both embed the same identity policy
(allowed_email_domains, group_roles, group_role_patterns,
default_roles, deny_without_group, auto_provision):
services/web/config/adfs.yaml— SAML (entra-saml): the IdP federation metadata file or its URL (which supplies the signing certificate), the claim URIs,allow_idp_initiated. There is no client secret; our entity id and ACS URL derive from the profile's external URL.services/web/config/entra-oidc.yaml— OIDC (entra-oidc): tenant id, client id,redirect_path, scopes, the groups claim. The client secret is not in the file: it is read from the secrets store underentra_oidc_client_secret(see Secrets Management).
Set Up Dynapps SSO (Microsoft Entra ID) is the full procedure: what your Entra ID administrator registers, the claims, every config key, how groups become roles and per-entity entitlements, and certificate rotation.
Deprovisioning
Removing someone from their Dynapps-Systemprompt-* group is the whole deprovision
step. There is nothing else to click.
Blocking the next sign-in would not be enough on its own: a cookie session and a
linked bridge both outlive the assertion that created them, so a directory
change that only guarded the front door would leave a working desktop app behind
it. So when a sign-in is refused for a missing group, a group overage or an
off-list domain (no_group, group_overage, forbidden), and
when a returning sign-in's groups no longer map to admin, the platform revokes
that user's:
- browser sessions (
user_sessions), - bridge PATs (
user_api_keys) — the desktop app must be linked again, - enrolled device certificates,
- any unredeemed bridge device-link code.
Rows are marked revoked, never deleted: what someone held and when it was taken away is part of the audit trail.
One caveat worth knowing when you are timing a removal: a gateway token already minted stays valid for its hour. Revocation stops the next one, so treat "the bridge is dead" as true within the hour, not within the second. For an immediate cut, close the account.
Session Management
| Property | Value |
|---|---|
| Cookie name | access_token |
| Token format | JWT |
| Expiry | security.jwt_access_token_expiration in the profile, also the cookie's Max-Age; there is no default — the profile must set it. Production: 2,592,000 seconds (30 days) |
| Cookie flags | path=/, HttpOnly, SameSite=Lax, Secure on HTTPS |
| Required scopes | user or admin |
That expiry is the outer bound on a session, not the only thing that ends one. Every request also confirms the session row is still active and the user record is still active (the latter through a 30-second cache), so revocation and account disablement cut a session short well before the token expires. A bridge token minted from a PAT is separate and lives one hour.
Every admin request passes through two middleware layers. User context
middleware extracts and validates the JWT, then loads the user's roles and
project into a UserContext. Auth check middleware rejects protected
routes without a valid user ID, returning HTTP 401.
UserContext carries user_id, username, email, roles, group_ids,
project_ids, the caller's marketplace participation, and the derived flags
is_admin, is_console, is_platform_admin and is_developer.
To sign out, clear the access_token cookie.
Public vs. Protected Routes
| Route | Access |
|---|---|
/admin/login |
Public |
/admin/auth/adfs/*, /admin/auth/entra/*, /saml/metadata |
Public — the SSO start, callback and SP metadata |
/api/v1/core/sso/{id}/* |
Public — core's generic routes onto the same providers |
/admin/profile, /admin/history, /admin/connect, /admin/connectors |
Any valid session, including a plain user |
/admin/analysis/conversations, /admin/analysis/versions, /admin/sync and the session, request, trace and context detail pages |
A valid session with admin / platform_admin, or marketplace participation — scoped to the participant's marketplaces |
/admin/* (everything else) |
A valid session and a console role (admin or platform_admin) |
Admin JSON writes (/api/public/admin/* mutations) |
admin or platform_admin |
Adding or removing AD-group mappings on groups and projects (POST/DELETE /api/public/admin/{groups,projects}/{id}/ad-mappings) |
platform_admin only |
/bridge-auth/* |
Requires a valid session — the desktop link runs through SSO |
/admin/login and the SSO routes above are the entire public surface. There is
no public registration, credential-enrolment, or invite-redemption route; if you
are adding one, you are adding a second way in.
Anonymous requests to a protected route are redirected to
/admin/login?redirect=…. A signed-in user without a console role is not
shown a 403 for console pages — they are redirected to /admin/profile, unless
the page is one their marketplace participation opens. JSON admin API routes
return HTTP 403 instead. The role model, including participation, is on
Create & Manage Users → Roles.
System-originated actions
Every action recorded by the platform — including scheduled jobs, hooks, and MCP-server invocations — traces to a real users row. There is no separate "system user" or synthesized principal. The platform refuses to attribute work to an invented identity.
How ownership is declared
Each scheduled job in services/scheduler/config.yaml carries an explicit owner: field naming an existing admin user:
- name: publish_pipeline
extension: web
owner: admin
schedule: "0 */15 * * * *"
enabled: true
At startup the scheduler resolves owner: to a users.id. If the named user does not exist or is inactive, startup fails loudly — the platform refuses to run with unowned jobs. To change ownership, edit the YAML and restart.
How attribution flows
The resolved owner becomes JobContext.actor for every execute() call. Job implementations consume it through ctx.actor() and pass it to any audit-row write. Governance audit rows carry three fields that together give full forensic clarity:
| Column | Meaning |
|---|---|
user_id |
The accountable principal — a real users.id. |
actor_kind |
The surface that ran the action: user, job, mcp. |
actor_id |
A label for that surface (job name, MCP server name, etc.). |
A direct human action shows as (user_id = alice, actor_kind = 'user', actor_id = 'alice'). A scheduled job owned by Alice shows as (user_id = alice, actor_kind = 'job', actor_id = 'publish_pipeline'). Same accountability column, different surface, queryable separately:
SELECT actor_kind, user_id, COUNT(*)
FROM governance_decisions
GROUP BY actor_kind, user_id;
Why no separate "system" user
A dedicated "system" identity would be either a synthesized principal (impersonation) or a backdoor account with no real human accountability. Neither passes the "every action traces to a real user" bar. The designated owner is a normal admin who legitimately authorized the platform's existence by installing it — same accountability model as a unix crontab. Compromising the designated owner is exactly as bad as compromising that admin's credentials directly; there is no additional power and no amplification path.
Service accounts and machine credentials
A workflow that writes back to an external system (opening a GitHub pull request, updating a Salesforce record, commenting in Jira) acts as a service account, never as a shared global key. A service account is a users row with account_kind = 'service'. Core refuses every interactive login for it: session login, passkey, bridge code and OAuth authorization code. Each workflow definition names the account it runs as, and an agent farm names one with run_as in services/web/config/agent-farms.yaml: its runs mint their credentials for that account and carry the purpose farm:<run_id>.
Administrators manage them at People & access → Service accounts (/admin/identity/service-accounts):
- Create the account with a name and an email. It starts with the plain
userrole. - Attach a machine credential per provider, keyed by the MCP server id (
github,salesforce-<org>,atlassian):
| Kind | Non-secret identifiers | Secret | What the broker returns |
|---|---|---|---|
github_app |
App ID, installation ID | App private key (PEM) | An installation token: core signs a 10-minute RS256 App JWT and exchanges it at /app/installations/{id}/access_tokens. |
salesforce_client_credentials |
Instance URL, consumer key (client ID) | Client secret | An access token from the org's client_credentials grant. |
atlassian_api_token |
Token owner email | API token | Basic base64(email:token). |
The secret is sealed with the platform master key in an envelope bound to the account and provider. It is never rendered or returned, and attaching again rotates it. Only a service account may hold a machine credential, and an account that still holds one cannot be deleted.
When core's MCP client asks the credential broker (/api/public/connectors/{provider}/token) for a service account's bearer, the broker requires the x-systemprompt-caller purpose core forwards from the run. It issues a token only for workflow:<definition_id>:write or farm:<id>. Any other purpose, or none, is refused with 403 and logged under the connector_broker target with the account, provider and caller. The broker does not mint anything itself. It opens the sealed secret and hands it to core's machine grant (connector_grant::MachineGrant), the same code that serves core-managed connectors declared with grant: app_installation or client_credentials, so there is one App JWT signer, one token exchange and one token cache. Core caches a minted token until two minutes before it expires; the cache key includes the credential's rotation time, so a rotated credential never serves the previous token. People keep the per-user OAuth path unchanged.
GitHub's hosted MCP server is declared in services/mcp/github.yaml for this purpose. No plugin enables it. services/access-control/rules.yaml grants it, and the Atlassian and Salesforce servers, to the service-accounts group that every new service account joins; among people only administrators reach GitHub.