Skip to content

REST Controllers

This page documents the bundled AuthController — its seven endpoints (five @Public(), two ARBAC-gated), their decorators, request/response shapes, and how to subclass it to extend the workflow allow-list.

AuthController

ts
import { AuthController } from "@aooth/auth-moost";

app.registerControllers(AuthController);

Decorated @Controller("auth") @ArbacResource("auth"). Constructor takes a DI-provided AuthCredential plus an @Optional() UserService:

ts
constructor(
  protected readonly auth: AuthCredential,
  @Optional() protected readonly users?: UserService,
)

users is optional — only GET /auth/invite/post-redemption reads it directly (returns 500 when unset); the other routes work without a UserService on the controller (the workflow-dispatching routes resolve UserService through the workflow, not this field).

Five endpoints are @Public(); two are ARBAC-gated. The public ones enforce authentication inside the handler bodies as defence-in-depth — every endpoint that requires a context calls useAuth().getAuthContext() and returns 401 if it's null. The reason they're public is that the routes themselves must be reachable to anonymous callers (a logged-out user trying to log in, an expired-access-token caller refreshing, an unauthenticated invitee redeeming a magic link). The remaining two — POST /auth/change-password and POST /auth/add-mfa — are not @Public(): they are authenticated self-service flows gated by @ArbacResource(...) @ArbacAction("self"), so the guard rejects anonymous (401) and unprivileged (403) callers before the handler runs (see their rows).

Endpoint table

MethodPathDecoratorsBodyResponseNotes
POST/auth/logout@Public()AuthLogoutBody { refreshToken? }AuthOkResponse { ok: true }Defence-in-depth 401 if null context. Revokes the current session's whole token family (revokeSession), with token-level revokes as fallback, then clearCookies().
POST/auth/refresh@Public()AuthRefreshBody { refreshToken? }AuthLoginResponseFalls back to the refresh cookie. 401 on AuthError. Returns new access+refresh, writes cookies.
GET/auth/status@Public()AuthContext401 when no context.
POST/auth/trigger@Public() @WfTrigger({ allow: DEFAULT_AUTH_WORKFLOWS }){ wfs?, input?: { action?, formData? } }WfFinished envelopeThe single entry-point covering auth/login/flow, auth/invite/start, auth/recovery/flow, auth/signup/flow.
GET/auth/invite/post-redemption@Public()?uid=<userId>WfFinished envelopeIdempotent "already accepted" envelope for re-clicked invite links after the wf state row is evicted. Needs UserService.
POST/auth/change-password@ArbacResource("auth.change-password") @ArbacAction("self") @WfTrigger({ allow: [CHANGE_PASSWORD_WORKFLOW] }){ wfs?, input?: { action?, formData? } }WfFinished envelopeGated trigger for auth/change-password/flow — NOT @Public(), NOT in DEFAULT_AUTH_WORKFLOWS. 401 if unauthenticated, 403 unless the role grants allow("auth.change-password", "*"). The method-level @ArbacResource overrides the class "auth". See Workflows — Change password.
POST/auth/add-mfa@ArbacResource("auth.add-mfa") @ArbacAction("self") @WfTrigger({ allow: [ADD_MFA_WORKFLOW] }){ wfs?, input?: { action?, formData? } }WfFinished envelopeGated trigger for auth/add-mfa/flow — the "Manage MFA" flow (add / change / remove, step-up first), the profile-maintenance twin of change-password. NOT @Public(), NOT in DEFAULT_AUTH_WORKFLOWS. 401/403 identical to above but keyed on allow("auth.add-mfa", "*"). See Workflows — Manage MFA.

DEFAULT_AUTH_WORKFLOWS is the exported as const allow-list:

ts
export const DEFAULT_AUTH_WORKFLOWS = [
  "auth/login/flow",
  "auth/invite/start",
  "auth/recovery/flow",
  "auth/signup/flow",
] as const;

