Compare commits

..

16 Commits

Author SHA1 Message Date
Florian Preinstorfer
bb34b4ebb8 Set doc version to 0.24.3 2025-03-21 06:49:15 +01:00
Florian Preinstorfer
f0c17ceeb6 Set doc version to 0.24.2 2025-02-02 16:54:17 +01:00
Florian Preinstorfer
8cfc804e07 Set doc version to 0.24.1 2025-02-02 16:51:42 +01:00
Florian Preinstorfer
453b000711 Set doc version to 0.24.0 2025-01-17 17:31:29 +01:00
Kristoffer Dalby
e88406e837 set changelog date (#2347) 2025-01-17 12:01:06 +01:00
Kristoffer Dalby
e4a3dcc3b8 use headscale server url as domain instead of base_domain (#2338) 2025-01-16 18:05:20 +01:00
Kristoffer Dalby
caad5c613d fix nil pointer deref (#2339) 2025-01-16 18:05:05 +01:00
Kristoffer Dalby
38aef77e54 allow @ and Log if OIDC username is not consider valid (#2340) 2025-01-16 18:04:54 +01:00
Dmitry Gordin
1ab7b315a2 Update apple.md for latest version of iOS (#2321)
The official iOS app now has a simpler login process for custom instances, directly within the app.
2025-01-13 12:09:53 +00:00
github-actions[bot]
610597bfb7 flake.lock: Update (#2342) 2025-01-12 18:54:59 +00:00
Stefan Majer
ede4f97a16 Fix typos 2025-01-09 10:38:25 +01:00
Kristoffer Dalby
fa641e38b8 Set CSRF cookies for OIDC (#2328)
* set state and nounce in oidc to prevent csrf

Fixes #2276

* try to fix new postgres issue

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-08 16:29:37 +01:00
github-actions[bot]
41bad2b9fd flake.lock: Update (#2324) 2025-01-05 07:35:18 +00:00
github-actions[bot]
f9bbfa5eab flake.lock: Update (#2320) 2024-12-29 11:41:52 +00:00
Rorical
b81420bef1 feat: Add PKCE Verifier for OIDC (#2314)
* feat: add PKCE verifier for OIDC

* Update CHANGELOG.md
2024-12-22 16:46:36 +00:00
github-actions[bot]
9313e5b058 flake.lock: Update (#2313) 2024-12-22 07:07:26 +00:00
25 changed files with 377 additions and 109 deletions

View File

@@ -25,6 +25,7 @@ jobs:
- TestOIDCAuthenticationPingAll
- TestOIDCExpireNodesBasedOnTokenExpiry
- TestOIDC024UserCreation
- TestOIDCAuthenticationWithPKCE
- TestAuthWebFlowAuthenticationPingAll
- TestAuthWebFlowLogoutAndRelogin
- TestUserCommand

View File

@@ -34,4 +34,10 @@ jobs:
- name: Run tests
if: steps.changed-files.outputs.files == 'true'
env:
# As of 2025-01-06, these env vars was not automatically
# set anymore which breaks the initdb for postgres on
# some of the database migration tests.
LC_ALL: "en_US.UTF-8"
LC_CTYPE: "en_US.UTF-8"
run: nix develop --command -- gotestsum

View File

@@ -2,7 +2,7 @@
## Next
## 0.24.0 (2024-xx-xx)
## 0.24.0 (2024-01-17)
### Security fix: OIDC changes in Headscale 0.24.0
@@ -153,7 +153,7 @@ This will also affect the way you
### Changes
- Improved compatibilty of built-in DERP server with clients connecting over
- Improved compatibility of built-in DERP server with clients connecting over
WebSocket [#2132](https://github.com/juanfont/headscale/pull/2132)
- Allow nodes to use SSH agent forwarding
[#2145](https://github.com/juanfont/headscale/pull/2145)
@@ -172,6 +172,7 @@ This will also affect the way you
[#2261](https://github.com/juanfont/headscale/pull/2261)
- Add `dns.extra_records_path` configuration option [#2262](https://github.com/juanfont/headscale/issues/2262)
- Support client verify for DERP [#2046](https://github.com/juanfont/headscale/pull/2046)
- Add PKCE Verifier for OIDC [#2314](https://github.com/juanfont/headscale/pull/2314)
## 0.23.0 (2024-09-18)
@@ -261,7 +262,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
- `prefixes.allocation` can be set to assign IPs at `sequential` or `random`.
[#1869](https://github.com/juanfont/headscale/pull/1869)
- MagicDNS domains no longer contain usernames []()
- This is in preperation to fix Headscales implementation of tags which
- This is in preparation to fix Headscales implementation of tags which
currently does not correctly remove the link between a tagged device and a
user. As tagged devices will not have a user, this will require a change to
the DNS generation, removing the username, see

View File

@@ -364,6 +364,18 @@ unix_socket_permission: "0770"
# allowed_users:
# - alice@example.com
#
# # Optional: PKCE (Proof Key for Code Exchange) configuration
# # PKCE adds an additional layer of security to the OAuth 2.0 authorization code flow
# # by preventing authorization code interception attacks
# # See https://datatracker.ietf.org/doc/html/rfc7636
# pkce:
# # Enable or disable PKCE support (default: false)
# enabled: false
# # PKCE method to use:
# # - plain: Use plain code verifier
# # - S256: Use SHA256 hashed code verifier (default, recommended)
# method: S256
#
# # Map legacy users from pre-0.24.0 versions of headscale to the new OIDC users
# # by taking the username from the legacy user and matching it with the username
# # provided by the OIDC. This is useful when migrating from legacy users to OIDC

View File

@@ -1,13 +1,13 @@
# DNS
Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS releated settings can be configured
Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS related settings can be configured
within `dns` section of the [configuration file](./configuration.md).
## Setting extra DNS records
Headscale allows to set extra DNS records which are made available via
[MagicDNS](https://tailscale.com/kb/1081/magicdns). Extra DNS records can be configured either via static entries in the
[configuration file](./configuration.md) or from a JSON file that Headscale continously watches for changes:
[configuration file](./configuration.md) or from a JSON file that Headscale continuously watches for changes:
* Use the `dns.extra_records` option in the [configuration file](./configuration.md) for entries that are static and
don't change while Headscale is running. Those entries are processed when Headscale is starting up and changes to the

View File

@@ -11,7 +11,7 @@ Headscale doesn't provide a built-in web interface but users may pick one from t
| --------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| headscale-webui | [Github](https://github.com/ifargle/headscale-webui) | A simple headscale web UI for small-scale deployments. |
| headscale-ui | [Github](https://github.com/gurucomputing/headscale-ui) | A web frontend for the headscale Tailscale-compatible coordination server |
| HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend enviroment required |
| HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend environment required |
| Headplane | [GitHub](https://github.com/tale/headplane) | An advanced Tailscale inspired frontend for headscale |
| headscale-admin | [Github](https://github.com/GoodiesHQ/headscale-admin) | Headscale-Admin is meant to be a simple, modern web interface for headscale |
| ouroboros | [Github](https://github.com/yellowsink/ouroboros) | Ouroboros is designed for users to manage their own devices, rather than for admins |

View File

@@ -45,6 +45,18 @@ oidc:
allowed_users:
- alice@example.com
# Optional: PKCE (Proof Key for Code Exchange) configuration
# PKCE adds an additional layer of security to the OAuth 2.0 authorization code flow
# by preventing authorization code interception attacks
# See https://datatracker.ietf.org/doc/html/rfc7636
pkce:
# Enable or disable PKCE support (default: false)
enabled: false
# PKCE method to use:
# - plain: Use plain code verifier
# - S256: Use SHA256 hashed code verifier (default, recommended)
method: S256
# 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

View File

@@ -16,7 +16,7 @@ README](https://github.com/juanfont/headscale#contributing) for more information
### Install from source
```shell
# Install prerequistes
# Install prerequisites
pkg_add go
git clone https://github.com/juanfont/headscale.git
@@ -42,7 +42,7 @@ cp headscale /usr/local/sbin
### Install from source via cross compile
```shell
# Install prerequistes
# Install prerequisites
# 1. go v1.20+: headscale newer than 0.21 needs go 1.20+ to compile
# 2. gmake: Makefile in the headscale repo is written in GNU make syntax

View File

@@ -15,14 +15,10 @@ Install the official Tailscale iOS client from the [App Store](https://apps.appl
### Configuring the headscale URL
- Open Tailscale and make sure you are _not_ logged in to any account
- Open Settings on the iOS device
- Scroll down to the `third party apps` section, under `Game Center` or `TV Provider`
- Find Tailscale and select it
- If the iOS device was previously logged into Tailscale, switch the `Reset Keychain` toggle to `on`
- Enter the URL of your headscale instance (e.g `https://headscale.example.com`) under `Alternate Coordination Server URL`
- Restart the app by closing it from the iOS app switcher, open the app and select the regular sign in option
_(non-SSO)_. It should open up to the headscale authentication page.
- Open the Tailscale app
- Click the account icon in the top-right corner and select `Log in…`.
- Tap the top-right options menu button and select `Use custom coordination server`.
- Enter your instance url (e.g `https://headscale.example.com`)
- Enter your credentials and log in. Headscale should now be working on your iOS device.
## macOS

6
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1734126203,
"narHash": "sha256-0XovF7BYP50rTD2v4r55tR5MuBLet7q4xIz6Rgh3BBU=",
"lastModified": 1736420959,
"narHash": "sha256-dMGNa5UwdtowEqQac+Dr0d2tFO/60ckVgdhZU9q2E2o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "71a6392e367b08525ee710a93af2e80083b5b3e2",
"rev": "32af3611f6f05655ca166a0b1f47b57c762b5192",
"type": "github"
},
"original": {

View File

@@ -31,7 +31,7 @@
checkFlags = ["-short"];
# When updating go.mod or go.sum, a new sha will need to be calculated,
# update this if you have a mismatch after doing a change to thos files.
# update this if you have a mismatch after doing a change to those files.
vendorHash = "sha256-SBfeixT8DQOrK2SWmHHSOBtzRdSZs+pwomHpw6Jd+qc=";
subPackages = ["cmd/headscale"];

View File

@@ -245,7 +245,7 @@ func RenameNode(tx *gorm.DB,
return fmt.Errorf("renaming node: %w", err)
}
uniq, err := isUnqiueName(tx, newName)
uniq, err := isUniqueName(tx, newName)
if err != nil {
return fmt.Errorf("checking if name is unique: %w", err)
}
@@ -630,7 +630,7 @@ func generateGivenName(suppliedName string, randomSuffix bool) (string, error) {
return suppliedName, nil
}
func isUnqiueName(tx *gorm.DB, name string) (bool, error) {
func isUniqueName(tx *gorm.DB, name string) (bool, error) {
nodes := types.Nodes{}
if err := tx.
Where("given_name = ?", name).Find(&nodes).Error; err != nil {
@@ -649,7 +649,7 @@ func ensureUniqueGivenName(
return "", err
}
unique, err := isUnqiueName(tx, givenName)
unique, err := isUniqueName(tx, givenName)
if err != nil {
return "", err
}

View File

@@ -417,10 +417,10 @@ func SaveNodeRoutes(tx *gorm.DB, node *types.Node) (bool, error) {
return sendUpdate, nil
}
// FailoverNodeRoutesIfNeccessary takes a node and checks if the node's route
// FailoverNodeRoutesIfNecessary takes a node and checks if the node's route
// need to be failed over to another host.
// If needed, the failover will be attempted.
func FailoverNodeRoutesIfNeccessary(
func FailoverNodeRoutesIfNecessary(
tx *gorm.DB,
isLikelyConnected *xsync.MapOf[types.NodeID, bool],
node *types.Node,
@@ -473,7 +473,7 @@ nodeRouteLoop:
return &types.StateUpdate{
Type: types.StatePeerChanged,
ChangeNodes: chng,
Message: "called from db.FailoverNodeRoutesIfNeccessary",
Message: "called from db.FailoverNodeRoutesIfNecessary",
}, nil
}

View File

@@ -342,7 +342,7 @@ func dbForTest(t *testing.T, testName string) *HSDatabase {
return db
}
func TestFailoverNodeRoutesIfNeccessary(t *testing.T) {
func TestFailoverNodeRoutesIfNecessary(t *testing.T) {
su := func(nids ...types.NodeID) *types.StateUpdate {
return &types.StateUpdate{
ChangeNodes: nids,
@@ -648,7 +648,7 @@ func TestFailoverNodeRoutesIfNeccessary(t *testing.T) {
want := tt.want[step]
got, err := Write(db.DB, func(tx *gorm.DB) (*types.StateUpdate, error) {
return FailoverNodeRoutesIfNeccessary(tx, smap(isConnected), node)
return FailoverNodeRoutesIfNecessary(tx, smap(isConnected), node)
})
if (err != nil) != tt.wantErr {

View File

@@ -105,8 +105,7 @@ func generateUserProfiles(
var profiles []tailcfg.UserProfile
for _, user := range userMap {
profiles = append(profiles,
user.TailscaleUserProfile())
profiles = append(profiles, user.TailscaleUserProfile())
}
return profiles
@@ -455,7 +454,7 @@ func (m *Mapper) baseWithConfigMapResponse(
resp.DERPMap = m.derpMap
resp.Domain = m.cfg.BaseDomain
resp.Domain = m.cfg.Domain()
// Do not instruct clients to collect services we do not
// support or do anything with them

View File

@@ -243,7 +243,7 @@ func (n *Notifier) sendAll(update types.StateUpdate) {
// has shut down the channel and is waiting for the lock held here in RemoveNode.
// This means that there is potential for a deadlock which would stop all updates
// going out to clients. This timeout prevents that from happening by moving on to the
// next node if the context is cancelled. Afther sendAll releases the lock, the add/remove
// next node if the context is cancelled. After sendAll releases the lock, the add/remove
// call will succeed and the update will go to the correct nodes on the next call.
ctx, cancel := context.WithTimeout(context.Background(), n.cfg.Tuning.NotifierSendTimeout)
defer cancel()

View File

@@ -3,9 +3,7 @@ package hscontrol
import (
"bytes"
"context"
"crypto/rand"
_ "embed"
"encoding/hex"
"errors"
"fmt"
"html/template"
@@ -28,12 +26,14 @@ import (
)
const (
randomByteSize = 16
randomByteSize = 16
defaultOAuthOptionsCount = 3
)
var (
errEmptyOIDCCallbackParams = errors.New("empty OIDC callback params")
errNoOIDCIDToken = errors.New("could not extract ID Token for OIDC callback")
errNoOIDCRegistrationInfo = errors.New("could not get registration info from cache")
errOIDCAllowedDomains = errors.New(
"authenticated principal does not match any allowed domain",
)
@@ -47,11 +47,17 @@ var (
errOIDCNodeKeyMissing = errors.New("could not get node key from cache")
)
// RegistrationInfo contains both machine key and verifier information for OIDC validation.
type RegistrationInfo struct {
MachineKey key.MachinePublic
Verifier *string
}
type AuthProviderOIDC struct {
serverURL string
cfg *types.OIDCConfig
db *db.HSDatabase
registrationCache *zcache.Cache[string, key.MachinePublic]
registrationCache *zcache.Cache[string, RegistrationInfo]
notifier *notifier.Notifier
ipAlloc *db.IPAllocator
polMan policy.PolicyManager
@@ -87,7 +93,7 @@ func NewAuthProviderOIDC(
Scopes: cfg.Scope,
}
registrationCache := zcache.New[string, key.MachinePublic](
registrationCache := zcache.New[string, RegistrationInfo](
registerCacheExpiration,
registerCacheCleanup,
)
@@ -149,28 +155,52 @@ func (a *AuthProviderOIDC) RegisterHandler(
return
}
randomBlob := make([]byte, randomByteSize)
if _, err := rand.Read(randomBlob); err != nil {
// Set the state and nonce cookies to protect against CSRF attacks
state, err := setCSRFCookie(writer, req, "state")
if err != nil {
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
stateStr := hex.EncodeToString(randomBlob)[:32]
// Set the state and nonce cookies to protect against CSRF attacks
nonce, err := setCSRFCookie(writer, req, "nonce")
if err != nil {
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
// place the node key into the state cache, so it can be retrieved later
a.registrationCache.Set(
stateStr,
machineKey,
)
// Initialize registration info with machine key
registrationInfo := RegistrationInfo{
MachineKey: machineKey,
}
// Add any extra parameter provided in the configuration to the Authorize Endpoint request
extras := make([]oauth2.AuthCodeOption, 0, len(a.cfg.ExtraParams))
extras := make([]oauth2.AuthCodeOption, 0, len(a.cfg.ExtraParams)+defaultOAuthOptionsCount)
// Add PKCE verification if enabled
if a.cfg.PKCE.Enabled {
verifier := oauth2.GenerateVerifier()
registrationInfo.Verifier = &verifier
extras = append(extras, oauth2.AccessTypeOffline)
switch a.cfg.PKCE.Method {
case types.PKCEMethodS256:
extras = append(extras, oauth2.S256ChallengeOption(verifier))
case types.PKCEMethodPlain:
// oauth2 does not have a plain challenge option, so we add it manually
extras = append(extras, oauth2.SetAuthURLParam("code_challenge_method", "plain"), oauth2.SetAuthURLParam("code_challenge", verifier))
}
}
// Add any extra parameters from configuration
for k, v := range a.cfg.ExtraParams {
extras = append(extras, oauth2.SetAuthURLParam(k, v))
}
extras = append(extras, oidc.Nonce(nonce))
authURL := a.oauth2Config.AuthCodeURL(stateStr, extras...)
// Cache the registration info
a.registrationCache.Set(state, registrationInfo)
authURL := a.oauth2Config.AuthCodeURL(state, extras...)
log.Debug().Msgf("Redirecting to %s for authentication", authURL)
http.Redirect(writer, req, authURL, http.StatusFound)
@@ -203,11 +233,34 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
return
}
idToken, err := a.extractIDToken(req.Context(), code)
log.Debug().Interface("cookies", req.Cookies()).Msg("Received oidc callback")
cookieState, err := req.Cookie("state")
if err != nil {
http.Error(writer, "state not found", http.StatusBadRequest)
return
}
if state != cookieState.Value {
http.Error(writer, "state did not match", http.StatusBadRequest)
return
}
idToken, err := a.extractIDToken(req.Context(), code, state)
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
nonce, err := req.Cookie("nonce")
if err != nil {
http.Error(writer, "nonce not found", http.StatusBadRequest)
return
}
if idToken.Nonce != nonce.Value {
http.Error(writer, "nonce did not match", http.StatusBadRequest)
return
}
nodeExpiry := a.determineNodeExpiry(idToken.Expiry)
var claims types.OIDCClaims
@@ -296,7 +349,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
// Neither node nor machine key was found in the state cache meaning
// that we could not reauth nor register the node.
http.Error(writer, err.Error(), http.StatusInternalServerError)
http.Error(writer, "login session expired, try again", http.StatusInternalServerError)
return
}
@@ -318,8 +371,21 @@ func extractCodeAndStateParamFromRequest(
func (a *AuthProviderOIDC) extractIDToken(
ctx context.Context,
code string,
state string,
) (*oidc.IDToken, error) {
oauth2Token, err := a.oauth2Config.Exchange(ctx, code)
var exchangeOpts []oauth2.AuthCodeOption
if a.cfg.PKCE.Enabled {
regInfo, ok := a.registrationCache.Get(state)
if !ok {
return nil, errNoOIDCRegistrationInfo
}
if regInfo.Verifier != nil {
exchangeOpts = []oauth2.AuthCodeOption{oauth2.VerifierOption(*regInfo.Verifier)}
}
}
oauth2Token, err := a.oauth2Config.Exchange(ctx, code, exchangeOpts...)
if err != nil {
return nil, fmt.Errorf("could not exchange code for token: %w", err)
}
@@ -394,7 +460,7 @@ func validateOIDCAllowedUsers(
// cache. If the machine key is found, it will try retrieve the
// node information from the database.
func (a *AuthProviderOIDC) getMachineKeyFromState(state string) (*types.Node, *key.MachinePublic) {
machineKey, ok := a.registrationCache.Get(state)
regInfo, ok := a.registrationCache.Get(state)
if !ok {
return nil, nil
}
@@ -403,9 +469,9 @@ func (a *AuthProviderOIDC) getMachineKeyFromState(state string) (*types.Node, *k
// The error is not important, because if it does not
// exist, then this is a new node and we will move
// on to registration.
node, _ := a.db.GetNodeByMachineKey(machineKey)
node, _ := a.db.GetNodeByMachineKey(regInfo.MachineKey)
return node, &machineKey
return node, &regInfo.MachineKey
}
// reauthenticateNode updates the node expiry in the database
@@ -554,3 +620,22 @@ func getUserName(
return userName, nil
}
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) {
val, err := util.GenerateRandomStringURLSafe(64)
if err != nil {
return val, err
}
c := &http.Cookie{
Path: "/oidc/callback",
Name: name,
Value: val,
MaxAge: int(time.Hour.Seconds()),
Secure: r.TLS != nil,
HttpOnly: true,
}
http.SetCookie(w, c)
return val, nil
}

View File

@@ -62,7 +62,7 @@ func theInternet() *netipx.IPSet {
internetBuilder.RemovePrefix(tsaddr.CGNATRange())
// Delete "cant find DHCP networks"
internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-loca
internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-local
internetBuilder.RemovePrefix(netip.MustParsePrefix("169.254.0.0/16"))
theInternetSet, _ := internetBuilder.IPSet()

View File

@@ -387,7 +387,7 @@ func (m *mapSession) serveLongPoll() {
func (m *mapSession) pollFailoverRoutes(where string, node *types.Node) {
update, err := db.Write(m.h.db.DB, func(tx *gorm.DB) (*types.StateUpdate, error) {
return db.FailoverNodeRoutesIfNeccessary(tx, m.h.nodeNotifier.LikelyConnectedMap(), node)
return db.FailoverNodeRoutesIfNecessary(tx, m.h.nodeNotifier.LikelyConnectedMap(), node)
})
if err != nil {
m.errf(err, fmt.Sprintf("failed to ensure failover routes, %s", where))
@@ -453,7 +453,7 @@ func (m *mapSession) handleEndpointUpdate() {
// If there is no NetInfo, keep the previous one.
// From 1.66 the client only sends it if changed:
// https://github.com/tailscale/tailscale/commit/e1011f138737286ecf5123ff887a7a5800d129a2
// TODO(kradalby): evaulate if we need better comparing of hostinfo
// TODO(kradalby): evaluate if we need better comparing of hostinfo
// before we take the changes.
if m.req.Hostinfo.NetInfo == nil && m.node.Hostinfo != nil {
m.req.Hostinfo.NetInfo = m.node.Hostinfo.NetInfo

View File

@@ -27,50 +27,27 @@ func Apple(url string) *elem.Element {
elem.Text("App store"),
),
),
elem.Li(nil,
elem.Text("Open Tailscale and make sure you are "),
elem.I(nil, elem.Text("not ")),
elem.Text("logged in to any account"),
),
elem.Li(nil,
elem.Text("Open Settings on the iOS device"),
elem.Li(
nil,
elem.Text("Open the Tailscale app"),
),
elem.Li(
nil,
elem.Text(
`Scroll down to the "third party apps" section, under "Game Center" or "TV Provider"`,
),
elem.Text(`Click the account icon in the top-right corner and select "Log in…".`),
),
elem.Li(nil,
elem.Text("Find Tailscale and select it"),
elem.Ul(nil,
elem.Li(
nil,
elem.Text(
`If the iOS device was previously logged into Tailscale, switch the "Reset Keychain" toggle to "on"`,
),
),
),
elem.Li(
nil,
elem.Text(`Tap the top-right options menu button and select "Use custom coordination server".`),
),
elem.Li(
nil,
elem.Text(
fmt.Sprintf(
`Enter "%s" under "Alternate Coordination Server URL"`,
`Enter your instance URL: "%s"`,
url,
),
),
),
elem.Li(
nil,
elem.Text(
"Restart the app by closing it from the iOS app switcher, open the app and select the regular sign in option ",
),
elem.I(nil, elem.Text("(non-SSO)")),
elem.Text(
". It should open up to the headscale authentication page.",
),
),
elem.Li(
nil,
elem.Text(

View File

@@ -26,11 +26,14 @@ import (
const (
defaultOIDCExpiryTime = 180 * 24 * time.Hour // 180 Days
maxDuration time.Duration = 1<<63 - 1
PKCEMethodPlain string = "plain"
PKCEMethodS256 string = "S256"
)
var (
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
)
type IPAllocationStrategy string
@@ -162,6 +165,11 @@ type LetsEncryptConfig struct {
ChallengeType string
}
type PKCEConfig struct {
Enabled bool
Method string
}
type OIDCConfig struct {
OnlyStartIfOIDCIsAvailable bool
Issuer string
@@ -176,6 +184,7 @@ type OIDCConfig struct {
Expiry time.Duration
UseExpiryFromToken bool
MapLegacyUsers bool
PKCE PKCEConfig
}
type DERPConfig struct {
@@ -226,6 +235,24 @@ type Tuning struct {
NodeMapSessionBufferedChanSize int
}
func validatePKCEMethod(method string) error {
if method != PKCEMethodPlain && method != PKCEMethodS256 {
return errInvalidPKCEMethod
}
return nil
}
// Domain returns the hostname/domain part of the ServerURL.
// If the ServerURL is not a valid URL, it returns the BaseDomain.
func (c *Config) Domain() string {
u, err := url.Parse(c.ServerURL)
if err != nil {
return c.BaseDomain
}
return u.Hostname()
}
// LoadConfig prepares and loads the Headscale configuration into Viper.
// This means it sets the default values, reads the configuration file and
// environment variables, and handles deprecated configuration options.
@@ -293,6 +320,8 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("oidc.expiry", "180d")
viper.SetDefault("oidc.use_expiry_from_token", false)
viper.SetDefault("oidc.map_legacy_users", true)
viper.SetDefault("oidc.pkce.enabled", false)
viper.SetDefault("oidc.pkce.method", "S256")
viper.SetDefault("logtail.enabled", false)
viper.SetDefault("randomize_client_port", false)
@@ -340,6 +369,12 @@ func validateServerConfig() error {
// after #2170 is cleaned up
// depr.fatal("oidc.strip_email_domain")
if viper.GetBool("oidc.enabled") {
if err := validatePKCEMethod(viper.GetString("oidc.pkce.method")); err != nil {
return err
}
}
depr.Log()
for _, removed := range []string{
@@ -593,7 +628,7 @@ func dns() (DNSConfig, error) {
// UnmarshalKey is compatible with Environment Variables.
// err := viper.UnmarshalKey("dns", &dns)
// if err != nil {
// return DNSConfig{}, fmt.Errorf("unmarshaling dns config: %w", err)
// return DNSConfig{}, fmt.Errorf("unmarshalling dns config: %w", err)
// }
dns.MagicDNS = viper.GetBool("dns.magic_dns")
@@ -608,7 +643,7 @@ func dns() (DNSConfig, error) {
err := viper.UnmarshalKey("dns.extra_records", &extraRecords)
if err != nil {
return DNSConfig{}, fmt.Errorf("unmarshaling dns extra records: %w", err)
return DNSConfig{}, fmt.Errorf("unmarshalling dns extra records: %w", err)
}
dns.ExtraRecords = extraRecords
}
@@ -928,6 +963,10 @@ func LoadServerConfig() (*Config, error) {
// after #2170 is cleaned up
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
MapLegacyUsers: viper.GetBool("oidc.map_legacy_users"),
PKCE: PKCEConfig{
Enabled: viper.GetBool("oidc.pkce.enabled"),
Method: viper.GetString("oidc.pkce.method"),
},
},
LogTail: logTailConfig,

View File

@@ -10,6 +10,7 @@ import (
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm"
"tailscale.com/tailcfg"
@@ -173,9 +174,11 @@ func (c *OIDCClaims) Identifier() string {
// FromClaim overrides a User from OIDC claims.
// All fields will be updated, except for the ID.
func (u *User) FromClaim(claims *OIDCClaims) {
err := util.CheckForFQDNRules(claims.Username)
err := util.ValidateUsername(claims.Username)
if err == nil {
u.Name = claims.Username
} else {
log.Debug().Err(err).Msgf("Username %s is not valid", claims.Username)
}
if claims.EmailVerified {

View File

@@ -6,6 +6,7 @@ import (
"net/netip"
"regexp"
"strings"
"unicode"
"go4.org/netipx"
"tailscale.com/util/dnsname"
@@ -20,10 +21,40 @@ const (
LabelHostnameLength = 63
)
var invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+")
var invalidCharsInUserRegex = regexp.MustCompile("[^a-z0-9-.]+")
var ErrInvalidUserName = errors.New("invalid user name")
func ValidateUsername(username string) error {
// Ensure the username meets the minimum length requirement
if len(username) < 2 {
return errors.New("username must be at least 2 characters long")
}
// Ensure the username does not start with a number
if unicode.IsDigit(rune(username[0])) {
return errors.New("username cannot start with a number")
}
atCount := 0
for _, char := range username {
switch {
case unicode.IsLetter(char), unicode.IsDigit(char), char == '-':
// Valid characters
case char == '@':
atCount++
if atCount > 1 {
return errors.New("username cannot contain more than one '@'")
}
default:
return fmt.Errorf("username contains invalid character: '%c'", char)
}
}
return nil
}
func CheckForFQDNRules(name string) error {
if len(name) > LabelHostnameLength {
return fmt.Errorf(
@@ -39,7 +70,7 @@ func CheckForFQDNRules(name string) error {
ErrInvalidUserName,
)
}
if invalidCharsInUserRegex.MatchString(name) {
if invalidDNSRegex.MatchString(name) {
return fmt.Errorf(
"DNS segment should only be composed of lowercase ASCII letters numbers, hyphen and dots. %v doesn't comply with theses rules: %w",
name,
@@ -52,7 +83,7 @@ func CheckForFQDNRules(name string) error {
func ConvertWithFQDNRules(name string) string {
name = strings.ToLower(name)
name = invalidCharsInUserRegex.ReplaceAllString(name, "")
name = invalidDNSRegex.ReplaceAllString(name, "")
return name
}
@@ -197,7 +228,7 @@ func NormalizeToFQDNRules(name string, stripEmailDomain bool) (string, error) {
} else {
name = strings.ReplaceAll(name, "@", ".")
}
name = invalidCharsInUserRegex.ReplaceAllString(name, "-")
name = invalidDNSRegex.ReplaceAllString(name, "-")
for _, elt := range strings.Split(name, ".") {
if len(elt) > LabelHostnameLength {

View File

@@ -10,6 +10,8 @@ import (
"log"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"sort"
"strconv"
@@ -534,6 +536,86 @@ func TestOIDC024UserCreation(t *testing.T) {
}
}
func TestOIDCAuthenticationWithPKCE(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
baseScenario, err := NewScenario(dockertestMaxWait())
assertNoErr(t, err)
scenario := AuthOIDCScenario{
Scenario: baseScenario,
}
defer scenario.ShutdownAssertNoPanics(t)
// Single user with one node for testing PKCE flow
spec := map[string]int{
"user1": 1,
}
mockusers := []mockoidc.MockUser{
oidcMockUser("user1", true),
}
oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, mockusers)
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
defer scenario.mockOIDC.Close()
oidcMap := map[string]string{
"HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer,
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
"HEADSCALE_OIDC_PKCE_ENABLED": "1", // Enable PKCE
"HEADSCALE_OIDC_MAP_LEGACY_USERS": "0",
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": "0",
}
err = scenario.CreateHeadscaleEnv(
spec,
hsic.WithTestName("oidcauthpkce"),
hsic.WithConfigEnv(oidcMap),
hsic.WithTLS(),
hsic.WithHostnameAsServerURL(),
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)),
)
assertNoErrHeadscaleEnv(t, err)
// Get all clients and verify they can connect
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
allIps, err := scenario.ListTailscaleClientsIPs()
assertNoErrListClientIPs(t, err)
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
// Verify PKCE was used in authentication
headscale, err := scenario.Headscale()
assertNoErr(t, err)
var listUsers []v1.User
err = executeAndUnmarshal(headscale,
[]string{
"headscale",
"users",
"list",
"--output",
"json",
},
&listUsers,
)
assertNoErr(t, 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 (s *AuthOIDCScenario) CreateHeadscaleEnv(
users map[string]int,
opts ...hsic.Option,
@@ -667,6 +749,24 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration, users []mockoidc
}, nil
}
type LoggingRoundTripper struct{}
func (t LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
noTls := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint
}
resp, err := noTls.RoundTrip(req)
if err != nil {
return nil, err
}
log.Printf("---")
log.Printf("method: %s | url: %s", resp.Request.Method, resp.Request.URL.String())
log.Printf("status: %d | cookies: %+v", resp.StatusCode, resp.Cookies())
return resp, nil
}
func (s *AuthOIDCScenario) runTailscaleUp(
userStr, loginServer string,
) error {
@@ -678,35 +778,39 @@ func (s *AuthOIDCScenario) runTailscaleUp(
log.Printf("running tailscale up for user %s", userStr)
if user, ok := s.users[userStr]; ok {
for _, client := range user.Clients {
c := client
tsc := client
user.joinWaitGroup.Go(func() error {
loginURL, err := c.LoginWithURL(loginServer)
loginURL, err := tsc.LoginWithURL(loginServer)
if err != nil {
log.Printf("%s failed to run tailscale up: %s", c.Hostname(), err)
log.Printf("%s failed to run tailscale up: %s", tsc.Hostname(), err)
}
loginURL.Host = fmt.Sprintf("%s:8080", headscale.GetIP())
loginURL.Host = fmt.Sprintf("%s:8080", headscale.GetHostname())
loginURL.Scheme = "http"
if len(headscale.GetCert()) > 0 {
loginURL.Scheme = "https"
}
insecureTransport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint
httptest.NewRecorder()
hc := &http.Client{
Transport: LoggingRoundTripper{},
}
hc.Jar, err = cookiejar.New(nil)
if err != nil {
log.Printf("failed to create cookie jar: %s", err)
}
log.Printf("%s login url: %s\n", c.Hostname(), loginURL.String())
log.Printf("%s login url: %s\n", tsc.Hostname(), loginURL.String())
log.Printf("%s logging in with url", c.Hostname())
httpClient := &http.Client{Transport: insecureTransport}
log.Printf("%s logging in with url", tsc.Hostname())
ctx := context.Background()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
resp, err := httpClient.Do(req)
resp, err := hc.Do(req)
if err != nil {
log.Printf(
"%s failed to login using url %s: %s",
c.Hostname(),
tsc.Hostname(),
loginURL,
err,
)
@@ -714,8 +818,10 @@ func (s *AuthOIDCScenario) runTailscaleUp(
return err
}
log.Printf("cookies: %+v", hc.Jar.Cookies(loginURL))
if resp.StatusCode != http.StatusOK {
log.Printf("%s response code of oidc login request was %s", c.Hostname(), resp.Status)
log.Printf("%s response code of oidc login request was %s", tsc.Hostname(), resp.Status)
body, _ := io.ReadAll(resp.Body)
log.Printf("body: %s", body)
@@ -726,12 +832,12 @@ func (s *AuthOIDCScenario) runTailscaleUp(
_, err = io.ReadAll(resp.Body)
if err != nil {
log.Printf("%s failed to read response body: %s", c.Hostname(), err)
log.Printf("%s failed to read response body: %s", tsc.Hostname(), err)
return err
}
log.Printf("Finished request for %s to join tailnet", c.Hostname())
log.Printf("Finished request for %s to join tailnet", tsc.Hostname())
return nil
})

View File

@@ -106,7 +106,7 @@ extra:
- icon: fontawesome/brands/discord
link: https://discord.gg/c84AZQhmpx
headscale:
version: 0.23.0
version: 0.24.3
# Extensions
markdown_extensions: