Skip to content

Commit 4ab1ba2

Browse files
committed
feat(auth): make token header and prefix configurable via flags and env
- Introduced `AuthTokenHeader` and `AuthTokenPrefix` fields to EnvConfig - Default token extraction uses `Authorization` header with `Bearer ` prefix - Updated TokenClientFactory to dynamically parse token using configured header and prefix - Added new CLI flags: `--auth-token-header` and `--auth-token-prefix` - Updated Makefile to support overriding header and prefix via `AUTH_TOKEN_HEADER` and `AUTH_TOKEN_PREFIX` - Improved error messages and testability of token parsing logic - Added Ginkgo unit tests for TokenClientFactory.ExtractRequestIdentity with and without prefixes - Cleaned up README: - Replaced all `X-Forwarded-Access-Token` references with `Authorization: Bearer` - Documented how to override token header and prefix via CLI, env, or Makefile Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>
1 parent 1a27427 commit 4ab1ba2

8 files changed

Lines changed: 173 additions & 25 deletions

File tree

clients/ui/bff/Makefile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ DEV_MODE ?= false
55
DEV_MODE_PORT ?= 8080
66
STANDALONE_MODE ?= true
77
AUTH_METHOD ?= internal
8+
AUTH_TOKEN_HEADER ?= Authorization
9+
AUTH_TOKEN_PREFIX ?= Bearer\
810
#frontend static assets root directory
911
STATIC_ASSETS_DIR ?= ./static
1012
# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary.
@@ -51,7 +53,7 @@ build: fmt vet test ## Builds the project to produce a binary executable.
5153
.PHONY: run
5254
run: fmt vet envtest ## Runs the project.
5355
ENVTEST_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \
54-
go run ./cmd --port=$(PORT) --auth-method=${AUTH_METHOD} --static-assets-dir=$(STATIC_ASSETS_DIR) --mock-k8s-client=$(MOCK_K8S_CLIENT) --mock-mr-client=$(MOCK_MR_CLIENT) --dev-mode=$(DEV_MODE) --dev-mode-port=$(DEV_MODE_PORT) --standalone-mode=$(STANDALONE_MODE) --log-level=$(LOG_LEVEL) --allowed-origins=$(ALLOWED_ORIGINS)
56+
go run ./cmd --port=$(PORT) --auth-method=${AUTH_METHOD} --auth-token-header=$(AUTH_TOKEN_HEADER) --auth-token-prefix="$(AUTH_TOKEN_PREFIX)" --static-assets-dir=$(STATIC_ASSETS_DIR) --mock-k8s-client=$(MOCK_K8S_CLIENT) --mock-mr-client=$(MOCK_MR_CLIENT) --dev-mode=$(DEV_MODE) --dev-mode-port=$(DEV_MODE_PORT) --standalone-mode=$(STANDALONE_MODE) --log-level=$(LOG_LEVEL) --allowed-origins=$(ALLOWED_ORIGINS)
5557

5658
##@ Dependencies
5759

clients/ui/bff/README.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,18 @@ curl -i "localhost:4000/healthcheck"
105105
```
106106
# GET /v1/user
107107
curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/user"
108-
curl -i -H "X-Forwarded-Access-Token: $TOKEN" "localhost:4000/api/v1/user"
108+
curl -i -H "Authorization: Bearer $TOKEN" "localhost:4000/api/v1/user"
109109
```
110110
```
111111
# GET /v1/namespaces (only works when DEV_MODE=true)
112112
curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/namespaces"
113-
curl -i -H "X-Forwarded-Access-Token: $TOKEN" "localhost:4000/api/v1/namespaces"
113+
curl -i -H "Authorization: Bearer $TOKEN" "localhost:4000/api/v1/namespaces"
114114
```
115115

116116
```
117117
# GET /v1/model_registry
118118
curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry?namespace=kubeflow"
119-
curl -i -H "X-Forwarded-Access-Token: $TOKEN" "localhost:4000/api/v1/model_registry?namespace=kubeflow"
119+
curl -i -H "Authorization: Bearer $TOKEN" "localhost:4000/api/v1/model_registry?namespace=kubeflow"
120120
```
121121

