Skip to content

Commit 96530d7

Browse files
authored
feat(oidc): RP-Initiated Logout — backend issues end_session URL on logout (#217)
Backend-owned RP-Initiated Logout: cache the verified id_token at login (AES-256-GCM encrypted in Redis, one-time consumed via Lua GETDEL) and return a single-use end_session_url (id_token_hint + server-configured post_logout_redirect_uri) from POST /logout so the frontend can terminate the IdP SSO session. Covers both the direct IssueSession and self-service bind login paths. Opt-in and byte-for-byte backward compatible when disabled; end_session endpoint resolved from discovery (https-validated) with config override; id_token never logged; logout response sets Cache-Control: no-store. Closes #215
1 parent a73501c commit 96530d7

10 files changed

Lines changed: 909 additions & 8 deletions

modules/oidc/api.go

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,13 @@ type OIDC struct {
8080
audit auditWriter
8181
killer sessionKiller
8282
revoker rtRevoker
83-
worker *SyncWorker
84-
tickLock *RedisTickLock
85-
cbGuard *CallbackGuard
86-
bind *BindService // 自助绑定(P0);Bind.Enabled=false 时为 nil,handler 不挂载
83+
// idTokens 缓存登录时验签过的 id_token,供 logout 当 RP-Initiated Logout 的
84+
// id_token_hint。nil 时 logout 不生成 end_session_url(降级为仅清本地)。
85+
idTokens idTokenStore
86+
worker *SyncWorker
87+
tickLock *RedisTickLock
88+
cbGuard *CallbackGuard
89+
bind *BindService // 自助绑定(P0);Bind.Enabled=false 时为 nil,handler 不挂载
8790
// bindStore 单独持引用便于 Close 时关连接池。bind.store 是 BindStore 接口,
8891
// 接口本身没 Close,production impl(*redisBindStore)有独立 redis.Client,
8992
// 不关会泄漏。
@@ -156,6 +159,26 @@ func New(ctx *config.Context) *OIDC {
156159
return o
157160
}
158161
o.client = client
162+
// id_token 缓存(RP-Initiated Logout)仅在功能"确实可用"时启用:既配了回跳地址
163+
// (PostLogoutRedirectURI),又拿得到*合法 https* 的 end_session 端点(discovery 或
164+
// override)。端点仅"非空"不够 —— 非法/非 https 端点下 logout 也出不了 URL
165+
// (buildEndSessionURL 会拒),此时建池存 PII id_token 纯属浪费。放在 client 之后才能判
166+
// 端点可用性。密钥已在 LoadConfig 校验为 32B,构造失败仅记日志降级,不影响登录主流程。
167+
if cfg.Provider.PostLogoutRedirectURI != "" {
168+
endpoint := o.endSessionEndpoint()
169+
switch {
170+
case endpoint == "" || validateLogoutURL("end_session_endpoint", endpoint) != nil:
171+
// 配了回跳地址却拿不到可用端点:打 Info 让运维可见"为什么 RP-logout 没生效"。
172+
o.Info("RP-Initiated Logout 已禁用:end_session 端点不可用(discovery 未提供且未配 override,或非 https)",
173+
zap.String("endpoint", endpoint))
174+
default:
175+
if enc, eerr := NewEncryptor(cfg.Provider.RefreshTokenEncryptionKey); eerr != nil {
176+
o.Error("构造 id_token Encryptor 失败,RP-Initiated Logout 禁用", zap.Error(eerr))
177+
} else {
178+
o.idTokens = newRedisIDTokenStore(ctx, enc)
179+
}
180+
}
181+
}
159182
return o
160183
}
161184

@@ -565,6 +588,8 @@ func (o *OIDC) callback(c *wkhttp.Context) {
565588
jti, ierr := o.bind.IssueWithReason(c.Request.Context(), claims, sd, reason)
566589
if ierr == nil {
567590
result = "bind_pending" // 已在 callbackResultLabels 注册
591+
// bind 接管时尚不知 uid,先按 jti 暂存 id_token,confirm/create 后迁移到 uid。
592+
o.saveBindIDTokenHint(c.Request.Context(), jti, rawID)
568593
o.writeAudit("bind:"+subHash(jti), EventBindIssued, sd, "")
569594
o.redirectToBindPage(c, sd, jti)
570595
return
@@ -745,6 +770,14 @@ func (o *OIDC) callback(c *wkhttp.Context) {
745770
result = "ok"
746771
// 成功路径清场:防止 IP 长尾累积导致历史失败 + 偶发 state 过期把用户误锁。
747772
o.cbGuard.ResetLogged(clientIP)
773+
// 缓存验签过的 id_token,供后续 logout 当 RP-Initiated Logout 的 id_token_hint。
774+
// best-effort:存失败只告警,不影响登录(logout 时退回"仅清本地")。日志不打 token。
775+
if o.idTokens != nil && sessResp.UID != "" {
776+
if serr := o.idTokens.Save(c.Request.Context(), sessResp.UID, rawID, o.cfg.Provider.IDTokenTTL); serr != nil {
777+
o.Warn("OIDC callback 缓存 id_token 失败(不影响登录,仅 RP-logout 降级)",
778+
zap.String("trace_id", traceID), zap.Error(serr))
779+
}
780+
}
748781
o.writeAudit(sessResp.UID, EventCallbackOK, sd, "")
749782
o.redirectAfterCallback(c, sd, false)
750783
}
@@ -773,6 +806,15 @@ func (o *OIDC) Close() error {
773806
}
774807
o.bindStore = nil
775808
}
809+
// idTokens 独立 redis.Client(RP-Initiated Logout),New() 在 enabled 时创建。
810+
// 与 bindStore 同样需在关闭路径释放,否则连接池 fd 泄漏。放在 stateStore nil
811+
// 早返回之前,保证 stateStore 已被置 nil 的二次 Close 仍能关掉 idTokens。
812+
if ridt, ok := o.idTokens.(*redisIDTokenStore); ok {
813+
if err := ridt.Close(); err != nil {
814+
o.Error("关闭 OIDC id_token store 失败", zap.Error(err))
815+
}
816+
o.idTokens = nil
817+
}
776818
if o.stateStore == nil {
777819
return nil
778820
}
@@ -789,8 +831,19 @@ func (o *OIDC) Close() error {
789831
// 理由:logout 客户端关心的是"我点了登出,本地已清空状态",对幂等性要求高于完美吊销。
790832
// 真正的兜底由 SyncWorker 的下次轮询补足(refresh 失败也会触发踢线)。
791833
//
792-
// IdP 端 RP-Initiated Logout(/end_session)由前端按需调用,后端不代理:
793-
// id_token_hint 在前端容易拿到,且跨域跳转更适合浏览器层面发起。
834+
// IdP 端 RP-Initiated Logout(/end_session)的跳转地址由后端拼好后随 200 响应返回
835+
// (end_session_url 字段),前端做顶层跳转。后端收口的原因:本架构 code→token 在
836+
// 服务端完成,前端无法自行*构造* id_token_hint(它不经手 token 交换),也不应散落
837+
// end_session 端点 / 参数 / 回跳白名单这些 IdP 细节 —— 由后端给出单次性 URL 最稳妥。
838+
// 真正终止 IdP 会话仍依赖浏览器顶层跳转携带 IdP 域 cookie,所以后端只给 URL,不代理跳转。
839+
//
840+
// 信任模型说明:end_session_url 里必然带 id_token_hint(RFC 规定的 front-channel 参数),
841+
// 因此该 id_token 会暴露到前端 JS、浏览器历史、Referer 及 IdP 访问日志 —— 这是
842+
// RP-Initiated Logout 协议固有的。可接受:它是单次性、不可重放的登出提示(octo-server
843+
// 自身从不把它当 bearer/assertion 复用),取出即原子作废(luaGetDel)。
844+
//
845+
// 配置缺失(未配 PostLogoutRedirectURI / 无 end_session 端点 / 无缓存的 id_token)时
846+
// 省略 end_session_url,前端降级为仅清本地 —— 纯增量,不影响存量行为。
794847
func (o *OIDC) logout(c *wkhttp.Context) {
795848
uid := c.GetLoginUID()
796849
if uid == "" {
@@ -838,7 +891,110 @@ func (o *OIDC) logout(c *wkhttp.Context) {
838891
IP: util.GetClientPublicIP(c.Request),
839892
UserAgent: c.Request.UserAgent(),
840893
}, "")
841-
c.JSON(http.StatusOK, map[string]interface{}{"status": 200})
894+
895+
resp := map[string]interface{}{"status": 200}
896+
// 拼 IdP end_session 跳转地址(RP-Initiated Logout)。任一前置缺失时返回空串,
897+
// 此时省略字段,前端降级为仅清本地。end_session_url 含 id_token,不写日志。
898+
if endSessionURL := o.buildEndSessionURL(ctx, uid); endSessionURL != "" {
899+
resp["end_session_url"] = endSessionURL
900+
// 响应体含 id_token_hint,禁止任何缓存(OAuth/OIDC 安全 BCP、RFC 6749 §5.1)。
901+
c.Header("Cache-Control", "no-store")
902+
c.Header("Pragma", "no-cache")
903+
}
904+
c.JSON(http.StatusOK, resp)
905+
}
906+
907+
// endSessionEndpoint 解析 IdP 的 RP-Initiated Logout 端点:config override 优先,
908+
// 否则取 Discovery 解析值。两者皆空时返回空串(IdP 未声明且未配 override)。
909+
func (o *OIDC) endSessionEndpoint() string {
910+
if o.cfg != nil && o.cfg.Provider.EndSessionURL != "" {
911+
return o.cfg.Provider.EndSessionURL
912+
}
913+
if o.client != nil {
914+
return o.client.EndSessionEndpoint()
915+
}
916+
return ""
917+
}
918+
919+
// buildEndSessionURL 构造 RP-Initiated Logout 跳转地址,带 id_token_hint +
920+
// post_logout_redirect_uri。任一前置不满足返回空串(调用方据此省略字段、前端降级):
921+
// - 未配置 PostLogoutRedirectURI(运维写死的回跳页,同时充当白名单);
922+
// - idTokens 未注入 / 取不到该 uid 的 id_token(非 OIDC 登录 / 已过期 / 已消费);
923+
// - 无可用 end_session 端点。
924+
//
925+
// 取出 id_token 即一次性消费(Take 内部删除)。不带 state(Aegis Discovery 未声明)。
926+
//
927+
// 顺序很关键:先解析+校验端点,再消费 id_token。原因有二:
928+
// - 端点非法时不白烧 token(GETDEL 不可逆),logout 仍可重试拿到 end_session_url;
929+
// - end_session 端点可能来自 discovery(非 config override,未经启动期校验),这里统一
930+
// 过一道 https 校验 —— 防 IdP 万一下发 http:// 把带 id_token 的 URL 降级到非 https。
931+
func (o *OIDC) buildEndSessionURL(ctx context.Context, uid string) string {
932+
if o.cfg == nil || o.cfg.Provider.PostLogoutRedirectURI == "" || o.idTokens == nil {
933+
return ""
934+
}
935+
endpoint := o.endSessionEndpoint()
936+
if endpoint == "" {
937+
return ""
938+
}
939+
// 校验 + 解析端点(在消费 token 之前)。validateLogoutURL 强制绝对 https
940+
// (dev 可 OCTO_OIDC_LOGOUT_ALLOW_INSECURE=1),覆盖 discovery 与 override 两种来源。
941+
if err := validateLogoutURL("end_session_endpoint", endpoint); err != nil {
942+
o.Error("OIDC logout end_session 端点不合法,跳过 IdP 登出", zap.String("endpoint", endpoint), zap.Error(err))
943+
return ""
944+
}
945+
u, err := url.Parse(endpoint)
946+
if err != nil {
947+
// 只记端点本身,不记含 id_token 的完整 URL。
948+
o.Error("OIDC logout 解析 end_session 端点失败", zap.String("endpoint", endpoint), zap.Error(err))
949+
return ""
950+
}
951+
idToken, err := o.idTokens.Take(ctx, uid)
952+
if err != nil {
953+
// 取 id_token 失败不阻断 logout(本地已踢线+吊销),仅降级跳过 IdP 跳转。
954+
o.Warn("OIDC logout 取 id_token 失败,跳过 end_session 跳转", zap.Error(err))
955+
return ""
956+
}
957+
if idToken == "" {
958+
return ""
959+
}
960+
q := u.Query()
961+
q.Set("id_token_hint", idToken)
962+
q.Set("post_logout_redirect_uri", o.cfg.Provider.PostLogoutRedirectURI)
963+
u.RawQuery = q.Encode()
964+
return u.String()
965+
}
966+
967+
// saveBindIDTokenHint 在自助绑定接管(callback bind_pending)时,按 bind token(jti)
968+
// 暂存验签过的 id_token,TTL 对齐 bind session —— bind 路径的 callback 还不知道最终
969+
// uid,无法直接按 uid 存。confirm/create 成功后由 promoteBindIDToken 迁移到 uid 名下。
970+
// 仅在 RP-Initiated Logout 启用(idTokens!=nil)时生效;best-effort,失败不阻断绑定。
971+
func (o *OIDC) saveBindIDTokenHint(ctx context.Context, jti, rawID string) {
972+
if o.idTokens == nil || jti == "" || rawID == "" {
973+
return
974+
}
975+
if err := o.idTokens.Save(ctx, bindIDTokenKey(jti), rawID, o.cfg.Bind.TokenTTL); err != nil {
976+
o.Warn("OIDC bind 暂存 id_token 失败(不影响绑定,仅 RP-logout 降级)", zap.Error(err))
977+
}
978+
}
979+
980+
// promoteBindIDToken 把 bind 接管阶段按 jti 暂存的 id_token 迁移到已确定的 uid 名下,
981+
// 供后续 logout 当 id_token_hint。一次性消费 jti 暂存项(Take 内部删除);无值时静默
982+
// (非 OIDC bind 登录 / 已过期 / 功能未启用)。best-effort,失败不阻断绑定完成。
983+
func (o *OIDC) promoteBindIDToken(ctx context.Context, jti, uid string) {
984+
if o.idTokens == nil || jti == "" || uid == "" {
985+
return
986+
}
987+
raw, err := o.idTokens.Take(ctx, bindIDTokenKey(jti))
988+
if err != nil {
989+
o.Warn("OIDC bind 取暂存 id_token 失败,跳过 RP-logout 缓存", zap.Error(err))
990+
return
991+
}
992+
if raw == "" {
993+
return
994+
}
995+
if err := o.idTokens.Save(ctx, uid, raw, o.cfg.Provider.IDTokenTTL); err != nil {
996+
o.Warn("OIDC bind 迁移 id_token 到 uid 失败", zap.Error(err))
997+
}
842998
}
843999

8441000
func (o *OIDC) failWithAuthcode(ctx context.Context, sd *StateData, claims *IDTokenClaims, err error) {

modules/oidc/api_bind.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,8 @@ func (o *OIDC) bindConfirm(c *wkhttp.Context) {
335335
return
336336
}
337337
m.result = "ok"
338+
// bind 完成,迁移 callback 阶段按 jti 暂存的 id_token 到 uid,供 logout 用。
339+
o.promoteBindIDToken(ctx, req.Token, resp.UID)
338340
// 回填原发起设备的 ThirdAuthcode key,让 A 设备的轮询能拿到 LoginRespJSON
339341
// (FR-6.3 跨设备流转)。同设备时这一步与 response body 等价,前端任选其一。
340342
// 写失败不致命:用户在 B 设备(当前)已经拿到了 LoginRespJSON。
@@ -483,6 +485,8 @@ func (o *OIDC) bindCreate(c *wkhttp.Context) {
483485
return
484486
}
485487
m.result = "ok"
488+
// bind 建号完成,迁移 callback 阶段按 jti 暂存的 id_token 到 uid,供 logout 用。
489+
o.promoteBindIDToken(ctx, req.Token, resp.UID)
486490
if resp.SD != nil && resp.SD.ClientAuthcode != "" && o.authcode != nil {
487491
if e := o.authcode.SetAuthcode(ctx, resp.SD.ClientAuthcode,
488492
resp.IssueResp.LoginRespJSON, thirdAuthcodeTTL); e != nil {

modules/oidc/api_bind_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,68 @@ func TestAPI_BindCreate_HappyPath(t *testing.T) {
715715
}
716716
}
717717

718+
// bind/confirm 成功后,应把 callback 阶段按 jti 暂存的 id_token 迁移到 uid 名下
719+
// (#215 review #2:bind 登录路径也要能拿到 end_session_url),并消费掉 jti 暂存项。
720+
func TestAPI_BindConfirm_PromotesIDToken(t *testing.T) {
721+
o, jti, auth, loc, _, _, users, _ := newTestOIDCWithBindFull(t, defaultBindCfg(), sampleClaims(), false)
722+
auth.verifyPasswordResp.matched = true
723+
loc.byUsername["alice"] = "u-alice"
724+
users.resp = &IssueSessionResp{UID: "u-alice", LoginRespJSON: `{"token":"t-alice"}`}
725+
ids := newFakeIDTokenStore()
726+
ids.tokens[bindIDTokenKey(jti)] = "raw-bind-idtoken" // 模拟 callback bind_pending 暂存
727+
o.idTokens = ids
728+
r := newTestBindRouter(o)
729+
730+
body, _ := json.Marshal(map[string]string{"token": jti, "identifier": "alice", "password": "Pwd@1"})
731+
req := httptest.NewRequest("POST", "/v1/auth/oidc/aegis/bind/verify/password", bytes.NewReader(body))
732+
req.Header.Set("Content-Type", "application/json")
733+
w := httptest.NewRecorder()
734+
r.ServeHTTP(w, req)
735+
if w.Code != http.StatusOK {
736+
t.Fatalf("verify status=%d body=%s", w.Code, w.Body.String())
737+
}
738+
739+
body2, _ := json.Marshal(map[string]string{"token": jti})
740+
req2 := httptest.NewRequest("POST", "/v1/auth/oidc/aegis/bind/confirm", bytes.NewReader(body2))
741+
req2.Header.Set("Content-Type", "application/json")
742+
w2 := httptest.NewRecorder()
743+
r.ServeHTTP(w2, req2)
744+
if w2.Code != http.StatusOK {
745+
t.Fatalf("confirm status=%d body=%s", w2.Code, w2.Body.String())
746+
}
747+
if got := ids.get("u-alice"); got != "raw-bind-idtoken" {
748+
t.Errorf("id_token not promoted to uid: got %q", got)
749+
}
750+
if ids.get(bindIDTokenKey(jti)) != "" {
751+
t.Errorf("bind-pending id_token must be consumed after confirm")
752+
}
753+
}
754+
755+
// bind/create 成功后同样迁移暂存的 id_token 到新建 uid。
756+
func TestAPI_BindCreate_PromotesIDToken(t *testing.T) {
757+
o, jti, _, _, _, _, users, _ := newTestOIDCWithBindFull(t, defaultBindCfg(), sampleClaims(), false)
758+
users.resp = &IssueSessionResp{UID: "u-created", LoginRespJSON: `{"token":"t-created"}`}
759+
ids := newFakeIDTokenStore()
760+
ids.tokens[bindIDTokenKey(jti)] = "raw-bind-idtoken"
761+
o.idTokens = ids
762+
r := newTestBindRouter(o)
763+
764+
body, _ := json.Marshal(map[string]string{"token": jti})
765+
req := httptest.NewRequest("POST", "/v1/auth/oidc/aegis/bind/create", bytes.NewReader(body))
766+
req.Header.Set("Content-Type", "application/json")
767+
w := httptest.NewRecorder()
768+
r.ServeHTTP(w, req)
769+
if w.Code != http.StatusOK {
770+
t.Fatalf("create status=%d body=%s", w.Code, w.Body.String())
771+
}
772+
if got := ids.get("u-created"); got != "raw-bind-idtoken" {
773+
t.Errorf("id_token not promoted to uid: got %q", got)
774+
}
775+
if ids.get(bindIDTokenKey(jti)) != "" {
776+
t.Errorf("bind-pending id_token must be consumed after create")
777+
}
778+
}
779+
718780
// T31: token 缺失/非法 → 400
719781
func TestAPI_BindCreate_MissingToken(t *testing.T) {
720782
o, _, _, _, _, _, _, _ := newTestOIDCWithBindFull(t, defaultBindCfg(), nil, true)

0 commit comments

Comments
 (0)