The two gated flows are not in this list — auth/change-password/flow (CHANGE_PASSWORD_WORKFLOW) and auth/add-mfa/flow (ADD_MFA_WORKFLOW) are each dispatched from their own ARBAC-guarded route above, so they are unreachable from the public /auth/trigger. auth/signup/flow is public (gated by resolveSignupPolicy().allowSignup, off by default) — see Workflows — Self-signup.

POST /auth/logout

ts
// minimal
POST /auth/logout
Cookie: aooth_session=<access>
Content-Type: application/json

{ "refreshToken": "<optional>" }

Response: { "ok": true }.

Behaviour:

  1. If useAuth().getAuthContext() is null → throw HttpError(401, "Not authenticated"). (Defence-in-depth — the route is @Public() only so the handler is reachable.)
  2. Ends THIS device's whole session family. Resolves the current sessionId from the auth context and best-effort calls auth.revokeSession(userId, sessionId) — revoking every token in the family (access + refresh + all rotations) on a store that can enumerate. This is what "log out" means now that a session is a token family: the SPA can't read the httpOnly refresh cookie, and that cookie's narrow path keeps it off /auth/logout, so the token-level revokes below can't reach the refresh credential on their own.
  3. Token-level fallback. Pulls the access token via useAuth().extractToken() and a refreshToken from the body, and best-effort revokes each. This covers stateless stores (where revokeSession is a no-op) and is a harmless no-op once the family is already gone. Failures are silently swallowed (logout must not block on a denied revoke).
  4. Calls useAuth().clearCookies() to clear both Set-Cookie headers.

Net effect: a logged-out device disappears from auth.listSessions(userId) immediately on a stateful store — it no longer lingers for the refresh TTL — while the user's other devices keep working.

Refresh cookie path is narrow on purpose

