---
path: /unauthorized
surface: tenant-admin
domain: meta
auth: anon (public deny page)
source: apps/oshun/tenant-admin/src/app/unauthorized/page.tsx
status: drafted
last_walked: '—'
---

# Tenant session required

## Purpose

Public deny page shown when a request to a gated tenant-admin route has no valid
session. It explains why access was refused and links back to the handoff so the
identity provider can issue a session. One of two public paths (`/unauthorized`,
`/handoff`) in `src/middleware.ts`.

## Entry points

- Middleware redirect (`src/middleware.ts`) when a non-public path has
  no/invalid session token:
  `/unauthorized?reason=<missing-session|invalid-session>&returnTo=<path>`
- Server-component redirect from every gated page when
  `getTenantServerSession()` returns null:
  `/unauthorized?reason=missing-session&returnTo=<route>`
- Direct URL `/unauthorized` (public; no auth required)

## Layout regions

No shell chrome (`TenantShell` is not used — this is a bare page so no session
leaks into the layout):

- **Main**: `<main data-testid="tenant-unauthorized-page">`
  - `<h1>Tenant session required</h1>`
  - reason copy paragraph (from `REASON_COPY[reason]`)
  - paragraph with the **return to the handoff** link

## States

- [ ] **Loading** — N/A; static server component, no data fetch
- [ ] **Empty** — N/A; always renders heading + copy + link
- [ ] **Populated (short)** — `reason=missing-session` (default) → "No
      tenant-admin session was found. Sign in through your identity provider and
      try again."
- [ ] **Populated (long)** — N/A
- [ ] **Error (recoverable)** — N/A; no query
- [ ] **Error (unrecoverable)** — unknown `reason` value falls back to the
      `missing-session` copy; `invalid-session` → "Your tenant-admin session is
      no longer valid. Sign in again to refresh your scope claims."
- [ ] **Offline** — static page; renders from cache if SW present (verify)
- [ ] **Gated** — N/A; this IS the deny page (public path in middleware). No
      information leak — copy is generic and never echoes session data
- [ ] **Standalone PWA** — verify; no manifest/SW confirmed in source

## Interactions

- [ ] **return to the handoff** (link, `next/link`)
  - Function: navigates to `/handoff?returnTo=<encoded returnTo>`
    (`encodeURIComponent`); `returnTo` defaults to `/` when absent
  - Keyboard: only focusable element; Enter activates
  - Screen reader: announces "return to the handoff"
  - Touch target: standard inline link — verify ≥ 44×44 px hit area on coarse
    pointers
  - Mobile (≤ 640 px): no layout delta (no shell)
  - Offline behavior: navigation only; `/handoff` is a public path
  - Telemetry: _None observed in source._

## Data & contracts

- **Reads**: `searchParams` (`reason`, `returnTo`) from the URL only — no
  BFF/network read
- **Writes**: _None._ (middleware separately deletes the `oshun-tenant-session`
  cookie when it redirected here with an invalid token)
- **Realtime**: _None._
- **Caching**: static / SSR; depends only on query params
- **Auth/role check**: none — `/unauthorized` is in middleware `PUBLIC_PATHS`

## Cross-references

- Middleware: `apps/oshun/tenant-admin/src/middleware.ts` (`PUBLIC_PATHS`,
  redirect construction, cookie delete on invalid token)
- Session: `apps/oshun/tenant-admin/src/lib/session-cookie.ts`
  (`parseTenantSessionToken`), `src/lib/server-session.ts`
  (`getTenantServerSession`)
- Handoff target: `/handoff` (other public path; no walkthrough file in scope)
- Gated routes that redirect here: [integrations.md](integrations.md),
  [members.md](members.md), [notifications.md](notifications.md),
  [policy.md](policy.md), [roles.md](roles.md), [status.md](status.md)
- Conventions:
  [../WALKTHROUGH/00-conventions.md](../WALKTHROUGH/00-conventions.md)

## Open questions / known gaps

- [ ] `/handoff` (the link target and other public path) has no walkthrough file
      in this scope; document it separately.
- [ ] No telemetry/event is fired on this deny page; confirm whether a denied-
      access event is expected.
