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

Authentication

Overview

Authentication turns an incoming HTTP request into a User row. The interface exposes one plain function, authenticateRequestUser, and four decorators built with CreateAuthDecorator from @antelopejs/interface-auth: AuthRawUser, AuthUser, IfAuthUser and AuthOwnerOnly.

All of them read the bearer token from the request's Authorization header (Authorization: Bearer <token>) and run the same two steps:

  1. Authenticator — decodes the token into a TenantTokenInput: the user id, the tenant id, and the undecoded token itself.
  2. Validator — loads the user named by that id, verifies the token signature against that user, and applies the extra requirement the decorator stands for.

Whatever the validator returns is what gets injected. The four decorators differ only in the requirement they enforce and in what they do when it fails.

The User type they inject comes from the /db subpath and is documented in Users. The tokens they consume are produced by the generators in Tokens and Sessions.

Require an Authenticated User

AuthUser injects the User and enforces email validation when the deployment's authentication configuration requires it. Use it for any route that must have a signed-in caller.

import { Controller, Get } from "@antelopejs/interface-api";
import { AuthUser } from "@antelopejs-private/cms/interfaces/cms-auth";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class ProfileController extends Controller("/api/profile") {
  @Get("")
  getProfile(@AuthUser() user: User): { id: string; email: string } {
    // 401 before the handler runs when the token is missing or invalid
    return { id: user._id, email: user.email };
  }
}

The injected value is the full User row, including fields you do not want to return over HTTP. Pass it through sanitizeUser before serializing it — see Users.

Skip the Email-Validation Gate

AuthRawUser resolves the same User but does not apply the email-validation requirement. It is what the routes that exist because the email is not validated yet need — reading the current account, logging out, requesting a new validation mail — and it is also what modules use to gate their own API routes on a signed-in caller.

import { Controller, Get, Post } from "@antelopejs/interface-api";
import {
  AuthRawUser,
  sendEmailValidationEmail,
} from "@antelopejs-private/cms/interfaces/cms-auth";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class AccountController extends Controller("/api/account") {
  @Get("/me")
  me(@AuthRawUser() user: User): { email: string; isValidated: boolean } {
    return { email: user.email, isValidated: user.isValidated };
  }

  @Post("/resend-validation")
  async resendValidation(@AuthRawUser() user: User): Promise<{ sent: true }> {
    // reachable while user.isValidated is still false
    await sendEmailValidationEmail(user);
    return { sent: true };
  }
}

Both decorators inject the raw database row; neither strips sensitive fields. "Raw" here refers to skipping the email-validation step, not to the shape of the object.

Accept Anonymous Callers

IfAuthUser is the optional variant. It injects User | undefined and does not reject the request: a missing token, a malformed token and a token that fails validation all resolve to undefined.

import { Controller, Get } from "@antelopejs/interface-api";
import { IfAuthUser } from "@antelopejs-private/cms/interfaces/cms-auth";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class ArticleController extends Controller("/api/articles") {
  @Get("")
  list(@IfAuthUser() user: User | undefined) {
    // a missing, malformed or expired token all land here as `undefined`
    return user
      ? { scope: "member", readerId: user._id }
      : { scope: "anonymous" };
  }
}

Because an expired token resolves to undefined rather than a 401, a client-side refresh interceptor keyed on 401 responses does not fire for a route guarded this way. When a route must hard-fail on an expired session, guard it with AuthUser and give the anonymous path its own route.

Restrict a Route to Platform Owners

AuthOwnerOnly requires the authenticated user to be a platform owner (User.owner === true). It answers 401 when no token is provided and 403 when the caller is authenticated but not an owner. The DMS applies it to pages under /modules/, which are owner-only administration spaces.

import { Controller, Get } from "@antelopejs/interface-api";
import { AuthOwnerOnly } from "@antelopejs-private/cms/interfaces/cms-auth";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class MaintenanceController extends Controller("/api/maintenance") {
  private jobs: string[] = ["reindex", "purge-sessions"];

  @Get("/jobs")
  listJobs(@AuthOwnerOnly() owner: User) {
    // only reached when owner.owner === true
    return { requestedBy: owner.email, jobs: this.jobs };
  }
}

AuthOwnerOnly is a plain function that returns a ready-to-apply decorator, so it is written @AuthOwnerOnly() exactly like the three decorator factories above. It takes no arguments, because it pre-applies its own validator.

Gate Every Route on a Controller

AuthRawUser, AuthUser and IfAuthUser are built with CreateAuthDecorator, so they apply to a parameter, to a property, or to the controller class itself. A property provider is a computed property of the controller, resolved on every request the controller handles whether or not the handler reads it — so a decorated property gates the whole controller and gives the handlers a typed this.user.

import { Controller, Get, JSONBody, Post } from "@antelopejs/interface-api";
import { AuthUser } from "@antelopejs-private/cms/interfaces/cms-auth";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

interface Settings {
  theme: string;
}

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

  private settings: Record<string, Settings> = {};

  @Get("")
  read(): Settings {
    return this.settings[this.user._id] ?? { theme: "light" };
  }

  @Post("")
  write(@JSONBody() body: Settings): Settings {
    // also authenticated: the provider ran before the handler
    this.settings[this.user._id] = body;
    return body;
  }
}

This is the form the DMS uses when it wires authentication onto a page controller: the decorator is applied to the user property of the page class.

Read the Token Payload

TenantTokenInput is the value the authenticator step produces and the validator step consumes. It is exported from the root subpath.

export interface TenantTokenInput {
  tenantId: string;
  id: string;
  rawToken: string;
}
FieldTypeDescription
tenantIdstringThe tenant the token was issued for, taken from the token's tenantId claim.
idstringThe _id of the user the token was issued for.
rawTokenstringThe undecoded bearer token, used to verify the signature against the user.

The decoded payload carries no signature guarantee on its own: the validator is the step that verifies rawToken against the user it names.

Authenticate Inside a Custom Provider

authenticateRequestUser performs the same authenticate-then-validate sequence outside the decorator machinery. Use it when you write your own parameter provider and need the user plus something else in one place.

authenticateRequestUser(ctx: RequestContext): Promise<User>
ParameterDescription
ctxThe request context handed to a parameter provider, from @antelopejs/interface-api. The function reads ctx.rawRequest.headers.authorization.

It runs the same validation as AuthUser, so the email-validation gate applies: a caller whose email is not validated is rejected when the deployment's configuration requires validation. Every failure throws an HTTPResult with status 401.

import {
  HTTPResult,
  type RequestContext,
  SetParameterProvider,
} from "@antelopejs/interface-api";
import { MakeParameterAndPropertyDecorator } from "@antelopejs/interface-core/decorators";
import { authenticateRequestUser } from "@antelopejs-private/cms/interfaces/cms-auth";

const FORBIDDEN = 403;

export const AuthBetaTester = MakeParameterAndPropertyDecorator(
  (target, key, index) => {
    SetParameterProvider(target, key, index, async (ctx: RequestContext) => {
      const user = await authenticateRequestUser(ctx);
      if (!user.email.endsWith("@example.com")) {
        throw new HTTPResult(FORBIDDEN, "Beta testers only");
      }
      return user;
    });
  },
);

This is how the DMS builds its tenant guards: AuthTenantOwner and AuthTenantMember in the cms interface both start from authenticateRequestUser, then resolve the request's tenant and check the caller's membership on top of it.

A provider is also the way to add a requirement without losing the built-in one. The CreateAuthDecorator contract lets a call site pass a replacement validator — @AuthUser(callback) — but that callback replaces the whole validation step, signature check included, and its return type is constrained to boolean | User. This interface exports no token-verification primitive, so a replacement validator cannot redo the signature check it removes.

Decorator Reference

DecoratorInjectsEmail validationOn failure
AuthRawUser()UserNot checked401
AuthUser()UserEnforced when the configuration requires it401
IfAuthUser()User | undefinedEnforced when the configuration requires itResolves to undefined
AuthOwnerOnly()UserEnforced when the configuration requires it401 without a token, 403 when not an owner

Next Steps

Continue with Tokens and Sessions to see how the tokens these decorators consume are issued and validated.