1
0
forked from baron/baron-sso

Merge branch 'dev' into feat/id_login

This commit is contained in:
2026-04-01 13:40:45 +09:00
41 changed files with 2079 additions and 397 deletions

View File

@@ -1,6 +1,16 @@
package domain
import "time"
import (
"strings"
"time"
)
const (
MetadataHeadlessLoginEnabled = "headless_login_enabled"
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
MetadataHeadlessJWKSURI = "headless_jwks_uri"
MetadataHeadlessJWKS = "headless_jwks"
)
type HydraClient struct {
ClientID string `json:"client_id"`
@@ -17,22 +27,53 @@ type HydraClient struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (c *HydraClient) IsTrustedRP() bool {
// A Trusted RP must have a public key registered (URI or Inline)
func (c *HydraClient) SupportsHeadlessLogin() bool {
// A headless login client must have a public key registered (URI or Inline)
// and use private_key_jwt for token endpoint authentication.
hasPublicKey := c.JWKSUri != "" || c.JWKS != nil
isPrivateKeyJwt := c.TokenEndpointAuthMethod == "private_key_jwt"
hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil
isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt"
return hasPublicKey && isPrivateKeyJwt
}
func (c *HydraClient) HeadlessTokenEndpointAuthMethod() string {
if c.Metadata != nil {
if raw, ok := c.Metadata[MetadataHeadlessTokenEndpointAuthMethod].(string); ok {
if value := strings.TrimSpace(raw); value != "" {
return value
}
}
}
return strings.TrimSpace(c.TokenEndpointAuthMethod)
}
func (c *HydraClient) HeadlessJWKSURI() string {
if c.Metadata != nil {
if raw, ok := c.Metadata[MetadataHeadlessJWKSURI].(string); ok {
if value := strings.TrimSpace(raw); value != "" {
return value
}
}
}
return strings.TrimSpace(c.JWKSUri)
}
func (c *HydraClient) HeadlessJWKS() interface{} {
if c.Metadata != nil {
if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil {
return value
}
}
return c.JWKS
}
func (c *HydraClient) IsHeadlessLoginEnabled() bool {
if !c.IsTrustedRP() {
if !c.SupportsHeadlessLogin() {
return false
}
if c.Metadata == nil {
return false
}
val, ok := c.Metadata["headless_login_enabled"]
val, ok := c.Metadata[MetadataHeadlessLoginEnabled]
if !ok {
return false
}

View File

@@ -2,7 +2,29 @@ package domain
import "testing"
func TestHydraClient_TrustedRPFlags(t *testing.T) {
func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
t.Run("metadata-backed headless login client is supported", func(t *testing.T) {
client := HydraClient{
TokenEndpointAuthMethod: "none",
Metadata: map[string]any{
"headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt",
"headless_jwks": map[string]any{
"keys": []map[string]any{{
"kty": "RSA",
}},
},
},
}
if !client.SupportsHeadlessLogin() {
t.Fatalf("expected metadata-backed headless login client")
}
if !client.IsHeadlessLoginEnabled() {
t.Fatalf("expected metadata-backed headless login enabled")
}
})
t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) {
client := HydraClient{
TokenEndpointAuthMethod: "private_key_jwt",
@@ -16,15 +38,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
},
}
if !client.IsTrustedRP() {
t.Fatalf("expected trusted rp")
if !client.SupportsHeadlessLogin() {
t.Fatalf("expected headless login client")
}
if !client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login enabled")
}
})
t.Run("jwks uri without private_key_jwt is not trusted", func(t *testing.T) {
t.Run("jwks uri without private_key_jwt does not support headless login", func(t *testing.T) {
client := HydraClient{
TokenEndpointAuthMethod: "none",
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
@@ -33,15 +55,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
},
}
if client.IsTrustedRP() {
t.Fatalf("expected untrusted rp")
if client.SupportsHeadlessLogin() {
t.Fatalf("expected headless login prerequisites to be missing")
}
if client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login disabled when client is not trusted")
t.Fatalf("expected headless login disabled when prerequisites are missing")
}
})
t.Run("trusted rp without boolean metadata flag is not headless enabled", func(t *testing.T) {
t.Run("headless login client without boolean metadata flag is not enabled", func(t *testing.T) {
client := HydraClient{
TokenEndpointAuthMethod: "private_key_jwt",
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
@@ -50,8 +72,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
},
}
if !client.IsTrustedRP() {
t.Fatalf("expected trusted rp")
if !client.SupportsHeadlessLogin() {
t.Fatalf("expected headless login client")
}
if client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login disabled for non-bool metadata")

View File

@@ -11,6 +11,7 @@ type NaverSmsRequest struct {
ContentType string `json:"contentType"`
CountryCode string `json:"countryCode"`
From string `json:"from"`
Subject string `json:"subject,omitempty"`
Content string `json:"content"`
Messages []SmsMessage `json:"messages"`
}

View File

@@ -66,19 +66,21 @@ const (
loginFlowLink = "link"
// Durations
defaultExpiration = 5 * time.Minute
signupStateExpiration = 10 * time.Minute
signupBlockDuration = 10 * time.Minute
maxSignupFailures = 5
emailCodeTTL = 5 * time.Minute
smsCodeTTL = 3 * time.Minute
prefixPwdResetToken = "pwdreset_token:"
pwdResetExpiration = 15 * time.Minute
minPollInterval = 2 * time.Second
loginCodeExpiration = 10 * time.Minute
linkResendCooldown = 60 * time.Second
prefixDrySend = "dry_send:"
headlessJWKSFetchTTL = 5 * time.Second
defaultExpiration = 5 * time.Minute
signupStateExpiration = 10 * time.Minute
signupBlockDuration = 10 * time.Minute
maxSignupFailures = 5
emailCodeTTL = 5 * time.Minute
smsCodeTTL = 3 * time.Minute
prefixPwdResetToken = "pwdreset_token:"
prefixPwdResetUsed = "pwdreset_used:"
pwdResetExpiration = 15 * time.Minute
pwdResetUsedExpiration = 2 * time.Minute
minPollInterval = 2 * time.Second
loginCodeExpiration = 10 * time.Minute
linkResendCooldown = 60 * time.Second
prefixDrySend = "dry_send:"
headlessJWKSFetchTTL = 5 * time.Second
)
type AuthHandler struct {
@@ -1741,14 +1743,14 @@ func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bo
func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient) (*jose.JSONWebKeySet, error) {
var raw []byte
switch {
case client.JWKS != nil:
data, err := json.Marshal(client.JWKS)
case client.HeadlessJWKS() != nil:
data, err := json.Marshal(client.HeadlessJWKS())
if err != nil {
return nil, fmt.Errorf("failed to encode jwks: %w", err)
}
raw = data
case strings.TrimSpace(client.JWKSUri) != "":
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(client.JWKSUri), nil)
case client.HeadlessJWKSURI() != "":
req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.HeadlessJWKSURI(), nil)
if err != nil {
return nil, fmt.Errorf("failed to build jwks request: %w", err)
}
@@ -1768,7 +1770,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
}
raw = body
default:
return nil, fmt.Errorf("trusted rp public key is not configured")
return nil, fmt.Errorf("headless login public key is not configured")
}
var keySet jose.JSONWebKeySet
@@ -1776,7 +1778,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
return nil, fmt.Errorf("failed to decode jwks: %w", err)
}
if len(keySet.Keys) == 0 {
return nil, fmt.Errorf("trusted rp jwks has no keys")
return nil, fmt.Errorf("headless login jwks has no keys")
}
return &keySet, nil
}
@@ -2410,9 +2412,9 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
}
userfrontURL := h.resolveUserfrontURL(c)
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
ale.RedirectTo = redirectURL
// 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다.
// 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다.
verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL)
// 내부 토큰 발급 + 우리 채널로 전송
resetToken := GenerateSecureToken(32)
@@ -2432,7 +2434,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
}
resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken)
resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken)
ale.RedirectTo = resetLink
ale.Operation = "SendPasswordReset"
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
@@ -2498,6 +2500,9 @@ func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
if token == "" {
token = c.Query("t")
}
if token == "" {
token = c.Params("token")
}
if token == "" {
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
@@ -2551,6 +2556,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
token = c.Query("t")
}
}
if token == "" {
token = c.Params("token")
}
ale.Token = token
if token == "" {
@@ -2625,6 +2633,14 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
if resetToken != "" {
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
if err != nil || strings.TrimSpace(val) == "" {
if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" {
ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime)
ale.Token = resetToken
ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID)
ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use")
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
}
ale.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = "Invalid or expired reset token"
@@ -2694,6 +2710,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
if resetToken != "" {
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
_ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration)
}
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
}

View File

@@ -16,13 +16,29 @@ import (
)
// Mock services
type mockEmailService struct{}
type mockEmailService struct {
lastTo string
lastSubject string
lastBody string
}
func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil }
func (m *mockEmailService) SendEmail(to, subject, body string) error {
m.lastTo = to
m.lastSubject = subject
m.lastBody = body
return nil
}
type mockSmsService struct{}
type mockSmsService struct {
lastTo string
lastContent string
}
func (m *mockSmsService) SendSms(to, content string) error { return nil }
func (m *mockSmsService) SendSms(to, content string) error {
m.lastTo = to
m.lastContent = content
return nil
}
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
app := fiber.New()
@@ -156,7 +172,7 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
assert.Equal(t, "expired_token", got["code"])
}
func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)}
privateKey, jwks := mustHeadlessRSAJWK(t)
@@ -170,12 +186,13 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123",
Client: domain.HydraClient{
ClientID: "trusted-rp",
TokenEndpointAuthMethod: "private_key_jwt",
JWKS: jwks,
ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{
"status": "active",
"headless_login_enabled": true,
"status": "active",
"headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt",
"headless_jwks": jwks,
},
},
})
@@ -198,8 +215,8 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
t.Setenv("USERFRONT_URL", "http://userfront.test")
body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"),
"client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
"loginId": "010-1234-5678",
"login_challenge": "challenge-123",
})
@@ -231,12 +248,13 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123",
Client: domain.HydraClient{
ClientID: "trusted-rp",
TokenEndpointAuthMethod: "private_key_jwt",
JWKS: jwks,
ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{
"status": "active",
"headless_login_enabled": true,
"status": "active",
"headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt",
"headless_jwks": jwks,
},
},
})
@@ -266,8 +284,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
t.Setenv("USERFRONT_URL", "http://userfront.test")
initBody, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"),
"client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
"loginId": "010-1234-5678",
"login_challenge": "challenge-123",
})
@@ -300,8 +318,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
pollBody, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/poll"),
"client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"),
"pendingRef": pendingRef,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))

View File

@@ -284,7 +284,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
}
}
func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt"},
@@ -305,12 +305,13 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123",
Client: domain.HydraClient{
ClientID: "trusted-rp",
TokenEndpointAuthMethod: "private_key_jwt",
JWKSUri: jwksServer.URL + "/.well-known/jwks.json",
ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{
"status": "active",
"headless_login_enabled": true,
"status": "active",
"headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt",
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
},
},
})
@@ -338,11 +339,11 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
clientAssertion := mustHeadlessClientAssertion(
t,
privateKey,
"trusted-rp",
"headless-login-client",
"http://example.com/api/v1/auth/headless/password/login",
)
body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp",
"client_id": "headless-login-client",
"client_assertion": clientAssertion,
"loginId": "employee001",
"password": "password",
@@ -389,7 +390,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123",
Client: domain.HydraClient{
ClientID: "trusted-rp",
ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt",
JWKS: map[string]any{
"keys": []map[string]any{},
@@ -420,7 +421,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp",
"client_id": "headless-login-client",
"loginId": "employee001",
"password": "password",
"login_challenge": "challenge-123",
@@ -459,7 +460,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123",
Client: domain.HydraClient{
ClientID: "trusted-rp",
ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt",
JWKS: jwks,
Metadata: map[string]interface{}{
@@ -490,11 +491,11 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
clientAssertion := mustHeadlessClientAssertion(
t,
invalidKey,
"trusted-rp",
"headless-login-client",
"http://example.com/api/v1/auth/headless/password/login",
)
body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp",
"client_id": "headless-login-client",
"client_assertion": clientAssertion,
"loginId": "employee001",
"password": "password",
@@ -523,11 +524,12 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123",
Client: domain.HydraClient{
ClientID: "trusted-rp",
TokenEndpointAuthMethod: "private_key_jwt",
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
ClientID: "headless-login-client",
TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{
"status": "active",
"status": "active",
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
"headless_token_endpoint_auth_method": "private_key_jwt",
},
},
})
@@ -547,7 +549,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp",
"client_id": "headless-login-client",
"loginId": "employee001",
"password": "password",
"login_challenge": "challenge-123",
@@ -576,11 +578,12 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
Challenge: "challenge-123",
Client: domain.HydraClient{
ClientID: "other-rp",
TokenEndpointAuthMethod: "private_key_jwt",
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
TokenEndpointAuthMethod: "none",
Metadata: map[string]interface{}{
"status": "active",
"headless_login_enabled": true,
"status": "active",
"headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt",
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
},
},
})
@@ -600,7 +603,7 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp",
"client_id": "headless-login-client",
"loginId": "employee001",
"password": "password",
"login_challenge": "challenge-123",

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -254,6 +255,65 @@ func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *test
}
}
func TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) {
const resetToken = "dup-token"
const loginID = "user@example.com"
const newPassword = "StrongPass1!"
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + resetToken: loginID,
},
}
idp := &mockIdpProvider{
userExists: true,
err: nil,
}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
}
app := newResetFlowTestApp(h)
body, _ := json.Marshal(map[string]string{
"newPassword": newPassword,
})
url := fmt.Sprintf(
"/api/v1/auth/password/reset/complete?token=%s",
resetToken,
)
firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
firstReq.Header.Set("Content-Type", "application/json")
firstResp, err := app.Test(firstReq)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
defer firstResp.Body.Close()
if firstResp.StatusCode != http.StatusOK {
t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode)
}
if idp.updateCallCount != 1 {
t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount)
}
secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
secondReq.Header.Set("Content-Type", "application/json")
secondResp, err := app.Test(secondReq)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
defer secondResp.Body.Close()
if secondResp.StatusCode != http.StatusOK {
t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode)
}
if idp.updateCallCount != 1 {
t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount)
}
}
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
const token = "tok-enc"
const loginID = "user+alias@example.com"
@@ -295,6 +355,102 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
}
}
func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) {
const token = "tok-ve"
const loginID = "user@example.com"
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + token: loginID,
},
}
h := &AuthHandler{
RedisService: redis,
}
app := fiber.New()
app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage)
app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken)
getReq := httptest.NewRequest(
http.MethodGet,
"/api/v1/auth/password/reset/ve?token="+token,
nil,
)
getResp, err := app.Test(getReq)
if err != nil {
t.Fatalf("get request failed: %v", err)
}
defer getResp.Body.Close()
if getResp.StatusCode != http.StatusOK {
t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode)
}
postReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/password/reset/ve?token="+token,
nil,
)
postResp, err := app.Test(postReq)
if err != nil {
t.Fatalf("post request failed: %v", err)
}
defer postResp.Body.Close()
if postResp.StatusCode != http.StatusFound {
t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode)
}
}
func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) {
const token = "tok-path"
const loginID = "user@example.com"
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + token: loginID,
},
}
h := &AuthHandler{
RedisService: redis,
}
app := fiber.New()
app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage)
app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken)
getReq := httptest.NewRequest(
http.MethodGet,
"/api/v1/auth/password/reset/v/"+token,
nil,
)
getResp, err := app.Test(getReq)
if err != nil {
t.Fatalf("get request failed: %v", err)
}
defer getResp.Body.Close()
if getResp.StatusCode != http.StatusOK {
t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode)
}
postReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/password/reset/v/"+token,
nil,
)
postResp, err := app.Test(postReq)
if err != nil {
t.Fatalf("post request failed: %v", err)
}
defer postResp.Body.Close()
if postResp.StatusCode != http.StatusFound {
t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode)
}
}
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
h := &AuthHandler{}
app := newResetInitAppWithErrorCodeEnricher(h)
@@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T)
t.Fatalf("expected code=bad_request, got %v", got["code"])
}
}
func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) {
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
redis := &testRedisRepo{values: map[string]string{}}
smsSvc := &mockSmsService{}
h := &AuthHandler{
RedisService: redis,
IdpProvider: &mockIdpProvider{},
SmsService: smsSvc,
}
app := fiber.New()
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
body, _ := json.Marshal(map[string]string{
"loginId": "01012345678",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
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 !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") {
t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent)
}
if strings.Contains(smsSvc.lastContent, "/reset-password?token=") {
t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent)
}
}

View File

@@ -21,6 +21,7 @@ type mockIdpProvider struct {
err error
initiateLinkErr error
updateCalled bool
updateCallCount int
updatedLoginID string
updatedPassword string
}
@@ -68,6 +69,7 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
m.updateCalled = true
m.updateCallCount++
m.updatedLoginID = loginID
m.updatedPassword = newPassword
return m.err

View File

@@ -891,6 +891,13 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
tokenAuthMethod = "client_secret_basic"
}
}
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
clientType,
tokenAuthMethod,
valueOr(req.JwksUri, ""),
req.Jwks,
metadata,
)
clientReq := domain.HydraClient{
ClientID: clientID,
@@ -900,8 +907,8 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "),
TokenEndpointAuthMethod: tokenAuthMethod,
JWKSUri: valueOr(req.JwksUri, ""),
JWKS: req.Jwks,
JWKSUri: jwksURI,
JWKS: jwks,
Metadata: metadata,
}
@@ -1044,6 +1051,23 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
metadata["status"] = status
}
resolvedClientType := currentSummary.Type
if clientType != "" {
resolvedClientType = clientType
}
resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod)
resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri)
resolvedJWKS := req.Jwks
if req.Jwks == nil {
resolvedJWKS = current.JWKS
}
resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig(
resolvedClientType,
resolvedTokenAuthMethod,
resolvedJWKSURI,
resolvedJWKS,
metadata,
)
updated := domain.HydraClient{
ClientID: current.ClientID,
@@ -1052,14 +1076,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod),
JWKSUri: valueOr(req.JwksUri, current.JWKSUri),
JWKS: req.Jwks,
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
JWKSUri: resolvedJWKSURI,
JWKS: resolvedJWKS,
Metadata: metadata,
}
if req.Jwks == nil {
updated.JWKS = current.JWKS
}
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error())
}
@@ -1676,6 +1697,70 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
}
}
func readMetadataStringValue(metadata map[string]interface{}, key string) string {
if metadata == nil {
return ""
}
raw, _ := metadata[key].(string)
return strings.TrimSpace(raw)
}
func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
if metadata == nil {
return false
}
value, _ := metadata[key].(bool)
return value
}
func normalizeHeadlessClientConfig(
clientType string,
tokenAuthMethod string,
jwksURI string,
jwks interface{},
metadata map[string]interface{},
) (string, string, interface{}, map[string]interface{}) {
if metadata == nil {
metadata = map[string]interface{}{}
}
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
if clientType == "pkce" && headlessEnabled {
headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") {
headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod)
}
if headlessTokenAuthMethod == "" {
headlessTokenAuthMethod = "private_key_jwt"
}
metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod
headlessJWKSURI := readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI)
if headlessJWKSURI == "" && strings.TrimSpace(jwksURI) != "" {
headlessJWKSURI = strings.TrimSpace(jwksURI)
}
if headlessJWKSURI != "" {
metadata[domain.MetadataHeadlessJWKSURI] = headlessJWKSURI
} else {
delete(metadata, domain.MetadataHeadlessJWKSURI)
}
if _, ok := metadata[domain.MetadataHeadlessJWKS]; !ok && jwks != nil {
metadata[domain.MetadataHeadlessJWKS] = jwks
}
if metadata[domain.MetadataHeadlessJWKS] == nil {
delete(metadata, domain.MetadataHeadlessJWKS)
}
return "none", "", nil, metadata
}
delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
delete(metadata, domain.MetadataHeadlessJWKSURI)
delete(metadata, domain.MetadataHeadlessJWKS)
return tokenAuthMethod, jwksURI, jwks, metadata
}
func defaultClientScopes() []string {
return []string{"openid", "profile", "email"}
}

