Merge pull request #1999 from umputun/configurable-microsoft-tenant
feat: make Microsoft Entra ID tenant configurable
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user