Newer
Older
navi-1 / docs / auth.md
@Eugene Sukhodolskiy Eugene Sukhodolskiy on 9 May 19 KB Document Android OAuth bridge page flow

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


Architecture overview

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

Key design choices:

  • Backend as OAuth proxy — browser never sees gnexus-auth tokens directly.
  • Encrypted token storageaccess_token and refresh_token are encrypted with Fernet (cryptography) before hitting the DB.
  • Cookie-based sessionsnavi_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:

# 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
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

.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

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

{ "ok": true }

GET /auth/me

Return current authenticated user.

Response 200

{
  "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

[
  {
    "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

[
  {
    "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

{ "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

{ "role": "admin" }

Response 200

{ "ok": true }

PATCH /admin/profiles/{profile_id}/availability

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

Request body

{ "is_admin_only": true }

Response 200

{ "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

{ "ok": true }

Errors

  • 400 — invalid JSON or HMAC verification failed

WebSocket auth

WebSocket connections authenticate via the same navi_auth_session cookie. 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).

Sessions created via WebSocket get user_id set from the authenticated user. Anonymous WebSocket connections are not supported for session creation.


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:

.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:

.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:

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.