Compare commits
4 commits
2af71c9e31
...
1592c8edc9
Author | SHA1 | Date | |
---|---|---|---|
1592c8edc9 | |||
5fc56adf11 | |||
|
9d58489903 | ||
|
205a008013 |
10 changed files with 734 additions and 36 deletions
65
.github/workflows/test-integration-v2-TestOIDCEmailGrant.yaml
vendored
Normal file
65
.github/workflows/test-integration-v2-TestOIDCEmailGrant.yaml
vendored
Normal file
|
@ -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
.github/workflows/test-integration-v2-TestOIDCUsernameGrant.yaml
vendored
Normal file
65
.github/workflows/test-integration-v2-TestOIDCUsernameGrant.yaml
vendored
Normal file
|
@ -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"
|
|
@ -29,12 +29,14 @@ API: Machine is now Node [#1553](https://github.com/juanfont/headscale/pull/1553
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
Make the OIDC callback page better [#1484](https://github.com/juanfont/headscale/pull/1484)
|
Make the OIDC callback page better [#1484](https://github.com/juanfont/headscale/pull/1484)
|
||||||
|
Allow use of the username OIDC claim [#1287](https://github.com/juanfont/headscale/pull/1287)
|
||||||
SSH support [#1487](https://github.com/juanfont/headscale/pull/1487)
|
SSH support [#1487](https://github.com/juanfont/headscale/pull/1487)
|
||||||
State management has been improved [#1492](https://github.com/juanfont/headscale/pull/1492)
|
State management has been improved [#1492](https://github.com/juanfont/headscale/pull/1492)
|
||||||
Use error group handling to ensure tests actually pass [#1535](https://github.com/juanfont/headscale/pull/1535) based on [#1460](https://github.com/juanfont/headscale/pull/1460)
|
Use error group handling to ensure tests actually pass [#1535](https://github.com/juanfont/headscale/pull/1535) based on [#1460](https://github.com/juanfont/headscale/pull/1460)
|
||||||
Fix hang on SIGTERM [#1492](https://github.com/juanfont/headscale/pull/1492) taken from [#1480](https://github.com/juanfont/headscale/pull/1480)
|
Fix hang on SIGTERM [#1492](https://github.com/juanfont/headscale/pull/1492) taken from [#1480](https://github.com/juanfont/headscale/pull/1480)
|
||||||
Send logs to stderr by default [#1524](https://github.com/juanfont/headscale/pull/1524)
|
Send logs to stderr by default [#1524](https://github.com/juanfont/headscale/pull/1524)
|
||||||
Fix [TS-2023-006](https://tailscale.com/security-bulletins/#ts-2023-006) security UPnP issue [#1563](https://github.com/juanfont/headscale/pull/1563)
|
Fix [TS-2023-006](https://tailscale.com/security-bulletins/#ts-2023-006) security UPnP issue [#1563](https://github.com/juanfont/headscale/pull/1563)
|
||||||
|
Add `oidc.groups_claim` and `oidc.email_claim` to allow setting those claim names [#1594](https://github.com/juanfont/headscale/pull/1594)
|
||||||
|
|
||||||
## 0.22.3 (2023-05-12)
|
## 0.22.3 (2023-05-12)
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oauth2-proxy/mockoidc"
|
"github.com/oauth2-proxy/mockoidc"
|
||||||
|
@ -64,6 +66,28 @@ func mockOIDC() error {
|
||||||
accessTTL = newTTL
|
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)
|
log.Info().Msgf("Access token TTL: %s", accessTTL)
|
||||||
|
|
||||||
port, err := strconv.Atoi(portStr)
|
port, err := strconv.Atoi(portStr)
|
||||||
|
@ -71,7 +95,7 @@ func mockOIDC() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
mock, err := getMockOIDC(clientID, clientSecret)
|
mock, err := getMockOIDC(clientID, clientSecret, users)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -93,7 +117,7 @@ func mockOIDC() error {
|
||||||
return nil
|
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)
|
keypair, err := mockoidc.NewKeypair(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -111,5 +135,9 @@ func getMockOIDC(clientID string, clientSecret string) (*mockoidc.MockOIDC, erro
|
||||||
ErrorQueue: &mockoidc.ErrorQueue{},
|
ErrorQueue: &mockoidc.ErrorQueue{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, v := range users {
|
||||||
|
mock.QueueUser(v)
|
||||||
|
}
|
||||||
|
|
||||||
return &mock, nil
|
return &mock, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -304,6 +304,16 @@ unix_socket_permission: "0770"
|
||||||
# allowed_users:
|
# allowed_users:
|
||||||
# - alice@example.com
|
# - 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.
|
# # 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`
|
# # 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
|
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
|
||||||
|
|
60
docs/oidc.md
60
docs/oidc.md
|
@ -24,6 +24,11 @@ oidc:
|
||||||
# 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"
|
||||||
|
# If provided, the name of a custom OIDC claim for specifying user groups.
|
||||||
|
# The claim value is expected to be a string or array of strings.
|
||||||
|
groups_claim: groups
|
||||||
|
# The OIDC claim to use as the email.
|
||||||
|
email_claim: email
|
||||||
|
|
||||||
# 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".
|
||||||
|
@ -43,6 +48,16 @@ oidc:
|
||||||
allowed_users:
|
allowed_users:
|
||||||
- alice@example.com
|
- 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.
|
# 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`
|
# 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
|
# If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
|
||||||
|
@ -170,3 +185,48 @@ oidc:
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -41,13 +42,46 @@ var (
|
||||||
"requested node state key expired before authorisation completed",
|
"requested node state key expired before authorisation completed",
|
||||||
)
|
)
|
||||||
errOIDCNodeKeyMissing = errors.New("could not get node key from cache")
|
errOIDCNodeKeyMissing = errors.New("could not get node key from cache")
|
||||||
|
errOIDCEmailClaimMissing = errors.New("email claim missing from ID Token")
|
||||||
|
errOIDCUsernameClaimMissing = errors.New("username claim missing from ID Token")
|
||||||
)
|
)
|
||||||
|
|
||||||
type IDTokenClaims struct {
|
type IDTokenClaims struct {
|
||||||
Name string `json:"name,omitempty"`
|
// in some cases the groups might be a single value and not a list
|
||||||
Groups []string `json:"groups,omitempty"`
|
Groups stringOrArray
|
||||||
Email string `json:"email"`
|
Email string
|
||||||
Username string `json:"preferred_username,omitempty"`
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
type stringOrArray []string
|
||||||
|
|
||||||
|
func (s *stringOrArray) UnmarshalJSON(b []byte) error {
|
||||||
|
var a []string
|
||||||
|
if err := json.Unmarshal(b, &a); err == nil {
|
||||||
|
*s = a
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(b, &str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = []string{str}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawClaims map[string]json.RawMessage
|
||||||
|
|
||||||
|
func (c rawClaims) unmarshalClaim(name string, v interface{}) error {
|
||||||
|
val, ok := c[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("claim not present")
|
||||||
|
}
|
||||||
|
return json.Unmarshal([]byte(val), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c rawClaims) hasClaim(name string) bool {
|
||||||
|
_, ok := c[name]
|
||||||
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Headscale) initOIDC() error {
|
func (h *Headscale) initOIDC() error {
|
||||||
|
@ -215,7 +249,7 @@ func (h *Headscale) OIDCCallback(
|
||||||
// return
|
// return
|
||||||
// }
|
// }
|
||||||
|
|
||||||
claims, err := extractIDTokenClaims(writer, idToken)
|
claims, err := extractIDTokenClaims(writer, h.cfg.OIDC, idToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -242,7 +276,12 @@ func (h *Headscale) OIDCCallback(
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -259,7 +298,7 @@ func (h *Headscale) OIDCCallback(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := renderOIDCCallbackTemplate(writer, claims)
|
content, err := renderOIDCCallbackTemplate(writer, userName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -355,25 +394,63 @@ func (h *Headscale) verifyIDTokenForOIDCCallback(
|
||||||
|
|
||||||
func extractIDTokenClaims(
|
func extractIDTokenClaims(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
|
cfg types.OIDCConfig,
|
||||||
idToken *oidc.IDToken,
|
idToken *oidc.IDToken,
|
||||||
) (*IDTokenClaims, error) {
|
) (*IDTokenClaims, error) {
|
||||||
var claims IDTokenClaims
|
var claims IDTokenClaims
|
||||||
if err := idToken.Claims(&claims); err != nil {
|
var rawClaims rawClaims
|
||||||
util.LogErr(err, "Failed to decode id token claims")
|
if err := idToken.Claims(&rawClaims); err != nil {
|
||||||
|
handleClaimError(writer, err)
|
||||||
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !rawClaims.hasClaim(cfg.EmailClaim) {
|
||||||
|
handleClaimError(writer, errOIDCEmailClaimMissing)
|
||||||
|
|
||||||
|
return nil, errOIDCEmailClaimMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rawClaims.unmarshalClaim(cfg.EmailClaim, &claims.Email); err != nil {
|
||||||
|
handleClaimError(writer, err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rawClaims.hasClaim(cfg.UsernameClaim) {
|
||||||
|
handleClaimError(writer, errOIDCUsernameClaimMissing)
|
||||||
|
|
||||||
|
return nil, errOIDCUsernameClaimMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rawClaims.unmarshalClaim(cfg.UsernameClaim, &claims.Username); err != nil {
|
||||||
|
handleClaimError(writer, err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawClaims.hasClaim(cfg.GroupsClaim) {
|
||||||
|
if err := rawClaims.unmarshalClaim(cfg.GroupsClaim, &claims.Groups); err != nil {
|
||||||
|
handleClaimError(writer, err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &claims, nil
|
return &claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleClaimError(writer http.ResponseWriter, err error) {
|
||||||
|
util.LogErr(err, "Failed to decode id token rawClaims")
|
||||||
|
|
||||||
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
writer.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, werr := writer.Write([]byte("Failed to decode id token rawClaims"))
|
||||||
|
if werr != nil {
|
||||||
|
util.LogErr(err, "Failed to write response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// validateOIDCAllowedDomains checks that if AllowedDomains is provided,
|
// validateOIDCAllowedDomains checks that if AllowedDomains is provided,
|
||||||
// that the authenticated principal ends with @<alloweddomain>.
|
// that the authenticated principal ends with @<alloweddomain>.
|
||||||
func validateOIDCAllowedDomains(
|
func validateOIDCAllowedDomains(
|
||||||
|
@ -539,9 +616,19 @@ func (h *Headscale) validateNodeForOIDCCallback(
|
||||||
Str("expiresAt", fmt.Sprintf("%v", expiry)).
|
Str("expiresAt", fmt.Sprintf("%v", expiry)).
|
||||||
Msg("successfully refreshed node")
|
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
|
var content bytes.Buffer
|
||||||
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
|
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
|
||||||
User: claims.Email,
|
User: userName,
|
||||||
Verb: "Reauthenticated",
|
Verb: "Reauthenticated",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
|
@ -576,18 +663,30 @@ func (h *Headscale) validateNodeForOIDCCallback(
|
||||||
func getUserName(
|
func getUserName(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
claims *IDTokenClaims,
|
claims *IDTokenClaims,
|
||||||
|
useUsernameClaim bool,
|
||||||
stripEmaildomain bool,
|
stripEmaildomain bool,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
var claim string
|
||||||
|
if useUsernameClaim {
|
||||||
|
claim = claims.Username
|
||||||
|
} else {
|
||||||
|
claim = claims.Email
|
||||||
|
}
|
||||||
userName, err := util.NormalizeToFQDNRules(
|
userName, err := util.NormalizeToFQDNRules(
|
||||||
claims.Email,
|
claim,
|
||||||
stripEmaildomain,
|
stripEmaildomain,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
_, werr := writer.Write([]byte("couldn't normalize email"))
|
_, werr := writer.Write([]byte(friendlyErrMsg))
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
util.LogErr(err, "Failed to write response")
|
util.LogErr(err, "Failed to write response")
|
||||||
}
|
}
|
||||||
|
@ -668,11 +767,11 @@ func (h *Headscale) registerNodeForOIDCCallback(
|
||||||
|
|
||||||
func renderOIDCCallbackTemplate(
|
func renderOIDCCallbackTemplate(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
claims *IDTokenClaims,
|
user string,
|
||||||
) (*bytes.Buffer, error) {
|
) (*bytes.Buffer, error) {
|
||||||
var content bytes.Buffer
|
var content bytes.Buffer
|
||||||
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
|
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
|
||||||
User: claims.Email,
|
User: user,
|
||||||
Verb: "Authenticated",
|
Verb: "Authenticated",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
|
|
164
hscontrol/oidc_test.go
Normal file
164
hscontrol/oidc_test.go
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
package hscontrol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/go-jose/go-jose/v3"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_extractIDTokenClaims(t *testing.T) {
|
||||||
|
tests := []verificationTest{
|
||||||
|
{
|
||||||
|
name: "default claim names",
|
||||||
|
idToken: `{"iss":"https://foo", "email": "foo@bar.baz", "groups": ["group1", "group2"]}`,
|
||||||
|
cfg: types.OIDCConfig{
|
||||||
|
EmailClaim: "email",
|
||||||
|
GroupsClaim: "groups",
|
||||||
|
},
|
||||||
|
want: &IDTokenClaims{
|
||||||
|
Groups: []string{"group1", "group2"},
|
||||||
|
Email: "foo@bar.baz",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom claim names",
|
||||||
|
idToken: `{"iss":"https://foo", "my_custom_claim": "foo@bar.baz", "https://foo.baz/groups": ["group3", "group4"]}`,
|
||||||
|
cfg: types.OIDCConfig{
|
||||||
|
EmailClaim: "my_custom_claim",
|
||||||
|
GroupsClaim: "https://foo.baz/groups",
|
||||||
|
},
|
||||||
|
want: &IDTokenClaims{
|
||||||
|
Groups: []string{"group3", "group4"},
|
||||||
|
Email: "foo@bar.baz",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "group claim not present",
|
||||||
|
idToken: `{"iss":"https://foo", "my_custom_claim": "foo@bar.baz"}`,
|
||||||
|
cfg: types.OIDCConfig{
|
||||||
|
EmailClaim: "my_custom_claim",
|
||||||
|
GroupsClaim: "https://foo.baz/groups",
|
||||||
|
},
|
||||||
|
want: &IDTokenClaims{
|
||||||
|
Email: "foo@bar.baz",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email claim not present",
|
||||||
|
idToken: `{"iss":"https://foo", "groups": ["group1", "group2"]}`,
|
||||||
|
cfg: types.OIDCConfig{
|
||||||
|
EmailClaim: "email",
|
||||||
|
GroupsClaim: "groups",
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
token, err := tt.getToken(t)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not parse the token: %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr {
|
||||||
|
assert.Equal(t, 200, recorder.Result().StatusCode)
|
||||||
|
assert.Empty(t, recorder.Result().Header)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := extractIDTokenClaims(recorder, tt.cfg, token)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("extractIDTokenClaims() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("extractIDTokenClaims() got = %v, want %v", got, tt.want)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type signingKey struct {
|
||||||
|
keyID string
|
||||||
|
key interface{}
|
||||||
|
pub interface{}
|
||||||
|
alg jose.SignatureAlgorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign creates a JWS using the private key from the provided payload.
|
||||||
|
func (s *signingKey) sign(t testing.TB, payload []byte) string {
|
||||||
|
privKey := &jose.JSONWebKey{Key: s.key, Algorithm: string(s.alg), KeyID: s.keyID}
|
||||||
|
|
||||||
|
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: s.alg, Key: privKey}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jws, err := signer.Sign(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := jws.CompactSerialize()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
type verificationTest struct {
|
||||||
|
name string
|
||||||
|
idToken string
|
||||||
|
cfg types.OIDCConfig
|
||||||
|
want *IDTokenClaims
|
||||||
|
wantErr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRSAKey(t testing.TB) *signingKey {
|
||||||
|
priv, err := rsa.GenerateKey(rand.Reader, 1028)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &signingKey{"", priv, priv.Public(), jose.RS256}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v verificationTest) getToken(t *testing.T) (*oidc.IDToken, error) {
|
||||||
|
key := newRSAKey(t)
|
||||||
|
token := key.sign(t, []byte(v.idToken))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
verifier := oidc.NewVerifier(
|
||||||
|
"https://foo",
|
||||||
|
&oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{key.pub}},
|
||||||
|
&oidc.Config{
|
||||||
|
SkipClientIDCheck: true,
|
||||||
|
SkipExpiryCheck: true,
|
||||||
|
SkipIssuerCheck: true,
|
||||||
|
InsecureSkipSignatureCheck: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return verifier.Verify(ctx, token)
|
||||||
|
}
|
|
@ -102,7 +102,11 @@ type OIDCConfig struct {
|
||||||
AllowedDomains []string
|
AllowedDomains []string
|
||||||
AllowedUsers []string
|
AllowedUsers []string
|
||||||
AllowedGroups []string
|
AllowedGroups []string
|
||||||
|
GroupsClaim string
|
||||||
|
EmailClaim string
|
||||||
|
UsernameClaim string
|
||||||
StripEmaildomain bool
|
StripEmaildomain bool
|
||||||
|
UseUsernameClaim bool
|
||||||
Expiry time.Duration
|
Expiry time.Duration
|
||||||
UseExpiryFromToken bool
|
UseExpiryFromToken bool
|
||||||
}
|
}
|
||||||
|
@ -183,8 +187,12 @@ func LoadConfig(path string, isFile bool) error {
|
||||||
|
|
||||||
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.strip_email_domain", true)
|
||||||
|
viper.SetDefault("oidc.use_username_claim", false)
|
||||||
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")
|
viper.SetDefault("oidc.expiry", "180d")
|
||||||
|
viper.SetDefault("oidc.groups_claim", "groups")
|
||||||
|
viper.SetDefault("oidc.email_claim", "email")
|
||||||
|
viper.SetDefault("oidc.username_claim", "preferred_username")
|
||||||
viper.SetDefault("oidc.use_expiry_from_token", false)
|
viper.SetDefault("oidc.use_expiry_from_token", false)
|
||||||
|
|
||||||
viper.SetDefault("logtail.enabled", false)
|
viper.SetDefault("logtail.enabled", false)
|
||||||
|
@ -631,6 +639,10 @@ func GetHeadscaleConfig() (*Config, error) {
|
||||||
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
|
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
|
||||||
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
|
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
|
||||||
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
|
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
|
||||||
|
UseUsernameClaim: viper.GetBool("oidc.use_username_claim"),
|
||||||
|
GroupsClaim: viper.GetString("oidc.groups_claim"),
|
||||||
|
EmailClaim: viper.GetString("oidc.email_claim"),
|
||||||
|
UsernameClaim: viper.GetString("oidc.username_claim"),
|
||||||
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
|
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
|
||||||
Expiry: func() time.Duration {
|
Expiry: func() time.Duration {
|
||||||
// if set to 0, we assume no expiry
|
// if set to 0, we assume no expiry
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -38,6 +39,181 @@ type AuthOIDCScenario struct {
|
||||||
mockOIDC *dockertest.Resource
|
mockOIDC *dockertest.Resource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOIDCUsernameGrant(t *testing.T) {
|
||||||
|
IntegrationSkip(t)
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
baseScenario, err := NewScenario()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to create scenario: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := AuthOIDCScenario{
|
||||||
|
Scenario: baseScenario,
|
||||||
|
}
|
||||||
|
defer scenario.Shutdown()
|
||||||
|
|
||||||
|
spec := map[string]int{
|
||||||
|
"user1": len(MustTestVersions),
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]string, len(MustTestVersions))
|
||||||
|
for i := range users {
|
||||||
|
users[i] = "test-user <test-email@example.com>"
|
||||||
|
}
|
||||||
|
userStr := strings.Join(users, ", ")
|
||||||
|
|
||||||
|
oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, userStr)
|
||||||
|
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
|
||||||
|
|
||||||
|
oidcMap := map[string]string{
|
||||||
|
"HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer,
|
||||||
|
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
|
||||||
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
||||||
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
||||||
|
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": "false",
|
||||||
|
"HEADSCALE_OIDC_USE_USERNAME_CLAIM": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = scenario.CreateHeadscaleEnv(
|
||||||
|
spec,
|
||||||
|
hsic.WithTestName("oidcauthping"),
|
||||||
|
hsic.WithConfigEnv(oidcMap),
|
||||||
|
hsic.WithHostnameAsServerURL(),
|
||||||
|
hsic.WithFileInContainer(
|
||||||
|
"/tmp/hs_client_oidc_secret",
|
||||||
|
[]byte(oidcConfig.ClientSecret),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to create headscale environment: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allClients, err := scenario.ListTailscaleClients()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to get clients: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that clients are registered under the right username
|
||||||
|
for _, client := range allClients {
|
||||||
|
fqdn, err := client.FQDN()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unable to get client FQDN: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(fqdn, "test-user.headscale.net") {
|
||||||
|
t.Errorf(
|
||||||
|
"Client registered with unexpected username. Client FQDN: %s",
|
||||||
|
fqdn,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allIps, err := scenario.ListTailscaleClientsIPs()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to get clients: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = scenario.WaitForTailscaleSync()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed wait for tailscale clients to be in sync: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
|
||||||
|
return x.String()
|
||||||
|
})
|
||||||
|
|
||||||
|
success := pingAllHelper(t, allClients, allAddrs)
|
||||||
|
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCEmailGrant(t *testing.T) {
|
||||||
|
IntegrationSkip(t)
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
baseScenario, err := NewScenario()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to create scenario: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := AuthOIDCScenario{
|
||||||
|
Scenario: baseScenario,
|
||||||
|
}
|
||||||
|
defer scenario.Shutdown()
|
||||||
|
|
||||||
|
spec := map[string]int{
|
||||||
|
"user1": len(MustTestVersions),
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]string, len(MustTestVersions))
|
||||||
|
for i := range users {
|
||||||
|
users[i] = "test-user <test-email@example.com>"
|
||||||
|
}
|
||||||
|
userStr := strings.Join(users, ", ")
|
||||||
|
|
||||||
|
oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, userStr)
|
||||||
|
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
|
||||||
|
|
||||||
|
oidcMap := map[string]string{
|
||||||
|
"HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer,
|
||||||
|
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
|
||||||
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
||||||
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
||||||
|
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": "true",
|
||||||
|
"HEADSCALE_OIDC_USE_USERNAME_CLAIM": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = scenario.CreateHeadscaleEnv(
|
||||||
|
spec,
|
||||||
|
hsic.WithTestName("oidcauthping"),
|
||||||
|
hsic.WithConfigEnv(oidcMap),
|
||||||
|
hsic.WithHostnameAsServerURL(),
|
||||||
|
hsic.WithFileInContainer(
|
||||||
|
"/tmp/hs_client_oidc_secret",
|
||||||
|
[]byte(oidcConfig.ClientSecret),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to create headscale environment: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allClients, err := scenario.ListTailscaleClients()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to get clients: %s", err)
|
||||||
|
}
|
||||||
|
// Check that clients are registered under the right username
|
||||||
|
for _, client := range allClients {
|
||||||
|
fqdn, err := client.FQDN()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unable to get client FQDN: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(fqdn, "test-email.headscale.net") {
|
||||||
|
t.Errorf(
|
||||||
|
"Client registered with unexpected username. Client FQDN: %s",
|
||||||
|
fqdn,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allIps, err := scenario.ListTailscaleClientsIPs()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to get clients: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = scenario.WaitForTailscaleSync()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed wait for tailscale clients to be in sync: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
|
||||||
|
return x.String()
|
||||||
|
})
|
||||||
|
|
||||||
|
success := pingAllHelper(t, allClients, allAddrs)
|
||||||
|
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
|
||||||
|
}
|
||||||
|
|
||||||
func TestOIDCAuthenticationPingAll(t *testing.T) {
|
func TestOIDCAuthenticationPingAll(t *testing.T) {
|
||||||
IntegrationSkip(t)
|
IntegrationSkip(t)
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
@ -54,7 +230,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
|
||||||
"user1": len(MustTestVersions),
|
"user1": len(MustTestVersions),
|
||||||
}
|
}
|
||||||
|
|
||||||
oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL)
|
oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, "")
|
||||||
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
|
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
|
||||||
|
|
||||||
oidcMap := map[string]string{
|
oidcMap := map[string]string{
|
||||||
|
@ -62,7 +238,10 @@ 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.StripEmaildomain,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = scenario.CreateHeadscaleEnv(
|
err = scenario.CreateHeadscaleEnv(
|
||||||
|
@ -70,7 +249,10 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
|
||||||
hsic.WithTestName("oidcauthping"),
|
hsic.WithTestName("oidcauthping"),
|
||||||
hsic.WithConfigEnv(oidcMap),
|
hsic.WithConfigEnv(oidcMap),
|
||||||
hsic.WithHostnameAsServerURL(),
|
hsic.WithHostnameAsServerURL(),
|
||||||
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)),
|
hsic.WithFileInContainer(
|
||||||
|
"/tmp/hs_client_oidc_secret",
|
||||||
|
[]byte(oidcConfig.ClientSecret),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assertNoErrHeadscaleEnv(t, err)
|
assertNoErrHeadscaleEnv(t, err)
|
||||||
|
|
||||||
|
@ -112,14 +294,17 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
|
||||||
"user1": 3,
|
"user1": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
oidcConfig, err := scenario.runMockOIDC(shortAccessTTL)
|
oidcConfig, err := scenario.runMockOIDC(shortAccessTTL, "")
|
||||||
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
|
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
|
||||||
|
|
||||||
oidcMap := map[string]string{
|
oidcMap := map[string]string{
|
||||||
"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.StripEmaildomain,
|
||||||
|
),
|
||||||
"HEADSCALE_OIDC_USE_EXPIRY_FROM_TOKEN": "1",
|
"HEADSCALE_OIDC_USE_EXPIRY_FROM_TOKEN": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -145,7 +330,11 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
success := pingAllHelper(t, allClients, allAddrs)
|
success := pingAllHelper(t, allClients, allAddrs)
|
||||||
t.Logf("%d successful pings out of %d (before expiry)", success, len(allClients)*len(allIps))
|
t.Logf(
|
||||||
|
"%d successful pings out of %d (before expiry)",
|
||||||
|
success,
|
||||||
|
len(allClients)*len(allIps),
|
||||||
|
)
|
||||||
|
|
||||||
// This is not great, but this sadly is a time dependent test, so the
|
// This is not great, but this sadly is a time dependent test, so the
|
||||||
// safe thing to do is wait out the whole TTL time before checking if
|
// safe thing to do is wait out the whole TTL time before checking if
|
||||||
|
@ -191,7 +380,10 @@ func (s *AuthOIDCScenario) CreateHeadscaleEnv(
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*types.OIDCConfig, error) {
|
func (s *AuthOIDCScenario) runMockOIDC(
|
||||||
|
accessTTL time.Duration,
|
||||||
|
users string,
|
||||||
|
) (*types.OIDCConfig, error) {
|
||||||
port, err := dockertestutil.RandomFreeHostPort()
|
port, err := dockertestutil.RandomFreeHostPort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("could not find an open port: %s", err)
|
log.Fatalf("could not find an open port: %s", err)
|
||||||
|
@ -215,6 +407,7 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*types.OIDCConf
|
||||||
fmt.Sprintf("MOCKOIDC_PORT=%d", port),
|
fmt.Sprintf("MOCKOIDC_PORT=%d", port),
|
||||||
"MOCKOIDC_CLIENT_ID=superclient",
|
"MOCKOIDC_CLIENT_ID=superclient",
|
||||||
"MOCKOIDC_CLIENT_SECRET=supersecret",
|
"MOCKOIDC_CLIENT_SECRET=supersecret",
|
||||||
|
fmt.Sprintf("MOCKOIDC_USERS=%s", users),
|
||||||
fmt.Sprintf("MOCKOIDC_ACCESS_TTL=%s", accessTTL.String()),
|
fmt.Sprintf("MOCKOIDC_ACCESS_TTL=%s", accessTTL.String()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue