Skip to content

Commit

Permalink
Allow use of the preferred_username OIDC claim
Browse files Browse the repository at this point in the history
Previously, Headscale would only use the `email` OIDC
claim to set the Headscale user. In certain cases
(self-hosted SSO), it may be useful to instead use the
`preferred_username` to set the Headscale username.
This also closes juanfont#938.

This adds a config setting to use this claim instead.
The OIDC docs have been updated to include this entry as well.
In addition, this adds an Authelia OIDC example to the docs.

Added OIDC claim integration tests.

Updated the MockOIDC wrapper to take an environment variable that
lets you set the username/email claims to return.

Added two integration tests, TestOIDCEmailGrant and
TestOIDCUsernameGrant, which check the username by checking the FQDN of
clients.

Updated the HTML template shown after OIDC login to show whatever
username is used, based on the Headscale settings.
  • Loading branch information
meson800 committed Oct 29, 2023
1 parent fb4ed95 commit dce2d4e
Show file tree
Hide file tree
Showing 8 changed files with 467 additions and 21 deletions.
65 changes: 65 additions & 0 deletions .github/workflows/test-integration-v2-TestOIDCEmailGrant.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/

name: Integration Test v2 - TestOIDCEmailGrant

on: [pull_request]

concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
TestOIDCEmailGrant:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
with:
fetch-depth: 2

- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: satackey/action-docker-layer-caching@main
continue-on-error: true

- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v34
with:
files: |
*.nix
go.*
**/*.go
integration_test/
config-example.yaml
- name: Run TestOIDCEmailGrant
if: steps.changed-files.outputs.any_changed == 'true'
run: |
nix develop --command -- docker run \
--tty --rm \
--volume ~/.cache/hs-integration-go:/go \
--name headscale-test-suite \
--volume $PWD:$PWD -w $PWD/integration \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume $PWD/control_logs:/tmp/control \
golang:1 \
go run gotest.tools/gotestsum@latest -- ./... \
-tags ts2019 \
-failfast \
-timeout 120m \
-parallel 1 \
-run "^TestOIDCEmailGrant$"
- uses: actions/upload-artifact@v3
if: always() && steps.changed-files.outputs.any_changed == 'true'
with:
name: logs
path: "control_logs/*.log"

- uses: actions/upload-artifact@v3
if: always() && steps.changed-files.outputs.any_changed == 'true'
with:
name: pprof
path: "control_logs/*.pprof.tar"
65 changes: 65 additions & 0 deletions .github/workflows/test-integration-v2-TestOIDCUsernameGrant.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/

name: Integration Test v2 - TestOIDCUsernameGrant

on: [pull_request]

concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
TestOIDCUsernameGrant:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
with:
fetch-depth: 2

- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: satackey/action-docker-layer-caching@main
continue-on-error: true

- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v34
with:
files: |
*.nix
go.*
**/*.go
integration_test/
config-example.yaml
- name: Run TestOIDCUsernameGrant
if: steps.changed-files.outputs.any_changed == 'true'
run: |
nix develop --command -- docker run \
--tty --rm \
--volume ~/.cache/hs-integration-go:/go \
--name headscale-test-suite \
--volume $PWD:$PWD -w $PWD/integration \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume $PWD/control_logs:/tmp/control \
golang:1 \
go run gotest.tools/gotestsum@latest -- ./... \
-tags ts2019 \
-failfast \
-timeout 120m \
-parallel 1 \
-run "^TestOIDCUsernameGrant$"
- uses: actions/upload-artifact@v3
if: always() && steps.changed-files.outputs.any_changed == 'true'
with:
name: logs
path: "control_logs/*.log"

- uses: actions/upload-artifact@v3
if: always() && steps.changed-files.outputs.any_changed == 'true'
with:
name: pprof
path: "control_logs/*.pprof.tar"
32 changes: 30 additions & 2 deletions cmd/headscale/cli/mockoidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"fmt"
"net"
"os"
"regexp"
"strconv"
"strings"
"time"

"github.com/oauth2-proxy/mockoidc"
Expand Down Expand Up @@ -64,14 +66,36 @@ func mockOIDC() error {
accessTTL = newTTL
}

mockUsers := os.Getenv("MOCKOIDC_USERS")
users := []mockoidc.User{}
if mockUsers != "" {
userStrings := strings.Split(mockUsers, ",")
userRe := regexp.MustCompile(`^\s*(?P<username>\S+)\s*<(?P<email>\S+@\S+)>\s*$`)
for _, v := range userStrings {
match := userRe.FindStringSubmatch(v)
if match != nil {
// Use the default mockoidc claims for other entries
users = append(users, &mockoidc.MockUser{
Subject: "1234567890",
Email: match[2],
PreferredUsername: match[1],
Phone: "555-987-6543",
Address: "123 Main Street",
Groups: []string{"engineering", "design"},
EmailVerified: true,
})
}
}
}

log.Info().Msgf("Access token TTL: %s", accessTTL)

port, err := strconv.Atoi(portStr)
if err != nil {
return err
}

mock, err := getMockOIDC(clientID, clientSecret)
mock, err := getMockOIDC(clientID, clientSecret, users)
if err != nil {
return err
}
Expand All @@ -93,7 +117,7 @@ func mockOIDC() error {
return nil
}

func getMockOIDC(clientID string, clientSecret string) (*mockoidc.MockOIDC, error) {
func getMockOIDC(clientID string, clientSecret string, users []mockoidc.User) (*mockoidc.MockOIDC, error) {
keypair, err := mockoidc.NewKeypair(nil)
if err != nil {
return nil, err
Expand All @@ -111,5 +135,9 @@ func getMockOIDC(clientID string, clientSecret string) (*mockoidc.MockOIDC, erro
ErrorQueue: &mockoidc.ErrorQueue{},
}

for _, v := range users {
mock.QueueUser(v)
}

return &mock, nil
}
10 changes: 10 additions & 0 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,16 @@ unix_socket_permission: "0770"
# allowed_users:
# - [email protected]
#
# # By default, Headscale will use the OIDC email address claim to determine the username.
# # OIDC also returns a `preferred_username` claim.
# #
# # If `use_username_claim` is set to `true`, then the `preferred_username` claim will
# # be used instead to set the Headscale username.
# # If `use_username_claim` is set to `false`, then the `email` claim will be used
# # to derive the Headscale username (as modified by the `strip_email_domain` entry).
#
# use_username_claim: false
#
# # 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
Expand Down
55 changes: 55 additions & 0 deletions docs/oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ oidc:
allowed_users:
- [email protected]

# By default, Headscale will use the OIDC email address claim to determine the username.
# OIDC also returns a `preferred_username` claim.
#
# If `use_username_claim` is set to `true`, then the `preferred_username` claim will
# be used instead to set the Headscale username.
# If `use_username_claim` is set to `false`, then the `email` claim will be used
# to derive the Headscale username (as modified by the `strip_email_domain` entry).

use_username_claim: false

# 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
Expand Down Expand Up @@ -170,3 +180,48 @@ oidc:
```

You can also use `allowed_domains` and `allowed_users` to restrict the users who can authenticate.

## Authelia Example

In order to integrate Headscale with your Authelia instance, you need to generate a client secret add your Headscale instance as a client.

First, generate a client secret. If you are running Authelia inside docker, prepend `docker-compose exec <authelia_container_name>` before these commands:

```shell
authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
```

This will return two strings, a "Random Password" which you will fill into Headscale, and a "Digest" you will fill into Authelia.

In your Authelia configuration, add Headscale under the client section:

```yaml
clients:
- id: headscale
description: Headscale
secret: "DIGEST_STRING_FROM_ABOVE"
public: false
authorization_policy: two_factor
redirect_uris:
- https://your.headscale.domain/oidc/callback
scopes:
- openid
- profile
- email
- groups
```

In your Headscale `config.yaml`, edit the config under `oidc`, filling in the `client_id` to match the `id` line in the Authelia config and filling in `client_secret` from the "Random Password" output.
You may want to tune the `expiry`, `only_start_if_oidc_available`, and other entries. The following are only the required entries.

```yaml
oidc:
issuer: "https://your.authelia.domain"
client_id: "headscale"
client_secret: "RANDOM_PASSWORD_STRING_FROM_ABOVE"
scope: ["openid", "profile", "email", "groups"]
allowed_groups:
- authelia_groups_you_want_to_limit
```

In particular, you may want to set `use_username_claim: true` to use Authelia's `preferred_username` grant to set Headscale usernames.
45 changes: 36 additions & 9 deletions hscontrol/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,12 @@ func (h *Headscale) OIDCCallback(
return
}

userName, err := getUserName(writer, claims, h.cfg.OIDC.StripEmaildomain)
userName, err := getUserName(
writer,
claims,
h.cfg.OIDC.UseUsernameClaim,
h.cfg.OIDC.StripEmaildomain,
)
if err != nil {
return
}
Expand All @@ -259,7 +264,7 @@ func (h *Headscale) OIDCCallback(
return
}

content, err := renderOIDCCallbackTemplate(writer, claims)
content, err := renderOIDCCallbackTemplate(writer, userName)
if err != nil {
return
}
Expand Down Expand Up @@ -539,9 +544,19 @@ func (h *Headscale) validateNodeForOIDCCallback(
Str("expiresAt", fmt.Sprintf("%v", expiry)).
Msg("successfully refreshed node")

userName, err := getUserName(
writer,
claims,
h.cfg.OIDC.UseUsernameClaim,
h.cfg.OIDC.StripEmaildomain,
)
if err != nil {
userName = "unknown"
}

var content bytes.Buffer
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
User: claims.Email,
User: userName,
Verb: "Reauthenticated",
}); err != nil {
log.Error().
Expand Down Expand Up @@ -576,18 +591,30 @@ func (h *Headscale) validateNodeForOIDCCallback(
func getUserName(
writer http.ResponseWriter,
claims *IDTokenClaims,
useUsernameClaim bool,
stripEmaildomain bool,
) (string, error) {
var claim string
if useUsernameClaim {
claim = claims.Username
} else {
claim = claims.Email
}
userName, err := util.NormalizeToFQDNRules(
claims.Email,
claim,
stripEmaildomain,
)
if err != nil {
util.LogErr(err, "couldn't normalize email")

var friendlyErrMsg string
if useUsernameClaim {
friendlyErrMsg = "couldn't normalize username (preferred_username OIDC claim)"
} else {
friendlyErrMsg = "couldn't normalize username (email OIDC claim)"
}
log.Error().Err(err).Caller().Msgf(friendlyErrMsg)
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, werr := writer.Write([]byte("couldn't normalize email"))
_, werr := writer.Write([]byte(friendlyErrMsg))
if werr != nil {
util.LogErr(err, "Failed to write response")
}
Expand Down Expand Up @@ -668,11 +695,11 @@ func (h *Headscale) registerNodeForOIDCCallback(

func renderOIDCCallbackTemplate(
writer http.ResponseWriter,
claims *IDTokenClaims,
user string,
) (*bytes.Buffer, error) {
var content bytes.Buffer
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
User: claims.Email,
User: user,
Verb: "Authenticated",
}); err != nil {
log.Error().
Expand Down
3 changes: 3 additions & 0 deletions hscontrol/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ type OIDCConfig struct {
AllowedUsers []string
AllowedGroups []string
StripEmaildomain bool
UseUsernameClaim bool
Expiry time.Duration
UseExpiryFromToken bool
}
Expand Down Expand Up @@ -183,6 +184,7 @@ func LoadConfig(path string, isFile bool) error {

viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
viper.SetDefault("oidc.strip_email_domain", true)
viper.SetDefault("oidc.use_username_claim", false)
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
viper.SetDefault("oidc.expiry", "180d")
viper.SetDefault("oidc.use_expiry_from_token", false)
Expand Down Expand Up @@ -631,6 +633,7 @@ func GetHeadscaleConfig() (*Config, error) {
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
UseUsernameClaim: viper.GetBool("oidc.use_username_claim"),
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
Expiry: func() time.Duration {
// if set to 0, we assume no expiry
Expand Down
Loading

0 comments on commit dce2d4e

Please sign in to comment.