1
0
forked from baron/baron-sso

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
This commit is contained in:
Lectom C Han
2026-04-01 21:05:41 +09:00
parent 3186fab596
commit 71a006cd7b
8 changed files with 372 additions and 19 deletions

View File

@@ -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}
}

View File

@@ -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()