From a72df2e839a0895be2fdb6474fdd76ed3b403679 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 4 May 2026 11:01:46 +0900 Subject: [PATCH] =?UTF-8?q?back-channel=20logout=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=8F=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_client_test.go | 66 +++++++++++ .../handler/auth_handler_sessions_test.go | 104 ++++++++++++++++++ .../backchannel_logout_service_test.go | 85 ++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 backend/internal/service/backchannel_logout_service_test.go diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go index 7263b358..6119340f 100644 --- a/backend/internal/handler/auth_handler_client_test.go +++ b/backend/internal/handler/auth_handler_client_test.go @@ -4,8 +4,11 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "encoding/json" + "io" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "time" @@ -53,6 +56,69 @@ func TestRevokeLinkedRp_Success(t *testing.T) { assert.Equal(t, 1, len(auditRepo.logs)) } +func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) { + t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc") + + var receivedBody string + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{"id": "user-123"}, + }), nil + } + if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + return httpResponse(r, http.StatusNoContent, ""), nil + } + if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "app-1", + "backchannel_logout_uri": "https://rp.example.com/backchannel-logout", + }), nil + } + if r.URL.Host == "rp.example.com" && r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" { + raw, _ := io.ReadAll(r.Body) + receivedBody = string(raw) + return httpResponse(r, http.StatusNoContent, ""), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + backchannelLogout, err := service.NewBackchannelLogoutService() + assert.NoError(t, err) + backchannelLogout.HTTPClient = client + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + BackchannelLogout: backchannelLogout, + AuditRepo: auditRepo, + } + app := fiber.New() + app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil) + req.Header.Set("Cookie", "valid") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, strings.Contains(receivedBody, "logout_token=")) + + values, err := url.ParseQuery(receivedBody) + assert.NoError(t, err) + assert.NotEmpty(t, values.Get("logout_token")) + + assert.Len(t, auditRepo.logs, 2) + assert.Equal(t, "backchannel_logout.sent", auditRepo.logs[1].EventType) +} + func TestListRpHistory_Aggregation(t *testing.T) { now := time.Now() auditRepo := &mockAuditRepo{ diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index 8a12de2c..dfb2565e 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -8,6 +8,8 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "time" @@ -500,6 +502,108 @@ func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t mockKratos.AssertExpectations(t) } +func TestDeleteMySession_SendsBackchannelLogoutTokenWhenClientConfigured(t *testing.T) { + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc") + + var receivedBody string + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": time.Now().UTC().Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + return httpResponse(r, http.StatusNoContent, ""), nil + } + if r.Method == http.MethodGet && r.URL.Path == "/clients/devfront" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "devfront", + "backchannel_logout_uri": "https://rp.example.com/backchannel-logout", + }), nil + } + case "rp.example.com": + if r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" { + raw, _ := io.ReadAll(r.Body) + receivedBody = string(raw) + return httpResponse(r, http.StatusNoContent, ""), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + setDefaultHTTPClientForTest(t, client.Transport) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + backchannelLogout, err := service.NewBackchannelLogoutService() + assert.NoError(t, err) + backchannelLogout.HTTPClient = client + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + BackchannelLogout: backchannelLogout, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + auditRepo.logs = append(auditRepo.logs, domain.AuditLog{ + UserID: "user-123", + EventType: "POST /api/v1/auth/oidc/login/accept", + SessionID: "target-sid", + Details: `{"client_id":"devfront","client_name":"Devfront"}`, + }) + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, strings.Contains(receivedBody, "logout_token=")) + + values, err := url.ParseQuery(receivedBody) + assert.NoError(t, err) + assert.NotEmpty(t, values.Get("logout_token")) + + foundBackchannelAudit := false + for _, log := range auditRepo.logs { + if log.EventType == "backchannel_logout.sent" { + foundBackchannelAudit = true + break + } + } + assert.True(t, foundBackchannelAudit) + + mockKratos.AssertExpectations(t) +} + func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) { t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") var hydraRevokeCalls int diff --git a/backend/internal/service/backchannel_logout_service_test.go b/backend/internal/service/backchannel_logout_service_test.go new file mode 100644 index 00000000..09e70425 --- /dev/null +++ b/backend/internal/service/backchannel_logout_service_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/go-jose/go-jose/v4" + josejwt "github.com/go-jose/go-jose/v4/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBackchannelLogoutService_BuildLogoutToken(t *testing.T) { + t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc") + + svc, err := NewBackchannelLogoutService() + require.NoError(t, err) + + token, err := svc.BuildLogoutToken("client-1", "user-1", "sid-1") + require.NoError(t, err) + require.NotEmpty(t, token) + + jwksRaw, err := svc.MarshalPublicJWKS() + require.NoError(t, err) + + var jwks struct { + Keys []jose.JSONWebKey `json:"keys"` + } + require.NoError(t, json.Unmarshal(jwksRaw, &jwks)) + require.Len(t, jwks.Keys, 1) + + parsed, err := josejwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + + var claims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Aud interface{} `json:"aud"` + Iat int64 `json:"iat"` + Jti string `json:"jti"` + Sid string `json:"sid"` + Events map[string]interface{} `json:"events"` + } + require.NoError(t, parsed.Claims(jwks.Keys[0].Key, &claims)) + + assert.Equal(t, "https://sso.example.com/oidc", claims.Issuer) + assert.Equal(t, "user-1", claims.Subject) + switch aud := claims.Aud.(type) { + case string: + assert.Equal(t, "client-1", aud) + case []interface{}: + assert.Len(t, aud, 1) + assert.Equal(t, "client-1", aud[0]) + default: + t.Fatalf("unexpected aud type: %T", claims.Aud) + } + assert.NotZero(t, claims.Iat) + assert.NotEmpty(t, claims.Jti) + assert.Equal(t, "sid-1", claims.Sid) + _, ok := claims.Events[backchannelLogoutEventURI] + assert.True(t, ok) +} + +func TestBackchannelLogoutService_SendLogoutToken(t *testing.T) { + var body string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + raw, _ := io.ReadAll(r.Body) + body = string(raw) + w.WriteHeader(http.StatusNoContent) + }) + + svc, err := NewBackchannelLogoutService() + require.NoError(t, err) + svc.HTTPClient = clientForHandler(handler) + + statusCode, err := svc.SendLogoutToken(context.Background(), "https://rp.example.com/backchannel-logout", "signed-token") + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, statusCode) + assert.Equal(t, "logout_token=signed-token", body) +}