Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow use of the preferred_username OIDC claim. #1287

Closed
wants to merge 5 commits into from
Closed
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
57 changes: 57 additions & 0 deletions .github/workflows/test-integration-v2-TestOIDCEmailGrant.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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:
test:
runs-on: ubuntu-latest

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

- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v34
with:
files: |
*.nix
go.*
**/*.go
integration_test/
config-example.yaml

- uses: cachix/install-nix-action@v18
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'

- name: Run general integration tests
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 test ./... \
-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"
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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:
test:
runs-on: ubuntu-latest

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

- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v34
with:
files: |
*.nix
go.*
**/*.go
integration_test/
config-example.yaml

- uses: cachix/install-nix-action@v18
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'

- name: Run general integration tests
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 test ./... \
-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"
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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great if this is prefixed with "HEADSCALE_INTEGRATION_"

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is (technically) not just useful for integration tests, as this environment variable controls what mockoidc does if you e.g. set the env variable and ran headscale mockoidc yourself. I followed the naming of the other variables in the mockOIDC function (MOCKOIDC_CLIENT_ID, MOCKOIDC_CLIENT_SECRET, and so on). I can still prefix this if desired.

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:
# - alice@example.com
#
# # 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 `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
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:
- alice@example.com

# 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 `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
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.
3 changes: 3 additions & 0 deletions hscontrol/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ type OIDCConfig struct {
AllowedUsers []string
AllowedGroups []string
StripEmaildomain bool
UseUsernameClaim bool
Expiry time.Duration
UseExpiryFromToken bool
}
Expand Down Expand Up @@ -188,6 +189,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 @@ -634,6 +636,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
34 changes: 26 additions & 8 deletions hscontrol/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ 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 @@ -256,7 +256,7 @@ func (h *Headscale) OIDCCallback(
return
}

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

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 @@ -625,17 +630,30 @@ func (h *Headscale) validateMachineForOIDCCallback(
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 := NormalizeToFQDNRules(
claims.Email,
claim,
stripEmaildomain,
)
if err != nil {
log.Error().Err(err).Caller().Msgf("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 {
log.Error().
Caller().
Expand Down Expand Up @@ -730,11 +748,11 @@ func (h *Headscale) registerMachineForOIDCCallback(

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
Loading