forked from baron/baron-sso
686 lines
21 KiB
Go
686 lines
21 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/service"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
)
|
|
|
|
func TestListMySessions_Success(t *testing.T) {
|
|
now := time.Date(2026, 4, 2, 1, 2, 3, 0, time.UTC)
|
|
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/sessions/whoami" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"id": "current-sid",
|
|
"authenticated_at": now.Format(time.RFC3339),
|
|
"identity": map[string]any{
|
|
"id": "user-123",
|
|
"traits": map[string]any{
|
|
"email": "user@example.com",
|
|
"name": "User",
|
|
"role": "user",
|
|
},
|
|
},
|
|
}), nil
|
|
}
|
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
|
}))
|
|
|
|
mockKratos := new(MockKratosAdminService)
|
|
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
|
{
|
|
ID: "current-sid",
|
|
Active: true,
|
|
AuthenticatedAt: now,
|
|
ExpiresAt: now.Add(24 * time.Hour),
|
|
},
|
|
{
|
|
ID: "other-sid",
|
|
Active: true,
|
|
AuthenticatedAt: now.Add(-2 * time.Hour),
|
|
ExpiresAt: now.Add(22 * time.Hour),
|
|
},
|
|
}, nil).Once()
|
|
|
|
auditRepo := &mockAuditRepo{
|
|
logs: []domain.AuditLog{
|
|
{
|
|
UserID: "user-123",
|
|
EventType: "login_success",
|
|
SessionID: "other-sid",
|
|
Timestamp: now.Add(-30 * time.Minute),
|
|
IPAddress: "203.0.113.10",
|
|
UserAgent: "Mozilla/5.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
h := &AuthHandler{
|
|
KratosAdmin: mockKratos,
|
|
AuditRepo: auditRepo,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
|
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
|
|
|
resp, err := app.Test(req, -1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Items []struct {
|
|
SessionID string `json:"session_id"`
|
|
IsCurrent bool `json:"is_current"`
|
|
IsActive bool `json:"is_active"`
|
|
IPAddress string `json:"ip_address"`
|
|
UserAgent string `json:"user_agent"`
|
|
} `json:"items"`
|
|
}
|
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
|
assert.NoError(t, err)
|
|
if assert.Len(t, body.Items, 2) {
|
|
assert.Equal(t, "current-sid", body.Items[0].SessionID)
|
|
assert.True(t, body.Items[0].IsCurrent)
|
|
assert.Equal(t, "other-sid", body.Items[1].SessionID)
|
|
assert.True(t, body.Items[1].IsActive)
|
|
assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress)
|
|
assert.Equal(t, "Mozilla/5.0", body.Items[1].UserAgent)
|
|
}
|
|
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestListMySessions_UsesConsentGrantForAppName(t *testing.T) {
|
|
now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC)
|
|
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/sessions/whoami" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"id": "current-sid",
|
|
"authenticated_at": now.Format(time.RFC3339),
|
|
"identity": map[string]any{
|
|
"id": "user-123",
|
|
"traits": map[string]any{
|
|
"email": "user@example.com",
|
|
"name": "User",
|
|
"role": "user",
|
|
},
|
|
},
|
|
}), nil
|
|
}
|
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
|
}))
|
|
|
|
mockKratos := new(MockKratosAdminService)
|
|
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
|
{
|
|
ID: "current-sid",
|
|
Active: true,
|
|
AuthenticatedAt: now,
|
|
ExpiresAt: now.Add(24 * time.Hour),
|
|
},
|
|
{
|
|
ID: "c7c721ea-session",
|
|
Active: true,
|
|
AuthenticatedAt: now.Add(-5 * time.Minute),
|
|
ExpiresAt: now.Add(23*time.Hour + 55*time.Minute),
|
|
},
|
|
}, nil).Once()
|
|
|
|
auditRepo := &mockAuditRepo{
|
|
logs: []domain.AuditLog{
|
|
{
|
|
UserID: "user-123",
|
|
EventType: "consent.granted",
|
|
SessionID: "c7c721ea-session",
|
|
Timestamp: now,
|
|
Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session","approved_session_id":"c7c721ea-session"}`,
|
|
},
|
|
},
|
|
}
|
|
|
|
h := &AuthHandler{
|
|
KratosAdmin: mockKratos,
|
|
AuditRepo: auditRepo,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
|
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
|
|
|
resp, err := app.Test(req, -1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Items []struct {
|
|
SessionID string `json:"session_id"`
|
|
AppName string `json:"app_name"`
|
|
ClientID string `json:"client_id"`
|
|
} `json:"items"`
|
|
}
|
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
|
assert.NoError(t, err)
|
|
if assert.Len(t, body.Items, 2) {
|
|
assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID)
|
|
assert.Equal(t, "DevFront", body.Items[1].AppName)
|
|
assert.Equal(t, "devfront", body.Items[1].ClientID)
|
|
}
|
|
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestListMySessions_PreservesAppNameFromOlderConsentGrant(t *testing.T) {
|
|
now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC)
|
|
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/sessions/whoami" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"id": "current-sid",
|
|
"authenticated_at": now.Format(time.RFC3339),
|
|
"identity": map[string]any{
|
|
"id": "user-123",
|
|
"traits": map[string]any{
|
|
"email": "user@example.com",
|
|
"name": "User",
|
|
"role": "user",
|
|
},
|
|
},
|
|
}), nil
|
|
}
|
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
|
}))
|
|
|
|
mockKratos := new(MockKratosAdminService)
|
|
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
|
{
|
|
ID: "current-sid",
|
|
Active: true,
|
|
AuthenticatedAt: now,
|
|
ExpiresAt: now.Add(24 * time.Hour),
|
|
},
|
|
{
|
|
ID: "c7c721ea-session",
|
|
Active: true,
|
|
AuthenticatedAt: now.Add(-5 * time.Minute),
|
|
ExpiresAt: now.Add(23*time.Hour + 55*time.Minute),
|
|
},
|
|
}, nil).Once()
|
|
|
|
auditRepo := &mockAuditRepo{
|
|
logs: []domain.AuditLog{
|
|
{
|
|
UserID: "user-123",
|
|
EventType: "consent.granted",
|
|
SessionID: "c7c721ea-session",
|
|
Timestamp: now.Add(-30 * time.Second),
|
|
IPAddress: "203.0.113.10",
|
|
Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session"}`,
|
|
},
|
|
{
|
|
UserID: "user-123",
|
|
EventType: "login_success",
|
|
SessionID: "c7c721ea-session",
|
|
Timestamp: now,
|
|
IPAddress: "10.0.0.12",
|
|
UserAgent: "Mozilla/5.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
h := &AuthHandler{
|
|
KratosAdmin: mockKratos,
|
|
AuditRepo: auditRepo,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
|
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
|
|
|
resp, err := app.Test(req, -1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Items []struct {
|
|
SessionID string `json:"session_id"`
|
|
AppName string `json:"app_name"`
|
|
ClientID string `json:"client_id"`
|
|
IPAddress string `json:"ip_address"`
|
|
} `json:"items"`
|
|
}
|
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
|
assert.NoError(t, err)
|
|
if assert.Len(t, body.Items, 2) {
|
|
assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID)
|
|
assert.Equal(t, "DevFront", body.Items[1].AppName)
|
|
assert.Equal(t, "devfront", body.Items[1].ClientID)
|
|
assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress)
|
|
}
|
|
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestListMySessions_CurrentSessionFallsBackToRequestMetadata(t *testing.T) {
|
|
now := time.Date(2026, 4, 6, 1, 2, 3, 0, time.UTC)
|
|
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/sessions/whoami" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"id": "current-sid",
|
|
"authenticated_at": now.Format(time.RFC3339),
|
|
"identity": map[string]any{
|
|
"id": "user-123",
|
|
"traits": map[string]any{
|
|
"email": "user@example.com",
|
|
"name": "User",
|
|
"role": "user",
|
|
},
|
|
},
|
|
}), nil
|
|
}
|
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
|
}))
|
|
|
|
mockKratos := new(MockKratosAdminService)
|
|
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
|
|
{
|
|
ID: "current-sid",
|
|
Active: true,
|
|
AuthenticatedAt: now,
|
|
ExpiresAt: now.Add(24 * time.Hour),
|
|
},
|
|
}, nil).Once()
|
|
|
|
h := &AuthHandler{
|
|
KratosAdmin: mockKratos,
|
|
AuditRepo: &mockAuditRepo{},
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Get("/api/v1/user/sessions", h.ListMySessions)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
|
|
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36")
|
|
req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25")
|
|
|
|
resp, err := app.Test(req, -1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Items []struct {
|
|
SessionID string `json:"session_id"`
|
|
IsCurrent bool `json:"is_current"`
|
|
IPAddress string `json:"ip_address"`
|
|
UserAgent string `json:"user_agent"`
|
|
ClientID string `json:"client_id"`
|
|
AppName string `json:"app_name"`
|
|
} `json:"items"`
|
|
}
|
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
|
assert.NoError(t, err)
|
|
if assert.Len(t, body.Items, 1) {
|
|
assert.Equal(t, "current-sid", body.Items[0].SessionID)
|
|
assert.True(t, body.Items[0].IsCurrent)
|
|
assert.Equal(t, "203.0.113.25", body.Items[0].IPAddress)
|
|
assert.Contains(t, body.Items[0].UserAgent, "Mozilla/5.0")
|
|
assert.Equal(t, "userfront", body.Items[0].ClientID)
|
|
assert.Equal(t, "UserFront", body.Items[0].AppName)
|
|
}
|
|
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDeleteMySession_Success(t *testing.T) {
|
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
|
var hydraRevokeCalls int
|
|
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" {
|
|
if r.URL.Query().Get("subject") != "user-123" {
|
|
t.Fatalf("unexpected revoke subject: %s", r.URL.Query().Get("subject"))
|
|
}
|
|
if r.URL.Query().Get("client") != "devfront" {
|
|
t.Fatalf("unexpected revoke client: %s", r.URL.Query().Get("client"))
|
|
}
|
|
hydraRevokeCalls++
|
|
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()
|
|
|
|
auditRepo := &mockAuditRepo{}
|
|
h := &AuthHandler{
|
|
KratosAdmin: mockKratos,
|
|
AuditRepo: auditRepo,
|
|
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)
|
|
if assert.Len(t, auditRepo.logs, 2) {
|
|
assert.Equal(t, "session.revoked", auditRepo.logs[len(auditRepo.logs)-1].EventType)
|
|
assert.Equal(t, "user-123", auditRepo.logs[len(auditRepo.logs)-1].UserID)
|
|
assert.Equal(t, "current-sid", auditRepo.logs[len(auditRepo.logs)-1].SessionID)
|
|
assert.Contains(t, auditRepo.logs[len(auditRepo.logs)-1].Details, "target-sid")
|
|
}
|
|
assert.Equal(t, 1, hydraRevokeCalls)
|
|
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t *testing.T) {
|
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
|
var hydraRevokeCalls int
|
|
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" {
|
|
hydraRevokeCalls++
|
|
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()
|
|
|
|
auditRepo := &mockAuditRepo{}
|
|
h := &AuthHandler{
|
|
KratosAdmin: mockKratos,
|
|
AuditRepo: auditRepo,
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: client,
|
|
},
|
|
}
|
|
|
|
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.Equal(t, 0, hydraRevokeCalls)
|
|
if assert.Len(t, auditRepo.logs, 1) {
|
|
assert.Equal(t, "session.revoked", auditRepo.logs[0].EventType)
|
|
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
|
|
assert.Contains(t, auditRepo.logs[0].Details, "target-sid")
|
|
}
|
|
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) {
|
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
|
var hydraRevokeCalls int
|
|
var revokedClient 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" {
|
|
revokedClient = r.URL.Query().Get("client")
|
|
hydraRevokeCalls++
|
|
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()
|
|
|
|
auditRepo := &mockAuditRepo{}
|
|
h := &AuthHandler{
|
|
KratosAdmin: mockKratos,
|
|
AuditRepo: auditRepo,
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: client,
|
|
},
|
|
}
|
|
auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
|
|
UserID: "user-123",
|
|
EventType: "POST /api/v1/auth/password/login",
|
|
SessionID: "target-sid",
|
|
Details: `{"client_id":"adminfront","client_name":"AdminFront","session_id":"target-sid"}`,
|
|
})
|
|
|
|
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.Equal(t, 1, hydraRevokeCalls)
|
|
assert.Equal(t, "adminfront", revokedClient)
|
|
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) {
|
|
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Host == "hydra.test" && r.URL.Path == "/oauth2/introspect" {
|
|
body, _ := io.ReadAll(r.Body)
|
|
if string(body) != "token=opaque-token" {
|
|
t.Fatalf("unexpected introspect body: %s", string(body))
|
|
}
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"active": true,
|
|
"sub": "user-123",
|
|
"client_id": "devfront",
|
|
"ext": map[string]any{
|
|
"session_id": "target-sid",
|
|
},
|
|
}), nil
|
|
}
|
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
|
})}
|
|
|
|
mockKratos := new(MockKratosAdminService)
|
|
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
|
|
ID: "target-sid",
|
|
Active: false,
|
|
Identity: &service.KratosIdentity{
|
|
ID: "user-123",
|
|
},
|
|
}, nil).Once()
|
|
|
|
h := &AuthHandler{
|
|
KratosAdmin: mockKratos,
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: client,
|
|
},
|
|
}
|
|
|
|
profile, err := h.getHydraProfile(context.Background(), "opaque-token")
|
|
assert.Nil(t, profile)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "inactive")
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) {
|
|
now := time.Date(2026, 4, 7, 4, 39, 0, 0, time.UTC)
|
|
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/sessions/whoami" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"id": "current-sid",
|
|
"authenticated_at": now.Format(time.RFC3339),
|
|
"identity": map[string]any{
|
|
"id": "user-123",
|
|
"traits": map[string]any{
|
|
"email": "user@example.com",
|
|
"name": "User",
|
|
"role": "user",
|
|
},
|
|
},
|
|
}), nil
|
|
}
|
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
|
}))
|
|
|
|
h := &AuthHandler{
|
|
AuditRepo: &mockAuditRepo{},
|
|
OathkeeperRepo: &mockOathkeeperRepo{
|
|
logs: []domain.OathkeeperAccessLog{
|
|
{
|
|
Timestamp: now,
|
|
RequestID: "req-1",
|
|
Method: http.MethodGet,
|
|
Path: "/api/v1/dev/sessions",
|
|
Status: http.StatusOK,
|
|
Subject: "user-123",
|
|
ClientIP: "203.0.113.7",
|
|
UserAgent: "Mozilla/5.0",
|
|
Raw: `{"request":{"url":"https://devfront.example.com/callback?client_id=devfront"},"extra":{"session_id":"target-sid"}}`,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", nil)
|
|
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
|
|
|
resp, err := app.Test(req, -1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Items []struct {
|
|
SessionID string `json:"session_id"`
|
|
ClientID string `json:"client_id"`
|
|
AppName string `json:"app_name"`
|
|
Source string `json:"source"`
|
|
} `json:"items"`
|
|
}
|
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
|
assert.NoError(t, err)
|
|
if assert.Len(t, body.Items, 1) {
|
|
assert.Equal(t, "target-sid", body.Items[0].SessionID)
|
|
assert.Equal(t, "devfront", body.Items[0].ClientID)
|
|
assert.Equal(t, "devfront", body.Items[0].AppName)
|
|
assert.Equal(t, "oathkeeper", body.Items[0].Source)
|
|
}
|
|
}
|