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).
Navi supports two modes:
httpOnly session cookie (navi_auth_session).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=falsemakes the server fully accessible to anyone who can reach it over the network. Use it only onlocalhost, a trusted LAN, or behind an authenticated reverse proxy.
For trusted single-user or local deployments, set:
NAVI_AUTH_ENABLED=false
When NAVI_AUTH_ENABLED=false:
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 }./admin endpoints are accessible without authentication.anonymous and grant full access.Sessions created while auth is disabled carry:
SELECT * FROM sessions WHERE user_id = 'anonymous';
This is a reliable marker that the session belongs to the local no-auth user.
To switch back to OAuth mode:
NAVI_AUTH_ENABLED=true (or remove the line).GNAUTH_CLIENT_ID, GNAUTH_CLIENT_SECRET, and NAVI_AUTH_ENCRYPTION_KEY.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).
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Browser │────▶│ Navi backend │────▶│ gnexus-auth │
│ (cookie) │◀────│ (proxy) │◀────│ (OAuth) │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ PostgreSQL │
│ user_auth_ │
│ sessions │
└─────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Headless │────▶│ X-Api-Token │────▶│ api_tokens │ │ client │ │ or ?api_token│ │ JOIN navi_ │ │ │◀────│ │◀────│ users │ └──────────────┘ └──────────────┘ └──────────────┘
Key design choices:
access_token and refresh_token are encrypted with Fernet (cryptography) before hitting the DB.navi_auth_session is an httpOnly + Secure (when HTTPS) + SameSite cookie.user, admin) plus fine-grained permissions from gnexus-auth.user_id columns are created automatically on startup using CREATE TABLE IF NOT EXISTS and ALTER TABLE ... ADD COLUMN IF NOT EXISTS.| 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 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:
/admin/*) → require_admin → 403 if role != admin.require_permission("navi.xxx") → 403 if permission missing.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
GET /auth/login
Navi backend:
client.build_authorization_request(return_to="/", scopes=["openid", "email", "profile", "roles", "permissions"])InMemoryPkceStore / InMemoryStateStore (from gnexus-auth-client-py)302 redirect to gnexus-auth /oauth/authorizeAfter 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 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:
User-Agent header (NaviAndroid/1.0) rather than a query parameter./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.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.GET /auth/login
Navi backend:
client.build_authorization_request(return_to="/", scopes=["openid", "email", "profile", "roles", "permissions"])InMemoryPkceStore / InMemoryStateStore (from gnexus-auth-client-py)platform=android alongside the state metadata so callback knows to skip the cookie302 redirect to gnexus-auth /oauth/authorizeUser authenticates with gnexus-auth, approves scopes, and is redirected back to:
GET /auth/callback?code=...&state=...
Navi backend:
state against InMemoryStateStorecode for tokens via client.exchange_authorization_code(code, state) (sync call wrapped in asyncio.to_thread())client.fetch_user(access_token)role from role_ids and collects permissions from permission_idsnavi_users row (id, email, display_name, role, permissions)user_auth_sessions row with encrypted tokensnavi_auth_session cookie and redirects to //auth/mobile-done?sid=<session_id>Every request with the cookie triggers get_current_user(request):
session_id from cookieuser_auth_sessions rowaccess_tokenrefresh_token, calls client.refresh_token(), updates DB with new tokensclient.fetch_user(access_token) to get fresh user datanavi_users with latest role/permissionsUser object, cached in request.state.userTokens 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).
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 |
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.| 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) |
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 |
| 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 |
NAVI_AUTH_ENCRYPTION_KEY.venv/bin/python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
Output example: gAAAAABm1234567890abcdefghijklmnopqrstuvwxyz1234567890abcd==
Critical:
user_auth_sessions will become unreadable.ValueError from TokenEncryptor).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.
| 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).
Create permissions with these exact slugs (the Navi backend checks for them literally):
navi.sessions.read_ownnavi.sessions.read_allnavi.sessions.delete_ownnavi.sessions.delete_allnavi.memory.read_ownnavi.memory.read_allnavi.files.read_ownnavi.files.read_allnavi.profiles.usenavi.profiles.managenavi.tools.usenavi.admin.accessnavi.debug.access| Permission | navi_user default |
navi_admin default |
|---|---|---|
navi.sessions.read_own |
✅ | ✅ |
navi.sessions.read_all |
❌ | ✅ |
| ... |
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.
.envGNAUTH_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
GET /auth/loginRedirect to gnexus-auth OAuth authorization endpoint. Sets PKCE + state internally.
Response 302 → Location: gnexus-auth /oauth/authorize
GET /auth/callbackOAuth callback. Validates state, exchanges code for tokens, creates DB session, sets cookie.
Query params
code — authorization code from gnexus-authstate — state parameter from gnexus-authResponse 302 → Location: /
Errors
400 — invalid state, PKCE failure, or token exchange failedPOST /auth/logoutLogout current user. Deletes DB session row and clears cookie.
Response 200
{ "ok": true }
GET /auth/meReturn 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 authenticatedAll admin endpoints require require_admin (role check) or require_permission(...).
GET /admin/sessionsReturn 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/usersReturn 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/memoryReturn all memory facts (global view). Requires navi.memory.read_all.
Response 200
{ "facts": [...], "count": 42 }
GET /admin/profilesReturn all profiles including admin-only ones. Requires navi.profiles.manage.
PATCH /admin/users/{user_id}/roleUpdate a user's cached role. Requires admin.
Request body
{ "role": "admin" }
Response 200
{ "ok": true }
PATCH /admin/profiles/{profile_id}/availabilityToggle 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" }
POST /webhooks/gnexus-authReceive webhooks from gnexus-auth. Payload is verified via HMAC and parsed by JsonWebhookParser.
Response 200
{ "ok": true }
Errors
400 — invalid JSON or HMAC verification failedFor 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.
POST /api-tokens in the web UI (Settings → API Keys)X-Api-Token HTTP header (REST) or ?api_token= query parameter (WebSocket)_resolve_user() falls back to token resolution when the cookie is absentrequest.state.user cacheNAVI_AUTH_ENABLED=false → return the fixed anonymous admin usernavi_auth_session cookie → OAuth session resolutionX-Api-Token header or ?api_token query paramNone)nav_<43-char URL-safe string> — 256 bits of entropy. Example:
nav_aB3xYz9WqLmNpQrStUvXyZaBCdEfGhIjKlMnOpQrStUvX
REST:
curl -H "X-Api-Token: nav_aB3xYz9W..." \ https://navi.gnexus.space/api/agents/profiles
WebSocket:
const ws = new WebSocket(
`wss://navi.gnexus.space/ws/sessions/${id}?api_token=${encodeURIComponent(token)}`
)
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.
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).
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).
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:
navi_users (they must log in at least once)user_id IS NULL sessions to the target user(category, key), the newer value is kept and the duplicate is deletedExample:
.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.
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.