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

Initial work on OIDC (SSO) integration #126

Merged
merged 26 commits into from
Oct 31, 2021
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e7a2501
initial work on OIDC (SSO) integration
Sep 26, 2021
b22a978
fix linter errors, error out if jwt does not contain a key id
Sep 26, 2021
8248b71
Merge branch 'main' into main
kradalby Sep 26, 2021
cc054d7
Merge branch 'main' into main
kradalby Sep 26, 2021
0393ab5
Merge branch 'main' into main
kradalby Sep 28, 2021
c487591
use go-oidc instead of verifying and extracting tokens ourselves, ren…
Oct 6, 2021
35795c7
Handle trailing slash on uris
Oct 8, 2021
e407d42
updates from code review
Oct 8, 2021
2997f4d
Merge branch 'main' into main
kradalby Oct 8, 2021
74e6c14
updates from code review
Oct 10, 2021
8843188
add notes to README.md about OIDC
Oct 10, 2021
0603e29
add login details to RegisterResponse so GUI clients show login displ…
Oct 15, 2021
afbfc1d
Merge branch 'main' into main
unreality Oct 16, 2021
d0cd5af
fix incorrect merge
Oct 16, 2021
710616f
Merge branch 'main' into main
kradalby Oct 17, 2021
a347d27
Fix broken machine test
kradalby Oct 18, 2021
677bd9b
Implement namespace matching
kradalby Oct 18, 2021
8fe72dc
Merge pull request #1 from kradalby/namespace-mappings
unreality Oct 19, 2021
da14750
Merge branch 'main' into main
kradalby Oct 19, 2021
e742422
Merge branch 'main' into main
kradalby Oct 19, 2021
dbe193a
Fix up leftovers from kradalby PR
kradalby Oct 19, 2021
2d252da
suggested documentation and comments
Oct 29, 2021
cbf3f5d
Resolve merge conflict
kradalby Oct 30, 2021
cd2914d
Make note about oidc being experimental
kradalby Oct 30, 2021
bac8117
Remove lint from generated testcode
kradalby Oct 30, 2021
73d22cd
Merge pull request #2 from kradalby/oidc-1
unreality Oct 30, 2021
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
59 changes: 43 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Headscale implements this coordination server.
- [x] Support for alternative IP ranges in the tailnets (default Tailscale's 100.64.0.0/10)
- [x] DNS (passing DNS servers to nodes)
- [x] Share nodes between ~~users~~ namespaces
- [x] Single-Sign-On (via Open ID Connect)
- [x] MagicDNS (see `docs/`)

## Client OS support
Expand Down Expand Up @@ -102,7 +103,22 @@ Suggestions/PRs welcomed!
docker exec <container_name> headscale create myfirstnamespace
```

5. Run the server
5. (Optional) Configure an OIDC Issuer

You can optionally configure an OIDC endpoint to which your users will be redirected to authenticate with headscale. In config.json set the following parameters:

```json
{
"oidc": {
"issuer": "https://your-oidc.issuer.com/path",
"client_id": "your-oidc-client-id",
"client_secret": "your-oidc-client-secret",
"domain_map": {
".*": "default-namespace"
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, github suggestions does not handle this well, there is a "end of codeblock" missing


6. Run the server

```shell
headscale serve
Expand All @@ -116,34 +132,34 @@ Suggestions/PRs welcomed!
docker run -v $(pwd)/private.key:/private.key -v $(pwd)/config.json:/config.json -v $(pwd)/derp.yaml:/derp.yaml -v $(pwd)/db.sqlite:/db.sqlite -p 127.0.0.1:8080:8080 headscale/headscale:x.x.x headscale serve
```

6. If you used tailscale.com before in your nodes, make sure you clear the tailscald data folder
7. If you used tailscale.com before in your nodes, make sure you clear the tailscald data folder

```shell
systemctl stop tailscaled
rm -fr /var/lib/tailscale
systemctl start tailscaled
```

7. Add your first machine
8. Add your first machine

```shell
tailscale up --login-server YOUR_HEADSCALE_URL
```

8. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key.
9. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key. If OIDC is configured, once you login your user will be added to a namespace automatically, and you can skip step 10.

9. In the server, register your machine to a namespace with the CLI
```shell
headscale -n myfirstnamespace nodes register YOURMACHINEKEY
```
or docker:
```shell
docker run -v $(pwd)/private.key:/private.key -v $(pwd)/config.json:/config.json -v $(pwd)/derp.yaml:/derp.yaml headscale/headscale:x.x.x headscale -n myfirstnamespace nodes register YOURMACHINEKEY
```
or if your server is already running in docker:
```shell
docker exec <container_name> headscale -n myfirstnamespace nodes register YOURMACHINEKEY
```
10. In the server, register your machine to a namespace with the CLI
```shell
headscale -n myfirstnamespace nodes register YOURMACHINEKEY
```
or docker:
```shell
docker run -v $(pwd)/private.key:/private.key -v $(pwd)/config.json:/config.json -v $(pwd)/derp.yaml:/derp.yaml headscale/headscale:x.x.x headscale -n myfirstnamespace nodes register YOURMACHINEKEY
```
or if your server is already running in docker:
```shell
docker exec <container_name> headscale -n myfirstnamespace nodes register YOURMACHINEKEY
```

Alternatively, you can use Auth Keys to register your machines:

Expand Down Expand Up @@ -220,6 +236,17 @@ Headscale's configuration file is named `config.json` or `config.yaml`. Headscal

The fields starting with `db_` are used for the PostgreSQL connection information.

OpenID Connect settings:
```
oidc:
issuer: "https://your-oidc.issuer.com/path"
client_id: "your-oidc-client-id"
client_secret: "your-oidc-client-secret"
domain_map:
".*": default-namespace
```


### Running the service via TLS (optional)

```
Expand Down
118 changes: 74 additions & 44 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/rs/zerolog/log"
Expand Down Expand Up @@ -64,7 +65,7 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
Str("handler", "Registration").
Err(err).
Msg("Cannot parse machine key")
machineRegistrations.WithLabelValues("unkown", "web", "error", "unknown").Inc()
machineRegistrations.WithLabelValues("unknown", "web", "error", "unknown").Inc()
c.String(http.StatusInternalServerError, "Sad!")
return
}
Expand All @@ -75,42 +76,69 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
Str("handler", "Registration").
Err(err).
Msg("Cannot decode message")
machineRegistrations.WithLabelValues("unkown", "web", "error", "unknown").Inc()
machineRegistrations.WithLabelValues("unknown", "web", "error", "unknown").Inc()
c.String(http.StatusInternalServerError, "Very sad!")
return
}

now := time.Now().UTC()
var m Machine
if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
m, err := h.GetMachineByMachineKey(mKey.HexString())
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Info().Str("machine", req.Hostinfo.Hostname).Msg("New machine")
m = Machine{
Expiry: &req.Expiry,
MachineKey: mKey.HexString(),
Name: req.Hostinfo.Hostname,
NodeKey: wgkey.Key(req.NodeKey).HexString(),
LastSuccessfulUpdate: &now,
newMachine := Machine{
Expiry: &time.Time{},
MachineKey: mKey.HexString(),
Name: req.Hostinfo.Hostname,
}
if err := h.db.Create(&m).Error; err != nil {
if err := h.db.Create(&newMachine).Error; err != nil {
log.Error().
Str("handler", "Registration").
Err(err).
Msg("Could not create row")
machineRegistrations.WithLabelValues("unkown", "web", "error", m.Namespace.Name).Inc()
machineRegistrations.WithLabelValues("unknown", "web", "error", m.Namespace.Name).Inc()
return
}
m = &newMachine
}

if !m.Registered && req.Auth.AuthKey != "" {
h.handleAuthKey(c, h.db, mKey, req, m)
h.handleAuthKey(c, h.db, mKey, req, *m)
return
}

resp := tailcfg.RegisterResponse{}

// We have the updated key!
if m.NodeKey == wgkey.Key(req.NodeKey).HexString() {
if m.Registered {

// The client sends an Expiry in the past if the client is requesting a logout
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we have a reference for this? would be good to have in the comment, for example if it can be found in the Tailscale source code.

if !req.Expiry.IsZero() && req.Expiry.UTC().Before(now) {
log.Info().
Str("handler", "Registration").
Str("machine", m.Name).
Msg("Client requested logout")

m.Expiry = &req.Expiry // save the expiry so that the machine is marked as expired
h.db.Save(&m)

resp.AuthURL = ""
resp.MachineAuthorized = false
resp.User = *m.Namespace.toUser()
respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "")
return
}
c.Data(200, "application/json; charset=utf-8", respBody)
return
}

if m.Registered && m.Expiry.UTC().After(now) {
// The machine registration is valid, respond with redirect to /map
log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Expand All @@ -119,6 +147,8 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
resp.AuthURL = ""
resp.MachineAuthorized = true
resp.User = *m.Namespace.toUser()
resp.Login = *m.Namespace.toLogin()

respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
log.Error().
Expand All @@ -134,12 +164,23 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
return
}

// The client has registered before, but has expired
log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Msg("Not registered and not NodeKey rotation. Sending a authurl to register")
resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
h.cfg.ServerURL, mKey.HexString())
Msg("Machine registration has expired. Sending a authurl to register")

if h.cfg.OIDC.Issuer != "" {
resp.AuthURL = fmt.Sprintf("%s/oidc/register/%s",
strings.TrimSuffix(h.cfg.ServerURL, "/"), mKey.HexString())
} else {
resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
strings.TrimSuffix(h.cfg.ServerURL, "/"), mKey.HexString())
}

m.RequestedExpiry = &req.Expiry // save the requested expiry time for retrieval later in the authentication flow
h.db.Save(&m)

respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
log.Error().
Expand All @@ -155,8 +196,8 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
return
}

// The NodeKey we have matches OldNodeKey, which means this is a refresh after an key expiration
if m.NodeKey == wgkey.Key(req.OldNodeKey).HexString() {
// The NodeKey we have matches OldNodeKey, which means this is a refresh after a key expiration
if m.NodeKey == wgkey.Key(req.OldNodeKey).HexString() && m.Expiry.UTC().After(now) {
log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Expand All @@ -179,35 +220,22 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
return
}

// We arrive here after a client is restarted without finalizing the authentication flow or
// when headscale is stopped in the middle of the auth process.
if m.Registered {
log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Msg("The node is sending us a new NodeKey, but machine is registered. All clear for /map")
resp.AuthURL = ""
resp.MachineAuthorized = true
resp.User = *m.Namespace.toUser()
respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "")
return
}
c.Data(200, "application/json; charset=utf-8", respBody)
return
}

// The machine registration is new, redirect the client to the registration URL
log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Msg("The node is sending us a new NodeKey, sending auth url")
resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
h.cfg.ServerURL, mKey.HexString())
if h.cfg.OIDC.Issuer != "" {
resp.AuthURL = fmt.Sprintf("%s/oidc/register/%s", strings.TrimSuffix(h.cfg.ServerURL, "/"), mKey.HexString())
} else {
resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
strings.TrimSuffix(h.cfg.ServerURL, "/"), mKey.HexString())
}

m.RequestedExpiry = &req.Expiry // save the requested expiry time for retrieval later in the authentication flow
m.NodeKey = wgkey.Key(req.NodeKey).HexString() // save the NodeKey
h.db.Save(&m)

respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
log.Error().
Expand Down Expand Up @@ -390,6 +418,8 @@ func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key,
m.RegisterMethod = "authKey"
db.Save(&m)

h.updateMachineExpiry(&m) // TODO: do we want to do different expiry times for AuthKeys?

pak.Used = true
db.Save(&pak)

Expand Down
32 changes: 30 additions & 2 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import (
"sync"
"time"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/patrickmn/go-cache"
"golang.org/x/oauth2"

"github.com/rs/zerolog/log"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -53,6 +57,18 @@ type Config struct {
ACMEEmail string

DNSConfig *tailcfg.DNSConfig

OIDC OIDCConfig

MaxMachineRegistrationDuration time.Duration
DefaultMachineRegistrationDuration time.Duration
}

type OIDCConfig struct {
Issuer string
ClientID string
ClientSecret string
MatchMap map[string]string
}

// Headscale represents the base app of the service
Expand All @@ -69,6 +85,10 @@ type Headscale struct {
aclRules *[]tailcfg.FilterRule

lastStateChange sync.Map

oidcProvider *oidc.Provider
oauth2Config *oauth2.Config
oidcStateCache *cache.Cache
}

// NewHeadscale returns the Headscale app
Expand Down Expand Up @@ -108,6 +128,13 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
return nil, err
}

if cfg.OIDC.Issuer != "" {
err = h.initOIDC()
if err != nil {
return nil, err
}
}

if h.cfg.DNSConfig != nil && h.cfg.DNSConfig.Proxied { // if MagicDNS
magicDNSDomains, err := generateMagicDNSRootDomains(h.cfg.IPPrefix, h.cfg.BaseDomain)
if err != nil {
Expand Down Expand Up @@ -188,8 +215,11 @@ func (h *Headscale) Serve() error {
r.GET("/register", h.RegisterWebAPI)
r.POST("/machine/:id/map", h.PollNetMapHandler)
r.POST("/machine/:id", h.RegistrationHandler)
r.GET("/oidc/register/:mkey", h.RegisterOIDC)
r.GET("/oidc/callback", h.OIDCCallback)
r.GET("/apple", h.AppleMobileConfig)
r.GET("/apple/:platform", h.ApplePlatformConfig)

var err error

go h.watchForKVUpdates(5000)
Expand Down Expand Up @@ -270,7 +300,6 @@ func (h *Headscale) getLastStateChange(namespaces ...string) time.Time {

times = append(times, lastChange)
}

}

sort.Slice(times, func(i, j int) bool {
Expand All @@ -281,7 +310,6 @@ func (h *Headscale) getLastStateChange(namespaces ...string) time.Time {

if len(times) == 0 {
return time.Now().UTC()

} else {
return times[0]
}
Expand Down
Loading