package xguard import ( "context" "encoding/json" "errors" "fmt" "net/http" "slices" "time" ) const backchannelEvent = "http://schemas.openid.net/event/backchannel-logout" // LogoutToken: isi logout_token dari back-channel logout yang sudah diverifikasi. type LogoutToken struct { Subject string SessionID string // kosong = cabut semua sesi milik Subject JTI string IssuedAt time.Time } // VerifyLogoutToken memverifikasi logout_token (OIDC Back-Channel Logout 1.0): // tanda tangan RS256 lewat JWKS X-Guard, iss, aud = ClientID, iat tidak lebih dari // 5 menit lalu, ada klaim events back-channel, dan tidak ada nonce. func (c *Client) VerifyLogoutToken(ctx context.Context, raw string) (*LogoutToken, error) { tok, err := c.jwtVerify.Verify(c.ctx(ctx), raw) if err != nil { return nil, fmt.Errorf("xguard: logout_token: %w", err) } if !slices.Contains(tok.Audience, c.cfg.ClientID) { return nil, errors.New("xguard: logout_token: aud bukan client ini") } var cl struct { SID string `json:"sid"` JTI string `json:"jti"` Nonce *string `json:"nonce"` Events map[string]json.RawMessage `json:"events"` } if err := tok.Claims(&cl); err != nil { return nil, fmt.Errorf("xguard: logout_token: %w", err) } if cl.Nonce != nil { return nil, errors.New("xguard: logout_token tidak boleh berisi nonce") } if _, ok := cl.Events[backchannelEvent]; !ok { return nil, errors.New("xguard: logout_token tanpa event back-channel") } if tok.Subject == "" && cl.SID == "" { return nil, errors.New("xguard: logout_token tanpa sub dan sid") } if age := time.Since(tok.IssuedAt); age > 5*time.Minute || age < -time.Minute { return nil, errors.New("xguard: logout_token terlalu lama atau dari masa depan") } return &LogoutToken{Subject: tok.Subject, SessionID: cl.SID, JTI: cl.JTI, IssuedAt: tok.IssuedAt}, nil } // BackchannelLogoutHandler menerima POST back-channel logout dari X-Guard. Daftarkan alamatnya sebagai "Alamat // back-channel logout" aplikasi di konsol. // revoke dipanggil untuk mencabut sesi aplikasi: cocokkan SessionID dengan sid // yang disimpan saat login (Identity.SessionID), atau semua sesi Subject bila SessionID kosong. // Balasan: 200 bila berhasil, 400 bila token tidak valid, 500 bila revoke gagal. func (c *Client) BackchannelLogoutHandler(revoke func(ctx context.Context, lt *LogoutToken) error) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 64<<10) lt, err := c.VerifyLogoutToken(r.Context(), r.PostFormValue("logout_token")) if err != nil { http.Error(w, "invalid logout_token", http.StatusBadRequest) return } if err := revoke(r.Context(), lt); err != nil { http.Error(w, "revoke failed", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) }) }