diff --git a/pkg/bearertoken/bearer_challenge.go b/pkg/bearertoken/bearer_challenge.go new file mode 100644 index 000000000..b9dc35b3e --- /dev/null +++ b/pkg/bearertoken/bearer_challenge.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bearertoken + +import ( + "fmt" + "net/http" + "strings" + + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/coredata" +) + +const ( + BearerErrInvalidToken = "invalid_token" + BearerErrInsufficientScope = "insufficient_scope" +) + +func protectedResourceMetadataURL(baseURL *baseurl.BaseURL) string { + return baseURL.WithPath("/.well-known/oauth-protected-resource").MustString() +} + +// BearerChallenge builds the WWW-Authenticate header value per RFC 6750 and RFC 9728. +// Pass errorCode == "" for discovery-only (401, no Bearer attempt). +// Pass scopes only when errorCode == BearerErrInsufficientScope. +func BearerChallenge( + baseURL *baseurl.BaseURL, + errorCode string, + scopes ...coredata.OAuth2Scope, +) string { + metadataURL := protectedResourceMetadataURL(baseURL) + + var parts []string + + if errorCode != "" { + parts = append(parts, fmt.Sprintf(`error="%s"`, errorCode)) + } + + if errorCode == BearerErrInsufficientScope && len(scopes) > 0 { + scopeValues := make([]string, len(scopes)) + for i, scope := range scopes { + scopeValues[i] = string(scope) + } + + parts = append(parts, fmt.Sprintf(`scope="%s"`, strings.Join(scopeValues, " "))) + } + + parts = append(parts, fmt.Sprintf(`resource_metadata="%s"`, metadataURL)) + + return "Bearer " + strings.Join(parts, ", ") +} + +func SetBearerChallenge( + w http.ResponseWriter, + baseURL *baseurl.BaseURL, + errorCode string, + scopes ...coredata.OAuth2Scope, +) { + w.Header().Set("WWW-Authenticate", BearerChallenge(baseURL, errorCode, scopes...)) +} + +// SetBearerUnauthenticated sets a discovery-only challenge (RFC 9728 resource_metadata). +func SetBearerUnauthenticated(w http.ResponseWriter, baseURL *baseurl.BaseURL) { + SetBearerChallenge(w, baseURL, "") +} + +func SetBearerInvalidToken(w http.ResponseWriter, baseURL *baseurl.BaseURL) { + SetBearerChallenge(w, baseURL, BearerErrInvalidToken) +} + +func SetBearerInsufficientScope( + w http.ResponseWriter, + baseURL *baseurl.BaseURL, + scopes ...coredata.OAuth2Scope, +) { + SetBearerChallenge(w, baseURL, BearerErrInsufficientScope, scopes...) +} diff --git a/pkg/bearertoken/bearer_challenge_test.go b/pkg/bearertoken/bearer_challenge_test.go new file mode 100644 index 000000000..fa115d5b0 --- /dev/null +++ b/pkg/bearertoken/bearer_challenge_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package bearertoken + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/coredata" +) + +func TestBearerChallenge(t *testing.T) { + t.Parallel() + + baseURL := baseurl.MustParse("https://example.com") + + tests := []struct { + name string + errorCode string + scopes []coredata.OAuth2Scope + want string + }{ + { + name: "discovery", + errorCode: "", + want: `Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"`, + }, + { + name: "invalid token", + errorCode: BearerErrInvalidToken, + want: `Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource"`, + }, + { + name: "insufficient scope", + errorCode: BearerErrInsufficientScope, + scopes: []coredata.OAuth2Scope{"v1:org:read", "v1:privacy"}, + want: `Bearer error="insufficient_scope", scope="v1:org:read v1:privacy", resource_metadata="https://example.com/.well-known/oauth-protected-resource"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := BearerChallenge(baseURL, tt.errorCode, tt.scopes...) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSetBearerChallenge(t *testing.T) { + t.Parallel() + + baseURL := baseurl.MustParse("https://example.com") + rec := httptest.NewRecorder() + + SetBearerInvalidToken(rec, baseURL) + + assert.Equal( + t, + `Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource"`, + rec.Header().Get("WWW-Authenticate"), + ) +} + +func TestIsAttempt(t *testing.T) { + t.Parallel() + + assert.True(t, IsAttempt("Bearer token")) + assert.True(t, IsAttempt("bearer token")) + assert.True(t, IsAttempt("BEARER")) + assert.False(t, IsAttempt("")) + assert.False(t, IsAttempt("Basic dXNlcjpwYXNz")) + assert.False(t, IsAttempt("Bear token")) +} diff --git a/pkg/bearertoken/bearertoken.go b/pkg/bearertoken/bearertoken.go index 4d235535b..c7e0e44d4 100644 --- a/pkg/bearertoken/bearertoken.go +++ b/pkg/bearertoken/bearertoken.go @@ -12,7 +12,8 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -// Package bearertoken parses Bearer tokens according to RFC 6750. +// Package bearertoken parses Bearer tokens and builds Bearer WWW-Authenticate +// challenges according to RFC 6750. // // The grammar is defined as: // @@ -69,6 +70,15 @@ func Parse(credentials string) (string, error) { return token, nil } +// IsAttempt reports whether credentials use the Bearer scheme. +func IsAttempt(credentials string) bool { + if len(credentials) < len(scheme) { + return false + } + + return strings.EqualFold(credentials[:len(scheme)], scheme) +} + // isValidToken checks if the given string is a valid b64token. // A valid b64token consists of 1 or more characters from the set // [A-Za-z0-9-._~+/] followed by zero or more '=' characters. diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go index ee1e3d8f2..4073e7465 100644 --- a/pkg/iam/authorizer.go +++ b/pkg/iam/authorizer.go @@ -118,7 +118,12 @@ func (a *Authorizer) checkOAuth2Scope( } if a.scopeRegistry == nil || !a.scopeRegistry.Allows(accessToken.Scopes, action) { - return NewInsufficientOAuth2ScopeError(principal, action) + var scopes []coredata.OAuth2Scope + if a.scopeRegistry != nil { + scopes = a.scopeRegistry.ScopesForAction(action) + } + + return NewInsufficientOAuth2ScopeError(principal, scopes...) } return nil diff --git a/pkg/iam/authorizer_oauth2_scope_test.go b/pkg/iam/authorizer_oauth2_scope_test.go index 813843350..09d99c31e 100644 --- a/pkg/iam/authorizer_oauth2_scope_test.go +++ b/pkg/iam/authorizer_oauth2_scope_test.go @@ -60,7 +60,38 @@ func TestAuthorizer_checkOAuth2Scope(t *testing.T) { scopeErr, ok := errors.AsType[*ErrInsufficientOAuth2Scope](err) require.True(t, ok) assert.Equal(t, principal, scopeErr.IdentityID) - assert.Equal(t, action, scopeErr.Action) + assert.Empty(t, scopeErr.Scopes) + }) + + t.Run("reports granting scopes when token lacks authorization", func(t *testing.T) { + t.Parallel() + + const ( + scopeV1OrgWrite = coredata.OAuth2Scope("v1:org") + updateAction = Action("core:organization:update") + ) + + scopeSet := oauth2scope.NewRegistry().Register( + map[coredata.OAuth2Scope][]string{ + scopeV1OrgRead: {action}, + scopeV1OrgWrite: {updateAction}, + }, + ) + + a := NewAuthorizer(nil, nil, scopeSet) + + ctx := oauth2.ContextWithAccessToken( + context.Background(), + &coredata.OAuth2AccessToken{Scopes: coredata.OAuth2Scopes{scopeV1OrgRead}}, + ) + + err := a.checkOAuth2Scope(ctx, principal, updateAction) + require.Error(t, err) + + scopeErr, ok := errors.AsType[*ErrInsufficientOAuth2Scope](err) + require.True(t, ok) + assert.Equal(t, principal, scopeErr.IdentityID) + assert.Equal(t, []coredata.OAuth2Scope{scopeV1OrgWrite}, scopeErr.Scopes) }) t.Run("allows when registered scopes authorize the action", func(t *testing.T) { diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index f160a5d1f..f9257c1e1 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -17,6 +17,7 @@ package iam import ( "fmt" + "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" ) @@ -205,18 +206,17 @@ func (e ErrInsufficientPermissions) Error() string { type ErrInsufficientOAuth2Scope struct { IdentityID gid.GID - Action Action + Scopes []coredata.OAuth2Scope } -func NewInsufficientOAuth2ScopeError(identityID gid.GID, action Action) error { - return &ErrInsufficientOAuth2Scope{IdentityID: identityID, Action: action} +func NewInsufficientOAuth2ScopeError(identityID gid.GID, scopes ...coredata.OAuth2Scope) error { + return &ErrInsufficientOAuth2Scope{IdentityID: identityID, Scopes: scopes} } func (e ErrInsufficientOAuth2Scope) Error() string { return fmt.Sprintf( - "identity %q does not have an OAuth2 scope granting action %s", + "identity %q does not have an OAuth2 scope granting the requested action", e.IdentityID, - e.Action, ) } diff --git a/pkg/iam/oauth2scope/registry.go b/pkg/iam/oauth2scope/registry.go index 3681cd15b..98e919ab5 100644 --- a/pkg/iam/oauth2scope/registry.go +++ b/pkg/iam/oauth2scope/registry.go @@ -72,6 +72,13 @@ func (r *Registry) Allows(tokenScopes coredata.OAuth2Scopes, action string) bool return slices.ContainsFunc(grantingScopes, tokenScopes.Contains) } +func (r *Registry) ScopesForAction(action string) []coredata.OAuth2Scope { + r.mu.RLock() + defer r.mu.RUnlock() + + return sortedScopes(r.invertedIndex[action]) +} + func (r *Registry) ValidateScopes(scopes coredata.OAuth2Scopes) error { r.mu.RLock() defer r.mu.RUnlock() diff --git a/pkg/iam/oauth2scope/registry_test.go b/pkg/iam/oauth2scope/registry_test.go index c46f85a74..7c2be3c1e 100644 --- a/pkg/iam/oauth2scope/registry_test.go +++ b/pkg/iam/oauth2scope/registry_test.go @@ -81,6 +81,30 @@ func TestRegistry_ValidateScopes(t *testing.T) { assert.EqualError(t, err, "invalid scope: v1:unknown:read") } +func TestRegistry_ScopesForAction(t *testing.T) { + t.Parallel() + + const ( + scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + scopeV1OrgWrite = coredata.OAuth2Scope("v1:org") + ) + + reg := oauth2scope.NewRegistry().Register( + map[coredata.OAuth2Scope][]string{ + scopeV1OrgRead: {"core:organization:get"}, + scopeV1OrgWrite: {"core:organization:get", "core:organization:update"}, + }, + ) + + assert.Equal( + t, + []coredata.OAuth2Scope{scopeV1OrgWrite, scopeV1OrgRead}, + reg.ScopesForAction("core:organization:get"), + ) + assert.Equal(t, []coredata.OAuth2Scope{scopeV1OrgWrite}, reg.ScopesForAction("core:organization:update")) + assert.Nil(t, reg.ScopesForAction("core:organization:delete")) +} + func TestRegistry_RegisteredScopes(t *testing.T) { t.Parallel() diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 2cde6be83..0fb4df5db 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -221,6 +221,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.IAM, cfg.Cookie, cfg.TokenSecret, + cfg.BaseURL, ), mcpHandler: mcp_v1.NewMux( cfg.Logger.Named("mcp.v1"), diff --git a/pkg/server/api/authn/context.go b/pkg/server/api/authn/context.go index 67e22eee0..9f5478063 100644 --- a/pkg/server/api/authn/context.go +++ b/pkg/server/api/authn/context.go @@ -28,7 +28,7 @@ var ( identityContextKey = &ctxKey{name: "identity"} sessionContextKey = &ctxKey{name: "session"} apiKeyContextKey = &ctxKey{name: "api_key"} - TrustCenterKey = &ctxKey{name: "trust_center"} + trustCenterKey = &ctxKey{name: "trust_center"} ) func SessionFromContext(ctx context.Context) *coredata.Session { diff --git a/pkg/server/api/authn/identity_presence_middleware.go b/pkg/server/api/authn/identity_presence_middleware.go index 0a9d06ab8..d603be8b2 100644 --- a/pkg/server/api/authn/identity_presence_middleware.go +++ b/pkg/server/api/authn/identity_presence_middleware.go @@ -20,16 +20,25 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/vektah/gqlparser/v2/gqlerror" "go.gearno.de/kit/httpserver" + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/bearertoken" "go.probo.inc/probo/pkg/server/gqlutils" ) -func NewIdentityPresenceMiddleware() func(next http.Handler) http.Handler { +func NewIdentityPresenceMiddleware(baseURL *baseurl.BaseURL) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - identity := IdentityFromContext(r.Context()) + ctx := r.Context() + identity := IdentityFromContext(ctx) if identity == nil { + if bearertoken.IsAttempt(r.Header.Get("Authorization")) { + bearertoken.SetBearerInvalidToken(w, baseURL) + } else { + bearertoken.SetBearerUnauthenticated(w, baseURL) + } + httpserver.RenderJSON( w, http.StatusUnauthorized, diff --git a/pkg/server/api/authn/oauth2_access_token_middleware.go b/pkg/server/api/authn/oauth2_access_token_middleware.go index 2bd99e909..a9644a1d1 100644 --- a/pkg/server/api/authn/oauth2_access_token_middleware.go +++ b/pkg/server/api/authn/oauth2_access_token_middleware.go @@ -36,15 +36,19 @@ func NewOAuth2AccessTokenMiddleware(svc *iam.Service) func(next http.Handler) ht return } - tokenValue, err := bearertoken.Parse(r.Header.Get("Authorization")) + authorization := r.Header.Get("Authorization") + + tokenValue, err := bearertoken.Parse(authorization) if err != nil { next.ServeHTTP(w, r) + return } accessToken, err := svc.OAuth2ServerService.LoadAccessToken(ctx, tokenValue) if err != nil { next.ServeHTTP(w, r) + return } diff --git a/pkg/server/api/connect/v1/oauth2_handler.go b/pkg/server/api/connect/v1/oauth2_handler.go index 10425c24f..570d781ed 100644 --- a/pkg/server/api/connect/v1/oauth2_handler.go +++ b/pkg/server/api/connect/v1/oauth2_handler.go @@ -77,7 +77,7 @@ func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenValue, err := bearertoken.Parse(r.Header.Get("Authorization")) if err != nil { - w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + bearertoken.SetBearerInvalidToken(w, h.baseURL) http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -85,7 +85,7 @@ func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler { accessToken, err := h.iam.OAuth2ServerService.LoadAccessToken(r.Context(), tokenValue) if err != nil { - w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + bearertoken.SetBearerInvalidToken(w, h.baseURL) http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -402,7 +402,7 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request) { accessToken, ok := oauth2.AccessTokenFromContext(r.Context()) if !ok { - w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + bearertoken.SetBearerInvalidToken(w, h.baseURL) http.Error(w, "unauthorized", http.StatusUnauthorized) return diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index 5e04aa587..0d2443487 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -76,11 +76,16 @@ func NewMux( sessionMiddleware := authn.NewSessionMiddleware(svc, cookieConfig) apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret) oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc) + identityPresenceMiddleware := authn.NewIdentityPresenceMiddleware(baseURL) graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig) samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger) scimHandler := NewSCIMHandler(svc, logger.Named("scim")) - router := r.With(sessionMiddleware, apiKeyMiddleware, oauth2Middleware) + router := r.With( + sessionMiddleware, + apiKeyMiddleware, + oauth2Middleware, + ) oidcHandler := NewOIDCHandler(svc, cookieConfig, logger, allowedRedirectHost, isTrustCenterDomain) @@ -115,7 +120,7 @@ func NewMux( // Session-authenticated endpoints. router.Get("/oauth2/authorize", oauth2Handler.AuthorizeHandler) - requireIdentity := router.With(authn.NewIdentityPresenceMiddleware()) + requireIdentity := router.With(identityPresenceMiddleware) requireIdentity.Post("/oauth2/register", oauth2Handler.RegisterHandler) return r diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index dc81621b9..36726f679 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -120,7 +120,7 @@ func NewMux( r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig)) r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret)) r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc)) - r.Use(authn.NewIdentityPresenceMiddleware()) + r.Use(authn.NewIdentityPresenceMiddleware(baseURL)) r.Use(dataloader.NewMiddleware(proboSvc, iamSvc, cookieBannerSvc, thirdPartySvc)) r.Handle("/graphql", graphqlHandler) diff --git a/pkg/server/api/files/v1/handler.go b/pkg/server/api/files/v1/handler.go index b76f6dcc5..8ae99c284 100644 --- a/pkg/server/api/files/v1/handler.go +++ b/pkg/server/api/files/v1/handler.go @@ -22,6 +22,8 @@ import ( "github.com/go-chi/chi/v5" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/bearertoken" "go.probo.inc/probo/pkg/brand" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/filemanager" @@ -41,6 +43,7 @@ type Handler struct { probo *probo.Service iamSvc *iam.Service assets *brand.Assets + baseURL *baseurl.BaseURL } func NewMux( @@ -50,6 +53,7 @@ func NewMux( iamSvc *iam.Service, cookieConfig securecookie.Config, tokenSecret string, + baseURL *baseurl.BaseURL, ) *chi.Mux { h := &Handler{ logger: logger, @@ -57,6 +61,7 @@ func NewMux( probo: proboSvc, iamSvc: iamSvc, assets: brand.NewAssets(), + baseURL: baseURL, } r := chi.NewRouter() @@ -68,7 +73,7 @@ func NewMux( r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig)) r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret)) r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc)) - r.Use(authn.NewIdentityPresenceMiddleware()) + r.Use(authn.NewIdentityPresenceMiddleware(baseURL)) r.Get("/{fileID}", h.handleGetFile) }) @@ -157,8 +162,10 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) { scope, err := h.iamSvc.Authorizer.Authorize(ctx, params) if err != nil { - if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + if scopeErr, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + bearertoken.SetBearerInsufficientScope(w, h.baseURL, scopeErr.Scopes...) jsonx.RenderForbidden(w) + return } diff --git a/pkg/server/api/files/v1/handler_test.go b/pkg/server/api/files/v1/handler_test.go index 6d7a2c634..33f9cc8ec 100644 --- a/pkg/server/api/files/v1/handler_test.go +++ b/pkg/server/api/files/v1/handler_test.go @@ -26,6 +26,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/securecookie" ) @@ -79,6 +80,7 @@ func TestHandleGetStaticFile(t *testing.T) { nil, securecookie.Config{}, "test-secret", + baseurl.MustParse("https://example.com"), ) rec := httptest.NewRecorder() @@ -118,6 +120,7 @@ func TestHandleGetFile_UnauthenticatedReturns401(t *testing.T) { nil, // iamSvc — not reached when no token/cookie present securecookie.Config{}, "test-secret", + baseurl.MustParse("https://example.com"), ) rec := httptest.NewRecorder() diff --git a/pkg/server/api/mcp/v1/v1_handler.go b/pkg/server/api/mcp/v1/v1_handler.go index c4cc6b2de..13ace685e 100644 --- a/pkg/server/api/mcp/v1/v1_handler.go +++ b/pkg/server/api/mcp/v1/v1_handler.go @@ -86,7 +86,7 @@ func NewMux( r := chi.NewMux() r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret)) r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc)) - r.Use(authn.NewIdentityPresenceMiddleware()) + r.Use(authn.NewIdentityPresenceMiddleware(baseURL)) r.Handle("/", protectedHandler) logger.Info("MCP server initialized successfully")