ai‑memory Authentication Ladder: From Static Bearer Tokens to OIDC Device‑Flow Tokens
ai‑memory implements a four‑rung "auth ladder" that resolves every HTTP request to a specific capability tier, with static bearer tokens at the base and OIDC device‑flow tokens enabling per‑developer database users.
The auth ladder in akitaonrails/ai‑memory provides a deliberately ordered authentication system where the first matching credential wins. This design prevents privilege escalation and ensures predictable capability boundaries across single‑user deployments, multi‑user teams, and SSO‑proxied environments.
The Four Rungs of the Authentication Ladder
The ladder processes credentials in strict priority order. Each rung represents a distinct trust boundary with specific capability limits.
| Rung | Trigger | Credential Source | Resulting Capability |
|---|---|---|---|
| 0 – Anonymous | No [auth].bearer_token configured |
— | Requests accepted with anonymous ActorContext (historic single‑user default) |
| 1 – Root | Authorization: Bearer <root‑token> matches configured bearer_token |
Static bearer token in server config | Root access; writes attributed to root_username if configured |
| 1b – Proxy‑Asserted User | Authorization: Bearer <proxy‑token> matches actor_proxy_bearer_token and request carries X‑Memory‑Actor‑* headers |
Separate static bearer for trusted SSO proxy | User (or root if OIDC issuer + subject matches root identity) |
| 2 – DB User | Bearer token hashes to row in users.token_hash |
Per‑user bearer tokens in SQLite users table |
Capabilities of that specific user; /admin/* routes remain root‑only |
| 3 – 401 | Bearer present but matches none of above | — | Request rejected with 401 Unauthorized |
The ladder termination at first match prevents a DB user token from ever being interpreted as root, even if tokens collide by accident.
Core Implementation: crates/ai-memory-mcp/src/auth.rs
The AuthState struct in auth.rs orchestrates the entire ladder. It holds the root token, optional proxy bearer, and an optional MultiUserResolver for database lookups.
// AuthState construction and configuration methods
// Lines 64-71: Enable trusted proxy bearer
pub fn with_trusted_proxy_bearer(mut self, token: impl Into<Option<String>>) -> Self {
self.actor_proxy_bearer = token.into();
self
}
// Lines 73-81: Enable multi-user DB lookups
pub fn with_multiuser(
mut self,
pepper: TokenPepper,
reader: MultiUserReader,
writer: MultiUserWriter
) -> Self {
self.resolver = Some(MultiUserResolver::new(pepper, reader, writer));
self
}
// Lines 88-99: Constructor initializing the root token
pub fn new(expected: impl Into<Option<String>>) -> Self {
Self {
expected: expected.into().map(TokenHash::from),
actor_proxy_bearer: None,
resolver: None,
}
}
The require_bearer middleware performs constant‑time token comparison using subtle::ConstantTimeEq to prevent timing attacks, then routes through the appropriate rung logic.
Ladder Execution Flow
- Bearer extraction — Parse
Authorizationheader; missing header → Rung 0 (anonymous) - Root check — Constant‑time compare to
AuthState::expected; match → Rung 1 - Proxy check — Compare to
actor_proxy_bearer; on success extractX‑Memory‑Actor‑IssuerandX‑Memory‑Actor‑Subheaders - DB‑user lookup — Hash token with server‑wide
TokenPepper, queryreader.find_active_user_by_token_hash; hit → Rung 2, miss → Rung 3
OIDC Device‑Flow Integration
The authentication ladder reaches its most dynamic tier through OIDC device‑flow tokens, implemented across two crates.
Token Acquisition: crates/ai-memory-llm/src/oidc.rs
The OIDC implementation follows the RFC 8628 device authorization flow:
// Key operations in the OIDC module
pub async fn discover(issuer: &Url) -> Result<OidcMetadata, OidcError>;
pub async fn request_device_code(
client: &reqwest::Client,
endpoint: &DeviceAuthorizationEndpoint,
client_id: &str,
scopes: &[String]
) -> Result<DeviceCodeResponse, OidcError>;
pub async fn poll_token_once(
client: &reqwest::Client,
token_endpoint: &TokenEndpoint,
client_id: &str,
device_code: &str
) -> Result<PollResult, OidcError>;
The resulting OidcTokenResponse is persisted as an OidcToken structure containing access token, refresh token, and OIDC metadata.
CLI Integration: crates/ai-memory-cli/src/commands/auth.rs
Developers initiate device flow through the CLI:
$ ai-memory auth login oidc-device --issuer https://idp.example \
--client-id ai-memory-dev --timeout 300
The command:
- Discovers endpoints via
.well-known/openid-configuration - Requests device code and displays user verification URL
- Polls token endpoint until authorization completes
- Saves tokens to
~/.local/share/ai-memory/auth.jsonviaOidcToken::save
The HTTP client (ai_memory_cli::http_client) subsequently reads this file to set Authorization: Bearer <access-token> headers.
DB User Rung: Token Storage and Verification
Per‑user bearer tokens populate Rung 2 of the ladder. These are created during ai-memory init and stored with cryptographic hashing:
// Example: configuring the auth ladder in ai-memory.toml
[auth]
bearer_token = "root-static-token" # Rung 1
actor_proxy_bearer_token = "proxy-static-token" # Rung 1b (optional)
token_pepper = "random-pepper-bytes" # Enables Rung 2 DB-user lookups
secure_cookie = true
root_issuer = "https://idp.example"
root_subject = "root-subject"
The TokenPepper (server‑wide constant) ensures that token hashes remain non‑portable across server instances even if the users table is compromised.
Trusted Proxy Pattern (Rung 1b)
For SSO‑terminated deployments, a forward proxy authenticates via its own static bearer while asserting the human identity through headers:
GET /api/v1/workspaces/ws/projects/p/handoffs HTTP/1.1
Authorization: Bearer proxy-static-token
X-Memory-Actor-Issuer: https://idp.example
X-Memory-Actor-Sub: oidc-subject-alice
The server validates this request by:
- Confirming
proxy-static-tokenmatchesactor_proxy_bearer_token - Extracting the OIDC pair from
X-Memory-Actor-*headers - Creating
ActorContextwithissuer=https://idp.example,sub=oidc-subject-alice
Root elevation rule: If the OIDC pair exactly matches the configured root_issuer + root_subject, the request is upgraded from user to root. This is tested in oidc_root_requires_the_exact_issuer_and_subject_pair ([crates/ai-memory-mcp/tests/auth.rs](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-mcp/tests/auth.rs), lines 50‑99).
Validating the Ladder: Test Coverage
The comprehensive test suite in crates/ai-memory-mcp/tests/auth.rs exercises each rung:
proxy_asserting_oidc_identity_is_downgraded_to_user_level(lines 16‑47): Confirms that proxy‑asserted identities without root‑matching OIDC pairs receive user‑level capabilitiesoidc_root_requires_the_exact_issuer_and_subject_pair(lines 50‑99): Validates that root elevation requires precise OIDC pair matching, preventing partial matches
These tests use the actual AuthState and MultiUserResolver implementations to ensure the ladder behaves identically in production and test environments.
Summary
- ai‑memory's auth ladder provides four strictly ordered rungs from anonymous access to root privileges
- Static bearer tokens serve root (Rung 1) and proxy authentication (Rung 1b) with constant‑time comparison
- OIDC device‑flow tokens enable per‑developer authentication without shared secrets, populating the DB‑user rung (Rung 2)
- Trusted proxy headers allow SSO termination while preserving identity attribution through
X-Memory-Actor-*headers - All components are implemented in
crates/ai-memory-mcp/src/auth.rs,crates/ai-memory-llm/src/oidc.rs, andcrates/ai-memory-cli/src/commands/auth.rs
Frequently Asked Questions
How does the auth ladder prevent token confusion between rungs?
The ladder uses strict priority ordering and early termination. Each incoming bearer is tested against root, then proxy, then hashed for DB lookup. A token matching the root pattern can never be interpreted as a DB user token, even if an attacker registers a colliding hash. The subtle::ConstantTimeEq comparisons prevent timing side channels during these checks.
Can I use OIDC tokens directly with the ai‑memory server without a proxy?
Yes. When you run ai-memory auth login oidc-device, the CLI stores the OIDC access token in auth.json. The HTTP client sends this as Authorization: Bearer <token>. The server hashes this token with token_pepper and looks it up in users.token_hash. If you pre‑register the token hash via ai-memory init, this places you at Rung 2 (DB user).
What happens if my OIDC issuer and subject match the root configuration?
If your proxy‑asserted or directly‑presented OIDC issuer + subject pair exactly matches the server's root_issuer and root_subject values, the auth ladder elevates you to root regardless of which token (proxy or DB user) authenticated the request. This allows designated administrators to retain root capabilities through OIDC flows rather than static tokens.
Where are per‑user bearer tokens created and stored?
Per‑user tokens are generated during ai-memory init and stored in the SQLite users table with peppered SHA‑256 hashes (not plaintext). The TokenPepper is a server‑wide secret that prevents rainbow table attacks even if the database is exposed. Token verification uses MultiUserResolver::find_active_user_by_token_hash against a read pool for performance.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →