@@ -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,前端降级为仅清本地 —— 纯增量,不影响存量行为。
794847func (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
8441000func (o * OIDC ) failWithAuthcode (ctx context.Context , sd * StateData , claims * IDTokenClaims , err error ) {
0 commit comments