The default refreshCookie.path is /auth/refresh (auto-derived to the controller's actual mount, see Config Reference), so browsers won't send it to any other path. /auth/logout therefore can't read the refresh cookie — which is exactly why logout revokes the whole family by sessionId rather than relying on the cookie. A body refreshToken field is still accepted as a belt-and-suspenders fallback (and for stateless stores). If you widen the path (e.g. to /), you lose the CSRF-resistance benefit — the refresh cookie is now sent on every request.

POST /auth/refresh

ts
// minimal
POST /auth/refresh
Cookie: aooth_refresh=<refresh>
Content-Type: application/json

{}

Response: AuthLoginResponse.

Behaviour:

  1. Pulls refreshToken from the body, falls back to the refresh cookie (path-scoped so it actually arrives here).
  2. If missing → 401 Refresh token required.
  3. Calls auth.refresh(refreshToken). On AuthError → 401 with the original message.
  4. Writes new Set-Cookie for both access + refresh via useAuth().writeCookies(issue).
  5. Returns useAuth().buildLoginResponse(userId, issue).

GET /auth/status

Response: AuthContext (the stashed context).

ts
// minimal
GET /auth/status
Cookie: aooth_session=<access>

Returns { userId, claims, expiresAt, ... }. 401 if no context (defence-in-depth — null bodies are not acceptable for a status check).

Use this for an SPA's "am I logged in?" probe on page load. Pair with /auth/refresh to bootstrap: if /auth/status returns 401, the SPA tries /auth/refresh and re-probes.

POST /auth/trigger

The single workflow trigger endpoint. The request body shape is @atscript/moost-wf's wf-trigger contract — the wf engine reads the action + form data directly from body.input:

ts
POST /auth/trigger
Content-Type: application/json

{
  "wfs":  "<resume-token>",          // resume an in-flight workflow (absent on start)
  "input": {
    "action":   "forgotPassword",    // alt-action name (forgotPassword, signup, magicLink, sso-<id>)
    "formData": { /* values for the current paused step */ }
  }
}

On start (no wfs), the workflow id is supplied by the client's <AsWfForm :name="…"> (e.g. auth/login/flow) — the trigger rejects any id not in allow. On resume, the wfs token carries the id. Response: WfFinished envelope (paused form, finished, or aborted) — see Workflows.

Behaviour:

  1. The handler body is intentionally empty (override triggerWf(): void { }). @WfTrigger({ allow: DEFAULT_AUTH_WORKFLOWS }) wraps it as a defineAfterInterceptor that:
    • Instantiates WfTriggerProvider.
    • Reads the trigger payload from body/query/cookie according to the provider's token wire config.
    • Dispatches into the requested workflow (rejecting any wfid not in allow).
    • Pauses on a form-input step → returns a paused envelope.
    • Finishes → returns the WfFinished envelope.
  2. Cookies attached by the login/auto-login finalize steps flow through the cookies field on the envelope (built via useAuth().buildFinishedCookies(issue)).

GET /auth/invite/post-redemption

A side route for re-clicked invite magic links. When an invitee clicks an already-redeemed link, the wf state row has been evicted (the resume returns 410), so the workflow can't re-enter to render the idempotent "already accepted" envelope. The magic-link URL carries the invitee uid (from buildMagicLinkUrl(kind, token, { userId })), and this route rebuilds the same envelope from it:

ts
GET /auth/invite/post-redemption?uid=<userId>

Resolves the user via the @Optional() UserService (500 if no UserService is wired), returns a WfFinished "already accepted" envelope when the invite is redeemed, or 404 when the invite is still pending. Override protected resolveInvitePostRedemption() to keep the loginUrl / alreadyAcceptedRedirectUrl in sync with your workflow opts.

DTOs

ts
// AuthLogoutBody
interface AuthLogoutBody {
  refreshToken?: string;
}

// AuthRefreshBody
interface AuthRefreshBody {
  refreshToken?: string;
}

// AuthLoginResponse
interface AuthLoginResponse {
  userId: string;
  accessExpiresAt: number;
  refreshExpiresAt?: number;
  accessToken?: string; // populated only when enableBearer === true
  refreshToken?: string; // same
}

// AuthOkResponse
interface AuthOkResponse {
  ok: true;
}

When enableBearer === false, the accessToken and refreshToken fields are omitted from the response — the browser MUST rely on cookies. The userId and accessExpiresAt fields are still useful for SPAs to schedule their next status probe.

Subclassing AuthController

The most common subclass extends the workflow allow-list. Two patterns:

Pattern A — override triggerWf with a wider allow

ts
import { AuthController, DEFAULT_AUTH_WORKFLOWS, Public, WfTrigger } from "@aooth/auth-moost";
import { AuthCredential } from "@aooth/auth";
import { UserService } from "@aooth/user";
import { Inherit, Controller, Optional } from "moost";
import { Post } from "@moostjs/event-http";

@Inherit()
@Controller("auth")
class MyAuthController extends AuthController {
  constructor(auth: AuthCredential, @Optional() users?: UserService) {
    super(auth, users); // forward both so post-redemption keeps working
  }

  @Post("trigger")
  @Public()
  @WfTrigger({
    allow: [...DEFAULT_AUTH_WORKFLOWS, "project.handover", "tenant.onboard"],
  })
  override triggerWf(): void {
    // body intentionally empty — see AuthController.triggerWf
  }
}

@Inherit() is mandatory on the subclass

Without @Inherit(), moost re-scans method decorators only on the subclass body and the unoverridden endpoints (logout, refresh, status) disappear from the route table. The @Inherit() decorator flows the parent class's @Get('status') / @Post('logout') / @Post('refresh') metadata down.

Pattern B — replace triggerWf entirely

If you need conditional logic — e.g. routing to a different wf based on a header — return a non-undefined value from your override; the after-interceptor will skip the default handle() dispatch and use your return value as the response.

ts
@Post("trigger")
@Public()
@WfTrigger({ allow: [...DEFAULT_AUTH_WORKFLOWS] })
override async triggerWf(): Promise<unknown> {
  const tenant = useRequest().rawRequest.headers["x-tenant"];
  if (tenant === "demo") return { wfid: "demo.login.short-circuit" };
  // returning undefined falls through to the default WfTriggerProvider.handle()
  return undefined;
}

See Workflows for the provider subclassing pattern (state store, outlets, token wire).

See also

Released under the MIT License.