Files
zrok/endpoints/publicProxy/providerGithub.go

270 lines
8.9 KiB
Go

package publicProxy
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/openziti/zrok/v2/endpoints"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/michaelquigley/df/dd"
"github.com/michaelquigley/df/dl"
"github.com/openziti/zrok/v2/endpoints/proxyUi"
"github.com/pkg/errors"
"github.com/zitadel/oidc/v3/pkg/client/rp"
zhttp "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/oauth2"
githubOAuth "golang.org/x/oauth2/github"
)
type githubConfig struct {
Name string
ClientId string
ClientSecret string
}
func newGithubConfig(v map[string]interface{}) (dd.Dynamic, error) {
return dd.New[githubConfig](v)
}
func (c *githubConfig) Type() string { return "github" }
func (c *githubConfig) ToMap() (map[string]any, error) { return nil, nil }
func (c *githubConfig) configure(cfg *OauthConfig, tls bool) error {
scheme := "http"
if tls {
scheme = "https"
}
signingKey, err := endpoints.DeriveKey(cfg.SigningKey, 32)
if err != nil {
return err
}
encryptionKey, err := endpoints.DeriveKey(cfg.EncryptionKey, 32)
if err != nil {
return err
}
cookieHandler := zhttp.NewCookieHandler(signingKey, encryptionKey, zhttp.WithUnsecure(), zhttp.WithDomain(cfg.CookieDomain))
rpConfig := &oauth2.Config{
ClientID: c.ClientId,
ClientSecret: c.ClientSecret,
RedirectURL: fmt.Sprintf("%v/%v/auth/callback", cfg.EndpointUrl, c.Name),
Scopes: []string{"user:email"},
Endpoint: githubOAuth.Endpoint,
}
providerOptions := []rp.Option{
rp.WithCookieHandler(cookieHandler),
rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
}
provider, err := rp.NewRelyingPartyOAuth(rpConfig, providerOptions...)
if err != nil {
return err
}
type githubUserResp struct {
Email string
Primary bool
Verified bool
Visibility string
}
auth := func(provider rp.RelyingParty) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
targetHost, err := url.QueryUnescape(r.URL.Query().Get("targetHost"))
if err != nil {
err := fmt.Errorf("unable to unescape targetHost: %v", err)
dl.Error(err)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(err))
return
}
rp.AuthURLHandler(func() string {
id := uuid.New().String()
t := jwt.NewWithClaims(jwt.SigningMethodHS256, IntermediateJWT{
State: id,
TargetHost: targetHost,
RefreshInterval: r.URL.Query().Get("refreshInterval"),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.IntermediateLifetime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "zrok",
Subject: "intermediate_token",
ID: id,
},
})
s, err := t.SignedString(signingKey)
if err != nil {
dl.Errorf("unable to sign intermediate JWT: %v", err)
}
return s
}, provider, rp.WithURLParam("access_type", "offline"), rp.URLParamOpt(rp.WithPrompt("login")))(w, r)
}
}
http.Handle(fmt.Sprintf("/%v/login", c.Name), auth(provider))
login := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty) {
token, err := jwt.ParseWithClaims(state, &IntermediateJWT{}, func(t *jwt.Token) (interface{}, error) {
return signingKey, nil
})
if err != nil {
errOut := errors.Wrap(err, "error parsing intermediate token")
dl.Error(errOut)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(errOut))
return
}
var refreshInterval time.Duration
if v, err := time.ParseDuration(token.Claims.(*IntermediateJWT).RefreshInterval); err == nil {
refreshInterval = v
} else {
errOut := errors.New("unable to parse authorization check interval")
dl.Errorf("unable to parse authorization check interval '%v': %v", token.Claims.(*IntermediateJWT).RefreshInterval, err)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(errOut))
return
}
parsedUrl, err := url.Parse("https://api.github.com/user/emails")
if err != nil {
errOut := errors.Wrap(err, "error parsing github url")
dl.Error(errOut)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(errOut))
return
}
req := &http.Request{
Method: http.MethodGet,
URL: parsedUrl,
Header: make(http.Header),
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tokens.AccessToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
errOut := errors.Wrap(err, "error getting user info from github")
dl.Error(errOut)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(errOut))
return
}
defer func() {
_ = resp.Body.Close()
}()
response, err := io.ReadAll(resp.Body)
if err != nil {
errOut := errors.Wrap(err, "error reading response body from github")
dl.Error(errOut)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(errOut))
return
}
var rDat []githubUserResp
err = json.Unmarshal(response, &rDat)
if err != nil {
errOut := errors.Wrap(err, "error unmarshalling response from github")
dl.Error(errOut)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(errOut))
return
}
primaryEmail := ""
for _, email := range rDat {
if email.Primary {
primaryEmail = email.Email
break
}
}
setSessionCookie(w, sessionCookieRequest{
oauthCfg: cfg,
supportsRefresh: false,
email: primaryEmail,
accessToken: tokens.AccessToken,
provider: c.Name,
refreshInterval: refreshInterval,
signingKey: signingKey,
encryptionKey: encryptionKey,
targetHost: token.Claims.(*IntermediateJWT).TargetHost,
})
http.Redirect(w, r, fmt.Sprintf("%s://%s", scheme, token.Claims.(*IntermediateJWT).TargetHost), http.StatusFound)
}
http.Handle(fmt.Sprintf("/%v/auth/callback", c.Name), rp.CodeExchangeHandler(login, provider))
logout := func(w http.ResponseWriter, r *http.Request) {
cookie, err := getSessionCookie(r, cfg)
if err == nil {
tkn, err := jwt.ParseWithClaims(cookie.Value, &zrokClaims{}, func(t *jwt.Token) (interface{}, error) {
return signingKey, nil
})
if err == nil {
claims := tkn.Claims.(*zrokClaims)
if claims.Provider == c.Name {
accessToken, err := endpoints.DecryptToken(claims.AccessToken, encryptionKey)
if err == nil {
req, err := http.NewRequest("DELETE",
fmt.Sprintf("https://api.github.com/applications/%s/token", c.ClientId),
strings.NewReader(fmt.Sprintf(`{"access_token":"%s"}`, accessToken)))
if err != nil {
dl.Errorf("error creating access token delete request for '%v': %v", claims.Email, err)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedUser(claims.Email).WithError(errors.New("error creating access token delete request")))
return
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(c.ClientId, c.ClientSecret) // Need client credentials
resp, err := http.DefaultClient.Do(req)
if err != nil {
dl.Errorf("error invoking access token delete request for '%v': %v", claims.Email, err)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedUser(claims.Email).WithError(errors.New("error executing access token delete request")))
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
dl.Infof("revoked github access token for '%v'", claims.Email)
} else {
dl.Errorf("access token revocation failed with status: %v", resp.StatusCode)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedUser(claims.Email).WithError(errors.New("access token revocation failed")))
return
}
} else {
dl.Errorf("unable to decrypt access token for '%v': %v", claims.Email, err)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedUser(claims.Email).WithError(errors.New("unable to decrypt access token")))
return
}
} else {
dl.Errorf("expected provider name '%v' got '%v'", c.Name, claims.Provider)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedUser(claims.Email).WithError(errors.New("provider name mismatch")))
return
}
} else {
dl.Errorf("invalid jwt; unable to parse: %v", err)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(errors.New("invalid jwt; unable to parse")))
return
}
} else {
dl.Errorf("error getting cookie '%v': %v", cfg.CookieName, err)
proxyUi.WriteUnauthorized(w, proxyUi.UnauthorizedData().WithError(errors.New("invalid cookie")))
return
}
clearSessionCookies(w, r, cfg.CookieName, cfg)
redirectURL := r.URL.Query().Get("redirect_url")
if redirectURL == "" {
redirectURL = fmt.Sprintf("%s/%s/login", cfg.EndpointUrl, c.Name)
}
http.Redirect(w, r, redirectURL, http.StatusFound)
}
http.HandleFunc(fmt.Sprintf("/%v/logout", c.Name), logout)
dl.Infof("configured github provider at '/%v", c.Name)
return nil
}