1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/af-ui

This commit is contained in:
2026-03-24 16:48:56 +09:00
42 changed files with 3353 additions and 1644 deletions

View File

@@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
name = clientID
}
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
clientURL := resolveLinkedRPURL(
client.ClientID,
client.ClientURI,
client.RedirectURIs,
)
lastAuth := time.Time{}
if session.AuthenticatedAt != nil {
@@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
name = client.ClientID
}
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
clientURL := resolveLinkedRPURL(
client.ClientID,
client.ClientURI,
client.RedirectURIs,
)
records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
@@ -4889,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string {
}
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
identityID, traits, _, err := h.getKratosIdentityWithSession(sessionToken)
return identityID, traits, err
}
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", nil, err
return "", nil, "", err
}
req.Header.Set("X-Session-Token", sessionToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", nil, err
return "", nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct {
Identity struct {
AuthenticatedAt string `json:"authenticated_at"`
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, err
return "", nil, "", err
}
return result.Identity.ID, result.Identity.Traits, nil
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
}
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
@@ -4996,37 +4999,43 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
}
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
identityID, traits, _, err := h.getKratosIdentityWithCookieAndSession(cookie)
return identityID, traits, err
}
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", nil, err
return "", nil, "", err
}
req.Header.Set("Cookie", cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", nil, err
return "", nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct {
Identity struct {
AuthenticatedAt string `json:"authenticated_at"`
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, err
return "", nil, "", err
}
return result.Identity.ID, result.Identity.Traits, nil
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
}
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
@@ -5161,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
return profile
}
func (h *AuthHandler) applySessionAuthenticatedAtFromWhoami(profile *domain.UserProfileResponse, authenticatedAt string) *domain.UserProfileResponse {
if profile == nil {
return nil
}
profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt)
return profile
}
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentity(sessionToken)
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithSession(sessionToken)
if err != nil {
return nil, err
}
return h.mapKratosIdentityToProfile(identityID, traits), nil
return h.applySessionAuthenticatedAtFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt,
), nil
}
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithCookieAndSession(cookie)
if err != nil {
return nil, err
}
return h.mapKratosIdentityToProfile(identityID, traits), nil
return h.applySessionAuthenticatedAtFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt,
), nil
}
// UpdateMe - Updates current user's profile with phone verification check
@@ -5423,6 +5446,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string {
return ""
}
func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string {
switch strings.TrimSpace(clientID) {
case "adminfront":
if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" {
return value
}
case "devfront":
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
return value
}
}
clientURL := strings.TrimSpace(clientURI)
if clientURL != "" {
return clientURL
}
if len(redirectURIs) > 0 {
if parsed, err := url.Parse(redirectURIs[0]); err == nil {
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
return ""
}
func mergeScopes(current []string, next []string) []string {
if len(next) == 0 {
return current

View File

@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/require"
)
func TestGetMe_IncludesSessionAuthenticatedAtFromKratosSession(t *testing.T) {
const (
token = "token-session"
identityID = "user-session"
sessionAuthenticated = "2026-03-23T15:30:00Z"
)
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "kratos.test" &&
r.URL.Path == "/sessions/whoami" &&
r.Method == http.MethodGet {
require.Equal(t, token, r.Header.Get("X-Session-Token"))
return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "kratos-session-1",
"authenticated_at": sessionAuthenticated,
"identity": map[string]any{
"id": identityID,
"traits": map[string]any{
"email": "qa@example.com",
"name": "QA User",
"department": "Platform",
"affiliationType": "GENERAL",
},
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
setDefaultHTTPClientForTest(t, transport)
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{}
app := fiber.New()
app.Get("/api/v1/user/me", h.GetMe)
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := app.Test(req, -1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var profile map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
}
func TestGetMe_IncludesSessionAuthenticatedAtForCookieSession(t *testing.T) {
const (
cookieHeader = "ory_kratos_session=session-cookie"
identityID = "user-cookie"
sessionAuthenticated = "2026-03-24T01:20:00Z"
)
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "kratos.test" &&
r.URL.Path == "/sessions/whoami" &&
r.Method == http.MethodGet {
require.Equal(t, cookieHeader, r.Header.Get("Cookie"))
return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "kratos-session-cookie",
"authenticated_at": sessionAuthenticated,
"identity": map[string]any{
"id": identityID,
"traits": map[string]any{
"email": "cookie@example.com",
"name": "Cookie User",
"department": "Platform",
"affiliationType": "GENERAL",
},
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
setDefaultHTTPClientForTest(t, transport)
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{}
app := fiber.New()
app.Get("/api/v1/user/me", h.GetMe)
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
req.Header.Set("Cookie", cookieHeader)
resp, err := app.Test(req, -1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var profile map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
}

View File

@@ -142,6 +142,10 @@ type clientUpsertRequest struct {
Metadata *map[string]interface{} `json:"metadata"`
}
var protectedSystemClientIDs = map[string]struct{}{
"oathkeeper-introspect": {},
}
func normalizeUserRole(role string) string {
return domain.NormalizeRole(role)
}
@@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string {
return strings.TrimSpace(profile.Role)
}
func isProtectedSystemClientID(clientID string) bool {
_, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)]
return ok
}
func isProtectedSystemClient(client domain.HydraClient) bool {
return isProtectedSystemClientID(client.ClientID)
}
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if (!ok || profile == nil) && h.Auth != nil {
@@ -557,6 +570,10 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
if isProtectedSystemClient(client) {
continue
}
summary := h.mapClientSummary(client)
// 1. [Security] Filter out 'private' clients if user is not an AppManager
@@ -604,6 +621,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*client) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
summary := h.mapClientSummary(*client)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if clientID == "" {
clientID = uuid.NewString()
}
if isProtectedSystemClientID(clientID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id")
}
name := strings.TrimSpace(valueOr(req.Name, ""))
if name == "" {
@@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
currentSummary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
var totalClients int64
if err == nil {
for _, client := range clients {
if isProtectedSystemClient(client) {
continue
}
if isSuperAdmin {
totalClients++
continue

View File

@@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
}
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.Get("/api/v1/dev/clients", h.ListClients)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res clientListResponse
_ = json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, "client-1", res.Items[0].ID)
}
func TestUpdateClientStatus_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -164,6 +202,38 @@ func TestUpdateClientStatus_Success(t *testing.T) {
assert.Equal(t, "inactive", res.Client.Status)
}
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "oathkeeper-introspect",
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestDeleteClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -204,6 +274,67 @@ func TestDeleteClient_Success(t *testing.T) {
assert.Error(t, err)
}
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}},
Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Delete("/api/v1/dev/clients/:id", h.DeleteClient)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "oathkeeper-introspect",
"client_name": "Internal Client",
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestRotateClientSecret_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -254,6 +385,7 @@ func TestGetStats_Success(t *testing.T) {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
}), nil
}