View File

@@ -611,7 +611,7 @@ func TestDevHandler_NoAuditNoAction(t *testing.T) {
})
}
func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
var captured domain.HydraClient
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -653,7 +653,7 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{
"name": "Trusted RP App",
"name": "Headless Login App",
"type": "pkce",
"redirectUris": []string{"https://rp.example.com/callback"},
"scopes": []string{"openid", "profile"},
@@ -676,22 +676,23 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod)
assert.NotNil(t, captured.JWKS)
assert.True(t, captured.IsTrustedRP())
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
assert.Nil(t, captured.JWKS)
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
assert.NotNil(t, captured.Metadata["headless_jwks"])
assert.True(t, captured.IsHeadlessLoginEnabled())
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"])
}
func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
var captured domain.HydraClient
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted",
"client_name": "Trusted Before",
"client_id": "client-headless-login",
"client_name": "Headless Login Before",
"redirect_uris": []string{"https://before.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
@@ -702,7 +703,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
},
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" {
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
err = json.Unmarshal(body, &captured)
@@ -740,7 +741,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{
"name": "Trusted After",
"name": "Headless Login After",
"type": "pkce",
"tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
@@ -749,14 +750,15 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
"request_object_signing_alg": "RS256",
},
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(body))
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod)
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri)
assert.True(t, captured.IsTrustedRP())
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
assert.Equal(t, "", captured.JWKSUri)
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
assert.True(t, captured.IsHeadlessLoginEnabled())
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
}

View File

@@ -342,7 +342,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
}
}
// Fallback: Try syncing based on the tenant namespaces being updated
if !synced && h.TenantService != nil {
for k := range req.Metadata {
@@ -1249,10 +1249,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
userPhone := extractTraitString(traits, "phone_number")
if err := domain.ValidateLoginID(explicitLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
finalLoginID := resolvePasswordLoginID(traits)
state := normalizeKratosState(req.Status)
@@ -1336,7 +1337,10 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
if req.Password != nil && *req.Password != "" {
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
if h.OryProvider == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available")
}
if err := h.OryProvider.UpdateUserPassword(finalLoginID, *req.Password, nil); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
@@ -1618,6 +1622,16 @@ func extractTraitString(traits map[string]interface{}, key string) string {
return ""
}
func resolvePasswordLoginID(traits map[string]interface{}) string {
if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" {
return loginID
}
if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" {
return email
}
return strings.TrimSpace(extractTraitString(traits, "phone_number"))
}
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field.
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
if loginIDField == "" {
@@ -1645,7 +1659,7 @@ func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantI
}
// 3. Check merged traits (which includes existing metadata)
// Important: Skip this if loginIDField is "id" because traits["id"] is the TARGET,
// Important: Skip this if loginIDField is "id" because traits["id"] is the TARGET,
// and we don't want to sync "id" to "id" if we already checked metadata.
if loginID == "" && loginIDField != "id" {
// Existing trait (flat)

View File

@@ -488,6 +488,117 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
})
}
func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"id": "dyddus1210",
"email": "dyddus1210@gmail.com",
"companyCode": "test-tenant",
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
}, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["id"] == "dyddus1210"
}), "").Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"id": "dyddus1210",
"email": "dyddus1210@gmail.com",
},
}, nil).Once()
mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
payload := map[string]interface{}{
"password": "asdfzxcv1234!",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockOry.AssertExpectations(t)
mockKratos.AssertNotCalled(t, "UpdateIdentityPassword", mock.Anything, mock.Anything, mock.Anything)
}
func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
userID := "u-2"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "dyddus1210@gmail.com",
"companyCode": "test-tenant",
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
}, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["email"] == "dyddus1210@gmail.com"
}), "").Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "dyddus1210@gmail.com",
},
}, nil).Once()
mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
payload := map[string]interface{}{
"password": "asdfzxcv1234!",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockOry.AssertExpectations(t)
}
func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
app := fiber.New()

View File

