Aviary
Dashboard

Dashboard auth

Gate the Telescope dashboard so only your logged-in admins see it — all the way to prod, no infra required. Two modes, one signed-cookie mechanism, both copy-pasteable.

dashboardAuth lets any host app gate the Telescope dashboard so only its logged-in admins can reach it — all the way to production, with no infra (no oauth2-proxy, no ingress rules). It's designed to be adoptable by any community user in a few lines.

The problem it solves

Hosts commonly use header-Bearer auth (e.g. a Keycloak JWT). The generic dashboard SPA can't attach that header to browser navigations and fetches, so the authorizer hook alone can't gate the dashboard without breaking it — the SPA would 403 itself out.

Cookies, however, ride along automatically. The UI client already uses fetch with the default credentials: same-origin, so a same-origin, path-scoped cookie reaches every SPA call with zero UI-client changes. dashboardAuth mints exactly such a cookie.

Both modes mint the same cookie — there's no session store and no revocation list, just a short TTL with sliding renewal:

  • Name telescope_session; httpOnly, SameSite=Lax, Secure when the request is https (or x-forwarded-proto: https), Path=/<mount path> (default /telescope).
  • Value base64url(JSON payload) . base64url(HMAC-SHA256(payload, secret)), payload { sub, name?, roles, iat, exp }. Verified with crypto.timingSafeEqual. No JWT dependency — node:crypto only.
  • Tampered / expired / malformed cookies are treated as absent (401), never thrown.
  • Sliding renewal is handled centrally in the guard: a valid cookie past 50% of its TTL is transparently re-issued on the response — optionally gated by a revalidate hook first (see below).

Two modes

Enable either, or both. At least one of session / login is required when dashboardAuth is set (boot error otherwise).

  • Mode A — session (seamless). Your own frontend — which already holds the host's auth — calls POST /telescope/api/auth/session with that auth. A host hook validates it and returns the session user. No second login.
  • Mode B — login (universal). Telescope ships a built-in login screen; a host hook validates the submitted credentials. Zero host-frontend changes.

Config surface

TelescopeModule.forRoot({
  dashboardAuth: {
    /** REQUIRED. HMAC-SHA256 signing key (32+ bytes recommended).
     *  Missing/empty while dashboardAuth is set => boot error (fail closed). */
    secret: process.env.TELESCOPE_AUTH_SECRET,

    /** Cookie TTL. Default '8h'. Sliding renewal re-issues past 50% of TTL. */
    ttl: '8h',

    /** Mode A. Called by POST /auth/session with the RAW request — the host
     *  validates its own auth and returns the session user, or null to deny. */
    session: (request) => /* TelescopeSessionUser | null */,

    /** Mode B. Called by POST /auth/login with the submitted credentials. */
    login: (username, password) => /* TelescopeSessionUser | null */,

    /** Optional. Re-checks the session on sliding renewal (at most once per
     *  ttl/2 per session). false, or a throw, clears the cookie and 401s.
     *  Not a mode — it can't mint a session, so it doesn't count toward the
     *  "at least one of session/login" boot check, and it never appears in
     *  `modes`. */
    revalidate: (session) => /* boolean */,
  },
});

interface TelescopeSessionUser {
  id: string;
  name?: string;
  /** Free-form role strings; the lib does NOT interpret them. Hooks decide who
   *  gets in; authorizeAction can read them for mutation gating. */
  roles?: string[];
}

The password submitted to login is passed through verbatim — including an empty string, since the built-in login screen never requires one — the hook owns whether it matters.

When dashboardAuth is not set, behavior is unchanged — the existing authorizer / default-deny-in-prod applies.

Revalidating a renewed session

Sliding renewal keeps an active tab logged in by re-issuing the cookie — but on its own, it never asks the host anything, so a deactivated or demoted operator keeps dashboard access for as long as the tab stays open. revalidate closes that gap: when a renewal is due, it runs first, receiving the already-minted session ({ id, name?, roles }), and returning false (or throwing) clears the cookie and denies the request instead of renewing it.

  • It only runs on the renewal path — at most once per ttl/2 per session — not on every request, so a DB round-trip in the hook is cheap.
  • It's distinct from session/login: those mint a session from a fresh request the dashboard's own XHRs don't carry the host's credential on; revalidate re-checks an existing session by identity instead.
  • It can't mint a session, so it isn't a mode — it doesn't count toward the "at least one of session/login" boot check, and it never appears in modes.

Revocation isn't instant

