1
0
forked from baron/baron-sso

Merge commit '1406c20959678870fe01564147613b24806697a2'

This commit is contained in:
2026-02-03 14:27:21 +09:00
25 changed files with 4683 additions and 277 deletions

View File

@@ -159,6 +159,20 @@ func main() {
slog.Info("✅ Connected to ClickHouse")
}
var oathkeeperRepo domain.OathkeeperLogRepository
oryCHHost := getEnv("ORY_CLICKHOUSE_HOST", "ory_clickhouse")
oryCHPort, _ := strconv.Atoi(getEnv("ORY_CLICKHOUSE_PORT_NATIVE", "9000"))
oryCHUser := getEnv("ORY_CLICKHOUSE_USER", "ory")
oryCHPass := getEnv("ORY_CLICKHOUSE_PASSWORD", "orypass")
oryCHDB := getEnv("ORY_CLICKHOUSE_DB", "ory")
if repo, err := repository.NewOathkeeperClickHouseRepository(oryCHHost, oryCHPort, oryCHUser, oryCHPass, oryCHDB); err != nil {
slog.Warn("Failed to connect to Ory ClickHouse. Oathkeeper logs will be skipped.", "error", err)
oathkeeperRepo = nil
} else {
oathkeeperRepo = repo
slog.Info("✅ Connected to Ory ClickHouse")
}
// PostgreSQL (Meta Store)
pgHost := getEnv("DB_HOST", "localhost")
pgPort := getEnv("DB_PORT", "5432")
@@ -244,7 +258,7 @@ func main() {
appEnv := getEnv("APP_ENV", "dev")
app := fiber.New(fiber.Config{
AppName: "Baron SSO Backend",
DisableStartupMessage: true, // Clean logs
DisableStartupMessage: true, // Clean logs
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
// Global Error Handler for Production Masking
ErrorHandler: func(c *fiber.Ctx, err error) error {
@@ -468,6 +482,7 @@ func main() {
auth.Post("/password/login", authHandler.PasswordLogin)
auth.Get("/consent", authHandler.GetConsentRequest)
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)

View File

@@ -0,0 +1,30 @@
package domain
import (
"context"
"time"
)
type OathkeeperAccessLog struct {
Timestamp time.Time
RequestID string
Method string
Path string
Status int
LatencyMs int
RP string
Action string
Target string
Subject string
ClientIP string
UserAgent string
Decision string
TraceID string
SpanID string
Raw string
}
type OathkeeperLogRepository interface {
FindPageBySubject(ctx context.Context, subject string, limit int, cursor *AuditCursor) ([]OathkeeperAccessLog, error)
Ping(ctx context.Context) error
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"baron-sso-backend/internal/service"
)
func newOidcLoginTestApp(h *AuthHandler) *fiber.App {
app := fiber.New()
app.Post("/api/v1/auth/oidc/login/accept", h.AcceptOidcLoginRequest)
return app
}
func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
var gotSubject string
var gotChallenge string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path != "/sessions/whoami" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
if r.Header.Get("X-Session-Token") != "" {
return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil
}
if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
}
return httpJSON(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "kratos-123",
"traits": map[string]interface{}{},
},
}), nil
case "hydra.test":
if r.URL.Path != "/oauth2/auth/requests/login/accept" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
gotChallenge = r.URL.Query().Get("login_challenge")
body, _ := io.ReadAll(r.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
if subject, ok := payload["subject"].(string); ok {
gotSubject = subject
}
return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil
default:
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
app := newOidcLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"login_challenge": "challenge-123",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Cookie", "ory_kratos_session=abc123")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got["redirectTo"] != "http://rp/cb" {
t.Fatalf("unexpected redirectTo: %v", got["redirectTo"])
}
if gotSubject != "kratos-123" {
t.Fatalf("unexpected subject: %v", gotSubject)
}
if gotChallenge != "challenge-123" {
t.Fatalf("unexpected login_challenge: %v", gotChallenge)
}
}
func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
var gotSubject string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path != "/sessions/whoami" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
if r.Header.Get("X-Session-Token") != "" {
return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil
}
if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
}
return httpJSON(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "kratos-456",
"traits": map[string]interface{}{},
},
}), nil
case "hydra.test":
if r.URL.Path != "/oauth2/auth/requests/login/accept" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
body, _ := io.ReadAll(r.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
if subject, ok := payload["subject"].(string); ok {
gotSubject = subject
}
return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil
default:
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
app := newOidcLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"login_challenge": "challenge-456",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer invalid-token")
req.Header.Set("Cookie", "ory_kratos_session=def456")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if gotSubject != "kratos-456" {
t.Fatalf("unexpected subject: %v", gotSubject)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func httpResponse(req *http.Request, status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: req,
}
}
func httpJSON(req *http.Request, status int, payload map[string]interface{}) *http.Response {
data, _ := json.Marshal(payload)
resp := httpResponse(req, status, string(data))
resp.Header.Set("Content-Type", "application/json")
return resp
}

View File

@@ -396,16 +396,26 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
items := make([]consentSummary, 0, len(sessions))
for _, session := range sessions {
client := session.Client
if client.ClientID == "" && session.ConsentRequest != nil {
client = session.ConsentRequest.Client
}
subject := session.Subject
if subject == "" && session.ConsentRequest != nil {
subject = session.ConsentRequest.Subject
}
authAt := ""
if session.AuthenticatedAt != nil {
authAt = session.AuthenticatedAt.Format(time.RFC3339)
} else if session.RequestedAt != nil {
authAt = session.RequestedAt.Format(time.RFC3339)
} else if session.HandledAt != nil {
authAt = session.HandledAt.Format(time.RFC3339)
}
items = append(items, consentSummary{
Subject: session.Subject,
ClientID: session.Client.ClientID,
ClientName: session.Client.ClientName,
Subject: subject,
ClientID: client.ClientID,
ClientName: client.ClientName,
GrantedScopes: session.GrantedScope,
AuthenticatedAt: authAt,
})

View File

@@ -0,0 +1,106 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"fmt"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)
type OathkeeperClickHouseRepository struct {
conn driver.Conn
}
func NewOathkeeperClickHouseRepository(host string, port int, user, password, db string) (*OathkeeperClickHouseRepository, error) {
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{fmt.Sprintf("%s:%d", host, port)},
Auth: clickhouse.Auth{
Database: db,
Username: user,
Password: password,
},
Debug: false,
})
if err != nil {
return nil, fmt.Errorf("failed to open ory clickhouse connection: %w", err)
}
if err := conn.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("failed to ping ory clickhouse: %w", err)
}
return &OathkeeperClickHouseRepository{conn: conn}, nil
}
func (r *OathkeeperClickHouseRepository) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) {
if limit <= 0 {
limit = 50
}
query := `
SELECT timestamp, request_id, method, path, status, latency_ms, rp, action, target, subject, client_ip, user_agent, decision, trace_id, span_id, raw
FROM oathkeeper_access_logs
`
args := make([]any, 0, 5)
if subject != "" {
query += `
WHERE subject = ?
`
args = append(args, subject)
if cursor != nil {
query += `
AND ((timestamp < ?) OR (timestamp = ? AND request_id < ?))
`
args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID)
}
} else if cursor != nil {
query += `
WHERE (timestamp < ?) OR (timestamp = ? AND request_id < ?)
`
args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID)
}
query += `
ORDER BY timestamp DESC, request_id DESC
LIMIT ?
`
args = append(args, limit)
rows, err := r.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query oathkeeper logs: %w", err)
}
defer rows.Close()
var logs []domain.OathkeeperAccessLog
for rows.Next() {
var log domain.OathkeeperAccessLog
if err := rows.Scan(
&log.Timestamp,
&log.RequestID,
&log.Method,
&log.Path,
&log.Status,
&log.LatencyMs,
&log.RP,
&log.Action,
&log.Target,
&log.Subject,
&log.ClientIP,
&log.UserAgent,
&log.Decision,
&log.TraceID,
&log.SpanID,
&log.Raw,
); err != nil {
return nil, fmt.Errorf("failed to scan oathkeeper log: %w", err)
}
logs = append(logs, log)
}
return logs, nil
}
func (r *OathkeeperClickHouseRepository) Ping(ctx context.Context) error {
if r == nil || r.conn == nil {
return fmt.Errorf("ory clickhouse connection is nil")
}
return r.conn.Ping(ctx)
}

