feat(oidc): RP-Initiated Logout — backend issues end_session URL on logout#217
Conversation
The OIDC code→token exchange happens server-side, so the frontend never holds the id_token and cannot build an RP-Initiated Logout URL itself. This makes the backend the single owner of the end_session URL. - callback caches the verified id_token (AES-256-GCM encrypted) in Redis, keyed by uid, with a configurable TTL (default 7d, clamped to >0) - POST /logout (kick + revoke unchanged) now builds and returns end_session_url with id_token_hint + a server-configured post_logout_redirect_uri; the id_token is consumed atomically (Lua GETDEL) - end_session_endpoint resolved from discovery, overridable via config - fully backward compatible: missing config / id_token / endpoint degrades to omitting end_session_url (frontend falls back to local-only logout) id_token is never logged; post_logout_redirect_uri is hard-coded server-side (not accepted from the request body), so it doubles as the allowlist. Refs Mininglamp-OSS#215
Address two review findings on the RP-Initiated Logout work: 1. Default-disabled deployments must not persist id_token. New() now only wires the id_token store when DM_OIDC_POST_LOGOUT_REDIRECT_URI is set, so a deployment without RP-Initiated Logout configured stops encrypting and storing PII-bearing JWTs in Redis and skips the extra connection pool. 2. Self-service bind login could never produce an end_session_url. The callback returns early on the bind_pending branch, so the success-path id_token save was never reached. The raw id_token is now stashed under a bind-token (jti) key at bind_pending (TTL = bind session), then promoted to the uid key on /bind/confirm or /bind/create success — one-time consumed. id_token never enters the BindSession; it stays in the dedicated AES-GCM store throughout. Both paths are best-effort and no-op when the feature is disabled. Refs Mininglamp-OSS#215
Align the three optional logout env vars with the OCTO_ prefix used by the bind sub-config (OCTO_OIDC_BIND_*) and OCTO_MASTER_KEY, instead of DM_: - OCTO_OIDC_POST_LOGOUT_REDIRECT_URI - OCTO_OIDC_PROVIDER_END_SESSION_URL - OCTO_OIDC_PROVIDER_ID_TOKEN_TTL All three remain optional; no behavior change. Not yet released, so no backward-compat alias is kept. Refs Mininglamp-OSS#215
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is in scope for octo-server: it extends the existing OIDC module’s logout flow and related bind/callback paths.
💬 Non-blocking
- 🟡 Warning: api.go accepts any
url.Parse-validend_sessionendpoint. Since the frontend will likely top-level navigate to this value, consider rejecting non-absolute or non-HTTP(S) endpoints before returningend_session_url. - 🔵 Suggestion: api.go enables the Redis id-token store solely from
PostLogoutRedirectURI. If discovery has noend_session_endpointand no override is configured, the service may persist encryptedid_tokens even though logout can never use them. Consider enabling the store only when a usable endpoint is also known.
✅ Highlights
- Good one-time consumption semantics using atomic Lua GET+DEL in logout_idtoken.go.
- Callback and bind flows both preserve the verified
id_tokenpath needed for RP-Initiated Logout. - The feature is opt-in and degrades cleanly to the prior local-only logout response when prerequisites are missing.
Verification: targeted new tests passed. Full go test ./modules/oidc could not complete in this environment because Redis/MySQL integration dependencies on 127.0.0.1:6379 and 127.0.0.1:3306 were unavailable.
lml2468
left a comment
There was a problem hiding this comment.
Security Assessment
This is a well-designed, security-conscious RP-Initiated Logout implementation. No blocking issues found.
Verified Security Properties
- id_token at rest: AES-256-GCM encrypted in Redis (same key derivation as refresh token), base64-encoded ciphertext — no plaintext id_token in storage. ✅
- id_token leakage: Never logged.
buildEndSessionURLlogs only the endpoint on parse error, never the assembled URL containingid_token_hint. ✅ - One-time consume: Lua
GETDELscript provides atomic get-and-delete, eliminating concurrent double-logout extraction window. ✅ - No open redirect:
post_logout_redirect_uriis server-side hard-coded via env var, never taken from request body. Single value = single allowlist entry. ✅ - Opt-in by default: With
OCTO_OIDC_POST_LOGOUT_REDIRECT_URIunset,idTokenStoreisnil, no Redis connection pool created, no PII cached, logout response unchanged. ✅ - TTL safety:
IDTokenTTL <= 0clamped to 7d default, preventing never-expiring Redis keys (go-redisSetwith 0 TTL = persistent). ✅ - Graceful degradation: All id_token save/take failures are best-effort (Warn-level log, no login/logout blocking). ✅
- Bind path: Two-phase stash (jti key at
bind_pending) → promote (uid key atconfirm/create) correctly handles the bind flow where uid is unknown at callback time. jti namespace isolation viabind:prefix prevents collision with uid keys. ✅ - URL construction: Uses
url.Parse+url.Values.Set+url.Values.Encode— proper query parameter escaping. TestTestBuildEndSessionURL_OverrideAndEscapingverifies round-trip of special characters. ✅ - Discovery parsing:
provider.Claims(&extra)failure is silently ignored — Client construction succeeds, logout falls back to config override or degrades. ✅
Code Quality
- 304 lines of tests covering: happy path, no-id-token degrade, no-redirect-config degrade, nil-store degrade, callback stores id_token, bind stash/promote for both confirm and create, URL override + escaping, discovery endpoint parsing.
- All test assertions verify both positive (URL present, correct parameters) and negative (consumed after use, not leaked) properties.
CI Note
check-sprintfails: linked issue #215 exists but has no Sprint set on the Octo Board. Administrative fix needed (assign Sprint W22 to issue #215).- Build/i18n/Lint/PersonalMsgSendReq passed. Test/Vet/CodeQL were pending at review time.
No state Parameter
The end_session URL omits the optional state parameter — code comment explains Aegis discovery does not declare it. Per RP-Initiated Logout spec, state is OPTIONAL and only useful when the RP needs to correlate the post-logout redirect. Since the redirect target is a static login page, omission is correct.
No findings.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #217 (octo-server)
feat(oidc): RP-Initiated Logout — backend issues end_session URL on logout
Independent review of the OIDC RP-Initiated Logout feature. Verified locally at head e0beb3a: go vet ./modules/oidc/ clean, gofmt clean (no changed file is dirty), and all new unit tests pass (TestAPI_Logout_*, TestBuildEndSessionURL_*, TestClient_EndSessionEndpoint_*, TestAPI_Callback_*, TestAPI_BindConfirm/Create_PromotesIDToken).
Verdict
APPROVED. No P0/P1 blockers. The feature is opt-in (constructed only when OCTO_OIDC_POST_LOGOUT_REDIRECT_URI is set), the cached id_token is AES-256-GCM encrypted at rest, one-time consumed via an atomic Lua GETDEL, and never logged. Backward compatibility is genuinely byte-for-byte when disabled. The findings below are hardening, comment-accuracy, and test-coverage improvements — none block merge, but several are worth addressing before enabling in production.
Strengths (verified)
- Encryption at rest reuses the audited
Encryptor(AES-256-GCM,crypto.go); value is base64 of nonce‖ct‖tag — no plaintext JWT in Redis. - One-time consume —
Takeuses the sameluaGetDelas the state store (logout_idtoken.go:74), closing the concurrent double-logout double-emit window. - Resource lifecycle —
idTokenspool is closed inClose()(api.go:803-808), placed before thestateStore==nilearly-return; created beforeNewClientso the Discovery-failureo.Close()path also releases it. Verified leak-safe and nil-safe. - Feature gate — when
PostLogoutRedirectURIis unset,o.idTokensstays nil, so no PII-bearing JWT is ever persisted. The<=0TTL clamp (config.go:187-189) correctly neutralizes the go-redis "never-expire" footgun. - Degrade contract — every
Save/Takefailure is best-effort: login/bind/logout still succeed; only the optionalend_session_urlis omitted. The genuinely security-relevant logout ops (Kick,RevokeRefreshByUID) keep their own error accounting and are not swallowed.
Findings
P2 — Misleading security comment: "frontend never holds id_token" is no longer accurate
api.go:826-828 justifies the backend-builds-URL design with "前端从不持有 id_token". But buildEndSessionURL puts the raw id_token into the id_token_hint query param (api.go:933) and logout returns that full URL in the JSON body (api.go:884-886); the frontend then does a top-level navigation. So the frontend does hold the token (in the URL), and it is exposed to the JS layer, browser history, the Referer header, and the IdP's HTTP access logs. This is inherent to OIDC RP-Initiated Logout (the spec requires id_token_hint in the front-channel redirect) and the token is single-use + non-replayable, so it is not an exploitable vuln — but for a security-sensitive change the rationale comment should be corrected to state the real trust model ("frontend cannot construct the hint; backend supplies a single-use URL that necessarily carries it") and the front-channel exposure should be explicitly accepted.
P2 — No scheme/host validation on PostLogoutRedirectURI / EndSessionURL
config.go:155-156 load both via plain getString with zero validation; buildEndSessionURL only checks non-empty + url.Parse success (url.Parse accepts javascript:, relative values, etc.). EndSessionURL is the host the browser navigates to with the id_token attached — a typo'd/wrong env value sends the token to an arbitrary host. Both are ops-trust inputs, so this is misconfiguration-hardening, not a client-facing vuln. But the codebase already establishes the exact pattern elsewhere — return_to.go:ValidateReturnTo (http/https + host allowlist) and bind_config.go OCTO_OIDC_BIND_REDIRECT_BASE (must be absolute https, explicitly rejecting javascript:/data:). Recommend a startup-time scheme==https + IsAbs check on both new values so a misconfig fails loudly at boot instead of silently weakening the "single value = allowlist" premise.
P2 — Best-effort degrade paths are untested (declared-but-unused error hooks)
fakeIDTokenStore declares saveErr and takeErr injection fields (logout_idtoken_test.go:22-23) and implements the return branches, but no test ever assigns them. As a result the PR's headline "best-effort degrade" contract is unproven on its error branches: buildEndSessionURL Take-error → Warn + omit URL + still 200 (api.go:917-922), and callback Save-error → still complete login (api.go:766-770). For a security-sensitive auth path, please add: (a) takeErr injected → logout still 200 and omits end_session_url; (b) saveErr injected → callback still 302/login succeeds.
P2 — bind:<jti> stash overloads the uid parameter / shares the id_token keyspace
saveBindIDTokenHint/promoteBindIDToken pass bindIDTokenKey(jti) ("bind:"+jti) through the uid parameter of Save/Take, so the bind stash lands at oidc:idtoken:bind:<jti> — same prefix as the real oidc:idtoken:<uid> cache. Isolation rests entirely on the comment assumption that uids never start with bind: (logout_idtoken.go:114-115), with no type/validation enforcement. Currently safe (uids are system-generated; jti is 32B base64), but a dedicated SaveByKey/separate stash interface, or a distinct physical prefix, would make the isolation a code guarantee rather than a convention.
nit — TTL doc/clamp polish
- The
IDTokenTTLcomment (config.go:89) cites the default as"7d", buttime.ParseDurationrejects thedunit — an operator who copiesOCTO_OIDC_PROVIDER_ID_TOKEN_TTL=7dwould silently fall back to the default. Suggest writing168hin the comment to match a parseable form. (Harm is low: the var is only referenced in a Go source comment, and the fallback value equals the intended 7d.) - Consider clamping the cache TTL to
min(IDTokenTTL, time-until-id_token.exp)—IDTokenClaims.Expiryis already decoded at callback, so a short-lived id_token need not sit encrypted at rest for 7 days after it is useless. - No test covers
OCTO_OIDC_PROVIDER_ID_TOKEN_TTLparsing or the<=0clamp; a smallconfig_test.gocase (default /168hoverride / negative→clamp) would lock in the footgun guard.
For the human reviewer (security-sensitive)
- Confirm the IdP (Aegis) treats
id_token_hintpurely as a logout hint and does not accept it as a bearer/assertion elsewhere (verified that octo-server itself never re-accepts it — it issues its own session post-callback). - Confirm the front-channel
id_tokenexposure (URL/history/Referer/IdP access logs) is acceptable per your threat model — it is spec-inherent to RP-Initiated Logout. - Ensure
OCTO_OIDC_POST_LOGOUT_REDIRECT_URIis registered on the Aegis logout-redirect allowlist before enabling (per the PR's ops checklist).
…#217) Address non-blocking review findings from Mininglamp-OSS#217: 1. Correct the logout rationale comment: the frontend DOES carry the id_token (via id_token_hint in the returned URL) — state the real trust model (front-channel exposure is spec-inherent and accepted; token is single-use, atomically consumed, never re-accepted as a bearer). 2. Validate OCTO_OIDC_POST_LOGOUT_REDIRECT_URI / OCTO_OIDC_PROVIDER_END_SESSION_URL at startup (fail-loud): must be absolute https, rejecting relative / javascript: / non-http(s) values so a typo can't send the id_token to an arbitrary host or run script on top-level navigation. Dev escape hatch OCTO_OIDC_LOGOUT_ALLOW_INSECURE=1, mirroring the bind redirect-base check. 3. Gate the id_token store on a usable end_session endpoint too, not just the redirect URI — moved its construction after the client is built so a deployment with no discoverable/overridden endpoint never persists tokens that logout can't use. 4. Cover the best-effort degrade paths: Take error -> logout still 200 and omits end_session_url; Save error -> callback still completes login. 5. Fix the IDTokenTTL doc (ParseDuration rejects "7d"; use 168h) and add config tests for TTL default/override/invalid/clamp and the URL validator. Refs Mininglamp-OSS#215
|
Addressed the non-blocking review findings in
Deferred (documented as convention, currently safe): bind-stash keyspace isolation (#4) and clamping cache TTL to the id_token's own Local: |
lml2468
left a comment
There was a problem hiding this comment.
APPROVED — re-review at 8f223d1
Previous round feedback fully addressed:
- URL scheme validation (Jerry-Xin + yujiawei):
validateLogoutURLnow rejects relative,javascript:,data:,ftp:, andhttp://URLs at startup. Dev escape hatch viaOCTO_OIDC_LOGOUT_ALLOW_INSECURE=1follows existingbindpattern. ✅ - Unnecessary Redis pool (Allen + yujiawei):
idTokenStoreinit moved after client construction, now guarded byPostLogoutRedirectURI != "" && endSessionEndpoint() != ""— if discovery lacks endpoint and no override configured, no pool created. ✅ - Comment accuracy (yujiawei): Logout comment now explicitly acknowledges id_token front-channel exposure is protocol-inherent (browser history, JS, Referer), not a side-effect of this implementation. ✅
- IDTokenTTL doc (new): Comment now warns
time.ParseDurationdoes not recognize"7d", must use"168h". ✅
New test coverage (+96 lines):
TestValidateLogoutURL— 7 cases covering empty/https/relative/javascript/ftp/http-default/http-insecureTestLoadConfig_RejectsInsecureLogoutURL— integration test proving startup rejectionTestLoadConfig_IDTokenTTL— 5 cases: default, override, invalid "7d" fallback, zero/negative clampingTestAPI_Logout_TakeError_OmitsURL— Redis Take failure degradeTestAPI_Callback_IDTokenSaveError_LoginStillSucceeds— Redis Save failure degrade
check-sprint now passing. All prior security verification carries forward. No new findings.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #217 (octo-server)
feat(oidc): RP-Initiated Logout — backend issues end_session URL on logout
Independent review of the RP-Initiated Logout implementation. Scope: backend caches the verified id_token at login (encrypted) and, on logout, returns a single-use end_session_url (id_token_hint + a server-configured post_logout_redirect_uri) so the frontend can terminate the IdP SSO session. Reviewed at head 8f223d1e2e50574adb7334e73bf0f94cb20ef252.
I checked out the PR locally and verified: go build ./modules/oidc/ ✅, go vet ./modules/oidc/ ✅, and the new unit tests (logout URL build / one-time consume / all degrade paths / config validation / bind promote / callback cache) all pass.
1. Verification
| Item | Result | Evidence |
|---|---|---|
No open-redirect surface — post_logout_redirect_uri is server-configured, never from request body |
✅ | api.go:962-963 reads o.cfg.Provider.PostLogoutRedirectURI; config.go:316-320 loaded from env only |
id_token encrypted at rest (AES-256-GCM, same key as refresh token) |
✅ | logout_idtoken.go:54-66 encrypts then base64; crypto.go:45-51 random-nonce GCM |
| One-time, atomic consume (no concurrent double-take) | ✅ | logout_idtoken.go:73-74 reuses luaGetDel (atomic GET+DEL, state_store_redis.go:21-27) |
id_token / assembled URL never logged |
✅ | Only zap.Error(err) / trace_id / uid logged; grep of api.go/logout_idtoken.go shows no token value in any log call |
Startup fail-loud URL validation (rejects relative / javascript: / non-https) |
✅ | config.go:219-242 validateLogoutURL, called at config.go:197,200; covered by TestValidateLogoutURL |
id_token store only constructed when feature is actually usable (no PII JWT persisted otherwise) |
✅ | api.go:167-173 gates on PostLogoutRedirectURI != "" && endSessionEndpoint() != "" |
| Redis client released on Close (no fd leak) | ✅ | api.go:804-809 placed before the stateStore == nil early-return |
TTL <= 0 clamp (avoids never-expiring key) |
✅ | config.go:190-191; covered by TestLoadConfig_IDTokenTTL |
Backward compatible / opt-in (unset → byte-for-byte prior {"status":200}) |
✅ | api.go:887-893 only adds the field when non-empty; TestAPI_Logout_NilStore_OmitsURL etc. |
| Best-effort degrade — login/bind never blocked by id_token store errors | ✅ | api.go:767-770 (callback Save), 949-955 / 961-975 (bind); TestAPI_Callback_IDTokenSaveError_LoginStillSucceeds |
| Bind path coverage (stash under jti → promote to uid on confirm/create) | ✅ | api.go:584, api_bind.go:209-211,485-487; TestAPI_BindConfirm/Create_PromotesIDToken |
2. Findings
No P0 (correctness/security/data-loss/build-break) and no P1 (functional blocker) issues found. The items below are P2 / suggestions and do not block merge.
P2-1 — Discovery-derived end_session_endpoint is not scheme-validated
validateLogoutURL (config.go:219) fail-louds the config-override EndSessionURL and PostLogoutRedirectURI, but the endpoint resolved from the IdP Discovery document (oidc_client.go:181-187 → endSessionEndpoint() at api.go:902-904) is used verbatim to build the URL that embeds id_token_hint. If the IdP's Discovery ever advertised an http:// (or otherwise unexpected) end_session_endpoint, the backend would assemble a URL carrying the id_token over a non-https scheme.
Risk is low — Discovery is fetched over the issuer's HTTPS endpoint and the IdP is the trust anchor — so this is not a blocker. But given the security_sensitive classification, a human should confirm that Aegis's Discovery end_session_endpoint is https, and consider running the discovery-derived endpoint through the same https-only check (or at least logging a warning on a non-https discovery value) for defense in depth.
P2-2 — id_token is consumed before url.Parse(endpoint)
In buildEndSessionURL (api.go:923 then ~937), Take (GETDEL) deletes the cached id_token first; if the subsequent url.Parse(endpoint) fails, the token is already burned and no URL is returned, so that logout degrades to local-only and a retry has nothing left to consume. Impact is minimal because the endpoint is static config/discovery (a parse failure is deterministic, not per-request), but parsing/validating the endpoint before consuming the token would be slightly cleaner.
P2-3 — Single cached id_token per uid across devices (note, not a bug)
Save overwrites the per-uid key, so only the most recent login's id_token is retained, and logout consumes that single value. For multi-device sessions, a logout on one device consumes the token and a concurrent logout on another degrades to local-only. This is consistent with the documented best-effort RP-Initiated Logout semantics (any valid recent id_token_hint suffices to terminate the IdP session) — flagging only so it's a conscious design choice rather than an oversight.
3. Suggestions
- Consider the P2-1 hardening (https check or warn on discovery-derived endpoint) as a follow-up.
- The two ops prerequisites in the PR description (register
post_logout_redirect_urion Aegis; confirm whether Aegisend_sessionrequiresid_token_hint) are real gating items — ensure they're tracked before enabling in any environment.
4. Additional observations
- The
IDTokenTTLenv parses withtime.ParseDuration(h/m/s only);"7d"silently falls back to the 7d default. This is correctly documented inconfig.go:90-92and covered by a test — good catch by the author. bindIDTokenKeyuses a"bind:"prefix that is namespace-isolated from uid keys, and jti is a 32B base64 token, so no key-collision/injection concern (logout_idtoken.go:628-635).- Encryption/consume reuse the existing refresh-token
Encryptorand state-storeluaGetDelrather than introducing new crypto/primitives — consistent with the module and lower risk.
Overall this is a careful, well-tested, opt-in change with a sound trust model. Approving; the P2 items are non-blocking follow-ups, with P2-1 worth a human confirmation given the security-sensitive scope.
…ininglamp-OSS#217) Address P2 review findings on buildEndSessionURL: - P2-1: the discovery-derived end_session_endpoint was used verbatim; only the config-override / redirect URI were https-validated at startup. Now run the resolved endpoint (discovery or override) through validateLogoutURL so a non-https endpoint can't carry the id_token over an insecure scheme. - P2-2: id_token was consumed (atomic GETDEL) before url.Parse(endpoint); a parse failure would burn the token with nothing to retry. Reordered to validate + parse the endpoint first, then consume the id_token. P2-3 (single cached id_token per uid across devices) is intentional best-effort RP-Initiated Logout semantics — left as documented design. Tests that rely on the http httptest mock endpoint set OCTO_OIDC_LOGOUT_ALLOW_INSECURE=1 (models "http in dev/test, https in prod"). Refs Mininglamp-OSS#215
|
Addressed the P2 items from the re-review in
Tests touching the http httptest mock endpoint set |
lml2468
left a comment
There was a problem hiding this comment.
APPROVED — re-review at f60283e
This commit addresses yujiawei's suggestion about id_token consumption timing:
-
Validate before consume:
validateLogoutURL+url.Parsenow run BEFOREidTokens.Take(). If the endpoint is invalid (non-https, unparseable), the id_token is NOT consumed — logout can be retried and still produce a validend_session_urlonce the endpoint issue is resolved. Previously,Take()(GETDEL, irreversible) ran first, so an invalid endpoint would silently burn the token. -
Discovery endpoint validation:
validateLogoutURLnow covers both config-override AND discovery-sourcedend_session_endpoint, catching an IdP that returnshttp://in its discovery document. -
Tests: 3 existing tests using
MockProvider(httptest =http://) correctly addOCTO_OIDC_LOGOUT_ALLOW_INSECURE=1to pass the new validation gate.
The reordering comment is thorough and explains both motivations clearly. No new findings.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #217 (octo-server)
Verdict: APPROVED. This is a careful, well-tested, security-aware implementation of RP-Initiated Logout. No P0/P1 blockers found. A few P2/informational notes below; none gate the merge.
Verification summary
| Area | Result | Evidence |
|---|---|---|
| Builds | ✅ | go build ./modules/oidc/ clean |
| Tests | ✅ | All 41 PR-specific unit tests pass (-race). Remaining failures in the package are integration tests needing localhost MySQL/Redis (bind_store_redis_test.go:17, TestRedisBindStore_*) — environment-only, untouched by this PR |
| gofmt / vet | ✅ | PR files clean; go vet ./modules/oidc/ clean |
| id_token encrypted at rest | ✅ | AES-256-GCM, random nonce per encrypt — crypto.go:45-52; ciphertext base64'd before Redis — logout_idtoken.go:60-66 |
| One-time consume (no double-spend) | ✅ | Atomic Lua GETDEL reused from state store — logout_idtoken.go:73-74, state_store_redis.go:21-29 |
| Open-redirect surface | ✅ | post_logout_redirect_uri is server-config-only, never read from request body — config.go:80-83, api.go:929-930 |
| https enforced on logout URLs | ✅ | validateLogoutURL forces absolute https at startup (config) AND at runtime on the discovery-sourced endpoint — config.go:219-242, api.go:907-911 |
| Token not wasted on bad endpoint | ✅ | Endpoint validated/parsed before Take consumes the id_token — api.go:907-922 |
| No token in logs | ✅ | Only the endpoint (never the assembled URL/id_token) is logged — api.go:908,914,924 |
| Auth required for logout | ✅ | uid == "" rejected; token fetched by the authenticated uid only, so no cross-user id_token disclosure — api.go:849 + TestAPI_Logout_NoAuth_Rejected |
| TTL footgun guard | ✅ | IDTokenTTL <= 0 clamped to 7d to avoid a never-expiring key — config.go:188-192 |
| Resource cleanup | ✅ | redisIDTokenStore.Close() released in OIDC.Close() before the early stateStore == nil return — api.go:801-808 |
| Off-by-default / additive | ✅ | Store only constructed when redirect URI + end_session endpoint are both present — api.go:162-171; logout response byte-for-byte unchanged otherwise |
Strengths worth calling out
- Correct ordering of side effects. Validating + parsing the endpoint before the irreversible
GETDELmeans a transient bad endpoint doesn't burn the user's one-shot id_token; logout stays retryable. (api.go:902-922) - Runtime re-validation of the discovery endpoint. Even though the override is checked at startup, the discovery-sourced
end_session_endpointis re-validated for https at logout time, so a compromised/misconfigured IdP can't downgrade an id_token-bearing URL to http. (api.go:907-911) - Honest threat-model documentation. The comment block at
api.go:830-838explicitly acknowledges thatid_token_hintis exposed to browser history / Referer / IdP logs by protocol design, and justifies acceptability (one-time, non-replayable, never reused as a bearer). Good security hygiene. - Bind-path key namespacing. The
bind:<jti>staging key is namespaced under the same prefix and promoted to the uid key on confirm/create; uid can't collide with thebind:prefix. (logout_idtoken.go:108-115,api.go:960-983)
P2 / Nits (non-blocking)
-
7dduration footgun (docs vs. parser). The PR body's config table lists the default as7d, butOCTO_OIDC_PROVIDER_ID_TOKEN_TTLis parsed withtime.ParseDuration, which does not accept adsuffix — setting"7d"silently falls back to the default. The code comment atconfig.go:90-92already documents this, but the operator-facing PR/ops docs should show168hto avoid a silent misconfiguration. Consider either supporting a day suffix or correcting the table. -
id_token retained up to 7d (encrypted). With the feature enabled, every successful OIDC login caches a PII-bearing signed JWT in Redis for the TTL. Encryption with
DM_OIDC_RT_ENC_KEYmitigates at-rest exposure, and the id_token_hint is not itself an auth credential, so impact is low — but worth a human nod on the threat model, especially the blast radius if the encryption key were to leak.
For human reviewer (security_sensitive)
- Confirm the two ops prerequisites in the PR description are tracked before any environment enables the feature: (a) register
post_logout_redirect_uriin the Aegis logout-redirect allowlist, and (b) confirm whether Aegis/end_sessionrequiresid_token_hint. If Aegis rejects logout requests lacking other params (e.g.client_id/state), the assembled URL may need extension — currently onlyid_token_hint+post_logout_redirect_uriare sent (api.go:923-926). - Sanity-check that no other code path persists the raw id_token unencrypted; this review found only the encrypted store path.
Jerry-Xin
left a comment
There was a problem hiding this comment.
Scope check: PASS. This PR extends the existing modules/oidc backend logout flow, so it belongs in this repository.
One-line summary: The implementation is mergeable; I found only non-blocking hardening and edge-case concerns.
💬 Non-Blocking
- 🟡 Warning: modules/oidc/api.go constructs the id-token Redis store when an
end_session_endpointmerely exists, but the endpoint is not validated until logout. If discovery returns an invalid or insecure endpoint, callbacks will still persist encrypted ID tokens even though logout can never returnend_session_url. Consider validatingo.endSessionEndpoint()before enablingo.idTokens. - 🟡 Warning: modules/oidc/api.go returns an
end_session_urlcontainingid_token_hintin a JSON response. Since that response now contains sensitive token material, consider settingCache-Control: no-storeandPragma: no-cachewhen the field is present. - 🔵 Suggestion: modules/oidc/logout_idtoken.go stores one ID token per UID. This is simple and matches the current global logout behavior, but in multi-browser or multi-device cases it may use the most recent login’s ID token rather than the current browser session’s token. If Aegis enforces
sidmatching, this could degrade IdP logout reliability.
✅ Highlights
- Good opt-in behavior: with no post-logout redirect configured, the store stays disabled and the prior logout response remains unchanged.
- The ID token is encrypted at rest, consumed atomically with Lua GET+DEL, and never logged as a full URL.
- Bind confirm/create promotion paths are covered, which closes an easy-to-miss flow.
- Targeted tests passed:
go test ./modules/oidc -run 'Test(API_Logout|API_Callback_IDToken|API_Callback_BindPending|BuildEndSessionURL|LoadConfig_IDTokenTTL|ValidateLogoutURL|LoadConfig_RejectsInsecureLogoutURL|Client_EndSessionEndpoint|API_BindConfirm_PromotesIDToken|API_BindCreate_PromotesIDToken)'.
Full go test ./modules/oidc could not complete in this workspace because existing integration tests require local Redis/MySQL on 127.0.0.1.
Jerry-Xin
left a comment
There was a problem hiding this comment.
✅ APPROVE
id_token consumption timing fix is clean and correct.
What changed this round:
buildEndSessionURLnow validates the endpoint URL (validateLogoutURL+url.Parse) before callingTake/GETDEL, preventing irreversible token burn on invalid endpoints- Discovery-sourced
end_session_endpointalso runs through the same https validation path - 3 httptest cases correctly set
OCTO_OIDC_LOGOUT_ALLOW_INSECURE=1for the http mock endpoints
Overall assessment (full PR):
- AES-256-GCM encrypted id_token in Redis with Lua GETDEL atomic consumption — no concurrent double-take window
PostLogoutRedirectURIserver-side only, no open redirect risk- Feature disabled by default; dual guard (redirect URI + end_session endpoint) controls Redis pool creation
- 365-line test file covers happy path, 4 degradation paths, bind stash/promote, URL escaping, Save/Take errors
Close()properly releases the Redis connection pool alongsidebindStore
💬 Non-blocking:
- If discovery has no
end_session_endpointand noEndSessionURLoverride is configured, id_token storage is correctly skipped (dual guard inNew()), but a startup info-level log noting "RP-Initiated Logout disabled: no end_session endpoint" would aid operator visibility
✅ Highlights: Thorough defense-in-depth — validate-before-consume ordering, per-field scheme enforcement, env-gated http exception for dev, and the saveBindIDTokenHint→promoteBindIDToken migration path for bind flows is well-designed
…onse (Mininglamp-OSS#217) Address the latest non-blocking review (Jerry-Xin): - A: New() now enables the id_token store only when the resolved end_session endpoint is a valid https URL (not merely non-empty). A non-https/malformed discovery endpoint would otherwise have callbacks persist encrypted id_tokens that logout can never use. Pairs the startup gate with buildEndSessionURL's runtime check. - D: when PostLogoutRedirectURI is set but no usable endpoint is available, log an Info line ("RP-Initiated Logout disabled: ...") for operator visibility. - B: the logout response carries id_token_hint inside end_session_url, so set Cache-Control: no-store + Pragma: no-cache when that field is present (OAuth/OIDC security BCP, RFC 6749 §5.1). P2-3 (one id_token per uid across devices / sid matching) left as documented best-effort design. Refs Mininglamp-OSS#215
|
Addressed the latest non-blocking items in
Left as documented best-effort design: one id_token per uid across devices /
|
lml2468
left a comment
There was a problem hiding this comment.
APPROVED — re-review at af92bdd
This commit addresses Jerry-Xin's remaining suggestions:
-
Cache-Control headers: Logout response now sets
Cache-Control: no-store+Pragma: no-cachewhenend_session_urlis present (containsid_token_hint). Per RFC 6749 §5.1 and OAuth/OIDC security BCP. Test asserts the header. ✅ -
Startup diagnostic log: When
PostLogoutRedirectURIis configured butend_sessionendpoint is unavailable or non-https, anInfo-level log explains why RP-logout is disabled — helps ops diagnose "why is end_session_url missing". ✅ -
Tighter init guard:
validateLogoutURLnow also runs atidTokenStoreconstruction time (not just atbuildEndSessionURLruntime). Non-https endpoint = no Redis pool created = no PII cached. ✅
All reviewer feedback from all rounds has been addressed. check-sprint green. No new findings.
Jerry-Xin
left a comment
There was a problem hiding this comment.
Summary: The PR is in scope for octo-server and cleanly extends the existing OIDC logout flow without blocking issues.
💬 Non-blocking
- 🔵 Suggestion — modules/oidc/logout_idtoken.go: Consider adding an integration test for the production
redisIDTokenStoreto assert encrypted-at-rest storage and Lua-backed one-time consume. The fake store tests cover behavior well, but not the Redis implementation itself. - 🔵 Suggestion — modules/oidc/api.go: The response correctly sets
Cache-Control: no-storeandPragma: no-cachewhen returningend_session_url. AddingExpires: 0would be a small extra hardening measure for older intermediaries.
✅ Highlights
- The feature is opt-in and preserves the previous logout response when disabled.
post_logout_redirect_uriis server-owned, avoiding a request-controlled redirect surface.- The cached
id_tokenis encrypted and consumed atomically on logout. - Bind confirm/create paths correctly promote the pending token to the resolved UID.
Verification:
go test ./modules/oidccould not complete locally because Redis127.0.0.1:6379and MySQL127.0.0.1:3306were unavailable.- Targeted new/unit tests passed with
go test ./modules/oidc -run 'Test(API_Logout|API_Callback_IDToken|API_Callback_BindPending|BuildEndSessionURL|Client_EndSessionEndpoint|LoadConfig_IDTokenTTL|ValidateLogoutURL|LoadConfig_RejectsInsecureLogoutURL|API_BindConfirm_PromotesIDToken|API_BindCreate_PromotesIDToken)'.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #217 (octo-server)
Scope: RP-Initiated Logout for OIDC. Backend caches the verified id_token at login (AES-256-GCM in Redis) and assembles an end_session_url (id_token_hint + server-configured post_logout_redirect_uri) on logout. 10 files, all under modules/oidc/, +909/-8.
Verdict: APPROVED. No P0/P1. The design is sound, the security-sensitive paths are handled carefully, and test coverage is thorough. A few optional notes below — none block merge.
1. Verification of the PR's claims
| Claim | Result | Evidence |
|---|---|---|
id_token cached only after verification |
✅ | api.go:478 VerifyIDToken runs before any Save; callback caches at api.go:773-779 using the already-verified rawID. |
| One-time, atomic consumption | ✅ | logout_idtoken.go:74 reuses luaGetDel (atomic GET+DEL) from state_store_redis.go:21; concurrent double-logout cannot double-spend. |
| Encrypted at rest, never logged | ✅ | logout_idtoken.go:54-66 AES-256-GCM via the shared Encryptor (random nonce per encrypt, crypto.go:46-52); all error logs record only the endpoint/trace, never the token or assembled URL (api.go:946-957). |
TTL clamped to >0 |
✅ | config.go:188-191 clamps <=0 to 7d; covered by TestLoadConfig_IDTokenTTL (zero/negative/invalid-7d cases). |
| No open-redirect from request body | ✅ | post_logout_redirect_uri is only ever read from o.cfg.Provider.PostLogoutRedirectURI (api.go:962); never bound from the request. Server config doubles as the allowlist. |
https-only, covering discovery-sourced endpoints |
✅ | validateLogoutURL (config.go:226) enforces absolute https at startup for config values, AND buildEndSessionURL re-validates the resolved endpoint at request time (api.go:945) — so a malicious/misconfigured IdP discovery doc returning http:// or javascript: cannot leak the token. |
Cache-Control: no-store on token-bearing responses |
✅ | Set only when end_session_url is present (api.go:898-901). |
| Purely additive / opt-in, byte-for-byte unchanged when off | ✅ | With OCTO_OIDC_POST_LOGOUT_REDIRECT_URI unset, the store is never constructed (api.go:167), o.idTokens stays nil, and logout returns exactly {"status":200} with no extra headers. Covered by TestAPI_Logout_NilStore_OmitsURL / TestAPI_Logout_NoRedirectConfig_OmitsURL. |
| Redis pool released on shutdown | ✅ | Close() releases the dedicated id_token client (api.go:809-817), correctly placed before the stateStore == nil early return. |
| Bind-path stash→promote | ✅ | callback stashes under bind:<jti> (api.go:592), promoted to the uid key on bindConfirm/bindCreate success (api_bind.go:339,489); one-time consumed via Take. Covered by TestAPI_BindConfirm_PromotesIDToken / TestAPI_BindCreate_PromotesIDToken. |
Build clean (go build ./modules/oidc/...); targeted tests pass (go test ./modules/oidc -run 'Logout|EndSession|IDToken|Config|Promote|Bind…').
2. Security analysis (this PR is auth/token-sensitive)
- Token-in-URL exposure is inherent to RP-Initiated Logout (
id_token_hintis an RFC front-channel parameter) and is correctly bounded: the cached token is single-use, atomically invalidated on read, never reused by the server as a bearer/assertion, and never logged. This is the right tradeoff and is well-documented in the code. - Key-namespace isolation between normal entries (
oidc:idtoken:<uid>) and bind stash (oidc:idtoken:bind:<jti>) is collision-free: uids never start withbind:, andjtiis 32 random bytes (bind_service.go:731). Thereq.Tokenused by promote is validated byauthcodeRebefore use. - Graceful degradation — every new failure path (encrypt/save fail at login, take/decrypt fail at logout, missing endpoint, nil store) degrades to "local-only logout" and never blocks the login or logout response.
TestAPI_Callback_IDTokenSaveError_LoginStillSucceedsconfirms login is unaffected. - Store gating correctly requires both a configured redirect URI AND a usable
httpsend_session endpoint (api.go:167-181) before persisting any PII-bearing JWT — no token is stored if the feature can't actually emit a URL.
An adversarial pass across token-lifecycle, open-redirect/leak, crypto/resource, and degradation dimensions surfaced no confirmed defects.
3. Optional notes (non-blocking)
- (suggestion)
id_token_hintsize vs. URL length. Aegisid_tokens embedding identity claims can be large; the assembledend_session_urlcould approach front-channel URL-length limits in some browsers/IdPs. Worth a quick check against the real Aegis token size before enabling in prod, but not a code change. - (nit) Ops checklist already in the PR body — registering
post_logout_redirect_urion the Aegis allowlist and confirming whether Aegis requiresid_token_hintare the two real go-live gates. Flagging for the human enabling this in an environment.
4. Items for human verification before enabling
- Register the configured
post_logout_redirect_uriin the Aegis (IdP) logout-redirect allowlist. - Confirm Aegis
end_sessionaccepts the assembled parameters (id_token_hint,post_logout_redirect_uri) and whetherstateis required (this PR intentionally omitsstatesince Aegis discovery doesn't declare it).
No blocking issues found. Approving.
100 commits 主要内容:
- pkg/i18n 大规模迁移(modules/{user,message,group,workplace,category} → ResponseErrorL)
- oidc RP-Initiated Logout (#217)
- voice → octo-speech adapter 替换 (#113)
- modules/bot_api / modules/user 重构
- 各种 CI workflow
冲突点预期:internal/modules.go (我们删了 runtime import,origin 可能新增 module)
# Conflicts:
# internal/modules.go
Summary
Implements RP-Initiated Logout for OIDC login: the backend now hands the
frontend a ready-to-use
end_session_urlso a logout can terminate both thelocal Octo session and the IdP (Aegis) browser SSO session.
Why backend-owned: the
code → tokenexchange happens server-side, so thefrontend never holds the
id_tokenand cannot build theid_token_hintitself.The backend caches the verified
id_tokenat login and assembles theend_sessionURL at logout. Companion frontend issue: Mininglamp-OSS/octo-web#184.Changes
oidc_client.go— resolveend_session_endpointfrom discovery(
provider.Claims), exposeEndSessionEndpoint().IssueSessionpath and theself-service bind path), cache the verified
id_tokenAES-256-GCMencrypted in Redis. The bind path stashes it under a bind-token (
jti) keyat
bind_pendingand promotes it to theuidkey on/bind/confirmor/bind/createsuccess (one-time consumed).logout— kick + revoke unchanged; now also returnsend_session_url(
id_token_hint+ a server-configuredpost_logout_redirect_uri). Theid_tokenis consumed atomically (Lua GETDEL).Configuration (new, all optional)
OCTO_OIDC_POST_LOGOUT_REDIRECT_URI""(feature off)OCTO_OIDC_PROVIDER_END_SESSION_URL""end_sessionendpoint; only needed if discovery omitsend_session_endpoint.OCTO_OIDC_PROVIDER_ID_TOKEN_TTL168h(7d)id_token; clamped to>0to avoid a never-expiring key. Parsed bytime.ParseDuration(nodunit — use168h, not7d).Backward compatibility
Purely additive and opt-in:
OCTO_OIDC_POST_LOGOUT_REDIRECT_URIunset (default), theid_tokenstore is not constructed — no PII-bearing JWT is persisted, no extra
Redis connection pool — and
logoutreturns exactly the prior{"status":200}. Behavior is byte-for-byte unchanged.(
end_session_url) on the logout response; JSON consumers ignore unknownfields.
outside
modules/oidc/.Security
id_tokenis encrypted at rest (same key derivation as the refresh token)and never logged (nor is the assembled URL that embeds it).
post_logout_redirect_uriis server-side hard-coded, so it doubles as theallowlist (no open-redirect surface from the request body).
Testing
go test ./modules/oidc -race(unit + integration) — pass.gofmt/go vetclean; D23lint-direct-error-responsegate — no newdirect error responses.
end_session_endpointparsing; logout URL build /escaping / one-time consume; degrade paths (no id_token / no redirect config /
store disabled); callback caches id_token; bind
confirm/createpromote thestashed id_token to uid.
Ops prerequisites (before enabling in an environment)
post_logout_redirect_urion the Aegis side (IdP logoutredirect allowlist).
end_sessionrequiresid_token_hint.Closes #215