Because revalidate only runs on the renewal path, access loss lags behind the change on the host side by up to ttl/2 — 4 hours at the default 8h TTL. A demoted or deactivated operator keeps dashboard access for the rest of that window if the tab stays open. Lower ttl to tighten the window; there's no way to force an immediate check without one.

Recipes

For expanded, practical wiring — login against your own user table with bcrypt, bridging an existing JWT session, and role-gating mutations — see the dashboard login & sessions recipe.

Gates the dashboard in 5 lines, works to prod, no host-frontend changes. Telescope renders the login screen; your hook checks the credentials against an env user/pass:

TelescopeModule.forRoot({
  dashboardAuth: {
    secret: process.env.TELESCOPE_AUTH_SECRET,
    login: (username, password) =>
      username === process.env.TELESCOPE_USER &&
      password === process.env.TELESCOPE_PASS
        ? { id: 'ops' }
        : null,
  },
});

Open /telescope, enter the credentials, and you're in.

Your app already authenticates the admin with a Bearer token. The hook verifies that token and returns the user; your frontend mints the session with one fetch, then opens the dashboard — no second login.

Backend — verify your own Bearer and gate on role:

TelescopeModule.forRoot({
  dashboardAuth: {
    secret: process.env.TELESCOPE_AUTH_SECRET,
    session: async (req) => {
      const user = await myAuth.verify(req); // verify the Bearer JWT
      return user?.isAdmin
        ? { id: user.id, name: user.name, roles: ['admin'] }
        : null;
    },
  },
});

Frontend — an "Open Telescope" button that mints the session, then opens the dashboard:

await fetch('/telescope/api/auth/session', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + token },
});
window.open('/telescope');

The POST sets the cookie; every subsequent SPA call carries it automatically.

Endpoints

/auth/* endpoints are not behind the session gate (they create it).

EndpointModeBehavior
POST /auth/sessionARuns session(request). User → set cookie, 204. Null → 401. 404 when mode A isn't configured.
POST /auth/loginBBody { username, password }. Runs login(...). User → set cookie, 204. Null → 401 (uniform message — no user enumeration). 404 when mode B isn't configured.
POST /auth/logoutbothClears the cookie. 204.
GET /auth/mebothValid cookie → 200 { user }. Else 401 with { auth: { modes } } — the unauthenticated SPA learns which screen to render from this body (meta stays gated).

Gate behavior

When dashboardAuth is configured:

  • The guard requires a valid session cookie for every /api/* route except /api/auth/*. The parsed session is attached as request.telescopeSession, so your hooks can read the user and roles.

  • The existing authorizer still runs after the session check (AND semantics — an optional extra restriction). The default prod-deny is replaced by the session gate.

  • The UI shell + hashed assets stay public (they hold no data). The SPA boots, calls /auth/me, and on 401 renders the auth screen instead of the app. A 401 from any later call flips it back to the auth screen — so an expired session mid-use is handled gracefully.

  • Mutations stay on authorizeAction (separate, default-deny). With sessions it can now do role checks:

    authorizeAction: ({ request }) =>
      request.telescopeSession?.roles?.includes('admin') ?? false,

Security notes

  • CSRF. SameSite=Lax blocks the cookie from riding cross-site POSTs. Queue mutations are POSTs, so they're covered by this alone.
  • Fail-closed boot. A missing or too-short secret while dashboardAuth is set is a hard boot error with a clear message — never a silent open door.
  • 401 vs 403. A missing / tampered / expired session is 401 (not authenticated → show the auth screen). A valid session that your authorizer or authorizeAction then rejects is 403 (authenticated, not allowed). The login endpoint returns a uniform 401 on bad credentials — no user enumeration.
  • Hook errors don't leak. A hook that throws is treated as a denial (null) with a once-per-kind warn log — it never 500s the auth endpoint into a stack-trace leak. revalidate follows the same rule: a throw denies (fails closed), same as returning false.
  • Clock skew. exp is checked with a 30-second grace.
  • Brute-force throttling on /auth/login is documented as the host's job (e.g. Nest's Throttler); the /auth/* endpoints do no heavy work before the hook runs.
  • revalidate is not immediate revocation. It only fires on the renewal path (past 50% of TTL), so a demoted/deactivated operator can retain access for up to ttl/2 after the change lands on the host side — see the callout above.

There's still no session store — revalidate is a per-renewal callback into the host, not a revocation list. Server-side session storage, OAuth/OIDC flows inside Telescope, and per-view authorization granularity remain out of scope: it's a single all-or-nothing dashboard session plus the existing authorizeAction for mutations. The codec is swappable, so a stored/revocable session could be added later without an API break.

Securing the console with your own guards

dashboardAuth is Telescope's own turnkey login. If your app already has an auth guard — a session/cookie check, an Inertia-style InertiaAuthGuard, whatever fronts the rest of your app — you don't need a second auth system. guards (+ imports for its dependencies) lets you front the console with THAT guard directly, on both TelescopeModule (the API) and TelescopeUiModule (the page).

TelescopeModule.forRoot({
  guards: [ConsoleAuthGuard],
  imports: [AuthModule], // resolves ConsoleAuthGuard's own dependencies
});

TelescopeUiModule.forRoot({
  guards: [ConsoleAuthGuard],
  imports: [AuthModule],
});

Set guards on both modules. They're independent options in separate packages: TelescopeUiModule has no visibility into what you passed TelescopeModule, or vice versa. A guard on the API alone still leaves the page (HTML shell + assets) reachable; a guard on the page alone still leaves the API reachable.

Why a guard here must handle cookies, not just headers

Most apps gate their API with a header — Authorization: Bearer <token>. That works for fetch/XHR calls the SPA makes, but not for the full-page navigation that loads the dashboard shell in the first place: a browser typing/clicking its way to /telescope sends a plain GET with whatever cookies are set for the origin — it cannot attach a bearer header to a navigation. A guard that only reads Authorization will 401 the shell every time, even for an already-logged-in admin.

So a guard passed to guards needs to authenticate from a cookie (your app's own session cookie, not dashboardAuth's), optionally falling back to a header for callers that do carry one (an API client, an internal service). And because it fronts BOTH a page (full-page GET) and a JSON API (fetch/XHR from the SPA), it should respond differently per surface: a redirect for the page (so an anonymous visitor lands on your sign-in screen, not a bare 401 page) and a plain 401/403 for the API (so the SPA's own fetches fail cleanly instead of getting redirected into an HTML sign-in page).

import {
  type CanActivate,
  type ExecutionContext,
  Inject,
  Injectable,
} from '@nestjs/common';
import { parseCookieHeader, readCookieHeader } from '@dudousxd/nestjs-telescope';
import { SessionService } from './session.service';

@Injectable()
export class ConsoleAuthGuard implements CanActivate {
  constructor(@Inject(SessionService) private readonly sessions: SessionService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const http = context.switchToHttp();
    const request = http.getRequest();
    const response = http.getResponse();

    // Full-page navigations (the dashboard shell) carry only cookies — no
    // Authorization header. XHR/fetch calls from the SPA (or an external
    // client) may carry a Bearer token instead; accept either.
    const cookieValue = parseCookieHeader(readCookieHeader(request))['app_session'];
    const bearer = request.headers?.authorization?.replace(/^Bearer\s+/i, '');
    const user = cookieValue
      ? await this.sessions.verifyCookie(cookieValue)
      : bearer
        ? await this.sessions.verifyToken(bearer)
        : null;

    if (user?.isAdmin) return true;

    // `sec-fetch-mode: navigate` marks a real browser navigation (loading the
    // HTML shell). Everything else — the SPA's own polling/fetch calls — is
    // API-shaped and gets a plain 401 instead of a redirect.
    if (request.headers?.['sec-fetch-mode'] === 'navigate') {
      response.redirect(302, '/signin?next=' + encodeURIComponent(request.url));
    } else {
      response.status(401).send({ message: 'Unauthorized' });
    }
    return false;
  }
}

parseCookieHeader / readCookieHeader are exported from @dudousxd/nestjs-telescope (the same helpers dashboardAuth uses internally) — reuse them instead of hand-rolling cookie parsing; your guard's own session format is entirely yours (this example delegates to a SessionService, but a JWT-in-cookie, an opaque session-store lookup, or bridging Keycloak/Passport all fit the same shape).

guards vs dashboardAuth

dashboardAuthguards
Auth mechanismTelescope's own signed cookie + built-in login/session-bridge endpointsWhatever CanActivate you bring — typically your app's existing auth
Fronts the page (HTML shell + assets)?No — the shell stays public by design; only the API is gatedYes, when set on TelescopeUiModule too
SetupOne config object, zero extra codeYou write (or already have) the guard
Best forStandalone hosts with no existing admin authHosts that already authenticate the rest of the app and want ONE gate to reuse

They're independent — nothing stops you from setting both (a host guard AND dashboardAuth both run; a request must pass every configured gate, since guards appends to Telescope's own gate on the API side — see TelescopeModuleOptions.guards). Most hosts pick one.

On this page