Agora
Dashboard

Dashboard auth

How the Telescope dashboard is gated — the default allow-in-dev / deny-in-prod policy, the built-in token and HTTP Basic credentials, delegating to your own app auth with an authorize hook, and the 401-vs-403 fail-closed guard behaviour.

The dashboard and its JSON API expose everything Telescope recorded — requests, queries, exception stacks, diagnostic payloads. That is exactly what you want for debugging and exactly what you must not leave open in production. Every route runs an authorize guard first; this page is how that guard works and how to wire it to your needs.

The default policy

Out of the box, with no authorize and no credentials configured:

  • Outside production (NODE_ENV !== 'production') → allowed. The dev dashboard just works.
  • In productiondenied, unless the request presents a configured credential.

So a fresh install is usable locally and closed in production until you make a deliberate choice. The policy lives in defaultAuthorize and is what you get when you set neither a hook nor credentials.

"Deny in production" is the default, not a guarantee you can skip configuring. If you mount the dashboard in production, set a token/basic credential or an authorize hook. Never rely on an obscure path alone.

Built-in credentials

The simplest production gate: set a credential and the default policy lets matching requests through (and still denies everything else in production).

config/telescope_ui.ts
import env from '#start/env'
import { defineConfig } from '@adonis-agora/telescope/ui'

export default defineConfig({
  credentials: { token: env.get('TELESCOPE_UI_TOKEN') },
})

A request is allowed when it carries Authorization: Bearer <token> or ?token=<token> matching the configured value. The compare is constant-time-ish so it doesn't trivially leak the secret's length.

curl -H 'Authorization: Bearer s3cr3t' http://app.example.com/telescope/api/entries
# or, for opening the page in a browser:
open 'http://app.example.com/telescope?token=s3cr3t'

/telescope only serves a page when @adonis-agora/telescope-ui is installed and its provider is registered; otherwise this prefix exposes just <path>/api/*, and only the curl form above applies.

You can set both token and basic — either matching one allows the request.

Delegate to your app's auth

For real apps, gate the dashboard on your existing session/roles. Supply an authorize hook — it receives the (framework-light) HTTP context, so it can read headers, a session, or an injected user. Sync or async. When you set authorize, the built-in credentials gate is ignored (your hook is the whole policy).

config/telescope_ui.ts
import { defineConfig } from '@adonis-agora/telescope/ui'

export default defineConfig({
  authorize: (ctx) => {
    const { auth } = ctx as unknown as { auth: { user?: { isAdmin?: boolean } } }
    return auth.user?.isAdmin === true
  },
})

The narrowing is the price of the guard being framework-light. The hook's declared parameter is a minimal { request, response } shape, but the provider passes your real HttpContext straight through — so auth, session and the rest are genuinely there at runtime, and you name the slice you rely on to reach them.

// Async — resolve a user and check a role.
export default defineConfig({
  async authorize(ctx) {
    const { auth } = ctx as unknown as {
      auth: { check(): Promise<boolean>; user?: { role?: string } }
    }
    await auth.check()
    return auth.user?.role === 'admin'
  },
})

The guard takes a deliberately minimal HTTP context (request.qs(), request.header(), and a response), and a real AdonisJS HttpContext satisfies it structurally — so the real ctx is passed straight through and your hook really can reach auth, session and the rest at runtime. They are not on the declared type, though, so narrow ctx to the slice you need (as above) rather than reaching for them directly.

Role-based gate via @adonis-agora/authz

If you use @adonis-agora/authz for RBAC (roles live in user_roles, or come from the token claim), the authorizeByRoles helper removes the narrowing entirely — same semantics as the requireRole middleware, in the (ctx) => boolean shape the dashboard expects:

config/telescope_ui.ts
import { defineConfig } from '@adonis-agora/telescope/ui'
import { authorizeByRoles } from '@adonis-agora/authz'

export default defineConfig({
  authorize: authorizeByRoles({ roles: ['ADMIN'] }),
})
  • The user comes from ctx.auth.getUser() (authkit) or ctx.auth.user (any guard), so it works with or without authkit — including a plain AdonisJS session cookie, a completely ordinary auth pattern that has nothing to do with this library's own credentials.token/credentials.basic gate.
  • effectiveRoles includes global token claims and app-level DB roles; roles is any-of (passes when the user holds at least one).
  • No authenticated user → false (the guard answers 401/403, or honors a redirect you write).
  • It accepts any context shape (the dashboards type authorize differently), reading ctx.auth structurally and never touching AdonisJS internals.

The same helper works across every @adonis-agora dashboard — telescope, durable, media and agent — so one RBAC gate config reads the same everywhere.

Composed as-is, authorizeByRoles returns a bare boolean — so BOTH a genuinely-anonymous request and an authenticated-but-wrong-role request deny with false, and the guard's request-shape heuristic (below) answers 401 for both: it only recognizes the built-in credentials.token/credentials.basic gate, and has no way to see a session cookie. The wrong-role case should really be 403. Wrap the call and return the enriched AuthorizeDecision ({ allowed, reason }) form to tell the guard which denial this is:

config/telescope_ui.ts
import { defineConfig } from '@adonis-agora/telescope/ui'
import { authorizeByRoles } from '@adonis-agora/authz'

const requireAdmin = authorizeByRoles({ roles: ['ADMIN'] })

export default defineConfig({
  authorize: async (ctx) => {
    if (await requireAdmin(ctx)) return true
    // authorizeByRoles said no — was anybody signed in at all?
    const { auth } = ctx as unknown as { auth: { user?: unknown } }
    return { allowed: false, reason: auth.user !== undefined ? 'forbidden' : 'unauthenticated' }
  },
})

Now a signed-in user missing the ADMIN role gets a precise 403, and an anonymous visitor still gets 401 — see Guard behaviour below for exactly how the guard uses reason.

Built-in login screen

For a shared, human-facing dashboard, the authorize hook and static credentials are awkward — you want a real login form and a session. The optional dashboardAuth block adds exactly that: a server-rendered login page gated by a signed session cookie, without touching the bundled SPA.

config/telescope_ui.ts
import env from '#start/env'
import User from '#models/user'
import hash from '@adonisjs/core/services/hash'
import { defineConfig } from '@adonis-agora/telescope/ui'

export default defineConfig({
  dashboardAuth: {
    secret: env.get('TELESCOPE_DASHBOARD_SECRET'),   // HMAC-SHA256 signing key (32+ bytes)
    ttl: '8h',
    async login(username, password) {
      const user = await User.findBy('email', username)
      if (!user || !(await hash.verify(user.password, password))) return null
      if (!user.isAdmin) return null
      return { id: String(user.id), name: user.fullName }   // the session user, or null to deny
    },
  },
})
KeyDefaultDescription
secretRequired. HMAC-SHA256 key that signs the session cookie. Missing/empty → boot error.
ttl'8h'Cookie lifetime as a duration string ('30m', '8h', '7d').
loginRequired. Validates the submitted username/password; return the session user, or null to deny. May be async.

When configured, the provider mounts three routes and stamps a session guard on the dashboard:

RouteServesGuarded
GET <path>/loginThe server-rendered login page (a static route, so it takes precedence).No
POST <path>/loginRuns the login hook; on success mints the signed session cookie.No
GET <path>/logoutClears the session cookie, then redirects to the login page.No

Those three sit outside both guards, in every environment — they have to, or you could never reach the page that mints a session, and the authorize policy would deny you before you ever saw a login form. They are the login surface itself, so treat them accordingly: the page renders no server-supplied text (the returnTo and error values are read client-side from the query string, never echoed into the HTML), every credential failure answers a uniform 401 so the endpoint cannot be used to enumerate users, and the POST does nothing but run your login hook. What they never expose is recorded data — every entry, trace, metric and stream still sits behind both guards.

dashboardAuth is additive and composes with authorize — both must pass. An unauthenticated page navigation is redirected (302) to the login page; an unauthenticated API request gets a plain 401. The signed cookie (telescope_dashboard_session) is minted only by your login hook. Omit dashboardAuth entirely and the dashboard behaves exactly as before — no login/logout routes, no session guard.

It fails closed at boot: a configured-but-missing secret or login throws at startup rather than shipping an un-mintable gate. A login hook that throws is treated as a denial (logged once, never surfaced), every credential failure returns a uniform 401 (no user-enumeration), and the post-login returnTo is validated against open redirects (same-origin, root-relative only). The page itself is a plain HTML form that works without JavaScript — a form submit is answered with a redirect (to returnTo, or back to the form with the error shown) — and its inline script and style carry @adonisjs/shield's request nonce, so a script-src 'self' @nonce policy keeps the page working.

Guard behaviour

The guard translates an authorize decision into an HTTP outcome, distinguishing two denial cases so clients and browsers behave correctly:

OutcomeStatusMeaning
authorize returned true (or { allowed: true })Request proceeds to the handler.
Denied with reason: 'unauthenticated'401"Authenticate" — sends WWW-Authenticate so a browser prompts.
Denied with reason: 'forbidden'403"Forbidden" — signed in, but not allowed.
Denied (false, or { allowed: false } with no reason), no credential presented401Same "Authenticate" outcome, decided by the fallback heuristic below.
Denied (false, or { allowed: false } with no reason), a credential was presented but rejected403Same "Forbidden" outcome, decided by the fallback heuristic below.
authorize threw403Fail-closed — a buggy hook never accidentally opens the door.
Denied, but the hook already set a location headerThe hook's redirect stands; the guard writes no status and no body.

Bare boolean vs the enriched { allowed, reason } return

An authorize hook may return a plain boolean (as every example above does) or an object, { allowed: boolean, reason?: 'unauthenticated' | 'forbidden' }. Both are backward compatible — a hook returning a bare boolean today keeps behaving byte-for-byte the same.

The reason is the escape hatch. It only matters on a denial (allowed: false), and it exists because the guard cannot see how a custom hook authenticates. Its fallback heuristic — "presented a credential" means an Authorization header was present or a ?token= was in the query string — was built for this library's OWN credentials.token/credentials.basic gate, where "no credential in the request" and "not authenticated" are the same fact. A hook that authenticates some other way (a session cookie, most commonly — see the authorizeByRoles example above) can bypass that heuristic entirely by setting reason explicitly:

  • reason: 'unauthenticated' → always 401, regardless of what the request looked like.
  • reason: 'forbidden' → always 403, regardless of what the request looked like.
  • reason omitted → falls back to the request-shape heuristic, exactly like a bare false always has.

The guard runs on every route that serves recorded data — every /api/* endpoint (including the SSE stream, the metrics rollups and the extension surface) and the @adonis-agora/telescope-ui SPA routes. You cannot accidentally leave the JSON API open while gating the page. The one exception is the dashboardAuth login surface described above (GET/POST <path>/login and GET <path>/logout), which is unguarded by necessity and only exists when you configure dashboardAuth.

Redirecting instead of answering JSON

If your authorize hook redirects — sets a location header, typically via ctx.response.redirect(...) — and then returns false, the guard leaves that redirect alone instead of overwriting it with a 403 JSON body. That is the hook for sending an unauthenticated visitor to your own product's login page rather than a bare { "error": "Forbidden" }:

// config/telescope_ui.ts
export default defineConfig({
  authorize: (ctx) => {
    const { auth } = ctx as unknown as { auth: { user?: { isAdmin?: boolean } } }
    if (auth.user?.isAdmin === true) return true
    ctx.response.redirect('/login?next=/telescope')
    return false
  },
})

The redirect wins only when the hook returns false; returning true proceeds to the handler as usual, redirect or not.

The access-denied page

The { "error": ... } JSON above is what the API answers with — the console's own fetch calls expect it. A refused page navigation (the @adonis-agora/telescope-ui shell or its assets) gets a real page instead: a dark card in the console's visual language showing the status, a sentence explaining the refusal, a "Back to app" link and, when dashboardAuth is configured, a "Sign in" button. The statuses are the same as the table above; the WWW-Authenticate challenge rides on the page's 401 only when credentials.basic is configured (so the native browser prompt appears exactly when it can be answered — the page is what a dismissed prompt reveals), and is left off for a host gating on its own authorize. It carries no inline script, so a nonce'd script-src CSP cannot break it (its one inline <style> picks up @adonisjs/shield's request nonce).

Tweak it with accessDenied — every field optional:

config/telescope_ui.ts
export default defineConfig({
  accessDenied: {
    brand: 'Entre Textos',           // eyebrow + <title>; default "Telescope"
    title: 'Sem acesso',             // default depends on the refusal
    message: 'Peça ao admin para liberar o console de observabilidade.',
    homeHref: '/admin',              // "Back to app"; default "/", `false` hides it
    homeLabel: 'Voltar',
    loginHref: '/entrar',            // default: the built-in login page when one exists
    loginLabel: 'Entrar',
    accent: '#f59e0b',               // any CSS colour; default: the console's magenta
  },
})

Or replace it. Pass a function and it receives the refusal (status, reason'unauthenticated' or 'forbidden'basePath, loginHref, and the CSP nonce when there is one) plus the same context the authorize hook gets. Return an HTML string to have it served with the right status; answer the request yourself and return nothing to make the guard stand down:

config/telescope_ui.ts
export default defineConfig({
  accessDenied: (info, ctx) => {
    if (info.reason === 'unauthenticated') {
      ctx.response.status(302).header('location', `/login?next=${info.basePath}`)
      return
    }
    return `<!doctype html><title>${info.status}</title><h1>Sem acesso</h1>`
  },
})

An authorize hook that already wrote a redirect still wins, with or without accessDenied.

Security notes

  • Always gate production. The default denies it, but a credential or hook is required to actually use it there.
  • Source secrets from the environment. Use env.get(...) for tokens/passwords; never hardcode them in config/telescope_ui.ts.
  • ?token= is convenient but leaky. It can land in logs and browser history. Prefer the Authorization header for anything but a quick manual open, and rotate the token if it leaks.
  • Obscure paths are defense in depth, not auth. Changing path to /__telescope raises the bar for a drive-by scan; it does not replace the guard.

On this page