fix(auth): the session row must carry the id the token claims

The sid revocation check added in e2589d4 rejected EVERY authenticated request
with 401. issue_session generated session_id = Uuid::new_v4(), minted the access
token with it as sid, then called create_session WITHOUT passing it, so Postgres
assigned the row a different primary key. is_session_live(sid) therefore never
matched and the liveness check failed closed on every call.

Both halves were individually correct, which is why unit tests did not catch it:
303 backend tests passed throughout. The e2e suite caught it, as a wallet that
authenticated successfully and then showed 0 for every asset.

NewSession now carries the id explicitly and create_session binds it, at all
three call sites (extension/website login and the refresh rotation, which shares
the helper).

Verified: fiat-balance e2e goes from all-zero to $108.21.
This commit is contained in:
jwinterm
2026-08-02 21:32:58 -04:00
parent f48373282f
commit c64cd9be8e
4 changed files with 13 additions and 2 deletions
+1
View File
@@ -224,6 +224,7 @@ async fn issue_session(
state state
.db .db
.create_session(NewSession { .create_session(NewSession {
id: session_id,
user_id, user_id,
refresh_token_hash, refresh_token_hash,
platform: platform.to_string(), platform: platform.to_string(),
+1
View File
@@ -302,6 +302,7 @@ pub async fn website_verify(
state state
.db .db
.create_session(NewSession { .create_session(NewSession {
id: session_id,
user_id: user.id, user_id: user.id,
refresh_token_hash, refresh_token_hash,
platform: Platform::Web.to_string(), platform: Platform::Web.to_string(),
+6 -2
View File
@@ -22,11 +22,15 @@ impl Database {
#[instrument(skip(self, input), fields(user_id = %input.user_id, platform = %input.platform))] #[instrument(skip(self, input), fields(user_id = %input.user_id, platform = %input.platform))]
pub async fn create_session(&self, input: NewSession) -> Result<Session, AppError> { pub async fn create_session(&self, input: NewSession) -> Result<Session, AppError> {
let sql = format!( let sql = format!(
// `id` is bound explicitly: the access token carries it as `sid`,
// so the row and the token must agree or revocation checks reject
// every request.
"INSERT INTO sessions \ "INSERT INTO sessions \
(user_id, refresh_token_hash, platform, device_info, expires_at) \ (id, user_id, refresh_token_hash, platform, device_info, expires_at) \
VALUES ($1, $2, $3, $4, $5) RETURNING {SESSION_COLS}" VALUES ($1, $2, $3, $4, $5, $6) RETURNING {SESSION_COLS}"
); );
let session = sqlx::query_as::<_, Session>(&sql) let session = sqlx::query_as::<_, Session>(&sql)
.bind(input.id)
.bind(input.user_id) .bind(input.user_id)
.bind(&input.refresh_token_hash) .bind(&input.refresh_token_hash)
.bind(&input.platform) .bind(&input.platform)
+5
View File
@@ -208,6 +208,11 @@ pub struct NewUser {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NewSession { pub struct NewSession {
/// The session's primary key. Supplied by the caller (NOT defaulted by the
/// DB) because the access token embeds it as `sid` for revocation: if the
/// row got a different id than the token, every authenticated request would
/// fail the liveness check.
pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub refresh_token_hash: String, pub refresh_token_hash: String,
pub platform: String, pub platform: String,