View File

@@ -135,7 +135,8 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
SessionID: authInfo.SessionToken.ID,
},
Subject: authInfo.User.UserID,
// 내부 식별자는 Kratos identity ID로 통일합니다.
Subject: "",
}
if authInfo.RefreshToken != nil {
res.RefreshToken = &domain.Token{
@@ -204,7 +205,8 @@ func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error)
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
SessionID: authInfo.SessionToken.ID,
},
Subject: authInfo.User.UserID,
// 내부 식별자는 Kratos identity ID로 통일합니다.
Subject: "",
}
if authInfo.RefreshToken != nil {
res.RefreshToken = &domain.Token{

View File

@@ -46,13 +46,17 @@ type HydraConsentRequest struct {
}
type HydraConsentSession struct {
Subject string `json:"subject"`
GrantedScope []string `json:"granted_scope"`
GrantedAudience []string `json:"granted_audience,omitempty"`
Remember bool `json:"remember"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
Client HydraClient `json:"client"`
ConsentRequestID string `json:"consent_request_id,omitempty"`
Subject string `json:"subject,omitempty"`
GrantedScope []string `json:"grant_scope,omitempty"`
GrantedAudience []string `json:"grant_access_token_audience,omitempty"`
Remember bool `json:"remember"`
RememberFor int `json:"remember_for,omitempty"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
HandledAt *time.Time `json:"handled_at,omitempty"`
Client HydraClient `json:"client,omitempty"`
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
}
func NewHydraAdminService() *HydraAdminService {
@@ -267,13 +271,13 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body))
}
var sessions []HydraConsentSession
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
if err := json.Unmarshal(body, &sessions); err != nil {
return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err)
}
return sessions, nil
@@ -398,7 +402,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str
return &consentReq, nil
}
func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest) (*AcceptConsentRequestResponse, error) {
func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest, sessionClaims map[string]any) (*AcceptConsentRequestResponse, error) {
params := map[string]string{
"consent_challenge": challenge,
}
@@ -413,6 +417,12 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
"remember": true,
"remember_for": 3600,
}
if len(sessionClaims) > 0 {
payload["session"] = map[string]any{
"id_token": sessionClaims,
"access_token": sessionClaims,
}
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body))
@@ -443,7 +453,6 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
return &AcceptConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
}
func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge string, subject string) (*AcceptLoginRequestResponse, error) {
params := map[string]string{
"login_challenge": challenge,