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

Tokens and Sessions

The interface issues four kinds of token, and it records a login as a row in the `sessions` table.

Overview

The interface issues four kinds of token, and it records a login as a row in the sessions table. Three of the four come with an exported validator; the access token is validated by the authentication decorators instead.

TokenGenerated byValidated byCarries
AccessgenerateAccessTokenThe authentication decorators in Authenticationid, tenantId, optional sessionId
RefreshgenerateRefreshTokenvalidateRefreshTokenid, tenantId, optional sessionId
Two-factorgenerateTwoFactorTokenvalidateTwoFactorTokenid, tenantId, a purpose marker
Tenant assignmentgenerateTenantAssignmentTokenvalidateTenantAssignmentTokenid, a purpose marker

Every generator takes the User it signs for, and every validator loads that user back before it returns. All seven are interface functions, so every call returns a promise.

Access and refresh tokens are the pair a client holds: the access token goes in the Authorization header of ordinary requests, and the refresh token buys a new access token when it expires. Two-factor and tenant-assignment tokens are short-lived hand-offs that stand between a password check and a real session.

Issue an Access and Refresh Token Pair

generateAccessToken(
  tenantId: string,
  user: User,
  sessionId?: string,
): Promise<{ expiresIn: number; token: string }>

generateRefreshToken(
  tenantId: string,
  user: User,
  sessionId?: string,
): Promise<{ expiresIn: number; token: string }>
ParameterDescription
tenantIdThe tenant the token is scoped to. It lands in the token's tenantId claim and is what the request's tenant resolution reads back.
userThe User the token is issued for. The user's authKey takes part in the signing secret, so rotating authKey invalidates every token previously issued to that user.
sessionIdThe _id of the Session row that records this login. Optional: a token issued without one is valid but is not tied to a revocable session. An access token issued with one is refused as soon as that row is deleted.

Both return the signed token and its lifetime as expiresIn in milliseconds, taken from the deployment's accessTokenLifetime and refreshTokenLifetime configuration.

import {
  createSession,
  generateAccessToken,
  generateRefreshToken,
} from "@antelopejs/interface-dms/auth";
import type {
  SessionModel,
  User,
} from "@antelopejs/interface-dms/auth/db";

export async function startSession(
  sessionModel: SessionModel,
  user: User,
  tenantId: string,
  userAgent: string,
  ip: string,
) {
  const sessionId = await createSession(sessionModel, user._id, userAgent, ip);

  const access = await generateAccessToken(tenantId, user, sessionId);
  const refresh = await generateRefreshToken(tenantId, user, sessionId);

  // store the token's hash on the session so it can be matched and revoked
  await sessionModel.replaceRefreshToken(sessionId, refresh.token);

  return {
    token_type: "Bearer",
    access_token: access.token,
    expires_in: access.expiresIn,
    refresh_token: refresh.token,
  };
}

Validate a Refresh Token

validateRefreshToken(
  token: string,
): Promise<{ id: string; tenantId: string; sessionId?: string }>

The function decodes the token, loads the user it names, and verifies the signature against that user. It throws HTTPResult(401) when the user no longer exists or the signature does not check out. It returns the claims only — confirming that the session still exists, that the token is still its current one (isCurrentRefreshToken), and that the user still has access to tenantId is the caller's job.

import { HTTPResult } from "@antelopejs/interface-api";
import {
  generateAccessToken,
  validateRefreshToken,
} from "@antelopejs/interface-dms/auth";
import {
  isCurrentRefreshToken,
  type SessionModel,
  type UserModel,
} from "@antelopejs/interface-dms/auth/db";

const UNAUTHORIZED = 401;

export async function refresh(
  userModel: UserModel,
  sessionModel: SessionModel,
  token: string,
) {
  const payload = await validateRefreshToken(token);

  const user = await userModel.get(payload.id);
  if (!user) throw new HTTPResult(UNAUTHORIZED, "error.invalid_token");

  if (payload.sessionId) {
    const session = await sessionModel.get(payload.sessionId);
    if (!session || !isCurrentRefreshToken(session, token)) {
      throw new HTTPResult(UNAUTHORIZED, "error.session_expired");
    }
    await sessionModel.update(payload.sessionId, { lastActiveAt: new Date() });
  }

  const access = await generateAccessToken(
    payload.tenantId,
    user,
    payload.sessionId,
  );
  return { access_token: access.token, expires_in: access.expiresIn };
}

Because the returned payload names a tenantId, the same call backs a tenant switch: validate the refresh token, check the user's membership of the target tenant, then issue a new pair for that tenant instead.

Hand Off to a Second Factor

When a user's credentials check out but the account has second factors enabled, issue a two-factor token instead of a session and let the client come back with the code.

generateTwoFactorToken(
  tenantId: string,
  user: User,
): Promise<{ token: string; expiresIn: number }>

validateTwoFactorToken(token: string): Promise<{
  id: string;
  tenantId: string;
  purpose?: string;
  user: User;
}>

validateTwoFactorToken rejects a token that was not issued for the two-factor purpose, and resolves the User for you so the verification route can read twoFactorMethods, twoFactorSecret and the rest without a second lookup. Whatever is wrong with the token (not a JWT, unknown user, bad signature, expired, another purpose), it throws the same HTTPResult(401, "error.invalid_2fa_token"), so a caller cannot tell which user ids exist. expiresIn is the token's lifetime in milliseconds.

import { createHash } from "node:crypto";
import {
  generateTwoFactorToken,
  send2FAEmail,
  validateTwoFactorToken,
} from "@antelopejs/interface-dms/auth";
import type {
  User,
  UserModel,
} from "@antelopejs/interface-dms/auth/db";

export async function beginTwoFactor(tenantId: string, user: User) {
  const { token, expiresIn } = await generateTwoFactorToken(tenantId, user);
  return {
    requires_2fa: true,
    two_factor_token: token,
    expires_in: expiresIn,
    methods: user.twoFactorMethods,
  };
}

export async function requestEmailCode(
  userModel: UserModel,
  twoFactorToken: string,
  code: string,
) {
  const { user } = await validateTwoFactorToken(twoFactorToken);

  // Stored hashed, the way the DMS's own verification route reads it back.
  user.twoFactorEmailCode = createHash("sha256")
    .update(JSON.stringify([user._id, code]))
    .digest("hex");
  user.twoFactorEmailCodeRequestedAt = new Date();
  await userModel.update(user);

  await send2FAEmail(user, code);
}

Once the code checks out, the route issues a real access and refresh pair with the tenantId the two-factor token carried. send2FAEmail and the other mail entry points are covered in Users.

Hand Off Tenant Assignment

A user whose credentials are valid but who belongs to no tenant yet cannot receive an access token, because an access token is always scoped to a tenant. generateTenantAssignmentToken covers that gap: it signs the user without a tenant claim so an onboarding flow can pick or create one.

generateTenantAssignmentToken(
  user: User,
): Promise<{ token: string; expiresIn: number }>

validateTenantAssignmentToken(token: string): Promise<{ id: string; user: User }>

validateTenantAssignmentToken rejects a token that was not issued for the tenant-assignment purpose and returns the user id together with the loaded User. Like the two-factor check, every refusal is the same HTTPResult(401, "Invalid tenant assignment token").

import {
  generateAccessToken,
  generateTenantAssignmentToken,
  validateTenantAssignmentToken,
} from "@antelopejs/interface-dms/auth";
import type { User } from "@antelopejs/interface-dms/auth/db";

export async function offerTenantAssignment(user: User) {
  const { token } = await generateTenantAssignmentToken(user);
  return { requires_tenant_assignment: true, tenant_assignment_token: token };
}

export async function completeTenantAssignment(
  assignmentToken: string,
  tenantId: string,
) {
  const { user } = await validateTenantAssignmentToken(assignmentToken);
  // the caller attaches the user to `tenantId` before issuing real tokens
  return generateAccessToken(tenantId, user);
}

The Token Payload

JwtUserPayload describes the claims an access or refresh token carries about its subject. Use it as the target type when you decode a token yourself.

export interface JwtUserPayload {
  id: string;
  tenantId: string;
  sessionId?: string;
}
FieldTypeDescription
idstringThe _id of the user the token was issued for.
tenantIdstringThe tenant the token is scoped to.
sessionIdstring | undefinedThe Session row backing the token, when the token was issued with one.

TenantTokenInput from Authentication names the same subject from the other side: it is what a validator receives — id and tenantId plus the undecoded rawToken — while JwtUserPayload is what the token itself says. The two overlap on id and tenantId; neither is a subset of the other.

Record a Login Session

