Tokens and Sessions
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.
| Token | Generated by | Validated by | Carries |
|---|---|---|---|
| Access | generateAccessToken | The authentication decorators in Authentication | id, tenantId, optional sessionId |
| Refresh | generateRefreshToken | validateRefreshToken | id, tenantId, optional sessionId |
| Two-factor | generateTwoFactorToken | validateTwoFactorToken | id, tenantId, a purpose marker |
| Tenant assignment | generateTenantAssignmentToken | validateTenantAssignmentToken | id, 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 }>
| Parameter | Description |
|---|---|
tenantId | The tenant the token is scoped to. It lands in the token's tenantId claim and is what the request's tenant resolution reads back. |
user | The 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. |
sessionId | The _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. |
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-private/cms/interfaces/cms-auth";
import type {
SessionModel,
User,
} from "@antelopejs-private/cms/interfaces/cms-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 refresh token on the session so it can be matched and revoked
await sessionModel.update(sessionId, { refreshToken: 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 stored refreshToken still matches, 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-private/cms/interfaces/cms-auth";
import type {
SessionModel,
UserModel,
} from "@antelopejs-private/cms/interfaces/cms-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 || session.refreshToken !== 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. expiresIn is the token's lifetime in milliseconds.
import {
generateTwoFactorToken,
send2FAEmail,
validateTwoFactorToken,
} from "@antelopejs-private/cms/interfaces/cms-auth";
import type {
User,
UserModel,
} from "@antelopejs-private/cms/interfaces/cms-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);
user.twoFactorEmailCode = code;
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.
import {
generateAccessToken,
generateTenantAssignmentToken,
validateTenantAssignmentToken,
} from "@antelopejs-private/cms/interfaces/cms-auth";
import type { User } from "@antelopejs-private/cms/interfaces/cms-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;
}
| Field | Type | Description |
|---|---|---|
id | string | The _id of the user the token was issued for. |
tenantId | string | The tenant the token is scoped to. |
sessionId | string | undefined | The 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>
| Parameter | Description |
|---|---|
sessionModel | The SessionModel instance to insert through. |
userId | The _id of the user logging in. |
userAgent | The request's User-Agent header. It is parsed into the browser, os and deviceType fields; the raw string is kept in userAgent. |
ip | The 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. Write the refresh token back with sessionModel.update 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-private/cms/interfaces/cms-auth/db";
| Field | Type | Description |
|---|---|---|
_id | string | Primary key. This is the value that travels in the token's sessionId claim. |
userId | string | The owning user's _id. Indexed, and declared as a relation to the User table. |
refreshToken | string | The refresh token currently valid for this session. Matching it on refresh is what makes a session revocable. |
userAgent | string | The raw User-Agent string captured at login. |
ip | string | The IP address captured at login. |
browser | string | Browser name and version parsed from userAgent, or "Unknown". |
os | string | OS name and version parsed from userAgent, or "Unknown". |
deviceType | string | Device class parsed from userAgent: "mobile", "tablet", or "desktop" when neither applies. |
location | string | Free-form location label. createSession leaves it empty. |
createdAt | Date | Set on insert by the creation-time decorator. Indexed. |
lastActiveAt | Date | Last time the session was seen. Refresh handlers update it. |
SESSIONS_TABLE_NAME holds the table name, "sessions".
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 two lookups by user.
| Method | Signature | Description |
|---|---|---|
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-private/cms/interfaces/cms-auth";
import {
SessionModel,
type User,
} from "@antelopejs-private/cms/interfaces/cms-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; pair it with a new authKey to kill live access tokens
return this.sessionModel.deleteByUserId(this.user._id);
}
}
Deleting a session stops refreshes from succeeding, but access tokens already handed out stay valid until they expire. To cut those off immediately, 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.