[data-reveal]{opacity:1!important;transform:none!important}
CMS Interfaces

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;
}
FieldDescription
providerProvider identifier, as declared in the instance's OAuth configuration.
providerAccountIdImmutable account identifier on the provider side. Never the email: users change their provider email without changing account.
emailEmail 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.

FieldTypeDescription
_idstring<provider>:<providerAccountId>. Being the primary key is what makes the binding unique.
userIdstringOwning user. Indexed, related to User.
providerstringProvider identifier.
providerAccountIdstringAccount identifier on the provider side.
emailstringEmail reported at the last successful login. Account resolution never reads it back.
createdAtDateSet on insert. Indexed.
lastLoginAtDateRefreshed 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:

MethodSignatureDescription
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.