createSession inserts a Session row for a login and returns its _id, ready to be passed as the sessionId of the token pair.

createSession(
  sessionModel: SessionModel,
  userId: string,
  userAgent: string,
  ip: string,
): Promise<string>
ParameterDescription
sessionModelThe SessionModel instance to insert through.
userIdThe _id of the user logging in.
userAgentThe request's User-Agent header. It is parsed into the browser, os and deviceType fields; the raw string is kept in userAgent.
ipThe caller's IP address, stored as-is.

The row starts with an empty refreshToken and location, and with createdAt and lastActiveAt set to the current time. Record the refresh token with sessionModel.replaceRefreshToken once you have generated it, as in the first example on this page.

The Session Table

Session is registered as the sessions table under the DMS core schema, so its rows are shared across tenants and its model is retrieved without a tenant instance. The class, its table-name constant and its model all live on the /db subpath.

import {
  SESSIONS_TABLE_NAME,
  Session,
  SessionModel,
} from "@antelopejs/interface-dms/auth/db";
FieldTypeDescription
_idstringPrimary key. This is the value that travels in the token's sessionId claim.
userIdstringThe owning user's _id. Indexed, and declared as a relation to the User table.
refreshTokenHashstring | nullsha256 of the refresh token currently valid for this session. Matching it on refresh is what makes a session revocable; the token itself is never stored.
sealedRefreshTokenstring | nullThe current refresh token encrypted under a key derived from its predecessor, so a client still holding the predecessor during the rotation grace window can be handed the current token. Unreadable without the predecessor.
userAgentstringThe raw User-Agent string captured at login.
ipstringThe IP address captured at login.
browserstringBrowser name and version parsed from userAgent, or "Unknown".
osstringOS name and version parsed from userAgent, or "Unknown".
deviceTypestringDevice class parsed from userAgent: "mobile", "tablet", or "desktop" when neither applies.
locationstringFree-form location label. createSession leaves it empty.
createdAtDateSet on insert by the creation-time decorator. Indexed.
lastActiveAtDateLast time the session was seen. Refresh handlers update it. Indexed: a daily job deletes the sessions last active longer ago than refreshTokenLifetime.

SESSIONS_TABLE_NAME holds the table name, "sessions".

isCurrentRefreshToken(session, token) tells whether a presented token is the session's current one, comparing hashes in constant time and still honouring a session written before hashing.

Query Sessions with SessionModel

SessionModel extends BasicDataModel(Session, SESSIONS_TABLE_NAME), so it has the standard get, getBy, getAll, insert, update and delete operations, plus the refresh-token operations and two lookups by user.

MethodSignatureDescription
replaceRefreshToken(sessionId: string, refreshToken: string) => Promise<void>Make a token the session's current one, storing only its hash.
rotateRefreshToken(sessionId: string, presentedToken: string, refreshToken: string, rotatedAt?: Date) => Promise<string | null>Atomically replace the presented token with a new one. A client presenting the immediate predecessor within the grace window gets the current token back; any other token gets null.
deleteInactiveSince(threshold: Date) => Promise<void>Delete the sessions last active before a date.
getByUserId(userId: string) => Promise<Session[]>Every session belonging to a user, read through the userId index.
deleteByUserId(userId: string) => Promise<void>Delete every session belonging to a user.
import { Controller, Delete, Get } from "@antelopejs/interface-api";
import { Model } from "@antelopejs/interface-database-decorators";
import { AuthUser } from "@antelopejs/interface-dms/auth";
import {
  SessionModel,
  type User,
} from "@antelopejs/interface-dms/auth/db";

export class SessionsController extends Controller("/api/sessions") {
  @AuthUser()
  declare user: User;

  @Model(SessionModel)
  declare sessionModel: SessionModel;

  @Get("")
  list() {
    return this.sessionModel.getByUserId(this.user._id);
  }

  @Delete("")
  revokeAll() {
    // drops every session; their access tokens stop working with them
    return this.sessionModel.deleteByUserId(this.user._id);
  }
}

Deleting a session stops refreshes from succeeding and cuts off the access tokens issued for it at once: the authentication decorators refuse an access token whose sessionId no longer names a session of its user. A token issued without a sessionId stays valid until it expires; to cut those off too, rotate the user's authKey as described in Users.

Next Steps

Continue with Users for the User table, UserModel, sanitizeUser, and the account mail entry points.