Agora
Observability

Dashboard auth

The console's optional session layer (dashboardAuth) — sign operators in at a built-in login page, or mint a session straight from your already-authenticated app. A signed HMAC cookie gates the pages (302 or 401) and the JSON API (401). Opt-in, additive to authorize, fails closed at boot.

By default the console is gated only by the per-request authorize guard — fine when you already have a bearer token or your own session middleware. When you'd rather operators arrive with a real session, opt into dashboardAuth: a signed session cookie minted through a hook you supply. It ships in @adonis-agora/durable — nothing extra to install.

dashboardAuth is additive. It composes with authorize (both must pass) — it does not replace it. Omit it and the console behaves exactly as before: no auth routes, no session cookie.

Two ways to get a session

There are two hooks, and you may configure either one or both.

Mode A — session: open the console from your app. The operator is already signed in to your application. Your app calls the console's session endpoint, which hands your hook the raw request so it can read whatever it already trusts — your own cookie, a JWT, a header — and returns the operator's identity. No second password, no second login page. This is the right shape when the console is a page inside a product an admin is already using.

Mode B — login: a standalone sign-in page. The console serves its own server-rendered username/password page and calls your hook to verify the credentials. The right shape when the console stands alone, with no host app in front of it.

Configuring both gives operators a login page and a way in from the host app.

Enable it

Add a dashboardAuth block to config/durable_dashboard.ts. secret is always required, plus at least one of session / login:

config/durable_dashboard.ts
import { defineConfig } from '@adonis-agora/durable/dashboard'
import env from '#start/env'
import User from '#models/user'

export default defineConfig({
  dashboardAuth: {
    secret: env.get('DURABLE_DASHBOARD_SECRET'), // HMAC-SHA256 signing key, 32+ bytes
    ttl: '8h',

    // Mode B — the built-in sign-in page.
    login: async (username, password) => {
      const user = await User.verifyCredentials(username, password).catch(() => null)
      if (!user || !user.isAdmin) return null // return null to deny
      return { id: String(user.id), name: user.fullName, roles: ['admin'] }
    },
  },
})

Both hooks are plain functions — they may be async and close over your app's services (import them at the top of the config file), so there's nothing extra to wire for the "reach a DB or auth service" case.

FieldRequiredDefaultDescription
secretyes—HMAC-SHA256 key the session cookie is signed with. Missing or empty fails closed at boot.
sessionone of the two—(request) => DashboardSessionUser | null (may be async). Receives the raw Node request. Mode A.
loginone of the two—(username, password) => DashboardSessionUser | null (may be async). Mode B.
ttlno'8h'Cookie lifetime as <integer><s|m|h|d> ('30m', '8h', '7d'). Any other spelling ('1.5h', '8 hours') silently falls back to 8 hours.

A hook returns a session user shaped { id: string; name?: string; roles?: string[] }, or null to deny. name (else id) is the actor the audit log records for every mutating action.

roles is interpreted. A session with an empty or absent roles list has full access. A session with a non-empty list is a viewer unless the list contains operator or admin (case-insensitive): it can read everything, but every mutating API route (retry, cancel, bulk, fix-and-replay, signal, update, task completion, schedule control) answers 403. So map your own roles onto these two names, or leave roles out, rather than passing your app's role names through unchanged.

Validated at boot, so a mis-wired gate cannot ship

Two conditions throw at boot rather than at the first request: an absent or empty secret, and neither session nor login being a function. Keep secret out of source — read it from an env var.

What it adds

When dashboardAuth is configured, the provider mounts up to five public endpoints under the console's path. They mint or clear the session the guard checks for, so they sit in front of it — and, importantly, in front of authorize too.

MethodRouteMounted whenPurpose
POST<path>/sessionsession is configuredhands the raw request to your session hook and sets the cookie
GET<path>/loginlogin is configuredthe server-rendered sign-in page
POST<path>/loginlogin is configuredverifies credentials via your login hook and sets the cookie
GET<path>/logoutalwaysclears the cookie and redirects
POST<path>/logoutalwaysthe same, for clients that prefer a non-GET logout

And it stamps a session guard onto every console route. Without a valid cookie:

  • a page navigation is redirected 302 to <path>/login (carrying a sanitized returnTo) when login is configured, and answered with the 401 access-denied page ("Open this console from your app") when only session is;
  • an API request gets 401 { "error": "unauthorized", "auth": { "modes": [...] } }. The modes array tells a client which way in exists — the console reads it to decide between redirecting to the login page and bouncing to the console root.

logout redirects to <path>/login when a login page exists, and to the console root otherwise.

POST <path>/login and POST <path>/session are rate-limited per client IP: past 10 attempts a minute they answer 429. The window is kept in memory per instance, so a cluster multiplies the budget by its pod count.

The routes are named durable_dashboard.login.page, durable_dashboard.login.submit, durable_dashboard.session.mint, durable_dashboard.logout and durable_dashboard.logout.post.

