Skip to main content

The session models

Every sign-in is one record of a session model.

  • A user's session is an Identities::UserSession, stored in identities_user_sessions.
  • An admin's session is an Identities::AdminSession, stored in identities_admin_sessions.
  • The two tables have the same columns. Only the owner column differs.

The columns​

Table identities_user_sessions {
id bigint [pk]
uuid string [unique, not null]
identities_user_id bigint [not null, ref: > identities_users.id]
token_digest string [null, unique]
csrf_token string [null]
ip_address string [null]
user_agent string [null]
created_at timestamptz [not null]
updated_at timestamptz [not null]
}
ColumnWhy it exists
idThe value the signed cookie holds. One primary-key read is the whole check
uuidEvery table in the repo has one. It is the session's identifier in any API response. An API response never shows id
identities_user_idThe owner. "Sign out everywhere" and a password change find every session of one owner through it. identities_admin_sessions has identities_admin_id instead
token_digestThe SHA-256 digest of a bearer token. Null on a cookie session. The unique index makes the bearer lookup one indexed read
csrf_tokenThe value a cookie-authenticated write must send back in X-CSRF-Token. Null on a token session, which needs none
ip_addressThe caller's address at login, as the Rails authentication generator records it. For support, and for a later "your signed-in devices" page
user_agentThe caller's browser or app at login. The same use
created_atWhen the sign-in happened. The absolute limit reads it
updated_atWhen the session was last used. The idle limit reads it

What an Identities::UserSession does not hold:

  • No expires_at. The limits live in configuration, so changing one needs no migration. A session's expiry is computed from created_at and updated_at when it is read.
  • No raw token. If the table leaks, the digests are useless to the thief, because SHA-256 cannot be reversed.
  • Nothing about the person beyond the owner id. Name, email and access rights are read from the owner's own record when the JSON is built.

How Rails finds the session on a request​

Every endpoint that needs a signed-in person runs the same lookup first.

The request carriesWhat Rails doesResult
A jodapp_session_id cookie whose signature is validIdentities::UserSession.not_expired.find_by(id: the signed id)The Identities::UserSession, or 401 when there is none
A jodapp_session_id cookie whose signature failsTreats it as no cookie401
An Authorization: Bearer <token> headerComputes the SHA-256 digest, then Identities::UserSession.not_expired.find_by(token_digest: the digest)The Identities::UserSession, or 401
Both a cookie and a headerChecks the cookie first, then the header. No client sends bothAs above
NeitherNothing to look up401

After the lookup, Rails keeps two things for the rest of the request.

  • CurrentRequest.identities_user is the owner, the same attribute the rest of the app already reads.
  • CurrentRequest.identities_user_session is the Identities::UserSession.
    • Logout destroys it. A password change keeps it and destroys the owner's other sessions.
  • The admin concern sets CurrentRequest.identities_admin and CurrentRequest.identities_admin_session the same way.

The lookup never reads the other identity system's table.

  • The user concern only ever queries identities_user_sessions.
  • The admin concern only ever queries identities_admin_sessions.
  • A wrong-side lookup is impossible, because the code for it does not exist.

The limits​

An Identities::UserSession expires in two ways, and the earlier one wins.

  • The idle limit: the session was last used too long ago. It reads updated_at.
  • The absolute limit: the session was created too long ago. It reads created_at.
Identity systemIdle limitAbsolute limitWhat the person experiences
Users7 days30 daysSomeone who uses Jod at least weekly stays signed in, and signs in again at most once a month
Admins1 day7 daysAn admin signs in again after a full day away, and at least once a week

The limits live in configuration, per identity system, in config/application.rb:

config.x.sessions = {
identities_user: { idle_limit: 7.days, absolute_limit: 30.days },
identities_admin: { idle_limit: 1.day, absolute_limit: 7.days },
touch_interval: 15.minutes
}

One scope on each model holds the rule, and everything else calls it.

module Identities
class UserSession < ApplicationRecord
scope :not_expired, -> {
limits = Rails.configuration.x.sessions.fetch(:identities_user)
where(updated_at: limits.fetch(:idle_limit).ago..)
.where(created_at: limits.fetch(:absolute_limit).ago..)
}
end
end
  • The lookup above uses it, so an expired session is never found.
  • The job that deletes expired sessions, below, uses its opposite, so the two can never disagree about which sessions are alive.

Keeping a session alive is one write, and a rare one.

  • A request that finds the Identities::UserSession touches updated_at, but only when it is older than the touch interval, 15 minutes.
  • So a person who clicks all day causes four writes an hour, not one per click.
  • The bearer path does the same. The limits and the touch belong to the session, not to how it was found.

The jobs that delete expired sessions​

Expired sessions are already dead to the lookup. These jobs only keep the tables small.

  • Identities::DeleteExpiredUserSessionsJob and Identities::DeleteExpiredAdminSessionsJob, one per table.
  • Each runs hourly at minute 20, from config/sidekiq_scheduler.yml, on the low queue.
  • Each job class sets retry: 0 on itself. The scheduler passes only the queue to an ActiveJob class, so a retry line in the yml would be ignored.
  • Each deletes not_expired.invert_where in batches of 1000.

How we know they ran:

  • The Sidekiq admin panel's "Recurring Jobs" tab, which sidekiq-scheduler adds, lists both jobs with their cron line, the last time each was enqueued and the next time it will be.
  • A job that raises goes to the panel's "Dead" tab on its first failure, because it has no retries, and the exception reaches Sentry through the Sidekiq error handler that already exists.
  • Nothing alerts when the scheduler itself stops. The jobs send no Sentry cron check-in: Sentry includes one cron monitor and charges for each further one, and a stopped job is never a security problem (below). A free alert for a stopped scheduler is a later decision, recorded in Identities D5, the row "How we know the cleanup jobs ran".
  • There is no log line to watch. A container log is not a control.

A stopped job is never a security problem.

  • The lookup enforces the limits on every request.
  • The only effect of a stopped job is a table that grows until the job runs again.