forked from baron/baron-sso
348 lines
12 KiB
Go
348 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/service"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type rpClaimsE2ERepo struct {
|
|
mu sync.Mutex
|
|
rows map[string]*domain.RPUserMetadata
|
|
getKeys []string
|
|
}
|
|
|
|
func newRPClaimsE2ERepo() *rpClaimsE2ERepo {
|
|
return &rpClaimsE2ERepo{rows: map[string]*domain.RPUserMetadata{}}
|
|
}
|
|
|
|
func (r *rpClaimsE2ERepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
key := rpClaimsE2ERepoKey(clientID, userID)
|
|
r.getKeys = append(r.getKeys, key)
|
|
row, ok := r.rows[key]
|
|
if !ok {
|
|
return nil, fmt.Errorf("rp user metadata not found")
|
|
}
|
|
return cloneRPUserMetadata(row), nil
|
|
}
|
|
|
|
func (r *rpClaimsE2ERepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.rows[rpClaimsE2ERepoKey(metadata.ClientID, metadata.UserID)] = cloneRPUserMetadata(metadata)
|
|
return nil
|
|
}
|
|
|
|
func (r *rpClaimsE2ERepo) seenGet(clientID, userID string) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
key := rpClaimsE2ERepoKey(clientID, userID)
|
|
for _, seen := range r.getKeys {
|
|
if seen == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func rpClaimsE2ERepoKey(clientID, userID string) string {
|
|
return strings.TrimSpace(clientID) + "\x00" + strings.TrimSpace(userID)
|
|
}
|
|
|
|
func cloneRPUserMetadata(row *domain.RPUserMetadata) *domain.RPUserMetadata {
|
|
if row == nil {
|
|
return nil
|
|
}
|
|
cloned := &domain.RPUserMetadata{
|
|
ClientID: row.ClientID,
|
|
UserID: row.UserID,
|
|
Metadata: domain.JSONMap{},
|
|
}
|
|
for key, value := range row.Metadata {
|
|
cloned.Metadata[key] = value
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
|
|
const userID = "user-rp-e2e"
|
|
const clientA = "client-rp-a"
|
|
const clientB = "client-rp-b"
|
|
|
|
clients := map[string]map[string]any{
|
|
clientA: rpClaimsE2EClient(clientA, []map[string]any{
|
|
rpClaimsE2EClaim("approvalLevel", "text", "A", "user_and_admin", "user_and_admin"),
|
|
rpClaimsE2EClaim("activeMember", "boolean", "true", "user_and_admin", "user_and_admin"),
|
|
rpClaimsE2EClaim("score", "number", "1", "user_and_admin", "user_and_admin"),
|
|
rpClaimsE2EClaim("featureList", "array", `["default"]`, "user_and_admin", "user_and_admin"),
|
|
rpClaimsE2EClaim("preferences", "object", `{"theme":"light","density":"comfortable"}`, "user_and_admin", "user_and_admin"),
|
|
rpClaimsE2EClaim("contractDate", "date", float64(1780930800), "user_and_admin", "user_and_admin"),
|
|
rpClaimsE2EClaim("approvedAt", "datetime", float64(1780965000), "user_and_admin", "user_and_admin"),
|
|
rpClaimsE2EClaim("adminManagedNote", "text", "admin-default", "user_and_admin", "admin_only"),
|
|
}),
|
|
clientB: rpClaimsE2EClient(clientB, []map[string]any{
|
|
rpClaimsE2EClaim("approvalLevel", "text", "B-default", "user_and_admin", "user_and_admin"),
|
|
rpClaimsE2EClaim("activeMember", "boolean", "false", "user_and_admin", "user_and_admin"),
|
|
}),
|
|
}
|
|
challenges := map[string]string{
|
|
"challenge-client-a-default": clientA,
|
|
"challenge-client-a-admin-update": clientA,
|
|
"challenge-client-a-self-update": clientA,
|
|
"challenge-client-b-default": clientB,
|
|
"challenge-client-b-update": clientB,
|
|
}
|
|
capturedClaims := map[string]map[string]any{}
|
|
hydraClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if strings.HasPrefix(r.URL.Path, "/clients/") {
|
|
clientID := strings.TrimPrefix(r.URL.Path, "/clients/")
|
|
client, ok := clients[clientID]
|
|
if !ok {
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusOK, client), nil
|
|
}
|
|
if r.URL.Path == "/oauth2/auth/requests/consent" {
|
|
challenge := r.URL.Query().Get("consent_challenge")
|
|
clientID, ok := challenges[challenge]
|
|
if !ok {
|
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"challenge": challenge,
|
|
"requested_scope": []string{"openid", "profile"},
|
|
"subject": userID,
|
|
"client": clients[clientID],
|
|
}), nil
|
|
}
|
|
if r.URL.Path == "/oauth2/auth/requests/consent/accept" {
|
|
challenge := r.URL.Query().Get("consent_challenge")
|
|
body, _ := io.ReadAll(r.Body)
|
|
var acceptReq map[string]any
|
|
_ = json.Unmarshal(body, &acceptReq)
|
|
session, _ := acceptReq["session"].(map[string]any)
|
|
idToken, _ := session["id_token"].(map[string]any)
|
|
capturedClaims[challenge] = idToken
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"redirect_to": "http://rp/cb",
|
|
}), nil
|
|
}
|
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
|
})}
|
|
|
|
repo := newRPClaimsE2ERepo()
|
|
kratos := new(MockKratosAdminService)
|
|
kratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
|
ID: userID,
|
|
State: "active",
|
|
Traits: map[string]any{
|
|
"email": "rp-e2e@example.com",
|
|
"name": "RP E2E User",
|
|
},
|
|
}, nil)
|
|
|
|
authHandler := &AuthHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: hydraClient,
|
|
},
|
|
KratosAdmin: kratos,
|
|
RPUserMetadataRepo: repo,
|
|
}
|
|
devHandler := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: hydraClient,
|
|
},
|
|
RPUserMetadataRepo: repo,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Put("/api/v1/dev/clients/:id/users/me/metadata", func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: userID, Role: domain.RoleUser})
|
|
return devHandler.SelfUpdateRPUserMetadata(c)
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
|
|
return devHandler.UpsertRPUserMetadata(c)
|
|
})
|
|
app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest)
|
|
|
|
initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default")
|
|
assert.Equal(t, "A", rpClaimValue(t, initialA, "approvalLevel"))
|
|
assert.Equal(t, "user_and_admin", rpClaimPermission(t, initialA, "approvalLevel", "readPermission"))
|
|
assert.Equal(t, true, rpClaimValue(t, initialA, "activeMember"))
|
|
assert.Equal(t, float64(1), rpClaimValue(t, initialA, "score"))
|
|
assert.Equal(t, []any{"default"}, rpClaimValue(t, initialA, "featureList"))
|
|
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, rpClaimValue(t, initialA, "preferences"))
|
|
assert.Equal(t, float64(1780930800), rpClaimValue(t, initialA, "contractDate"))
|
|
assert.Equal(t, float64(1780965000), rpClaimValue(t, initialA, "approvedAt"))
|
|
|
|
upsertRPClaimsE2EMetadata(t, app, clientA, userID, map[string]any{
|
|
"approvalLevel": "B",
|
|
"activeMember": false,
|
|
"score": 42,
|
|
"featureList": []string{"sso", "claims"},
|
|
"preferences": map[string]any{"theme": "dark", "density": "compact"},
|
|
"contractDate": float64(1781017200),
|
|
"approvedAt": float64(1780968600),
|
|
"adminManagedNote": "admin-updated",
|
|
"approvalLevel_permissions": map[string]any{
|
|
"writePermission": "user_and_admin",
|
|
},
|
|
})
|
|
|
|
updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update")
|
|
assert.Equal(t, "B", rpClaimValue(t, updatedA, "approvalLevel"))
|
|
assert.Equal(t, false, rpClaimValue(t, updatedA, "activeMember"))
|
|
assert.Equal(t, float64(42), rpClaimValue(t, updatedA, "score"))
|
|
assert.Equal(t, []any{"sso", "claims"}, rpClaimValue(t, updatedA, "featureList"))
|
|
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaimValue(t, updatedA, "preferences"))
|
|
assert.Equal(t, float64(1781017200), rpClaimValue(t, updatedA, "contractDate"))
|
|
assert.Equal(t, float64(1780968600), rpClaimValue(t, updatedA, "approvedAt"))
|
|
assert.Equal(t, "admin-updated", rpClaimValue(t, updatedA, "adminManagedNote"))
|
|
assert.NotContains(t, updatedA, "approvalLevel_permissions")
|
|
assert.NotContains(t, updatedA, "adminManagedNote_permissions")
|
|
|
|
rejectedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
|
|
"metadata": map[string]any{
|
|
"adminManagedNote": "user-should-not-overwrite",
|
|
},
|
|
})
|
|
assert.Equal(t, http.StatusForbidden, rejectedSelfUpdate.StatusCode)
|
|
|
|
allowedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
|
|
"metadata": map[string]any{
|
|
"approvalLevel": "C",
|
|
},
|
|
})
|
|
assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode)
|
|
|
|
selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update")
|
|
assert.Equal(t, "C", rpClaimValue(t, selfUpdatedA, "approvalLevel"))
|
|
assert.Equal(t, "admin-updated", rpClaimValue(t, selfUpdatedA, "adminManagedNote"))
|
|
|
|
defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default")
|
|
assert.Equal(t, "B-default", rpClaimValue(t, defaultB, "approvalLevel"))
|
|
assert.Equal(t, false, rpClaimValue(t, defaultB, "activeMember"))
|
|
assert.NotContains(t, defaultB, "score")
|
|
assert.NotContains(t, defaultB, "featureList")
|
|
assert.NotContains(t, defaultB, "adminManagedNote")
|
|
|
|
upsertRPClaimsE2EMetadata(t, app, clientB, userID, map[string]any{
|
|
"approvalLevel": "B-rp-only",
|
|
"activeMember": true,
|
|
})
|
|
updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update")
|
|
assert.Equal(t, "B-rp-only", rpClaimValue(t, updatedB, "approvalLevel"))
|
|
assert.Equal(t, true, rpClaimValue(t, updatedB, "activeMember"))
|
|
assert.NotEqual(t, rpClaimValue(t, selfUpdatedA, "approvalLevel"), rpClaimValue(t, updatedB, "approvalLevel"))
|
|
assert.NotContains(t, updatedB, "score")
|
|
assert.NotContains(t, updatedB, "featureList")
|
|
|
|
require.True(t, repo.seenGet(clientA, userID))
|
|
require.True(t, repo.seenGet(clientB, userID))
|
|
require.Contains(t, capturedClaims, "challenge-client-a-admin-update")
|
|
require.Contains(t, capturedClaims, "challenge-client-b-update")
|
|
kratos.AssertExpectations(t)
|
|
}
|
|
|
|
func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any {
|
|
return map[string]any{
|
|
"client_id": clientID,
|
|
"client_name": clientID,
|
|
"metadata": map[string]any{
|
|
"tenant_id": "tenant-rp-e2e",
|
|
"id_token_claims": claims,
|
|
},
|
|
}
|
|
}
|
|
|
|
func rpClaimsE2EClaim(key string, valueType string, value any, readPermission string, writePermission string) map[string]any {
|
|
return map[string]any{
|
|
"namespace": "rp_claims",
|
|
"key": key,
|
|
"valueType": valueType,
|
|
"value": value,
|
|
"readPermission": readPermission,
|
|
"writePermission": writePermission,
|
|
}
|
|
}
|
|
|
|
func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[string]map[string]any, challenge string) map[string]any {
|
|
t.Helper()
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"consent_challenge": challenge,
|
|
"grant_scope": []string{"openid", "profile"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req, -1)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
idToken, ok := capturedClaims[challenge]
|
|
require.True(t, ok)
|
|
rpClaims, ok := idToken["rp_claims"].(map[string]any)
|
|
require.True(t, ok)
|
|
return rpClaims
|
|
}
|
|
|
|
func rpClaimValue(t *testing.T, claims map[string]any, key string) any {
|
|
t.Helper()
|
|
|
|
payload, ok := claims[key].(map[string]any)
|
|
require.Truef(t, ok, "rp_claims.%s must be an object payload", key)
|
|
return payload["value"]
|
|
}
|
|
|
|
func rpClaimPermission(t *testing.T, claims map[string]any, key string, permissionKey string) string {
|
|
t.Helper()
|
|
|
|
payload, ok := claims[key].(map[string]any)
|
|
require.Truef(t, ok, "rp_claims.%s must be an object payload", key)
|
|
value, ok := payload[permissionKey].(string)
|
|
require.Truef(t, ok, "rp_claims.%s.%s must be a string", key, permissionKey)
|
|
return value
|
|
}
|
|
|
|
func upsertRPClaimsE2EMetadata(t *testing.T, app *fiber.App, clientID, userID string, metadata map[string]any) {
|
|
t.Helper()
|
|
|
|
resp := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientID+"/users/"+userID+"/metadata", map[string]any{
|
|
"metadata": metadata,
|
|
})
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
}
|
|
|
|
func putRPClaimsE2EMetadata(t *testing.T, app *fiber.App, method, path string, body map[string]any) *http.Response {
|
|
t.Helper()
|
|
|
|
payload, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req, -1)
|
|
require.NoError(t, err)
|
|
return resp
|
|
}
|