@@ -425,16 +425,16 @@ func TestRotateClientSecret_PersistsForLaterDetailFetch(t *testing.T) {
}
}
func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
func TestUpdateClient_HeadlessLoginSecretPersistsForLaterDetailFetch(t *testing.T) {
getCount := 0
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
getCount++
if getCount == 1 {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted",
"client_name": "Trusted Before",
"client_id": "client-headless-login",
"client_name": "Headless Login Before",
"redirect_uris": []string{"https://before.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
@@ -447,14 +447,14 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
}
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted",
"client_name": "Trusted After",
"redirect_uris": []string{"https://trusted.example.com/callback"},
"client_id": "client-headless-login",
"client_name": "Headless Login After",
"redirect_uris": []string{"https://headless.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "private_key_jwt",
"jwks_uri": "https://trusted.example.com/jwks.json",
"jwks_uri": "https://headless.example.com/jwks.json",
"metadata": map[string]any{
"status": "active",
"headless_login_enabled": true,
@@ -463,17 +463,17 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" {
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted",
"client_name": "Trusted After",
"client_secret": "trusted-secret",
"redirect_uris": []string{"https://trusted.example.com/callback"},
"client_id": "client-headless-login",
"client_name": "Headless Login After",
"client_secret": "headless-secret",
"redirect_uris": []string{"https://headless.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile",
"token_endpoint_auth_method": "private_key_jwt",
"jwks_uri": "https://trusted.example.com/jwks.json",
"jwks_uri": "https://headless.example.com/jwks.json",
"metadata": map[string]any{
"status": "active",
"headless_login_enabled": true,
@@ -507,16 +507,16 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
app.Get("/api/v1/dev/clients/:id", h.GetClient)
updateBody, _ := json.Marshal(map[string]any{
"name": "Trusted After",
"redirectUris": []string{"https://trusted.example.com/callback"},
"name": "Headless Login After",
"redirectUris": []string{"https://headless.example.com/callback"},
"tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://trusted.example.com/jwks.json",
"jwksUri": "https://headless.example.com/jwks.json",
"metadata": map[string]any{
"headless_login_enabled": true,
"request_object_signing_alg": "RS256",
},
})
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(updateBody))
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(updateBody))
updateReq.Header.Set("Content-Type", "application/json")
updateResp, err := app.Test(updateReq, -1)
@@ -527,20 +527,20 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
t.Fatalf("expected update 200, got %d", updateResp.StatusCode)
}
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-trusted")
if storedSecret != "trusted-secret" {
t.Fatalf("expected postgres secret trusted-secret, got %q", storedSecret)
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-headless-login")
if storedSecret != "headless-secret" {
t.Fatalf("expected postgres secret headless-secret, got %q", storedSecret)
}
redisSecret, err := redisRepo.Get("client_secret:client-trusted")
redisSecret, err := redisRepo.Get("client_secret:client-headless-login")
if err != nil {
t.Fatalf("expected redis secret, got error: %v", err)
}
if redisSecret != "trusted-secret" {
t.Fatalf("expected redis secret trusted-secret, got %q", redisSecret)
if redisSecret != "headless-secret" {
t.Fatalf("expected redis secret headless-secret, got %q", redisSecret)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-trusted", nil)
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-headless-login", nil)
getResp, err := app.Test(getReq, -1)
if err != nil {
t.Fatalf("get request failed: %v", err)
@@ -557,7 +557,7 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
if err := json.NewDecoder(getResp.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Client.ClientSecret != "trusted-secret" {
t.Fatalf("expected detail secret trusted-secret, got %q", payload.Client.ClientSecret)
if payload.Client.ClientSecret != "headless-secret" {
t.Fatalf("expected detail secret headless-secret, got %q", payload.Client.ClientSecret)
}
}

View File

@@ -11,14 +11,20 @@ import (
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
type KratosIdentity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
State string `json:"state,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ID string `json:"id"`
SchemaID string `json:"schema_id,omitempty"`
Traits map[string]interface{} `json:"traits"`
State string `json:"state,omitempty"`
MetadataAdmin interface{} `json:"metadata_admin,omitempty"`
MetadataPublic interface{} `json:"metadata_public,omitempty"`
ExternalID string `json:"external_id,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type KratosAdminService interface {
@@ -172,20 +178,54 @@ func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID stri
}
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
patchOps := []map[string]interface{}{
{
"op": "add",
"path": "/credentials/password/config/password",
"value": newPassword,
},
}
body, _ := json.Marshal(patchOps)
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
identity, err := s.GetIdentity(ctx, identityID)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json-patch+json")
if identity == nil {
return fmt.Errorf("kratos admin identity not found: %s", identityID)
}
hashedPassword, err := hashPasswordForKratosAdmin(newPassword)
if err != nil {
return err
}
payload := map[string]interface{}{
"schema_id": identity.SchemaID,
"traits": identity.Traits,
"state": identity.State,
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"hashed_password": hashedPassword,
},
},
},
}
if payload["schema_id"] == "" {
payload["schema_id"] = "default"
}
if payload["state"] == "" {
payload["state"] = "active"
}
if identity.MetadataAdmin != nil {
payload["metadata_admin"] = identity.MetadataAdmin
}
if identity.MetadataPublic != nil {
payload["metadata_public"] = identity.MetadataPublic
}
if identity.ExternalID != "" {
payload["external_id"] = identity.ExternalID
}
body, _ := json.Marshal(payload)
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req)
if err != nil {
@@ -199,6 +239,14 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
return nil
}
func hashPasswordForKratosAdmin(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)

View File

@@ -14,6 +14,8 @@ import (
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
@@ -711,20 +713,53 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
}
patchOps := []map[string]interface{}{
{
"op": "add",
"path": "/credentials/password/config/password",
"value": newPassword,
},
identity, err := o.getIdentity(identityID)
if err != nil {
return fmt.Errorf("ory provider: load identity failed: %w", err)
}
if identity == nil {
return fmt.Errorf("ory provider: identity payload missing for loginID=%s", loginID)
}
body, _ := json.Marshal(patchOps)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
hashedPassword, err := hashPasswordForKratos(newPassword)
if err != nil {
return fmt.Errorf("ory provider: hash password failed: %w", err)
}
payload := map[string]interface{}{
"schema_id": identity.SchemaID,
"traits": identity.Traits,
"state": identity.State,
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"hashed_password": hashedPassword,
},
},
},
}
if payload["schema_id"] == "" {
payload["schema_id"] = "default"
}
if payload["state"] == "" {
payload["state"] = "active"
}
if identity.MetadataAdmin != nil {
payload["metadata_admin"] = identity.MetadataAdmin
}
if identity.MetadataPublic != nil {
payload["metadata_public"] = identity.MetadataPublic
}
if identity.ExternalID != "" {
payload["external_id"] = identity.ExternalID
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("ory provider: build request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json-patch+json")
req.Header.Set("Content-Type", "application/json")
resp, err := o.httpClient().Do(req)
if err != nil {
@@ -789,6 +824,41 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
return identities[0].ID, nil
}
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
if err != nil {
return nil, err
}
resp, err := o.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("ory provider: get identity failed status=%d body=%s", resp.StatusCode, string(respBody))
}
var identity KratosIdentity
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
return nil, err
}
return &identity, nil
}
func hashPasswordForKratos(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func (o *OryProvider) httpClient() *http.Client {
if o.HTTPClient != nil {
return o.HTTPClient

View File

@@ -45,18 +45,38 @@ func TestUpdateUserPassword_Success(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
q := r.URL.Query()
if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
if r.URL.Path == "/admin/identities" {
q := r.URL.Query()
if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": identityID},
})
return
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": identityID},
if r.URL.Path != "/admin/identities/"+identityID {
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": identityID,
"schema_id": "default",
"state": "active",
"traits": map[string]interface{}{
"email": loginID,
},
})
return
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch:
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPut:
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), newPassword) {
t.Fatalf("payload missing new password, body=%s", string(body))
if !strings.Contains(string(body), "\"hashed_password\"") {
t.Fatalf("payload missing hashed_password, body=%s", string(body))
}
if strings.Contains(string(body), newPassword) {
t.Fatalf("payload must not contain plain password, body=%s", string(body))
}
if !strings.Contains(string(body), "\"schema_id\":\"default\"") {
t.Fatalf("payload missing schema_id, body=%s", string(body))
}
w.WriteHeader(http.StatusOK)
return
@@ -99,11 +119,25 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"},
})
return
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch:
if r.URL.Path == "/admin/identities" {
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"},
})
return
}
if r.URL.Path == "/admin/identities/abc" {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": "abc",
"schema_id": "default",
"state": "active",
"traits": map[string]interface{}{
"email": "user@example.com",
},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPut:
http.Error(w, "boom", http.StatusInternalServerError)
return
default:

View File

@@ -17,6 +17,8 @@ import (
"time"
)
const naverSMSMaxBytes = 90
type SmsServiceImpl struct {
accessKey string
secretKey string
@@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
// Naver SENS API requires phone number without '+'
sanitizedTo := strings.Replace(to, "+", "", 1)
reqBody := domain.NaverSmsRequest{
Type: "SMS",
ContentType: "COMM",
CountryCode: "82",
From: s.senderPhone,
Content: content,
Messages: []domain.SmsMessage{
{
To: sanitizedTo,
},
},
reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content)
if reqBody.Type == "LMS" {
slog.Info("[SmsService] Upgrading message type to LMS due to content length",
"bytes", len([]byte(content)),
)
}
jsonBody, err := json.Marshal(reqBody)
@@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
return nil
}
func buildNaverSmsRequest(senderPhone, sanitizedTo, content string) domain.NaverSmsRequest {
requestType := "SMS"
subject := ""
if len([]byte(content)) > naverSMSMaxBytes {
requestType = "LMS"
subject = "[Baron 로그인]"
}
return domain.NaverSmsRequest{
Type: requestType,
ContentType: "COMM",
CountryCode: "82",
From: senderPhone,
Subject: subject,
Content: content,
Messages: []domain.SmsMessage{
{
To: sanitizedTo,
},
},
}
}
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
space := " "
newLine := "\n"

View File

@@ -0,0 +1,26 @@
package service
import "testing"
func TestBuildNaverSmsRequest_UsesSMSForShortContent(t *testing.T) {
req := buildNaverSmsRequest("0262857755", "821012345678", "123456")
if req.Type != "SMS" {
t.Fatalf("expected SMS, got %s", req.Type)
}
if req.Subject != "" {
t.Fatalf("expected empty subject for SMS, got %q", req.Subject)
}
}
func TestBuildNaverSmsRequest_UsesLMSForLongContent(t *testing.T) {
content := "[Baron 로그인] 비밀번호 재설정 링크: http://sso-test.hmac.kr/api/v1/auth/password/reset/v/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
req := buildNaverSmsRequest("0262857755", "821012345678", content)
if req.Type != "LMS" {
t.Fatalf("expected LMS, got %s", req.Type)
}
if req.Subject == "" {
t.Fatal("expected LMS subject to be set")
}
}