122122
```
@@ -130,7 +130,7 @@ curl -i \
130130
```
131131
# GET /v1/model_registry/{model_registry_id}/registered_models
132132
curl -i -H "kubeflow-userid: user@example.com" "localhost:4000/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow"
133-
curl -i -H "X-Forwarded-Access-Token: $TOKEN" "localhost:4000/api/v1/model_registry/model-registry-service/registered_models?namespace=kubeflow-user-example-com""
133+
curl -i -H "Authorization: Bearer $TOKEN" "localhost:4000/api/v1/model_registry/model-registry-service/registered_models?namespace=kubeflow-user-example-com""
134134
```
135135

136136
```
@@ -381,17 +381,16 @@ The BFF supports two authentication modes, selectable via the --auth-method flag
381381
- In this mode, user identity is passed via the kubeflow-userid and optionally kubeflow-groups headers.
382382
- This is the default mode and works well with mock clients and local testing.
383383
- `user_token`: Uses a user-provided Bearer token for authentication.
384-
- The token must be passed in the `X-Forwarded-Access-Token` header.
385-
- Useful when the frontend is fronted by an auth proxy (e.g., Istio + OIDC) that injects a valid Kubernetes token.
386-
384+
- The token must be passed in the `Authorization` header using the Bearer schema (e.g., `Authorization: Bearer <token>`).
385+
- This method works with OIDC-authenticated flows and frontend proxies that preserve standard Bearer tokens.
387386

388387
#### 4. How BFF authorization works?
389388

390389
Authorization is performed using Kubernetes access reviews, validating whether the user (or their groups) can perform certain actions.
391390
There are two review mechanisms depending on the authentication mode:
392391
- Internal mode (auth-method=internal):
393392
Uses SubjectAccessReview (SAR) to check whether the impersonated user (from kubeflow-userid and kubeflow-groups headers) has the required permissions.
394-
- User token mode (auth-method=user_token): Uses SelfSubjectAccessReview (SSAR), leveraging the Bearer token provided in the X-Forwarded-Access-Token header to check the current user's permissions directly.
393+
- User token mode (auth-method=user_token): Uses SelfSubjectAccessReview (SSAR), leveraging the Bearer token provided in the `Authorization` header to check the current user's permissions directly.
395394

