Skip to content

Commit 2c87bfd

Browse files
authored
fix: allow OIDC tokens without tenant claim when tenant resolved from subdomain (#1362)
The TenantAuthorizationMiddleware blocked requests with empty JWT tenant claims even when the tenant was successfully resolved from subdomain or X-Tenant-Slug header. This prevented standard OIDC providers like Dex (which issue identity-only tokens without custom tenant claims) from working with subdomain-based tenant routing. When JWT has no tenant claim and the user is not a platform admin, now checks if a tenant was resolved from the request context (subdomain/slug) and allows the request scoped to that tenant. Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 92856c3 commit 2c87bfd

2 files changed

Lines changed: 56 additions & 8 deletions

File tree

services/gateway/auth/combined_middleware.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,10 @@ func (m *TenantAuthorizationMiddleware) Handler(next http.Handler) http.Handler
245245
return
246246
}
247247

248-
// Platform-admin/super-admin bypass: when JWT has no tenant claim but has
249-
// elevated platform role, allow access without tenant context (platform-level
250-
// endpoints like ListTenants) or cross-tenant access (tenant-scoped endpoints
251-
// resolved via subdomain).
248+
// When JWT has no tenant claim (e.g. standard OIDC tokens from Dex):
249+
// 1. Platform-admin/super-admin: allow access to any tenant
250+
// 2. Regular user with resolved tenant (subdomain/slug): scope to that tenant
251+
// 3. Otherwise: deny
252252
if jwtTenantID == "" {
253253
claims, hasClaims := GetClaimsFromContext(ctx)
254254
if hasClaims && hasPlatformAdminRole(claims) {
@@ -259,7 +259,20 @@ func (m *TenantAuthorizationMiddleware) Handler(next http.Handler) http.Handler
259259
next.ServeHTTP(w, r)
260260
return
261261
}
262-
// No tenant claim and not a platform admin
262+
263+
// Allow if tenant was resolved from subdomain/slug — user inherits
264+
// tenant scope from the request routing rather than from JWT claims.
265+
// This supports OIDC providers (like Dex) that issue identity-only
266+
// tokens without custom tenant claims.
267+
if resolvedTenant, ok := tenant.FromContext(ctx); ok && !resolvedTenant.IsEmpty() {
268+
m.logger.Debug("tenant resolved from request context (no JWT tenant claim)",
269+
slog.String("tenant", resolvedTenant.String()),
270+
slog.String("path", r.URL.Path),
271+
)
272+
next.ServeHTTP(w, r)
273+
return
274+
}
275+
263276
writeForbidden(w, "missing tenant claim in token")
264277
return
265278
}

services/gateway/auth/combined_middleware_test.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,39 @@ func TestTenantAuthorizationMiddleware(t *testing.T) {
353353
assert.Contains(t, rr.Body.String(), "not authorized for this tenant")
354354
})
355355

356-
t.Run("403 when no JWT tenant claim", func(t *testing.T) {
356+
t.Run("OIDC token without tenant claim uses resolved tenant from subdomain", func(t *testing.T) {
357+
middleware := NewTenantAuthorizationMiddleware(logger)
358+
359+
var capturedCtx context.Context
360+
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
361+
capturedCtx = r.Context()
362+
})
363+
364+
handler := middleware.Handler(nextHandler)
365+
366+
// Simulate OIDC token (e.g. Dex) with no tenant claim but resolved tenant from subdomain
367+
claims := &platformauth.Claims{
368+
UserID: "oidc-user",
369+
RegisteredClaims: jwt.RegisteredClaims{
370+
Subject: "dex-subject-id",
371+
},
372+
}
373+
ctx := injectClaimsToContext(context.Background(), claims)
374+
ctx = tenant.WithTenant(ctx, tenant.MustNewTenantID("volterra"))
375+
376+
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
377+
req = req.WithContext(ctx)
378+
rr := httptest.NewRecorder()
379+
380+
handler.ServeHTTP(rr, req)
381+
382+
assert.Equal(t, http.StatusOK, rr.Code)
383+
resolvedTenant, hasTenant := tenant.FromContext(capturedCtx)
384+
assert.True(t, hasTenant)
385+
assert.Equal(t, "volterra", resolvedTenant.String())
386+
})
387+
388+
t.Run("403 when no JWT tenant claim and no resolved tenant", func(t *testing.T) {
357389
middleware := NewTenantAuthorizationMiddleware(logger)
358390

359391
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
@@ -362,8 +394,11 @@ func TestTenantAuthorizationMiddleware(t *testing.T) {
362394

363395
handler := middleware.Handler(nextHandler)
364396

365-
// Create context with resolved tenant but no JWT tenant
366-
ctx := tenant.WithTenant(context.Background(), tenant.MustNewTenantID("some_corp"))
397+
// JWT has no tenant claim and no tenant resolved from subdomain
398+
claims := &platformauth.Claims{
399+
UserID: "oidc-user",
400+
}
401+
ctx := injectClaimsToContext(context.Background(), claims)
367402

368403
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
369404
req = req.WithContext(ctx)

0 commit comments

Comments
 (0)