Merge remote-tracking branch 'IamTaoChen/dev_oidc'
This commit is contained in:
commit
46b481b102
6 changed files with 299 additions and 136 deletions
|
@ -321,10 +321,10 @@ dns:
|
||||||
unix_socket: /var/run/headscale/headscale.sock
|
unix_socket: /var/run/headscale/headscale.sock
|
||||||
unix_socket_permission: "0770"
|
unix_socket_permission: "0770"
|
||||||
#
|
#
|
||||||
# headscale supports experimental OpenID connect support,
|
# # headscale supports experimental OpenID connect support,
|
||||||
# it is still being tested and might have some bugs, please
|
# # it is still being tested and might have some bugs, please
|
||||||
# help us test it.
|
# # help us test it.
|
||||||
# OpenID Connect
|
# # OpenID Connect
|
||||||
# oidc:
|
# oidc:
|
||||||
# only_start_if_oidc_is_available: true
|
# only_start_if_oidc_is_available: true
|
||||||
# issuer: "https://your-oidc.issuer.com/path"
|
# issuer: "https://your-oidc.issuer.com/path"
|
||||||
|
@ -333,44 +333,60 @@ unix_socket_permission: "0770"
|
||||||
# # Alternatively, set `client_secret_path` to read the secret from the file.
|
# # Alternatively, set `client_secret_path` to read the secret from the file.
|
||||||
# # It resolves environment variables, making integration to systemd's
|
# # It resolves environment variables, making integration to systemd's
|
||||||
# # `LoadCredential` straightforward:
|
# # `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.
|
# # 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
|
# # 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".
|
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
|
||||||
#
|
|
||||||
# scope: ["openid", "profile", "email", "custom"]
|
# scope: ["openid", "profile", "email", "custom"]
|
||||||
# extra_params:
|
# # extra_params:
|
||||||
# domain_hint: example.com
|
# # domain_hint: example.com
|
||||||
#
|
|
||||||
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
|
# expiry:
|
||||||
# # authentication request will be rejected.
|
# #
|
||||||
#
|
# # Use the expiry from the token received from OpenID when the user logged
|
||||||
# allowed_domains:
|
# # in, this will typically lead to frequent need to reauthenticate and should
|
||||||
# - example.com
|
# # only been enabled if you know what you are doing.
|
||||||
# # Note: Groups from keycloak have a leading '/'
|
# # Note: enabling this will cause `oidc.expiry.fixed_time` to be ignored.
|
||||||
# allowed_groups:
|
# from_token: false
|
||||||
# - /headscale
|
# #
|
||||||
# allowed_users:
|
# # The amount of time from a node is authenticated with OpenID until it
|
||||||
# - alice@example.com
|
# # expires and needs to reauthenticate.
|
||||||
#
|
# # Setting the value to "0" will mean no expiry.
|
||||||
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
|
# fixed_time: 180d
|
||||||
# # This will transform `first-name.last-name@example.com` 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
|
# # # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
|
||||||
# user: `first-name.last-name.example.com`
|
# # # authentication request will be rejected.
|
||||||
#
|
# # allowed:
|
||||||
# strip_email_domain: true
|
# # domains:
|
||||||
|
# # - example.com
|
||||||
|
# # groups:
|
||||||
|
# # - admins
|
||||||
|
# # users:
|
||||||
|
# # - admin@example.com
|
||||||
|
|
||||||
|
# # 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 `first-name.last-name@example.com` 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 configuration
|
||||||
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
|
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
|
||||||
|
|
80
docs/oidc.md
80
docs/oidc.md
|
@ -13,43 +13,69 @@ In your `config.yaml`, customize this to your liking:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
oidc:
|
oidc:
|
||||||
# Block further startup until the OIDC provider is healthy and available
|
|
||||||
only_start_if_oidc_is_available: true
|
only_start_if_oidc_is_available: true
|
||||||
# Specified by your OIDC provider
|
|
||||||
issuer: "https://your-oidc.issuer.com/path"
|
issuer: "https://your-oidc.issuer.com/path"
|
||||||
# Specified/generated by your OIDC provider
|
|
||||||
client_id: "your-oidc-client-id"
|
client_id: "your-oidc-client-id"
|
||||||
client_secret: "your-oidc-client-secret"
|
client_secret: "your-oidc-client-secret"
|
||||||
# alternatively, set `client_secret_path` to read the secret from the file.
|
# Alternatively, set `client_secret_path` to read the secret from the file.
|
||||||
# It resolves environment variables, making integration to systemd's
|
# It resolves environment variables, making integration to systemd's
|
||||||
# `LoadCredential` straightforward:
|
# `LoadCredential` straightforward:
|
||||||
#client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
||||||
# as third option, it's also possible to load the oidc secret from environment variables
|
# client_secret and client_secret_path are mutually exclusive.
|
||||||
# set HEADSCALE_OIDC_CLIENT_SECRET to the required value
|
#
|
||||||
|
|
||||||
# Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
|
# 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".
|
# parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
|
||||||
scope: ["openid", "profile", "email", "custom"]
|
scope: ["openid", "profile", "email", "custom"]
|
||||||
# Optional: Passed on to the browser login request – used to tweak behaviour for the OIDC provider
|
extra_params:
|
||||||
extra_params:
|
domain_hint: example.com
|
||||||
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.
|
||||||
|
allowed:
|
||||||
|
domains:
|
||||||
|
- example.com
|
||||||
|
groups:
|
||||||
|
- admins
|
||||||
|
users:
|
||||||
|
- admin@example.com
|
||||||
|
|
||||||
|
# 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 `first-name.last-name@example.com` 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: "/"
|
||||||
|
|
||||||
# Optional: 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
|
|
||||||
# Optional. Note that groups from Keycloak have a leading '/'.
|
|
||||||
allowed_groups:
|
|
||||||
- /headscale
|
|
||||||
# Optional.
|
|
||||||
allowed_users:
|
|
||||||
- alice@example.com
|
|
||||||
|
|
||||||
# If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
|
|
||||||
# This will transform `first-name.last-name@example.com` 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Azure AD example
|
## Azure AD example
|
||||||
|
@ -171,4 +197,4 @@ oidc:
|
||||||
scope: ["openid", "profile", "email"]
|
scope: ["openid", "profile", "email"]
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also use `allowed_domains` and `allowed_users` to restrict the users who can authenticate.
|
You can also use `allowed.domains` and `allowed.users` to restrict the users who can authenticate.
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
@ -77,11 +78,11 @@ func (h *Headscale) initOIDC() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Headscale) determineTokenExpiration(idTokenExpiration time.Time) time.Time {
|
func (h *Headscale) determineTokenExpiration(idTokenExpiration time.Time) time.Time {
|
||||||
if h.cfg.OIDC.UseExpiryFromToken {
|
if h.cfg.OIDC.Expiry.FromToken {
|
||||||
return idTokenExpiration
|
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
|
// RegisterOIDC redirects to the OIDC provider for authentication
|
||||||
|
@ -197,20 +198,20 @@ func (h *Headscale) OIDCCallback(
|
||||||
// return
|
// 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 {
|
if err != nil {
|
||||||
return
|
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
|
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
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -223,8 +224,7 @@ func (h *Headscale) OIDCCallback(
|
||||||
if err != nil || nodeExists {
|
if err != nil || nodeExists {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userName, err := getUserName(writer, claims, h.cfg.OIDC.Misc.StripEmaildomain)
|
||||||
userName, err := getUserName(writer, claims, h.cfg.OIDC.StripEmaildomain)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -338,22 +338,54 @@ func (h *Headscale) verifyIDTokenForOIDCCallback(
|
||||||
func extractIDTokenClaims(
|
func extractIDTokenClaims(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
idToken *oidc.IDToken,
|
idToken *oidc.IDToken,
|
||||||
|
claimsMap types.OIDCClaimsMap,
|
||||||
|
flattenGroup bool,
|
||||||
|
flattenSpliter string,
|
||||||
) (*IDTokenClaims, error) {
|
) (*IDTokenClaims, error) {
|
||||||
var claims IDTokenClaims
|
var claims json.RawMessage
|
||||||
|
// Parse the ID Token claims into the struct
|
||||||
if err := idToken.Claims(&claims); err != nil {
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
util.LogErr(err, "Failed to decode id token claims")
|
util.LogErr(err, "Failed to decode id token claims")
|
||||||
|
|
||||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
writer.WriteHeader(http.StatusBadRequest)
|
writer.WriteHeader(http.StatusBadRequest)
|
||||||
_, werr := writer.Write([]byte("Failed to decode id token claims"))
|
_, werr := writer.Write([]byte("Failed to decode id token claims"))
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
util.LogErr(err, "Failed to write response")
|
util.LogErr(err, "Failed to write response")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &claims, nil
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
// validateOIDCAllowedDomains checks that if AllowedDomains is provided,
|
||||||
|
@ -551,7 +583,7 @@ func getUserName(
|
||||||
stripEmaildomain bool,
|
stripEmaildomain bool,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
userName, err := util.NormalizeToFQDNRules(
|
userName, err := util.NormalizeToFQDNRules(
|
||||||
claims.Email,
|
claims.Username,
|
||||||
stripEmaildomain,
|
stripEmaildomain,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -663,3 +695,25 @@ func renderOIDCCallbackTemplate(
|
||||||
|
|
||||||
return &content, nil
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -47,7 +47,9 @@ func (s *Suite) ResetDB(c *check.C) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
OIDC: types.OIDCConfig{
|
OIDC: types.OIDCConfig{
|
||||||
StripEmaildomain: false,
|
Misc: types.OIDCMiscConfig{
|
||||||
|
StripEmaildomain: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -161,12 +161,34 @@ type OIDCConfig struct {
|
||||||
ClientSecret string
|
ClientSecret string
|
||||||
Scope []string
|
Scope []string
|
||||||
ExtraParams map[string]string
|
ExtraParams map[string]string
|
||||||
AllowedDomains []string
|
ClaimsMap OIDCClaimsMap
|
||||||
AllowedUsers []string
|
Allowed OIDCAllowedConfig
|
||||||
AllowedGroups []string
|
Expiry OIDCExpireConfig
|
||||||
StripEmaildomain bool
|
Misc OIDCMiscConfig
|
||||||
Expiry time.Duration
|
}
|
||||||
UseExpiryFromToken bool
|
|
||||||
|
type OIDCExpireConfig struct {
|
||||||
|
FromToken bool
|
||||||
|
FixedTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type OIDCAllowedConfig struct {
|
||||||
|
Domains []string
|
||||||
|
Users []string
|
||||||
|
Groups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OIDCClaimsMap struct {
|
||||||
|
Name string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Groups string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OIDCMiscConfig struct {
|
||||||
|
StripEmaildomain bool
|
||||||
|
FlattenGroups bool
|
||||||
|
FlattenSplter string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DERPConfig struct {
|
type DERPConfig struct {
|
||||||
|
@ -268,10 +290,19 @@ func LoadConfig(path string, isFile bool) error {
|
||||||
viper.SetDefault("database.sqlite.write_ahead_log", true)
|
viper.SetDefault("database.sqlite.write_ahead_log", true)
|
||||||
|
|
||||||
viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
|
viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
|
||||||
viper.SetDefault("oidc.strip_email_domain", true)
|
|
||||||
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
|
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
|
||||||
viper.SetDefault("oidc.expiry", "180d")
|
// expiry
|
||||||
viper.SetDefault("oidc.use_expiry_from_token", false)
|
viper.SetDefault("oidc.expiry.fixed_time", "180d")
|
||||||
|
viper.SetDefault("oidc.expiry.from_token", false)
|
||||||
|
// claims_map
|
||||||
|
viper.SetDefault("oidc.claims_map.name", "name")
|
||||||
|
viper.SetDefault("oidc.claims_map.username", "preferred_username")
|
||||||
|
viper.SetDefault("oidc.claims_map.email", "email")
|
||||||
|
viper.SetDefault("oidc.claims_map.groups", "groups")
|
||||||
|
// misc
|
||||||
|
viper.SetDefault("oidc.misc.strip_email_domain", false)
|
||||||
|
viper.SetDefault("oidc.misc.flatten_groups", false)
|
||||||
|
viper.SetDefault("oidc.misc.flatten_splitter", "/")
|
||||||
|
|
||||||
viper.SetDefault("logtail.enabled", false)
|
viper.SetDefault("logtail.enabled", false)
|
||||||
viper.SetDefault("randomize_client_port", false)
|
viper.SetDefault("randomize_client_port", false)
|
||||||
|
@ -723,6 +754,76 @@ func PrefixV6() (*netip.Prefix, error) {
|
||||||
return &prefixV6, nil
|
return &prefixV6, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetOIDCConfig() (OIDCConfig, error) {
|
||||||
|
|
||||||
|
// get expiry config
|
||||||
|
oidcExpireConfig := OIDCExpireConfig{
|
||||||
|
FromToken: viper.GetBool("oidc.expiry.from_token"),
|
||||||
|
FixedTime: func() time.Duration {
|
||||||
|
// if set to 0, we assume no expiry
|
||||||
|
if value := viper.GetString("oidc.expiry.fixed_time"); value == "0" {
|
||||||
|
return maxDuration
|
||||||
|
} else {
|
||||||
|
expiry, err := model.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Msg("failed to parse oidc.expiry.fixed_time, defaulting back to 180 days")
|
||||||
|
|
||||||
|
return defaultOIDCExpiryTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(expiry)
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
// get allowed config
|
||||||
|
oidcAllowed := OIDCAllowedConfig{
|
||||||
|
Domains: viper.GetStringSlice("oidc.allowed.domains"),
|
||||||
|
Users: viper.GetStringSlice("oidc.allowed.users"),
|
||||||
|
Groups: viper.GetStringSlice("oidc.allowed.groups"),
|
||||||
|
}
|
||||||
|
// get claims map
|
||||||
|
oidcClaimsMap := OIDCClaimsMap{
|
||||||
|
Name: viper.GetString("oidc.claims_map.name"),
|
||||||
|
Username: viper.GetString("oidc.claims_map.username"),
|
||||||
|
Email: viper.GetString("oidc.claims_map.email"),
|
||||||
|
Groups: viper.GetString("oidc.claims_map.groups"),
|
||||||
|
}
|
||||||
|
// get misc config
|
||||||
|
oidcMiscConfig := OIDCMiscConfig{
|
||||||
|
StripEmaildomain: viper.GetBool("oidc.misc.strip_email_domain"),
|
||||||
|
FlattenGroups: viper.GetBool("oidc.misc.flatten_groups"),
|
||||||
|
FlattenSplter: viper.GetString("oidc.misc.flatten_splitter"),
|
||||||
|
}
|
||||||
|
// get client secret
|
||||||
|
oidcClientSecret := viper.GetString("oidc.client_secret")
|
||||||
|
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
|
||||||
|
if oidcClientSecretPath != "" && oidcClientSecret != "" {
|
||||||
|
return OIDCConfig{}, errOidcMutuallyExclusive
|
||||||
|
}
|
||||||
|
if oidcClientSecretPath != "" {
|
||||||
|
secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath))
|
||||||
|
if err != nil {
|
||||||
|
return OIDCConfig{}, err
|
||||||
|
}
|
||||||
|
oidcClientSecret = strings.TrimSpace(string(secretBytes))
|
||||||
|
}
|
||||||
|
OIDC := OIDCConfig{
|
||||||
|
OnlyStartIfOIDCIsAvailable: viper.GetBool(
|
||||||
|
"oidc.only_start_if_oidc_is_available",
|
||||||
|
),
|
||||||
|
Issuer: viper.GetString("oidc.issuer"),
|
||||||
|
ClientID: viper.GetString("oidc.client_id"),
|
||||||
|
ClientSecret: oidcClientSecret,
|
||||||
|
Scope: viper.GetStringSlice("oidc.scope"),
|
||||||
|
ExtraParams: viper.GetStringMapString("oidc.extra_params"),
|
||||||
|
Allowed: oidcAllowed,
|
||||||
|
ClaimsMap: oidcClaimsMap,
|
||||||
|
Expiry: oidcExpireConfig,
|
||||||
|
Misc: oidcMiscConfig,
|
||||||
|
}
|
||||||
|
return OIDC, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetHeadscaleConfig() (*Config, error) {
|
func GetHeadscaleConfig() (*Config, error) {
|
||||||
if IsCLIConfigured() {
|
if IsCLIConfigured() {
|
||||||
return &Config{
|
return &Config{
|
||||||
|
@ -772,17 +873,9 @@ func GetHeadscaleConfig() (*Config, error) {
|
||||||
logTailConfig := GetLogTailConfig()
|
logTailConfig := GetLogTailConfig()
|
||||||
randomizeClientPort := viper.GetBool("randomize_client_port")
|
randomizeClientPort := viper.GetBool("randomize_client_port")
|
||||||
|
|
||||||
oidcClientSecret := viper.GetString("oidc.client_secret")
|
oidcConfig, err := GetOIDCConfig()
|
||||||
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
|
if err != nil {
|
||||||
if oidcClientSecretPath != "" && oidcClientSecret != "" {
|
return nil, err
|
||||||
return nil, errOidcMutuallyExclusive
|
|
||||||
}
|
|
||||||
if oidcClientSecretPath != "" {
|
|
||||||
secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
oidcClientSecret = strings.TrimSpace(string(secretBytes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serverURL := viper.GetString("server_url")
|
serverURL := viper.GetString("server_url")
|
||||||
|
@ -835,40 +928,9 @@ func GetHeadscaleConfig() (*Config, error) {
|
||||||
|
|
||||||
UnixSocket: viper.GetString("unix_socket"),
|
UnixSocket: viper.GetString("unix_socket"),
|
||||||
UnixSocketPermission: util.GetFileMode("unix_socket_permission"),
|
UnixSocketPermission: util.GetFileMode("unix_socket_permission"),
|
||||||
|
OIDC: oidcConfig,
|
||||||
OIDC: OIDCConfig{
|
LogTail: logConfig,
|
||||||
OnlyStartIfOIDCIsAvailable: viper.GetBool(
|
RandomizeClientPort: randomizeClientPort,
|
||||||
"oidc.only_start_if_oidc_is_available",
|
|
||||||
),
|
|
||||||
Issuer: viper.GetString("oidc.issuer"),
|
|
||||||
ClientID: viper.GetString("oidc.client_id"),
|
|
||||||
ClientSecret: oidcClientSecret,
|
|
||||||
Scope: viper.GetStringSlice("oidc.scope"),
|
|
||||||
ExtraParams: viper.GetStringMapString("oidc.extra_params"),
|
|
||||||
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
|
|
||||||
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
|
|
||||||
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
|
|
||||||
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
|
|
||||||
Expiry: func() time.Duration {
|
|
||||||
// if set to 0, we assume no expiry
|
|
||||||
if value := viper.GetString("oidc.expiry"); value == "0" {
|
|
||||||
return maxDuration
|
|
||||||
} else {
|
|
||||||
expiry, err := model.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Msg("failed to parse oidc.expiry, defaulting back to 180 days")
|
|
||||||
|
|
||||||
return defaultOIDCExpiryTime
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Duration(expiry)
|
|
||||||
}
|
|
||||||
}(),
|
|
||||||
UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"),
|
|
||||||
},
|
|
||||||
|
|
||||||
LogTail: logTailConfig,
|
|
||||||
RandomizeClientPort: randomizeClientPort,
|
|
||||||
|
|
||||||
Policy: GetPolicyConfig(),
|
Policy: GetPolicyConfig(),
|
||||||
|
|
||||||
|
|
|
@ -62,7 +62,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
|
||||||
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
|
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
|
||||||
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
||||||
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
||||||
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.StripEmaildomain),
|
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.Misc.StripEmaildomain),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = scenario.CreateHeadscaleEnv(
|
err = scenario.CreateHeadscaleEnv(
|
||||||
|
@ -121,7 +121,7 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
|
||||||
"HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer,
|
"HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer,
|
||||||
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
|
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
|
||||||
"HEADSCALE_OIDC_CLIENT_SECRET": oidcConfig.ClientSecret,
|
"HEADSCALE_OIDC_CLIENT_SECRET": oidcConfig.ClientSecret,
|
||||||
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.StripEmaildomain),
|
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.Misc.StripEmaildomain),
|
||||||
"HEADSCALE_OIDC_USE_EXPIRY_FROM_TOKEN": "1",
|
"HEADSCALE_OIDC_USE_EXPIRY_FROM_TOKEN": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -269,6 +269,9 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*types.OIDCConf
|
||||||
|
|
||||||
log.Printf("headscale mock oidc is ready for tests at %s", hostEndpoint)
|
log.Printf("headscale mock oidc is ready for tests at %s", hostEndpoint)
|
||||||
|
|
||||||
|
oidcMisc := types.OIDCMiscConfig{
|
||||||
|
StripEmaildomain: true,
|
||||||
|
}
|
||||||
return &types.OIDCConfig{
|
return &types.OIDCConfig{
|
||||||
Issuer: fmt.Sprintf(
|
Issuer: fmt.Sprintf(
|
||||||
"http://%s/oidc",
|
"http://%s/oidc",
|
||||||
|
@ -276,7 +279,7 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*types.OIDCConf
|
||||||
),
|
),
|
||||||
ClientID: "superclient",
|
ClientID: "superclient",
|
||||||
ClientSecret: "supersecret",
|
ClientSecret: "supersecret",
|
||||||
StripEmaildomain: true,
|
Misc: oidcMisc,
|
||||||
OnlyStartIfOIDCIsAvailable: true,
|
OnlyStartIfOIDCIsAvailable: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue