diff --git a/adminfront/playwright-report/index.html b/adminfront/playwright-report/index.html
new file mode 100644
index 00000000..8371ce48
--- /dev/null
+++ b/adminfront/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/adminfront/test-results/.last-run.json b/adminfront/test-results/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/adminfront/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index d56b992e..2186c6c2 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -231,7 +231,7 @@ func main() {
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, userRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler()
- tenantHandler := handler.NewTenantHandler(db, tenantService)
+ tenantHandler := handler.NewTenantHandler(db)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService)
diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml
index c5e191a5..f1ecda2e 100644
--- a/backend/docs/openapi.yaml
+++ b/backend/docs/openapi.yaml
@@ -970,12 +970,18 @@ components:
properties:
token:
type: string
+ verifyOnly:
+ type: boolean
MagicLinkVerifyResponse:
type: object
properties:
token:
type: string
+ status:
+ type: string
+ pendingRef:
+ type: string
message:
type: string
diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go
index 34676c02..ff025ddb 100644
--- a/backend/internal/domain/auth_models.go
+++ b/backend/internal/domain/auth_models.go
@@ -26,7 +26,8 @@ type EnchantedLinkPollResponse struct {
}
type MagicLinkVerifyRequest struct {
- Token string `json:"token"`
+ Token string `json:"token"`
+ VerifyOnly bool `json:"verifyOnly,omitempty"`
}
type QRInitResponse struct {
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index e47d9268..aab70eeb 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -39,6 +39,12 @@ const (
prefixLoginCodeSmsTarget = "login_code_sms_target:"
prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
prefixLoginCodeShort = "login_code_short:"
+ prefixLoginCodeValue = "login_code_value:"
+ prefixLoginIDRaw = "login_id_raw:"
+ prefixLoginMethod = "login_method:"
+ prefixLoginFlow = "login_flow:"
+ prefixLoginStrategy = "login_strategy:"
+ prefixLoginApproverMeta = "login_approver_meta:"
prefixLoginCodeSmsOnly = "login_code_sms_only:"
prefixLoginCodeQrPending = "login_code_qr_pending:"
prefixLoginCodeQr = "login_code_qr:"
@@ -54,6 +60,10 @@ const (
statusPending = "pending"
statusSuccess = "success"
+ // Login Flows
+ loginFlowCode = "code"
+ loginFlowLink = "link"
+
// Durations
defaultExpiration = 5 * time.Minute
signupStateExpiration = 10 * time.Minute
@@ -682,7 +692,16 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
}
pendingRef := GenerateSecureToken(3)
- h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusPending,
+ "loginId": keyLoginID,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
+ intent := loginFlowLink
+ if req.CodeOnly {
+ intent = loginFlowCode
+ }
+ h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowCode, loginCodeExpiration)
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
if drySend {
_ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration)
@@ -724,11 +743,20 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
// Store in Redis
- h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration)
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusPending,
+ "loginId": lookupLoginID,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
if drySend {
_ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration)
}
+ intent := loginFlowLink
+ if req.CodeOnly {
+ intent = loginFlowCode
+ }
+ h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowLink, defaultExpiration)
// Generate Link
slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL)
@@ -821,6 +849,96 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
})
}
+ if data["status"] == "approved" {
+ loginID := data["loginId"]
+ if loginID == "" {
+ loginID = data["login_id"]
+ }
+ if loginID == "" {
+ slog.Warn("[Poll] Approved but missing loginId", "pendingRef", req.PendingRef)
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
+ }
+ if h.IdpProvider == nil {
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
+ }
+
+ loginStrategy := h.loadLoginStrategy(req.PendingRef)
+ if loginStrategy == "" {
+ loginStrategy = loginFlowLink
+ }
+
+ var authInfo *domain.AuthInfo
+ var err error
+ if loginStrategy == loginFlowCode {
+ code, _ := h.RedisService.Get(prefixLoginCodeValue + req.PendingRef)
+ code = normalizeLoginCode(code)
+ if code == "" {
+ slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", req.PendingRef)
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login code expired"})
+ }
+ flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
+ if flowID == "" {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
+ }
+ authInfo, err = h.IdpProvider.VerifyLoginCode(loginID, flowID, code)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ }
+ slog.Error("[Poll] IDP code verify failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"})
+ }
+ } else {
+ authInfo, err = h.IdpProvider.IssueSession(loginID)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ }
+ slog.Error("[Poll] IDP session issue failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
+ }
+ }
+ if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
+ }
+
+ c.Locals("login_id", loginID)
+ setSessionIDLocal(c, authInfo.SessionToken)
+ sessionID := extractSessionIDFromToken(authInfo.SessionToken)
+ if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" {
+ if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" {
+ sessionID = resolved
+ authInfo.SessionToken.SessionID = resolved
+ setSessionIDLocal(c, authInfo.SessionToken)
+ }
+ }
+
+ sessionData := map[string]string{
+ "status": statusSuccess,
+ "jwt": authInfo.SessionToken.JWT,
+ }
+ if sessionID != "" {
+ sessionData["session_id"] = sessionID
+ }
+ sessionDataJSON, _ := json.Marshal(sessionData)
+ h.RedisService.Set(prefixSession+req.PendingRef, string(sessionDataJSON), defaultExpiration)
+
+ h.writeLinkAuditLog(loginID, req.PendingRef, authInfo.SessionToken, c)
+ h.clearLoginMeta(req.PendingRef)
+ if loginStrategy == loginFlowCode {
+ h.RedisService.Delete(prefixLoginCode + loginID)
+ h.RedisService.Delete(prefixLoginCodePending + loginID)
+ h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
+ h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID)
+ h.RedisService.Delete(prefixLoginCodeValue + req.PendingRef)
+ }
+
+ return c.JSON(fiber.Map{
+ "sessionJwt": authInfo.SessionToken.JWT,
+ "status": "ok",
+ })
+ }
+
return c.JSON(fiber.Map{
"error": "authorization_pending",
"interval": int(minPollInterval.Seconds()),
@@ -851,6 +969,29 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef)
+ if req.VerifyOnly {
+ c.Locals("auth_timeline_skip", true)
+ if pendingRef == "" || loginID == "" {
+ slog.Warn("[Verify] Missing pendingRef/loginID for verify-only", "token", req.Token)
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
+ }
+
+ h.storeLoginApproverMeta(pendingRef, c, defaultExpiration)
+
+ // 승인 전용: 세션 발급 없이 승인 상태만 기록
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": "approved",
+ "loginId": loginID,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
+
+ return c.JSON(fiber.Map{
+ "status": "approved",
+ "pendingRef": pendingRef,
+ "message": "Login approved",
+ })
+ }
+
if h.IdpProvider == nil {
slog.Error("[Verify] IDP Provider is nil")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
@@ -899,6 +1040,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
LoginID string `json:"loginId"`
Code string `json:"code"`
PendingRef string `json:"pendingRef"`
+ VerifyOnly bool `json:"verifyOnly,omitempty"`
}
if err := c.BodyParser(&req); err != nil {
slog.Error("[LoginCode] Body parse error", "error", err)
@@ -925,6 +1067,43 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
}
+ if req.VerifyOnly {
+ c.Locals("auth_timeline_skip", true)
+ effectiveLoginID := lookupLoginID
+ if !strings.Contains(loginID, "@") {
+ if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookupLoginID); mapped != "" {
+ effectiveLoginID = mapped
+ }
+ }
+ pendingRef := strings.TrimSpace(req.PendingRef)
+ storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
+ if pendingRef == "" {
+ pendingRef = storedRef
+ } else if storedRef != "" && pendingRef != storedRef {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
+ }
+ if pendingRef == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
+ }
+ expectedCode, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef)
+ expectedCode = normalizeLoginCode(expectedCode)
+ inputCode := normalizeLoginCode(req.Code)
+ if expectedCode == "" || inputCode == "" || inputCode != expectedCode {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"})
+ }
+ h.storeLoginApproverMeta(pendingRef, c, loginCodeExpiration)
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": "approved",
+ "loginId": effectiveLoginID,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
+ return c.JSON(fiber.Map{
+ "status": "approved",
+ "pendingRef": pendingRef,
+ "message": "Login approved",
+ })
+ }
+
authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code)
if err != nil {
if errors.Is(err, domain.ErrNotSupported) {
@@ -977,6 +1156,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
var req struct {
ShortCode string `json:"shortCode"`
+ VerifyOnly bool `json:"verifyOnly,omitempty"`
}
if err := c.BodyParser(&req); err != nil {
slog.Error("[LoginShortCode] Body parse error", "error", err)
@@ -1001,6 +1181,29 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
}
+ if req.VerifyOnly {
+ c.Locals("auth_timeline_skip", true)
+ if payload.PendingRef == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
+ }
+ normalizedCode := normalizeLoginCode(payload.Code)
+ if normalizedCode != "" {
+ h.RedisService.Set(prefixLoginCodeValue+payload.PendingRef, normalizedCode, loginCodeExpiration)
+ }
+ h.storeLoginApproverMeta(payload.PendingRef, c, loginCodeExpiration)
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": "approved",
+ "loginId": payload.LoginID,
+ })
+ h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
+ h.RedisService.Delete(prefixLoginCodeShort + shortCode)
+ return c.JSON(fiber.Map{
+ "status": "approved",
+ "pendingRef": payload.PendingRef,
+ "message": "Login approved",
+ })
+ }
+
if h.IdpProvider == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
}
@@ -1805,6 +2008,20 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri
}
}
+ if loginCode != "" && label == "로그인" {
+ loginID := req.Recipient
+ if !strings.Contains(loginID, "@") {
+ loginID = normalizePhoneForLoginID(loginID)
+ }
+ pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
+ if pendingRef != "" {
+ normalizedCode := normalizeLoginCode(loginCode)
+ if normalizedCode != "" {
+ _ = h.RedisService.Set(prefixLoginCodeValue+pendingRef, normalizedCode, loginCodeExpiration)
+ }
+ }
+ }
+
if code == "" {
return subject, fmt.Sprintf("[Baron 통합로그인] %s 요청이 도착했습니다", label)
}
@@ -2228,6 +2445,39 @@ func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) {
return meta, true
}
+func (h *AuthHandler) storeLoginApproverMeta(pendingRef string, c *fiber.Ctx, ttl time.Duration) {
+ if h.RedisService == nil || pendingRef == "" || c == nil {
+ return
+ }
+ meta := qrMeta{
+ IPAddress: extractClientIPFromHeaders(c),
+ UserAgent: c.Get("User-Agent"),
+ }
+ raw, err := json.Marshal(meta)
+ if err != nil {
+ return
+ }
+ _ = h.RedisService.Set(prefixLoginApproverMeta+pendingRef, string(raw), ttl)
+}
+
+func (h *AuthHandler) loadLoginApproverMeta(pendingRef string) (qrMeta, bool) {
+ if h.RedisService == nil || pendingRef == "" {
+ return qrMeta{}, false
+ }
+ val, err := h.RedisService.Get(prefixLoginApproverMeta + pendingRef)
+ if err != nil || val == "" {
+ return qrMeta{}, false
+ }
+ var meta qrMeta
+ if err := json.Unmarshal([]byte(val), &meta); err != nil {
+ return qrMeta{}, false
+ }
+ if meta.IPAddress == "" && meta.UserAgent == "" {
+ return qrMeta{}, false
+ }
+ return meta, true
+}
+
func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) {
if h.RedisService == nil || pendingRef == "" || sessionID == "" {
return
@@ -2246,6 +2496,57 @@ func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string {
return strings.TrimSpace(val)
}
+func (h *AuthHandler) storeLoginMeta(pendingRef, loginID, rawMethod, flow, strategy string, ttl time.Duration) {
+ if h.RedisService == nil || pendingRef == "" {
+ return
+ }
+ method := resolveLoginMethod(rawMethod, loginID)
+ if method != "" {
+ _ = h.RedisService.Set(prefixLoginMethod+pendingRef, method, ttl)
+ }
+ if flow != "" {
+ _ = h.RedisService.Set(prefixLoginFlow+pendingRef, flow, ttl)
+ }
+ if strategy != "" {
+ _ = h.RedisService.Set(prefixLoginStrategy+pendingRef, strategy, ttl)
+ }
+ if strings.TrimSpace(loginID) != "" {
+ _ = h.RedisService.Set(prefixLoginIDRaw+pendingRef, loginID, ttl)
+ }
+}
+
+func (h *AuthHandler) loadLoginMeta(pendingRef string) (string, string, string, string) {
+ if h.RedisService == nil || pendingRef == "" {
+ return "", "", "", ""
+ }
+ method, _ := h.RedisService.Get(prefixLoginMethod + pendingRef)
+ flow, _ := h.RedisService.Get(prefixLoginFlow + pendingRef)
+ strategy, _ := h.RedisService.Get(prefixLoginStrategy + pendingRef)
+ rawLoginID, _ := h.RedisService.Get(prefixLoginIDRaw + pendingRef)
+ return strings.TrimSpace(method), strings.TrimSpace(flow), strings.TrimSpace(strategy), strings.TrimSpace(rawLoginID)
+}
+
+func (h *AuthHandler) loadLoginFlow(pendingRef string) string {
+ _, flow, _, _ := h.loadLoginMeta(pendingRef)
+ return flow
+}
+
+func (h *AuthHandler) loadLoginStrategy(pendingRef string) string {
+ _, _, strategy, _ := h.loadLoginMeta(pendingRef)
+ return strategy
+}
+
+func (h *AuthHandler) clearLoginMeta(pendingRef string) {
+ if h.RedisService == nil || pendingRef == "" {
+ return
+ }
+ _ = h.RedisService.Delete(prefixLoginMethod + pendingRef)
+ _ = h.RedisService.Delete(prefixLoginFlow + pendingRef)
+ _ = h.RedisService.Delete(prefixLoginStrategy + pendingRef)
+ _ = h.RedisService.Delete(prefixLoginIDRaw + pendingRef)
+ _ = h.RedisService.Delete(prefixLoginApproverMeta + pendingRef)
+}
+
func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) {
if h.AuditRepo == nil || pendingRef == "" {
return
@@ -2301,14 +2602,53 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken
meta.UserAgent = c.Get("User-Agent")
}
sessionID := extractSessionIDFromToken(sessionToken)
+ loginMethod, loginFlow, loginStrategy, rawLoginID := h.loadLoginMeta(pendingRef)
+ path := "/api/v1/auth/magic-link/verify"
+ authLabel := "링크"
+ if loginStrategy == loginFlowCode {
+ path = "/api/v1/auth/login/code/verify"
+ }
+ displayFlow := loginFlow
+ if displayFlow == "" {
+ displayFlow = loginStrategy
+ }
+ if displayFlow == loginFlowCode {
+ authLabel = "코드"
+ } else if displayFlow == loginFlowLink {
+ authLabel = "링크"
+ }
+ logLoginID := loginID
+ if rawLoginID != "" {
+ logLoginID = rawLoginID
+ }
details := map[string]any{
- "path": "/api/v1/auth/magic-link/verify",
- "login_id": loginID,
+ "path": path,
+ "login_id": logLoginID,
"pending_ref": pendingRef,
}
if sessionID != "" {
details["session_id"] = sessionID
}
+ if loginMethod != "" {
+ details["login_method"] = loginMethod
+ }
+ if loginFlow != "" {
+ details["login_flow"] = loginFlow
+ }
+ if loginStrategy != "" {
+ details["login_strategy"] = loginStrategy
+ }
+ if rawLoginID != "" && rawLoginID != loginID {
+ details["login_id_effective"] = loginID
+ }
+ if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok {
+ if approverMeta.IPAddress != "" {
+ details["approved_ip"] = approverMeta.IPAddress
+ }
+ if approverMeta.UserAgent != "" {
+ details["approved_user_agent"] = approverMeta.UserAgent
+ }
+ }
detailsJSON, _ := json.Marshal(details)
log := &domain.AuditLog{
@@ -2316,12 +2656,12 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken
Timestamp: time.Now(),
UserID: "",
SessionID: sessionID,
- EventType: "POST /api/v1/auth/magic-link/verify",
+ EventType: fmt.Sprintf("POST %s", path),
Status: "success",
IPAddress: meta.IPAddress,
UserAgent: meta.UserAgent,
Details: string(detailsJSON),
- AuthMethod: "링크",
+ AuthMethod: authLabel,
}
_ = h.AuditRepo.Create(log)
}
@@ -2357,6 +2697,16 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
limit = 100
}
+ cursorRaw := strings.TrimSpace(c.Query("cursor"))
+ var cursor *domain.AuditCursor
+ if cursorRaw != "" {
+ var err error
+ cursor, err = parseAuditCursor(cursorRaw)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid cursor"})
+ }
+ }
+
profile, err := h.resolveCurrentProfile(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
@@ -2371,38 +2721,76 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
fetchLimit = 500
}
- logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, nil)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
- }
-
items := make([]domain.AuditLog, 0, limit)
- for _, log := range logs {
- if !isAuthEventType(log.EventType) {
- continue
+ nextCursor := ""
+ currentCursor := cursor
+ const maxBatches = 10
+ for batch := 0; batch < maxBatches && len(items) < limit; batch++ {
+ logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
}
- if !matchesAuthTimelineUser(log, profile, candidates) {
- continue
+ if len(logs) == 0 {
+ nextCursor = ""
+ break
}
- if log.UserID == "" {
- log.UserID = profile.ID
+
+ var lastScanned *domain.AuditLog
+ for i := range logs {
+ log := logs[i]
+ lastScanned = &log
+ if !isAuthEventType(log.EventType) {
+ continue
+ }
+ if !matchesAuthTimelineUser(log, profile, candidates) {
+ continue
+ }
+ if shouldSkipAuthTimeline(log) {
+ continue
+ }
+ if log.UserID == "" {
+ log.UserID = profile.ID
+ }
+ log.AuthMethod = deriveAuthMethod(log)
+ if log.AuthMethod == "" {
+ continue
+ }
+ if log.SessionID == "" {
+ log.SessionID = extractSessionIDFromAuditDetails(log.Details)
+ }
+ items = append(items, log)
+ if len(items) >= limit {
+ nextCursor = encodeAuditCursor(log)
+ break
+ }
}
- log.AuthMethod = deriveAuthMethod(log)
- if log.AuthMethod == "" {
- continue
- }
- if log.SessionID == "" {
- log.SessionID = extractSessionIDFromAuditDetails(log.Details)
- }
- items = append(items, log)
+
if len(items) >= limit {
break
}
+
+ if len(logs) < fetchLimit {
+ nextCursor = ""
+ break
+ }
+
+ if lastScanned == nil {
+ nextCursor = ""
+ break
+ }
+
+ currentCursor = &domain.AuditCursor{
+ Timestamp: lastScanned.Timestamp,
+ EventID: lastScanned.EventID,
+ }
+ nextCursor = encodeAuditCursor(*lastScanned)
}
return c.JSON(fiber.Map{
- "items": items,
- "limit": limit,
+ "items": items,
+ "limit": limit,
+ "cursor": cursorRaw,
+ "next_cursor": nextCursor,
})
}
@@ -2627,6 +3015,54 @@ func extractRequestBody(details map[string]any) map[string]any {
return body
}
+func shouldSkipAuthTimeline(log domain.AuditLog) bool {
+ details, _ := parseAuditDetails(log.Details)
+ path := strings.ToLower(extractAuditPath(log))
+ if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") {
+ return true
+ }
+ if path != "" && (strings.Contains(path, "/api/v1/auth/magic-link/verify") ||
+ strings.Contains(path, "/api/v1/auth/login/code/verify")) {
+ sessionID := log.SessionID
+ if sessionID == "" {
+ sessionID = extractSessionIDFromAuditDetails(log.Details)
+ }
+ if sessionID == "" {
+ return true
+ }
+ }
+ if details != nil {
+ if raw, ok := details["auth_timeline_skip"]; ok {
+ switch value := raw.(type) {
+ case bool:
+ if value {
+ return true
+ }
+ case string:
+ if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
+ return true
+ }
+ }
+ }
+ }
+ requestBody := extractRequestBody(details)
+ if requestBody != nil {
+ if raw, ok := requestBody["verifyOnly"]; ok {
+ switch value := raw.(type) {
+ case bool:
+ if value {
+ return true
+ }
+ case string:
+ if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
func loginIDKind(loginID string) string {
normalized := strings.TrimSpace(loginID)
if normalized == "" {
@@ -2638,6 +3074,31 @@ func loginIDKind(loginID string) string {
return "phone"
}
+func resolveLoginMethod(rawMethod, loginID string) string {
+ method := strings.ToLower(strings.TrimSpace(rawMethod))
+ if method == "sms" || method == "email" {
+ return method
+ }
+ if strings.TrimSpace(loginID) == "" {
+ return ""
+ }
+ if strings.Contains(loginID, "@") {
+ return "email"
+ }
+ return "sms"
+}
+
+func loginMethodLabel(method string) string {
+ switch strings.ToLower(strings.TrimSpace(method)) {
+ case "sms":
+ return "SMS"
+ case "email":
+ return "Email"
+ default:
+ return ""
+ }
+}
+
func deriveAuthMethod(log domain.AuditLog) string {
path := strings.ToLower(extractAuditPath(log))
if path == "" {
@@ -2648,6 +3109,57 @@ func deriveAuthMethod(log domain.AuditLog) string {
kind := loginIDKind(loginID)
details, _ := parseAuditDetails(log.Details)
requestBody := extractRequestBody(details)
+ if details != nil {
+ if raw, ok := details["auth_timeline_skip"]; ok {
+ switch value := raw.(type) {
+ case bool:
+ if value {
+ return ""
+ }
+ case string:
+ if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
+ return ""
+ }
+ }
+ }
+ }
+ if requestBody != nil {
+ if raw, ok := requestBody["verifyOnly"]; ok {
+ switch value := raw.(type) {
+ case bool:
+ if value {
+ return ""
+ }
+ case string:
+ if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
+ return ""
+ }
+ }
+ }
+ }
+ if path != "" && (strings.Contains(path, "/api/v1/auth/qr/init") ||
+ strings.Contains(path, "/api/v1/auth/qr/poll") ||
+ strings.Contains(path, "/api/v1/auth/qr/approve")) {
+ return "QR"
+ }
+ if details != nil {
+ rawFlow, _ := details["login_flow"].(string)
+ rawMethod, _ := details["login_method"].(string)
+ flow := strings.ToLower(strings.TrimSpace(rawFlow))
+ methodLabel := loginMethodLabel(rawMethod)
+ switch flow {
+ case loginFlowCode:
+ if methodLabel != "" {
+ return fmt.Sprintf("코드(%s)", methodLabel)
+ }
+ return "코드"
+ case loginFlowLink:
+ if methodLabel != "" {
+ return fmt.Sprintf("링크(%s)", methodLabel)
+ }
+ return "링크"
+ }
+ }
switch {
case strings.Contains(path, "/api/v1/auth/password/login"):
diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go
index 1f9ba05a..19e72276 100644
--- a/backend/internal/handler/dev_handler.go
+++ b/backend/internal/handler/dev_handler.go
@@ -12,11 +12,13 @@ import (
type DevHandler struct {
Hydra *service.HydraAdminService
+ Redis *service.RedisService
}
-func NewDevHandler() *DevHandler {
+func NewDevHandler(redis *service.RedisService) *DevHandler {
return &DevHandler{
Hydra: service.NewHydraAdminService(),
+ Redis: redis,
}
}
@@ -102,7 +104,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
- items = append(items, mapClientSummary(client))
+ items = append(items, h.mapClientSummary(client))
}
return c.JSON(clientListResponse{
@@ -126,7 +128,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
- summary := mapClientSummary(*client)
+ summary := h.mapClientSummary(*client)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -165,7 +167,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
- summary := mapClientSummary(*updated)
+ summary := h.mapClientSummary(*updated)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -251,9 +253,14 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
}
created.Metadata["client_secret"] = created.ClientSecret
_, _ = h.Hydra.UpdateClient(c.Context(), created.ClientID, *created)
+
+ // Also store in Redis if available
+ if h.Redis != nil {
+ _ = h.Redis.Set("client_secret:"+created.ClientID, created.ClientSecret, 0)
+ }
}
- summary := mapClientSummary(*created)
+ summary := h.mapClientSummary(*created)
return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -341,7 +348,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
- summary := mapClientSummary(*updatedClient)
+ summary := h.mapClientSummary(*updatedClient)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -367,6 +374,11 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ // Clean up Redis
+ if h.Redis != nil {
+ _ = h.Redis.Delete("client_secret:" + clientID)
+ }
+
return c.SendStatus(fiber.StatusNoContent)
}
@@ -416,7 +428,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
-func mapClientSummary(client service.HydraClient) clientSummary {
+func (h *DevHandler) mapClientSummary(client service.HydraClient) clientSummary {
status := "active"
if client.Metadata != nil {
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
@@ -436,6 +448,20 @@ func mapClientSummary(client service.HydraClient) clientSummary {
scopes := strings.Fields(client.Scope)
+ clientSecret := client.ClientSecret
+ // 1. Check Metadata (Legacy/Fallback)
+ if clientSecret == "" && client.Metadata != nil {
+ if val, ok := client.Metadata["client_secret"].(string); ok {
+ clientSecret = val
+ }
+ }
+ // 2. Check Redis (New)
+ if clientSecret == "" && h.Redis != nil {
+ if val, err := h.Redis.Get("client_secret:" + client.ClientID); err == nil && val != "" {
+ clientSecret = val
+ }
+ }
+
return clientSummary{
ID: client.ClientID,
Name: name,
@@ -443,7 +469,7 @@ func mapClientSummary(client service.HydraClient) clientSummary {
Status: status,
RedirectURIs: client.RedirectURIs,
Scopes: scopes,
- ClientSecret: client.ClientSecret,
+ ClientSecret: clientSecret,
Metadata: client.Metadata,
}
}
diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go
index a88982c0..b42e5756 100644
--- a/backend/internal/middleware/audit_middleware.go
+++ b/backend/internal/middleware/audit_middleware.go
@@ -144,6 +144,9 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
"tenant_id": tenantID,
"request_body": maskedBody,
}
+ if skipTimeline, ok := c.Locals("auth_timeline_skip").(bool); ok && skipTimeline {
+ details["auth_timeline_skip"] = true
+ }
if sessionID != "" {
details["session_id"] = sessionID
}
diff --git a/compose.infra.yaml b/compose.infra.yaml
index 5e88b917..368b8911 100644
--- a/compose.infra.yaml
+++ b/compose.infra.yaml
@@ -48,6 +48,24 @@ services:
networks:
- baron_net
+ gateway:
+ build:
+ context: ./gateway
+ dockerfile: Dockerfile
+ container_name: baron_gateway
+ restart: always
+ ports:
+ - "${USERFRONT_PORT:-5000}:5000"
+ networks:
+ - baron_net
+ - public_net
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
volumes:
postgres_data:
clickhouse_data:
@@ -58,3 +76,7 @@ networks:
name: baron_net
external: true
driver: bridge
+ public_net:
+ name: public_net
+ external: true
+
diff --git a/compose.ory.yaml b/compose.ory.yaml
index 878aa29f..2b97f4c9 100644
--- a/compose.ory.yaml
+++ b/compose.ory.yaml
@@ -160,6 +160,7 @@ services:
- "4457:4455" # Proxy
environment:
- APP_ENV=${APP_ENV:-development}
+ - LOG_LEVEL=debug
volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
- ./docker/ory/oathkeeper/logs:/var/log/oathkeeper
diff --git a/devfront/playwright-report/index.html b/devfront/playwright-report/index.html
new file mode 100644
index 00000000..c98a4a9a
--- /dev/null
+++ b/devfront/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx
index b30cb04c..0bbba406 100644
--- a/devfront/src/app/routes.tsx
+++ b/devfront/src/app/routes.tsx
@@ -4,7 +4,6 @@ import ClientConsentsPage from "../features/clients/ClientConsentsPage";
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
import ClientsPage from "../features/clients/ClientsPage";
-import { ClientFederationPage } from "../features/clients/routes/ClientFederationPage";
export const router = createBrowserRouter(
[
@@ -18,7 +17,6 @@ export const router = createBrowserRouter(
{ path: "clients/:id", element: },
{ path: "clients/:id/consents", element: },
{ path: "clients/:id/settings", element: },
- { path: "clients/:id/federation", element: },
],
},
],
diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx
index 36aa843d..d21349db 100644
--- a/devfront/src/features/clients/ClientConsentsPage.tsx
+++ b/devfront/src/features/clients/ClientConsentsPage.tsx
@@ -120,12 +120,6 @@ function ClientConsentsPage() {
>
Settings
-
- Federation
-
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx
index 96baf9f8..dcecfbae 100644
--- a/devfront/src/features/clients/ClientDetailsPage.tsx
+++ b/devfront/src/features/clients/ClientDetailsPage.tsx
@@ -130,12 +130,6 @@ function ClientDetailsPage() {
>
Settings
-
- Federation
-
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index 8495d475..d3be16b7 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -154,7 +154,6 @@ function ClientGeneralPage() {
Connection
Consent & Users
Settings
- Federation
>
)}
diff --git a/devfront/test-results/.last-run.json b/devfront/test-results/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/devfront/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 09e9897a..981831c9 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -82,28 +82,6 @@ services:
networks:
- baron_net
- gateway:
- build:
- context: ./gateway
- dockerfile: Dockerfile
- container_name: baron_gateway
- restart: always
- ports:
- - "${USERFRONT_PORT:-5000}:5000"
- networks:
- - baron_net
- - public_net
- depends_on:
- backend:
- condition: service_healthy
- userfront:
- condition: service_healthy
- healthcheck:
- test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
- interval: 10s
- timeout: 5s
- retries: 3
- start_period: 10s
userfront:
build:
context: ./userfront
diff --git a/docker/ory/oathkeeper/entrypoint.sh b/docker/ory/oathkeeper/entrypoint.sh
index 506af8cd..fe15ce6a 100755
--- a/docker/ory/oathkeeper/entrypoint.sh
+++ b/docker/ory/oathkeeper/entrypoint.sh
@@ -24,7 +24,12 @@ if [ ! -f "$RULES_FILE" ]; then
echo "[oathkeeper] rules file not found: $RULES_FILE"
exit 1
fi
-cp "$RULES_FILE" "$RULES_ACTIVE"
+
+# Remove existing active rules file to prevent overwrite issues (File exists/Permission denied)
+if [ -f "$RULES_ACTIVE" ]; then
+ rm -f "$RULES_ACTIVE" || echo "[oathkeeper] Warning: Failed to remove existing rules.active.json"
+fi
+cp -f "$RULES_FILE" "$RULES_ACTIVE" || echo "[oathkeeper] Warning: Failed to copy rules file. Using existing if present."
LOG_DIR="/var/log/oathkeeper"
LOG_FILE="${LOG_DIR}/access.log"
diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml
old mode 100644
new mode 100755
diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json
old mode 100644
new mode 100755
index e02c3382..e65e9d51
--- a/docker/ory/oathkeeper/rules.active.json
+++ b/docker/ory/oathkeeper/rules.active.json
@@ -1,92 +1,114 @@
[
{
"id": "public-health",
- "description": "공개 헬스체크",
+ "description": "공개 헬스체크 (STAGE 도메인)",
"match": {
- "url": "http://<.*>/health",
+ "url": "<.*>://sso-test.hmac.kr/health",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
- "description": "CORS preflight",
+ "description": "CORS preflight (STAGE 도메인)",
"match": {
- "url": "http://<.*>/api/v1/<.*>",
+ "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
- "description": "인증/회원가입 등 공개 엔드포인트",
+ "description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)",
"match": {
- "url": "http://<.*>/api/v1/auth/<.*>",
+ "url": "<.*>://sso-test.hmac.kr/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
- "url": "http://<.*>/api/v1/<.*>",
+ "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
- "url": "http://<.*>/api/v1/<.*>",
+ "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-well-known",
+ "description": "Hydra OIDC Discovery & JWKS",
+ "match": {
+ "url": "<.*>://sso-test.hmac.kr/.well-known/<.*>",
+ "methods": ["GET", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-oauth2",
+ "description": "Hydra OAuth2 Endpoints",
+ "match": {
+ "url": "<.*>://sso-test.hmac.kr/oauth2/<.*>",
+ "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-userinfo",
+ "description": "Hydra Userinfo",
+ "match": {
+ "url": "<.*>://sso-test.hmac.kr/userinfo",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
}
-]
+]
\ No newline at end of file
diff --git a/docker/ory/oathkeeper/rules.draft.json b/docker/ory/oathkeeper/rules.draft.json
old mode 100644
new mode 100755
index 835689ec..95c387f5
--- a/docker/ory/oathkeeper/rules.draft.json
+++ b/docker/ory/oathkeeper/rules.draft.json
@@ -9,13 +9,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
@@ -27,13 +23,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
@@ -45,13 +37,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
@@ -63,13 +51,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "kratos-public",
@@ -82,13 +66,9 @@
"url": "http://kratos:4433",
"strip_path": "/kratos"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-public",
@@ -101,12 +81,8 @@
"url": "http://hydra:4444",
"strip_path": "/hydra"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
}
]
diff --git a/docker/ory/oathkeeper/rules.json b/docker/ory/oathkeeper/rules.json
old mode 100644
new mode 100755
index e02c3382..921b8366
--- a/docker/ory/oathkeeper/rules.json
+++ b/docker/ory/oathkeeper/rules.json
@@ -3,90 +3,112 @@
"id": "public-health",
"description": "공개 헬스체크",
"match": {
- "url": "http://<.*>/health",
+ "url": "<.*>://<.*>/health",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
"description": "CORS preflight",
"match": {
- "url": "http://<.*>/api/v1/<.*>",
+ "url": "<.*>://<.*>/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트",
"match": {
- "url": "http://<.*>/api/v1/auth/<.*>",
+ "url": "<.*>://<.*>/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
- "url": "http://<.*>/api/v1/<.*>",
+ "url": "<.*>://<.*>/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
- "url": "http://<.*>/api/v1/<.*>",
+ "url": "<.*>://<.*>/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-well-known",
+ "description": "Hydra OIDC Discovery & JWKS",
+ "match": {
+ "url": "<.*>://<.*>/.well-known/<.*>",
+ "methods": ["GET", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-oauth2",
+ "description": "Hydra OAuth2 Endpoints",
+ "match": {
+ "url": "<.*>://<.*>/oauth2/<.*>",
+ "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-userinfo",
+ "description": "Hydra Userinfo",
+ "match": {
+ "url": "<.*>://<.*>/userinfo",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
}
]
diff --git a/docker/ory/oathkeeper/rules.prod.json b/docker/ory/oathkeeper/rules.prod.json
old mode 100644
new mode 100755
index d6537966..ef5524d5
--- a/docker/ory/oathkeeper/rules.prod.json
+++ b/docker/ory/oathkeeper/rules.prod.json
@@ -9,13 +9,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
@@ -27,13 +23,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
@@ -45,13 +37,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
@@ -63,13 +51,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
@@ -81,12 +65,8 @@
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
}
]
diff --git a/docker/ory/oathkeeper/rules.stage.json b/docker/ory/oathkeeper/rules.stage.json
old mode 100644
new mode 100755
index 3dabd9a0..e65e9d51
--- a/docker/ory/oathkeeper/rules.stage.json
+++ b/docker/ory/oathkeeper/rules.stage.json
@@ -3,90 +3,112 @@
"id": "public-health",
"description": "공개 헬스체크 (STAGE 도메인)",
"match": {
- "url": "https://sso.hmac.kr/health",
+ "url": "<.*>://sso-test.hmac.kr/health",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
"description": "CORS preflight (STAGE 도메인)",
"match": {
- "url": "https://sso.hmac.kr/api/v1/<.*>",
+ "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)",
"match": {
- "url": "https://sso.hmac.kr/api/v1/auth/<.*>",
+ "url": "<.*>://sso-test.hmac.kr/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "noop" }
- ],
+ "authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
- "url": "https://sso.hmac.kr/api/v1/<.*>",
+ "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
- "url": "https://sso.hmac.kr/api/v1/<.*>",
+ "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
- "authenticators": [
- { "handler": "cookie_session" }
- ],
+ "authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
- "mutators": [
- { "handler": "noop" }
- ]
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-well-known",
+ "description": "Hydra OIDC Discovery & JWKS",
+ "match": {
+ "url": "<.*>://sso-test.hmac.kr/.well-known/<.*>",
+ "methods": ["GET", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-oauth2",
+ "description": "Hydra OAuth2 Endpoints",
+ "match": {
+ "url": "<.*>://sso-test.hmac.kr/oauth2/<.*>",
+ "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
+ },
+ {
+ "id": "hydra-userinfo",
+ "description": "Hydra Userinfo",
+ "match": {
+ "url": "<.*>://sso-test.hmac.kr/userinfo",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444"
+ },
+ "authenticators": [{ "handler": "noop" }],
+ "authorizer": { "handler": "allow" },
+ "mutators": [{ "handler": "noop" }]
}
-]
+]
\ No newline at end of file
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index f621b3cb..227b1214 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -1,22 +1,49 @@
# AGENTS 가이드 (Baron SSO)
## 목적
-- Inbound Auth/Launcher와 관리(Admin) 기능을 하나의 백엔드에서 운영하되, 네임스페이스·도메인·권한으로 강하게 분리한다.
-- 사용자 플로우(가입/로그인)와 관리 플로우(Descope Management Key 사용)를 명확히 구분해 보안 사고면을 축소한다.
+- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
+- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리
+- 네트워크/보안 경계를 문서화해 회귀/설정 오류를 방지
-## 현재 원칙
-- **경계 분리**: `/admin/*` + admin 서브도메인에서만 관리 기능 노출. 일반 사용자 번들과 관리자 번들(또는 라우트)을 분리.
-- **관리 키 취급**: Descope Management Key는 서버 내부에서만 사용, 비동기 잡/관리 API에서 래핑. 모든 관리 액션을 감사 로그/알람/레이트리밋으로 보호.
-- **권한/가드**: role/permission 기반 접근 제어. 관리자 세션 TTL은 짧게, step-up MFA 고려.
+## 시스템 요약
+- **Backend**: Command 단일 진입점, 감사 로그를 ClickHouse에 적재
+- **Ory Stack**: Kratos/Hydra/Keto/Oathkeeper (인증/토큰/정책)
+- **Front**: UserFront(Flutter)-사용자 접점, AdminFront/DevFront(React)-내부 관리도구
+- **원칙**: Front는 Backend API를 통해서만 IDP 기능을 호출
-## 인증 플로우 핵심
-- **최초 회원가입**: SMS 인증(Enchanted Link/OTP) 필수 → 인증 성공 후 계정 생성 및 초기 세션 발급.
-- **재로그인 분기 (앱 세션 보유 + 사용자 선택)**:
- - 앱 로그인 상태 + 사용자가 “앱 승인” 선택: 앱을 MFA/IDPW 대체 수단으로 사용(푸시/딥링크 승인) → 승인 시 웹 세션 발급.
- - 앱 세션이 없거나, 사용자가 이번 로그인에서 앱을 사용하지 않기로 선택: SMS 또는 이메일/비밀번호 경로로 진행.
-- **세션 TTL**: 앱 기반 세션 유지시간을 `APP_SESSION_TTL_MINUTES` 환경 변수로 관리(기본 예: 30분).
+## 네트워크/보안 경계
+- `ory-net`: Ory 내부 통신 전용 네트워크
+- `baron_net`: App(backend/userfront/adminfront/devfront) 네트워크
+- `public_net`: Oathkeeper, userfront 외부 공개. Gateway를 이용해 Proxy 분기
-## 작업 시 체크리스트
-- 관리 기능 개발 시 admin 네임스페이스, 권한 체크, 감사 로깅, 레이트리밋을 기본 포함.
-- 인증/로그인 변경 시 “폴백은 사용자 선택일 때만” 규칙을 준수하고, UI에도 선택 흐름을 노출.
-- 새 설정/비밀값은 .env.sample에 반영하고 서버에서만 소비하게 설계한다.
+핵심 규칙:
+- **Ory Admin 포트는 외부 노출 금지** (Backend만 `ory-net`을 통해 접근)
+- **UserFront는 Oathkeeper 뒤에 있지 않음**
+- **모든 Front(User/Admin/Dev)는 Ory Admin 엔드포인트에 직접 접근하지 않음**
+
+## 인증/세션 핵심
+- `IDP_PROVIDER` Ory 전용 저장 구조지만 향후 마이그레이션으로 추가 스택 지원할 수 있음
+- `sessionJwt`는 **JWT가 아닐 수 있음** (Kratos session token은 opaque 가능)
+- OIDC Consent 플로우는 UserFront의 `/consent` 경로에서 처리
+- 토큰/쿠키 전달 방식 변경 시 `docs/auth-flow.md`를 반드시 갱신
+
+## 작업 체크리스트
+- 인증/로그인 변경 시
+ - `docs/auth-flow.md` 업데이트
+ - 세션/쿠키/Authorization 전달 방식 영향도 점검
+ - UserFront가 Ory/Oathkeeper 직접 호출하지 않도록 확인
+
+- Ory 설정 변경 시
+ - `compose.ory.yaml`, `docker/ory/*` 변경 범위 명시
+ - `ory-net`/`public_net` 경계 유지 여부 확인
+
+- 환경 변수 추가/변경 시
+ - `.env.sample` 반영
+ - 문서/가이드 갱신
+
+- 배포/운영 변경 시
+ - `Makefile`/compose 실행 절차 영향 확인
+ - 최소 Smoke 테스트 수행
+
+## 테스트 참고
+- 테스트 계획 및 수동 실행 기준은 `docs/test-plan.md`를 따른다.
diff --git a/docs/auth-flow.md b/docs/auth-flow.md
index 45ba01e0..d5bab1de 100644
--- a/docs/auth-flow.md
+++ b/docs/auth-flow.md
@@ -11,8 +11,8 @@
| 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 |
|---|---|---|---|
| ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | IDP 추상화 사용 (Ory/Descope) |
-| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | 링크 클릭 시 `POST /api/v1/auth/magic-link/verify` 호출 |
-| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | Polling 세션 갱신용 |
+| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | Ory는 `/api/v1/auth/login/code/verify`/`verify-short`(verifyOnly) 사용 |
+| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | 비-Ory 경로(verifyOnly 가능) |
| SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 현재는 내부 토큰(placeholder). Kratos 세션 교환 필요 |
| QR 로그인 | `POST /api/v1/auth/qr/init` → `POST /api/v1/auth/qr/poll` | `sessionJwt` | 모바일 승인: `POST /api/v1/auth/qr/approve` |
@@ -27,8 +27,10 @@
### 2.2 Enchanted Link (Email/SMS)
1. `POST /api/v1/auth/enchanted-link/init` → `pendingRef` 수신
2. `POST /api/v1/auth/enchanted-link/poll`로 폴링
-3. 사용자가 링크 클릭하면 UserFront가 `POST /api/v1/auth/magic-link/verify` 호출
-4. Polling 응답에서 `sessionJwt` 수신
+3. 사용자가 링크 클릭하면 UserFront가 아래 중 하나 호출
+ - Ory: `POST /api/v1/auth/login/code/verify` 또는 `/api/v1/auth/login/code/verify-short` (verifyOnly=1)
+ - 비-Ory: `POST /api/v1/auth/magic-link/verify` (verifyOnly=1)
+4. Polling 응답에서 `sessionJwt` 수신 (승인 후 Backend에서 세션 발급)
### 2.3 QR 로그인
1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신
@@ -75,14 +77,41 @@
---
-## 5) UserFront 주의사항
+## 5) 링크 로그인 ↔ QR 로그인 공유/분리 로직
+
+### 5.1 공유되는 로직 (공통 기반)
+- **IDP 코드 검증 로직 공유**: Ory 기준으로 링크 로그인과 QR 로그인 모두 `VerifyLoginCode`(코드 기반 로그인 검증)를 사용합니다.
+- **Kratos courier relay 공유**: Kratos에서 발송되는 `login_code`를 `HandleKratosCourierRelay`에서 처리하며, 링크/QR 모두 이 경로를 거칩니다.
+- **코드/플로우 상태 저장**: 코드 로그인 플로우의 `flow_id`는 공통 키(`prefixLoginCode`)에 저장됩니다.
+
+### 5.2 분리되는 로직 (pendingRef/승인 경로)
+- **pendingRef 네임스페이스 분리**:
+ - 링크 로그인: `prefixSession`, `prefixLoginCodePending`, `prefixLoginMethod`, `prefixLoginFlow`
+ - QR 로그인: `prefixLoginCodeQrPending`, `prefixLoginCodeQr`, `prefixQrPending`, `prefixQrMeta`, `prefixQrApproverSession`
+- **승인 엔드포인트 분리**:
+ - 링크 로그인: `/api/v1/auth/magic-link/verify` 또는 `/api/v1/auth/login/code/verify*` (verify-only)
+ - QR 로그인: `/api/v1/auth/qr/approve`
+- **세션 발급 주체 분리**:
+ - 링크 로그인: Polling(요청 기기 A)에서 세션 발급
+ - QR 로그인: Polling(웹)에서 세션 발급, 모바일은 승인만 수행
+- **audit 기록 경로 분리**:
+ - 링크 로그인: `writeLinkAuditLog`
+ - QR 로그인: `writeQrAuditLog`
+
+### 5.3 verify-only 적용 범위
+- 링크 로그인/코드 로그인 경로에만 적용됩니다.
+- QR 로그인은 approve/poll 구조이므로 verify-only를 사용하지 않습니다.
+
+---
+
+## 6) UserFront 주의사항
- `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능)
- 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함
---
-## 6) 다음 액션 제안
+## 7) 다음 액션 제안
1. **Kratos 세션 쿠키 전달 방식(A) 구현**
2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계
diff --git a/docs/issue-146-remote-login.md b/docs/issue-146-remote-login.md
new file mode 100644
index 00000000..5f34bbdc
--- /dev/null
+++ b/docs/issue-146-remote-login.md
@@ -0,0 +1,35 @@
+# #146 원격 링크 로그인 세션/이력 불일치 대응
+
+## 요약
+- Ory 링크 로그인은 실제로 `/api/v1/auth/login/code/verify` 또는 `/api/v1/auth/login/code/verify-short` 경로를 사용합니다.
+- 기존에는 `verifyOnly`가 `/api/v1/auth/magic-link/verify`에만 적용되어, 링크를 클릭한 기기에서 세션이 발급되는 문제가 있었습니다.
+- 인증수단 표기는 loginId 기반 추론에 의존해 SMS 요청이 Email로 표시되는 문제가 있었습니다.
+
+## 원인
+- verify-only 적용 범위가 magic link에 한정되어 있었고, Ory 코드 기반 경로는 세션을 즉시 발급했습니다.
+- audit 로그의 인증수단 표기는 request_body/loginId 기반 추론만 사용했습니다.
+
+## 변경 사항
+### 1) verify-only 범위 확장
+- `/api/v1/auth/login/code/verify`, `/api/v1/auth/login/code/verify-short`에 `verifyOnly` 지원 추가
+- verify-only일 때는 승인 상태만 저장하고 세션 발급은 Polling(Desktop)에서 수행
+
+### 2) Polling 시 세션 발급 주체 정리
+- 승인 상태(`status=approved`)는 **요청한 기기(A)**에서만 세션 발급
+- Ory 코드 플로우는 Polling 시점에 `VerifyLoginCode`를 수행해 세션 생성
+
+### 3) 인증수단 표기 개선
+- `pendingRef` 기준으로 `login_method`(sms/email), `login_flow`(code/link) 저장
+- audit 로그에 해당 메타를 기록하여 SMS/Email, 코드/링크 구분을 명확히 표시
+- verify-only 요청 로그는 타임라인에서 제외
+
+## 영향 범위
+- Backend: 링크 로그인 승인/세션 발급 경로 변경
+- Front: verify-only 플래그 전달 확장
+- 문서: auth-flow/test-plan 업데이트
+
+## 테스트 계획 (요약)
+- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Polling으로 세션 발급
+- Mobile 단말에서 세션/로그인 이력 미생성 확인
+- 인증수단 표기(SMS/Email) 정확성 확인
+- 코드/링크 만료/재사용 시나리오 점검
diff --git a/docs/test-plan.md b/docs/test-plan.md
new file mode 100644
index 00000000..f9546356
--- /dev/null
+++ b/docs/test-plan.md
@@ -0,0 +1,132 @@
+# 테스트 계획 및 원칙 (Baron SSO)
+
+## 1) 목적
+- 인증/인가 핵심 플로우의 안정성과 회귀 방지
+- 멀티 서비스(Backend/Ory Stack/Front) 연동 품질 확보
+- 릴리즈 기준과 장애 분석 기준의 표준화
+
+## 2) 범위
+### 포함
+- Backend (Go Fiber)
+- UserFront (Flutter Web/App)
+- AdminFront / DevFront (React)
+- Ory Stack (Kratos/Hydra/Keto/Oathkeeper)
+- Gateway/네트워크 구성 (baron_net, ory-net, public_net)
+- DB (PostgreSQL, ClickHouse, Redis)
+
+### 제외(별도 계획)
+- 외부 IDP 벤더의 장애 대응 (Descope 등)
+- 프로덕션 데이터 복구 시나리오(백업/DR)
+
+## 3) 원칙
+- **Shift-left**: 개발 단계에서 최대한 조기 검증
+- **단계적 신뢰**: Unit → Integration → E2E 순으로 신뢰도 상승
+- **환경 분리**: 로컬/스테이징/프로덕션 구성 차이를 문서로 명시
+- **결정적 테스트**: 시간/랜덤/외부 의존성 최소화
+- **Idempotent**: 반복 실행 시 동일 결과 보장
+- **보안 우선**: 민감정보(PII/Token)는 테스트 로그에 노출 금지
+- **실패 우선 기록**: 실패 로그/재현 절차를 우선 확보
+
+## 4) 테스트 레이어 및 목표
+### 4.1 Unit Test
+- Backend: 비즈니스 로직, 유효성 검증, Mapper/Adapter
+- Frontend: 유틸/상태관리/컴포넌트 로직
+- 목표: 빠른 피드백(수초~수분)
+
+### 4.2 Integration Test
+- Backend + DB(Postgres/ClickHouse/Redis)
+- Backend + Ory Admin API (Kratos/Hydra/Keto)
+- 목표: 네트워크/스토리지 연동 검증
+
+### 4.3 Contract Test
+- Backend ↔ Frontend API 스키마/응답 계약 검증
+- OIDC/OpenID Connect 표준 응답 형식 검증
+
+### 4.4 E2E Test (Happy/Edge Path)
+- 로그인 플로우(Password / Magic Link / SMS / QR)
+- Consent 플로우 (Hydra login/consent)
+- 토큰 발급/재발급/로그아웃/세션 만료
+- 목표: 핵심 사용자 여정의 회귀 방지
+
+### 4.5 Smoke Test
+- 배포 직후 필수 엔드포인트 헬스체크
+- `GET /health`, Ory readiness, UserFront 정적 리소스
+
+### 4.6 Regression / Non-functional
+- 성능: 로그인/토큰 발급 지연, 대량 감사 로그 적재
+- 보안: 인증 우회, 권한 상승, 세션 고정 공격
+- 관측성: 핵심 로그/메트릭 누락 여부
+
+## 5) 환경 전략
+- 로컬: `make up-all` 또는 `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d`
+- 스테이징: 프로덕션과 동일한 네트워크/도메인 구성
+- 프로덕션: 최소한의 smoke/관측성 점검
+
+## 6) 테스트 데이터 정책
+- 표준 시드 사용자/테넌트/클라이언트 세트 정의
+- PII 마스킹 규칙(이메일/전화번호/토큰)
+- 재현용 고정 데이터와 랜덤 데이터 분리
+- 테스트 종료 후 클린업 규칙 정의
+
+## 7) 자동화 및 CI/CD 기준 (현행)
+- **현재 상태**: 레포에 CI/CD 워크플로우 정의가 없음. 테스트는 로컬/수동 실행 기준으로 운영.
+- **CI 변수 활용**: AdminFront/DevFront Playwright 설정은 `CI` 환경 변수에 따라 재시도/워커 수를 조정함.
+- **수동 실행 기준**:
+ - Backend: `go test ./...` (위치: `backend/`)
+ - UserFront: `flutter test` (위치: `userfront/`)
+ - AdminFront: `npm test` (Playwright, 위치: `adminfront/`, baseURL `http://localhost:5173`)
+ - DevFront: `npm test` (Playwright, 위치: `devfront/`, baseURL `http://localhost:5174`)
+
+### 7.1 수동 게이트 제안(현행 기준)
+- PR/머지 전 최소 기준: Backend Unit + 해당 Front 테스트(변경 범위)
+- 배포 전 최소 기준: Smoke + 핵심 E2E(로그인/Consent)
+
+## 8) 핵심 플로우 테스트 시나리오
+### 인증/세션
+- Password 로그인 성공/실패/락/재시도
+- Magic Link 발송/검증/만료
+- SMS 코드 발송/검증/재시도 제한
+- QR 승인/거절/타임아웃
+- 로그아웃 시 세션/쿠키/토큰 무효화
+
+### 원격 링크 로그인(verify-only)
+- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Poll로 세션 발급
+- Mobile 단말에 세션 생성/로그인이 발생하지 않는지 확인
+- Audit/로그인 이력에 Desktop 세션 ID만 기록되는지 확인
+- 인증수단 표기(SMS/Email)가 요청 수단과 일치하는지 확인
+- 코드/링크 만료 시 승인 실패 및 재요청 안내
+
+### OIDC/Hydra
+- Login Challenge 처리
+- Consent 승인/거절
+- Token/Refresh Token 발급
+- Redirect URI 검증
+
+### 권한/정책(Keto)
+- 권한 부여/회수 시 접근 제어 확인
+- 관리자/일반 사용자 분리
+
+### 네트워크/프록시
+- `baron_net`와 `ory-net` 경계 준수
+- Frontend에서 Ory 내부 Admin 포트 접근 불가
+
+## 9) 관측성/장애 대응 테스트
+- 에러 로그 구조(필수 필드 포함) 확인
+- Audit Log 누락/중복 체크
+- 실패 시 재시도 정책 검증
+
+## 10) 책임 및 운영 프로세스
+- 각 영역별 오너 지정(Backend/Front/Ory)
+- 실패 시 triage 기준: 재현 가능 여부 → 영향도 → 우선순위
+- 테스트 케이스/기대 결과는 이슈/PR에 링크
+
+## 11) 유지보수 원칙
+- 신규 기능은 반드시 관련 테스트 추가
+- 회귀 버그 발생 시 재현 테스트를 우선 추가
+- 불안정 테스트는 원인 분석 후 격리 또는 개선
+
+## 12) 체크리스트 (릴리즈 전)
+- Smoke 통과
+- 핵심 E2E 통과
+- 보안 관련 회귀 없음
+- 장애/모니터링 대시보드 정상
diff --git a/gateway/entrypoint.sh b/gateway/entrypoint.sh
new file mode 100644
index 00000000..c54fac71
--- /dev/null
+++ b/gateway/entrypoint.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+
+# 대상 호스트와 포트가 준비될 때까지 대기하는 함수
+wait_for_host() {
+ host=$1
+ port=$2
+ name=$3
+
+ echo "Waiting for $name ($host:$port)..."
+
+ # 최대 30초 동안 대기
+ count=0
+ until nc -z $host $port || [ $count -eq 30 ]; do
+ sleep 1
+ count=$((count + 1))
+ done
+
+ if [ $count -eq 30 ]; then
+ echo "Timeout waiting for $name"
+ else
+ echo "$name is ready!"
+ fi
+}
+
+# 백엔드와 유저프론트 대기 (Oathkeeper는 인프라 레벨이므로 함께 뜰 가능성이 높지만 안전을 위해 포함)
+wait_for_host "baron_backend" 3000 "Backend"
+wait_for_host "baron_userfront" 5000 "UserFront"
+wait_for_host "oathkeeper" 4455 "Oathkeeper"
+
+echo "All dependencies are up. Starting Nginx..."
+exec nginx -g 'daemon off;'
diff --git a/gateway/nginx.conf b/gateway/nginx.conf
index df36f3f9..9b94fe8a 100644
--- a/gateway/nginx.conf
+++ b/gateway/nginx.conf
@@ -56,6 +56,7 @@ server {
# Hydra Public API
location /oidc {
+ rewrite ^/oidc/(.*)$ /$1 break;
proxy_pass $oathkeeper_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart
index 2a07288b..8389815f 100644
--- a/userfront/lib/core/services/auth_proxy_service.dart
+++ b/userfront/lib/core/services/auth_proxy_service.dart
@@ -125,7 +125,7 @@ class AuthProxyService {
throw Exception('Polling failed: ${response.body}');
}
- static Future