OpenConceptLab/ocl_online#339 | Sign-in: don't map a Keycloak identity onto a deactivated account unless the verified email matches - #921
Conversation
…ty onto a deactivated account unless its verified email matches filter_users_by_claims matched on username alone, so a sign-in could land on a deactivated account. Now a deactivated account is reactivated only when the claims carry its email, verified by Keycloak (compared case-insensitively). Otherwise the sign-in is refused and the account left untouched: usernames are unique, so the sign-in can't get a new account under that name either. - The refusal is a DeactivatedAccountLoginRefused (a DRF AuthenticationFailed), logged with the user id. The API answers 401 with a message pointing to the OCL team, rather than the anonymous-access 403. - Usernames still match exactly, as Keycloak sends them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuamFpdtPmh4FX8CpVgANp
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 1 (codex-cli 0.159.2, commit d878f26)
Reviewed git diff origin/master...HEAD; HEAD is d878f2631e960f845680de3c08e04e5969c41cdd. No files modified. These are source-based findings; Docker access was denied, so tests were not run.
-
High — Account checks and writes are vulnerable to races. backends.py:113, backends.py:87
An active user can be fetched, concurrently deactivated, then saved byupdate_user()with staleis_active=Truewhen another claim changes. This reactivates the account and its repos without checking verified email. Likewise, a deactivated account’s email can change after the check;undelete()saves the stale object, restoring the old email and activating it. Deactivation between the two queries can also produce an empty result followed by duplicate-username creation and an uncaught integrity error.
Fix: Lock the username row inside a transaction spanning lookup, verification, reactivation andupdate_user(). Coordinate competing account-state writes with that locking strategy. -
(Moved to the private ticket.) This finding is about existing production data rather than the code in this diff. It is tracked on OpenConceptLab/ocl_online#339.
-
Medium — Refused callbacks retain newly stored credentials and potentially an existing login. backends.py:93
Upstreamauth.py:318stores tokens beforeget_or_create_user(). WithOIDC_STORE_ACCESS_TOKEN=True, returningNonedoes not remove the refused identity’s access token. Upstream callbacklogin_failure()only redirects; an existing authenticated session also remains.TokenAuthMiddleWaresubsequently injects the retained token into requests, creating inconsistent session/header identities and repeated refusals.
Fix: Store credentials only after successful account resolution, or restore the previous credential state on refusal. Explicitly define whether a failed account-switch attempt preserves or clears the existing login. -
Medium — Reactivation can partially commit and then return 500. backends.py:119, signals.py:23
undelete()saves the user; its signal updates sources, synchronously indexes them, then updates/indexes collections. An ES failure can leave the user and sources active while collections remain inactive.BatchIndexingErroris not handled by the refusal catches, so REST, middleware, GraphQL and callback paths can return 500 after writes. Large owners also incur full repo indexing during authentication.
Fix: Make database changes atomic and enqueue indexing after commit with retry support. Keep ES availability outside authentication success. -
Medium — Non-string email claims cause 500 instead of refusal. backends.py:126
A truthy JSON number, boolean, list or object inemailraisesAttributeErrorat.strip(), even whenemail_verified=False. Upstream claim verification only checks that the email key exists. None of the relevant callers catches this error. This requires malformed provider claims, rather than an ordinary valid Keycloak email.
Fix: Require a string email and literalTrueverification before normalization; otherwise raise the refusal. -
Low — Every active-user authentication adds a database query. backends.py:113
The inactive-row.first()query precedes the original user lookup. Protected REST requests can authenticate once in middleware and again in DRF, adding two queries per request relative to the previous code.
Fix: Fetch the unique username row once, inspect its state, and reuse the resolved object through the authentication flow.
The new tests leave material gaps:
- common/tests.py:2606: unchanged fields and timestamps do not prove no save, no
update_user()call, or no signal invocation. Spy on those operations; also check collections and token state. - common/tests.py:2691: mocking upstream
authenticate()proves exception conversion only; it cannot reveal retained session tokens. - middlewares/tests.py:74: mocking
OCLAuthenticationproves response formatting, not real backend propagation or absence of writes. - Missing coverage includes concurrency, malformed claim types, indexing failures, query counts and real callback/DRF/GraphQL integration.
No other significant finding: Literal email_verified is True rejects coercion tricks; empty normalized emails fail. String trimming/lowercasing follows the stated policy, with no additional Unicode bypass identified. Exact username matching and the unique constraint prevent ordinary multiple-row selection; case variants remain separate accounts as before.
The refusal exception itself propagates correctly: REST and TokenExchangeView use DRF’s 401 handling; protected middleware returns 401; GraphQL catches it as AuthenticationFailed and produces anonymous/invalid context; session authentication returns None. Ordinary sequential refusal occurs before undelete() or update_user() and does not write the user or repos.
Successful undelete() deliberately clears verification/deactivation metadata and activates all inactive owned sources and collections, including independently inactive repos. It creates no Django token; token exchange can create one afterward. New log messages contain only user IDs and outcomes—no emails, claims or tokens.
…fuses a non-string email, and drops a refused identity's session tokens From the Codex review on #921: - filter_users_by_claims evaluates one query and returns it, so the row it checks is the row update_user gets, with no extra query per request. - A non-string email claim is refused instead of raising AttributeError. - On the session callback path, a refused sign-in removes the tokens upstream stored before the account was looked up. Tests: spies prove a refusal doesn't save, update or create a user; the owner's collections come back on reactivation; one query per lookup; and a bearer request runs through the real middleware, DRF OIDC authentication and backend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuamFpdtPmh4FX8CpVgANp
|
How the Codex pass 1 findings were handled (fixes in e5a6279):
Tests:
Local, at e5a6279: the full suite passes (2,322 tests), and |
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 2 (codex-cli 0.159.2, commit e5a6279)
Reviewed git diff origin/master...HEAD; HEAD is e5a6279a0cdcf558b2afdfc0f7f8681004094044. One functional issue remains.
-
Medium — an absent email claim bypasses the refusal and session cleanup.
Locations: core/common/backends.py:93, core/common/backends.py:102; upstreamauth.py:81,:318,:343.Scenario: Valid Keycloak userinfo names a deactivated account but omits
emailentirely. With the configured email scope, upstreamverify_claimsraisesSuspiciousOperationbeforefilter_users_by_claimsexecutes.- On a protected bearer request, this becomes generic
AuthenticationFailed, which the middleware suppresses into anonymous 403, rather than the intended refusal 401. - During the session callback, tokens have already been stored. Upstream catches
SuspiciousOperationand returnsNone, so the new exception handler never removes those tokens.
The account and repositories remain untouched; this does not authenticate the refused identity. It does leave failed-login token state and violates the specified refusal behavior.
Suggested fix: Handle the deactivated-account guard during claim verification, before upstream rejects a missing email, while preserving normal validation for other accounts. Add full-path bearer and callback tests with the
emailkey absent. - On a protected bearer request, this becomes generic
-
Low — the callback test does not prove persisted session cleanup.
Location: core/common/tests.py:2719.Scenario: The test replaces upstream
authenticatewith a function that directly raises the new exception and uses a dictionary session. It proves the catch-and-pop branch, but misses the upstream early-return path above and callback session persistence.Suggested fix: Exercise the actual callback with a Django session, mocking provider responses only; reload the session and assert both token keys are absent and no refused user is logged in.
Otherwise, no significant issue found within the agreed scope:
- The evaluated queryset is cached and reused by upstream
update_user; the extra lookup is removed. - Non-string emails, empty values, and non-boolean verification claims fail closed. Exact username matching and trimmed, case-insensitive email comparison match the stated policy.
- Refusal occurs before user updates, group changes, saves, or repository propagation.
- DRF views, TokenExchangeView, and GraphQL handle the exception without a new 500. GraphQL treats it as invalid authentication.
- Active-user behavior remains unchanged. Reactivation retains the acknowledged verification, repository, checksum, and synchronous indexing side effects;
undelete()does not recreate a Django token. - (One line about the production data item from pass 1 moved to the private ticket, OpenConceptLab/ocl_online#339.)
- New logs contain only numeric user IDs and outcome text, with no emails, claims, or tokens.
No files were modified. Tests could not run because Docker socket access was denied.
|
Codex pass 2: nothing changed in code. Both findings describe behaviour that is the same on master:
Pass 2 found no other issues in the change: the single lookup, the claim checks, the refusal before any write, the DRF/GraphQL/middleware handling, unchanged active-user behaviour, and the logging. |
Part of OpenConceptLab/ocl_online#339.
What changes
OCLOIDCAuthenticationBackend.filter_users_by_claimsmatched a Keycloak identity to an OCL account by username alone, including a deactivated account. Now:undelete()) only when the claims carryemail_verified: trueand an email equal to the account's (case-insensitive, trimmed).undelete()saves the profile, sopropagate_owner_statusbrings the owner's repos back with it.Design note: why a refusal, not a new account
Usernames are unique (
user_profiles_username_key). If the filter returned no match for a deactivated account's username, mozilla-django-oidc would callcreate_user. That raisesIntegrityErroron the unique index (checked with a probe), which would be an unhandled 500 on every request. So the sign-in is refused instead, never mapped onto the old row:DeactivatedAccountLoginRefused(a DRFAuthenticationFailed) answers 401 with a message asking the person to contact the OCL team (COMMUNITY_EMAIL).RequireAuthenticationMiddlewarereturns that 401 as-is. Before this change it became the anonymous-access 403. Other authentication failures still get the 403.authenticate()returnsNone, so that login fails cleanly instead of raising.Staff can restore an account with the existing
PUT /users/<username>/reactivate/.Tests
core/common/tests.py(OCLOIDCAuthenticationBackendTest):get_or_create_userrun.updated_at).authenticate()returnsNoneon a refusal.core/middlewares/tests.py:WWW-Authenticate.AuthenticationFailedstill gets the 403.Local results, in an isolated compose stack with CI's commands:
backends.py(8 failures, 1 error) and pass with this change.pylint -j0 core/rates 10.00/10.🤖 Generated with Claude Code
https://claude.ai/code/session_01LuamFpdtPmh4FX8CpVgANp