How to Implement OIDC Device Authentication for ai-memory Shared Server Hook Writes

The ai-memory server requires a JWT containing the mcp:read realm role for hook writes, which the CLI obtains via the OIDC Device Authorization Grant (RFC 8628) instead of static bearer tokens.

The akitaonrails/ai-memory repository supports per-developer authentication for shared server hook writes using the OpenID Connect (OIDC) Device Flow. This eliminates shared secrets by allowing headless clients to obtain scoped JWTs through a browser-based authorization flow, storing credentials securely in a local JSON file for automatic reuse and refresh.

Why Use OIDC Device Flow for ai-memory?

Static bearer tokens create security risks in shared environments. The OIDC Device Authorization Grant solves this by enabling authentication on devices without browsers (like remote servers or containers) while the actual approval happens on a separate user device. This flow provides offline_access scope support, giving the CLI long-lived refresh tokens that avoid repetitive re-authorization while maintaining strict per-user access control. The server validates each JWT against the configured OIDC realm (Keycloak, Auth0, etc.), ensuring the client possesses the mandatory mcp:read role before accepting hook writes.

Configuring Your OIDC Provider

Before running the CLI, configure your identity provider to support the device flow:

  1. Create a public client (no client secret required) in your OIDC provider admin console.
  2. Enable the Device Authorization Grant flow for this client.
  3. Add the offline_access scope to allow refresh tokens.
  4. Record the issuer URL (e.g., https://kc.example.com/realms/ai-memory) and client ID (e.g., my-cli-client).

These credentials allow the ai-memory client to discover endpoints and initiate the device flow without hardcoded secrets.

The Client-Side Authentication Flow

The implementation in crates/ai-memory-llm/src/oidc.rs follows the standard RFC 8628 flow through four distinct phases.

Discovery and Endpoint Detection

The discover() function fetches the provider's OpenID Connect discovery document (.well-known/openid-configuration) to locate the device authorization and token endpoints dynamically. This ensures the client always uses the current provider configuration without hardcoded URLs.

Requesting the Device Code

The CLI calls request_device_code() to POST the client ID and requested scopes to the device authorization endpoint. The provider returns a device_code, user_code, and verification_uri. The CLI displays these to the user, instructing them to open the URI in a browser and enter the code.

Polling for Authorization

Once the user visits the verification URI, the CLI enters a polling loop using poll_token_once(). This function repeatedly POSTs the device_code to the token endpoint at intervals specified by the provider, returning a PollOutcome enum indicating:

  • Pending: Authorization not yet complete; wait and retry.
  • SlowDown: Polling too fast; increase interval.
  • Denied or Expired: Flow failed; abort.
  • Token: Success; contains the access and refresh tokens.

Token Persistence with OidcToken

Upon success, the CLI creates an OidcToken struct (a specialization of StoredOAuthToken defined in crates/ai-memory-llm/src/stored_token.rs). This struct stores the access token, refresh token, expiration time, issuer, client ID, and token endpoint metadata (OidcExtras). The OidcToken::save() method writes this data to $HOME/.config/ai-memory/auth.json under the "oidc" key, preserving existing entries for other providers like OpenAI.

Implementing the Device Flow in Code

The following Rust example demonstrates the complete flow using the ai-memory-llm crate:

use std::time::Duration;
use ai_memory_llm::oidc::{self, OidcToken};

// 1️⃣ Discover endpoints
let client = reqwest::Client::new();
let discovery = oidc::discover(&client, "https://kc.example.com/realms/ai-memory")
    .await?;

// 2️⃣ Request a device code
let dev = oidc::request_device_code(
    &client,
    &discovery,
    "my-cli-client",
    oidc::OIDC_DEFAULT_SCOPE, // "openid profile offline_access"
)
.await?;
println!("Open {} and enter code {}", dev.verification_uri, dev.user_code);

// 3️⃣ Poll until authorized
loop {
    match oidc::poll_token_once(&client, &discovery, "my-cli-client", &dev.device_code).await? {
        oidc::PollOutcome::Pending => {
            tokio::time::sleep(Duration::from_secs(dev.interval.unwrap_or(5))).await;
        },
        oidc::PollOutcome::SlowDown => {
            tokio::time::sleep(Duration::from_secs(10)).await;
        },
        oidc::PollOutcome::Token(tok) => {
            let token = OidcToken::from_token_response(
                &tok, 
                "https://kc.example.com/realms/ai-memory", 
                "my-cli-client", 
                &discovery.token_endpoint, 
                None
            )?;
            token.save(&std::path::Path::new(&format!("{}/.config/ai-memory/auth.json", std::env::var("HOME")?)))?;
            break;
        },
        other => {
            eprintln!("Authorization failed: {:?}", other);
            break;
        }
    }
}

Alternatively, use the CLI command provided in crates/ai-memory-cli/src/commands/generate_auth_token.rs:

ai-memory auth login oidc-device \
    --issuer https://kc.example.com/realms/ai-memory \
    --client-id my-cli-client \
    --scope "openid profile offline_access"

Automatic Token Refresh

The OidcToken implementation includes the needs_refresh() method, which checks if the token expiration (expires_at_ms) is within a configurable threshold. When the CLI executes a hook write, it loads the stored token via OidcToken::load(), checks refresh status, and—if necessary—calls refresh_access_token() to perform an OAuth2 refresh grant using the stored refresh token. The new access token immediately overwrites the old entry in auth.json, ensuring seamless long-lived sessions without manual re-authentication.

Sending Authenticated Hook Writes

When performing writes, the CLI automatically loads the valid OIDC token and attaches it to HTTP requests. According to the implementation in crates/ai-memory-llm/src/auth.rs, the request includes:

  • The header X-Memory-Actor-User derived from the JWT's preferred_username claim.
  • The header Authorization: Bearer <access_token> containing the JWT itself.

The server receives the request at endpoints like /hook or /handoff, validates the JWT signature against the OIDC realm, and verifies the payload contains the mcp:read realm role before processing the write.

Example CLI usage that automatically handles authentication:

ai-memory hook capture --path notes/example.md --content "Hello world"

Key Implementation Files in the Source Code

Understanding these specific files helps when customizing the authentication flow:

Summary

  • The ai-memory server requires JWTs with the mcp:read realm role for hook writes, rejecting unauthenticated or under-privileged requests.
  • The CLI implements the OIDC Device Authorization Grant (RFC 8628) in crates/ai-memory-llm/src/oidc.rs to obtain per-developer tokens without browser access on the client device.
  • Tokens are stored as OidcToken entries in $HOME/.config/ai-memory/auth.json, supporting automatic refresh via the offline_access scope.
  • Use ai-memory auth login oidc-device to initiate the flow, then execute hook commands normally; the CLI handles JWT attachment and renewal automatically.
  • The server validates tokens against your configured OIDC provider (Keycloak, Auth0, etc.), ensuring secure, auditable access control for shared ai-memory instances.

Frequently Asked Questions

Which OIDC providers are compatible with ai-memory device authentication?

Any provider supporting the Device Authorization Grant (RFC 8628) and the offline_access scope works, including Keycloak, Auth0, Okta, and Azure AD. The implementation uses standard OIDC discovery, so it automatically adapts to provider-specific endpoints.

How does the ai-memory CLI know when to refresh the access token?

The OidcToken struct tracks expiration via the expires_at_ms field. Before each hook write, the CLI calls needs_refresh(); if the token is near expiry, refresh_access_token() uses the stored refresh token to obtain a new access token without user interaction.

What happens if I don't have the mcp:read realm role?

The server rejects the request with an authorization error. The JWT must contain the mcp:read role in its realm access claims, as enforced by the authentication middleware when validating the Authorization or X-Memory-Actor-User headers.

Where is the authentication state stored on disk?

The CLI persists tokens in $HOME/.config/ai-memory/auth.json (or platform equivalent). The file contains a JSON object with an "oidc" key holding the OidcToken data, alongside potential entries for other providers like OpenAI. Use OidcToken::remove() to clear stored credentials.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →