Console Authentication
The built-in session-cookie login for the media console — Mode A mints a session from your app's own auth, Mode B accepts credentials at a login screen. Plus the exported signing helpers and how the guard slides a live session forward.
The console is served at a literal path (/media/dashboard by default), which means a plain browser navigation to it carries none of your app's identity. You have three ways to close that gap.
The first is authorize — an access-decision hook, same shape as the other @adonis-agora
dashboards (telescope, durable, agent). Return true to allow, false to deny; it runs
before middleware and composes with the built-in auth session guard (all must pass).
Use it to gate the whole console on your app's session/roles in one line:
import { defineConfig } from '@adonis-agora/media/dashboard'
import { authorizeByRoles } from '@adonis-agora/authz'
export default defineConfig({
authorize: authorizeByRoles({ roles: ['ADMIN'] }),
})The hook receives the real HttpContext, so it can read the session, a bearer
token, an IP allow-list — anything your app already trusts. A denied request is a 403 — the
hook knew who was asking and said no, the same status every other @adonis-agora console
answers with: { error: 'Forbidden' } JSON for an API request, the
access-denied page for a page navigation (the SPA shell or its
assets). The 401 with { auth: { modes } } belongs to the session guard alone — it is what
tells the SPA to show its login screen. Two refinements are shared with the other dashboards:
- A redirect the hook writes wins. If
authorizesets alocationheader (typicallyctx.response.redirect(...)) before returningfalse, the guard leaves that redirect alone instead of overwriting it with the403— the hook for sending an unauthenticated visitor to your own login page. - A throwing hook fails closed. If
authorizethrows, the request is denied (403) — a buggy hook never accidentally opens the console.
The second is middleware — front the whole console with your own AdonisJS guard. That works, and if your app already has a session cookie the browser sends on every request, it may be all you need.
The third is auth: a self-contained session for the console itself, signed by the console, carried in its own cookie, with a login screen the SPA renders when the cookie is missing. It exists because the first two options cover only the case where your app's auth is already cookie-shaped and already scoped to the path the console lives at. Token auth in a header, a separate admin realm, an SPA that holds its credentials in memory — none of those survive a full-page navigation.
The access-denied page
What a browser sees when authorize refuses it: the same 403 the API answers with, as a page
— a dark card in the console's visual language showing the status, a sentence explaining the
refusal and a "Back to app" link.
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:
export default defineConfig({
authorize: authorizeByRoles({ roles: ['ADMIN'] }),
accessDenied: {
brand: 'Entre Textos', // eyebrow + <title>; default "Media"
title: 'Sem acesso', // default "Access denied"
message: 'Peça ao admin para liberar o console de mídia.',
homeHref: '/admin', // "Back to app"; default "/", `false` hides it
homeLabel: 'Voltar',
accent: '#f59e0b', // any CSS colour; default: the console's cyan
},
})Or replace it. Pass a function and it receives the refusal (status, reason, basePath, and
the CSP nonce when there is one) plus the HttpContext. Return an HTML string to have it
served with the right status; answer the request yourself and return nothing to make the
provider stand down:
export default defineConfig({
accessDenied: (info, ctx) => {
ctx.response.redirect(`/login?next=${encodeURIComponent(info.basePath)}`)
},
})They compose
authorize, middleware and auth are independent gates on the same routes. Configure
multiple and a request must pass all of them — authorize first, then middleware, then
the console's session guard.
The two modes
import { defineConfig } from '@adonis-agora/media/dashboard'
export default defineConfig({
auth: {
secret: env.get('MEDIA_CONSOLE_SECRET'), // required
ttl: '8h', // default '8h'
session: async (request) => { … }, // Mode A
login: async (username, password) => { … }, // Mode B
revalidate: async (user) => { … }, // optional
},
})| Key | Type | Default | What it does |
|---|---|---|---|
secret | string | — | Required. HMAC-SHA256 signing key. Missing or empty ⇒ boot error. |
ttl | string | '8h' | Cookie lifetime as <number><s|m|h|d>. An unparseable value falls back to 8h. |
session | (request) => user | null | — | Mode A. Validate your app's own auth on the raw request. |
login | (username, password) => user | null | — | Mode B. Validate credentials from the built-in login screen. |
revalidate | (user) => boolean | — | Re-check a live session when the cookie slides forward. Not a mode — it cannot mint. |
Both hooks return a ConsoleSessionUser ({ id, name?, roles? }) or null to deny. At least one of session / login must be present; configuring auth with neither is a boot error, deliberately — a gate nothing can mint a session for would lock the console permanently, and finding that out at boot beats finding it out in production.
Your app is already authenticated. An XHR from inside it — which does carry that identity — posts to POST <apiBasePath>/session, your hook decides, and the console answers with its own cookie. The navigation that follows rides it.
auth: {
secret: env.get('MEDIA_CONSOLE_SECRET'),
async session(request) {
// `request` is the raw Node request — read whatever your app authenticates with
const token = (request as IncomingMessage).headers.authorization?.replace('Bearer ', '')
if (!token) return null
const user = await verifyAccessToken(token)
if (!user || !user.roles.includes('admin')) return null
return { id: String(user.id), name: user.name, roles: user.roles }
},
}The browser half is a one-liner from @adonis-agora/media-react:
import { OpenMediaDashboardButton } from '@adonis-agora/media-react'
<OpenMediaDashboardButton headers={() => ({ Authorization: `Bearer ${token()}` })} />This is the mode to reach for when your app already knows who the user is. The console never learns a password, never grows a second user table, and the decision about who may open it stays one function in your codebase.
Configure both and the login screen offers both: the SPA learns which modes exist from the 401 body of GET /me ({ auth: { modes: ['session', 'login'] } }) and renders accordingly.
How the session actually works
The cookie is stateless: base64url(payload).base64url(hmac), signed with HMAC-SHA256 over node:crypto — no JWT dependency, no server-side session table. The payload is a ConsoleSession:
interface ConsoleSession {
sub: string // the user id
name?: string
roles: string[] // free-form; the console does not interpret them
iat: number // issued-at, epoch ms
exp: number // expiry, epoch ms
}It is written as media_dashboard_session, HttpOnly, SameSite=Lax, Secure over https, with Path=/. The broad path is deliberate: basePath and apiBasePath are independently configurable and can live at unrelated paths, and a cookie scoped to one would be withheld on a full-page navigation to the other. The cookie is HttpOnly, signed and short-lived, so the wider scope is a reasonable trade.
Verification rejects anything tampered, malformed or expired, and never throws — every failure path returns null. The signature comparison is constant-time, and a wrong-length signature is caught before the comparison rather than throwing out of it. Expiry allows a 30-second grace so a marginally-skewed clock doesn't bounce a valid cookie.
Sliding renewal
Past the halfway point of its TTL, the guard re-issues the cookie on the next request, so an admin working continuously is never logged out mid-session. That renewal is also the hook point for revalidate:
auth: {
secret: env.get('MEDIA_CONSOLE_SECRET'),
session: (request) => …,
async revalidate(user) {
const admin = await Admin.find(Number(user.id))
return admin !== null && !admin.suspended
},
}Return false and the cookie is cleared and the request 401s — which is how you revoke access to a stateless session without a revocation list. Note the boundary: revalidation happens at renewal, not on every request, so a revoked user keeps access for up to half the TTL. Set a shorter ttl if that window matters. A revalidate that throws is treated as a denial — fail closed.
Failure behaviour
- Both host hooks are run defensively: a hook that throws is a denial (
null), never a500. Your database being down does not turn into a stack trace on a login screen. - With
authunset,GET /mereports{ authRequired: false }and/login//sessionreturn404— there is nothing to mint. The console is then open, andmiddlewareis your only gate. POST /logoutalways clears the cookie and returns204, even withauthunset. Clearing a cookie that isn't there is harmless.
The exported helpers
Everything above is in @adonis-agora/media/dashboard, framework-free — no AdonisJS import anywhere in auth.ts. Use them to mint or verify a console session outside the provider's own routes:
import {
resolveConsoleAuth,
signSessionCookie,
verifySessionCookie,
} from '@adonis-agora/media/dashboard'
import type {
ConsoleAuthOptions,
ConsoleSession,
ConsoleSessionUser,
ResolvedConsoleAuth,
} from '@adonis-agora/media/dashboard'| Export | Signature | Use |
|---|---|---|
resolveConsoleAuth | (options?) => ResolvedConsoleAuth | null | Validate + normalize the auth option: parses ttl to ms, collects the available modes, throws on a missing secret or no hook. null when unconfigured. |
signSessionCookie | (user, { secret, ttlMs, now? }) => string | Mint a cookie value. now is injectable for deterministic tests. |
verifySessionCookie | (value, { secret, now? }) => ConsoleSession | null | Verify one. Never throws. |
A realistic use: minting a console session from a route of your own, so an operator arrives at the console already signed in from an internal tool.
import { signSessionCookie } from '@adonis-agora/media/dashboard'
router.get('/admin/open-media', async ({ auth, response }) => {
const user = auth.getUserOrFail()
await bouncer.authorize('openMediaConsole')
const value = signSessionCookie(
{ id: String(user.id), name: user.fullName, roles: ['admin'] },
{ secret: env.get('MEDIA_CONSOLE_SECRET'), ttlMs: 8 * 60 * 60 * 1000 },
)
response.append(
'set-cookie',
`media_dashboard_session=${encodeURIComponent(value)}; Path=/; Max-Age=28800; HttpOnly; SameSite=Lax; Secure`,
)
return response.redirect('/media/dashboard')
}).use(middleware.auth())One secret, one meaning
The secret is the whole gate. Anyone who can compute the HMAC can mint a session for any user id and any roles. Keep it in the environment, keep it out of the config file's literal text, and rotate it by changing the value — every outstanding cookie stops verifying immediately, which is the fastest logout available.
Next steps
- Dashboard — the console's config, routes and views
- Console launcher — the React side of Mode A, in three tiers
- Programmatic API —
DashboardServiceand the JSON contract types
Dashboard
The management console that ships inside @adonis-agora/media — a React SPA plus a JSON API to browse buckets, inspect media records, watch resumable uploads, upload objects, and copy/move/delete across buckets, over the real disk and session-store surfaces.
Collections View
The cross-owner MediaStore.list — a cursor-paginated, filterable listing of media-library records across every owner (newest first), for management and console reads. Backs the dashboard's Collections view.