1
0
forked from baron/baron-sso

back-channel logout 서비스 및 핸들러 테스트 추가

This commit is contained in:
2026-05-04 11:01:46 +09:00
parent 0664640c6f
commit a72df2e839
3 changed files with 255 additions and 0 deletions

View File

@@ -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{

View File

@@ -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

View File

@@ -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)
}