forked from baron/baron-sso
564 lines
19 KiB
Go
564 lines
19 KiB
Go
package handlerregression
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/handler"
|
|
"baron-sso-backend/internal/service"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
type mockSecretRepo struct {
|
|
secrets map[string]string
|
|
}
|
|
|
|
func (m *mockSecretRepo) Upsert(ctx context.Context, clientID, secret string) error {
|
|
if m.secrets == nil {
|
|
m.secrets = make(map[string]string)
|
|
}
|
|
m.secrets[clientID] = secret
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSecretRepo) GetByID(ctx context.Context, clientID string) (string, error) {
|
|
return m.secrets[clientID], nil
|
|
}
|
|
|
|
func (m *mockSecretRepo) Delete(ctx context.Context, clientID string) error {
|
|
delete(m.secrets, clientID)
|
|
return nil
|
|
}
|
|
|
|
type mockRedisRepo struct {
|
|
data map[string]string
|
|
}
|
|
|
|
func (m *mockRedisRepo) Set(key, value string, exp time.Duration) error {
|
|
if m.data == nil {
|
|
m.data = make(map[string]string)
|
|
}
|
|
m.data[key] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRedisRepo) Get(key string) (string, error) {
|
|
v, ok := m.data[key]
|
|
if !ok {
|
|
return "", fmt.Errorf("not found")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (m *mockRedisRepo) Delete(key string) error {
|
|
delete(m.data, key)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRedisRepo) StoreVerificationCode(p, c string) error { return nil }
|
|
func (m *mockRedisRepo) GetVerificationCode(p string) (string, error) { return "", nil }
|
|
func (m *mockRedisRepo) DeleteVerificationCode(p string) error { return nil }
|
|
|
|
func httpJSONAny(r *http.Request, code int, payload any) *http.Response {
|
|
body, _ := json.Marshal(payload)
|
|
return &http.Response{
|
|
StatusCode: code,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(bytes.NewReader(body)),
|
|
Request: r,
|
|
}
|
|
}
|
|
|
|
func newDevHandlerApp(h *handler.DevHandler) *fiber.App {
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
app.Post("/api/v1/dev/clients/:id/secret/rotate", h.RotateClientSecret)
|
|
return app
|
|
}
|
|
|
|
func decodeClientSecret(t *testing.T, resp *http.Response) string {
|
|
t.Helper()
|
|
|
|
var payload struct {
|
|
Client struct {
|
|
ClientSecret string `json:"clientSecret"`
|
|
} `json:"client"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
return payload.Client.ClientSecret
|
|
}
|
|
|
|
func TestGetClient_ClientSecretFallbackPaths(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hydraClient map[string]any
|
|
initialRedis map[string]string
|
|
initialSecrets map[string]string
|
|
expectedSecret string
|
|
expectedRedisAfter string
|
|
expectRedisAfterSet bool
|
|
}{
|
|
{
|
|
name: "uses hydra client_secret directly",
|
|
hydraClient: map[string]any{
|
|
"client_id": "client-direct",
|
|
"client_name": "Direct App",
|
|
"client_secret": "hydra-secret",
|
|
"redirect_uris": []string{"https://direct.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{"status": "active"},
|
|
},
|
|
expectedSecret: "hydra-secret",
|
|
},
|
|
{
|
|
name: "falls back to metadata client_secret",
|
|
hydraClient: map[string]any{
|
|
"client_id": "client-metadata",
|
|
"client_name": "Metadata App",
|
|
"redirect_uris": []string{"https://metadata.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{
|
|
"status": "active",
|
|
"client_secret": "metadata-secret",
|
|
},
|
|
},
|
|
expectedSecret: "metadata-secret",
|
|
},
|
|
{
|
|
name: "falls back to redis cache",
|
|
hydraClient: map[string]any{
|
|
"client_id": "client-redis",
|
|
"client_name": "Redis App",
|
|
"redirect_uris": []string{"https://redis.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{"status": "active"},
|
|
},
|
|
initialRedis: map[string]string{"client_secret:client-redis": "redis-secret"},
|
|
expectedSecret: "redis-secret",
|
|
},
|
|
{
|
|
name: "falls back to postgres and warms redis",
|
|
hydraClient: map[string]any{
|
|
"client_id": "client-postgres",
|
|
"client_name": "Postgres App",
|
|
"redirect_uris": []string{"https://postgres.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{"status": "active"},
|
|
},
|
|
initialSecrets: map[string]string{"client-postgres": "postgres-secret"},
|
|
expectedSecret: "postgres-secret",
|
|
expectedRedisAfter: "postgres-secret",
|
|
expectRedisAfterSet: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/"+tt.hydraClient["client_id"].(string) {
|
|
return httpJSONAny(r, http.StatusOK, tt.hydraClient), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
secretRepo := &mockSecretRepo{secrets: map[string]string{}}
|
|
for k, v := range tt.initialSecrets {
|
|
secretRepo.secrets[k] = v
|
|
}
|
|
|
|
redisRepo := &mockRedisRepo{data: map[string]string{}}
|
|
for k, v := range tt.initialRedis {
|
|
redisRepo.data[k] = v
|
|
}
|
|
|
|
h := &handler.DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: secretRepo,
|
|
Redis: redisRepo,
|
|
}
|
|
|
|
app := newDevHandlerApp(h)
|
|
|
|
clientID := tt.hydraClient["client_id"].(string)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/"+clientID, nil)
|
|
resp, err := app.Test(req, -1)
|
|
if err != nil {
|
|
t.Fatalf("get request failed: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected get 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
if secret := decodeClientSecret(t, resp); secret != tt.expectedSecret {
|
|
t.Fatalf("expected secret %q, got %q", tt.expectedSecret, secret)
|
|
}
|
|
|
|
if tt.expectRedisAfterSet {
|
|
redisSecret, err := redisRepo.Get("client_secret:" + clientID)
|
|
if err != nil {
|
|
t.Fatalf("expected warmed redis secret, got error: %v", err)
|
|
}
|
|
if redisSecret != tt.expectedRedisAfter {
|
|
t.Fatalf("expected warmed redis secret %q, got %q", tt.expectedRedisAfter, redisSecret)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateClient_PersistsSecretForLaterDetailFetch(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
|
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
|
"client_id": "client-created",
|
|
"client_name": "Created App",
|
|
"client_secret": "created-secret",
|
|
"redirect_uris": []string{"https://created.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{"status": "active"},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-created" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-created",
|
|
"client_name": "Created App",
|
|
"redirect_uris": []string{"https://created.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{"status": "active"},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
|
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
|
|
|
h := &handler.DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: secretRepo,
|
|
Redis: redisRepo,
|
|
}
|
|
|
|
app := newDevHandlerApp(h)
|
|
|
|
createBody, _ := json.Marshal(map[string]any{
|
|
"name": "Created App",
|
|
"type": "private",
|
|
"redirectUris": []string{"https://created.example.com/callback"},
|
|
"scopes": []string{"openid", "profile"},
|
|
})
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(createBody))
|
|
createReq.Header.Set("Content-Type", "application/json")
|
|
|
|
createResp, err := app.Test(createReq, -1)
|
|
if err != nil {
|
|
t.Fatalf("create request failed: %v", err)
|
|
}
|
|
if createResp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("expected create 201, got %d", createResp.StatusCode)
|
|
}
|
|
|
|
if secret := decodeClientSecret(t, createResp); secret != "created-secret" {
|
|
t.Fatalf("expected create secret created-secret, got %q", secret)
|
|
}
|
|
|
|
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-created")
|
|
if storedSecret != "created-secret" {
|
|
t.Fatalf("expected postgres secret created-secret, got %q", storedSecret)
|
|
}
|
|
|
|
redisSecret, err := redisRepo.Get("client_secret:client-created")
|
|
if err != nil {
|
|
t.Fatalf("expected redis secret after create, got error: %v", err)
|
|
}
|
|
if redisSecret != "created-secret" {
|
|
t.Fatalf("expected redis secret created-secret, got %q", redisSecret)
|
|
}
|
|
|
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-created", nil)
|
|
getResp, err := app.Test(getReq, -1)
|
|
if err != nil {
|
|
t.Fatalf("get request failed: %v", err)
|
|
}
|
|
if getResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected get 200, got %d", getResp.StatusCode)
|
|
}
|
|
|
|
if secret := decodeClientSecret(t, getResp); secret != "created-secret" {
|
|
t.Fatalf("expected detail secret created-secret, got %q", secret)
|
|
}
|
|
}
|
|
|
|
func TestRotateClientSecret_PersistsForLaterDetailFetch(t *testing.T) {
|
|
getCount := 0
|
|
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-rotate" {
|
|
getCount++
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-rotate",
|
|
"client_name": "Rotate App",
|
|
"redirect_uris": []string{"https://rotate.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{"status": "active"},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-rotate" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-rotate",
|
|
"client_name": "Rotate App",
|
|
"redirect_uris": []string{"https://rotate.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{"status": "active"},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
|
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
|
|
|
h := &handler.DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: secretRepo,
|
|
Redis: redisRepo,
|
|
}
|
|
|
|
app := newDevHandlerApp(h)
|
|
|
|
rotateReq := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-rotate/secret/rotate", nil)
|
|
rotateResp, err := app.Test(rotateReq, -1)
|
|
if err != nil {
|
|
t.Fatalf("rotate request failed: %v", err)
|
|
}
|
|
if rotateResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected rotate 200, got %d", rotateResp.StatusCode)
|
|
}
|
|
|
|
rotatedSecret := decodeClientSecret(t, rotateResp)
|
|
if rotatedSecret == "" {
|
|
t.Fatalf("expected rotated secret to be present")
|
|
}
|
|
|
|
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-rotate")
|
|
if storedSecret != rotatedSecret {
|
|
t.Fatalf("expected postgres secret %q, got %q", rotatedSecret, storedSecret)
|
|
}
|
|
|
|
redisSecret, err := redisRepo.Get("client_secret:client-rotate")
|
|
if err != nil {
|
|
t.Fatalf("expected redis secret after rotate, got error: %v", err)
|
|
}
|
|
if redisSecret != rotatedSecret {
|
|
t.Fatalf("expected redis secret %q, got %q", rotatedSecret, redisSecret)
|
|
}
|
|
|
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-rotate", nil)
|
|
getResp, err := app.Test(getReq, -1)
|
|
if err != nil {
|
|
t.Fatalf("get request failed: %v", err)
|
|
}
|
|
if getResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected get 200, got %d", getResp.StatusCode)
|
|
}
|
|
|
|
if secret := decodeClientSecret(t, getResp); secret != rotatedSecret {
|
|
t.Fatalf("expected detail secret %q, got %q", rotatedSecret, secret)
|
|
}
|
|
}
|
|
|
|
func TestUpdateClient_HeadlessLoginSecretPersistsForLaterDetailFetch(t *testing.T) {
|
|
getCount := 0
|
|
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
|
getCount++
|
|
if getCount == 1 {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-headless-login",
|
|
"client_name": "Headless Login Before",
|
|
"redirect_uris": []string{"https://before.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "none",
|
|
"metadata": map[string]any{
|
|
"status": "active",
|
|
},
|
|
}), nil
|
|
}
|
|
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-headless-login",
|
|
"client_name": "Headless Login After",
|
|
"redirect_uris": []string{"https://headless.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "private_key_jwt",
|
|
"jwks_uri": "https://headless.example.com/jwks.json",
|
|
"metadata": map[string]any{
|
|
"status": "active",
|
|
"headless_login_enabled": true,
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
}), nil
|
|
}
|
|
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-headless-login",
|
|
"client_name": "Headless Login After",
|
|
"client_secret": "headless-secret",
|
|
"redirect_uris": []string{"https://headless.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "private_key_jwt",
|
|
"jwks_uri": "https://headless.example.com/jwks.json",
|
|
"metadata": map[string]any{
|
|
"status": "active",
|
|
"headless_login_enabled": true,
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
}), nil
|
|
}
|
|
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
|
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
|
|
|
h := &handler.DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: secretRepo,
|
|
Redis: redisRepo,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
|
|
|
updateBody, _ := json.Marshal(map[string]any{
|
|
"name": "Headless Login After",
|
|
"redirectUris": []string{"https://headless.example.com/callback"},
|
|
"tokenEndpointAuthMethod": "private_key_jwt",
|
|
"jwksUri": "https://headless.example.com/jwks.json",
|
|
"metadata": map[string]any{
|
|
"headless_login_enabled": true,
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
})
|
|
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(updateBody))
|
|
updateReq.Header.Set("Content-Type", "application/json")
|
|
|
|
updateResp, err := app.Test(updateReq, -1)
|
|
if err != nil {
|
|
t.Fatalf("update request failed: %v", err)
|
|
}
|
|
if updateResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected update 200, got %d", updateResp.StatusCode)
|
|
}
|
|
|
|
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-headless-login")
|
|
if storedSecret != "headless-secret" {
|
|
t.Fatalf("expected postgres secret headless-secret, got %q", storedSecret)
|
|
}
|
|
|
|
redisSecret, err := redisRepo.Get("client_secret:client-headless-login")
|
|
if err != nil {
|
|
t.Fatalf("expected redis secret, got error: %v", err)
|
|
}
|
|
if redisSecret != "headless-secret" {
|
|
t.Fatalf("expected redis secret headless-secret, got %q", redisSecret)
|
|
}
|
|
|
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-headless-login", nil)
|
|
getResp, err := app.Test(getReq, -1)
|
|
if err != nil {
|
|
t.Fatalf("get request failed: %v", err)
|
|
}
|
|
if getResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected get 200, got %d", getResp.StatusCode)
|
|
}
|
|
|
|
var payload struct {
|
|
Client struct {
|
|
ClientSecret string `json:"clientSecret"`
|
|
} `json:"client"`
|
|
}
|
|
if err := json.NewDecoder(getResp.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if payload.Client.ClientSecret != "headless-secret" {
|
|
t.Fatalf("expected detail secret headless-secret, got %q", payload.Client.ClientSecret)
|
|
}
|
|
}
|