From 31e20fc26d778d5285a827395a225ac1eae3e8ab Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Tue, 10 Feb 2026 23:47:17 +0000 Subject: [PATCH] feat: make Microsoft Entra ID tenant configurable Add AUTH_MICROSOFT_TENANT env var to allow configuring the Azure AD tenant for single-tenant Entra ID applications, which cannot use the default /common endpoint. Depends on go-pkgz/auth#266 Closes #1998 --- backend/app/cmd/server.go | 11 ++++-- backend/go.mod | 2 +- backend/go.sum | 6 ++++ .../vendor/github.com/go-pkgz/auth/v2/auth.go | 34 +++++++++++++++---- .../go-pkgz/auth/v2/middleware/auth.go | 33 +++++++++++++++--- .../go-pkgz/auth/v2/provider/oauth2.go | 2 ++ .../go-pkgz/auth/v2/provider/providers.go | 7 +++- backend/vendor/modules.txt | 2 +- .../docs/configuration/authorization/index.md | 1 + .../docs/configuration/parameters/index.md | 1 + 10 files changed, 82 insertions(+), 17 deletions(-) diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 9a6256d1..32074f3e 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -103,7 +103,7 @@ type ServerCommand struct { Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"` Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"` Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"` - Microsoft AuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"` + Microsoft MicrosoftAuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"` Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"` Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"[deprecated, doesn't work] Twitter OAuth"` Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"` @@ -152,6 +152,13 @@ type AuthGroup struct { CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"` } +// MicrosoftAuthGroup defines options group for Microsoft auth params +type MicrosoftAuthGroup struct { + CID string `long:"cid" env:"CID" description:"OAuth client ID"` + CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"` + Tenant string `long:"tenant" env:"TENANT" description:"Azure AD tenant ID, domain, or 'common' (default)" default:"common"` +} + // StoreGroup defines options group for store params type StoreGroup struct { Type string `long:"type" env:"TYPE" description:"type of storage" choice:"bolt" choice:"rpc" default:"bolt"` // nolint @@ -939,7 +946,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error { providersCount++ } if s.Auth.Microsoft.CID != "" && s.Auth.Microsoft.CSEC != "" { - authenticator.AddProvider("microsoft", s.Auth.Microsoft.CID, s.Auth.Microsoft.CSEC) + authenticator.AddMicrosoftProvider(s.Auth.Microsoft.CID, s.Auth.Microsoft.CSEC, s.Auth.Microsoft.Tenant) providersCount++ } if s.Auth.Yandex.CID != "" && s.Auth.Yandex.CSEC != "" { diff --git a/backend/go.mod b/backend/go.mod index 6e4f6425..6c0e4c75 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,7 +9,7 @@ require ( github.com/didip/tollbooth/v8 v8.0.1 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 - github.com/go-pkgz/auth/v2 v2.1.1 + github.com/go-pkgz/auth/v2 v2.1.2-0.20260211003156-fbba7f2baa6b github.com/go-pkgz/jrpc v0.4.0 github.com/go-pkgz/lcw/v2 v2.0.0 github.com/go-pkgz/lgr v0.12.1 diff --git a/backend/go.sum b/backend/go.sum index 2d66a32e..be793b64 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -50,6 +50,12 @@ github.com/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6Xm github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ= github.com/go-pkgz/auth/v2 v2.1.1 h1:CBH3Z6ovLT51Nx9TkBcu2L8Dd/xwL6CgJcgqUnC2isQ= github.com/go-pkgz/auth/v2 v2.1.1/go.mod h1:9LwzESczjMavmXNZo1XhYpfYdKWtoCbXt/ZIi0GTvF0= +github.com/go-pkgz/auth/v2 v2.1.2-0.20260119213210-5ff800f4c064 h1:+7XirxGV7RV0VzStWbyP6c0edbfaCXB69JeZh6uVpE0= +github.com/go-pkgz/auth/v2 v2.1.2-0.20260119213210-5ff800f4c064/go.mod h1:9LwzESczjMavmXNZo1XhYpfYdKWtoCbXt/ZIi0GTvF0= +github.com/go-pkgz/auth/v2 v2.1.2-0.20260210234152-7e1ed2cedf71 h1:FxzoRgUemfWKBt0iw9J9Qck5nHB6bNlhmUJneMNL/C8= +github.com/go-pkgz/auth/v2 v2.1.2-0.20260210234152-7e1ed2cedf71/go.mod h1:9LwzESczjMavmXNZo1XhYpfYdKWtoCbXt/ZIi0GTvF0= +github.com/go-pkgz/auth/v2 v2.1.2-0.20260211003156-fbba7f2baa6b h1:N8iS/o/LgbSL4NLabOuLgfmROjtMLW2Qc3EsMmdYNGs= +github.com/go-pkgz/auth/v2 v2.1.2-0.20260211003156-fbba7f2baa6b/go.mod h1:9LwzESczjMavmXNZo1XhYpfYdKWtoCbXt/ZIi0GTvF0= github.com/go-pkgz/email v0.6.0 h1:snZnXldjeF4PgKSjnx9Fa25mtOgFpAOEeWvnQvrxjLE= github.com/go-pkgz/email v0.6.0/go.mod h1:+wgi4x7S33IuCzfcCM5euN0GwQG6XvO/PBLxrNffYLI= github.com/go-pkgz/expirable-cache/v3 v3.1.0 h1:s05P851/O6QJ6Mc+7o2bh9aGtD3romB1SxDTXifdoqc= diff --git a/backend/vendor/github.com/go-pkgz/auth/v2/auth.go b/backend/vendor/github.com/go-pkgz/auth/v2/auth.go index efb4ddb6..b4ffad3a 100644 --- a/backend/vendor/github.com/go-pkgz/auth/v2/auth.go +++ b/backend/vendor/github.com/go-pkgz/auth/v2/auth.go @@ -68,17 +68,17 @@ type Opts struct { AvatarRoutePath string // avatar routing prefix, i.e. "/api/v1/avatar", default `/avatar` UseGravatar bool // for email based auth (verified provider) use gravatar service - AdminPasswd string // if presented, allows basic auth with user admin and given password - BasicAuthChecker middleware.BasicAuthFunc // user custom checker for basic auth, if one defined then "AdminPasswd" will ignored - AudienceReader token.Audience // list of allowed aud values, default (empty) allows any - AudSecrets bool // allow multiple secrets (secret per aud) - Logger logger.L // logger interface, default is no logging at all - RefreshCache middleware.RefreshCache // optional cache to keep refreshed tokens + AdminPasswd string // if presented, allows basic auth with user admin and given password + BasicAuthChecker middleware.BasicAuthFunc // user custom checker for basic auth, if one defined then "AdminPasswd" will ignored + AudienceReader token.Audience // list of allowed aud values, default (empty) allows any + AudSecrets bool // allow multiple secrets (secret per aud) + Logger logger.L // logger interface, default is no logging at all + RefreshCache middleware.RefreshCache // optional cache to keep refreshed tokens + ErrorHandler middleware.ErrorHandlerFunc // custom error handler for auth failures } // NewService initializes everything func NewService(opts Opts) (res *Service) { - res = &Service{ opts: opts, logger: opts.Logger, @@ -87,6 +87,7 @@ func NewService(opts Opts) (res *Service) { AdminPasswd: opts.AdminPasswd, BasicAuthChecker: opts.BasicAuthChecker, RefreshCache: opts.RefreshCache, + ErrorHandler: opts.ErrorHandler, }, issuer: opts.Issuer, useGravatar: opts.UseGravatar, @@ -320,6 +321,25 @@ func (s *Service) AddProvider(name, cid, csecret string) { s.addProviderByName(name, p) } +// AddMicrosoftProvider adds microsoft provider with a configurable tenant. +// If tenant is empty, "common" is used. For single-tenant Entra ID apps, +// pass the directory (tenant) ID or domain name. +// For advanced configuration (e.g., UserAttributes), construct provider.Params directly. +func (s *Service) AddMicrosoftProvider(cid, csecret, tenant string) { + p := provider.Params{ + URL: s.opts.URL, + JwtService: s.jwtService, + Issuer: s.issuer, + AvatarSaver: s.avatarProxy, + Cid: cid, + Csecret: csecret, + L: s.logger, + UserAttributes: map[string]string{}, + MicrosoftTenant: tenant, + } + s.addProvider(provider.NewMicrosoft(p)) +} + // AddDevProvider with a custom host and port func (s *Service) AddDevProvider(host string, port int) { p := provider.Params{ diff --git a/backend/vendor/github.com/go-pkgz/auth/v2/middleware/auth.go b/backend/vendor/github.com/go-pkgz/auth/v2/middleware/auth.go index e25968f8..bb375495 100644 --- a/backend/vendor/github.com/go-pkgz/auth/v2/middleware/auth.go +++ b/backend/vendor/github.com/go-pkgz/auth/v2/middleware/auth.go @@ -24,6 +24,7 @@ type Authenticator struct { AdminPasswd string BasicAuthChecker BasicAuthFunc RefreshCache RefreshCache + ErrorHandler ErrorHandlerFunc // custom error handler for auth failures } // RefreshCache defines interface storing and retrieving refreshed tokens @@ -45,6 +46,13 @@ type TokenService interface { // The second return parameter `User` need for add user claims into context of request. type BasicAuthFunc func(user, passwd string) (ok bool, userInfo token.User, err error) +// ErrorHandlerFunc type is an adapter to allow custom error handling for auth failures. +// It receives the suggested HTTP status code and the error that caused the auth failure. +// The handler can respond with custom status codes, HTML pages, redirects, or JSON responses. +// Status codes are typically http.StatusUnauthorized (401) for auth failures +// or http.StatusForbidden (403) for permission denied. +type ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, statusCode int, err error) + // adminUser sets claims for an optional basic auth var adminUser = token.User{ ID: "admin", @@ -73,7 +81,7 @@ func (a *Authenticator) auth(reqAuth bool) func(http.Handler) http.Handler { return } a.Logf("[DEBUG] auth failed, %v", err) - http.Error(w, "Unauthorized", http.StatusUnauthorized) + a.errResponse(w, r, http.StatusUnauthorized, err) } f := func(h http.Handler) http.Handler { @@ -208,12 +216,12 @@ func (a *Authenticator) AdminOnly(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { user, err := token.GetUserInfo(r) if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + a.errResponse(w, r, http.StatusUnauthorized, err) return } if !user.IsAdmin() { - http.Error(w, "Access denied", http.StatusForbidden) + a.errResponse(w, r, http.StatusForbidden, fmt.Errorf("user %s is not admin", user.Name)) return } next.ServeHTTP(w, r) @@ -242,6 +250,21 @@ func (a *Authenticator) basicAdminUser(r *http.Request) bool { return true } +// errResponse calls ErrorHandler if set, otherwise returns default http error +func (a *Authenticator) errResponse(w http.ResponseWriter, r *http.Request, code int, err error) { + if a.ErrorHandler != nil { + a.ErrorHandler(w, r, code, err) + return + } + // preserve original error messages for backward compatibility + switch code { + case http.StatusForbidden: + http.Error(w, "Access denied", code) + default: + http.Error(w, "Unauthorized", code) + } +} + // RBAC middleware allows role based control for routes // this handler internally wrapped with auth(true) to avoid situation if RBAC defined without prior Auth func (a *Authenticator) RBAC(roles ...string) func(http.Handler) http.Handler { @@ -250,7 +273,7 @@ func (a *Authenticator) RBAC(roles ...string) func(http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { user, err := token.GetUserInfo(r) if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + a.errResponse(w, r, http.StatusUnauthorized, err) return } @@ -262,7 +285,7 @@ func (a *Authenticator) RBAC(roles ...string) func(http.Handler) http.Handler { } } if !matched { - http.Error(w, "Access denied", http.StatusForbidden) + a.errResponse(w, r, http.StatusForbidden, fmt.Errorf("user %s role %s not in allowed roles", user.Name, user.Role)) return } h.ServeHTTP(w, r) diff --git a/backend/vendor/github.com/go-pkgz/auth/v2/provider/oauth2.go b/backend/vendor/github.com/go-pkgz/auth/v2/provider/oauth2.go index 273a1906..e1ee0cfc 100644 --- a/backend/vendor/github.com/go-pkgz/auth/v2/provider/oauth2.go +++ b/backend/vendor/github.com/go-pkgz/auth/v2/provider/oauth2.go @@ -44,6 +44,8 @@ type Params struct { Port int // relevant for providers supporting port customization, for example dev oauth2 Host string // relevant for providers supporting host customization, for example dev oauth2 + + MicrosoftTenant string // tenant for microsoft provider, default "common" } // UserData is type for user information returned from oauth2 providers /info API method diff --git a/backend/vendor/github.com/go-pkgz/auth/v2/provider/providers.go b/backend/vendor/github.com/go-pkgz/auth/v2/provider/providers.go index 471fa312..5f4b7bf1 100644 --- a/backend/vendor/github.com/go-pkgz/auth/v2/provider/providers.go +++ b/backend/vendor/github.com/go-pkgz/auth/v2/provider/providers.go @@ -5,6 +5,7 @@ import ( "crypto/sha1" //nolint "encoding/json" "fmt" + "strings" "github.com/dghubble/oauth1" "github.com/dghubble/oauth1/twitter" @@ -192,9 +193,13 @@ func NewBattlenet(p Params) Oauth2Handler { // NewMicrosoft makes microsoft azure oauth2 provider func NewMicrosoft(p Params) Oauth2Handler { + tenant := p.MicrosoftTenant + if tenant == "" || strings.ContainsAny(tenant, "/?# \t\n\r") || strings.Contains(tenant, "..") { + tenant = "common" + } return initOauth2Handler(p, Oauth2Handler{ name: "microsoft", - endpoint: microsoft.AzureADEndpoint("common"), + endpoint: microsoft.AzureADEndpoint(tenant), scopes: []string{"User.Read"}, infoURL: "https://graph.microsoft.com/v1.0/me", // non-beta doesn't provide photo for consumers yet diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index db103b77..b0b22626 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -56,7 +56,7 @@ github.com/go-chi/cors github.com/go-oauth2/oauth2/v4 github.com/go-oauth2/oauth2/v4/errors github.com/go-oauth2/oauth2/v4/server -# github.com/go-pkgz/auth/v2 v2.1.1 +# github.com/go-pkgz/auth/v2 v2.1.2-0.20260211003156-fbba7f2baa6b ## explicit; go 1.24.0 github.com/go-pkgz/auth/v2 github.com/go-pkgz/auth/v2/avatar diff --git a/site/src/docs/configuration/authorization/index.md b/site/src/docs/configuration/authorization/index.md index 244019b6..1557b038 100644 --- a/site/src/docs/configuration/authorization/index.md +++ b/site/src/docs/configuration/authorization/index.md @@ -82,6 +82,7 @@ _instructions for Google OAuth2 setup borrowed from [oauth2_proxy](https://githu 3. In **"Overview"** take note of the **Application (client) ID** (`AUTH_MICROSOFT_CID`) 4. Choose the new project from the top right project dropdown (only if another project is selected) 5. Select **"Certificates & secrets"** and click on **"+ New Client Secret"** (`AUTH_MICROSOFT_CSEC`) +6. For single-tenant Entra ID applications, set `AUTH_MICROSOFT_TENANT` to your tenant ID or domain name. The default value is `common`, which works for multi-tenant applications. ### Yandex diff --git a/site/src/docs/configuration/parameters/index.md b/site/src/docs/configuration/parameters/index.md index ac80a068..f6124cf8 100644 --- a/site/src/docs/configuration/parameters/index.md +++ b/site/src/docs/configuration/parameters/index.md @@ -91,6 +91,7 @@ services: | auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret | | auth.microsoft.cid | AUTH_MICROSOFT_CID | | Microsoft OAuth client ID | | auth.microsoft.csec | AUTH_MICROSOFT_CSEC | | Microsoft OAuth client secret | +| auth.microsoft.tenant | AUTH_MICROSOFT_TENANT | `common` | Azure AD tenant ID, domain, or "common" | | auth.github.cid | AUTH_GITHUB_CID | | GitHub OAuth client ID | | auth.github.csec | AUTH_GITHUB_CSEC | | GitHub OAuth client secret | | auth.patreon.cid | AUTH_PATREON_CID | | Patreon OAuth Client ID |