forked from baron/baron-sso
Merge pull request 'dev/ory-hydra2' (#218) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#218
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
*.swp
|
||||
*.log
|
||||
*.out
|
||||
*.exe
|
||||
|
||||
# Docker Services Data (Volumes)
|
||||
postgres_data/
|
||||
@@ -20,6 +21,7 @@ backend/bin/
|
||||
backend/vendor/
|
||||
backend/tmp/
|
||||
backend/.env
|
||||
backend/server
|
||||
|
||||
# userfront (Flutter)
|
||||
# Note: userfront might have its own .gitignore, but adding here just in case
|
||||
|
||||
@@ -35,15 +35,25 @@ func main() {
|
||||
godotenv.Load("backend/.env")
|
||||
|
||||
pgHost := os.Getenv("DB_HOST")
|
||||
if pgHost == "" { pgHost = "localhost" }
|
||||
if pgHost == "" {
|
||||
pgHost = "localhost"
|
||||
}
|
||||
pgPort := os.Getenv("DB_PORT")
|
||||
if pgPort == "" { pgPort = "5432" }
|
||||
if pgPort == "" {
|
||||
pgPort = "5432"
|
||||
}
|
||||
pgUser := os.Getenv("DB_USER")
|
||||
if pgUser == "" { pgUser = "baron" }
|
||||
if pgUser == "" {
|
||||
pgUser = "baron"
|
||||
}
|
||||
pgPass := os.Getenv("DB_PASSWORD")
|
||||
if pgPass == "" { pgPass = "password" }
|
||||
if pgPass == "" {
|
||||
pgPass = "password"
|
||||
}
|
||||
pgName := os.Getenv("DB_NAME")
|
||||
if pgName == "" { pgName = "baron_sso" }
|
||||
if pgName == "" {
|
||||
pgName = "baron_sso"
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
|
||||
pgHost, pgUser, pgPass, pgName, pgPort)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/bootstrap"
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/handler"
|
||||
"baron-sso-backend/internal/idp"
|
||||
@@ -28,8 +29,6 @@ import (
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormLogger "gorm.io/gorm/logger"
|
||||
|
||||
"baron-sso-backend/internal/bootstrap"
|
||||
)
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
@@ -613,6 +612,7 @@ func main() {
|
||||
dev.Post("/clients", devHandler.CreateClient)
|
||||
dev.Get("/clients/:id", devHandler.GetClient)
|
||||
dev.Put("/clients/:id", devHandler.UpdateClient)
|
||||
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
|
||||
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
|
||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
||||
dev.Get("/consents", devHandler.ListConsents)
|
||||
|
||||
@@ -31,4 +31,3 @@ func (ug *UserGroup) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3820,6 +3820,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
|
||||
@@ -31,12 +31,15 @@ type MockIdentityProvider struct {
|
||||
func (m *MockIdentityProvider) Name() string {
|
||||
return "mock-idp"
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||
args := m.Called(loginID, password)
|
||||
if args.Get(0) == nil {
|
||||
@@ -44,27 +47,35 @@ func (m *MockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInf
|
||||
}
|
||||
return args.Get(0).(*domain.AuthInfo), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) UserExists(loginID string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -9,8 +10,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"baron-sso-backend/internal/service"
|
||||
)
|
||||
|
||||
func newOidcLoginTestApp(h *AuthHandler) *fiber.App {
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -508,6 +511,71 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
}
|
||||
|
||||
// 1. Generate new secret
|
||||
newSecret, err := generateRandomSecret(20)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"})
|
||||
}
|
||||
|
||||
// 2. Get current client to preserve other fields
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// 3. Update Hydra
|
||||
current.ClientSecret = newSecret
|
||||
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// 4. Update Persistence (DB & Redis)
|
||||
if h.SecretRepo != nil {
|
||||
if err := h.SecretRepo.Upsert(c.Context(), clientID, newSecret); err != nil {
|
||||
// Log error but don't fail the request as Hydra is already updated
|
||||
fmt.Printf("failed to update secret in repo: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if h.Redis != nil {
|
||||
_ = h.Redis.Set("client_secret:"+clientID, newSecret, 0)
|
||||
}
|
||||
|
||||
// Return the new secret
|
||||
summary := h.mapClientSummary(*updated)
|
||||
summary.ClientSecret = newSecret
|
||||
|
||||
return c.JSON(clientDetailResponse{
|
||||
Client: summary,
|
||||
Endpoints: clientEndpoints{
|
||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||
Issuer: h.Hydra.PublicURL,
|
||||
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
|
||||
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
|
||||
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func generateRandomSecret(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Use Base64 URL encoding (no padding) to look like Hydra's native secrets
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||
status := "active"
|
||||
if client.Metadata != nil {
|
||||
|
||||
@@ -108,8 +108,6 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(req)
|
||||
}
|
||||
|
||||
|
||||
// --- Deprecated Tenant-based IdP Config Methods ---
|
||||
|
||||
// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant.
|
||||
@@ -158,4 +156,5 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error {
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(req)
|
||||
}
|
||||
|
||||
// TODO: Re-implement Update, Delete handlers for IdP Configs for Clients
|
||||
|
||||
@@ -3,8 +3,9 @@ package handler
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type RelyingPartyHandler struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ package middleware
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// RBACConfig defines the configuration for RBAC middleware
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
||||
@@ -50,4 +50,3 @@ func (r *userGroupRepository) ListByTenantID(ctx context.Context, tenantID strin
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ func (r *userRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.
|
||||
return users, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&users).Error; err != nil {
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type FederationService struct {
|
||||
@@ -80,7 +81,6 @@ func (s *FederationService) HandleOIDCCallback(ctx context.Context, code, state
|
||||
return "http://localhost:3000/login?login_successful=true", nil // Placeholder
|
||||
}
|
||||
|
||||
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
|
||||
@@ -176,4 +176,3 @@ func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *doma
|
||||
}
|
||||
return rp
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
rp, err := svc.Create(context.Background(), tenantID, inputClient)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
@@ -200,7 +199,6 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
rp, hc, err := svc.Get(context.Background(), clientID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
@@ -233,7 +231,6 @@ func TestRelyingPartyService_Update_Success(t *testing.T) {
|
||||
|
||||
updateReq := domain.HydraClient{ClientName: "New Name"}
|
||||
rp, err := svc.Update(context.Background(), clientID, updateReq)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
@@ -272,7 +269,6 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
|
||||
|
||||
svc := NewRelyingPartyService(hydraSvc, mockKeto)
|
||||
err := svc.Delete(context.Background(), clientID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
BIN
backend/server
BIN
backend/server
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { AlertCircle, Copy, Eye, EyeOff, Link2, Shield, Workflow, Save } from "lucide-react";
|
||||
import { AlertCircle, Copy, Eye, EyeOff, Link2, Shield, Workflow, Save, RefreshCw } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "../../components/ui/table";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { fetchClient, updateClient } from "../../lib/devApi";
|
||||
import { fetchClient, updateClient, rotateClientSecret } from "../../lib/devApi";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { CopyButton } from "../../components/ui/copy-button";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
@@ -57,6 +57,24 @@ function ClientDetailsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: () => rotateClientSecret(clientId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||
toast("Client Secret이 재발급되었습니다.");
|
||||
setShowSecret(true); // 재발급 후 바로 보여줌
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(`재발급 실패: ${(err as Error).message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleRotateSecret = () => {
|
||||
if (window.confirm("경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?")) {
|
||||
rotateMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
return <div className="p-8 text-center">Client ID가 필요합니다.</div>;
|
||||
}
|
||||
@@ -176,14 +194,20 @@ function ClientDetailsPage() {
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleRotateSecret}
|
||||
disabled={rotateMutation.isPending}
|
||||
title="비밀키 재발급 (Rotate)"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", rotateMutation.isPending && "animate-spin")} />
|
||||
</Button>
|
||||
<CopyButton
|
||||
value={clientSecret}
|
||||
disabled={!showSecret && clientSecret === "SECRET_NOT_AVAILABLE"}
|
||||
onCopy={() => toast("Client Secret이 복사되었습니다.")}
|
||||
/>
|
||||
<Button variant="outline" size="icon" className="border-amber-500/50 text-amber-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,9 +130,6 @@ function ClientsPage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button variant="outline" size="sm">
|
||||
비밀키 재발행
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-lg shadow-primary/30"
|
||||
@@ -196,9 +193,6 @@ function ClientsPage() {
|
||||
클라이언트 목록
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Button variant="outline" size="sm">
|
||||
비밀키 재발행
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||
<Plus className="h-4 w-4" />새 클라이언트
|
||||
</Button>
|
||||
|
||||
@@ -136,6 +136,13 @@ export async function updateClient(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function rotateClientSecret(clientId: string) {
|
||||
const { data } = await apiClient.post<ClientDetailResponse>(
|
||||
`/dev/clients/${clientId}/secret/rotate`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteClient(clientId: string) {
|
||||
await apiClient.delete(`/dev/clients/${clientId}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user