Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Session Management

What counts as a session?

A session is the Redis record keyed by a SessionId UUID that maps a logged-in user to the metadata needed to mint tokens and audit activity:

pub struct Session {
    pub id: SessionId,
    pub user_id: Uuid,
    pub terminated: bool,
    pub last_refreshed: u64,
}

The record stores the owning user, whether it has been terminated, and the last time it was refreshed so we can expire idle sessions without touching the database. Bulk operations rely on the companion UserSessions index, which keeps the list of session IDs per user so ListUserSessions and TerminateAllSessions can enumerate them without scanning SQL tables.

Dual-token model

Each session issues two JWTs with different audiences and lifetimes:

pub struct JwtConfig {
    pub secret: CompactString,
    pub refresh_token_expiration: time::Duration,
    pub access_token_expiration: time::Duration,
    pub issuer: CompactString,
    pub access_audience: CompactString,
    pub refresh_audience: CompactString,
}
  • Access token – Short-lived bearer token for regular APIs guarded by UserAuthLayer. It embeds the user ID (sub) and session ID (sid) and expires per access_token_expiration in the configuration.
  • Refresh token – Long-lived token scoped to refreshing or terminating the session. Its audience differs from the access token and it carries a longer exp, typically thirty days by default.

Both tokens are minted when SessionService::process(CreateSession) is called. The refresh expiration is also used as the TTL for the session key in Redis, ensuring Redis evicts the record once the refresh token is no longer valid:

pub async fn verify_refresh_token(
    &self,
    refresh_token: &str,
) -> Result<Option<SessionId>, Error> {
    let config = self.load_config().await?;
    let decode = config.jwt.refresh_token_decoder();
    let session_id = decode(refresh_token).ok().map(|c| c.claims.sid);
    Ok(session_id.map(SessionId))
}

The refresh token ID is verified via SessionService::verify_refresh_token before any privileged session action proceeds, keeping refresh operations isolated from the access token path.

Lifecycle

Creation

Login and registration flows call SessionService::CreateSession after authentication succeeds. The service allocates a fresh session UUID, stores the Redis record with a TTL derived from the refresh lifetime, emits a UserLoginEvent for downstream consumers, and returns both JWTs:

async fn process(&self, input: CreateSession) -> Result<(AccessToken, RefreshToken), Error> {
    let session_id = Uuid::new_v4();
    let now = time::OffsetDateTime::now_utc().unix_timestamp() as u64;
    let config = self.load_config().await?;

    let session = Session {
        id: SessionId(session_id),
        user_id: input.user_id,
        terminated: false,
        last_refreshed: now,
    };

    // Store with TTL equal to refresh token expiration
    Session::write_kv_with_ttl(&mut redis, SessionId(session_id), session, refresh_token_expiration).await?;

    let access = config.jwt.generate_access_token(input.user_id, SessionId(session_id))?;
    let refresh = config.jwt.generate_refresh_token(input.user_id, SessionId(session_id))?;

    // Emit login event for downstream consumers
    UserLoginEvent { ... }.send(&self.mq).await?;

    Ok((access, refresh))
}

OAuth and email providers share this path so every login surface behaves consistently.

Refresh

Clients invoke the RefreshSession RPC with the refresh token in the x-refresh-token header. The server decodes the session ID from the token, reloads the Redis record, rejects terminated or missing sessions, enforces the inactivity timeout, and then rewrites the record with an updated timestamp before minting a new access/refresh pair:

pub const REFRESH_TOKEN_HEADER: &str = "x-refresh-token";

async fn process(&self, input: RefreshSession) -> Result<SessionRefreshResult, Error> {
    let Some(mut session) = Session::read(&mut redis, SessionId(input.session_id)).await? else {
        return Ok(SessionRefreshResult::NotFound);
    };

    if session.terminated {
        return Ok(SessionRefreshResult::Terminated);
    }

    let last_refreshed = time::OffsetDateTime::from_unix_timestamp(session.last_refreshed as i64);
    let now = time::OffsetDateTime::now_utc();
    if now - last_refreshed > refresh_expiration {
        return Ok(SessionRefreshResult::Expired);
    }

    session.last_refreshed = now.unix_timestamp() as u64;
    Session::write_kv(&mut redis, SessionId(input.session_id), session).await?;

    let access = config.jwt.generate_access_token(user_id, SessionId(input.session_id))?;
    let refresh = config.jwt.generate_refresh_token(user_id, SessionId(input.session_id))?;
    Ok(SessionRefreshResult::Refreshed(access, refresh))
}

Refreshes never accept the access token, and the refresh token is not honored by UserAuthLayer, so each token stays in its intended lane.

Expiration and revocation

Sessions can disappear through several channels:

  1. Access token expiration – The JWT validator in UserAuthLayer simply rejects expired access tokens, forcing the client to refresh with a valid refresh token:
pub const ACCESS_TOKEN_HEADER: &str = "x-user-authorization";

async fn user_auth(metadata: &HeaderMap, mut redis: RedisConnection) -> Result<UserId, Status> {
    let header = metadata
        .get(ACCESS_TOKEN_HEADER)
        .and_then(|h| h.to_str().ok())
        .ok_or(Status::unauthenticated("Missing authorization header"))?;

    let config = find_config_from_redis::<AuthConfig>(&mut redis).await?;
    let decode = config.jwt.decoder();
    let jwt_claims = decode(header)
        .map_err(|_| Status::unauthenticated("Invalid authorization header"))?
        .claims;
    Ok(UserId(jwt_claims.sub))
}
  1. Refresh inactivity timeoutSessionService::process(RefreshSession) returns Expired once the elapsed time since last_refreshed exceeds refresh_token_expiration, preventing resurrection of idle sessions.
  2. Redis TTL – Because the session key is stored with a TTL equal to the refresh lifetime, Redis will evict it automatically even if the refresh path never runs again.
  3. Scheduled cleanup – The SessionCleanupJob cron scans remaining session keys, deleting any whose last_refreshed predates the configured expiration window to catch edge cases where TTLs were extended or missing.
  4. Manual termination – Users can call UserAccount::TerminateSession with a refresh token to flag a session as terminated; future refresh attempts see the Terminated status and refuse to mint tokens. Password resets also invoke TerminateAllSessions so compromised credentials cannot keep a foothold.

Administrative levers

Operations tooling can directly reuse SessionService::TerminateAllSessions to purge a user’s active logins, and the password-reset flow demonstrates how to hook that processor after security-sensitive events. Beyond the cron cleanup job, there is currently no dedicated admin RPC that lists or manages sessions; support dashboards should wire into the Redis-backed processors (ListUserSessions, TerminateSession, TerminateAllSessions) when that capability is required.

Token-free user APIs

Only the gRPC services mounted without UserAuthLayer skip access-token checks. These “entry” endpoints live on UserAuth and cover registration, login, password resets, OAuth challenges, and session refresh; everything else, including the UserAccount service, is wrapped by UserAuthLayer and requires the access token in the x-user-authorization header. The refresh token is still required via metadata when calling RefreshSession or TerminateSession, but it never unlocks general-purpose APIs.