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 peraccess_token_expirationin 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:
- Access token expiration – The JWT validator in
UserAuthLayersimply 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))
}
- Refresh inactivity timeout –
SessionService::process(RefreshSession)returnsExpiredonce the elapsed time sincelast_refreshedexceedsrefresh_token_expiration, preventing resurrection of idle sessions. - 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.
- Scheduled cleanup – The
SessionCleanupJobcron scans remaining session keys, deleting any whoselast_refreshedpredates the configured expiration window to catch edge cases where TTLs were extended or missing. - Manual termination – Users can call
UserAccount::TerminateSessionwith a refresh token to flag a session as terminated; future refresh attempts see theTerminatedstatus and refuse to mint tokens. Password resets also invokeTerminateAllSessionsso 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.