OIDC: Add support for Microsoft Entra ID security groups #5334

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-11-23 16:04:25 +01:00
parent 73fa8fb86f
commit 5f0ade87f5
8 changed files with 380 additions and 6 deletions

View File

@@ -98,6 +98,47 @@ func OIDCRedirect(router *gin.RouterGroup) {
return return
} }
groupClaim := conf.OIDCGroupClaim()
var idTokenClaims map[string]any
if tokens != nil && tokens.IDTokenClaims != nil {
idTokenClaims = tokens.IDTokenClaims.Claims
}
groups, groupOverage := oidc.GroupsFromClaims(idTokenClaims, groupClaim)
moreGroups, moreOverage := oidc.GroupsFromClaims(userInfo.Claims, groupClaim)
if len(groups) == 0 && len(moreGroups) > 0 {
groups = moreGroups
}
if moreOverage {
groupOverage = true
}
requiredGroups := conf.OIDCGroup()
if len(requiredGroups) > 0 {
switch {
case groupOverage && len(groups) == 0:
message := "group claim overage; cannot validate required groups"
event.AuditErr([]string{clientIp, "create session", "oidc", message})
event.LoginError(clientIp, "oidc", userName, userAgent, message)
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
return
case !oidc.HasAnyGroup(groups, requiredGroups):
message := "missing required group membership"
event.AuditErr([]string{clientIp, "create session", "oidc", message})
event.LoginError(clientIp, "oidc", userName, userAgent, message)
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
return
}
}
mappedRole, hasMappedRole := oidc.MapGroupsToRole(groups, conf.OIDCGroupRoles())
defaultRole := conf.OIDCRole()
// Step 1: Create user account if it does not exist yet. // Step 1: Create user account if it does not exist yet.
var user *entity.User var user *entity.User
var err error var err error
@@ -216,6 +257,10 @@ func OIDCRedirect(router *gin.RouterGroup) {
user.VerifiedAt = entity.TimeStamp() user.VerifiedAt = entity.TimeStamp()
} }
if hasMappedRole && !user.HasRole(mappedRole) {
user.SetRole(mappedRole.String())
}
// Update Subject ID and Issuer URI. // Update Subject ID and Issuer URI.
user.SetAuthID(userInfo.Subject, provider.Issuer()) user.SetAuthID(userInfo.Subject, provider.Issuer())
@@ -294,7 +339,11 @@ func OIDCRedirect(router *gin.RouterGroup) {
} }
// Set user role and permissions. // Set user role and permissions.
user.SetRole(conf.OIDCRole().String()) if hasMappedRole {
user.SetRole(mappedRole.String())
} else {
user.SetRole(defaultRole.String())
}
user.CanLogin = true user.CanLogin = true
user.WebDAV = conf.OIDCWebDAV() user.WebDAV = conf.OIDCWebDAV()

View File

@@ -53,14 +53,16 @@
### Security Group Extension for Entra ID ### Security Group Extension for Entra ID
- [ ] Parse `groups` claim (string GUIDs) from ID/Access tokens and map to roles using a configurable mapping, mirroring LDAP group handling. - [x] Parse `groups` claim (string GUIDs) from ID/Access tokens and map to roles using a configurable mapping, mirroring LDAP group handling.
- [ ] Detect `_claim_names.groups` overage markers; optionally fetch memberships from Microsoft Graph (`/me/transitiveMemberOf`) behind a feature flag and enforced timeouts. - [x] Detect `_claim_names.groups` overage markers; optionally fetch memberships from Microsoft Graph (`/me/transitiveMemberOf`) behind a feature flag and enforced timeouts.
- [ ] Keep `roles`/`wids` (app and directory roles) separate from security groups; document precedence. - [x] Keep `roles`/`wids` (app and directory roles) separate from security groups; document precedence.
- [ ] Add unit tests with signed JWT fixtures covering: direct `groups` array, overage marker, and no-groups fallback. - [x] Add unit tests with signed JWT fixtures covering: direct `groups` array, overage marker, and no-groups fallback.
- [x] Expose config knobs (e.g., `AuthOIDCGroupsToRoles`, Graph fetch enable/disable, request timeout) and surface them in CLI/`options.yml` reports.
- [ ] Add integration doc/tests for Entra app registration requirements (`groupMembershipClaims=SecurityGroup|All|ApplicationGroup`) and token size limits (~200 groups). - [ ] Add integration doc/tests for Entra app registration requirements (`groupMembershipClaims=SecurityGroup|All|ApplicationGroup`) and token size limits (~200 groups).
- [ ] Expose config knobs (e.g., `AuthOIDCGroupsToRoles`, Graph fetch enable/disable, request timeout) and surface them in CLI/`options.yml` reports.
- [ ] Update Pro parity notes so LDAP and OIDC group mappings share helpers and behavior. - [ ] Update Pro parity notes so LDAP and OIDC group mappings share helpers and behavior.
> **Note:** Entra ID security groups are only supported in PhotoPrism® Pro, so the related configuration flags are hidden in other editions.
#### Related Resources & Specs #### Related Resources & Specs
- Microsoft Entra group claims: https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-claim - Microsoft Entra group claims: https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-claim

