zeroclaw-labs/zeroclaw · error

Nevis session expired

Error message

Nevis session expired

What it means

The token resolved to an identity, but identity.session_expiry (the exp claim from introspection, nevis.rs:229) is earlier than the local clock (nevis.rs:158-160). A session_expiry of 0 (missing exp) skips the check, so this fires only when a concrete past expiry was reported.

Source

Thrown at crates/zeroclaw-runtime/src/security/nevis.rs:159

        let identity = match self.validation_mode {
            TokenValidationMode::Local => self.validate_token_local(token).await?,
            TokenValidationMode::Remote => self.validate_token_remote(token).await?,
        };

        if self.require_mfa && !identity.mfa_verified {
            bail!(
                "MFA is required but user '{}' has not completed MFA verification",
                crate::security::redact(&identity.user_id)
            );
        }

        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        if identity.session_expiry > 0 && identity.session_expiry < now {
            bail!("Nevis session expired");
        }

        Ok(identity)
    }

    /// Validate token by calling the Nevis introspection endpoint.
    async fn validate_token_remote(&self, token: &str) -> Result<NevisIdentity> {
        let introspect_url = format!(
            "{}/auth/realms/{}/protocol/openid-connect/token/introspect",
            self.instance_url.trim_end_matches('/'),
            self.realm,
        );

        let mut form = vec![("token", token), ("client_id", &self.client_id)];
        // client_secret is optional (public clients don't need it)
        let secret_ref;
        if let Some(ref secret) = self.client_secret {
            secret_ref = secret.as_str();

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Send the caller through re-authentication or token refresh, then retry with the new token
  2. Verify host clock sync (NTP/systemd-timesyncd) on the machine running ZeroClaw
  3. If you mint test tokens, confirm exp is epoch seconds, not milliseconds, and in the future

Example fix

// before
match provider.validate_token(token).await {
    Ok(id) => id,
    Err(e) => return internal_error(e),
}

// after
match provider.validate_token(token).await {
    Ok(id) => id,
    Err(e) if e.to_string().contains("Nevis session expired") => return unauthorized_reauth(),
    Err(e) => return internal_error(e),
}
Defensive patterns

Strategy: try-catch

Validate before calling

fn jwt_expired(token: &str) -> Option<bool> {
    let payload = token.split('.').nth(1)?;
    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
    let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    let exp = claims.get("exp")?.as_u64()?;
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
    Some(exp < now)
}

Try / catch

Match err.to_string().contains("Nevis session expired") and return 401 with a re-authenticate challenge; treat it as a user-state event, not a server error.

Prevention

When it happens

Trigger: validate_token with a token whose exp is in the past; local host clock running ahead of the IdP (NTP drift); a stale token fixture reused in tests.

Common situations: Resuming an integration test with an old token; VM or container clock skew after sleep or migration; cached token reused after the user logged out elsewhere.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/6c5b5400da8e6911. Report an issue: GitHub.