Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
92 changes: 54 additions & 38 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,10 @@ dns_config:
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
#
# headscale supports experimental OpenID connect support,
# it is still being tested and might have some bugs, please
# help us test it.
# OpenID Connect
# # headscale supports experimental OpenID connect support,
# # it is still being tested and might have some bugs, please
# # help us test it.
# # OpenID Connect
# oidc:
# only_start_if_oidc_is_available: true
# issuer: "https://your-oidc.issuer.com/path"
Expand All @@ -284,44 +284,60 @@ unix_socket_permission: "0770"
# # Alternatively, set `client_secret_path` to read the secret from the file.
# # It resolves environment variables, making integration to systemd's
# # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret and client_secret_path are mutually exclusive.
#
# # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry.
# expiry: 180d
#
# # Use the expiry from the token received from OpenID when the user logged
# # in, this will typically lead to frequent need to reauthenticate and should
# # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry` to be ignored.
# use_expiry_from_token: false
#
# #
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
#
# scope: ["openid", "profile", "email", "custom"]
# extra_params:
# domain_hint: example.com
#
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # authentication request will be rejected.
#
# allowed_domains:
# - example.com
# # Note: Groups from keycloak have a leading '/'
# allowed_groups:
# - /headscale
# allowed_users:
# - [email protected]
#
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# # This will transform `[email protected]` to the user `first-name.last-name`
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
# user: `first-name.last-name.example.com`
#
# strip_email_domain: true
# # extra_params:
# # domain_hint: example.com

# expiry:
# #
# # Use the expiry from the token received from OpenID when the user logged
# # in, this will typically lead to frequent need to reauthenticate and should
# # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry.fixed_time` to be ignored.
# from_token: false
# #
# # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry.
# fixed_time: 180d

# # # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # # authentication request will be rejected.
# # allowd:
# # domains:
# # - example.com
# # groups:
# # - admins
# # users:
# # - [email protected]

# # Map claims from the OIDC token to the user object
# claims_map:
# name: name
# username: email
# # username: preferred_username
# email: email
# groups: groups


# # some random configuration
# misc:
# # if the username is set to `email` then `strip_email_domain` is valid
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# # This will transform `[email protected]` to the user `first-name.last-name`
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
# # user: `first-name.last-name.example.com`
# strip_email_domain: true
# # If `flatten_groups` is set to `true`, the groups claim will be flattened to a single level.
# # this is used for keycloak where the groups are nested. the groups format from keycloak is `group1/subgroup1/subgroup2`
# flatten_groups: true
# # If `flatten_splitter` is set to a string, the groups claim will be split by the string and flattened to a single level.
# flatten_splitter: "/"

# Logtail configuration
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
Expand Down
78 changes: 66 additions & 12 deletions hscontrol/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
Expand Down Expand Up @@ -77,11 +78,11 @@ func (h *Headscale) initOIDC() error {
}

func (h *Headscale) determineTokenExpiration(idTokenExpiration time.Time) time.Time {
if h.cfg.OIDC.UseExpiryFromToken {
if h.cfg.OIDC.Expiry.FromToken {
return idTokenExpiration
}

return time.Now().Add(h.cfg.OIDC.Expiry)
return time.Now().Add(h.cfg.OIDC.Expiry.FixedTime)
}

// RegisterOIDC redirects to the OIDC provider for authentication
Expand Down Expand Up @@ -197,20 +198,20 @@ func (h *Headscale) OIDCCallback(
// return
// }

claims, err := extractIDTokenClaims(writer, idToken)
claims, err := extractIDTokenClaims(writer, idToken, h.cfg.OIDC.ClaimsMap, h.cfg.OIDC.Misc.FlattenGroups, h.cfg.OIDC.Misc.FlattenSplter)
if err != nil {
return
}

if err := validateOIDCAllowedDomains(writer, h.cfg.OIDC.AllowedDomains, claims); err != nil {
if err := validateOIDCAllowedDomains(writer, h.cfg.OIDC.Allowed.Domains, claims); err != nil {
return
}

if err := validateOIDCAllowedGroups(writer, h.cfg.OIDC.AllowedGroups, claims); err != nil {
if err := validateOIDCAllowedGroups(writer, h.cfg.OIDC.Allowed.Groups, claims); err != nil {
return
}

if err := validateOIDCAllowedUsers(writer, h.cfg.OIDC.AllowedUsers, claims); err != nil {
if err := validateOIDCAllowedUsers(writer, h.cfg.OIDC.Allowed.Users, claims); err != nil {
return
}

Expand All @@ -223,8 +224,7 @@ func (h *Headscale) OIDCCallback(
if err != nil || nodeExists {
return
}

userName, err := getUserName(writer, claims, h.cfg.OIDC.StripEmaildomain)
userName, err := getUserName(writer, claims, h.cfg.OIDC.Misc.StripEmaildomain)
if err != nil {
return
}
Expand Down Expand Up @@ -338,22 +338,54 @@ func (h *Headscale) verifyIDTokenForOIDCCallback(
func extractIDTokenClaims(
writer http.ResponseWriter,
idToken *oidc.IDToken,
claimsMap types.OIDCClaimsMap,
flattenGroup bool,
flattenSpliter string,
) (*IDTokenClaims, error) {
var claims IDTokenClaims
var claims json.RawMessage
// Parse the ID Token claims into the struct
if err := idToken.Claims(&claims); err != nil {
util.LogErr(err, "Failed to decode id token claims")

writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
_, werr := writer.Write([]byte("Failed to decode id token claims"))
if werr != nil {
util.LogErr(err, "Failed to write response")
}
return nil, err
}

// Unmarshal the claims into a map
mappedClaims := make(map[string]interface{})
if err := json.Unmarshal(claims, &mappedClaims); err != nil {
util.LogErr(err, "Failed to unmarshal id token claims")
return nil, err
}

return &claims, nil
// Map the claims to the final struct
var finalClaims IDTokenClaims
if val, ok := mappedClaims[claimsMap.Name]; ok {
finalClaims.Name = val.(string)
}
if val, ok := mappedClaims[claimsMap.Username]; ok {
finalClaims.Username = val.(string)
}
if val, ok := mappedClaims[claimsMap.Email]; ok {
finalClaims.Email = val.(string)
}
if val, ok := mappedClaims[claimsMap.Groups]; ok && val != nil {
groups, ok := val.([]interface{})
if ok {
for _, group := range groups {
finalClaims.Groups = append(finalClaims.Groups, group.(string))
}
}
}
// Flatten groups if needed
if flattenGroup {
finalClaims.Groups = flattenGroups(finalClaims.Groups, flattenSpliter)
}
return &finalClaims, nil
}

// validateOIDCAllowedDomains checks that if AllowedDomains is provided,
Expand Down Expand Up @@ -541,7 +573,7 @@ func getUserName(
stripEmaildomain bool,
) (string, error) {
userName, err := util.NormalizeToFQDNRules(
claims.Email,
claims.Username,
stripEmaildomain,
)
if err != nil {
Expand Down Expand Up @@ -653,3 +685,25 @@ func renderOIDCCallbackTemplate(

return &content, nil
}

// flattenGroups takes a list of groups and returns a list of all groups and subgroups.
// groups format is a list of strings with the groups separated by slashes. e.g.: ["a/b/c", "a/b/d"]
func flattenGroups(groups []string, spliter string) []string {
// A map to keep track of which groups we have seen
seen := make(map[string]bool)
var result []string

// Iterate over each group, format is a/b/c
for _, group := range groups {
// Split the group into segments, e.g. ["a", "b", "c"]
segments := strings.Split(group, spliter)
for _, segment := range segments {
if !seen[segment] && segment != "" {
seen[segment] = true
result = append(result, segment)
}
}
}

return result
}
4 changes: 3 additions & 1 deletion hscontrol/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ func (s *Suite) ResetDB(c *check.C) {
},
},
OIDC: types.OIDCConfig{
StripEmaildomain: false,
Misc: types.OIDCMiscConfig{
StripEmaildomain: false,
},
},
}

Expand Down
Loading