Merge pull request #1999 from umputun/configurable-microsoft-tenant

feat: make Microsoft Entra ID tenant configurable
This commit is contained in:
Umputun
2026-02-10 22:53:31 -06:00
committed by GitHub
10 changed files with 82 additions and 17 deletions
+9 -2
View File
@@ -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 != "" {
+1 -1
View File
@@ -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
+6
View File
@@ -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=
+27 -7
View File
@@ -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{
+28 -5
View File
@@ -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)
+2
View File
@@ -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
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -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 |