diff --git a/api.go b/api.go index e2a5618..2c5a132 100644 --- a/api.go +++ b/api.go @@ -133,8 +133,13 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) { 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()) + + if h.cfg.OIDCEndpoint != "" { + resp.AuthURL = fmt.Sprintf("%s/oidc/register/%s", h.cfg.ServerURL, mKey.HexString()) + } else { + resp.AuthURL = fmt.Sprintf("%s/register?key=%s", + h.cfg.ServerURL, mKey.HexString()) + } respBody, err := encode(resp, &mKey, h.privateKey) if err != nil { log.Error(). @@ -199,8 +204,12 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) { 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.OIDCEndpoint != "" { + resp.AuthURL = fmt.Sprintf("%s/oidc/register/%s", h.cfg.ServerURL, mKey.HexString()) + } else { + resp.AuthURL = fmt.Sprintf("%s/register?key=%s", + h.cfg.ServerURL, mKey.HexString()) + } respBody, err := encode(resp, &mKey, h.privateKey) if err != nil { log.Error(). diff --git a/app.go b/app.go index c903d83..81871a8 100644 --- a/app.go +++ b/app.go @@ -45,6 +45,10 @@ type Config struct { TLSKeyPath string DNSConfig *tailcfg.DNSConfig + + OIDCEndpoint string + OIDCClientID string + OIDCClientSecret string } // Headscale represents the base app of the service @@ -168,6 +172,8 @@ 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) var err error timeout := 30 * time.Second diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 7ada669..b7faad5 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -170,6 +170,10 @@ func getHeadscaleApp() (*headscale.Headscale, error) { TLSKeyPath: absPath(viper.GetString("tls_key_path")), DNSConfig: GetDNSConfig(), + + OIDCEndpoint: viper.GetString("oidc_endpoint"), + OIDCClientID: viper.GetString("oidc_client_id"), + OIDCClientSecret: viper.GetString("oidc_client_secret"), } h, err := headscale.NewHeadscale(cfg) diff --git a/go.mod b/go.mod index 8709119..031460e 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,10 @@ require ( github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect github.com/opencontainers/runc v1.0.2 // indirect github.com/ory/dockertest/v3 v3.7.0 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pterm/pterm v0.12.30 github.com/rs/zerolog v1.25.0 + github.com/s12v/go-jwks v0.2.1 github.com/spf13/cobra v1.2.1 github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.7.0 @@ -28,6 +30,7 @@ require ( golang.org/x/net v0.0.0-20210913180222-943fd674d43e // indirect golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c + gopkg.in/square/go-jose.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 gorm.io/datatypes v1.0.2 gorm.io/driver/postgres v1.1.1 diff --git a/go.sum b/go.sum index ac934db..195fb21 100644 --- a/go.sum +++ b/go.sum @@ -711,6 +711,8 @@ github.com/ory/dockertest/v3 v3.7.0 h1:Bijzonc69Ont3OU0a3TWKJ1Rzlh3TsDXP1JrTAkSm github.com/ory/dockertest/v3 v3.7.0/go.mod h1:PvCCgnP7AfBZeVrzwiUTjZx/IUXlGLC1zQlUQrLIlUE= github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= @@ -786,6 +788,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/ryancurrah/gomodguard v1.1.0/go.mod h1:4O8tr7hBODaGE6VIhfJDHcwzh5GUccKSJBU0UMXJFVM= github.com/ryanrolds/sqlclosecheck v0.3.0/go.mod h1:1gREqxyTGR3lVtpngyFo3hZAgk0KCtEdgEkHwDbigdA= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/s12v/go-jwks v0.2.1 h1:2zShofKJoSXztWyh5ASPfpzuQrE+b+Sum9JJdif05Po= +github.com/s12v/go-jwks v0.2.1/go.mod h1:DmmtP4Etd59Y90j8zmTS4z61MKu0QPvgioAXv+mqyjQ= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= @@ -845,6 +849,8 @@ github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= +github.com/square/go-jose v2.5.1+incompatible h1:FC+BwI9FzJZWpKaE0yUhFNbp/CyFHndARzuGVME/LGk= +github.com/square/go-jose v2.5.1+incompatible/go.mod h1:7MxpAF/1WTVUu8Am+T5kNy+t0902CaLWM4Z745MkOa8= github.com/ssgreg/nlreturn/v2 v2.1.0/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= @@ -958,6 +964,7 @@ go4.org/mem v0.0.0-20201119185036-c04c5a6ff174/go.mod h1:reUoABIJ9ikfM5sgtSF3Wus go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222175341-b30ae309168e/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063 h1:1tk03FUNpulq2cuWpXZWj649rwJpk0d20rxWiopKRmc= go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= +golang.org/x/crypto v0.0.0-20180621125126-a49355c7e3f8/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -1021,6 +1028,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180729183719-c4299a1a0d85/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1097,6 +1105,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1436,6 +1445,8 @@ gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4= +gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= diff --git a/oidc.go b/oidc.go new file mode 100644 index 0000000..0006cc3 --- /dev/null +++ b/oidc.go @@ -0,0 +1,310 @@ +package headscale + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/patrickmn/go-cache" + "github.com/rs/zerolog/log" + "github.com/s12v/go-jwks" + "gopkg.in/square/go-jose.v2/jwt" + "gorm.io/gorm" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type OpenIDConfiguration struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +type OpenIDTokens struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + IdToken string `json:"id_token"` + NotBeforePolicy int `json:"not-before-policy,omitempty"` + RefreshExpiresIn int `json:"refresh_expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + SessionState string `json:"session_state,omitempty"` + TokenType string `json:"token_type,omitempty"` +} + +type AccessToken struct { + jwt.Claims + Name string `json:"name,omitempty"` + Groups []string `json:"groups,omitempty"` + Email string `json:"email"` + Username string `json:"preferred_username,omitempty"` +} + +var oidcConfig *OpenIDConfiguration +var stateCache *cache.Cache +var jwksSource *jwks.WebSource +var jwksClient jwks.JWKSClient + +func verifyToken(token string) (*AccessToken, error) { + + if jwksClient == nil { + jwksSource = jwks.NewWebSource(oidcConfig.JWKSURI) + jwksClient = jwks.NewDefaultClient( + jwksSource, + time.Hour, // Refresh keys every 1 hour + 12*time.Hour, // Expire keys after 12 hours + ) + } + + //decode jwt + tok, err := jwt.ParseSigned(token) + if err != nil { + return nil, err + } + + if tok.Headers[0].KeyID != "" { + log.Debug().Msgf("Checking KID %s\n", tok.Headers[0].KeyID) + + jwk, err := jwksClient.GetSignatureKey(tok.Headers[0].KeyID) + if err != nil { + return nil, err + } + + claims := AccessToken{} + + err = tok.Claims(jwk.Certificates[0].PublicKey, &claims) + if err != nil { + return nil, err + } else { + + err = claims.Validate(jwt.Expected{ + Time: time.Now(), + }) + if err != nil { + return nil, err + } + + return &claims, nil + } + + } else { + return nil, err + } +} + +func getOIDCConfig(oidcConfigURL string) (*OpenIDConfiguration, error) { + client := &http.Client{} + req, err := http.NewRequest("GET", oidcConfigURL, nil) + if err != nil { + log.Error().Msgf("%v", err) + return nil, err + } + + log.Debug().Msgf("Requesting OIDC Config from %s", oidcConfigURL) + + oidcConfigResp, err := client.Do(req) + if err != nil { + log.Error().Msgf("%v", err) + return nil, err + } + defer oidcConfigResp.Body.Close() + + var oidcConfig OpenIDConfiguration + + err = json.NewDecoder(oidcConfigResp.Body).Decode(&oidcConfig) + if err != nil { + log.Error().Msgf("%v", err) + return nil, err + } + return &oidcConfig, nil +} + +func (h *Headscale) exchangeCodeForTokens(code string, redirectURI string) (*OpenIDTokens, error) { + var err error + + if oidcConfig == nil { + oidcConfig, err = getOIDCConfig(fmt.Sprintf("%s.well-known/openid-configuration", h.cfg.OIDCEndpoint)) + if err != nil { + return nil, err + } + } + + params := url.Values{} + params.Add("grant_type", "authorization_code") + params.Add("code", code) + params.Add("client_id", h.cfg.OIDCClientID) + params.Add("client_secret", h.cfg.OIDCClientSecret) + params.Add("redirect_uri", redirectURI) + + client := &http.Client{} + req, err := http.NewRequest("POST", oidcConfig.TokenEndpoint, strings.NewReader(params.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + if err != nil { + log.Error().Msgf("%v", err) + return nil, err + } + + tokenResp, err := client.Do(req) + if err != nil { + log.Error().Msgf("%v", err) + return nil, err + } + defer tokenResp.Body.Close() + + if tokenResp.StatusCode != 200 { + b, _ := io.ReadAll(tokenResp.Body) + log.Error().Msgf("%s", b) + } + + var tokens OpenIDTokens + + err = json.NewDecoder(tokenResp.Body).Decode(&tokens) + if err != nil { + log.Error().Msgf("%v", err) + return nil, err + } + + log.Info().Msg("Successfully exchanged code for tokens") + + return &tokens, nil +} + +// RegisterOIDC redirects to the OIDC provider for authentication +// Puts machine key in cache so the callback can retrieve it using the oidc state param +// Listens in /oidc/register/:mKey +func (h *Headscale) RegisterOIDC(c *gin.Context) { + mKeyStr := c.Param("mKey") + if mKeyStr == "" { + c.String(http.StatusBadRequest, "Wrong params") + return + } + + var err error + + // grab oidc config if it hasn't been already + if oidcConfig == nil { + oidcConfig, err = getOIDCConfig(fmt.Sprintf("%s.well-known/openid-configuration", h.cfg.OIDCEndpoint)) + + if err != nil { + c.String(http.StatusInternalServerError, "Could not retrieve OIDC Config") + return + } + } + + b := make([]byte, 16) + _, err = rand.Read(b) + stateStr := hex.EncodeToString(b)[:32] + + // init the state cache if it hasn't been already + if stateCache == nil { + stateCache = cache.New(time.Minute*5, time.Minute*10) + } + + // place the machine key into the state cache, so it can be retrieved later + stateCache.Set(stateStr, mKeyStr, time.Minute*5) + + params := url.Values{} + params.Add("response_type", "code") + params.Add("client_id", h.cfg.OIDCClientID) + params.Add("redirect_uri", fmt.Sprintf("%s/oidc/callback", h.cfg.ServerURL)) + params.Add("scope", "openid") + params.Add("state", stateStr) + + authUrl := fmt.Sprintf("%s?%s", oidcConfig.AuthorizationEndpoint, params.Encode()) + log.Debug().Msg(authUrl) + + c.Redirect(http.StatusFound, authUrl) +} + +// OIDCCallback handles the callback from the OIDC endpoint +// Retrieves the mkey from the state cache, if the machine is not registered, presents a confirmation +// Listens in /oidc/callback +func (h *Headscale) OIDCCallback(c *gin.Context) { + + code := c.Query("code") + state := c.Query("state") + + if code == "" || state == "" { + c.String(http.StatusBadRequest, "Wrong params") + return + } + + redirectURI := fmt.Sprintf("%s/oidc/callback", h.cfg.ServerURL) + + tokens, err := h.exchangeCodeForTokens(code, redirectURI) + + if err != nil { + c.String(http.StatusBadRequest, "Could not exchange code for token") + return + } + + //verify tokens + claims, err := verifyToken(tokens.AccessToken) + + if err != nil { + c.String(http.StatusBadRequest, "invalid tokens") + return + } + + //retrieve machinekey from state cache + mKeyIf, mKeyFound := stateCache.Get(state) + + if !mKeyFound { + c.String(http.StatusBadRequest, "state has expired") + return + } + mKeyStr, mKeyOK := mKeyIf.(string) + + if !mKeyOK { + c.String(http.StatusInternalServerError, "could not get machine key from cache") + return + } + + // retrieve machine information + var m Machine + if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKeyStr); errors.Is(result.Error, gorm.ErrRecordNotFound) { + log.Error().Msg("machine key not found in database") + c.String(http.StatusInternalServerError, "could not get machine info from database") + return + } + + //look for a namespace of the users email for now + if !m.Registered { + + ns, err := h.GetNamespace(claims.Email) + if err != nil { + ns, err = h.CreateNamespace(claims.Email) + } + + ip, err := h.getAvailableIP() + if err != nil { + c.String(http.StatusInternalServerError, "could not get an IP from the pool") + return + } + + m.IPAddress = ip.String() + m.NamespaceID = ns.ID + m.Registered = true + m.RegisterMethod = "oidc" + h.db.Save(&m) + } + + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(fmt.Sprintf(` + +
++ Authenticated, you can now close this window. +
+ + + +`))) +}