# Authentication & Authorization

Navi uses **gnexus-auth** as its identity provider via OAuth 2.0 + PKCE. The Navi backend acts as an OAuth client/proxy: it redirects users to gnexus-auth, exchanges the authorization code for tokens, encrypts and stores tokens in PostgreSQL, and issues an `httpOnly` session cookie (`navi_auth_session`).

## Table of contents

- [Disabling authentication](#disabling-authentication)
- [Architecture overview](#architecture-overview)
- [Role & permission model](#role--permission-model)
- [OAuth login flow](#oauth-login-flow)
- [Token refresh](#token-refresh)
- [Session & memory isolation](#session--memory-isolation)
- [Webhooks](#webhooks)
- [Environment variables](#environment-variables)
- [Setting up the gnexus-auth client](#setting-up-the-gnexus-auth-client)
- [API reference](#api-reference)
- [API token auth](#api-token-auth)
- [WebSocket auth](#websocket-auth)
- [Testing with mocked auth](#testing-with-mocked-auth)

---

Navi supports two modes:

1. **Multi-user OAuth mode** (default) — uses **gnexus-auth** as the identity provider via OAuth 2.0 + PKCE. The Navi backend acts as an OAuth client/proxy: it redirects users to gnexus-auth, exchanges the authorization code for tokens, encrypts and stores tokens in PostgreSQL, and issues an `httpOnly` session cookie (`navi_auth_session`).
2. **Single-user / no-auth mode** — set `NAVI_AUTH_ENABLED=false` in `.env`. Every request is treated as the local `anonymous` admin user. Useful for trusted local or single-user deployments where running an OAuth provider is unnecessary.

> **Security warning:** `NAVI_AUTH_ENABLED=false` makes the server fully accessible to anyone who can reach it over the network. Use it only on `localhost`, a trusted LAN, or behind an authenticated reverse proxy.

---

## Disabling authentication

For trusted single-user or local deployments, set:

```dotenv
NAVI_AUTH_ENABLED=false
```

When `NAVI_AUTH_ENABLED=false`:

- Navi **short-circuits** all OAuth, cookie, and API-token resolution.
- Every REST/WebSocket request is treated as the user `anonymous` with `role="admin"`.
- On startup, Navi upserts a fixed `navi_users` row:

  | Column | Value |
  |---|---|
  | `id` | `anonymous` |
  | `email` | `anonymous@navi.local` |
  | `display_name` | `Anonymous` |
  | `role` | `admin` |
  | `permissions` | `[]` |

- New sessions are created with `sessions.user_id = 'anonymous'`.
- `GET /auth/status` returns `{ "enabled": false, "configured": false }`.
- The web client skips the login screen and shows **Local mode** in the sidebar footer.
- `/admin` endpoints are accessible without authentication.
- API tokens still work; tokens created in this mode belong to `anonymous` and grant full access.

### Identifying anonymous sessions in the database

Sessions created while auth is disabled carry:

```sql
SELECT * FROM sessions WHERE user_id = 'anonymous';
```

This is a reliable marker that the session belongs to the local no-auth user.

### Re-enabling auth

To switch back to OAuth mode:

1. Stop Navi.
2. Set `NAVI_AUTH_ENABLED=true` (or remove the line).
3. Configure `GNAUTH_CLIENT_ID`, `GNAUTH_CLIENT_SECRET`, and `NAVI_AUTH_ENCRYPTION_KEY`.
4. Restart Navi.

Sessions created during no-auth mode remain owned by `anonymous` and will only be visible to admins (or to the anonymous user if auth is disabled again).

---

## Architecture overview

### OAuth flow (browser / Android)

```
┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   Browser   │────▶│  Navi backend │────▶│ gnexus-auth │
│  (cookie)   │◀────│   (proxy)     │◀────│  (OAuth)    │
└─────────────┘     └──────────────┘     └─────────────┘
                           │
                           ▼
                    ┌─────────────┐
                    │  PostgreSQL │
                    │ user_auth_  │
                    │ sessions    │
                    └─────────────┘
```

### API token flow (headless clients)

```
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│ Headless     │────▶│ X-Api-Token  │────▶│ api_tokens   │
│ client       │     │ or ?api_token│     │ JOIN navi_   │
│              │◀────│              │◀────│ users        │
└──────────────┘     └──────────────┘     └──────────────┘
```

Key design choices:

- **Backend as OAuth proxy** — browser never sees gnexus-auth tokens directly.
- **Encrypted token storage** — `access_token` and `refresh_token` are encrypted with Fernet (`cryptography`) before hitting the DB.
- **Cookie-based sessions** — `navi_auth_session` is an `httpOnly` + `Secure` (when HTTPS) + `SameSite` cookie.
- **Hybrid role + permission model** — two fixed roles (`user`, `admin`) plus fine-grained permissions from gnexus-auth.
- **DDL auto-migration** — all auth tables and `user_id` columns are created automatically on startup using `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.

---

## Role & permission model

### Fixed roles (hard boundary)

| Role | Slug source | Description |
|---|---|---|
| `user` | default | Regular user. Can own sessions, manage own memory, use profiles. |
| `admin` | `role_ids` contains `navi_admin` (configurable via `GNAUTH_ADMIN_ROLE_SLUG`) | Full access. Admins can view all sessions, manage users, access debug endpoints, and see admin-only profiles. |

Role is determined at login by inspecting `client_access_list[].role_ids` from gnexus-auth for the matching `client_id`.

### Permissions (fine-grained, from gnexus-auth)

Permissions are `permission_ids` from gnexus-auth, also read from `client_access_list` for the Navi client. They control what a user can do **inside** their role.

| Permission | Description | User default | Admin default |
|---|---|---|---|
| `navi.sessions.read_own` | View own sessions | ✅ | ✅ |
| `navi.sessions.read_all` | View all sessions | ❌ | ✅ |
| `navi.sessions.delete_own` | Delete own sessions | ✅ | ✅ |
| `navi.sessions.delete_all` | Delete other users' sessions | ❌ | ✅ |
| `navi.memory.read_own` | View own memory | ✅ | ✅ |
| `navi.memory.read_all` | View other users' memory | ❌ | ✅ |
| `navi.files.read_own` | Download own session files | ✅ | ✅ |
| `navi.files.read_all` | Download other users' files | ❌ | ✅ |
| `navi.profiles.use` | Use agent profiles | ✅ | ✅ |
| `navi.profiles.manage` | Manage profile availability | ❌ | ✅ |
| `navi.tools.use` | Use tools | ✅ | ✅ |
| `navi.admin.access` | Access admin endpoints | ❌ | ✅ |
| `navi.debug.access` | Access debug panel | ❌ | ✅ |

**Permission check rules:**

1. If the function is **admin-only** (e.g. debug panel, `/admin/*`) → `require_admin` → 403 if role != `admin`.
2. Otherwise → `require_permission("navi.xxx")` → 403 if permission missing.
3. Admin role **implicitly grants all permissions** as a soft fallback, but explicit permission checks are still used for clarity.

**Example in code:**

```python
# Hard admin-only boundary
@router.get("/admin/sessions")
async def admin_sessions(user: Annotated[User, Depends(require_admin)]):
    ...

# Flexible permission gate
@router.get("/sessions")
async def list_sessions(user: Annotated[User, Depends(require_user)]):
    if user.role == "admin" or user.has_permission("navi.sessions.read_all"):
        return all_sessions
    return own_sessions
```

---

## OAuth login flow

### Browser flow (default)

```
GET /auth/login
```

Navi backend:
1. Calls `client.build_authorization_request(return_to="/", scopes=["openid", "email", "profile", "roles", "permissions"])`
2. Stores PKCE verifier and state in `InMemoryPkceStore` / `InMemoryStateStore` (from `gnexus-auth-client-py`)
3. Returns `302` redirect to gnexus-auth `/oauth/authorize`

After the user logs in at gnexus-auth, they are redirected back to `/auth/callback`, where the backend exchanges the code, creates a DB session, sets an `httpOnly` cookie, and redirects to `/`.

### Android flow (WebView bridge)

Android WebView **cannot share cookies** with the system browser (Chrome, DuckDuckGo, etc.). Therefore the Android app uses a **bridge-page flow** that avoids cookies entirely:

```
GET /auth/login          ← Backend detects Android via User-Agent (NaviAndroid/1.0)
  ↓ 302
gnexus-auth /oauth/authorize   ← Opens in external browser (SSO works)
  ↓
GET /auth/callback?code=...&state=...
  ↓ 302
/auth/mobile-done?sid=<session_id>
  ↓ JS redirect
intent://auth/callback?sid=...#Intent;scheme=navi;package=com.navi.client;end
  ↓
Android intercepts intent → CookieManager.setCookie() → reload WebView
```

Key points:
- Backend detects Android from the HTTP `User-Agent` header (`NaviAndroid/1.0`) rather than a query parameter.
- For Android, `/auth/callback` **does not set a cookie** — instead it redirects to `/auth/mobile-done`.
- `/auth/mobile-done` renders a bridge page styled with the gnexus UI kit (dark theme, success accent border). It immediately attempts a Chrome Intent URL auto-redirect; if the browser blocks it, a fallback button appears after 1.5s.
- The `intent://` URL is intercepted by Android's `intent-filter` for `navi://auth/callback`, which delivers the `sid` to `MainActivity.onNewIntent`. The app then sets the `navi_auth_session` cookie via `CookieManager` and reloads the WebView.

### 1. Initiate login

```
GET /auth/login
```

Navi backend:
1. Calls `client.build_authorization_request(return_to="/", scopes=["openid", "email", "profile", "roles", "permissions"])`
2. Stores PKCE verifier and state in `InMemoryPkceStore` / `InMemoryStateStore` (from `gnexus-auth-client-py`)
3. **For Android**: stores `platform=android` alongside the state metadata so callback knows to skip the cookie
4. Returns `302` redirect to gnexus-auth `/oauth/authorize`

### 2. User logs in at gnexus-auth

User authenticates with gnexus-auth, approves scopes, and is redirected back to:

```
GET /auth/callback?code=...&state=...
```

### 3. Callback handling

Navi backend:
1. Validates `state` against `InMemoryStateStore`
2. Exchanges `code` for tokens via `client.exchange_authorization_code(code, state)` (sync call wrapped in `asyncio.to_thread()`)
3. Fetches user info via `client.fetch_user(access_token)`
4. Determines `role` from `role_ids` and collects `permissions` from `permission_ids`
5. **Upserts** `navi_users` row (id, email, display_name, role, permissions)
6. **Creates** `user_auth_sessions` row with encrypted tokens
7. **Browser**: sets `navi_auth_session` cookie and redirects to `/`
8. **Android**: redirects to `/auth/mobile-done?sid=<session_id>`

### 4. Subsequent requests

Every request with the cookie triggers `get_current_user(request)`:
1. Reads `session_id` from cookie
2. Looks up `user_auth_sessions` row
3. Decrypts `access_token`
4. If expired: decrypts `refresh_token`, calls `client.refresh_token()`, updates DB with new tokens
5. Calls `client.fetch_user(access_token)` to get fresh user data
6. Upserts `navi_users` with latest role/permissions
7. Returns `User` object, cached in `request.state.user`

---

## Token refresh

Tokens are refreshed lazily — only when `expires_at < now()`. The refresh happens inside `get_current_user()` (and `get_current_user_ws()` for WebSocket). After refresh, the DB row is updated with new encrypted `access_token` and `refresh_token`.

If refresh fails (e.g. user revoked access in gnexus-auth), the DB session is deleted and the user is treated as unauthenticated (401).

---

## Session & memory isolation

### New tables

**`navi_users`** — cached user identity from gnexus-auth

| Column | Type | Description |
|---|---|---|
| `id` | TEXT PRIMARY KEY | `user_id` from gnexus-auth |
| `email` | TEXT NOT NULL | |
| `display_name` | TEXT | From `profile.display_name` |
| `role` | TEXT NOT NULL DEFAULT 'user' | `user` or `admin` |
| `permissions` | TEXT NOT NULL DEFAULT '[]' | JSON array of permission slugs |
| `created_at` | TIMESTAMPTZ | |
| `updated_at` | TIMESTAMPTZ | |

**`user_auth_sessions`** — encrypted OAuth tokens

| Column | Type | Description |
|---|---|---|
| `id` | TEXT PRIMARY KEY | Cookie value |
| `user_id` | TEXT NOT NULL → navi_users.id | |
| `access_token_enc` | TEXT NOT NULL | Fernet-encrypted access token |
| `refresh_token_enc` | TEXT NOT NULL | Fernet-encrypted refresh token |
| `expires_at` | TIMESTAMPTZ | |
| `created_at` | TIMESTAMPTZ | |
| `last_used_at` | TIMESTAMPTZ | |

### Isolation

- **`sessions.user_id`** — nullable for legacy sessions. Legacy sessions are accessible only to admins.
- **`memory_facts.user_id`** — facts are scoped per user. Admin with `navi.memory.read_all` can pass `user_id=None` for global search.
- **`memory_summary.user_id`** — conversation summaries are per-user.

### Ownership rules in endpoints

| Endpoint | Access rule |
|---|---|
| `GET /sessions` | Admin or `navi.sessions.read_all` → all sessions; else → own sessions |
| `GET /sessions/{id}` | Owner, admin, or `navi.sessions.read_all` |
| `DELETE /sessions/{id}` | Owner, admin, or `navi.sessions.delete_all` |
| `GET /sessions/{id}/files/{name}` | Owner, admin, or `navi.files.read_all` |
| `GET /sessions/{id}/context` | Admin only (`require_admin`) |
| `GET /sessions/{id}/planning` | Admin only (`require_admin`) |

---

## Webhooks

Navi receives webhook events from gnexus-auth at:

```
POST /webhooks/gnexus-auth
```

**Payload verification:** HMAC-SHA256 via `HmacWebhookVerifier` (from `gnexus-auth-client-py`). The webhook secret must be configured in gnexus-auth admin panel and matched against `client_secret` (or a dedicated webhook secret if the library supports it).

**Events handled:**

| Event | Action |
|---|---|
| `user.blocked` / `user.archived` / `user.deleted` | `DELETE FROM user_auth_sessions WHERE user_id = $1` |
| `auth.global_logout` | `DELETE FROM user_auth_sessions` (all sessions) |
| `session.revoked` | If `user_id` present → invalidate all sessions for that user |
| `client.roles_changed` / `client.permissions_changed` | Clear cached `permissions` and `role` in `navi_users` so next request re-fetches from gnexus-auth |

---

## Environment variables

| Variable | Type | Default | Description |
|---|---|---|---|
| `NAVI_AUTH_ENABLED` | bool | `true` | Master switch. Set `false` to disable OAuth/API-token auth and treat every request as the local `anonymous` admin. |
| `GNAUTH_BASE_URL` | str | `http://gnexus-auth.local` | gnexus-auth server base URL |
| `GNAUTH_CLIENT_ID` | str | `""` | OAuth client ID from gnexus-auth |
| `GNAUTH_CLIENT_SECRET` | str | `""` | OAuth client secret |
| `GNAUTH_REDIRECT_URI` | str | `http://localhost:8000/auth/callback` | Must match the redirect URI registered in gnexus-auth exactly |
| `GNAUTH_ADMIN_ROLE_SLUG` | str | `navi_admin` | Role slug that grants admin status |
| `NAVI_AUTH_ENCRYPTION_KEY` | str | `""` | **Fernet key** (base64, 32 bytes). Generate once, keep constant. See below. |
| `NAVI_AUTH_COOKIE_NAME` | str | `navi_auth_session` | Cookie name |
| `NAVI_AUTH_COOKIE_SECURE` | bool | `False` | Set `True` when serving over HTTPS |
| `NAVI_AUTH_COOKIE_SAMESITE` | str | `lax` | SameSite policy |
| `NAVI_AUTH_COOKIE_MAX_AGE_DAYS` | int | `30` | Cookie lifetime in days |

### Generating `NAVI_AUTH_ENCRYPTION_KEY`

```bash
.venv/bin/python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```

Output example: `gAAAAABm1234567890abcdefghijklmnopqrstuvwxyz1234567890abcd==`

**Critical:**
- Generate **once** and reuse across all Navi instances sharing the same database.
- **Never change** after the first launch — existing encrypted tokens in `user_auth_sessions` will become unreadable.
- Without it, the app refuses to start (`ValueError` from `TokenEncryptor`).

---

## Setting up the gnexus-auth client

### 1. Create a Client in gnexus-auth

Go to gnexus-auth admin panel → Clients → New.

| Field | Value |
|---|---|
| Name | `navi` |
| Redirect URI | `https://navi.your-domain.com/auth/callback` (or `http://localhost:8000/auth/callback` for local dev) |
| Scopes | `openid`, `email`, `profile`, `roles`, `permissions` |

If you need both server and local development, check if gnexus-auth supports multiple redirect URIs per client. Otherwise create a separate dev client.

### 2. Create roles

| Role slug | Display name | Mapped to in Navi |
|---|---|---|
| `navi_user` | Navi User | `role: "user"` |
| `navi_admin` | Navi Admin | `role: "admin"` |

The admin slug must match `GNAUTH_ADMIN_ROLE_SLUG` (default: `navi_admin`).

### 3. Create permissions

Create permissions with these exact slugs (the Navi backend checks for them literally):

- `navi.sessions.read_own`
- `navi.sessions.read_all`
- `navi.sessions.delete_own`
- `navi.sessions.delete_all`
- `navi.memory.read_own`
- `navi.memory.read_all`
- `navi.files.read_own`
- `navi.files.read_all`
- `navi.profiles.use`
- `navi.profiles.manage`
- `navi.tools.use`
- `navi.admin.access`
- `navi.debug.access`

### 4. Assign permissions to roles

| Permission | `navi_user` default | `navi_admin` default |
|---|---|---|
| `navi.sessions.read_own` | ✅ | ✅ |
| `navi.sessions.read_all` | ❌ | ✅ |
| ... | | |

### 5. Configure the webhook (optional but recommended)

Set the webhook URL in gnexus-auth to:

```
https://navi.your-domain.com/webhooks/gnexus-auth
```

Configure a shared secret for HMAC verification. The Navi backend uses `HmacWebhookVerifier` from `gnexus-auth-client-py`.

### 6. Update `.env`

```dotenv
GNAUTH_BASE_URL=https://auth.your-domain.com
GNAUTH_CLIENT_ID=your-client-id
GNAUTH_CLIENT_SECRET=your-client-secret
GNAUTH_REDIRECT_URI=https://navi.your-domain.com/auth/callback
NAVI_AUTH_ENCRYPTION_KEY=your-fernet-key-generated-once
```

---

## API reference

### Auth endpoints

#### `GET /auth/login`

Redirect to gnexus-auth OAuth authorization endpoint. Sets PKCE + state internally.

**Response `302`** → Location: gnexus-auth `/oauth/authorize`

---

#### `GET /auth/callback`

OAuth callback. Validates state, exchanges code for tokens, creates DB session, sets cookie.

**Query params**
- `code` — authorization code from gnexus-auth
- `state` — state parameter from gnexus-auth

**Response `302`** → Location: `/`

**Errors**
- `400` — invalid state, PKCE failure, or token exchange failed

---

#### `POST /auth/logout`

Logout current user. Deletes DB session row and clears cookie.

**Response `200`**
```json
{ "ok": true }
```

---

#### `GET /auth/me`

Return current authenticated user.

**Response `200`**
```json
{
  "id": "user-uuid",
  "email": "user@example.com",
  "display_name": "User Name",
  "role": "admin",
  "permissions": ["navi.sessions.read_all", "navi.memory.read_all"]
}
```

**Errors**
- `401` — not authenticated

---

### Admin endpoints

All admin endpoints require `require_admin` (role check) or `require_permission(...)`.

#### `GET /admin/sessions`

Return all sessions across all users.

**Response `200`**
```json
[
  {
    "session_id": "...",
    "profile_id": "secretary",
    "user_id": "user-uuid",
    "name": "Research task",
    "message_count": 12,
    "pinned": false,
    "created_at": "2026-05-04T10:00:00+00:00",
    "last_active": "2026-05-04T10:30:00+00:00"
  }
]
```

---

#### `GET /admin/users`

Return all registered `navi_users`.

**Response `200`**
```json
[
  {
    "id": "user-uuid",
    "email": "user@example.com",
    "display_name": "User Name",
    "role": "admin",
    "permissions": ["navi.sessions.read_all"],
    "created_at": "2026-05-04T10:00:00+00:00",
    "updated_at": "2026-05-04T10:30:00+00:00"
  }
]
```

---

#### `GET /admin/memory`

Return all memory facts (global view). Requires `navi.memory.read_all`.

**Response `200`**
```json
{ "facts": [...], "count": 42 }
```

---

#### `GET /admin/profiles`

Return all profiles including admin-only ones. Requires `navi.profiles.manage`.

---

#### `PATCH /admin/users/{user_id}/role`

Update a user's cached role. Requires admin.

**Request body**
```json
{ "role": "admin" }
```

**Response `200`**
```json
{ "ok": true }
```

---

#### `PATCH /admin/profiles/{profile_id}/availability`

Toggle `is_admin_only` for a profile. Requires `navi.profiles.manage`.

**Request body**
```json
{ "is_admin_only": true }
```

**Response `200`**
```json
{ "ok": true, "note": "Profile availability is managed via profile config files" }
```

---

### Webhook endpoint

#### `POST /webhooks/gnexus-auth`

Receive webhooks from gnexus-auth. Payload is verified via HMAC and parsed by `JsonWebhookParser`.

**Response `200`**
```json
{ "ok": true }
```

**Errors**
- `400` — invalid JSON or HMAC verification failed

---

## API token auth

For headless clients that cannot perform OAuth2 redirects (scripts, IoT devices, voice assistants, smart watches), Navi supports long-lived API tokens.

Full documentation: [`docs/api_tokens.md`](api_tokens.md).

### How it works

1. User creates a token via `POST /api-tokens` in the web UI (Settings → API Keys)
2. The plain token is shown **once** — SHA-256 hash is stored in DB
3. Headless client sends the token in the `X-Api-Token` HTTP header (REST) or `?api_token=` query parameter (WebSocket)
4. `_resolve_user()` falls back to token resolution when the cookie is absent

### Resolution order

1. Check `request.state.user` cache
2. If `NAVI_AUTH_ENABLED=false` → return the fixed `anonymous` admin user
3. Read `navi_auth_session` cookie → OAuth session resolution
4. No cookie → try `X-Api-Token` header or `?api_token` query param
5. No token → anonymous (`None`)

### Token format

`nav_<43-char URL-safe string>` — 256 bits of entropy. Example:
```
nav_aB3xYz9WqLmNpQrStUvXyZaBCdEfGhIjKlMnOpQrStUvX
```

### Using tokens

**REST:**
```bash
curl -H "X-Api-Token: nav_aB3xYz9W..." \
  https://navi.gnexus.space/api/agents/profiles
```

**WebSocket:**
```javascript
const ws = new WebSocket(
  `wss://navi.gnexus.space/ws/sessions/${id}?api_token=${encodeURIComponent(token)}`
)
```

---

## WebSocket auth

WebSocket connections authenticate via the same `navi_auth_session` cookie **or** an API token via query parameter.

When `NAVI_AUTH_ENABLED=false`, no cookie or token is required; every WebSocket connection is treated as the `anonymous` admin user, including for owned sessions.

### Cookie auth (browser clients)

The browser sends cookies automatically during the WebSocket handshake. Navi's WebSocket handler (`navi/api/websocket.py`) resolves the user from the cookie the same way REST endpoints do (`get_current_user_ws`).

### Token auth (headless clients)

Headless clients append `?api_token=<token>` to the WebSocket URL. The handler reads `websocket.query_params.get("api_token")` in `_resolve_user()` and resolves the user the same way as the `X-Api-Token` REST header.

> **Security note**: The query parameter is visible in server access logs. Future versions may support a `{type: "auth", api_token: "..."}` message sent immediately after connect to avoid log exposure.

Sessions created via WebSocket get `user_id` set from the authenticated user. Anonymous WebSocket connections are not supported for owned sessions (legacy `user_id=NULL` sessions are admin-only).

---

## Legacy data migration

Sessions, memory facts, and summaries created **before** multi-user auth have `user_id = NULL`. They remain visible **only to admins** until explicitly assigned to a user.

To transfer legacy data to a specific user after their first login:

```bash
.venv/bin/python scripts/assign_legacy_sessions.py <user_id>
```

The script:
1. Verifies the target user exists in `navi_users` (they must log in at least once)
2. Assigns all `user_id IS NULL` sessions to the target user
3. Assigns legacy memory facts — if the target user already has a fact with the same `(category, key)`, the newer value is kept and the duplicate is deleted
4. Assigns or merges the legacy memory summary

**Example:**
```bash
.venv/bin/python scripts/assign_legacy_sessions.py 550e8400-e29b-41d4-a716-446655440000
```

After running the script, the target user will see all previously anonymous sessions and memory in their account.

---

## Testing with mocked auth

Integration tests in `tests/integration/conftest.py` bypass real OAuth by overriding FastAPI dependencies:

```python
app.dependency_overrides[get_current_user] = lambda: fake_user
app.dependency_overrides[require_user] = lambda: fake_user
app.dependency_overrides[require_admin] = lambda: fake_user_admin
```

This allows tests to run without a running gnexus-auth instance.

For unit tests that don't start the full app, auth is simply not injected.
