mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-11 16:24:11 +01:00
OIDC: Add support for Microsoft Entra ID security groups #5334
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -98,6 +98,47 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
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.
|
||||
var user *entity.User
|
||||
var err error
|
||||
@@ -216,6 +257,10 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
user.VerifiedAt = entity.TimeStamp()
|
||||
}
|
||||
|
||||
if hasMappedRole && !user.HasRole(mappedRole) {
|
||||
user.SetRole(mappedRole.String())
|
||||
}
|
||||
|
||||
// Update Subject ID and Issuer URI.
|
||||
user.SetAuthID(userInfo.Subject, provider.Issuer())
|
||||
|
||||
@@ -294,7 +339,11 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// 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.WebDAV = conf.OIDCWebDAV()
|
||||
|
||||
|
||||
@@ -53,14 +53,16 @@
|
||||
|
||||
### 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.
|
||||
- [ ] 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.
|
||||
- [ ] Add unit tests with signed JWT fixtures covering: direct `groups` array, overage marker, and no-groups fallback.
|
||||
- [x] Parse `groups` claim (string GUIDs) from ID/Access tokens and map to roles using a configurable mapping, mirroring LDAP group handling.
|
||||
- [x] Detect `_claim_names.groups` overage markers; optionally fetch memberships from Microsoft Graph (`/me/transitiveMemberOf`) behind a feature flag and enforced timeouts.
|
||||
- [x] Keep `roles`/`wids` (app and directory roles) separate from security groups; document precedence.
|
||||
- [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).
|
||||
- [ ] 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.
|
||||
|
||||
> **Note:** Entra ID security groups are only supported in PhotoPrism® Pro, so the related configuration flags are hidden in other editions.
|
||||
|
||||
#### Related Resources & Specs
|
||||
|
||||
- Microsoft Entra group claims: https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-claim
|
||||
|
||||
129
internal/auth/oidc/groups.go
Normal file
129
internal/auth/oidc/groups.go
Normal 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
|
||||
}
|
||||
53
internal/auth/oidc/groups_test.go
Normal file
53
internal/auth/oidc/groups_test.go
Normal 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))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"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.
|
||||
func (c *Config) OIDCDomain() string {
|
||||
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})
|
||||
}
|
||||
|
||||
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{
|
||||
{"oidc-role", c.OIDCRole().String()},
|
||||
{"oidc-webdav", fmt.Sprintf("%t", c.OIDCWebDAV())},
|
||||
@@ -201,3 +277,8 @@ func (c *Config) OIDCReport() (rows [][]string, cols []string) {
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -132,6 +132,43 @@ func TestConfig_OIDCUsername(t *testing.T) {
|
||||
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) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"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/entity"
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
@@ -117,6 +118,25 @@ var Flags = CliFlags{
|
||||
Value: authn.OidcClaimPreferredUsername,
|
||||
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{
|
||||
Name: "oidc-webdav",
|
||||
Usage: "allows new OpenID Connect users to use WebDAV when they have a role that allows it",
|
||||
|
||||
@@ -45,6 +45,9 @@ type Options struct {
|
||||
OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"`
|
||||
OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"`
|
||||
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"`
|
||||
OIDCRole string `yaml:"-" json:"-" flag:"oidc-role" tags:"pro"`
|
||||
OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"`
|
||||
|
||||
Reference in New Issue
Block a user