Skip to content

fix(auth): revoke a user's OIDC/BFF sessions when their account is deleted - #6324

Draft
hero101 wants to merge 5 commits into
developfrom
story/6315-oidc-session-revocation-cascade
Draft

fix(auth): revoke a user's OIDC/BFF sessions when their account is deleted#6324
hero101 wants to merge 5 commits into
developfrom
story/6315-oidc-session-revocation-cascade

Conversation

@hero101

@hero101 hero101 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Refs #6315

Deleting a user left their BFF/OIDC session untouched in Redis. The browser kept
authenticating against an account that no longer existed for up to the 30-day
absolute ceiling — an access-control failure (ISO 27001 A.5.18, SOC 2 CC6.2),
and an incomplete erasure too, since the session payload caches display name and
email (GDPR Art. 17).

The root cause is a missing primitive, not a missing call. Sessions live at
alkemio:sid:<sid> and there is no way to get from a subject to that subject's
sessions, so they cannot be enumerated, so they cannot be revoked. That same gap
blocks client-web#10070 and server#6073.

What this changes

WS1 — per-subject session index + OidcSessionRevocationService

  • alkemio:sub:<sub> Redis set. TTL rolled forward to the latest
    member's absolute ceiling and never shortened — a SADD without an EXPIRE
    leaks the key forever, and a shortened TTL would evict a live member and
    make revocation silently miss a session.
  • revokeAllForSub(sub, reason, opts?) tombstones every session with
    markTerminated, never destroy. This is the whole fix. A tombstone
    yields 401 and deterministically flips client-web to signed-out; destroy
    yields a silent anonymous fall-through with HTTP 200 — the reported bug in a
    new costume. The tombstone also nulls request_context_cache, so one verb
    closes both the access defect and the PII-residue defect
    .
  • Clears the refresh lock, prunes the index, then revokes the refresh grant at
    Hydra (RFC 7009 — did not exist anywhere in this repo before). 3 s
    timeout, no retry, no circuit breaker, each with a written rationale inline
    because constitution principle 8 requires all three.
  • Local teardown completes before the remote call, so a Hydra outage cannot
    leave a session alive.
  • exceptSid, per-session outcomes and partial-failure tolerance so
    server#6073 and client-web#10070 consume it unchanged.
  • Self-healing index write from CookieSessionStrategy, fire-and-forget.
    Without it every session alive on deploy day is permanently unrevocable —
    precisely the population carrying this bug. Unawaited, but not unobserved: the
    .catch() logs.

WS2 — the deletion cascade

deleteUser now calls revokeAllForSub and
kratosService.invalidateAllIdentitySessions (which already existed and was
simply never called from here). Both unconditional — deliberately not
gated on deleteData.deleteIdentity, since a surviving Kratos identity with
live sessions is exactly the orphan state. Both after commit, outside the
transaction, each individually trapped so neither can fail the delete
mutation
. Deletion has broken against Kratos four times before (#5350, #5678,
#4762, #2137); a security improvement must not become an availability
regression.

WS3 — graceful degradation in me

The seven me sub-resolvers return 0 / [] / an empty page / {} on an
empty actorID instead of throwing. Two of the seven were not in the original
triage list
, and missing either would have shipped a fix that does not work:

  • notifications throws ForbiddenException, not ValidationException, so an
    exception-type sweep skips it — and it is the paginated non-nullable field.
  • The real conversations thrower is in me.conversations.resolver.fields.ts.
    Relaxing only the container in me.resolver.fields.ts leaves
    me { conversations { conversations } } erroring exactly as before — which is
    what UserPendingMemberships actually selects.

Incidentally this also fixes a live defect wider than the orphan case:
createAnonymous() sets actorID = '', so these fields error for every
anonymous visitor today.

Structural note

New OidcCoreModule holds OidcService, the Redis client, the session-store
handle and the revocation service. UserModule cannot import OidcModule
that pulls in Authentication / ActorContext / Authorization /
PlatformAuthorizationPolicy, several of which sit above it, so it would be a
cycle. No forwardRef: constitution principle 2 says circular dependencies
require redesign, and forwardRef makes a cycle legal rather than absent. The
hoist also collapses two Redis connections into one.

Footprint

No DDL. No migration. No GraphQL schema delta. Verified, not assumed —
schema:print/sort/diff produced only unrelated description-text drift on
CalloutContributorsMapView (a different feature's un-regenerated docstrings),
which was reverted rather than folded in.

Audit-enum collision check (design-input trap 6)

Checked against 027-platform-role-redesign, whose slice A is code-complete and
in review, so the risk was live rather than hypothetical:

Enum 027 adds Collides with account_deleted?
PlatformAuditCategory platform_role_assignment, platform_user_record, platform_configuration, platform_resource No
PlatformAuditOutcome 12 values incl. identity_deleted, account_reset No — but note the near-miss
PlatformAuditInitiatorRole nothing n/a

No literal collision — but the check surfaced something more useful. 027
already owns the database-side audit row for user deletion (its T062 writes
platform_user_record / identity_deleted), and its residual-risk register
already carries an open high finding (spec-server-27) that this row is
written on only one branch. A second writer would make that finding unfixable.

So the revocation trail rides the existing OIDC audit stream instead
(session.revocation.initiatedsession.revoked per session →
session.revocation.completed). It fits the data better anyway — these are
per-session events carrying sub/client_id/rp_id, none of which
platform_audit_entry has a column for. Net effect: zero DDL, and this merges
with 027 in either order without touching src/migrations/.

Tests are the audit evidence

Per the compliance framing, "we call the method" is not proof that access ended.

  • revocation-ends-access.spec.ts wires the real revocation service to
    the real CookieSessionStrategy over a shared in-memory Redis and asserts
    the next request is rejected with CookieSessionInvalidError. rejects is
    load-bearing: not.toBeNull() would pass for a merely-destroyed session.
    Also asserts the cached display name and email are gone, and that a different
    subject's session still authenticates.
  • subject-contract.spec.ts pins session.sub === user.authenticationID
    (trap 7). If that cross-service convention ever changes, revokeAllForSub
    would return cheerful empty successful reports while revoking nothing — a
    control failing open and silent. The test's comment says so explicitly, so a
    future failure reads as "the subject source changed" rather than "the fixture
    drifted".
  • The leak-proof test caught two real defects in my own first draft: an
    upstream error message embedding the refresh token verbatim ("revocation failed for token <value>" — no pattern reliably catches every shape), and
    error.stack being logged raw, which defeats a redacted message entirely.
    Fixed by scrubbing the known secret values the session holds rather than
    pattern-guessing, with the pattern rules kept as a backstop.

Gates: pnpm lint, pnpm build, pnpm test:ci:no:coverage (7514 passed / 673
files / 0 failures), schema contract — all green in one uninterrupted run.

SDD artifacts

specs/107-oidc-session-revocation/spec.md, research.md (R0–R13),
plan.md, data-model.md, quickstart.md, contracts/ ×4, tasks.md
(39/39). Constitution check 10/10, no violations, no complexity-tracking
entries.

  • Clarify loop: 3 passes (5 resolutions → 1 → clean).
  • Analyze loop: 3 passes (8 findings → 3 → zero).

All ambiguities were resolved by decision and recorded in spec.md's
Clarifications section with rationale.

Every file:line in the triage document was re-verified against this branch.
Its behavioural claims all hold; two anchors had drifted, and its me guard
list was two short (see WS3 above) — research.md R0/R9 record both.

Findings recorded, not acted on

prompt: 'login' (alkemio#1989 lead) — confirmed as a code fact. It is
hardcoded unconditionally on every authorization request at
oidc.controller.ts:228 (the triage doc said :169; it drifted). Per OIDC Core
§3.1.2.1 this forces re-authentication and is exactly what stops Hydra setting
skip=true, defeating SSO. The mechanism is real and present; whether it causes
#1989 needs a live check. No change made — out of scope.

authenticationID backfill — assessed, recommended, not implemented.
Migration 1764590889000-userIdentityCleanup.ts:26 adds authenticationID uuid
nullable with no backfill, and cannot backfill in pure SQL because the
Kratos UUID is not derivable from the dropped email-based accountUpn. The
re-link is lazy and login-only (user.identity.service.ts, email → identity).

WS1–WS3 do not require it, but there is an honest residual gap worth
stating plainly: for a user whose authenticationID is NULL, deleteUser
skips revocation entirely (correctly — there is no subject to revoke by), so
their session is still not revoked on deletion. The exposure is bounded —
such a session resolves to no actor, so it carries no authorization, and after
WS3 it degrades to a clean empty state — but #6315's literal title stays true
for that subpopulation until the backfill lands. Recommendation: prioritise
the backfill as a follow-up
; it needs a Kratos email→identity lookup inside a
migration, a pattern this repo does not have, so it deserves its own design.

Follow-ups to file

Enumerated for a human to action — no issues were created.

  1. NEW BUG, highest priority — silently live in production. The shipped
    admin email-change flow (user.email.change.service.ts:519, from Allow changing a user's login email while preserving their Alkemio profile #6064)
    revokes only the Kratos session, so the BFF session survives an admin email
    change with full API access
    . Same defect class as this one, already
    deployed, no issue filed. revokeAllForSub(sub, 'email_changed') is ready
    and needs one call site.
  2. authenticationID backfill migration — see above.
  3. Epic alkemio#1868: ask the owner to promote the account-event cascade out
    of "What this unlocks" into must-have scope. Two open bugs (BUG: Deleting a user does NOT revoke their session (their session stays active BUT cannot login) #6315,
    client-web#10070) plus one silent production defect currently sit in a
    deliberately-deferred bucket that nobody owns.
  4. Record OIDC Back-Channel Logout as the target architecture on the epic.
    Alkemio now has several RPs; without it each new one recreates this bug.
  5. Verify or dismiss the prompt: 'login' lead (alkemio#1989) — evidence
    above.
  6. UNVERIFIED: whether deleting a user kills their Matrix/Synapse access
    tokens and any assistant delegation tokens (alkemio#1955). OIDC: session management UI — per-session revoke + sign out of all devices #6073 names a
    "webhook → Hydra / Synapse fan-out" that does not exist yet.

Downstream consumers of the WS1 primitive

  • client-web#10070 (password change → revoke others) — needs exceptSid,
    which ships and is tested. No change to this service required.
  • server#6073 (self-service session management) — needs exceptSid,
    per-session outcomes and partial-failure tolerance; all three ship. A
    list-sessions read method is still to be added, and contract C10 already
    guarantees it cannot expose token material.
  • Epic alkemio#1868 (OIDC convergence).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Deleting an account now revokes active OIDC sessions across devices, preventing continued access.
    • Sessions are safely terminated with remote token revocation and audit tracking.
    • Supported "me" fields now return graceful empty responses for orphaned sessions.
    • Session indexing supports reliable cleanup, self-healing, and multi-session revocation.
  • Bug Fixes

    • Prevented deleted-account sessions from remaining active for extended periods.
    • Account deletion continues safely when cleanup steps fail.
  • Documentation

    • Added specifications, implementation guidance, verification steps, and audit-event contracts.

Svetoslav Petkov added 3 commits July 31, 2026 16:14
Full SpecKit flow for #6315 — spec, clarify (3 passes),
plan, tasks, analyze (3 passes to zero findings).

Key decisions recorded:
- markTerminated, never destroy: the tombstone is what produces the 401
  that flips the client to signed-out, and it is what discards the cached
  PII from the session payload. destroy reproduces the reported bug.
- Audit trail rides the existing OIDC audit stream, not platform_audit_entry.
  Checked for an enum collision with 027-platform-role-redesign: none, but
  027 already owns the DB-side audit row for user deletion. Net effect is
  zero DDL and zero migrations.
- New OidcCoreModule breaks the UserModule -> OidcModule cycle without a
  forwardRef, and collapses two Redis clients into one.
- Self-healing index write so sessions alive at deploy time are revocable.

Refs #6315
Fixes the anchor defect in #6315: deleting a user left their BFF/OIDC session
untouched in Redis, so the browser kept authenticating against an account that
no longer existed for up to the 30-day absolute ceiling. That is an
access-control failure (ISO 27001 A.5.18, SOC 2 CC6.2), and an incomplete
erasure too, since the session payload caches display name and email.

The root cause was a missing primitive rather than a missing call: sessions live
at alkemio:sid:<sid> with no way to get from a subject to that subject's
sessions, so they could not be enumerated, so they could not be revoked.

WS1 — the primitive
- alkemio:sub:<sub> Redis set; TTL rolled FORWARD to the latest member's
  absolute ceiling and never shortened (a SADD without an EXPIRE leaks the key
  forever; a shortened TTL would evict a live member and make revocation
  silently miss a session)
- OidcSessionRevocationService.revokeAllForSub(sub, reason, opts?) tombstones
  every session with markTerminated, NEVER destroy. This is the whole fix: a
  tombstone yields 401 and flips the client to signed-out, while destroy yields
  a silent anonymous fall-through with HTTP 200 — the reported bug in a new
  costume. The tombstone also nulls request_context_cache, so it closes the
  GDPR Art. 17 half of the defect in the same move.
- clears the refresh lock, prunes the index, then revokes the refresh grant at
  Hydra (RFC 7009, 3s timeout, no retry, no circuit breaker — rationale inline
  per constitution principle 8). Local teardown completes BEFORE the remote
  call, so a Hydra outage cannot leave a session alive.
- exceptSid, per-session outcomes and partial-failure tolerance so
  client-web#10070 and server#6073 consume it unchanged
- self-healing index write from CookieSessionStrategy, fire-and-forget, so
  sessions minted before this ships become revocable on their next request
  instead of never

WS2 — the cascade
- deleteUser now calls revokeAllForSub AND the pre-existing-but-never-called
  kratosService.invalidateAllIdentitySessions, both unconditionally (NOT gated
  on deleteData.deleteIdentity — a surviving identity with live sessions is
  exactly the orphan state), after commit, each individually trapped so neither
  can fail the delete mutation

WS3 — graceful degradation
- the seven me sub-resolvers return 0/[]/empty-page/{} on an empty actorID
  instead of throwing. Two of the seven are not in the original triage list:
  notifications throws ForbiddenException rather than ValidationException, and
  the real conversations thrower is in me.conversations.resolver.fields.ts —
  fixing only the container would have left the actual client query erroring.

New OidcCoreModule breaks the UserModule -> OidcModule cycle without a
forwardRef and collapses two Redis clients into one.

No DDL, no migration, no GraphQL schema delta. The revocation audit trail rides
the existing OIDC audit stream rather than platform_audit_entry, deliberately:
027-platform-role-redesign already owns the DB-side audit row for user deletion.

Tests are the audit evidence, not decoration. revocation-ends-access.spec.ts
wires the real service to the real strategy and asserts the next request is
REJECTED — proof that access ended, not that a method was called. The
leak-proof test caught two real defects in the first draft (an upstream error
message embedding the token, and error.stack logged raw); both fixed by
scrubbing known secret values rather than pattern-guessing.

Refs #6315
Copilot AI review requested due to automatic review settings July 31, 2026 13:52
@CLAassistant

CLAassistant commented Jul 31, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ hero101
❌ Svetoslav Petkov


Svetoslav Petkov seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions

Copy link
Copy Markdown

📊 PR Metrics Summary

Title: fix(auth): revoke a user's OIDC/BFF sessions when their account is deleted
Total LOC Changed: 6543
Files Changed: 35
Proposed Review Type: HUMAN_AUGMENTED_LLM
Rationale:

  • high_risk_keyword
  • LOC>200
  • files>10
  • low_risk_keyword

Flags

  • High Risk Keyword
  • Low Risk Keyword
  • Composite High Risk Trigger

Thresholds

{
  "critical_loc": 200,
  "simple_loc": 100,
  "file_count": 10
}

@github-actions

Copy link
Copy Markdown

Schema Diff Summary: No blocking changes

Category Count
breaking 0
prematureRemoval 0
invalidDeprecation 0
deprecated 0
additive 0
info 0

Baseline branch: develop
Current schema MD5: 0d4505323913e76f67c7567f3e3e180a (size 319465)
Previous schema MD5: 0d4505323913e76f67c7567f3e3e180a

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ffea59eb-e9a4-493f-a017-a6b9f7bd6b60

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds subject-scoped Redis session indexing, OIDC session revocation with tombstones and audit events, post-transaction user-deletion integration, and empty-value handling for orphaned me fields. The change also adds contracts, implementation plans, tests, and verification guidance.

Changes

OIDC session revocation

Layer / File(s) Summary
Contracts and OIDC foundation
specs/107-oidc-session-revocation/*, src/core/auth/oidc/audit.ts, src/core/auth/oidc/oidc-core.module.ts, src/core/auth/oidc/oidc.module.ts
Defines the Redis keyspace, revocation reports, audit events, lifecycle rules, implementation tasks, and shared OIDC providers.
Subject index lifecycle
src/core/auth/oidc/session-index.redis.ts, src/core/auth/oidc/oidc.controller.ts, src/core/auth/oidc/strategies/cookie-session.strategy.ts, src/core/auth/oidc/*spec.ts
Maintains alkemio:sub:<sub> during callback, validation, logout, and session teardown.
Revocation orchestration
src/core/auth/oidc/revocation/*
Adds OidcSessionRevocationService with tombstoning, RFC 7009 revocation, audit events, redaction, partial-failure reports, subject markers, and exceptSid support.
Account deletion cascade
src/domain/community/user/user.service.ts, src/domain/community/user/user.module.ts, src/domain/community/user/user.service.delete.spec.ts
Runs OIDC revocation and Kratos session invalidation after commit, regardless of the identity-deletion flag, without failing user deletion.
Orphaned me-field handling
src/services/api/me/me.resolver.fields.ts, src/services/api/me/me.conversations.resolver.fields.ts, src/services/api/me/*spec.ts
Returns empty values and emits warnings when no actor ID is available. Authenticated service delegation remains covered by tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

  • alkem-io/server#6066 — The revocation service includes email_changed support for email-change session invalidation.
  • alkem-io/server#6122 — The revocation service includes password_changed support for password-change session invalidation.

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: revoking a user's OIDC/BFF sessions when the account is deleted.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch story/6315-oidc-session-revocation-cascade

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an auth lifecycle gap where deleting a user left their OIDC/BFF Redis-backed sessions valid, enabling continued authentication (and leaving cached PII in session payloads) until absolute TTL expiry. It introduces a per-subject session index + revocation primitive, wires it into deleteUser, and hardens GraphQL me sub-resolvers to degrade cleanly when no actor can be resolved.

Changes:

  • Add a Redis alkemio:sub:<sub> index and an OidcSessionRevocationService that tombstones (not destroys) all sessions for a subject and attempts RFC 7009 refresh-token revocation.
  • Wire revocation into the post-commit user-deletion cascade (plus calling the previously-unused Kratos session invalidation).
  • Make me sub-resolvers return empty values (and warn) when actorID is empty; add tests for the new behavior.

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/services/api/me/me.resolver.fields.ts Degrade multiple me.* sub-resolvers to empty values when no actorID.
src/services/api/me/me.resolver.fields.spec.ts Adds tests for degradation behavior (note: hook cleanup issue raised in comments).
src/services/api/me/me.conversations.resolver.fields.ts Degrade me.conversations.conversations to [] when no actorID.
src/services/api/me/me.conversations.resolver.fields.spec.ts Adds tests for conversations degradation (note: spy restore issue raised in comments).
src/domain/community/user/user.service.ts Calls OIDC session revocation + Kratos session invalidation post-commit during deleteUser.
src/domain/community/user/user.service.delete.spec.ts New unit coverage for the deletion cascade ordering, unconditional behavior, and failure tolerance.
src/domain/community/user/user.module.ts Imports OidcCoreModule to access revocation service without creating a module cycle.
src/core/auth/oidc/strategies/cookie-session.strategy.ts Adds optional Redis client injection and a fire-and-forget self-healing index write.
src/core/auth/oidc/strategies/cookie-session.strategy.index.spec.ts Tests self-healing indexing behavior and non-awaiting semantics.
src/core/auth/oidc/session-index.redis.ts Implements alkemio:sub:<sub> set primitives with TTL roll-forward rules.
src/core/auth/oidc/session-index.redis.spec.ts Tests index TTL invariants, keyspace safety, and edge cases.
src/core/auth/oidc/revocation/subject-contract.spec.ts Pins cross-service invariant session.sub === user.authenticationID.
src/core/auth/oidc/revocation/session-revocation.types.ts Adds reason/outcome/report types for revocation.
src/core/auth/oidc/revocation/revocation-ends-access.spec.ts Integration-style test proving revocation causes the next request to be rejected.
src/core/auth/oidc/revocation/oidc-session-revocation.service.ts New revocation service: tombstone local sessions, prune index, clear refresh lock, RFC 7009 revoke.
src/core/auth/oidc/oidc.tokens.ts Adds OIDC_REDIS_CLIENT DI token for a shared Redis client.
src/core/auth/oidc/oidc.module.ts Hoists foundational providers into OidcCoreModule and re-exports it.
src/core/auth/oidc/oidc.controller.ts Maintains the per-subject index on callback/logout/teardown paths.
src/core/auth/oidc/oidc.controller.spec.ts Tests controller-driven index write/prune behavior and best-effort failure semantics.
src/core/auth/oidc/oidc-core.module.ts New dependency-light module providing OIDC Redis client, session store handle, and revocation service.
src/core/auth/oidc/audit.ts Extends audit vocabulary with session revocation event types and reason field.
specs/107-oidc-session-revocation/tasks.md Adds SDD tasks artifact for the feature.
specs/107-oidc-session-revocation/quickstart.md Adds quickstart/manual verification guide.
specs/107-oidc-session-revocation/plan.md Adds implementation plan artifact.
specs/107-oidc-session-revocation/data-model.md Documents Redis keyspace + type contracts (no DDL/migrations).
specs/107-oidc-session-revocation/contracts/session-revocation-service.md Documents internal revocation service contract and consumer matrix.
specs/107-oidc-session-revocation/contracts/redis-keyspace.md Documents Redis key families and invariants.
specs/107-oidc-session-revocation/contracts/graphql-me-degradation.md Documents me degradation contract.
specs/107-oidc-session-revocation/contracts/audit-events.md Documents the audit event semantics for session revocation.
specs/107-oidc-session-revocation/checklists/requirements.md Adds spec quality checklist artifact.
CLAUDE.md Updates repo guidance with the new feature’s summary + plan pointer.
.specify/feature.json Points Spec Kit feature directory to specs/107-oidc-session-revocation.

Comment thread src/services/api/me/me.resolver.fields.spec.ts
Comment thread src/services/api/me/me.conversations.resolver.fields.spec.ts Outdated
@hero101
hero101 marked this pull request as ready for review July 31, 2026 14:05
@hero101
hero101 removed the request for review from valentinyanakiev July 31, 2026 14:05

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (8)
src/core/auth/oidc/oidc.controller.ts (2)

556-563: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the destroy-then-deindex block into one private helper.

The same seven-line promise wrapper around req.session.destroy now appears three times, each followed by deindexSession. A single helper keeps the "capture sub before destroy" invariant in one place, so a future edit cannot restore the ordering bug in only two of the three call sites.

♻️ Proposed helper
+  /**
+   * server#6315 / FR-003 — destroy the session, then prune the index with the
+   * `sub` captured beforehand. Reading `sub` after destroy would prune with
+   * `undefined` and silently do nothing.
+   */
+  private async destroyAndDeindex(
+    req: Request,
+    sub: string | undefined | null
+  ): Promise<void> {
+    const sid = req.sessionID;
+    await new Promise<void>(resolve => {
+      try {
+        req.session.destroy(() => resolve());
+      } catch {
+        resolve();
+      }
+    });
+    await this.deindexSession(sub, sid);
+  }

Also applies to: 631-638, 690-697

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/auth/oidc/oidc.controller.ts` around lines 556 - 563, Extract the
repeated session-destruction and deindexing sequence into one private helper,
preserving the invariant that the subject is captured before req.session.destroy
runs. Update all three call sites, including the blocks around the referenced
flows, to invoke this helper and remove their duplicated Promise wrapper and
direct deindexSession calls.

100-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Both new index-maintenance loggers use the NestJS Logger instead of Winston. The coding guidelines require Winston logging, and OidcSessionRevocationService in this same PR injects WINSTON_MODULE_NEST_PROVIDER. The shared root cause is that the two index-maintenance classes instantiate @nestjs/common Logger directly, so this feature writes its warnings to two different logging backends.

  • src/core/auth/oidc/oidc.controller.ts#L100-L101: replace the new Logger(...) field with an injected LoggerService from WINSTON_MODULE_NEST_PROVIDER, and pass a LogContext value as the context argument in indexSession and deindexSession.
  • src/core/auth/oidc/strategies/cookie-session.strategy.ts#L45-L45: replace the new Logger(...) field with the same injected Winston LoggerService, and update the reindexSession warn call to the (message, context) signature.

Both spec files assert on Logger.prototype.warn, so update those assertions to the injected logger mock.

As per coding guidelines: "Use the Winston logger with signatures verbose/warning(message, context) and error(message, stacktrace, context)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/auth/oidc/oidc.controller.ts` around lines 100 - 101, Replace the
direct NestJS Logger instances in src/core/auth/oidc/oidc.controller.ts lines
100-101 and src/core/auth/oidc/strategies/cookie-session.strategy.ts line 45
with injected LoggerService instances from WINSTON_MODULE_NEST_PROVIDER; pass a
LogContext value to the indexSession, deindexSession, and reindexSession warning
calls using the (message, context) signature. Update both spec files to assert
against the injected logger mock instead of Logger.prototype.warn.

Source: Coding guidelines

src/core/auth/oidc/revocation/subject-contract.spec.ts (1)

110-120: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

This test asserts only the assignment it just made.

user.authenticationID = null followed by expect(user.authenticationID).toBeNull() passes for any entity shape, including one where the column later becomes non-nullable at the database level. It does not pin the stated intent.

A type-level assertion pins the nullability the comment describes:

♻️ Proposed type-level pin
   it('user.authenticationID is nullable, and that is a legitimate state', () => {
-    const user = new User();
-    user.authenticationID = null;
-
-    expect(user.authenticationID).toBeNull();
+    // Fails to compile if the property stops accepting null.
+    const authenticationID: User['authenticationID'] = null;
+
+    expect(authenticationID).toBeNull();
   });

As per path instructions: "Unit tests use the *.spec.ts suffix and should be added when they provide meaningful signal; trivial pass-through coverage is not required."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/auth/oidc/revocation/subject-contract.spec.ts` around lines 110 -
120, Replace the assignment-and-read assertion in the authenticationID test with
a type-level assertion that verifies User.authenticationID accepts string or
null, preserving the intended nullable contract without relying on runtime
assignment. Keep the test focused on the User entity’s declared property type
and retain the existing spec naming.

Source: Coding guidelines

src/domain/community/user/user.service.ts (1)

589-628: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated error-logging blocks.

The two catch blocks at lines 596-606 and 615-626 log the same shape (userID, authenticationID, error.message, error.stack, LogContext.AUTH) and differ only in the message text. Extract a small helper to remove the duplication.

♻️ Proposed refactor to remove duplication
+  private logRevocationFailure(
+    message: string,
+    userID: string,
+    authenticationID: string,
+    error: any
+  ): void {
+    this.logger.error?.(
+      { message, userID, authenticationID, error: error?.message },
+      error?.stack,
+      LogContext.AUTH
+    );
+  }
+
   if (user.authenticationID) {
       try {
         await this.oidcSessionRevocationService.revokeAllForSub(
           user.authenticationID,
           'account_deleted'
         );
       } catch (error: any) {
-        this.logger.error?.(
-          {
-            message:
-              'Failed to revoke OIDC sessions during user deletion; the deletion still stands',
-            userID: id,
-            authenticationID: user.authenticationID,
-            error: error?.message,
-          },
-          error?.stack,
-          LogContext.AUTH
-        );
+        this.logRevocationFailure(
+          'Failed to revoke OIDC sessions during user deletion; the deletion still stands',
+          id,
+          user.authenticationID,
+          error
+        );
       }

       try {
         await this.kratosService.invalidateAllIdentitySessions(
           user.authenticationID
         );
       } catch (error: any) {
-        this.logger.error?.(
-          {
-            message:
-              'Failed to invalidate Kratos identity sessions during user deletion; the deletion still stands',
-            userID: id,
-            authenticationID: user.authenticationID,
-            error: error?.message,
-          },
-          error?.stack,
-          LogContext.AUTH
-        );
+        this.logRevocationFailure(
+          'Failed to invalidate Kratos identity sessions during user deletion; the deletion still stands',
+          id,
+          user.authenticationID,
+          error
+        );
       }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/community/user/user.service.ts` around lines 589 - 628, Extract
the duplicated catch-block logging in the user deletion flow into a small helper
method, preserving the existing logger payload fields, stack argument, and
LogContext.AUTH value. Have the helper accept the operation-specific message,
then call it from both revokeAllForSub and invalidateAllIdentitySessions error
handlers without changing deletion behavior.
src/services/api/me/me.resolver.fields.ts (1)

38-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated "degrade on missing actor" guard into a shared helper. Seven resolver fields across two files repeat the same if (!actorContext.actorID) { this.logger.warn(...); return <default>; } pattern, differing only in the log message and default value. The shared root cause is the lack of a common helper for this cross-cutting concern.

  • src/services/api/me/me.resolver.fields.ts#L38-L46: replace with a call to a shared helper, e.g. this.degradeOrElse('me.notifications', () => this.inAppNotificationService.getPaginatedNotifications(...)), passing the empty-page default.
  • src/services/api/me/me.resolver.fields.ts#L63-L67: use the same helper with 0 as the default.
  • src/services/api/me/me.resolver.fields.ts#L112-L116: use the same helper with 0 as the default.
  • src/services/api/me/me.resolver.fields.ts#L137-L141: use the same helper with [] as the default.
  • src/services/api/me/me.resolver.fields.ts#L163-L167: use the same helper with [] as the default.
  • src/services/api/me/me.resolver.fields.ts#L225-L229: use the same helper with {} as the default.
  • src/services/api/me/me.conversations.resolver.fields.ts#L24-L28: use the same helper (or a shared base class/mixin, since this is a different resolver class) with [] as the default.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/api/me/me.resolver.fields.ts` around lines 38 - 46, Extract the
repeated missing-actor guard into a shared degradeOrElse helper that accepts the
field name, fallback value, and resolver operation, while preserving the warning
behavior. Replace the guards in src/services/api/me/me.resolver.fields.ts at
lines 38-46, 63-67, 112-116, 137-141, 163-167, and 225-229 with helper calls
using defaults PaginatedInAppNotifications empty page, 0, 0, [], [], and {}
respectively; update src/services/api/me/me.conversations.resolver.fields.ts
lines 24-28 to use the same shared helper or a base-class/mixin equivalent with
[] as its default.
src/core/auth/oidc/audit.ts (1)

3-3: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the @core path alias for the revocation type import. Replace the relative path with @core/auth/oidc/revocation/session-revocation.types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/auth/oidc/audit.ts` at line 3, Update the SessionRevocationReason
import in audit.ts to use the
`@core/auth/oidc/revocation/session-revocation.types` alias instead of the
relative revocation path.

Source: Coding guidelines

src/core/auth/oidc/oidc-core.module.ts (1)

39-48: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Attach an error listener to the shared Redis client.

The OIDC_REDIS_CLIENT factory creates the client with no .on('error', ...) listener. ioredis only emits error events when a listener is registered; without one, connection errors on this now-shared client (session store and per-subject index) are silently dropped rather than logged.

Add a listener that logs the error with the Winston logger at warn or error level, so a Redis outage on this shared connection is visible in operations instead of failing silently.

🔧 Proposed fix
     {
       provide: OIDC_REDIS_CLIENT,
       inject: [ConfigService],
       useFactory: (configService: ConfigService<AlkemioConfig, true>) => {
         const { host, port } = configService.get('storage.redis', {
           infer: true,
         });
-        return new Redis({ host, port: Number(port) });
+        const client = new Redis({ host, port: Number(port) });
+        client.on('error', err => {
+          logger.warn('OIDC Redis client error', { error: err.message });
+        });
+        return client;
       },
     },

Please confirm with the latest ioredis documentation whether an unhandled error event on a client instance can, under any configuration used here, still surface as an unhandled rejection/exception rather than being silently dropped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/auth/oidc/oidc-core.module.ts` around lines 39 - 48, In the
OIDC_REDIS_CLIENT factory function, attach an error event listener to the Redis
client instance that is created by new Redis({ host, port: Number(port) })
before returning it. The listener should log errors using the Winston logger at
warn or error level to ensure connection errors on this shared Redis client are
visible in operations rather than silently dropped. Inject the Logger service
into the factory to access the logging capability.
specs/107-oidc-session-revocation/contracts/audit-events.md (1)

76-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clarify the overloaded error_code field.

error_code holds the outcome name (for example "revoked") on success and the redacted failure cause on failure. A field named error_code that is always populated, even on success, can mislead a log consumer who filters on "non-null error_code" to find real errors. Consider renaming this field (for example outcome_code) or documenting explicitly that it is not error-specific.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/107-oidc-session-revocation/contracts/audit-events.md` around lines 76
- 88, The `error_code` field in the audit events table holds outcome names like
"revoked" on success and error details on failure, which is misleading for
consumers filtering on non-null values to find real errors. Either rename the
`error_code` field to a neutral name like `outcome_code` throughout the table
and related documentation to clarify it always contains a meaningful status
value, or add explicit documentation in the field description stating that the
field is populated on both success and failure outcomes and that it contains
outcome names (like "revoked") on success and redacted error causes on failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/107-oidc-session-revocation/quickstart.md`:
- Around line 16-18: Replace the contributor-specific absolute path in the
quickstart installation steps with a repository-relative instruction, such as
changing to the repository root, while preserving the subsequent pnpm install
command.

In `@specs/107-oidc-session-revocation/spec.md`:
- Around line 21-33: Close the pre-deployment gap described around
revokeAllForSub by ensuring account deletion invalidates sessions that have not
yet been indexed, using a subject-level revocation marker checked during session
validation or another safe bounded mechanism. Add coverage for deletion
occurring before the session’s first authenticated request, and update the
affected specification text and User Story 1/SC-001 traceability so the behavior
is consistent.

In `@src/core/auth/oidc/revocation/oidc-session-revocation.service.ts`:
- Around line 140-144: The revokedCount and failedCount filters overlap when an
entry has outcome === 'revoked' but tokenRevocation === 'failed', causing both
counters to count the same session. Refactor the failedCount filter to exclude
entries already counted in revokedCount by checking only for outcome ===
'failed' and adding a separate counter for remote revocation failures where
outcome === 'revoked' && tokenRevocation === 'failed'. Update the
SessionRevocationReport interface to include the new remote-failure counter,
update the complete flag derivation to account for all failure paths, and update
the test assertions at lines 479, 544, and 797 to match the partitioned counter
logic so the audit record reflects distinct session outcomes rather than
overlapping counts.
- Around line 134-138: Replace the sequential await pattern in the loop that
processes sids with a bounded concurrency approach. Instead of awaiting each
this.revokeOne call individually within the loop iteration, collect the promises
and execute them with a concurrency limit (such as using Promise.allSettled with
chunking or a concurrency-control library). Preserve the order of entries if
required, or ensure the final entries array is properly populated from the
concurrent batch results. Verify that the test assertions in
oidc-session-revocation.service.spec.ts at Lines 295-300 and Line 325 still
pass, since they currently assume strict command ordering that may no longer
hold under bounded concurrency.

In `@src/core/auth/oidc/revocation/revocation-ends-access.spec.ts`:
- Around line 90-110: Update the seedPayload fixture so its alkemio_actor_id is
a real actor identifier such as actor-1, ensuring CookieSessionStrategy.validate
produces an authenticated context. Replace the resolves.not.toBeNull()
assertions in the affected baseline and revocation tests with assertions
matching the expected actorID and isAnonymous: false, including the cases around
the identified assertion groups.

In `@src/core/auth/oidc/session-index.redis.ts`:
- Around line 59-73: The TTL update in the session-index operation must be
atomic and never shorten an existing expiry. Replace the separate redis.sadd,
redis.ttl, and redis.expire calls with an atomic Lua script that performs SADD,
sets the candidate TTL when the key has no expiry, and extends an existing TTL
only when the candidate is greater; then update the affected tests to cover
initialization, extension, and non-shortening behavior.

In `@src/core/auth/oidc/strategies/cookie-session.strategy.ts`:
- Around line 131-146: Update the self-healing flow around reindexSession and
addSessionToSubIndex so index maintenance uses a single Redis pipeline rather
than separate SADD, TTL, and EXPIRE round trips. Reuse the session-index Redis
implementation, including EXPIRE GT semantics, to eliminate the TTL read while
preserving non-blocking error handling and the existing tombstone/TTL branch
behavior.

---

Nitpick comments:
In `@specs/107-oidc-session-revocation/contracts/audit-events.md`:
- Around line 76-88: The `error_code` field in the audit events table holds
outcome names like "revoked" on success and error details on failure, which is
misleading for consumers filtering on non-null values to find real errors.
Either rename the `error_code` field to a neutral name like `outcome_code`
throughout the table and related documentation to clarify it always contains a
meaningful status value, or add explicit documentation in the field description
stating that the field is populated on both success and failure outcomes and
that it contains outcome names (like "revoked") on success and redacted error
causes on failure.

In `@src/core/auth/oidc/audit.ts`:
- Line 3: Update the SessionRevocationReason import in audit.ts to use the
`@core/auth/oidc/revocation/session-revocation.types` alias instead of the
relative revocation path.

In `@src/core/auth/oidc/oidc-core.module.ts`:
- Around line 39-48: In the OIDC_REDIS_CLIENT factory function, attach an error
event listener to the Redis client instance that is created by new Redis({ host,
port: Number(port) }) before returning it. The listener should log errors using
the Winston logger at warn or error level to ensure connection errors on this
shared Redis client are visible in operations rather than silently dropped.
Inject the Logger service into the factory to access the logging capability.

In `@src/core/auth/oidc/oidc.controller.ts`:
- Around line 556-563: Extract the repeated session-destruction and deindexing
sequence into one private helper, preserving the invariant that the subject is
captured before req.session.destroy runs. Update all three call sites, including
the blocks around the referenced flows, to invoke this helper and remove their
duplicated Promise wrapper and direct deindexSession calls.
- Around line 100-101: Replace the direct NestJS Logger instances in
src/core/auth/oidc/oidc.controller.ts lines 100-101 and
src/core/auth/oidc/strategies/cookie-session.strategy.ts line 45 with injected
LoggerService instances from WINSTON_MODULE_NEST_PROVIDER; pass a LogContext
value to the indexSession, deindexSession, and reindexSession warning calls
using the (message, context) signature. Update both spec files to assert against
the injected logger mock instead of Logger.prototype.warn.

In `@src/core/auth/oidc/revocation/subject-contract.spec.ts`:
- Around line 110-120: Replace the assignment-and-read assertion in the
authenticationID test with a type-level assertion that verifies
User.authenticationID accepts string or null, preserving the intended nullable
contract without relying on runtime assignment. Keep the test focused on the
User entity’s declared property type and retain the existing spec naming.

In `@src/domain/community/user/user.service.ts`:
- Around line 589-628: Extract the duplicated catch-block logging in the user
deletion flow into a small helper method, preserving the existing logger payload
fields, stack argument, and LogContext.AUTH value. Have the helper accept the
operation-specific message, then call it from both revokeAllForSub and
invalidateAllIdentitySessions error handlers without changing deletion behavior.

In `@src/services/api/me/me.resolver.fields.ts`:
- Around line 38-46: Extract the repeated missing-actor guard into a shared
degradeOrElse helper that accepts the field name, fallback value, and resolver
operation, while preserving the warning behavior. Replace the guards in
src/services/api/me/me.resolver.fields.ts at lines 38-46, 63-67, 112-116,
137-141, 163-167, and 225-229 with helper calls using defaults
PaginatedInAppNotifications empty page, 0, 0, [], [], and {} respectively;
update src/services/api/me/me.conversations.resolver.fields.ts lines 24-28 to
use the same shared helper or a base-class/mixin equivalent with [] as its
default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0451d0ad-2bbe-4439-9341-67f1c4c47458

📥 Commits

Reviewing files that changed from the base of the PR and between a4e30c6 and 66edb0f.

📒 Files selected for processing (35)
  • .specify/feature.json
  • CLAUDE.md
  • specs/107-oidc-session-revocation/checklists/requirements.md
  • specs/107-oidc-session-revocation/contracts/audit-events.md
  • specs/107-oidc-session-revocation/contracts/graphql-me-degradation.md
  • specs/107-oidc-session-revocation/contracts/redis-keyspace.md
  • specs/107-oidc-session-revocation/contracts/session-revocation-service.md
  • specs/107-oidc-session-revocation/data-model.md
  • specs/107-oidc-session-revocation/plan.md
  • specs/107-oidc-session-revocation/quickstart.md
  • specs/107-oidc-session-revocation/research.md
  • specs/107-oidc-session-revocation/spec.md
  • specs/107-oidc-session-revocation/tasks.md
  • src/core/auth/oidc/audit.ts
  • src/core/auth/oidc/oidc-core.module.ts
  • src/core/auth/oidc/oidc.controller.spec.ts
  • src/core/auth/oidc/oidc.controller.ts
  • src/core/auth/oidc/oidc.module.ts
  • src/core/auth/oidc/oidc.tokens.ts
  • src/core/auth/oidc/revocation/oidc-session-revocation.service.spec.ts
  • src/core/auth/oidc/revocation/oidc-session-revocation.service.ts
  • src/core/auth/oidc/revocation/revocation-ends-access.spec.ts
  • src/core/auth/oidc/revocation/session-revocation.types.ts
  • src/core/auth/oidc/revocation/subject-contract.spec.ts
  • src/core/auth/oidc/session-index.redis.spec.ts
  • src/core/auth/oidc/session-index.redis.ts
  • src/core/auth/oidc/strategies/cookie-session.strategy.index.spec.ts
  • src/core/auth/oidc/strategies/cookie-session.strategy.ts
  • src/domain/community/user/user.module.ts
  • src/domain/community/user/user.service.delete.spec.ts
  • src/domain/community/user/user.service.ts
  • src/services/api/me/me.conversations.resolver.fields.spec.ts
  • src/services/api/me/me.conversations.resolver.fields.ts
  • src/services/api/me/me.resolver.fields.spec.ts
  • src/services/api/me/me.resolver.fields.ts

Comment thread specs/107-oidc-session-revocation/quickstart.md
Comment thread specs/107-oidc-session-revocation/spec.md
Comment thread src/core/auth/oidc/revocation/oidc-session-revocation.service.ts Outdated
Comment thread src/core/auth/oidc/revocation/oidc-session-revocation.service.ts Outdated
Comment thread src/core/auth/oidc/revocation/revocation-ends-access.spec.ts
Comment thread src/core/auth/oidc/session-index.redis.ts Outdated
Comment thread src/core/auth/oidc/strategies/cookie-session.strategy.ts
Addresses two rounds of review on #6324 (CodeRabbit, Copilot, and a local
xhigh pass) — 24 findings, none declined.

The headline defect: the per-session tombstone is not a trustworthy record
that a revocation happened, because this feature does not own the key it
lives in. express-session owns alkemio:sid:<sid>, so a request already in
flight when revocation runs holds the pre-revocation payload and persists it
afterwards (an explicit save() on /refresh, or the lazy idle renewal firing
at response end), erasing the tombstone. By then the sid has also been
SREM'd from the index, so no retry can find it — the session is alive and
permanently unrevocable, which is the bug this PR exists to fix, reachable
by a race whose window is a whole request. Independently, sessions minted
before the index shipped are not in it at all, so revoking a user who has
not made a request since deploy enumerates nothing.

Both are fixed by an account-level marker, alkemio:subrevoked:<sub>, holding
the epoch-second at which the subject was revoked and checked on every
authenticated request. It needs neither an intact payload nor index
membership. A timestamp rather than a flag is what keeps it from being a
permanent ban: a session whose created_at is later was minted after the
revocation and passes, so a re-login needs no cleanup. Not written for
exceptSid revocations — it rejects by subject and cannot spare the surviving
session. Reads fail OPEN: a hard Redis outage already 503s at the session
read, so a failure here means Redis is up and one command blipped, and
failing closed would sign out the platform to defend a window the tombstone
already covers.

Correctness
- both index writes are single atomic EVAL scripts. Client-side
  SADD/TTL/EXPIRE could die between the first and the last (key immortal) or
  interleave with a concurrent login so the shorter TTL lands last and
  evicts a live member. EXPIRE ... GT alone does not fix it: Redis reads a
  key with no expiry as infinite, so GT never sets the FIRST expiry
- the login callback de-indexes the sid regenerate() destroyed. Nothing else
  did — logout and revocation are the only de-index paths and neither runs
  on a re-login — so every re-login left a phantom member, and a later
  revocation emitted one session.revoked record per phantom, outcome=success
- session teardown runs with bounded concurrency (5). The 3s RFC 7009 bound
  governs one call; awaiting strictly in turn multiplied it by the session
  count and could stall deleteUser for tens of seconds against a
  black-holed Hydra

Audit integrity
- revokedCount / failedCount / tokenRevocationFailedCount now partition the
  entries. A session tombstoned locally whose upstream grant survived was
  counted in two buckets and read, in the summary, as a session still alive
- a missing revocation_endpoint reports skipped, not failed. initDiscovery
  is fire-and-forget with indefinite retry, so an unresolved endpoint is
  routine in dev, CI and restart waves; reporting it as failure audited
  fully successful teardowns as outcome=failure
- post-tombstone cleanup moved past the success boundary, so a Redis blip
  during the SREM no longer reports a provably-401ing session as failed
- no blind DEL of the refresh-lock key. The mutex in production is the
  in-process refreshInFlight Map, so the key is never written; and
  releaseRefreshLock is an owner-checked compare-and-delete specifically so
  a lock cannot be stolen, which an unconditional DEL would defeat

Leak and noise
- scrubbing floors at 4 characters, not 8: `secrets` carries the cached
  display name, and short real names were sailing through the gate that was
  their only defence — no backstop pattern matches a personal name
- deleteUser passes error.stack through redactStack. A stack begins with the
  error's own message, so logging it raw defeats a redacted message — the
  practice this same module documents as a leak vector
- me degradation logs drop WARN -> verbose and route through Winston +
  LogContext.AUTH. Anonymous and guest contexts both carry an empty actorID
  and the SPA queries me on nearly every page load, so this was up to six
  WARN lines per logged-out visit, and none of it was filterable in Elastic
- the dead me.conversations guard is gone; both branches returned the same
  empty envelope, so it only ever added a log line

Tests (+26)
- the race, end to end: revoke, write the live payload back over the
  tombstone, assert the session still 401s
- an unindexed session revoked with entries: [] and still rejected
- a session created after the revocation still authenticates
- marker semantics incl. later-timestamp-wins; re-login de-indexing; marker
  skipped for exceptSid; marker failure tolerated; counter partitioning
- the end-to-end fixture carried alkemio_actor_id: null, so validate()
  returned an ANONYMOUS context and every resolves.not.toBeNull() passed for
  a session that never authenticated — including the baseline test that
  existed to stop the suite passing for the wrong reason. Now asserts the
  identity
- Logger.prototype spies removed. With isolate: false and clearMocks (not
  restoreMocks), an unrestored prototype spy leaks a no-op logger into every
  later file in the worker
- the strategy index spec derived its clock from a hardcoded epoch while the
  guard compares against Date.now(), so four tests would have started
  failing on 2027-02-14 with an unrelated-looking 401

Refs #6315

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 15:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (6)

specs/107-oidc-session-revocation/contracts/graphql-me-degradation.md:133

  • This acceptance bullet says seven warn lines are emitted, but the implementation/tests emit verbose logs for these degradation paths. Keeping this aligned avoids confusion when manually verifying behavior.
- seven warn lines were emitted.

specs/107-oidc-session-revocation/quickstart.md:164

  • Quickstart says the post-fix behavior produces “Seven warn lines”, but the implementation emits verbose logs for degraded me fields. The quickstart should match the real log level to avoid false-negative manual verification.
`[]`; `notifications.total` `0`. Seven warn lines in the server log.

specs/107-oidc-session-revocation/contracts/session-revocation-service.md:80

  • Session revocation docs say the revocation flow deletes alkemio:sid:<sid>:refresh-lock, but the implementation explicitly does not touch refresh-lock keys (see the FR-011 note in OidcSessionRevocationService#revokeOne). This contract should match the shipped behavior so operators/reviewers don’t verify the wrong thing.
3. `DEL alkemio:sid:<sid>:refresh-lock` (FR-011).

specs/107-oidc-session-revocation/data-model.md:46

  • This data model section states the alkemio:sid:<sid>:refresh-lock key is deleted during revocation, but the revocation service deliberately does not delete refresh-lock keys (it only tombstones + prunes the subject index + does RFC7009). Keeping this accurate matters for operational/debug expectations.
`refresh-lock.ts:16`. Deleted as part of revocation (FR-011) so nothing keyed to
a dead session lingers.

specs/107-oidc-session-revocation/quickstart.md:118

  • Quickstart verification currently claims the refresh mutex key is removed by revocation, but the implementation explicitly does not delete :refresh-lock keys. This step can mislead manual verification and incident response.

This issue also appears on line 164 of the same file.

# c) The refresh mutex is gone
redis-cli EXISTS alkemio:sid:<sid>:refresh-lock
# → 0

specs/107-oidc-session-revocation/contracts/graphql-me-degradation.md:88

  • This section says the me degradation emits warn-level logs, but the implementation/tests use logger.verbose(...) (to avoid log noise for expected anonymous traffic). The contract should match the actual log level.

This issue also appears on line 133 of the same file.

Every degraded field emits exactly one **warn**-level line before returning:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
specs/107-oidc-session-revocation/contracts/redis-keyspace.md (1)

82-82: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update invariant I2 to reference the actual enforcement.

The Enforced by cell names the max(...) above. The TTL-roll script at lines 45-54 contains no max(...); it uses the ttl < 0 or candidate > ttl branch. Align the wording so the invariant stays traceable to the script.

📝 Proposed wording
-| I2 | TTL only ever grows | the `max(...)` above + spec |
+| I2 | TTL only ever grows | the `ttl < 0 or candidate > ttl` branch in the `EVAL` above + spec |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/107-oidc-session-revocation/contracts/redis-keyspace.md` at line 82,
Update invariant I2 in the Redis keyspace specification to reference the
TTL-roll script’s actual enforcement condition, specifically the `ttl < 0 or
candidate > ttl` branch, instead of referring to the nonexistent `max(...)`
expression. Preserve the invariant’s meaning that TTL only increases.
🧹 Nitpick comments (6)
src/core/auth/oidc/revocation/revocation-ends-access.spec.ts (1)

496-511: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider pinning the marker boundary.

The fresh session uses created_at = now + 5, so the test does not distinguish a > comparison from a >= comparison in the marker check. A session minted in the same second as the revocation is the realistic edge case. Add one case where created_at equals the marker timestamp, and assert the expected decision.

This is optional; the current test already covers the "not a permanent ban" property.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/auth/oidc/revocation/revocation-ends-access.spec.ts` around lines
496 - 511, Extend the revocation boundary tests around revokeAllForSub and the
fresh-session validation to cover a session whose created_at exactly equals the
revocation marker timestamp, asserting the expected authentication decision;
keep the existing future-timestamp case that verifies revocation is not
permanent.
src/domain/community/user/user.service.ts (3)

593-635: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider emitting a domain event instead of calling the OIDC service directly.

UserService now calls OidcSessionRevocationService directly after the commit. The constructor comment on Lines 89-90 records the cost: the direct dependency would be a module cycle, so OidcCoreModule exists to break it. A UserDeleted domain event with an OIDC-side listener would remove that coupling, remove the need for the cycle workaround, and keep the best-effort semantics, because a listener failure cannot fail the mutation.

Two properties would need care in the event route: the event must publish after the commit, and the listener must receive authenticationID. If the team prefers the direct call for delivery guarantees, keep it and record the decision in the spec so the divergence from the event standard is traceable.

As per coding guidelines: "Emit domain events instead of performing direct repository writes from API or orchestration code" and "For new GraphQL surface area, align with docs/Pagination.md, enforce DTO validation, and emit domain events instead of direct repository writes".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/community/user/user.service.ts` around lines 593 - 635, Replace
the direct OIDC revocation call in the user-deletion flow with a UserDeleted
domain event. Publish it only after the deletion transaction commits and include
authenticationID in the event payload. Add an OIDC-side listener that invokes
OidcSessionRevocationService.revokeAllForSub with best-effort error handling,
then remove the direct dependency and related cycle workaround from UserService.

Source: Coding guidelines


593-593: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Merge the two user.authenticationID guards.

Lines 593 and 641 test the same condition and the blocks are adjacent. One guard would hold the session revocation, the Kratos session invalidation, the metadata clear, and the conditional identity deletion, in that order. This keeps the documented ordering and removes one branch.

Also applies to: 641-641

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/community/user/user.service.ts` at line 593, The user deletion
flow in user.service.ts contains two separate if guards checking the same
user.authenticationID condition, creating redundant branching. Merge the two
user.authenticationID guards into a single conditional block, combining the
operations from both blocks while preserving their documented order: session
revocation, followed by Kratos session invalidation, followed by metadata clear,
followed by conditional identity deletion. This eliminates the duplicate branch
while maintaining the correct operation sequence.

18-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the redaction helpers from oidc-session-revocation.service.ts.

Both helpers are consumed by the revocation service and user.service.ts. Move them to a separate redaction module and update both imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/community/user/user.service.ts` around lines 18 - 22, Extract the
redactError and redactStack helpers from OidcSessionRevocationService into a
dedicated redaction module, then update imports in both
OidcSessionRevocationService and the user service to use that module while
preserving their existing behavior and exports.
src/core/auth/oidc/session-index.redis.ts (1)

86-97: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Floor the candidate TTL before it reaches EXPIRE.

candidateTtlS is absoluteExpiresAtEpochSeconds - nowS. If a caller ever passes a fractional absolute_expires_at, the value stays fractional and EXPIRE rejects it with value is not an integer or out of range. The whole EVAL then fails, so the SADD is rolled back and the session is never indexed. Today's producers pass integers, so this is defensive only, but the failure mode is a silent loss of index coverage.

Add the floor in the script so it holds for every caller.

♻️ Proposed fix
 local added = redis.call('SADD', KEYS[1], ARGV[1])
-local candidate = tonumber(ARGV[2])
+local candidate = math.floor(tonumber(ARGV[2]))
 if candidate < 1 then candidate = 1 end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/auth/oidc/session-index.redis.ts` around lines 86 - 97, Floor the
TTL inside ADD_AND_ROLL_TTL_LUA before passing it to EXPIRE, ensuring fractional
absoluteExpiresAtEpochSeconds values become valid integer TTLs for every caller.
Keep the existing candidateTtlS calculation and EVAL invocation behavior
unchanged.
src/core/auth/oidc/strategies/cookie-session.strategy.index.spec.ts (1)

262-275: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the unindexed-session test distinct, and pin the equality boundary.

Two points on this describe block:

  1. This test is byte-equivalent in behaviour to rejects a session minted BEFORE the subject was revoked at lines 230-241. Both build a default payload and set subRevokedAt = created_at + 1. Nothing here models index membership, so the test cannot demonstrate the property its comment claims. Either assert something the other test does not — for example that no index read (smembers) happens before the rejection — or fold it into the earlier test.

  2. The block does not cover revokedAt === payload.created_at. The implementation rejects on equality (revokedAt >= payload.created_at in cookie-session.strategy.ts line 130), so a same-second re-login is refused. That operator is the whole control; pin it with a test so a later change to > is caught.

Based on learnings: use a risk-based testing approach; add tests when they provide real signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/auth/oidc/strategies/cookie-session.strategy.index.spec.ts` around
lines 262 - 275, The unindexed-session test duplicates an existing revocation
test and does not verify index-independent rejection. Update the relevant
describe block to either remove it or assert the distinct behavior that no
smembers/index read occurs before rejection; additionally add a test covering
revokedAt equal to payload.created_at, verifying cookie-session validation
rejects the session at the >= boundary.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/domain/community/user/user.service.ts`:
- Around line 606-611: Update the catch blocks for clearIdentityActorMetadata
and deleteIdentityById to use redactError(error) for logged errors and
redactStack(error) for logged stacks, matching invalidateAllIdentitySessions.
Ensure no raw error?.message or error?.stack values remain in this deletion
path.

In `@src/services/api/me/me.resolver.fields.spec.ts`:
- Around line 283-286: Update the test case around resolver.conversations to
capture its returned value and assert that it is the expected empty conversation
envelope, while retaining the existing logger.verbose non-invocation assertion.
Ensure the test verifies both the result and the absence of logging.

---

Outside diff comments:
In `@specs/107-oidc-session-revocation/contracts/redis-keyspace.md`:
- Line 82: Update invariant I2 in the Redis keyspace specification to reference
the TTL-roll script’s actual enforcement condition, specifically the `ttl < 0 or
candidate > ttl` branch, instead of referring to the nonexistent `max(...)`
expression. Preserve the invariant’s meaning that TTL only increases.

---

Nitpick comments:
In `@src/core/auth/oidc/revocation/revocation-ends-access.spec.ts`:
- Around line 496-511: Extend the revocation boundary tests around
revokeAllForSub and the fresh-session validation to cover a session whose
created_at exactly equals the revocation marker timestamp, asserting the
expected authentication decision; keep the existing future-timestamp case that
verifies revocation is not permanent.

In `@src/core/auth/oidc/session-index.redis.ts`:
- Around line 86-97: Floor the TTL inside ADD_AND_ROLL_TTL_LUA before passing it
to EXPIRE, ensuring fractional absoluteExpiresAtEpochSeconds values become valid
integer TTLs for every caller. Keep the existing candidateTtlS calculation and
EVAL invocation behavior unchanged.

In `@src/core/auth/oidc/strategies/cookie-session.strategy.index.spec.ts`:
- Around line 262-275: The unindexed-session test duplicates an existing
revocation test and does not verify index-independent rejection. Update the
relevant describe block to either remove it or assert the distinct behavior that
no smembers/index read occurs before rejection; additionally add a test covering
revokedAt equal to payload.created_at, verifying cookie-session validation
rejects the session at the >= boundary.

In `@src/domain/community/user/user.service.ts`:
- Around line 593-635: Replace the direct OIDC revocation call in the
user-deletion flow with a UserDeleted domain event. Publish it only after the
deletion transaction commits and include authenticationID in the event payload.
Add an OIDC-side listener that invokes
OidcSessionRevocationService.revokeAllForSub with best-effort error handling,
then remove the direct dependency and related cycle workaround from UserService.
- Line 593: The user deletion flow in user.service.ts contains two separate if
guards checking the same user.authenticationID condition, creating redundant
branching. Merge the two user.authenticationID guards into a single conditional
block, combining the operations from both blocks while preserving their
documented order: session revocation, followed by Kratos session invalidation,
followed by metadata clear, followed by conditional identity deletion. This
eliminates the duplicate branch while maintaining the correct operation
sequence.
- Around line 18-22: Extract the redactError and redactStack helpers from
OidcSessionRevocationService into a dedicated redaction module, then update
imports in both OidcSessionRevocationService and the user service to use that
module while preserving their existing behavior and exports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c38340ce-2be6-4962-a476-20b0bb8a9fe3

📥 Commits

Reviewing files that changed from the base of the PR and between 66edb0f and 2c85bb9.

📒 Files selected for processing (23)
  • specs/107-oidc-session-revocation/contracts/redis-keyspace.md
  • specs/107-oidc-session-revocation/quickstart.md
  • specs/107-oidc-session-revocation/spec.md
  • src/core/auth/oidc/audit.ts
  • src/core/auth/oidc/oidc.controller.spec.ts
  • src/core/auth/oidc/oidc.controller.ts
  • src/core/auth/oidc/revocation/oidc-session-revocation.service.spec.ts
  • src/core/auth/oidc/revocation/oidc-session-revocation.service.ts
  • src/core/auth/oidc/revocation/revocation-ends-access.spec.ts
  • src/core/auth/oidc/revocation/session-revocation.types.ts
  • src/core/auth/oidc/revocation/subject-contract.spec.ts
  • src/core/auth/oidc/session-index.redis.spec.ts
  • src/core/auth/oidc/session-index.redis.ts
  • src/core/auth/oidc/strategies/cookie-session.strategy.index.spec.ts
  • src/core/auth/oidc/strategies/cookie-session.strategy.spec.ts
  • src/core/auth/oidc/strategies/cookie-session.strategy.ts
  • src/domain/community/user/user.service.ts
  • src/services/api/me/me.conversations.resolver.fields.spec.ts
  • src/services/api/me/me.conversations.resolver.fields.ts
  • src/services/api/me/me.resolver.fields.spec.ts
  • src/services/api/me/me.resolver.fields.ts
  • test/integration/oidc/bearer-test-harness.ts
  • test/integration/oidc/oidc-test-harness.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/services/api/me/me.conversations.resolver.fields.ts
  • src/services/api/me/me.conversations.resolver.fields.spec.ts
  • src/core/auth/oidc/session-index.redis.spec.ts
  • src/core/auth/oidc/revocation/subject-contract.spec.ts

Comment thread src/domain/community/user/user.service.ts
Comment thread src/services/api/me/me.resolver.fields.spec.ts
Round 2 of review on #6324 (CodeRabbit, 2 findings, both agreed).

- clearIdentityActorMetadata and deleteIdentityById still logged error.message
  and error.stack raw. They hit the same Kratos client as
  invalidateAllIdentitySessions, so they can surface the same material, and a
  stack repeats the message — which is what defeats a redacted message. Both
  now use redactError/redactStack, so the deletion path has one policy rather
  than a redacted first call and two unredacted ones.
- the me.conversations degradation test asserted only that nothing was logged,
  so a wrong non-empty result would have passed. It now asserts the empty
  envelope as well.

Refs #6315

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 15:18
@hero101
hero101 marked this pull request as draft July 31, 2026 15:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (2)

specs/107-oidc-session-revocation/contracts/session-revocation-service.md:81

  • The contract claims per-session teardown includes deleting alkemio:sid:<sid>:refresh-lock, but the implementation in OidcSessionRevocationService#revokeOne explicitly does NOT delete the refresh lock (see src/core/auth/oidc/revocation/oidc-session-revocation.service.ts:353+). This mismatch makes the contract misleading for future consumers/maintainers.
1. `get(sid)` → capture `client_id` and `refresh_token` **before** any mutation
   (the tombstone blanks them).
2. `markTerminated(sid, reason, { sub, client_id })`.
3. `DEL alkemio:sid:<sid>:refresh-lock` (FR-011).
4. `SREM alkemio:sub:<sub> sid`.

specs/107-oidc-session-revocation/contracts/graphql-me-degradation.md:88

  • This contract says degraded me fields log at warn level, but the implementation/tests use logger.verbose (e.g. src/services/api/me/me.resolver.fields.ts:41+, me.resolver.fields.spec.ts:98+). The contract should match the actual log level (and the rationale in me.conversations.resolver.fields.spec.ts for avoiding WARN noise).
Every degraded field emits exactly one **warn**-level line before returning:

@hero101

hero101 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants