---
title: "Portal auth and capability model"
description: "Short-lived capability token permission model used by the mikan admin, login, and session portals."
url: "https://geminixiang.github.io/portal-auth-model/"
---

# Portal auth and capability model

The design goal is to let users conveniently open management, login, and session-view pages while avoiding mixing "read data", "change settings", and "write secrets" into one permission.

## Three portal links

| Interface            | How users get it                                                         | What it can do                                                                                                            | Token lifetime | One-time token? |
| -------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | -------------- | --------------- |
| Admin portal         | `/admin` / `/pi-admin`                                                   | Manage conversations, model, sandbox, auto-reply, workspace previews, and events. Can also generate session/login links.  | 30 minutes     | No              |
| Login / vault portal | `/login` / `/pi-login`, or generated by the admin portal                 | Store API keys or complete built-in OAuth flows, writing credentials into the vault.                                      | 15 minutes     | Yes, on write   |
| Session view         | `session` / `/session` / `/pi-session`, or generated by the admin portal | View the session timeline; when interactive mode is available, also send messages from the web page back to that session. | 24 hours       | No              |

In short:

```text
/admin   → change settings, view workspace, generate other links
/link    → write vault secrets or OAuth credentials
/session → view session; optionally send messages back to the session
```

These three pages share the same portal shell, but they do not share the same authorization token.

## Permission boundaries

### Admin portal

The admin portal is control-plane access. Anyone with an admin link can manage mikan settings and conversation state for a short time.

The admin portal can:

- view the current user and conversation identity
- list conversations in the working directory
- read and update conversation model, thinking level, sandbox mount, auto-reply, and Slack reply mode
- read and update global model, sandbox defaults, and Slack defaults
- view limited workspace files, skills, and events metadata/files
- delete events for the selected conversation
- generate a session view link or login/vault link for the target conversation

The admin portal does not directly write secret values. Even when it generates a login link, the real secret write still goes through the one-time token flow of the Login / vault portal.

### Login / vault portal

The Login / vault portal is the highest-risk action capability because it can write credentials.

The Login / vault portal can:

- show the credential or OAuth onboarding form for a specified vault
- write environment variables into that vault
- write credential files from a preset or OAuth flow, such as config files required by supported tools
- complete supported OAuth flows and save access tokens, refresh tokens, or credential files
- notify the source conversation after a successful write

Important login token behavior:

- opening the `/link` page does not consume the token
- starting OAuth does not consume the token
- completing a credential POST or OAuth callback consumes the token
- when the same platform user creates a new login token, the old login token becomes invalid

Additional protections:

- Credential POST routes require `Content-Type: application/json`.
- When `LINK_URL` / `MIKAN_LINK_URL` is set, credential POST routes check same-origin `Origin` or `Referer`.
- OAuth state is independent from the login token, has a 10-minute TTL, and uses a PKCE verifier.
- Secret values are not re-rendered to the browser; existing vault summaries show only secret names and mount targets.

### Session view

Session view is session content access. It is mainly used to view a structured session timeline.

Session view can:

- render the session timeline
- navigate parent/thread session relationships
- subscribe to live status and timeline updates through SSE
- when interactive wiring is available, send messages from the web page back to the selected session

Session view is not purely read-only. As long as the `/session/message` route exists and interactive wiring is available, a session view token can send a `session_view` event and call the bot handler.

The session view token is anchored to the base session file. When navigating with `/session?session=<file.jsonl>`, it can only switch to session files in the same directory.

## Route and token mapping

| Route                | Method | Token source      | Validation                               | Notes                                                    |
| -------------------- | ------ | ----------------- | ---------------------------------------- | -------------------------------------------------------- |
| `/admin`             | `GET`  | query `token`     | `adminTokenStore.peek()`                 | Render admin portal.                                     |
| `/admin/api/*`       | `GET`  | query `token`     | `adminTokenStore.peek()`                 | Unauthorized returns 403.                                |
| `/admin/api/*`       | `POST` | JSON body `token` | `adminTokenStore.peek()`                 | Unauthorized returns 403.                                |
| `/link`              | `GET`  | query `token`     | `linkTokenStore.peek()`                  | Render login/vault page; does not consume token.         |
| `/api/link/complete` | `POST` | JSON body `token` | `linkTokenStore.consume()`               | Write credentials; consumes token.                       |
| `/api/oauth/start`   | `POST` | JSON body `token` | `linkTokenStore.peek()` + OAuth state    | Create OAuth redirect; does not consume login token yet. |
| `/oauth/callback`    | `GET`  | query `state`     | OAuth state + `linkTokenStore.consume()` | Complete OAuth; consumes OAuth state and login token.    |
| `/session`           | `GET`  | query `token`     | `sessionViewTokenStore.peek()`           | Render session page.                                     |
| `/session/stream`    | `GET`  | query `token`     | `sessionViewTokenStore.peek()`           | Open SSE stream; requires interactive wiring.            |
| `/session/message`   | `POST` | JSON body `token` | `sessionViewTokenStore.peek()`           | Send session message; requires interactive wiring.       |

## Why not use one token type

The three token types have different risks:

- Admin token: reusable short-lived management permission.
- Login token: can write secrets, so it has a shorter lifetime and is consumed on write.
- Session view token: useful for sharing and reviewing sessions, so it lasts longer, but permission is limited to the session view scope.

Even if a full dashboard is added later, these boundaries should remain:

- Dashboard identity may authorize viewing and settings operations.
- Secret writes should still require a short-lived one-time capability, or equivalent second confirmation.
- Standalone session links can remain capability links for session viewing.

## Implementation locations

| Feature              | Main code                                                         |
| -------------------- | ----------------------------------------------------------------- |
| Portal HTTP server   | `startWebServer()` in `src/web/server.ts`                         |
| Admin portal         | `src/web/admin/portal.ts`, `src/web/admin/store.ts`               |
| Login / vault portal | `src/web/login/portal.ts`, `src/web/login/store.ts`               |
| Session view         | `src/web/session-view/portal.ts`, `src/web/session-view/store.ts` |
| Shared token store   | `src/web/token-store.ts`                                          |
| Shared portal shell  | `src/web/portal-shell.ts`                                         |

`startWebServer()` dispatch order:

1. `GET /health`
2. Agent event HTTP routes
3. Admin routes
4. Session view routes
5. Login / vault routes
6. `404`

The server starts only when `LINK_PORT` / `MIKAN_LINK_PORT` can be parsed as a port. If `LINK_URL` / `MIKAN_LINK_URL` is set but no port is configured, mikan uses the default port `8181`.

Token stores are currently in-memory and `src/main.ts` cleans expired tokens every five minutes. A process restart invalidates all unexpired web tokens.

These URLs are bearer capabilities. Query-string tokens can leak through browser history, screenshots, copied URLs, or proxy logs; share them only with the intended user and never publish them in chat channels or issue trackers.