The login page is a plain HTML form that works without JavaScript: a form submit is answered with a redirect — to the page the operator came from (returnTo, same-origin only), or back to the form with the error shown. The page's own fetch sends JSON instead and gets JSON back: 200 { redirectTo } on success, 400 when username or password is not a string, 401 { error } otherwise. The password is passed to your hook as typed (an empty string when blank), so a hook that gates on the username alone works with the same page. Its inline script (which upgrades the submit to a fetch) and style carry @adonisjs/shield's request nonce, so a script-src 'self' @nonce policy keeps the page working.

The session is a stateless, signed HMAC-SHA256 cookie (durable_dashboard_session) — no server-side session store. It carries the user id, optional name and roles, plus issued-at and expiry, and is rejected once tampered with or expired. It is httpOnly, sameSite=lax, scoped to /, and secure on an HTTPS request — and always secure under NODE_ENV=production, even when a TLS-terminating proxy hides the scheme from AdonisJS.

The cookie is not renewed while the operator works: it expires ttl after it was minted, and the operator signs in (or is sent through your app) again. A user you deactivate or demote in your app keeps the access the cookie carries until it expires, so keep ttl short when that matters.

Every credential failure — unknown user, wrong password, or a throwing hook — returns the same uniform 401 ("Invalid username or password."), so the endpoint does not leak whether a username exists. A throwing hook is warn-logged once per process server-side, but never changes the client-visible response.

Mode A: opening the console from your app

This is the flow where the operator never sees a second login. Your app already knows who they are; the console's job is to accept that.

Your integration point is the route, not a helper

The supported, stable contract is POST <path>/session. The bundled console has an internal helper that calls it, but it is not published as an importable entry point. Write the call yourself, as below — anything that can issue an HTTP request can open the console.

1. Write the session hook

The hook receives the raw Node IncomingMessage for the POST <path>/session request, so it can read whatever your app already trusts on it. Return the operator's identity, or null to deny:

config/durable_dashboard.ts
import { defineConfig } from '@adonis-agora/durable/dashboard'
import { parse as parseCookie } from 'node:querystring'
import env from '#start/env'
import { verifyAppSession } from '#services/app_session'

export default defineConfig({
  dashboardAuth: {
    secret: env.get('DURABLE_DASHBOARD_SECRET'),
    ttl: '8h',

    session: async (request) => {
      const req = request as import('node:http').IncomingMessage

      // Read whatever your app already trusts — here, its own session cookie.
      const raw = req.headers.cookie ?? ''
      const token = parseCookie(raw.replaceAll('; ', '&')).app_session
      if (typeof token !== 'string') return null

      const user = await verifyAppSession(token)
      if (!user || !user.roles.includes('ops')) return null

      // Map onto the console's two roles: `operator` may act, anything else is read-only.
      return { id: user.id, name: user.name, roles: user.roles.includes('ops-admin') ? ['operator'] : ['viewer'] }
    },
  },
})

Note that the request reaches your hook exactly as the browser sent it, so a cross-origin call must include credentials for your app's cookie to be there at all.

2. Mint the session, then navigate

From your already-authenticated app, POST to the session route and only then send the operator to the console. The POST sets the cookie the console's guard checks for; the navigation that follows arrives authenticated:

resources/js/open_console.ts
export async function openDurableConsole() {
  const res = await fetch('/durable/session', {
    method: 'POST',
    credentials: 'include', // your app's cookie must ride along
    redirect: 'manual',     // an auth middleware answering 3xx is a failure, not a session
  })

  if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
    throw new Error('Session mint was redirected — the operator is not signed in.')
  }
  if (!res.ok) {
    throw new Error(`Could not open the durable console (HTTP ${res.status}).`)
  }

  window.location.assign('/durable')
}

A successful mint answers 204 with an empty body; a denial (the hook returned null or threw) answers 401 { "error": "unauthorized" }. It takes no request body — everything your hook needs is already on the request.

Handle the redirect case deliberately: if your own auth middleware turns an unauthenticated POST into a 302 to your sign-in page, fetch would follow it and report a cheerful 200 for an HTML login form. redirect: 'manual' turns that into the failure it actually is.

3. Give operators a way out

GET <path>/logout clears the cookie. In a Mode-A-only setup it redirects to the console root, which the guard then answers with the "open this console from your application" page — the console has no sign-in of its own to return to. A plain link is enough, since the route is idempotent and only clears the caller's own session:

<a href="/durable/logout">Sign out of the console</a>

Choosing between the guards

  • Machine-to-machine, or you already have app auth in front of the console → keep the default authorize (or your own guard) and skip dashboardAuth entirely. See Authorization.
  • Operators are already signed in to your app and should reach the console in one click → Mode A, the session hook.
  • The console stands alone and operators need somewhere to type a username and password → Mode B, the login hook.
  • Both, when some operators arrive from the app and others go straight to the console.

All of these layer on top of authorize, so you can keep an IP allow-list or an environment gate there and require a session on top of it.

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →