use go-oidc instead of verifying and extracting tokens ourselves, rename oidc_endpoint to oidc_issuer to be more inline with spec
This commit is contained in:
parent
0393ab524c
commit
c487591437
7 changed files with 69 additions and 185 deletions
222
oidc.go
222
oidc.go
|
@ -1,186 +1,37 @@
|
|||
package headscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"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"
|
||||
"golang.org/x/oauth2"
|
||||
"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
|
||||
type IDTokenClaims struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"preferred_username,omitempty"`
|
||||
}
|
||||
|
||||
var oidcConfig *OpenIDConfiguration
|
||||
var oidcProvider *oidc.Provider
|
||||
var oauth2Config *oauth2.Config
|
||||
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, errors.New("JWT does not contain a key id")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
mKeyStr := c.Param("mkey")
|
||||
if mKeyStr == "" {
|
||||
c.String(http.StatusBadRequest, "Wrong params")
|
||||
return
|
||||
|
@ -189,13 +40,23 @@ func (h *Headscale) RegisterOIDC(c *gin.Context) {
|
|||
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 oauth2Config == nil {
|
||||
oidcProvider, err = oidc.NewProvider(context.Background(), h.cfg.OIDCIssuer)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Msgf("Could not retrieve OIDC Config: %s", err.Error())
|
||||
c.String(http.StatusInternalServerError, "Could not retrieve OIDC Config")
|
||||
return
|
||||
}
|
||||
|
||||
oauth2Config = &oauth2.Config{
|
||||
ClientID: h.cfg.OIDCClientID,
|
||||
ClientSecret: h.cfg.OIDCClientSecret,
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
RedirectURL: fmt.Sprintf("%s/oidc/callback", h.cfg.ServerURL),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
b := make([]byte, 16)
|
||||
|
@ -217,21 +78,16 @@ func (h *Headscale) RegisterOIDC(c *gin.Context) {
|
|||
// 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)
|
||||
authUrl := oauth2Config.AuthCodeURL(stateStr)
|
||||
log.Debug().Msgf("Redirecting to %s for authentication", 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
|
||||
// Retrieves the mkey from the state cache and adds the machine to the users email namespace
|
||||
// TODO: A confirmation page for new machines should be added to avoid phishing vulnerabilities
|
||||
// TODO: Add groups information from OIDC tokens into machine HostInfo
|
||||
// Listens in /oidc/callback
|
||||
func (h *Headscale) OIDCCallback(c *gin.Context) {
|
||||
|
||||
|
@ -243,20 +99,36 @@ func (h *Headscale) OIDCCallback(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
redirectURI := fmt.Sprintf("%s/oidc/callback", h.cfg.ServerURL)
|
||||
|
||||
tokens, err := h.exchangeCodeForTokens(code, redirectURI)
|
||||
|
||||
oauth2Token, err := oauth2Config.Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Could not exchange code for token")
|
||||
return
|
||||
}
|
||||
|
||||
//verify tokens
|
||||
claims, err := verifyToken(tokens.AccessToken)
|
||||
rawIDToken, rawIDTokenOK := oauth2Token.Extra("id_token").(string)
|
||||
if !rawIDTokenOK {
|
||||
c.String(http.StatusBadRequest, "Could not extract ID Token")
|
||||
return
|
||||
}
|
||||
|
||||
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: h.cfg.OIDCClientID})
|
||||
|
||||
idToken, err := verifier.Verify(context.Background(), rawIDToken)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "invalid tokens")
|
||||
c.String(http.StatusBadRequest, "Failed to verify id token: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
//userInfo, err := oidcProvider.UserInfo(context.Background(), oauth2.StaticTokenSource(oauth2Token))
|
||||
//if err != nil {
|
||||
// c.String(http.StatusBadRequest, "Failed to retrieve userinfo: "+err.Error())
|
||||
// return
|
||||
//}
|
||||
|
||||
// Extract custom claims
|
||||
var claims IDTokenClaims
|
||||
if err = idToken.Claims(&claims); err != nil {
|
||||
c.String(http.StatusBadRequest, "Failed to decode id token claims: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue