From 71a006cd7bb80fb560a8987232caeb9e4732106b Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Wed, 1 Apr 2026 21:05:41 +0900 Subject: [PATCH] fix(headless-login): honor public base url for audience checks - resolve headless audience against BACKEND_PUBLIC_URL first - keep forwarded header support for https absolute audiences - add regression tests for https success and http mismatch rejection - write BACKEND_PUBLIC_URL into staging workflow env generation --- .env.sample | 1 + .gitea/workflows/staging_code_pull.yml | 1 + .gitea/workflows/staging_release.yml | 1 + backend/cmd/server/headless_login_e2e_test.go | 115 +++++++++++++- backend/internal/handler/auth_handler.go | 118 +++++++++++++-- .../handler/auth_handler_login_test.go | 141 +++++++++++++++++- docs/test-plan/backend-test-inventory.md | 13 +- scripts/test_staging_workflow_env.sh | 1 + 8 files changed, 372 insertions(+), 19 deletions(-) diff --git a/.env.sample b/.env.sample index 3ccdca39..89e4daf8 100644 --- a/.env.sample +++ b/.env.sample @@ -61,6 +61,7 @@ ADMIN_PASSWORD=adminPasswordIsNotSimple USERFRONT_URL=https://sso.hmac.kr # Services proxied via Nginx +BACKEND_PUBLIC_URL=${USERFRONT_URL} BACKEND_URL=${USERFRONT_URL} OATHKEEPER_PUBLIC_URL=${USERFRONT_URL} diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 3278c443..a787993c 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -91,6 +91,7 @@ jobs: USERFRONT_URL=${{ vars.USERFRONT_URL }} ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }} DEVFRONT_URL=${{ vars.DEVFRONT_URL }} + BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }} BACKEND_URL=${{ vars.BACKEND_URL }} OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }} ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }} diff --git a/.gitea/workflows/staging_release.yml b/.gitea/workflows/staging_release.yml index 093eaafb..edbb8d90 100644 --- a/.gitea/workflows/staging_release.yml +++ b/.gitea/workflows/staging_release.yml @@ -98,6 +98,7 @@ jobs: ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }} ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }} USERFRONT_URL=${{ vars.USERFRONT_URL }} + BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }} BACKEND_URL=${{ vars.BACKEND_URL }} OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }} ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }} diff --git a/backend/cmd/server/headless_login_e2e_test.go b/backend/cmd/server/headless_login_e2e_test.go index 7bdab1d6..f91a5b53 100644 --- a/backend/cmd/server/headless_login_e2e_test.go +++ b/backend/cmd/server/headless_login_e2e_test.go @@ -248,6 +248,28 @@ func runHeadlessPasswordLoginE2E( jwks map[string]any, clientAssertion string, ) (*http.Response, string) { + return runHeadlessPasswordLoginE2ERequest( + t, + logger, + appEnv, + jwks, + clientAssertion, + "http://example.com/api/v1/auth/headless/password/login", + nil, + ) +} + +func runHeadlessPasswordLoginE2ERequest( + t *testing.T, + logger *slog.Logger, + appEnv string, + jwks map[string]any, + clientAssertion string, + requestURL string, + headers map[string]string, +) (*http.Response, string) { + t.Helper() + t.Helper() logBuffer := &bytes.Buffer{} @@ -324,8 +346,11 @@ func runHeadlessPasswordLoginE2E( "password": "password", "login_challenge": "challenge-123", }) - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, requestURL, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } resp, err := app.Test(req) if err != nil { @@ -417,3 +442,91 @@ func TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics(t *testing.T) { t.Fatalf("expected debug logs to include login challenge prefix, got=%s", output) } } + +func TestHeadlessPasswordLogin_E2E_AcceptsForwardedHTTPSAudience(t *testing.T) { + privateKey, jwks := mustE2EHeadlessRSAJWK(t) + const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login" + clientAssertion := mustE2EHeadlessClientAssertion( + t, + privateKey, + "headless-login-client", + receivedAudience, + ) + + logBuffer := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug})) + + resp, output := runHeadlessPasswordLoginE2ERequest( + t, + logger, + "production", + jwks, + clientAssertion, + "http://sso.hmac.kr/api/v1/auth/headless/password/login", + map[string]string{ + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "sso.hmac.kr", + }, + ) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200 for forwarded https audience, got %d, body=%s", resp.StatusCode, string(bodyBytes)) + } + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got["redirectTo"] != "http://rp/cb" { + t.Fatalf("expected redirectTo, got=%v", got["redirectTo"]) + } + + if strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") { + t.Fatalf("did not expect audience mismatch log, got=%s", output) + } +} + +func TestHeadlessPasswordLogin_E2E_AcceptsConfiguredPublicHTTPSAudience(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "https://sso.hmac.kr") + + privateKey, jwks := mustE2EHeadlessRSAJWK(t) + const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login" + clientAssertion := mustE2EHeadlessClientAssertion( + t, + privateKey, + "headless-login-client", + receivedAudience, + ) + + logBuffer := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug})) + + resp, output := runHeadlessPasswordLoginE2ERequest( + t, + logger, + "production", + jwks, + clientAssertion, + "http://sso.hmac.kr/api/v1/auth/headless/password/login", + nil, + ) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200 for configured public https audience, got %d, body=%s", resp.StatusCode, string(bodyBytes)) + } + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got["redirectTo"] != "http://rp/cb" { + t.Fatalf("expected redirectTo, got=%v", got["redirectTo"]) + } + if strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") { + t.Fatalf("did not expect audience mismatch log, got=%s", output) + } +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index faaa356a..ec1aa584 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -703,30 +703,124 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string { return parts[1] } -func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string { - // 1. Try to use the Host header from the request - host := c.Get("X-Forwarded-Host") +func firstForwardedValue(raw string) string { + for _, part := range strings.Split(raw, ",") { + value := strings.TrimSpace(part) + if value != "" { + return value + } + } + return "" +} + +func forwardedDirective(raw, key string) string { + for _, group := range strings.Split(raw, ",") { + for _, directive := range strings.Split(group, ";") { + pair := strings.SplitN(strings.TrimSpace(directive), "=", 2) + if len(pair) != 2 { + continue + } + if !strings.EqualFold(strings.TrimSpace(pair[0]), key) { + continue + } + return strings.Trim(strings.TrimSpace(pair[1]), "\"") + } + } + return "" +} + +func normalizedAbsoluteBaseURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + + parsed.RawQuery = "" + parsed.Fragment = "" + return strings.TrimRight(parsed.String(), "/") +} + +func forwardedRequestHost(c *fiber.Ctx) string { + if c == nil { + return "" + } + if host := firstForwardedValue(c.Get("X-Forwarded-Host")); host != "" { + return host + } + if host := forwardedDirective(c.Get("Forwarded"), "host"); host != "" { + return host + } + return "" +} + +func forwardedRequestProto(c *fiber.Ctx) string { + if c == nil { + return "" + } + if proto := firstForwardedValue(c.Get("X-Forwarded-Proto")); proto != "" { + return strings.ToLower(proto) + } + if proto := forwardedDirective(c.Get("Forwarded"), "proto"); proto != "" { + return strings.ToLower(proto) + } + return "" +} + +func resolvePublicRequestBaseURL(c *fiber.Ctx, configuredBaseURL string) string { + if base := normalizedAbsoluteBaseURL(configuredBaseURL); base != "" { + return base + } + if c == nil { + return "" + } + + host := forwardedRequestHost(c) + proto := forwardedRequestProto(c) + if host != "" && proto != "" { + return fmt.Sprintf("%s://%s", proto, host) + } + + base := strings.TrimRight(strings.TrimSpace(c.BaseURL()), "/") + if base != "" { + return base + } + + host = strings.TrimSpace(c.Get("Host")) if host == "" { - host = c.Hostname() + host = strings.TrimSpace(c.Hostname()) } - - // 2. Determine scheme - scheme := "https" - if os.Getenv("APP_ENV") == "dev" || os.Getenv("APP_ENV") == "" || c.Protocol() == "http" { - scheme = "http" + proto = strings.ToLower(strings.TrimSpace(c.Protocol())) + if host == "" || proto == "" { + return "" } + return fmt.Sprintf("%s://%s", proto, host) +} - // 3. Fallback to env if host is not available or is localhost (and not in dev) +func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string { envURL := os.Getenv("USERFRONT_URL") if envURL == "" { envURL = "http://sso.hmac.kr" } + baseURL := resolvePublicRequestBaseURL(c, "") + host := strings.TrimSpace(forwardedRequestHost(c)) + if host == "" { + host = strings.TrimSpace(c.Hostname()) + } + if host == "" || (host == "localhost" && os.Getenv("APP_ENV") != "dev") { return strings.TrimRight(envURL, "/") } - return fmt.Sprintf("%s://%s", scheme, host) + if baseURL == "" { + return strings.TrimRight(envURL, "/") + } + return baseURL } func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error { @@ -1751,7 +1845,7 @@ func headlessAssertionAudiences(c *fiber.Ctx) []string { return nil } - base := strings.TrimRight(strings.TrimSpace(c.BaseURL()), "/") + base := resolvePublicRequestBaseURL(c, os.Getenv("BACKEND_PUBLIC_URL")) if base == "" { return []string{path} } diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 3ea9ca50..5386bdc5 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -336,6 +336,16 @@ func mustHeadlessClientAssertionWithAlgorithm(t *testing.T, privateKey any, alg } func runHeadlessPasswordLoginWithAssertion(t *testing.T, jwks map[string]any, clientAssertion string) *http.Response { + return runHeadlessPasswordLoginWithAssertionRequest(t, jwks, clientAssertion, "http://example.com/api/v1/auth/headless/password/login", nil) +} + +func runHeadlessPasswordLoginWithAssertionRequest( + t *testing.T, + jwks map[string]any, + clientAssertion string, + requestURL string, + headers map[string]string, +) *http.Response { t.Helper() mockIdp := new(MockIdentityProvider) @@ -399,8 +409,11 @@ func runHeadlessPasswordLoginWithAssertion(t *testing.T, jwks map[string]any, cl "password": "password", "login_challenge": "challenge-123", }) - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, requestURL, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } resp, err := app.Test(req) if err != nil { @@ -414,6 +427,24 @@ func runHeadlessPasswordLoginWithAssertionAndLogger( jwks map[string]any, clientAssertion string, logger *slog.Logger, +) *http.Response { + return runHeadlessPasswordLoginWithAssertionAndLoggerRequest( + t, + jwks, + clientAssertion, + "http://example.com/api/v1/auth/headless/password/login", + nil, + logger, + ) +} + +func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( + t *testing.T, + jwks map[string]any, + clientAssertion string, + requestURL string, + headers map[string]string, + logger *slog.Logger, ) *http.Response { t.Helper() @@ -478,9 +509,12 @@ func runHeadlessPasswordLoginWithAssertionAndLogger( "password": "password", "login_challenge": "challenge-123", }) - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, requestURL, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Request-Id", "req-headless-test-123") + for key, value := range headers { + req.Header.Set(key, value) + } if logger != nil { previous := slog.Default() @@ -1060,6 +1094,109 @@ func TestHeadlessPasswordLogin_AudienceMismatchReturnsDetailedCode(t *testing.T) } } +func TestHeadlessPasswordLogin_AcceptsForwardedHTTPSAudience(t *testing.T) { + privateKey, jwks := mustHeadlessRSAJWK(t) + clientAssertion := mustHeadlessClientAssertion( + t, + privateKey, + "headless-login-client", + "https://sso.hmac.kr/api/v1/auth/headless/password/login", + ) + + resp := runHeadlessPasswordLoginWithAssertionRequest( + t, + jwks, + clientAssertion, + "http://sso.hmac.kr/api/v1/auth/headless/password/login", + map[string]string{ + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "sso.hmac.kr", + }, + ) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200 for forwarded https audience, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["redirectTo"] != "http://rp/cb" { + t.Fatalf("expected redirectTo, got=%v", got["redirectTo"]) + } +} + +func TestHeadlessPasswordLogin_AcceptsConfiguredPublicHTTPSAudience(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "https://sso.hmac.kr") + + privateKey, jwks := mustHeadlessRSAJWK(t) + clientAssertion := mustHeadlessClientAssertion( + t, + privateKey, + "headless-login-client", + "https://sso.hmac.kr/api/v1/auth/headless/password/login", + ) + + resp := runHeadlessPasswordLoginWithAssertionRequest( + t, + jwks, + clientAssertion, + "http://sso.hmac.kr/api/v1/auth/headless/password/login", + nil, + ) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200 for configured public https audience, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["redirectTo"] != "http://rp/cb" { + t.Fatalf("expected redirectTo, got=%v", got["redirectTo"]) + } +} + +func TestHeadlessPasswordLogin_RejectsHTTPAudienceWhenConfiguredPublicURLIsHTTPS(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "https://sso.hmac.kr") + + privateKey, jwks := mustHeadlessRSAJWK(t) + clientAssertion := mustHeadlessClientAssertion( + t, + privateKey, + "headless-login-client", + "http://sso.hmac.kr/api/v1/auth/headless/password/login", + ) + + resp := runHeadlessPasswordLoginWithAssertionRequest( + t, + jwks, + clientAssertion, + "http://sso.hmac.kr/api/v1/auth/headless/password/login", + nil, + ) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 401 for mismatched http audience, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["code"] != "invalid_client_assertion_audience" { + t.Fatalf("expected invalid_client_assertion_audience, got=%v", got["code"]) + } +} + func TestHeadlessPasswordLogin_IssSubMismatchReturnsDetailedCode(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) now := time.Now() diff --git a/docs/test-plan/backend-test-inventory.md b/docs/test-plan/backend-test-inventory.md index a14ced0d..ee223920 100644 --- a/docs/test-plan/backend-test-inventory.md +++ b/docs/test-plan/backend-test-inventory.md @@ -9,8 +9,10 @@ | `backend/cmd/server/error_handler_test.go:48` | `TestNewErrorHandler_ProductionPassesClientError` | 오류/예외/거부 경로 검증 | | `backend/cmd/server/error_handler_test.go:73` | `TestNewErrorHandler_DevelopmentReturnsOriginalServerError` | 오류/예외/거부 경로 검증 | | `backend/cmd/server/error_handler_test.go:98` | `TestNewErrorHandler_MapsUnauthorizedCode` | 오류/예외/거부 경로 검증 | -| `backend/cmd/server/headless_login_e2e_test.go:338` | `TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs` | 실제 app 경로에서 headless login 401 응답 본문과 구조화 로그를 함께 검증 | -| `backend/cmd/server/headless_login_e2e_test.go:382` | `TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics` | 실제 app 경로에서 debug 레벨일 때만 headless 진단 필드가 로그에 포함되는지 검증 | +| `backend/cmd/server/headless_login_e2e_test.go:363` | `TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs` | 실제 app 경로에서 headless login 401 응답 본문과 구조화 로그를 함께 검증 | +| `backend/cmd/server/headless_login_e2e_test.go:407` | `TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics` | 실제 app 경로에서 debug 레벨일 때만 headless 진단 필드가 로그에 포함되는지 검증 | +| `backend/cmd/server/headless_login_e2e_test.go:446` | `TestHeadlessPasswordLogin_E2E_AcceptsForwardedHTTPSAudience` | forwarded proto/host가 있는 경우 `https` absolute audience가 실제 app 경로에서 성공하는지 검증 | +| `backend/cmd/server/headless_login_e2e_test.go:491` | `TestHeadlessPasswordLogin_E2E_AcceptsConfiguredPublicHTTPSAudience` | `BACKEND_PUBLIC_URL` 기준으로 internal `http` 요청에서도 `https` absolute audience가 성공하는지 검증 | | `backend/internal/handler/api_key_handler_test.go:19` | `TestApiKeyHandler_CreateApiKey` | 핵심 CRUD/서비스 동작 검증 | | `backend/internal/handler/api_key_handler_test.go:41` | `TestApiKeyHandler_Validation` | 유효성/정책/유틸 검증 | | `backend/internal/handler/auth_handler_async_test.go:198` | `TestSignup_AsyncDB_Isolation` | 복구/격리/회복 탄력성 검증 | @@ -29,8 +31,11 @@ | `backend/internal/handler/auth_handler_login_test.go:1033` | `TestHeadlessPasswordLogin_AudienceMismatchReturnsDetailedCode` | headless 로그인 audience mismatch 분류 검증 | | `backend/internal/handler/auth_handler_login_test.go:1062` | `TestHeadlessPasswordLogin_IssSubMismatchReturnsDetailedCode` | headless 로그인 iss/sub mismatch 분류 검증 | | `backend/internal/handler/auth_handler_login_test.go:1102` | `TestHeadlessPasswordLogin_ExpiredAssertionReturnsDetailedCode` | headless 로그인 만료 assertion 분류 검증 | -| `backend/internal/handler/auth_handler_login_test.go:1142` | `TestHeadlessPasswordLogin_DebugLogIncludesDiagnostics` | debug 로그에서만 진단 필드가 노출되는지 검증 | -| `backend/internal/handler/auth_handler_login_test.go:1174` | `TestHeadlessPasswordLogin_InfoLogOmitsDebugDiagnostics` | 일반 로그에서 debug 진단 필드가 숨겨지는지 검증 | +| `backend/internal/handler/auth_handler_login_test.go:1097` | `TestHeadlessPasswordLogin_AcceptsForwardedHTTPSAudience` | forwarded proto/host가 있는 경우 `https` absolute audience가 성공하는지 검증 | +| `backend/internal/handler/auth_handler_login_test.go:1132` | `TestHeadlessPasswordLogin_AcceptsConfiguredPublicHTTPSAudience` | `BACKEND_PUBLIC_URL` 기준으로 internal `http` 요청에서도 `https` absolute audience가 성공하는지 검증 | +| `backend/internal/handler/auth_handler_login_test.go:1166` | `TestHeadlessPasswordLogin_RejectsHTTPAudienceWhenConfiguredPublicURLIsHTTPS` | `BACKEND_PUBLIC_URL=https`일 때 잘못된 `http` absolute audience는 계속 거부되는지 검증 | +| `backend/internal/handler/auth_handler_login_test.go:1280` | `TestHeadlessPasswordLogin_DebugLogIncludesDiagnostics` | debug 로그에서만 진단 필드가 노출되는지 검증 | +| `backend/internal/handler/auth_handler_login_test.go:1312` | `TestHeadlessPasswordLogin_InfoLogOmitsDebugDiagnostics` | 일반 로그에서 debug 진단 필드가 숨겨지는지 검증 | | `backend/internal/handler/auth_handler_login_test.go:1442` | `TestPasswordLogin_InvalidCredentials_ReturnsCode` | 비밀번호 불일치 응답 코드 검증 | | `backend/internal/handler/auth_handler_oidc_test.go:106` | `TestAcceptOidcLoginRequest_TokenFallbackToCookie` | 복구/격리/회복 탄력성 검증 | | `backend/internal/handler/auth_handler_oidc_test.go:21` | `TestAcceptOidcLoginRequest_CookieOnly` | 인증/OIDC 플로우 검증 | diff --git a/scripts/test_staging_workflow_env.sh b/scripts/test_staging_workflow_env.sh index 925384ad..977541a8 100644 --- a/scripts/test_staging_workflow_env.sh +++ b/scripts/test_staging_workflow_env.sh @@ -17,6 +17,7 @@ do assert_contains "$workflow" "APP_ENV=stage" assert_contains "$workflow" "BACKEND_LOG_LEVEL=debug" assert_contains "$workflow" "CLIENT_LOG_DEBUG=true" + assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}' done echo "staging workflow env checks passed"