Skip to main content
Omniflow speaks OpenID Connect generically. An operator supplies a discovery document and client credentials, and no provider-specific branch exists anywhere in the application: admin_oidc_providers and customer_oidc_providers are data, and adding Google or Keycloak or a corporate identity provider is a row, not a release. An installation with no row authenticates with passwords (operators) and Telegram (customers), which is the default and fully supported posture. There are two independent registries, because the two surfaces have different threat models and different defaults. An operator account moves money and reads other people’s records; a customer account is somebody’s own subscription. The two never share a provider row, a session cookie, or a flow cookie. The auto-provisioning defaults differ on purpose. An operator panel that hands an account to anyone a provider accepts is a privilege-escalation path, so the default is to refuse an unknown subject. A customer panel that refuses everyone who has not already used the bot is not a sign-in method, it is a second lock.
Both registries need APP_DATA_ENCRYPTION_KEY — a base64-encoded 32-byte AES key. It seals the stored client secret and the short-lived sign-in flow cookie. Without a valid key the identity services refuse to start.

Operator OIDC

What it is and why

Optional OpenID Connect sign-in for /admin, using Authorization Code with PKCE (S256). The ID token is verified against the provider’s published JWKS, its issuer, its audience, and its expiry before anything about it is trusted. PKCE is used even though this is a confidential client, because it binds the authorization code to the request that started the flow: a code intercepted at the redirect cannot be redeemed by anyone else. The anti-forgery state and the PKCE verifier travel in a sealed, short-lived cookie rather than a database row, so an abandoned sign-in leaves nothing to clean up and a provider that never redirects back costs nothing. The matching policy is the part worth understanding. An unlinked subject never adopts an existing operator account by matching its email address. If it did, anyone who can make a provider assert an address could take over an operator account. Only the (provider, subject) pair identifies an operator.

Configuring a provider

There is no panel screen for operator OIDC in this build. The endpoints are implemented and permission-gated; /admin/settings renders commerce settings, installation settings, and customer sign-in providers only. Configure the provider with an authenticated /v1/panel call — you need a session cookie and the matching X-CSRF-Token, both of which you can read from GET /v1/panel/auth/session.
Plain HTTP is refused for both the issuer and the discovery URL. Auto-provisioning into owner is refused outright, because it would let anyone the provider accepts take full control of the installation. Changing an operator provider is owner-only: PUT and DELETE need settings.write and admins.roles, and only the owner role holds the latter. A provider decides who becomes which operator — and, with auto-provisioning, with which role — so it is the same authority as granting roles. An administrator who could edit one could point it at an issuer they control. DELETE /v1/panel/settings/oidc/{slug} removes the provider and cascades its linked identities. Saving and deleting both write audit rows (admin.oidc.provider_saved, admin.oidc.provider_deleted, category configuration).
The issuer and discoveryUrl of a provider that operators are linked through cannot be changed: the save answers 409 oidc_provider_bound. To move to a different identity provider, delete the provider (which drops its bindings) and create a new one; each operator then links again. See Issuer pinning.

The redirect URI

Register this with the provider, with <slug> replaced by the value you chose:
Omniflow rebuilds this URL from the incoming request’s Host header in one place, because it must match byte for byte between the authorization request and the token exchange. The scheme is https unless APP_ADMIN_COOKIE_SECURE is false and the request did not arrive over TLS — the local-development case. A deployment behind a reverse proxy must therefore forward the original Host; see Reverse proxy.

Signing in

1

List the enabled providers

GET /v1/panel/auth/oidc answers {"items":[{"slug","displayName"}]} and needs no credential — it runs before anyone is signed in.
2

Start the flow

Navigate the browser to GET /v1/panel/auth/oidc/{slug}/start?next=/admin/finance. The API seals {slug, state, verifier, next} into the __Host-omniflow_oidc cookie — omniflow_oidc on a stack without TLS, since a browser discards a __Host- cookie that is not Secure — (lifetime 10 minutes) and answers 302 to the provider. next is accepted only as a path under /admin: it must start with / but not //, may contain no backslash and no control character (browsers read \ as / and strip tabs and newlines, so /\evil.example would otherwise leave the site), must parse with no scheme or host, and its cleaned path must be /admin or below it. Anything else is dropped and the callback lands on /admin.
3

Return through the callback

The provider redirects to /v1/panel/auth/oidc/{slug}/callback. The flow cookie is cleared before the token exchange, so a replayed callback cannot be processed twice. state is compared in constant time. On success the session cookie is set and the browser lands on next, defaulting to /admin.
/admin/login does not render OIDC buttons in this build; it never calls GET /v1/panel/auth/oidc. Reaching the provider means navigating to the /start URL directly — a bookmark, or a link from your own intranet page. An OIDC sign-in issues a complete session: pending_totp is false and the session records auth_methods = {oidc}. The provider is trusted to have applied its own second factor, so Omniflow does not ask for a TOTP code on top of it.

