From 57a00c02363f4042e648029b1fbed1365047aadd Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 7 May 2026 13:53:23 +0900 Subject: [PATCH 1/3] Fix SMS login code flow for phone relay --- backend/internal/handler/auth_handler.go | 25 +++++++-- .../handler/auth_handler_link_test.go | 51 +++++++++++++++++++ scripts/render_ory_config.sh | 5 +- test/ory_v26_compose_policy_test.sh | 5 ++ 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 8493d2ec..807ab5b1 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1028,6 +1028,13 @@ func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string { envParsed.Scheme == "https" && baseParsed.Scheme == "http" { return strings.TrimRight(envURL, "/") } + if os.Getenv("APP_ENV") == "dev" && + envErr == nil && baseErr == nil && + strings.EqualFold(envParsed.Hostname(), baseParsed.Hostname()) && + (envParsed.Hostname() == "localhost" || envParsed.Hostname() == "127.0.0.1") && + envParsed.Port() != "" && baseParsed.Port() == "" { + return strings.TrimRight(envURL, "/") + } return baseURL } @@ -2003,6 +2010,13 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { if !strings.Contains(loginID, "@") { lookupLoginID = normalizePhoneForLoginID(loginID) } + smsLookupLoginID := "" + if !strings.Contains(loginID, "@") { + smsLookupLoginID = lookupLoginID + if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + smsLookupLoginID); mapped != "" { + lookupLoginID = mapped + } + } if h.IdpProvider == nil { return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable") @@ -2016,11 +2030,6 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { 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 == "" { @@ -2075,6 +2084,9 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { h.RedisService.Delete(prefixLoginCode + lookupLoginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) + if smsLookupLoginID != "" { + h.RedisService.Delete(prefixLoginCodeSmsLookup + smsLookupLoginID) + } pendingRef := strings.TrimSpace(req.PendingRef) if pendingRef == "" { @@ -2089,6 +2101,9 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) h.RedisService.Delete(prefixLoginCodePending + lookupLoginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) + if smsLookupLoginID != "" { + h.RedisService.Delete(prefixLoginCodeSmsLookup + smsLookupLoginID) + } return c.JSON(fiber.Map{ "status": "approved", "pendingRef": pendingRef, diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index b53ab116..cf9b7857 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -6,6 +6,7 @@ import ( "baron-sso-backend/internal/testsupport" "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -150,6 +151,56 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) { assert.NotEmpty(t, initResp["userCode"]) } +func TestResolveUserfrontURL_DevLocalhostUsesConfiguredPort(t *testing.T) { + t.Setenv("APP_ENV", "dev") + t.Setenv("USERFRONT_URL", "http://localhost:5000") + + h := &AuthHandler{} + app := fiber.New() + app.Get("/probe", func(c *fiber.Ctx) error { + return c.SendString(h.resolveUserfrontURL(c)) + }) + + req := httptest.NewRequest(http.MethodGet, "http://localhost/probe", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, "http://localhost:5000", string(body)) +} + +func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + prefixLoginCode + "su-@samaneng.com": "flow-123", + prefixLoginCodePending + "su-@samaneng.com": "pending-123", + prefixLoginCodeSmsLookup + "+821041585840": "su-@samaneng.com", + prefixLoginCodeSmsTarget + "su-@samaneng.com": "+821041585840", + prefixLoginCodeValue + "pending-123": "569765", + }} + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{}, + } + app := fiber.New() + app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode) + + body, _ := json.Marshal(map[string]interface{}{ + "loginId": "01041585840", + "code": "569765", + "pendingRef": "pending-123", + "verifyOnly": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "approved", got["status"]) + assert.Equal(t, "pending-123", got["pendingRef"]) +} + func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} h := &AuthHandler{ diff --git a/scripts/render_ory_config.sh b/scripts/render_ory_config.sh index d2487101..5c5c5f0f 100755 --- a/scripts/render_ory_config.sh +++ b/scripts/render_ory_config.sh @@ -65,14 +65,14 @@ OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oath export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET -rm -rf "$OUTPUT_DIR" -mkdir -p "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR/kratos" "$OUTPUT_DIR/hydra" "$OUTPUT_DIR/keto" "$OUTPUT_DIR/oathkeeper" render_template "$TEMPLATE_ROOT/kratos/kratos.yml.template" "$OUTPUT_DIR/kratos/kratos.yml" copy_if_exists "$TEMPLATE_ROOT/kratos/identity.schema.json" "$OUTPUT_DIR/kratos/identity.schema.json" copy_if_exists "$TEMPLATE_ROOT/kratos/courier-http.jsonnet" "$OUTPUT_DIR/kratos/courier-http.jsonnet" if [[ -d "$TEMPLATE_ROOT/kratos/courier-templates" ]]; then mkdir -p "$OUTPUT_DIR/kratos" + rm -rf "$OUTPUT_DIR/kratos/courier-templates" cp -a "$TEMPLATE_ROOT/kratos/courier-templates" "$OUTPUT_DIR/kratos/courier-templates" fi @@ -85,6 +85,7 @@ copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.yml" "$OUTPUT_DIR/keto/namespaces render_template "$TEMPLATE_ROOT/oathkeeper/oathkeeper.yml.template" "$OUTPUT_DIR/oathkeeper/oathkeeper.yml" copy_if_exists "$TEMPLATE_ROOT/oathkeeper/entrypoint.sh" "$OUTPUT_DIR/oathkeeper/entrypoint.sh" chmod +x "$OUTPUT_DIR/oathkeeper/entrypoint.sh" +find "$OUTPUT_DIR/oathkeeper" -maxdepth 1 -type f -name 'rules*.json' -delete for rules_file in "$TEMPLATE_ROOT"/oathkeeper/rules*.json; do [[ -e "$rules_file" ]] || continue copy_if_exists "$rules_file" "$OUTPUT_DIR/oathkeeper/$(basename "$rules_file")" diff --git a/test/ory_v26_compose_policy_test.sh b/test/ory_v26_compose_policy_test.sh index ddf467ca..a46f1a6c 100644 --- a/test/ory_v26_compose_policy_test.sh +++ b/test/ory_v26_compose_policy_test.sh @@ -274,6 +274,11 @@ if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging exit 1 fi +if grep -Eq '^[[:space:]]*rm -rf "?\$OUTPUT_DIR"?[[:space:]]*$' "$repo_root/scripts/render_ory_config.sh"; then + echo "ERROR: Ory renderer must preserve config/.generated/ory service directories so live bind mounts stay valid." >&2 + exit 1 +fi + "$repo_root/scripts/render_ory_config.sh" >/dev/null for generated_config in \ From 43b4bd5a83ce85f2b77ed5a76ba1847aee67d9c8 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 7 May 2026 14:01:45 +0900 Subject: [PATCH 2/3] Render Kratos return URLs for staging --- docker/ory/kratos/kratos.yml.template | 33 ++++-------- scripts/render_ory_config.sh | 74 +++++++++++++++++++++++++++ test/ory_v26_compose_policy_test.sh | 26 ++++++++++ 3 files changed, 111 insertions(+), 22 deletions(-) diff --git a/docker/ory/kratos/kratos.yml.template b/docker/ory/kratos/kratos.yml.template index ad713e9a..a82b210f 100644 --- a/docker/ory/kratos/kratos.yml.template +++ b/docker/ory/kratos/kratos.yml.template @@ -4,7 +4,7 @@ dsn: ${KRATOS_DSN} serve: public: - base_url: http://localhost:4433/ + base_url: ${KRATOS_BROWSER_URL:-http://localhost:4433/} cors: enabled: true allowed_origins: @@ -15,7 +15,7 @@ serve: - http://backend:3000 - http://baron_backend:3000 admin: - base_url: http://localhost:4434/ + base_url: ${KRATOS_ADMIN_URL:-http://localhost:4434/} session: cookie: @@ -24,20 +24,9 @@ session: path: / selfservice: - default_browser_return_url: http://localhost:5000/ + default_browser_return_url: ${KRATOS_UI_URL:-http://localhost:5000/} allowed_return_urls: - - http://localhost:5000 - - http://localhost:5000/ - - http://localhost:5000/ko - - http://localhost:5000/ko/ - - http://localhost:5000/en - - http://localhost:5000/en/ - - http://localhost:5000/auth/callback - - http://localhost:5000/ko/auth/callback - - http://localhost:5000/en/auth/callback - - http://localhost:5173/auth/callback - - http://localhost:5174/auth/callback - - http://localhost:5175/auth/callback +${KRATOS_ALLOWED_RETURN_URLS_YAML} methods: password: @@ -50,24 +39,24 @@ selfservice: flows: error: - ui_url: http://localhost:5000/error + ui_url: ${KRATOS_UI_URL:-http://localhost:5000}/error settings: - ui_url: http://localhost:5000/error?error=settings_disabled + ui_url: ${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled privileged_session_max_age: 15m recovery: - ui_url: http://localhost:5000/recovery + ui_url: ${KRATOS_UI_URL:-http://localhost:5000}/recovery use: code verification: - ui_url: http://localhost:5000/verification + ui_url: ${KRATOS_UI_URL:-http://localhost:5000}/verification use: code logout: after: - default_browser_return_url: http://localhost:5000/login + default_browser_return_url: ${KRATOS_UI_URL:-http://localhost:5000}/login login: - ui_url: http://localhost:5000/login + ui_url: ${KRATOS_UI_URL:-http://localhost:5000}/login lifespan: 10m registration: - ui_url: http://localhost:5000/registration + ui_url: ${KRATOS_UI_URL:-http://localhost:5000}/registration lifespan: 10m log: diff --git a/scripts/render_ory_config.sh b/scripts/render_ory_config.sh index 5c5c5f0f..09613e6f 100755 --- a/scripts/render_ory_config.sh +++ b/scripts/render_ory_config.sh @@ -40,6 +40,78 @@ copy_if_exists() { fi } +json_array_to_lines() { + local json="$1" + local newline=$'\n' + json="${json//$'\n'/}" + json="${json#\[}" + json="${json%\]}" + json="${json//\",\"/$newline}" + json="${json//\"/}" + json="${json//,/$newline}" + printf '%s\n' "$json" | sed '/^[[:space:]]*$/d' +} + +append_unique_url() { + local candidate="${1:-}" + [[ -n "$candidate" ]] || return 0 + local existing + for existing in "${KRATOS_ALLOWED_RETURN_URLS[@]}"; do + [[ "$existing" == "$candidate" ]] && return 0 + done + KRATOS_ALLOWED_RETURN_URLS+=("$candidate") +} + +build_kratos_allowed_return_urls_yaml() { + KRATOS_ALLOWED_RETURN_URLS=() + if [[ -n "${KRATOS_ALLOWED_RETURN_URLS_JSON:-}" ]]; then + while IFS= read -r allowed_url; do + append_unique_url "$allowed_url" + done < <(json_array_to_lines "$KRATOS_ALLOWED_RETURN_URLS_JSON") + fi + + if [[ ${#KRATOS_ALLOWED_RETURN_URLS[@]} -eq 0 ]]; then + local kratos_ui="${KRATOS_UI_URL:-http://localhost:5000}" + local userfront="${USERFRONT_URL:-http://localhost:5000}" + local adminfront="${ADMINFRONT_URL:-http://localhost:5173}" + local devfront="${DEVFRONT_URL:-http://localhost:5174}" + local orgfront="${ORGFRONT_URL:-http://localhost:5175}" + + append_unique_url "$kratos_ui" + append_unique_url "$kratos_ui/" + append_unique_url "$userfront" + append_unique_url "$userfront/" + append_unique_url "$userfront/ko" + append_unique_url "$userfront/ko/" + append_unique_url "$userfront/en" + append_unique_url "$userfront/en/" + append_unique_url "$userfront/auth/callback" + append_unique_url "$userfront/ko/auth/callback" + append_unique_url "$userfront/en/auth/callback" + append_unique_url "$adminfront/auth/callback" + append_unique_url "$devfront/auth/callback" + append_unique_url "$orgfront/auth/callback" + fi + + if [[ -n "${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}" ]]; then + IFS=',' read -r -a extra_urls <<<"$KRATOS_ALLOWED_RETURN_URLS_EXTRA" + local extra_url + for extra_url in "${extra_urls[@]}"; do + extra_url="$(printf '%s' "$extra_url" | xargs)" + append_unique_url "$extra_url" + done + fi + + if [[ ${#KRATOS_ALLOWED_RETURN_URLS[@]} -eq 0 ]]; then + fail "Kratos allowed_return_urls is empty" + fi + + KRATOS_ALLOWED_RETURN_URLS_YAML="$( + printf '%s\n' "${KRATOS_ALLOWED_RETURN_URLS[@]}" | sed 's/^/ - /' + )" + export KRATOS_ALLOWED_RETURN_URLS_YAML +} + if [[ -n "${ORY_CONFIG_ENV_FILES:-}" ]]; then IFS=':' read -r -a env_files <<<"$ORY_CONFIG_ENV_FILES" for env_file in "${env_files[@]}"; do @@ -65,6 +137,8 @@ OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oath export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET +build_kratos_allowed_return_urls_yaml + mkdir -p "$OUTPUT_DIR/kratos" "$OUTPUT_DIR/hydra" "$OUTPUT_DIR/keto" "$OUTPUT_DIR/oathkeeper" render_template "$TEMPLATE_ROOT/kratos/kratos.yml.template" "$OUTPUT_DIR/kratos/kratos.yml" diff --git a/test/ory_v26_compose_policy_test.sh b/test/ory_v26_compose_policy_test.sh index a46f1a6c..e13dd46d 100644 --- a/test/ory_v26_compose_policy_test.sh +++ b/test/ory_v26_compose_policy_test.sh @@ -281,6 +281,32 @@ fi "$repo_root/scripts/render_ory_config.sh" >/dev/null +stage_render_dir="$(mktemp -d)" +stage_render_env="$(mktemp)" +cat > "$stage_render_env" <<'EOF' +USERFRONT_URL=https://sso.hmac.kr +ADMINFRONT_URL=https://sadmin.hmac.kr +DEVFRONT_URL=https://sdev.hmac.kr +ORGFRONT_URL=https://sorg.hmac.kr +KRATOS_UI_URL=https://sso.hmac.kr +KRATOS_BROWSER_URL=https://sso.hmac.kr/auth +KRATOS_ADMIN_URL=http://kratos:4434 +ORY_POSTGRES_PASSWORD=policy-test +KRATOS_ALLOWED_RETURN_URLS_JSON= +KRATOS_ALLOWED_RETURN_URLS_EXTRA= +EOF +ORY_CONFIG_ENV_FILES="$stage_render_env" ORY_CONFIG_OUTPUT_DIR="$stage_render_dir/ory" "$repo_root/scripts/render_ory_config.sh" >/dev/null +stage_rendered_kratos="$stage_render_dir/ory/kratos/kratos.yml" +if ! awk '/allowed_return_urls:/ { in_block=1; next } in_block && /^[[:space:]]+methods:/ { exit } in_block { print }' "$stage_rendered_kratos" | grep -q 'https://sso.hmac.kr'; then + echo "ERROR: rendered stage Kratos config must include the public userfront URL in allowed_return_urls." >&2 + exit 1 +fi +if awk '/allowed_return_urls:/ { in_block=1; next } in_block && /^[[:space:]]+methods:/ { exit } in_block { print }' "$stage_rendered_kratos" | grep -q 'http://localhost:5000'; then + echo "ERROR: rendered stage Kratos allowed_return_urls must not fall back to localhost." >&2 + exit 1 +fi +rm -rf "$stage_render_dir" "$stage_render_env" + for generated_config in \ "$repo_root/config/.generated/ory/kratos/kratos.yml" \ "$repo_root/config/.generated/ory/hydra/hydra.yml" \ From 37a878fb935d1880327dec4f5616d714107cde45 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 7 May 2026 14:06:53 +0900 Subject: [PATCH 3/3] Restart Ory after staging config render --- .gitea/workflows/staging_code_pull.yml | 2 ++ test/ory_v26_compose_policy_test.sh | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 4e1a6233..bddb527b 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -175,6 +175,8 @@ jobs: docker compose -f staging_pull_compose.yaml build --pull docker compose -f staging_pull_compose.yaml up -d --remove-orphans + docker compose -f staging_pull_compose.yaml up -d --force-recreate kratos hydra keto oathkeeper + docker compose -f staging_pull_compose.yaml up -d --force-recreate ory_stack_check docker compose -f staging_pull_compose.yaml up -d init-rp # 배포 후 상태 확인 (실패 시 로그 출력을 위함) diff --git a/test/ory_v26_compose_policy_test.sh b/test/ory_v26_compose_policy_test.sh index e13dd46d..1b22b922 100644 --- a/test/ory_v26_compose_policy_test.sh +++ b/test/ory_v26_compose_policy_test.sh @@ -274,6 +274,11 @@ if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging exit 1 fi +if ! grep -q 'up -d --force-recreate kratos hydra keto oathkeeper' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then + echo "ERROR: staging code pull must restart Ory services after rendering static config." >&2 + exit 1 +fi + if grep -Eq '^[[:space:]]*rm -rf "?\$OUTPUT_DIR"?[[:space:]]*$' "$repo_root/scripts/render_ory_config.sh"; then echo "ERROR: Ory renderer must preserve config/.generated/ory service directories so live bind mounts stay valid." >&2 exit 1