Skip to main content

Identities

WIP

The account row, typing your own profile, provider links, email verification, soft delete and erasure, bulk import, and GDPR export

An identity is an account: one row with your profile fields, the provider logins linked to it, and whether its address was proven. auth.identities reads and writes it.

The row

FieldMeaning
idGenerated by the store.
profileYour fields. email and username are required and unique; everything else is yours.
providersLinked logins, each { providerId, providerSub, addedAt, addedBy }, such as oauth:google and Google's sub.
emailVerifiedWhether the address was proven by a link or a verified IdP, not merely typed.
versionOptimistic-concurrency counter; updateProfile takes it and refuses a stale write.
deletedAtSet by a soft delete; the row reads as absent while it is set.
createdBy, updatedBy, deletedByThe actor each write was attributed to, or null.

Typing the profile

The profile type flows from the engine's first type parameter:

import { MemoryAdapter } from '@gentleduck/auth/adapters/memory'
import { createAuth } from '@gentleduck/auth/core'

type Profile = { email: string; username: string; name: string; locale?: string }

const auth = createAuth<Profile>({
  baseUrl: 'http://localhost:3000',
  stores: new MemoryAdapter<Profile>(),
})

const ada = await auth.identities.create({ profile: { email: 'ada@example.com', username: 'ada', name: 'Ada' } })
ada.profile.name // string

The serialized profile is capped at 16 KiB by default; set identities: { profileMaxBytes } to change it, or 0 to remove the cap.

Reading

await auth.identities.getById(id)
await auth.identities.getByEmail('ada@example.com').orNull()
await auth.identities.getByProviderSub('oauth:github', '583231').orNull()

Each answers an Answer: awaiting it throws AUTH_IDENTITY_NOT_FOUND for a missing or soft-deleted row, and .orNull() answers null instead. Email lookups are case-insensitive.

Writing

const ada = await auth.identities.create({
  profile: { email: 'ada@example.com', username: 'ada', name: 'Ada' },
  emailVerified: false,
})

await auth.identities.updateProfile(ada.id, { name: 'Ada Lovelace' }, ada.version)
  • create emits signup.completed, and refuses a taken email or username with AUTH_EMAIL_TAKEN or AUTH_USERNAME_TAKEN.
  • Every write bumps version, a link included, so read the row again before an updateProfile that follows one.
  • updateProfile merges the patch. A patch that changes email clears emailVerified in the same write, since a new address is unproven.
  • markEmailVerified(id, email) sets the flag, and refuses once the identity no longer holds that address. flows.completeEmailVerification calls it for you.
await auth.identities.link(ada.id, { providerId: 'oauth:github', providerSub: '583231' })
await auth.identities.unlink(ada.id, 'oauth:github')

link refuses a provider the identity already holds, and a login another identity holds (AUTH_PROVIDER_TAKEN). unlink refuses to drop the last way in: the remaining links plus credentials such as a password must not reach zero. flows.linkProvider and flows.unlinkProvider wrap both for a signed-in user.

Deleting

CallEffect
softDelete(id)Hides the row for the grace period (7 days by default, identities.softDeleteGracePeriodMs).
restore(id)Clears a soft delete; after the window it throws AUTH_GRACE_EXPIRED.
erase(id, { reason, operatorId })Removes the row and everything under it for good, and emits identity.erased.
gc()Erases every row whose grace window has closed. Schedule it yourself.

A soft delete does not end sessions; flows.completeAccountDeletion does both, and mails an undo link if asked.

Bulk and export

const result = await auth.identities.bulkCreate(rows, { mode: 'skipExisting' })
// { created, skipped, failed }

const blob = await auth.identities.exportAll(ada.id, auth.cfg.stores.credentials, {}, { sessions: auth.cfg.stores.sessions })
// { identity, credentials, sessions, schemaVersion: '1', exportedAt }

bulkCreate matches existing rows by email: skipExisting passes over them, merge links the new logins onto them, replace erases and recreates them. exportAll is the GDPR right-of-access export, with every credential secret and session CSRF hash removed.

softDeleteMany, restoreMany, eraseMany, updateProfileMany, linkMany and unlinkMany do the same over a set.