External Identities
Overview
An external identity binds an account a user owns on a login provider — GitHub, Google, whichever the deployment configures — to a User row, so that signing in through that provider resolves to the same account every time. The binding is what the provider login flow reads; the provider account id, not the email, is what identifies it.
The two functions live on the root subpath, the table and its model on /db.
import {
getExternalIdentities,
linkExternalIdentity,
} from "@antelopejs-private/cms/interfaces/cms-auth";
import {
USER_EXTERNAL_IDENTITIES_TABLE_NAME,
UserExternalIdentity,
UserExternalIdentityModel,
buildExternalIdentityId,
} from "@antelopejs-private/cms/interfaces/cms-auth/db";
This surface is scoped to authentication. A provider connection that powers a product feature — repository access, deployments — owns its own storage and must never be derived from these rows: they say who the user is, not what the deployment may do on their behalf.
Bind a Provider Account
linkExternalIdentity(
userId: string,
identity: ExternalIdentityInput,
): Promise<ExternalIdentityLinkResult>
interface ExternalIdentityInput {
provider: string;
providerAccountId: string;
email: string;
}
interface ExternalIdentityLinkResult {
wasCreated: boolean;
}
| Field | Description |
|---|---|
provider | Provider identifier, as declared in the instance's OAuth configuration. |
providerAccountId | Immutable account identifier on the provider side. Never the email: users change their provider email without changing account. |
email | Email the provider reported, stored lowercased for support and audit only. |
wasCreated distinguishes a first binding from a refresh, which is what a login flow needs to decide whether the user just connected a new provider or simply signed in again.
import { linkExternalIdentity } from "@antelopejs-private/cms/interfaces/cms-auth";
const { wasCreated } = await linkExternalIdentity(user._id, {
provider: "github",
providerAccountId: profile.id,
email: profile.email,
});
The call is idempotent for the owning user: re-linking the same provider account only refreshes its email and lastLoginAt. Binding an account already owned by another user throws 409 with the message error.oauth.identity_already_linked — a provider account is a single-user credential.
Uniqueness is enforced by the row's deterministic primary key rather than by a secondary index, so two concurrent bindings of the same provider account collide instead of duplicating; the loser re-reads the row and either refreshes it or gets the 409.
List a User's Identities
getExternalIdentities(userId: string): Promise<UserExternalIdentity[]>
Returns an empty array for a user who only signs in with a password, which is how an account-settings screen decides whether to offer disconnecting a provider.
The UserExternalIdentity Table
Table user_external_identities in the DMS core schema, so its rows are shared across tenants like users. USER_EXTERNAL_IDENTITIES_TABLE_NAME holds the name.
| Field | Type | Description |
|---|---|---|
_id | string | <provider>:<providerAccountId>. Being the primary key is what makes the binding unique. |
userId | string | Owning user. Indexed, related to User. |
provider | string | Provider identifier. |
providerAccountId | string | Account identifier on the provider side. |
email | string | Email reported at the last successful login. Account resolution never reads it back. |
createdAt | Date | Set on insert. Indexed. |
lastLoginAt | Date | Refreshed on every successful login through this identity. |
buildExternalIdentityId(provider, providerAccountId) composes the id. Use it rather than formatting the string yourself, so a lookup by id keeps matching what linkExternalIdentity writes.
UserExternalIdentityModel
UserExternalIdentityModel extends BasicDataModel(UserExternalIdentity, USER_EXTERNAL_IDENTITIES_TABLE_NAME) and adds three methods:
| Method | Signature | Description |
|---|---|---|
getByProviderAccount | (provider: string, providerAccountId: string) => Promise<UserExternalIdentity | undefined> | The identity a provider account is bound to, or undefined when the account is unknown. |
getByUserId | (userId: string) => Promise<UserExternalIdentity[]> | Every identity bound to a user, read through the userId index. |
deleteByUserId | (userId: string) => Promise<void> | Drop every identity of a user. |
getByProviderAccount is the login-side lookup: resolve the provider profile to an identity, and its userId to the account to sign in.
import { GetModel } from "@antelopejs/interface-database-decorators";
import {
UserExternalIdentityModel,
UserModel,
} from "@antelopejs-private/cms/interfaces/cms-auth/db";
const identities = GetModel(UserExternalIdentityModel);
const identity = await identities.getByProviderAccount("github", profile.id);
const user = identity
? await GetModel(UserModel).get(identity.userId)
: undefined;
An unknown provider account means no account exists for it yet — hand the flow to signup or to invite redemption rather than creating one implicitly. deleteByUserId belongs to account deletion: leaving identities behind would let a recycled provider account resolve to a user row that is gone.