396395
##### Authorization logic
397396
* Access to Model Registry List (/v1/model_registry):
@@ -402,6 +401,25 @@ Uses SubjectAccessReview (SAR) to check whether the impersonated user (from kube
402401
- Checks for get on the specific service (identified by model_registry_id) in the namespace.
403402
- If authorized, access is granted.
404403

404+
##### Overriding Token Header and Prefix
405+
406+
By default, the BFF expects the token to be passed in the standard Authorization header with a Bearer prefix:
407+
408+
```shell
409+
Authorization: Bearer <your-token>
410+
```
411+
If you're integrating with a proxy or tool that uses a custom header (e.g., X-Forwarded-Access-Token without a prefix), you can override this behavior using environment variables or Makefile arguments.
412+
413+
```shell
414+
make run AUTH_METHOD=user_token AUTH_TOKEN_HEADER=X-Forwarded-Access-Token AUTH_TOKEN_PREFIX=""
415+
```
416+
This will configure the BFF to extract the raw token from the following header:
417+
418+
```shell
419+
X-Forwarded-Access-Token: <your-token>
420+
```
421+
422+
405423
#### 5. How do I allow CORS requests from other origins
406424

407425
When serving the UI directly from the BFF there is no need for any CORS headers to be served, by default they are turned off for security reasons.

clients/ui/bff/cmd/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ func main() {
3333
flag.TextVar(&cfg.LogLevel, "log-level", parseLevel(getEnvAsString("LOG_LEVEL", "INFO")), "Sets server log level, possible values: error, warn, info, debug")
3434
flag.Func("allowed-origins", "Sets allowed origins for CORS purposes, accepts a comma separated list of origins or * to allow all, default none", newOriginParser(&cfg.AllowedOrigins, getEnvAsString("ALLOWED_ORIGINS", "")))
3535
flag.StringVar(&cfg.AuthMethod, "auth-method", "internal", "Authentication method (internal or user_token)")
36+
flag.StringVar(&cfg.AuthTokenHeader, "auth-token-header", getEnvAsString("AUTH_TOKEN_HEADER", config.DefaultAuthTokenHeader), "Header used to extract the token (e.g., Authorization)")
37+
flag.StringVar(&cfg.AuthTokenPrefix, "auth-token-prefix", getEnvAsString("AUTH_TOKEN_PREFIX", config.DefaultAuthTokenPrefix), "Prefix used in the token header (e.g., 'Bearer ')")
3638
flag.Parse()
3739

3840
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{

clients/ui/bff/internal/config/environment.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ const (
1212

1313
// AuthMethodUser uses a user-provided Bearer token for authentication.
1414
AuthMethodUser = "user_token"
15+
16+
// DefaultAuthTokenHeader is the standard header for Bearer token auth.
17+
DefaultAuthTokenHeader = "Authorization"
18+
19+
// DefaultAuthTokenPrefix is the prefix used in the Authorization header.
20+
// note: the space here is intentional, as the prefix is "Bearer " (with a space).
21+
DefaultAuthTokenPrefix = "Bearer "
1522
)
1623

1724
type EnvConfig struct {
@@ -24,6 +31,17 @@ type EnvConfig struct {
2431
StaticAssetsDir string
2532
LogLevel slog.Level
2633
AllowedOrigins []string
27-
// Either AuthMethodInternal or AuthMethodUser
34+
35+
// ─── AUTH ───────────────────────────────────────────────────
36+
// Specifies the authentication method used by the server.
37+
// Valid values: "internal" or "user_token"
2838
AuthMethod string
39+
40+
// Header used to extract the authentication token.
41+
// Default is "Authorization" and can be overridden via CLI/env for proxy integration scenarios.
42+
AuthTokenHeader string
43+
44+
// Optional prefix to strip from the token header value.
45+
// Default is "Bearer ", can be set to empty if the token is sent without a prefix.
46+
AuthTokenPrefix string
2947
}

clients/ui/bff/internal/constants/keys.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ const (
1010

1111
// The following keys are used to store the user access token in the context
1212
RequestIdentityKey contextKey = "requestIdentityKey"
13-
//For config.AuthMethodUser
14-
XForwardedAccessTokenHeader = "X-Forwarded-Access-Token"
1513

1614
// For config.AuthMethodInternal
1715
// Kubeflow authorization operates using custom authentication headers:

clients/ui/bff/internal/integrations/kubernetes/factory.go

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func NewKubernetesClientFactory(cfg config.EnvConfig, logger *slog.Logger) (Kube
2222
return k8sFactory, nil
2323

2424
case config.AuthMethodUser:
25-
k8sFactory := NewTokenClientFactory(logger)
25+
k8sFactory := NewTokenClientFactory(logger, cfg)
2626
return k8sFactory, nil
2727

2828
default:
@@ -65,7 +65,6 @@ func (f *StaticClientFactory) ExtractRequestIdentity(httpHeader http.Header) (*R
6565
userID := httpHeader.Get(constants.KubeflowUserIDHeader)
6666
//`kubeflow-userid`: Contains the user's email address.
6767
if userID == "" {
68-
return nil, fmt.Errorf("missing required header on AuthMethodInternal: kubeflow-userid")
6968
}
7069

7170
userGroupsHeader := httpHeader.Get(constants.KubeflowUserGroupsIdHeader)
@@ -102,22 +101,36 @@ func (f *StaticClientFactory) ValidateRequestIdentity(identity *RequestIdentity)
102101
//
103102

104103
type TokenClientFactory struct {
105-
logger *slog.Logger
104+
Logger *slog.Logger
105+
Header string
106+
Prefix string
106107
}
107108

108-
func NewTokenClientFactory(logger *slog.Logger) KubernetesClientFactory {
109-
return &TokenClientFactory{logger: logger}
109+
func NewTokenClientFactory(logger *slog.Logger, cfg config.EnvConfig) KubernetesClientFactory {
110+
return &TokenClientFactory{
111+
Logger: logger,
112+
Header: cfg.AuthTokenHeader,
113+
Prefix: cfg.AuthTokenPrefix,
114+
}
110115
}
111116

112117
func (f *TokenClientFactory) ExtractRequestIdentity(httpHeader http.Header) (*RequestIdentity, error) {
113-
token := httpHeader.Get(constants.XForwardedAccessTokenHeader)
114-
if token == "" {
115-
return nil, fmt.Errorf("missing required header on AuthMethodUser: access token")
118+
raw := httpHeader.Get(f.Header)
119+
if raw == "" {
120+
return nil, fmt.Errorf("missing required Header: %s", f.Header)
116121
}
117-
identity := &RequestIdentity{
118-
Token: token,
122+
123+
token := raw
124+
if f.Prefix != "" {
125+
if !strings.HasPrefix(raw, f.Prefix) {
126+
return nil, fmt.Errorf("expected token Header %s to start with Prefix %q", f.Header, f.Prefix)
127+
}
128+
token = strings.TrimPrefix(raw, f.Prefix)
119129
}
120-
return identity, nil
130+
131+
return &RequestIdentity{
132+
Token: strings.TrimSpace(token),
133+
}, nil
121134
}
122135

123136
func (f *TokenClientFactory) ValidateRequestIdentity(identity *RequestIdentity) error {
@@ -144,5 +157,5 @@ func (f *TokenClientFactory) GetClient(ctx context.Context) (KubernetesClientInt
144157
return nil, fmt.Errorf("invalid or missing identity token")
145158
}
146159

147-
return newTokenKubernetesClient(identity.Token, f.logger)
160+
return newTokenKubernetesClient(identity.Token, f.Logger)
148161
}

clients/ui/bff/internal/integrations/kubernetes/k8mocks/mock_factory.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,12 @@ type MockedTokenClientFactory struct {
105105

106106
// NewTokenClientFactory initializes a factory using a known envtest clientset + config.
107107
func NewTokenClientFactory(clientset kubernetes.Interface, restConfig *rest.Config, logger *slog.Logger) (k8s.KubernetesClientFactory, error) {
108-
realFactory := k8s.NewTokenClientFactory(logger)
108+
cfg := config.EnvConfig{
109+
AuthMethod: config.AuthMethodUser,
110+
AuthTokenHeader: config.DefaultAuthTokenHeader,
111+
AuthTokenPrefix: config.DefaultAuthTokenPrefix,
112+
}
113+
realFactory := k8s.NewTokenClientFactory(logger, cfg)
109114

110115
return &MockedTokenClientFactory{
111116
logger: logger,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package tests
2+
3+
import (
4+
"github.com/kubeflow/model-registry/ui/bff/internal/config"
5+
"github.com/kubeflow/model-registry/ui/bff/internal/integrations/kubernetes"
6+
"net/http"
7+
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
)
11+
12+
var _ = Describe("TokenClientFactory ExtractRequestIdentity", func() {
13+
14+
var factory *kubernetes.TokenClientFactory
15+
var header http.Header
16+
17+
BeforeEach(func() {
18+
header = http.Header{}
19+
})
20+
21+
Context("with Bearer prefix", func() {
22+
BeforeEach(func() {
23+
factory = &kubernetes.TokenClientFactory{
24+
Logger: nil,
25+
Header: config.DefaultAuthTokenHeader,
26+
Prefix: config.DefaultAuthTokenPrefix,
27+
}
28+
})
29+
30+
It("should extract the token successfully", func() {
31+
header.Set("Authorization", "Bearer doratoken")
32+
33+
identity, err := factory.ExtractRequestIdentity(header)
34+
Expect(err).NotTo(HaveOccurred())
35+
Expect(identity.Token).To(Equal("doratoken"))
36+
})
37+
38+
It("should fail if prefix does not match", func() {
39+
header.Set("Authorization", "Token bellatoken")
40+
41+
_, err := factory.ExtractRequestIdentity(header)
42+
Expect(err).To(HaveOccurred())
43+
Expect(err.Error()).To(ContainSubstring("expected token Header Authorization to start with Prefix \"Bearer \""))
44+
})
45+
46+
It("should fail if prefix is missing", func() {
47+
header.Set("Authorization", "doratoken")
48+
49+
_, err := factory.ExtractRequestIdentity(header)
50+
Expect(err).To(HaveOccurred())
51+
Expect(err.Error()).To(ContainSubstring("expected token Header Authorization to start with Prefix \"Bearer \""))
52+
})
53+
54+
It("should fail if header is missing", func() {
55+
_, err := factory.ExtractRequestIdentity(header)
56+
Expect(err).To(HaveOccurred())
57+
Expect(err.Error()).To(ContainSubstring("missing required Header: Authorization"))
58+
})
59+
})
60+
61+
Context("with no prefix", func() {
62+
BeforeEach(func() {
63+
factory = &kubernetes.TokenClientFactory{
64+
Logger: nil,
65+
Header: "X-Forwarded-Access-Token",
66+
Prefix: "",
67+
}
68+
})
69+
70+
It("should extract the raw token", func() {
71+
header.Set("X-Forwarded-Access-Token", "bellatoken")
72+
73+
identity, err := factory.ExtractRequestIdentity(header)
74+
Expect(err).NotTo(HaveOccurred())
75+
Expect(identity.Token).To(Equal("bellatoken"))
76+
})
77+
78+
It("should fail if header is missing", func() {
79+
_, err := factory.ExtractRequestIdentity(header)
80+
Expect(err).To(HaveOccurred())
81+
Expect(err.Error()).To(ContainSubstring("missing required Header: X-Forwarded-Access-Token"))
82+
})
83+
84+
It("should fail if header mismatch", func() {
85+
header.Set("X-WRONG-Access-Token", "bellatoken")
86+
87+
_, err := factory.ExtractRequestIdentity(header)
88+
Expect(err).To(HaveOccurred())
89+
Expect(err.Error()).To(ContainSubstring("missing required Header: X-Forwarded-Access-Token"))
90+
})
91+
})
92+
})

0 commit comments

Comments
 (0)