What happens when it is refused

Every failure lands on /admin/login?error=<reason>, so the panel can explain the outcome without the API leaking anything to the provider’s redirect. With auto-provisioning on, a subject whose address already belongs to an operator is still refused — the address is never used to adopt an account. An account created this way has no password at all (password_hash is null) and authenticates through the provider only; the creation writes the audit row admin.oidc.auto_provisioned and posts a notice to the 🔐 Admin security topic in the operator Telegram group.

Limits of the operator flow in this build

These are properties of the current code, not configuration you can change:
  • No nonce is issued or checked. PKCE binds the code to the flow; the customer flow additionally binds the ID token to the flow with a nonce, and the operator flow does not.
  • An existing operator cannot link a provider to their account. The linking routine exists in the service but no route calls it, so the only way to a linked identity is auto-provisioning, which creates a new account.
  • Disabling or deleting a provider does not revoke the sessions it established. Those sessions survive until their own deadlines — up to the 12-hour absolute lifetime. To end them now, suspend the operator accounts concerned, which revokes their live sessions immediately.
  • Discovery is fetched per flow and never cached, so a rotated JWKS or a moved endpoint needs no restart.

Customer OIDC

What it is and why

The same generic flow for /account, with one difference that is data rather than code: several providers may be enabled at once and the sign-in screen renders them all. The customer flow issues and verifies a nonce in addition to PKCE — PKCE binds the authorization code to this flow, and the nonce binds the resulting ID token to it, which PKCE alone does not do. Without it, a token obtained elsewhere for this client could be replayed into this callback.

Configuring a provider

Open /admin/settings → Customer sign-in providers (needs settings.read to see it, settings.write to change it).
1

Start from a preset, or from an empty form

Three presets ship with the panel. A preset is data that prefills the form — nothing downstream ever asks which preset a provider came from.Yandex defaults to off because it does not consistently return email_verified; requiring the claim would refuse every sign-in. Discord issues an ID token only when openid is requested, and the email scope is what carries the address.
2

Fill in the credentials

slug, displayName, issuer, discoveryUrl, clientId, clientSecret, scopes, enabled, icon, sortOrder, requireVerifiedEmail, allowAutoProvision. The icon is the name of an icon the panel ships, matched against ^[a-z0-9][a-z0-9-]{0,38}$, never a URL — enabling a provider must not make the sign-in page fetch from a third-party host.
3

Register the redirect URI with the provider

For Google, add it to the OAuth client’s authorised redirect URIs before enabling the provider.
4

Save

The form posts PUT /v1/panel/settings/customer-oidc. The client-secret field is always blank when the form loads and an empty field means keep what is stored, so an operator can re-save a provider without re-typing the secret.
Disabling a provider also ends every customer session it established, with revoked_reason = 'provider_disabled'. An operator who has just turned a provider off during an incident should not still have its customers signed in for up to the 60-day absolute session lifetime. Deleting a provider does the same.
Removing a provider asks you to type its slug to confirm, and the dialog states both consequences before you do: the provider stops being offered, and the sessions it established end. Deleting a provider deliberately leaves the identities rows alone. They are the record that a customer once signed in that way, and removing them would silently take a sign-in method away from customers who may have no other one. While any customer has an active identity through a provider, its issuer and discoveryUrl cannot be changed — the save is refused with oidc_rejected and a message saying so. Moving customers to a different identity provider means deleting this one and creating a new one; the old bindings stay pinned to the old issuer and never match the new one (see Issuer pinning), so affected customers sign in another way and link the new provider from Sign-in methods.

How a customer signs in

/account/sign-in calls GET /v1/account/auth/methods and renders only what the installation actually offers: the Telegram widget, the bot-delivered link if it is enabled, and one button per enabled OIDC provider. Each button links to GET /v1/account/auth/oidc/{slug}/start; the provider returns to /v1/account/auth/oidc/{slug}/callback, which sets the session cookie and redirects to next, defaulting to /account. next follows the same rule as the operator flow, scoped to /account: a value outside it, or one containing a backslash or a control character, is dropped. Failures redirect to /account/sign-in?error=<reason> with reasons sign_in_expired, sign_in_denied, unverified_email, account_unavailable, and sign_in_failed. An unlinked subject at a provider with auto-provisioning turned off is sign_in_failed — deliberately the same wording as any other rejection, so the screen cannot be used to ask whether an identity is registered.

Linking a provider to an existing account

