Login providers
How GitHub and Google sign-in run through the DMS backend, which account a provider identity resolves to, and the helpers for modules that create accounts themselves.
When auth.oauth enables GitHub or Google, the auth screens gain a "Continue with …" button above the email form. This page describes what happens after the user presses it. The password flows are on Built-in authentication.
The backend owns the round-trip
The browser-facing layer carries only the redirect and the state cookie. The instance exchanges the authorization code itself, and the client secret never leaves it. The provider vouches for the identity that comes back, not the frontend.
The OAuth API endpoints accept only the frontend server. They require a relay secret derived from the instance jwtSecret, which the layer receives through server-side options. The layer that set the state cookie runs the browser-binding CSRF check. The relay secret guarantees that no other caller can stand in for that layer. A module that composes its own OAuth entry flow presents the same secret, read from runtimeConfig.dms.oauth.relaySecret in its server routes.
That guarantee holds only while the frontend server is the one thing that receives the secret. A production frontend reads it from its own environment, DMS_OAUTH_RELAY_SECRET (The server's secrets), and provider login fails while it is unset. In development, ajs dms dev receives it with the manifest, which frontend.bootstrapSecret protects. Without frontend.bootstrapSecret, the backend withholds every private module option.
Once the identity resolves to an account, the flow rejoins the login branches unchanged: requires_tenant_assignment, requires_2fa, or a session. A provider login on an account with 2FA enabled still runs the challenge.
Which account a provider identity resolves to
The DMS applies these rules in order:
- A provider account already bound to a user always resolves to that user. Provider emails change, and provider account ids do not.
- An account holding the same email is adopted only when the provider marks that email verified and the local account is validated. The instance must also keep
linkByVerifiedEmailon, its default. Any other case is refused, never linked silently. - An unvalidated account holding that email is never adopted as it stands, because nobody proved they own it. An invitation addressed to that email reclaims it instead, unless the account already holds a seat in a workspace. Reclaiming wipes its password, recovery token, auth key, second factor and provider bindings, so nothing the previous holder set survives. A password signup overwrites an unvalidated account the same way.
- An unknown identity creates an account only when
allowAccountCreationis on or the flow carries a valid invitation, and only on a provider-verified email. An invitation is already the permission to join, as it is for a password signup. It is consumed only when the provider's email matches the invited email. The new account has no usable password. Password recovery is how such a user sets one later. - No account is created before the platform has its owner, which the onboarding wizard creates. The wizard creates the owner only while the platform has no user, so an earlier account would leave the platform with no owner.
A refusal answers 403 with one of these codes:
| Code | Refused because |
|---|---|
error.oauth.email_not_verified | The provider does not mark the email verified. |
error.oauth.account_not_validated | The matching local account is unvalidated, and no invitation can reclaim it. |
error.oauth.linking_disabled | linkByVerifiedEmail is off. |
error.oauth.registration_disabled | The identity is unknown, allowAccountCreation is off, and the flow carries no invitation. |
error.oauth.platform_not_onboarded | The identity is unknown, and the platform has no owner yet because onboarding is not complete. |
Bind an identity from your own module
Bindings live in their own user_external_identities table, one row per provider and provider account. @antelopejs/interface-dms/auth exports linkExternalIdentity() and getExternalIdentities() for modules that create accounts themselves and bind the identity inside their own transaction.
A product integration that connects the same provider for another purpose, such as repository access or deployments, keeps its own storage. These rows mean only "this person can sign in as that account".
Announce a registration
A module that owns its own registration flow, such as a SaaS transactional signup, calls announceRegistration(userModel, user, tenantId) once its provisioning has committed. @antelopejs/interface-dms/auth exports it.
The call runs everything that must happen when an account has joined a tenant: the welcome and collaborator notifications, then the USER_REGISTERED hook (Backend services). The DMS password signup and a provider login that consumes an invitation make the same call, so automations never depend on how the account arrived. The call only reports. Registration is already complete when it runs, and it logs a failing hook subscriber instead of failing the request.