View File

@@ -0,0 +1,129 @@
package oidc
import (
"strings"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/pkg/clean"
)
// NormalizeGroupID lowercases and sanitizes a group identifier (GUID or name).
func NormalizeGroupID(id string) string {
return strings.ToLower(clean.Auth(id))
}
// GroupsFromClaims extracts group identifiers from token or userinfo claims and detects Entra-style overage markers.
func GroupsFromClaims(claims map[string]any, claimName string) (groups []string, overage bool) {
if len(claims) == 0 {
return nil, false
}
if claimName == "" {
claimName = "groups"
}
if raw, ok := claims[claimName]; ok {
groups = append(groups, normalizeGroupValues(raw)...)
}
if raw, ok := claims["_claim_names"]; ok {
if names, ok := raw.(map[string]any); ok {
if _, ok := names[claimName]; ok {
overage = true
}
}
}
return uniqueGroups(groups), overage
}
// MapGroupsToRole returns the first matching role for the provided groups using the supplied mapping.
func MapGroupsToRole(groups []string, mapping map[string]acl.Role) (acl.Role, bool) {
if len(groups) == 0 || len(mapping) == 0 {
return acl.RoleNone, false
}
for _, g := range uniqueGroups(groups) {
if role, ok := mapping[g]; ok && role != acl.RoleNone {
return role, true
}
}
return acl.RoleNone, false
}
// HasAnyGroup returns true when at least one of the user's groups matches a required group.
func HasAnyGroup(groups []string, required []string) bool {
if len(required) == 0 {
return true
}
normalized := make(map[string]struct{}, len(uniqueGroups(groups)))
for _, g := range uniqueGroups(groups) {
normalized[g] = struct{}{}
}
for _, r := range required {
if _, ok := normalized[NormalizeGroupID(r)]; ok {
return true
}
}
return false
}
func normalizeGroupValues(raw any) []string {
switch v := raw.(type) {
case []string:
return normalizeGroupSlice(v)
case []any:
result := make([]string, 0, len(v))
for _, s := range v {
if val, ok := s.(string); ok {
result = append(result, val)
}
}
return normalizeGroupSlice(result)
case string:
return normalizeGroupSlice([]string{v})
default:
return nil
}
}
// normalizeGroupSlice sanitizes and lowercases each group identifier in the provided slice.
func normalizeGroupSlice(values []string) []string {
result := make([]string, 0, len(values))
for _, v := range values {
if n := NormalizeGroupID(v); n != "" {
result = append(result, n)
}
}
return result
}
// uniqueGroups returns a deduplicated, normalized list of group identifiers.
func uniqueGroups(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, v := range normalizeGroupSlice(values) {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
result = append(result, v)
}
return result
}

View File