A signed-in customer opens /account/security → Sign-in methods and starts the flow from GET /v1/account/sign-in-methods/oidc/{slug}/start. The account to link to is sealed into the flow cookie, not read from the session on the callback, so a link flow can never complete against a different account than the one that started it. The callback returns to /account/security?linked=<slug>, or ?error=identity_taken|unverified_email|link_failed. A subject that already belongs to somebody else is identity_taken and is never merged. Two customers are two customers, and quietly joining them would move one person’s orders, wallet, and subscription into another person’s account with no way back. Linking a subject that is already linked to this same customer is a no-op, so a double submission is harmless.

The last-sign-in-method guard

DELETE /v1/account/sign-in-methods/{identityID} refuses with 409 last_sign_in_method when it would remove the customer’s only remaining method. A customer who removes their last method has locked themselves out of an account holding a paid subscription, and nothing in the operator panel can let them back in. The rule is about the count of remaining methods, not which one: a customer with Telegram plus Google may drop either, and a customer with only one may drop neither. The count is taken inside the same transaction that performs the removal, so two tabs unlinking two different methods at once cannot both observe “two remain” and both succeed. Only telegram and oidc:* identities with status active count. Removal is a revoke, not a delete: the row stays with status='revoked'. It also requires a recent sign-in — 401 reauthentication_required otherwise — and writes an identity_unlinked row to the customer’s own security log with the provider slug and nothing else. The panel disables the Remove button and says why before the attempt rather than after it.

Claim mapping

Both surfaces map claims the same way, and the mapping is deliberately narrow. The email is never used to find an account. Only (provider, subject) is. Two consequences follow directly, and both are intentional: an unlinked subject cannot take over an account by asserting its address, and an email change at the provider is not an identity change and needs no recovery path at all.

Issuer pinning

A sub is only unique within one issuer. Every OIDC binding therefore records the issuer that asserted it — admin_oidc_identities.issuer for operators, identities.oidc_issuer for customers — and a sign-in matches a binding only when the verified ID token’s iss equals that recorded value. The provider row’s current issuer is not what counts: if it were, editing a provider to point at an issuer somebody else runs would let that issuer mint the subject of any linked operator or customer and sign in as them, without a second factor. A subject that is linked at a different issuer is refused outright — for an operator as oidc_no_account, for a customer as sign_in_failed, and for a customer linking from Sign-in methods as identity_taken. It is never auto-provisioned, because the (provider, subject) pair is taken. Bindings created before issuer pinning were backfilled with their provider’s issuer at upgrade time; a customer binding whose provider row had already been deleted has no recorded issuer and matches nothing. Customer OIDC subjects join the single identities table under provider = "oidc:<slug>". Two providers may legitimately issue the same sub, and the table’s UNIQUE (provider, subject) has to keep meaning “one external account, one customer”. Sessions established this way record auth_method = 'oidc' and auth_provider = <slug>; the table enforces that the two agree. Nothing else from the token is retained for authorization. The raw claim payload an identity provider returned is listed among the declared redactions of a customer’s personal-data export — it is not this installation’s to redistribute. See Customer privacy.

How the client secret is sealed

A client secret is a credential that spends on the operator’s behalf at somebody else’s service, so it is stored the way every other credential in Omniflow is.
  • It is sealed with AES-GCM under APP_DATA_ENCRYPTION_KEY before it reaches the database, so a database dump alone yields no usable secret.
  • Each field gets its own associated-data label — admin.oidc.client_secret for the operator registry, customer.oidc.client_secret for the customer one, admin.flow and customer.flow for the sign-in flow cookies. A ciphertext moved between columns fails to open rather than decrypting somewhere it does not belong.
  • It is never returned. Both list endpoints report hasClientSecret and nothing more, which is the only safe rendering of a secret.
  • On save, an empty clientSecret leaves the stored one untouched. That is what makes the form re-saveable: an operator changing sortOrder does not have to produce the secret again, and the screen never has to echo one.
  • That only holds while the provider still points where the secret was entered for. A save that changes issuer, discoveryUrl, or clientId without a new clientSecret is refused (422 oidc_rejected, with a message asking for the secret), and the storage layer drops a stored secret whose provider was moved. Otherwise whoever edits the address could collect the secret at a token endpoint they run.
The flow cookie is sealed with the same key and is not readable or forgeable by the browser that holds it. It carries state, the PKCE verifier, the customer flow’s nonce, the next path, and — for a link flow — the account being linked to.
Rotating APP_DATA_ENCRYPTION_KEY makes every sealed value unopenable: OIDC client secrets, operator TOTP secrets, and customer contact values. Treat it as permanent for the life of the installation and back it up separately from the database.

Admin panel

Operator sessions, roles, two-factor, and the audit trail.

Security

The whole posture: credentials, sessions, secret sealing, and headers.

Customer web panel

Where a customer manages sign-in methods, sessions, and their own history.

Configuration

APP_DATA_ENCRYPTION_KEY and the rest of the environment reference.