Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!--
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

### Deprecated

- Passing auth tokens via ?x-auth-token= URL query parameter (use POST endpoint or X-Auth-Token header)
151 changes: 151 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,153 @@ 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)
}
20 changes: 20 additions & 0 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ func setupRouter(keystoneDriver, globalKeystoneDriver keystone.Driver, storageDr

// domain-prefixed paths. Order is relevant! This implies that there must be no domain federate, static or graph :-)
mainRouter.Methods(http.MethodGet).Path("/{domain}/graph").HandlerFunc(authorize(observeDuration(observeResponseSize(graph, "graph"), "graph"), true, "metric:show"))
// POST-based token login: accepts token in request body instead of URL query parameter.
// This avoids exposing tokens in URLs (browser history, server logs, Referrer headers).
// After successful auth (handled by authorize middleware which sets the cookie),
// redirects to the dashboard.
mainRouter.Methods(http.MethodPost).Path("/{domain}/auth").HandlerFunc(authorize(observeDuration(tokenLogin, "auth"), true, "metric:show"))
Comment thread
notque marked this conversation as resolved.
mainRouter.Methods(http.MethodGet).Path("/{domain}").HandlerFunc(redirectToDomainRootPage)

// provide the inflight metrics for all paths
Expand Down Expand Up @@ -229,6 +234,21 @@ func Federate(w http.ResponseWriter, req *http.Request) {
ReturnResponse(w, response)
}

// tokenLogin handles POST /{domain}/auth for secure token handoff.
// The authorize middleware has already validated the token and set the auth cookie.
// This handler simply redirects to the dashboard.
func tokenLogin(w http.ResponseWriter, req *http.Request) {
domain, ok := mux.Vars(req)["domain"]
if !ok || !validDomain.MatchString(domain) {
logg.Debug("Invalid domain in token login: %s", domain)
http.Error(w, "Invalid domain", http.StatusBadRequest)
return
}
logg.Debug("Token login redirect for domain %s", domain)
// Redirect to the expression browser (cookie is already set by authorize middleware)
http.Redirect(w, req, "/"+url.PathEscape(domain)+"/graph", http.StatusSeeOther)
Comment thread
notque marked this conversation as resolved.
Outdated
}

// graph returns the Prometheus UI page
func graph(w http.ResponseWriter, req *http.Request) {
// Get keystone from context (secure, race-condition-free approach)
Expand Down
25 changes: 25 additions & 0 deletions pkg/keystone/keystone.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package keystone

import (
"context"
"errors"
"fmt"

"net/http"
Expand Down Expand Up @@ -421,10 +422,34 @@ func (d *keystone) authOptionsFromRequest(ctx context.Context, r *http.Request,
ba.TokenID = token
} else if token := query.Get("x-auth-token"); token != "" {
// perfect: we have a token and thus a authorization scope (albeit in lower-case)
logg.Info("DEPRECATION: token passed via URL query parameter on %s; migrate to POST /{domain}/auth or X-Auth-Token header", r.URL.Path)
ba.TokenID = token
// relocate to header
query.Del("x-auth-token")
Comment thread
notque marked this conversation as resolved.
Outdated
r.Header.Set("X-Auth-Token", ba.TokenID)
} else if r.Method == http.MethodPost && r.Body != nil &&
strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
Comment thread
notque marked this conversation as resolved.
Outdated
// Secure alternative: token submitted via POST body (avoids URL exposure).
// Limit body size to prevent memory exhaustion — a Keystone token is ~200 bytes.
r.Body = http.MaxBytesReader(nil, r.Body, 16*1024)
Comment thread
notque marked this conversation as resolved.
Outdated
if err := r.ParseForm(); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
logg.Error("POST body exceeds size limit on %s", r.URL.Path)
} else {
logg.Info("POST body parse failed for %s: %v", r.URL.Path, err)
}
// Fall through — will hit "missing credentials" path below
} else if token := r.PostForm.Get("x-auth-token"); token != "" {
ba.TokenID = token
r.Header.Set("X-Auth-Token", ba.TokenID)
} else {
logg.Debug("POST body present but no x-auth-token field for %s", r.URL.Path)
}
}

if ba.TokenID != "" {
// Token was found (via header, URL query, or POST body) — skip other auth methods
} else if (appCredID != "" && appCredSecret != "") || (appCredName != "" && appCredUserName != "") {
ba.ApplicationCredentialID = appCredID
ba.ApplicationCredentialName = appCredName
Expand Down
Loading