forked from baron/baron-sso
back-channel logout 서비스 및 핸들러 테스트 추가
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
85
backend/internal/service/backchannel_logout_service_test.go
Normal file
85
backend/internal/service/backchannel_logout_service_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user