fix(oidc): invalidate current token on logout#225
Conversation
|
Addressed the blocking token resurrection race flagged in review. What changed:
Verification:
|
8277912 to
51e7d54
Compare
|
Rebased on latest origin/main and pushed with --force-with-lease. Also addressed the post-8277912 blocking feedback from Jerry-Xin:
Verification after the change:
|
Jerry-Xin
left a comment
There was a problem hiding this comment.
β Re-Review: APPROVE
All three blocking issues from the previous rounds are now fixed:
-
Lua CAS atomic delete β
uidtokencleanup uses a Lua script (GET == want THEN DEL) instead of the non-atomicGET+DELsequence. Concurrent login updatinguidtokenbetween the two operations can no longer cause logout to delete the new session's reverse index. -
Login-side SETXX β
refreshExistingLoginTokenuses RedisSET XX(set only if key exists). If logout has already deletedtoken:<old>, SETXX returns false and login generates a fresh UUID. No more token resurrection. -
Redis client lifecycle β
OIDC.Close()explicitly closes theredisCompareDeleter's Redis client. No resource leak.
Test coverage is solid:
raceyCompareDeletersimulates concurrent login updating uidtoken during logout cleanup β verifies new login's index survivesTestRefreshExistingLoginToken_DoesNotRecreateDeletedTokenβ verifies deleted token key is not recreated by concurrent loginTestAPI_Logout_InvalidatesCurrentHTTPTokenOnlyβ end-to-end: current token invalidated, other device tokens untouched
The complete chain is now race-free:
- Logout deletes
token:<current>β - Concurrent login SETXX detects missing key β generates new token β
- Logout Lua CAS prevents stale uidtoken deletion β
- Resources properly released β
Clean fix. Approve.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review β PR #225 (octo-server)
Security-sensitive review of the OIDC logout token-invalidation change. I checked out the head SHA, built the changed modules, ran the new unit tests, and traced every login path that reuses a cached token. The OIDC-side fix and the execLogin (web/PC) fix are correct and well-tested. However, one token-reuse login path was left unpatched and reopens the exact resurrection window this PR is meant to close, so I'm requesting changes.
Verdict
CHANGES_REQUESTED β one P1 (logout-bypass via a second, unfixed login path). The rest of the change is solid.
What is correct (verified)
OIDC.logoutβInvalidateCurrentToken(modules/oidc/api.go:915,:1360): deletestoken:<current>(the keyAuthMiddlewarereads β confirmedAuthMiddlewareusesc.GetHeader("token")and looks uptokenPrefix+token), then atomically compare-deletes theuidtoken:<flag><uid>reverse index via a LuaGET==ARGV[1] then DELscript (:82,DeleteIfValueat:1409). The compare-and-delete correctly avoids clobbering a concurrent re-login that already rotated the index to a new token.TestCurrentTokenInvalidator_DoesNotDeleteConcurrentUIDTokenUpdateexercises this race and passes.execLoginweb/PC reuse (modules/user/api.go:1512,:1528β:1546): reused tokens are now refreshed withSET ... XX(refreshExistingLoginTokenβSetIfExists). In go-redis v6 (v6.15.9)SetXXmaps the nil reply to(false, nil)(verified inBoolCmd.readReply), so whentoken:<oldToken>was already deleted by logout, the path falls through to a fresh UUID instead of recreating the dead key. Legitimate web multi-login is preserved (key exists β XX refresh succeeds β same token).TestRefreshExistingLoginToken_DoesNotRecreateDeletedTokencovers this and passes.- Same Redis keyspace: the two new raw
rd.NewClient(MustBuildOptions(...))clients use the same Addr/Pass and DB 0 asctx.Cache()(common.NewRedisCache(RedisAddr, RedisPass)), and follow the established repo pattern (state_store_redis.go,sync_lock.go, etc.). The OIDC client is closed via the moduleStop: o.Closewiring. - Build of
./modules/oidc/... ./modules/user/...is clean;gofmt/go vetclean on changed files. The new metric labeltoken_failand the independent-counter refactor inlogoutare correct (each failure dimension now increments independently).
P1 β Blocking: scan-login path still resurrects a logged-out token
loginWithAuthCode (the QR scan-login handler, route POST /user/login_authcode/:auth_code, modules/user/api.go:1982) reuses the cached token the same way execLogin used to β but it was not given the SetXX treatment:
// modules/user/api.go:2024
token, err := u.ctx.Cache().Get(fmt.Sprintf("%s%d%s", ...UIDTokenCachePrefix, flag, scaner))
...
if strings.TrimSpace(token) == "" { // :2031
token = util.GenerUUID()
}
...
// modules/user/api.go:2114 β unconditional, recreates the key
err = u.ctx.Cache().SetAndExpire(...TokenCachePrefix+token, tokenPayload, ...TokenExpire)This is the identical TOCTOU the PR fixes elsewhere. logout deletes token:<current> first and compare-deletes the uidtoken index second; in the window between those two steps a concurrent scan-login (default flag = Web, modules/user/api.go:1988) for the same uid reads the still-present uidtoken:1<uid>, gets the just-logged-out token string, and re-SetAndExpires token:<that token> β resurrecting the session. Logout returned 200 but the bearer token works again, which is exactly the logout-bypass this PR exists to prevent. The path is a live, rate-limited route (modules/user/api.go:297), reachable for any uid including OIDC-provisioned users.
Required change: apply the same guard to loginWithAuthCode β reuse the cached token only if token:<oldToken> still exists. The cleanest option is to route it through the same SET XX helper used by execLogin (refreshExistingLoginToken), falling back to a fresh UUID when the refresh returns false. Pure reordering inside logout would only shrink the window, not close it.
P2 / non-blocking
redisExistingTokenSetter's client (modules/user/api.go:158) is never closed βUserhas noClose. It's a process-lifetime singleton created once at startup, so not a leak in practice, but worth a comment or aClosehook for symmetry with the OIDC side.- Two independent Redis clients are now created at startup (OIDC
redisCompareDeleter+ userredisExistingTokenSetter), each with its own pool. Consistent with the existing per-store-client pattern in this repo, so acceptable; just noting the growing client count. InvalidateCurrentTokenissues a compare-del for all three device flags on every logout (up to 3 Redis round trips). Fine for logout volume; only flagging for awareness.
Manual-verification call-outs for a human reviewer (security-sensitive)
- Confirm the threat model treats scan-login (
loginWithAuthCode) as in-scope for "a logged-out token must stay dead." If it is (it should be), the P1 above must be fixed before merge. - Confirm in the deployed config that Redis runs on DB 0 for both
ctx.Cache()and the new raw clients (the options builder sets no DB index β it inherits the default 0, matchingctx.Cache()).
51e7d54 to
261fff1
Compare
lml2468
left a comment
There was a problem hiding this comment.
Re-review (261fff1)
New commit fix(user): guard scan-login token reuse with SET XX addresses Allen's blocking point about the QR code login path.
What changed
loginWithAuthCode now mirrors the execLogin SETXX pattern:
reuseExistingTokenflag set when uidtoken yields a non-empty old tokenrefreshExistingLoginToken(SETXX) checks iftoken:<old>still exists before reuse- If SETXX returns false (deleted by concurrent logout), generates new UUID
- Token decision moved BEFORE
UpdateIMTokenβ IM always gets the final token
Tests
- Source guard (
api_authcode_token_test.go): VerifiesrefreshExistingLoginTokencall exists inloginWithAuthCode, is beforeUpdateIMToken, and!reuseExistingTokenfallback is present. Regression-proof. - E2E (
api_authcode_e2e_test.go): Real Redis, two cases: (a) deleted token β new UUID issued, old not resurrected; (b) existing token β properly reused (Web multi-device preserved).
Full chain now covers ALL login paths
execLogin(password/SMS): SETXX βloginWithAuthCode(QR scan): SETXX β- Logout: token delete + Lua CAS uidtoken cleanup β
- Redis client lifecycle β
No remaining gaps. APPROVED.
Jerry-Xin
left a comment
There was a problem hiding this comment.
β Re-Review: APPROVE
All blocking issues resolved across four rounds:
- Token invalidation β logout correctly deletes
token:<current>β - Login SETXX (execLogin) β Web/PC token reuse guarded by
SET XX; deleted tokens cannot be resurrected β - Login SETXX (loginWithAuthCode) β scan-login path now uses the same
refreshExistingLoginTokenguard β - Lua CAS atomic uidtoken cleanup β
GET == want THEN DELprevents concurrent login's index from being deleted β - Redis client lifecycle β
OIDC.Close()properly closes the compare-deleter client β
Test coverage is thorough:
- Unit:
raceyCompareDeletersimulates concurrent uidtoken update during logout - Unit:
TestRefreshExistingLoginToken_DoesNotRecreateDeletedToken - Source guard: locks
refreshExistingLoginTokenbeforeUpdateIMTokenin authcode path - E2E: real Redis validates both invariants (deleted β new token / existing β reuse)
All login entry points now have SETXX guards. No gaps remain.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review β PR #225 (octo-server)
Verdict: APPROVED β security-sensitive (auth/token). No blocking (P0/P1) issues found. A couple of non-blocking notes below.
Summary of the change
This PR closes a real session-fixation / logout-bypass gap: previously OIDC logout only quit the IM connection and revoked refresh tokens, leaving the HTTP API token (token:<token>) live, and a concurrent or subsequent Web/PC/scan login could re-create a token that logout had just deleted. The fix:
- OIDC logout now invalidates the current HTTP token (
cacheCurrentTokenInvalidator): deletestoken:<token>, then for each device flag compare-deletes theuidtoken:<flag><uid>reverse index only if it still points at the current token (atomic Redis Lua compare-del, with a non-atomic Get+Delete fallback for the in-memory test path). Correctly scoped to the current device β it does not nuke other devices' tokens. - Login paths close the resurrection race (
execLoginWeb/PC reuse branch andloginWithAuthCode): when reusing a token reverse-looked-up fromuidtoken, the write now usesSET XX(SetIfExists). If the token key was already deleted by a concurrent logout,SET XXfails and the path falls back to a fresh UUID instead of resurrecting the logged-out token.loginWithAuthCodewas reordered so the final token decision happens beforeUpdateIMToken, ensuring IM receives the token that is actually persisted. - Metrics: adds a
token_faillabel and refactors the logout result switch so token/kick/revoke failures are counted independently in the same request.
1. Verification
- β
Logout actually invalidates the session token.
logoutβo.tokenKill.InvalidateCurrentToken(ctx, uid, c.GetHeader("token"))(modules/oidc/api.go:914-921). The token header is the same oneAuthMiddlewareauthenticates with (octo-lib .../wkhttp/http.go:271,c.GetHeader("token")), and logout is gated onuid != ""(api.go:893-897), so the value is a post-auth session token. - β
uidtokenindex is only removed when it still points at the current token. Lua compare-del is atomic (api.go:37-42,197-210); a concurrent new login that rewrote the index is preserved. Covered byTestCurrentTokenInvalidator_DoesNotDeleteConcurrentUIDTokenUpdateandTestAPI_Logout_InvalidatesCurrentHTTPTokenOnly. - β
Login does not resurrect a logged-out token.
SET XX+ fallback-to-new-UUID in bothexecLogin(api.go:1525-1552) andloginWithAuthCode(api.go:2105-2128). Covered byTestRefreshExistingLoginToken_DoesNotRecreateDeletedTokenand the contract/e2e guards. - β
Token decision precedes
UpdateIMTokeninloginWithAuthCodeβ verified by source-contract testTestLoginWithAuthCode_ReusedTokenGuardedBySetXX. - β
Web/PC multi-login not broken β reuse path still returns the old token when it remains valid (
TestLoginWithAuthCode_E2E_TokenResurrection"tokenδ»εε¨ζΆ_ζ£εΈΈε€η¨ζ§token"). - β
Both Redis clients (cache + compare-deleter) target the same
RedisAddr/DB (pkg/redis/options.go:24-44vsocto-lib config/context.go:107-121), so the compare-delete sees the same keys as the cache. The OIDC compare-deleter client is properly closed inOIDC.Close()(api.go:72-79). - β
go build/go vet/gofmt/git diff --checkclean; the dependency-free unit tests pass.
2. Findings
No P0/P1. Two non-blocking notes:
P2 β redisExistingTokenSetter Redis client has no close path (modules/user/api.go:158-162). User.New() creates a dedicated rd.Client that is never closed, and User has no Close() method. In practice this is not a leak that grows: User is a process-lifetime singleton registered once via register.AddModule, and the module already holds an identical never-closed client (rlRedis, api.go:191), so this follows existing precedent rather than introducing new behavior. Worth a follow-up to give User a Close() that releases both clients for shutdown hygiene, but it is not a merge blocker for this PR.
nit β neutral observation, pre-existing, out of scope. loginWithAuthCode's token payload omits Role (api.go:2095-2099) whereas execLogin includes it (api.go:1517-1522). This is not introduced by this PR β the same omission exists on main (the PR only relocated the auth.Encode block, it did not change its fields). Flagging only so it isn't mistaken for a regression here; if QR-login role propagation matters it deserves its own ticket.
3. Recommendations
- (Optional, follow-up) Add a
User.Close()that closesexistingTokenSetterandrlRedis, mirroringOIDC.Close(). - (Optional, separate ticket) Decide whether scan-login tokens should carry
Rolefor consistency withexecLogin.
4. Additional notes for a human verifier (security-sensitive)
- The fix deliberately invalidates only the current device's HTTP token, not all tokens for the UID β confirm this matches the intended logout semantics (single-device logout). The code comments state this is intentional.
- The atomicity guarantee against the login/logout race rests on the Redis
SET XXand Lua compare-del executing against the live Redis. The in-memoryMemoryCachefallback (deleteIndexIfCurrentTokenGet+Delete) is test-only and non-atomic β fine because production always injects the Lua-backedredisCompareDeleter.
Overall this is a focused, well-tested security fix that closes the logout-bypass / token-resurrection gap correctly. Approving.
loginWithAuthCode reused the uidtoken-indexed token and unconditionally recreated token:<old>, resurrecting a token a concurrent OIDC logout had just deleted. Route the reuse through refreshExistingLoginToken (SET XX) and fall back to a fresh UUID when the key is gone, matching execLogin. Token cache write is moved before UpdateIMToken so IM always gets the final token. Tests: a real-Redis integration test pins the production SET XX semantics (missing key not recreated, existing key refreshed), and a source-contract lock asserts loginWithAuthCode routes the reuse through that guard before the IM call. A full HTTP e2e is intentionally avoided: CI WuKongIM rejects UpdateIMToken for unregistered uids, so the regression is anchored at the Redis layer where the race actually occurs.
261fff1 to
ca5e246
Compare
lml2468
left a comment
There was a problem hiding this comment.
Re-review (ca5e246)
Same scan-login SETXX fix as 261fff, rebased. E2E test replaced with a CI-portable real-Redis integration test (api_authcode_token_redis_test.go) that validates SETXX semantics directly without WuKongIM dependency. Source guard and logic unchanged.
APPROVED β same assessment as previous review.
Jerry-Xin
left a comment
There was a problem hiding this comment.
β Re-Review: APPROVE
Delta from 261fff β ca5e24: rebase only, scan-login SETXX logic unchanged. E2E split into api_authcode_token_redis_test.go (real Redis) + api_authcode_token_test.go (source guard). No new issues.
All login entry points guarded, full chain race-free. Approve stands.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review β PR #225 (octo-server)
Scope: fix(oidc): invalidate current token on logout β head ca5e246, base f3793b3.
Security-sensitive (auth/session). Reviewed against all token reuse/invalidation paths in the repo, not just the diff.
Verdict: APPROVED
The PR delivers its stated guarantee β a logged-out HTTP token cannot be resurrected β and closes the cross-file TOCTOU window across all three relevant paths. No blocking (P0/P1) issues found. A few non-blocking nits below.
What the change does
- OIDC logout now invalidates the current HTTP session token (
modules/oidc/api.go).device_quitonly tears down the IM long-connection; HTTP API access is still gated bytoken:<token>in Redis, so logout must explicitly delete it.InvalidateCurrentTokendeletestoken:<current>and then, for each device flag, atomically compare-deletes theuidtoken:<flag><uid>reverse index via a LuaGET == ARGV then DELscript (redisCompareDeleter). This avoids clobbering a concurrent re-login that has already repointed the index to a new token.- Only the current token is removed, not all of the user's tokens β other devices stay logged in. Verified by
TestAPI_Logout_InvalidatesCurrentHTTPTokenOnly.
- Login paths no longer resurrect a deleted token. Both web/PC reuse paths now refresh the reused token with
SET XX(refreshExistingLoginTokenβSetIfExists), so a token is only rewritten iftoken:<oldToken>still exists; otherwise they fall back to a fresh UUID:execLogin(modules/user/api.go:1528-1551)loginWithAuthCode/ scan login (modules/user/api.go:2105-2127)
Verification
| Item | Result | Evidence |
|---|---|---|
Logout deletes current token: + matching uidtoken: index only |
β | modules/oidc/api.go:1362-1397; TestAPI_Logout_InvalidatesCurrentHTTPTokenOnly |
| Reverse-index delete is atomic (no clobber of concurrent re-login) | β | Lua compare-del api.go:78-83; TestCurrentTokenInvalidator_DoesNotDeleteConcurrentUIDTokenUpdate |
execLogin web/PC reuse guarded by SET XX with UUID fallback |
β | modules/user/api.go:1528-1551 |
Scan-login (loginWithAuthCode) reuse guarded by SET XX with UUID fallback |
β | modules/user/api.go:2105-2127; source-contract lock TestLoginWithAuthCode_ReusedTokenGuardedBySetXX |
| Token-reuse decision finalized before the IM call (IM gets the final token) | β | token cache write moved ahead of UpdateIMToken; asserted by the source-contract test |
SET XX on a missing key returns (false, nil), not an error β fallback fires |
β | go-redis v6.15.9 BoolCmd.readReply maps redis.Nil β val=false, err=nil |
Raw redis clients share the same keyspace as ctx.Cache() |
β | all clients default to DB 0; BuildOptions sets only Addr/Pass/TLS, NewRedisCache likewise |
uidtoken cleanup covers the full DeviceFlag enum |
β | loop over {APP, Web, PC}; enum has exactly those three (octo-lib config/msg.go:32-37) |
Logout route is behind AuthMiddleware (uid + token header guaranteed) |
β | modules/oidc/api.go:319-320 |
Logout token header matches the key AuthMiddleware reads |
β | both use c.GetHeader("token") (octo-lib pkg/wkhttp/http.go:307) |
| OIDC raw redis client is closed on shutdown | β | OIDC.Close() closes redisCompareDeleter |
Metrics gain a token_fail label; counter logic emits each failed dimension |
β | metrics.go; logout_total label set + test updated |
| Build / vet | β | go build + go vet on both modules clean |
| Unit tests (infra-independent) | β | new SET XX / contract / invalidator tests pass; integration tests are env-gated on MySQL/Redis |
Non-blocking notes (nits / for a human to eyeball)
- Orphan token key on a failed IM call (nit). In
loginWithAuthCode, the token cache write now precedesUpdateIMToken. If the IM call errors or returnsBanafter a new-UUID branch wrotetoken:<uuid>, that key lingers until its TTL expires. It is never returned to any client and theuidtokenindex is not written, so there is no security impact β just a short-lived orphan. The reuse/SET XXbranch only refreshes an already-valid token, so it is benign. Optional: write the token cache after the IM success check, or clean up on the error path. - Raw redis client lifecycle in
User(nit).redisExistingTokenSetter's client is never closed (noUser.Close). This matches the existing rate-limiter client in the same file (modules/user/api.go:191), so it is a pre-existing, process-lifetime-singleton pattern rather than a regression. OIDC's side does close its client. - Security note for a human reviewer. The correctness of the cross-client compare-delete rests on all redis clients targeting the same DB (DB 0). That holds today because neither
BuildOptionsnorNewRedisCachesets a DB index. Worth keeping in mind if a non-zerodbis ever introduced into the redis config.
Conclusion
This is a clean, well-tested fix. It addresses the previously-identified second resurrection path (scan login) in addition to the OIDC + web/PC paths, the atomic compare-delete correctly avoids racing a concurrent re-login, and the test suite locks the key invariants (including a source-contract guard so the SET XX guard cannot be silently regressed). Approving; the nits above are optional follow-ups, not merge blockers.
Summary
Closes #227
Tests
Notes