@@ -0,0 +1,53 @@
package oidc
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/auth/acl"
)
func TestGroupsFromClaims(t *testing.T) {
claims := map[string]any{
"groups": []any{"ABC-123", "def-456", 7},
}
groups, overage := GroupsFromClaims(claims, "groups")
assert.False(t, overage)
assert.Equal(t, []string{"abc-123", "def-456"}, groups)
}
func TestGroupsFromClaimsOverage(t *testing.T) {
claims := map[string]any{
"_claim_names": map[string]any{
"groups": "src1",
},
}
groups, overage := GroupsFromClaims(claims, "groups")
assert.True(t, overage)
assert.Nil(t, groups)
}
func TestMapGroupsToRole(t *testing.T) {
mapping := map[string]acl.Role{
"abc-123": acl.RoleAdmin,
"def-456": acl.RoleGuest,
}
role, ok := MapGroupsToRole([]string{"zzz", "DEF-456"}, mapping)
assert.True(t, ok)
assert.Equal(t, acl.RoleGuest, role)
}
func TestHasAnyGroup(t *testing.T) {
required := []string{"abc-123", "def-456"}
assert.True(t, HasAnyGroup([]string{"ABC-123"}, required))
assert.False(t, HasAnyGroup([]string{"zzz"}, required))
assert.True(t, HasAnyGroup([]string{"zzz"}, nil))
}

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -133,6 +134,62 @@ func (c *Config) OIDCUsername() string {
} }
} }
// OIDCGroupClaim returns the claim name that should contain security group identifiers.
func (c *Config) OIDCGroupClaim() string {
if claim := strings.TrimSpace(c.options.OIDCGroupClaim); claim != "" {
return claim
}
return "groups"
}
// OIDCGroup returns the normalized list of required groups; empty means no group check.
func (c *Config) OIDCGroup() []string {
if len(c.options.OIDCGroup) == 0 {
return nil
}
result := make([]string, 0, len(c.options.OIDCGroup))
for _, g := range c.options.OIDCGroup {
if n := normalizeGroupID(g); n != "" {
result = append(result, n)
}
}
return result
}
// OIDCGroupRoles maps normalized group identifiers to roles.
func (c *Config) OIDCGroupRoles() map[string]acl.Role {
result := make(map[string]acl.Role, len(c.options.OIDCGroupRole))
for _, entry := range c.options.OIDCGroupRole {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
sep := strings.IndexAny(entry, "=:")
if sep < 1 || sep >= len(entry)-1 {
continue
}
group := normalizeGroupID(entry[:sep])
role := acl.ParseRole(entry[sep+1:])
if group == "" || role == acl.RoleNone {
continue
}
result[group] = role
}
return result
}
// OIDCDomain returns the email domain name for restricted single sign-on via OIDC. // OIDCDomain returns the email domain name for restricted single sign-on via OIDC.
func (c *Config) OIDCDomain() string { func (c *Config) OIDCDomain() string {
return clean.Domain(c.options.OIDCDomain) return clean.Domain(c.options.OIDCDomain)
@@ -193,6 +250,25 @@ func (c *Config) OIDCReport() (rows [][]string, cols []string) {
rows = append(rows, []string{"oidc-domain", domain}) rows = append(rows, []string{"oidc-domain", domain})
} }
if claim := c.OIDCGroupClaim(); claim != "" {
rows = append(rows, []string{"oidc-group-claim", claim})
}
if groups := c.OIDCGroup(); len(groups) > 0 {
rows = append(rows, []string{"oidc-group", strings.Join(groups, ",")})
}
if roles := c.OIDCGroupRoles(); len(roles) > 0 {
pairs := make([]string, 0, len(roles))
for g, r := range roles {
pairs = append(pairs, fmt.Sprintf("%s=%s", g, r))
}
sort.Strings(pairs)
rows = append(rows, []string{"oidc-group-role", strings.Join(pairs, ",")})
}
rows = append(rows, [][]string{ rows = append(rows, [][]string{
{"oidc-role", c.OIDCRole().String()}, {"oidc-role", c.OIDCRole().String()},
{"oidc-webdav", fmt.Sprintf("%t", c.OIDCWebDAV())}, {"oidc-webdav", fmt.Sprintf("%t", c.OIDCWebDAV())},
@@ -201,3 +277,8 @@ func (c *Config) OIDCReport() (rows [][]string, cols []string) {
return rows, cols return rows, cols
} }
// normalizeGroupID lowercases and sanitizes a group identifier (GUID or name) for comparisons.
func normalizeGroupID(id string) string {
return strings.ToLower(clean.Auth(id))
}

View File

@@ -132,6 +132,43 @@ func TestConfig_OIDCUsername(t *testing.T) {
assert.Equal(t, authn.OidcClaimPreferredUsername, c.OIDCUsername()) assert.Equal(t, authn.OidcClaimPreferredUsername, c.OIDCUsername())
} }
func TestConfig_OIDCGroupClaim(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "groups", c.OIDCGroupClaim())
c.options.OIDCGroupClaim = " roles "
assert.Equal(t, "roles", c.OIDCGroupClaim())
}
func TestConfig_OIDCGroup(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Nil(t, c.OIDCGroup())
c.options.OIDCGroup = []string{"ABC-123", " DEF-456 ", ""}
assert.Equal(t, []string{"abc-123", "def-456"}, c.OIDCGroup())
}
func TestConfig_OIDCGroupRoles(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.OIDCGroupRole = []string{
"ABC-123=admin",
"def-456:guest",
"invalid",
"=none",
}
roles := c.OIDCGroupRoles()
assert.Equal(t, acl.RoleAdmin, roles["abc-123"])
assert.Equal(t, acl.RoleGuest, roles["def-456"])
assert.Len(t, roles, 2)
}
func TestConfig_OIDCDomain(t *testing.T) { func TestConfig_OIDCDomain(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())

View File

@@ -7,6 +7,7 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/ai/face" "github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config/ttl" "github.com/photoprism/photoprism/internal/config/ttl"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/ffmpeg/encode" "github.com/photoprism/photoprism/internal/ffmpeg/encode"
@@ -117,6 +118,25 @@ var Flags = CliFlags{
Value: authn.OidcClaimPreferredUsername, Value: authn.OidcClaimPreferredUsername,
EnvVars: EnvVars("OIDC_USERNAME"), EnvVars: EnvVars("OIDC_USERNAME"),
}}, { }}, {
Flag: &cli.StringFlag{
Name: "oidc-group-claim",
Usage: "group claim `NAME` to read from OIDC tokens (default groups)",
Value: "",
EnvVars: EnvVars("OIDC_GROUP_CLAIM"),
Hidden: true,
}}, {
Flag: &cli.StringSliceFlag{
Name: "oidc-group",
Usage: "require membership in at least one group `ID` (repeat flag to add multiple)",
EnvVars: EnvVars("OIDC_GROUP"),
Hidden: true,
}}, {
Flag: &cli.StringSliceFlag{
Name: "oidc-group-role",
Usage: "map `GROUP=ROLE`; repeat to add more (roles: " + acl.UserRoles.CliUsageString() + ")",
EnvVars: EnvVars("OIDC_GROUP_ROLE"),
Hidden: true,
}}, {
Flag: &cli.BoolFlag{ Flag: &cli.BoolFlag{
Name: "oidc-webdav", Name: "oidc-webdav",
Usage: "allows new OpenID Connect users to use WebDAV when they have a role that allows it", Usage: "allows new OpenID Connect users to use WebDAV when they have a role that allows it",

View File

@@ -45,6 +45,9 @@ type Options struct {
OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"` OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"`
OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"` OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"`
OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"` OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"`
OIDCGroupClaim string `yaml:"OIDCGroupClaim" json:"-" flag:"oidc-group-claim" tags:"pro"`
OIDCGroup []string `yaml:"OIDCGroup" json:"-" flag:"oidc-group" tags:"pro"`
OIDCGroupRole []string `yaml:"OIDCGroupRole" json:"-" flag:"oidc-group-role" tags:"pro"`
OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain" tags:"pro"` OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain" tags:"pro"`
OIDCRole string `yaml:"-" json:"-" flag:"oidc-role" tags:"pro"` OIDCRole string `yaml:"-" json:"-" flag:"oidc-role" tags:"pro"`
OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"` OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"`