Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/ACTIVE-DIRECTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,14 @@ These map AD attributes to SimpleAuth user fields:
| `department_attr` | `department` | Department name |
| `company_attr` | `company` | Company name |
| `job_title_attr` | `title` | Job title |
| `given_name_attr` | `givenName` | First name, emitted as the OIDC `given_name` claim |
| `family_name_attr` | `sn` | Last name, emitted as the OIDC `family_name` claim |
| `groups_attr` | `memberOf` | Group membership (multi-valued DN list) |

Left empty, `given_name_attr` and `family_name_attr` fall back to `givenName` and `sn`, so existing
configurations start emitting `given_name` / `family_name` without changes. OIDC clients such as
OpenProject use these claims for the user's first and last name.

### Priority

| Field | Default | Description |
Expand Down
7 changes: 5 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ curl -k https://auth.example.com/sauth/.well-known/openid-configuration
"scopes_supported": ["openid", "profile", "email", "roles"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"claims_supported": [
"sub", "iss", "aud", "exp", "iat", "name", "email",
"sub", "iss", "aud", "exp", "iat", "name", "given_name", "family_name", "email",
"preferred_username", "realm_access",
"department", "company", "job_title", "groups"
]
Expand Down Expand Up @@ -817,7 +817,8 @@ Error codes: `invalid_request`, `invalid_client`, `invalid_grant`, `unsupported_

**Auth:** Bearer Token

OIDC UserInfo endpoint.
OIDC UserInfo endpoint. `given_name` and `family_name` come from the directory (`given_name_attr` /
`family_name_attr`) and are omitted when unknown, for example for local users.

```bash
curl -k -H "Authorization: Bearer ACCESS_TOKEN" \
Expand All @@ -830,6 +831,8 @@ curl -k -H "Authorization: Bearer ACCESS_TOKEN" \
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Smith",
"given_name": "John",
"family_name": "Smith",
"email": "jsmith@corp.local",
"preferred_username": "jsmith@corp.local",
"department": "Engineering",
Expand Down
2 changes: 2 additions & 0 deletions internal/auth/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type Claims struct {
jwt.RegisteredClaims
GUID string `json:"guid,omitempty"`
Name string `json:"name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
Email string `json:"email,omitempty"`
Department string `json:"department,omitempty"`
Company string `json:"company,omitempty"`
Expand Down
12 changes: 11 additions & 1 deletion internal/auth/ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type LDAPConfig struct {
CompanyAttr string
JobTitleAttr string
GroupsAttr string
GivenNameAttr string
FamilyNameAttr string
}

type LDAPResult struct {
Expand All @@ -45,6 +47,8 @@ type LDAPResult struct {
Department string
Company string
JobTitle string
GivenName string
FamilyName string
Groups []string
// Disabled is true when AD reports the account as disabled
// (userAccountControl ACCOUNTDISABLE) or past its accountExpires date.
Expand Down Expand Up @@ -250,7 +254,7 @@ func ldapAttrs(cfg *LDAPConfig) []string {
usernameAttr = "sAMAccountName"
}
attrs := []string{"dn", usernameAttr, "userAccountControl", "accountExpires"}
for _, a := range []string{cfg.DisplayNameAttr, cfg.EmailAttr, cfg.DepartmentAttr, cfg.CompanyAttr, cfg.JobTitleAttr, cfg.GroupsAttr} {
for _, a := range []string{cfg.DisplayNameAttr, cfg.EmailAttr, cfg.DepartmentAttr, cfg.CompanyAttr, cfg.JobTitleAttr, cfg.GroupsAttr, cfg.GivenNameAttr, cfg.FamilyNameAttr} {
if a != "" {
attrs = append(attrs, a)
}
Expand Down Expand Up @@ -283,6 +287,12 @@ func entryToResult(entry *ldap.Entry, cfg *LDAPConfig) *LDAPResult {
if cfg.JobTitleAttr != "" {
result.JobTitle = entry.GetAttributeValue(cfg.JobTitleAttr)
}
if cfg.GivenNameAttr != "" {
result.GivenName = entry.GetAttributeValue(cfg.GivenNameAttr)
}
if cfg.FamilyNameAttr != "" {
result.FamilyName = entry.GetAttributeValue(cfg.FamilyNameAttr)
}
if cfg.GroupsAttr != "" {
result.Groups = entry.GetAttributeValues(cfg.GroupsAttr)
// Extract the CN from full DN group names, case-insensitively. AD returns
Expand Down
38 changes: 38 additions & 0 deletions internal/auth/ldap_names_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package auth

import (
"slices"
"testing"

"github.com/go-ldap/ldap/v3"
)

func TestLDAPReadsGivenAndFamilyName(t *testing.T) {
cfg := &LDAPConfig{UsernameAttr: "sAMAccountName", GivenNameAttr: "givenName", FamilyNameAttr: "sn"}

attrs := ldapAttrs(cfg)
if !slices.Contains(attrs, "givenName") || !slices.Contains(attrs, "sn") {
t.Fatalf("search must request the name attributes, got %v", attrs)
}

entry := ldap.NewEntry("CN=Salem Almarri,CN=Users,DC=green,DC=org", map[string][]string{
"sAMAccountName": {"salem"},
"givenName": {"سالم"},
"sn": {"المري"},
})
got := entryToResult(entry, cfg)
if got.GivenName != "سالم" || got.FamilyName != "المري" {
t.Fatalf("given/family name not read from the entry: %q/%q", got.GivenName, got.FamilyName)
}
}

func TestLDAPNameAttributesOptional(t *testing.T) {
cfg := &LDAPConfig{UsernameAttr: "sAMAccountName"}
if attrs := ldapAttrs(cfg); slices.Contains(attrs, "") {
t.Fatalf("unset name attributes must not add empty attribute names: %v", attrs)
}
got := entryToResult(ldap.NewEntry("CN=x", map[string][]string{"sAMAccountName": {"x"}, "givenName": {"X"}}), cfg)
if got.GivenName != "" || got.FamilyName != "" {
t.Fatalf("unmapped attributes must not populate names: %q/%q", got.GivenName, got.FamilyName)
}
}
8 changes: 8 additions & 0 deletions internal/handler/admin_ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ func (h *Handler) handleTestLDAPUser(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]interface{}{
"status": "ok",
"display_name": result.DisplayName,
"given_name": result.GivenName,
"family_name": result.FamilyName,
"email": result.Email,
"department": result.Department,
"company": result.Company,
Expand Down Expand Up @@ -204,6 +206,8 @@ func (h *Handler) handleImportLDAP(w http.ResponseWriter, r *http.Request) {
CompanyAttr: "company",
JobTitleAttr: "title",
GroupsAttr: "memberOf",
GivenNameAttr: "givenName",
FamilyNameAttr: "sn",
Domain: req.Domain,
}

Expand Down Expand Up @@ -352,6 +356,8 @@ func (h *Handler) handleAutoDiscoverLDAP(w http.ResponseWriter, r *http.Request)
CompanyAttr: "company",
JobTitleAttr: "title",
GroupsAttr: "memberOf",
GivenNameAttr: "givenName",
FamilyNameAttr: "sn",
Domain: domain,
}

Expand Down Expand Up @@ -484,6 +490,8 @@ func (h *Handler) handleImportLDAPUsers(w http.ResponseWriter, r *http.Request)
// Create SimpleAuth user
user := &store.User{
DisplayName: ldapUser.DisplayName,
GivenName: ldapUser.GivenName,
FamilyName: ldapUser.FamilyName,
Email: ldapUser.Email,
Department: ldapUser.Department,
Company: ldapUser.Company,
Expand Down
16 changes: 16 additions & 0 deletions internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ func (h *Handler) authenticateUser(username, password string, app *store.App) (s
// JIT provisioning
newUser := &store.User{
DisplayName: result.DisplayName,
GivenName: result.GivenName,
FamilyName: result.FamilyName,
Email: result.Email,
Department: result.Department,
Company: result.Company,
Expand Down Expand Up @@ -331,6 +333,14 @@ func (h *Handler) syncUserFromLDAP(user *store.User, result *auth.LDAPResult) {
user.JobTitle = result.JobTitle
changed = true
}
if result.GivenName != "" && result.GivenName != user.GivenName {
user.GivenName = result.GivenName
changed = true
}
if result.FamilyName != "" && result.FamilyName != user.FamilyName {
user.FamilyName = result.FamilyName
changed = true
}
if result.Username != "" && result.Username != user.SAMAccountName {
user.SAMAccountName = result.Username
changed = true
Expand Down Expand Up @@ -390,6 +400,8 @@ func (h *Handler) issueTokenPair(user *store.User, roles []string, perms []strin
claims := auth.Claims{
GUID: user.GUID,
Name: user.DisplayName,
GivenName: user.GivenName,
FamilyName: user.FamilyName,
Email: user.Email,
Department: user.Department,
Company: user.Company,
Expand Down Expand Up @@ -717,6 +729,8 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
newClaims := auth.Claims{
GUID: user.GUID,
Name: user.DisplayName,
GivenName: user.GivenName,
FamilyName: user.FamilyName,
Email: user.Email,
Department: user.Department,
Company: user.Company,
Expand Down Expand Up @@ -1614,6 +1628,8 @@ func (h *Handler) resolveKerberosUser(username string) (string, []string, error)

newUser := &store.User{
DisplayName: result.DisplayName,
GivenName: result.GivenName,
FamilyName: result.FamilyName,
Email: result.Email,
Department: result.Department,
Company: result.Company,
Expand Down
9 changes: 9 additions & 0 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,13 @@ func (h *Handler) auditLogin(user *store.User, ip string, extra map[string]inter
}

func ldapConfigFromStore(p *store.LDAPConfig) *auth.LDAPConfig {
givenNameAttr, familyNameAttr := p.GivenNameAttr, p.FamilyNameAttr
if givenNameAttr == "" {
givenNameAttr = "givenName"
}
if familyNameAttr == "" {
familyNameAttr = "sn"
}
return &auth.LDAPConfig{
URL: p.URL,
BaseDN: p.BaseDN,
Expand All @@ -506,6 +513,8 @@ func ldapConfigFromStore(p *store.LDAPConfig) *auth.LDAPConfig {
CompanyAttr: p.CompanyAttr,
JobTitleAttr: p.JobTitleAttr,
GroupsAttr: p.GroupsAttr,
GivenNameAttr: givenNameAttr,
FamilyNameAttr: familyNameAttr,
}
}

Expand Down
12 changes: 11 additions & 1 deletion internal/handler/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func (h *Handler) handleOIDCDiscovery(w http.ResponseWriter, r *http.Request) {
"token_endpoint_auth_methods_supported": authMethods,
"code_challenge_methods_supported": []string{"S256"},
"claims_supported": []string{
"sub", "iss", "aud", "exp", "iat", "name", "email",
"sub", "iss", "aud", "exp", "iat", "name", "given_name", "family_name", "email",
"preferred_username", "realm_access", "resource_access",
"department", "company", "job_title", "groups",
},
Expand Down Expand Up @@ -888,6 +888,8 @@ func (h *Handler) issueOIDCTokens(w http.ResponseWriter, r *http.Request, user *
idAud := appAudience(app)
idClaims := auth.Claims{
Name: user.DisplayName,
GivenName: user.GivenName,
FamilyName: user.FamilyName,
Email: user.Email,
PreferredUsername: user.Email,
Nonce: nonce,
Expand Down Expand Up @@ -941,6 +943,8 @@ func (h *Handler) buildOIDCAccessClaims(user *store.User, roles, perms, groups [

claims := auth.Claims{
Name: user.DisplayName,
GivenName: user.GivenName,
FamilyName: user.FamilyName,
Email: user.Email,
Department: user.Department,
Company: user.Company,
Expand Down Expand Up @@ -1000,6 +1004,12 @@ func (h *Handler) handleOIDCUserInfo(w http.ResponseWriter, r *http.Request) {
if user.Email == "" {
resp["preferred_username"] = user.DisplayName
}
if user.GivenName != "" {
resp["given_name"] = user.GivenName
}
if user.FamilyName != "" {
resp["family_name"] = user.FamilyName
}
if user.Department != "" {
resp["department"] = user.Department
}
Expand Down
Loading
Loading