Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
SPDX-FileCopyrightText: 2017 SAP SE or an SAP affiliate company
SPDX-License-Identifier: Apache-2.0
-->

# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- POST /{domain}/auth endpoint for token handoff via request body

### Changed

- POST /{domain}/auth now accepts Content-Type values per RFC 9110 §8.3.1, so
case variants such as `Application/X-WWW-Form-Urlencoded; charset=utf-8` are
recognised as form-encoded.
- POST /{domain}/auth redirects now preserve `?global=true` (and the
`X-Global-Region` header equivalent) so the dashboard load binds to the same
Keystone backend that authenticated the handoff.
- POST /{domain}/auth no longer promotes a pre-existing X-Auth-Token cookie
into the request header before the form body is parsed, so a fresh token in
the body takes precedence over a stale session cookie.

### Deprecated

- Passing auth tokens via ?x-auth-token= URL query parameter (use POST endpoint or X-Auth-Token header)
320 changes: 320 additions & 0 deletions pkg/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"

"errors"
Expand Down Expand Up @@ -661,3 +662,322 @@ func TestRedirectPreservesGlobalFlag(t *testing.T) {
assert.Contains(t, location, "global=true", "Redirect should add global flag from header")
})
}

func TestTokenLogin_success(t *testing.T) {
ctrl := gomock.NewController(t)

router, keystoneMock, _ := setupTest(t, ctrl)

// The POST /auth endpoint uses guessScope=true like the graph endpoint.
// Include X-Auth-Token in injected headers so setAuthCookies() can set the cookie.
headerWithToken := map[string]string{
"X-User-Id": projectContext.Auth["user_id"],
"X-User-Name": projectContext.Auth["user_name"],
"X-User-Domain-Name": projectContext.Auth["user_domain_name"],
"X-Project-Id": projectContext.Auth["project_id"],
"X-Project-Name": projectContext.Auth["project_name"],
"X-Auth-Token": "someverylongtokenideed",
}
httpReqMatcher := test.HTTPRequestMatcher{InjectHeader: headerWithToken}
keystoneMock.EXPECT().AuthenticateRequest(test.MatchContext(), httpReqMatcher, true).Return(projectContext, nil)

// POST form body with x-auth-token (the secure alternative to URL query param)
req := httptest.NewRequest(http.MethodPost, "/testdomain/auth", strings.NewReader("x-auth-token=someverylongtokenideed"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

resp := recorder.Result()
defer resp.Body.Close()

// Expect 303 See Other redirect to the graph page
assert.Equal(t, http.StatusSeeOther, resp.StatusCode, "Expected redirect to graph")

// Check redirect target
location := resp.Header.Get("Location")
assert.Equal(t, "/testdomain/graph", location, "Should redirect to domain graph page")

// Check that auth cookie was set
cookies := resp.Cookies()
var tokenCookie *http.Cookie
for _, c := range cookies {
if c.Name == "X-Auth-Token" {
tokenCookie = c
break
}
}
assert.NotNil(t, tokenCookie, "Auth cookie should be set")
if tokenCookie != nil {
assert.True(t, tokenCookie.HttpOnly, "Cookie should be HttpOnly")
assert.True(t, tokenCookie.Secure, "Cookie should be Secure")
}
}

func TestTokenLogin_failAuth(t *testing.T) {
ctrl := gomock.NewController(t)

router, keystoneMock, _ := setupTest(t, ctrl)

httpReqMatcher := test.HTTPRequestMatcher{InjectHeader: projectHeader}
keystoneMock.EXPECT().AuthenticateRequest(test.MatchContext(), httpReqMatcher, true).Return(nil, keystone.NewAuthenticationError(keystone.StatusWrongCredentials, "invalid token"))

req := httptest.NewRequest(http.MethodPost, "/testdomain/auth", strings.NewReader("x-auth-token=invalidtoken"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

resp := recorder.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Invalid token should return 401")
}

func TestTokenLogin_noToken(t *testing.T) {
ctrl := gomock.NewController(t)

router, keystoneMock, _ := setupTest(t, ctrl)

// With no token in POST body and no other credentials, auth should fail
httpReqMatcher := test.HTTPRequestMatcher{InjectHeader: projectHeader}
keystoneMock.EXPECT().AuthenticateRequest(test.MatchContext(), httpReqMatcher, true).Return(nil, keystone.NewAuthenticationError(keystone.StatusMissingCredentials, "Authorization header missing"))

req := httptest.NewRequest(http.MethodPost, "/testdomain/auth", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

resp := recorder.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Missing token should return 401")
}

func TestTokenLogin_bodyTooLarge(t *testing.T) {
prometheus.DefaultRegisterer = prometheus.NewPedanticRegistry()
keystoneInstance = nil
globalKeystoneInstance = nil

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockKeystone := keystone.NewMockDriver(ctrl)
mockStorage := storage.NewMockDriver(ctrl)

// With a too-large body, ParseForm fails, no token extracted,
// so AuthenticateRequest is called with no credentials and should return error.
mockKeystone.EXPECT().ServiceURL().Return("http://localhost:9091").AnyTimes()
mockKeystone.EXPECT().AuthenticateRequest(gomock.Any(), gomock.Any(), true).Return(
nil, keystone.NewAuthenticationError(keystone.StatusMissingCredentials, "missing credentials"))

router := setupRouter(mockKeystone, nil, mockStorage)

// Create body larger than 16KB
largeBody := strings.Repeat("x-auth-token=", 2000) // ~26KB
req := httptest.NewRequest(http.MethodPost, "/testdomain/auth", strings.NewReader(largeBody))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

// Should get 401 (missing credentials) since token extraction failed
assert.Equal(t, http.StatusUnauthorized, w.Code)
}

func TestTokenLogin_wrongContentType(t *testing.T) {
prometheus.DefaultRegisterer = prometheus.NewPedanticRegistry()
keystoneInstance = nil
globalKeystoneInstance = nil

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockKeystone := keystone.NewMockDriver(ctrl)
mockStorage := storage.NewMockDriver(ctrl)

// With wrong Content-Type, body is not parsed, no token found → missing credentials
mockKeystone.EXPECT().ServiceURL().Return("http://localhost:9091").AnyTimes()
mockKeystone.EXPECT().AuthenticateRequest(gomock.Any(), gomock.Any(), true).Return(
nil, keystone.NewAuthenticationError(keystone.StatusMissingCredentials, "missing credentials"))

router := setupRouter(mockKeystone, nil, mockStorage)

body := `{"x-auth-token": "someverylongtokenideed"}`
req := httptest.NewRequest(http.MethodPost, "/testdomain/auth", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

// Should get 401 — JSON body not parsed as form data
assert.Equal(t, http.StatusUnauthorized, w.Code)
}

// TestTokenLogin_caseInsensitiveContentType verifies that POST /{domain}/auth
// accepts an upper-cased Content-Type with parameters, per RFC 9110 §8.3.1.
func TestTokenLogin_caseInsensitiveContentType(t *testing.T) {
ctrl := gomock.NewController(t)

router, keystoneMock, _ := setupTest(t, ctrl)

headerWithToken := map[string]string{
"X-User-Id": projectContext.Auth["user_id"],
"X-User-Name": projectContext.Auth["user_name"],
"X-User-Domain-Name": projectContext.Auth["user_domain_name"],
"X-Project-Id": projectContext.Auth["project_id"],
"X-Project-Name": projectContext.Auth["project_name"],
"X-Auth-Token": "someverylongtokenideed",
}
httpReqMatcher := test.HTTPRequestMatcher{InjectHeader: headerWithToken}
keystoneMock.EXPECT().AuthenticateRequest(test.MatchContext(), httpReqMatcher, true).Return(projectContext, nil)

req := httptest.NewRequest(http.MethodPost, "/testdomain/auth", strings.NewReader("x-auth-token=someverylongtokenideed"))
// Mixed-case media type with charset parameter — must still be parsed as form-encoded.
req.Header.Set("Content-Type", "Application/X-WWW-Form-Urlencoded; charset=utf-8")

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

resp := recorder.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusSeeOther, resp.StatusCode, "Mixed-case Content-Type should be accepted")
assert.Equal(t, "/testdomain/graph", resp.Header.Get("Location"))
}

// TestTokenLogin_redirectPreservesGlobalParam verifies that ?global=true on the
// POST /{domain}/auth request is propagated to the redirected GET so the
// follow-up dashboard load binds to the same Keystone backend.
func TestTokenLogin_redirectPreservesGlobalParam(t *testing.T) {
prometheus.DefaultRegisterer = prometheus.NewPedanticRegistry()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockKeystone := keystone.NewMockDriver(ctrl)
mockGlobalKeystone := keystone.NewMockDriver(ctrl)
mockStorage := storage.NewMockDriver(ctrl)

mockGlobalKeystone.EXPECT().ServiceURL().Return("http://global-maia").AnyTimes()
headerWithToken := map[string]string{
"X-User-Id": projectContext.Auth["user_id"],
"X-User-Name": projectContext.Auth["user_name"],
"X-User-Domain-Name": projectContext.Auth["user_domain_name"],
"X-Project-Id": projectContext.Auth["project_id"],
"X-Project-Name": projectContext.Auth["project_name"],
"X-Auth-Token": "freshglobaltoken",
}
httpReqMatcher := test.HTTPRequestMatcher{InjectHeader: headerWithToken}
mockGlobalKeystone.EXPECT().AuthenticateRequest(test.MatchContext(), httpReqMatcher, true).Return(projectContext, nil)

viper.Set("keystone.policy_file", "../test/policy.json")
viper.Set("maia.label_value_ttl", "72h")
router := setupRouter(mockKeystone, mockGlobalKeystone, mockStorage)

req := httptest.NewRequest(http.MethodPost, "/testdomain/auth?global=true", strings.NewReader("x-auth-token=freshglobaltoken"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

resp := recorder.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
location := resp.Header.Get("Location")
assert.Contains(t, location, "/testdomain/graph", "redirect target must point to the dashboard")
assert.Contains(t, location, "global=true", "redirect must preserve the global selector")
}

// TestTokenLogin_redirectPreservesGlobalHeader verifies that an
// X-Global-Region header on the POST is converted into ?global=true on the
// redirect target, matching parseGlobalRequest precedence.
func TestTokenLogin_redirectPreservesGlobalHeader(t *testing.T) {
prometheus.DefaultRegisterer = prometheus.NewPedanticRegistry()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockKeystone := keystone.NewMockDriver(ctrl)
mockGlobalKeystone := keystone.NewMockDriver(ctrl)
mockStorage := storage.NewMockDriver(ctrl)

mockGlobalKeystone.EXPECT().ServiceURL().Return("http://global-maia").AnyTimes()
headerWithToken := map[string]string{
"X-User-Id": projectContext.Auth["user_id"],
"X-User-Name": projectContext.Auth["user_name"],
"X-User-Domain-Name": projectContext.Auth["user_domain_name"],
"X-Project-Id": projectContext.Auth["project_id"],
"X-Project-Name": projectContext.Auth["project_name"],
"X-Auth-Token": "freshglobaltoken",
}
httpReqMatcher := test.HTTPRequestMatcher{InjectHeader: headerWithToken}
mockGlobalKeystone.EXPECT().AuthenticateRequest(test.MatchContext(), httpReqMatcher, true).Return(projectContext, nil)

viper.Set("keystone.policy_file", "../test/policy.json")
viper.Set("maia.label_value_ttl", "72h")
router := setupRouter(mockKeystone, mockGlobalKeystone, mockStorage)

req := httptest.NewRequest(http.MethodPost, "/testdomain/auth", strings.NewReader("x-auth-token=freshglobaltoken"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Global-Region", "true")

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

resp := recorder.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Location"), "global=true",
"X-Global-Region header must be propagated to the redirect query string")
}

// TestTokenLogin_staleCookieDoesNotShadowFormToken verifies that a stale
// X-Auth-Token cookie sent with the POST /{domain}/auth handoff request does
// NOT pre-empt the fresh token in the form body. The keystone driver must
// receive the body token, not the cookie.
func TestTokenLogin_staleCookieDoesNotShadowFormToken(t *testing.T) {
ctrl := gomock.NewController(t)

router, keystoneMock, _ := setupTest(t, ctrl)

const freshToken = "FRESH_token_from_form_body"
const staleCookieToken = "STALE_token_from_old_cookie"

headerWithToken := map[string]string{
"X-User-Id": projectContext.Auth["user_id"],
"X-User-Name": projectContext.Auth["user_name"],
"X-User-Domain-Name": projectContext.Auth["user_domain_name"],
"X-Project-Id": projectContext.Auth["project_id"],
"X-Project-Name": projectContext.Auth["project_name"],
"X-Auth-Token": freshToken,
}
// ExpectHeader asserts the request handed to AuthenticateRequest has NO
// X-Auth-Token at this stage (that header is set inside authOptionsFromRequest
// from the parsed form body, not by the api-level cookie promotion).
httpReqMatcher := test.HTTPRequestMatcher{
ExpectHeader: map[string]string{"X-Auth-Token": "^$"},
InjectHeader: headerWithToken,
}
keystoneMock.EXPECT().AuthenticateRequest(test.MatchContext(), httpReqMatcher, true).Return(projectContext, nil)

req := httptest.NewRequest(http.MethodPost, "/testdomain/auth", strings.NewReader("x-auth-token="+freshToken))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{
Name: "X-Auth-Token",
Value: staleCookieToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

resp := recorder.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusSeeOther, resp.StatusCode,
"stale cookie + fresh form token should still authenticate via the form token")
}
Loading
Loading