// Copyright 2022 Molecula Corp. (DBA FeatureBase). // SPDX-License-Identifier: Apache-2.0 // Package authn handles authentication package authn import ( "context" "encoding/hex" "encoding/json" "fmt" "net" "net/http" "net/url" "strconv" "strings" "time" "github.com/golang-jwt/jwt" "google.golang.org/grpc" "google.golang.org/grpc/metadata" "github.com/featurebasedb/featurebase/v3/logger" "github.com/pkg/errors" "golang.org/x/oauth2" ) // AuthContextKey is a unique type to prevent collisions when using context.WithValue() type AuthContextKey string const ( // AccessCookieName is the name of the cookie that holds the access token. AccessCookieName = "molecula-chip" // RefreshCookieName is the name of the cookie that holds the refresh token. RefreshCookieName = "refresh-molecula-chip" // RefreshHeaderName is the name of the header that holds the refresh token. RefreshHeaderName = "X-Molecula-Refresh-Token" // ContextValueAccessToken is the key used to set AccessTokens in a ctx. ContextValueAccessToken = AuthContextKey("Access") // ContextValueRefreshToken is the key used to set RefreshTokens in a ctx. ContextValueRefreshToken = AuthContextKey("Refresh") ) // cachedGroups is used to hold groups and when they were last cached type cachedGroups struct { cacheTime time.Time groups []Group } // UserInfo holds the information about the user from the token type UserInfo struct { UserID string `json:"userid"` UserName string `json:"username"` Groups []Group `json:"groups"` Expiry time.Time `json:"expiry"` Token string `json:"token"` RefreshToken string `json:"refreshtoken"` } // Group holds group information for an authenticated user type Group struct { GroupID string `json:"id"` GroupName string `json:"displayName"` } // Groups holds a slice of Group for marshalling from JSON type Groups struct { NextLink string `json:"@odata.nextLink"` Groups []Group `json:"value"` } // Auth holds state, configuration, and utilities needed for authentication. type Auth struct { logger logger.Logger accessCookieName string refreshCookieName string secretKey []byte groupEndpoint string logoutEndpoint string fbURL string // fbURL is the domain featurebase is hosted on, used for post logout redirection oAuthConfig *oauth2.Config cacheTTL time.Duration // cacheTTL is used to determine if a cached item should be refreshed or not groupsCache map[string]cachedGroups // groupsCache is a map of accessToken -> group memberships lastCacheClean time.Time // last cache clean is the time that the cache was last cleaned allowedNetworks []net.IPNet // list of allowed networks for ingest } // NewAuth instantiates and returns a new Auth struct func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenURL, groupEndpoint, logout, clientID, clientSecret, secretKey string, configuredIPs []string) (auth *Auth, err error) { auth = &Auth{ logger: logger, accessCookieName: AccessCookieName, refreshCookieName: RefreshCookieName, groupEndpoint: groupEndpoint, logoutEndpoint: logout, fbURL: url, oAuthConfig: &oauth2.Config{ RedirectURL: fmt.Sprintf("%s/redirect", url), ClientID: clientID, ClientSecret: clientSecret, Scopes: scopes, Endpoint: oauth2.Endpoint{ AuthURL: authURL, TokenURL: tokenURL, }, }, groupsCache: map[string]cachedGroups{}, cacheTTL: 10 * time.Minute, lastCacheClean: time.Now(), } if auth.secretKey, err = decodeHex(secretKey); err != nil { return nil, errors.Wrap(err, "decoding secret key") } // convert IPs and add them to allowed networks err = auth.convertIP(configuredIPs) if err != nil { return nil, err } return auth, nil } // CleanOAuthConfig returns a's oauthConfig without the client secret func (a Auth) CleanOAuthConfig() oauth2.Config { b := *a.oAuthConfig b.ClientSecret = "" return b } // SecretKey is a convenient function to get the SecretKey from an Auth struct func (a Auth) SecretKey() []byte { return a.secretKey } // refreshToken refreshes a given access/refresh token pair func (a *Auth) refreshToken(access, refresh string) (string, string, error) { resp, err := http.PostForm(a.oAuthConfig.Endpoint.TokenURL, url.Values{ "grant_type": {"refresh_token"}, "refresh_token": {refresh}, "client_id": {a.oAuthConfig.ClientID}, "client_secret": {a.oAuthConfig.ClientSecret}, }, ) if err != nil { return "", "", errors.Wrap(err, "refreshing token") } if resp.StatusCode != http.StatusOK { return "", "", fmt.Errorf("refreshing token: %s", resp.Status) } defer resp.Body.Close() var t oauth2.Token if err := json.NewDecoder(resp.Body).Decode(&t); err != nil { return "", "", errors.Wrap(err, "decoding refreshed token") } // remove the old groups from the groups cache delete(a.groupsCache, access) return t.AccessToken, t.RefreshToken, nil } // Authenticate takes in a auth token `access` and returns UserInfo from that token // it is caller's responsibility to inform the user that the access token has been refreshed func (a *Auth) Authenticate(access, refresh string) (*UserInfo, error) { // clean up the cache every 30 minutes or so if time.Since(a.lastCacheClean) >= 30*time.Minute { a.cleanCache() } if len(access) == 0 { return nil, fmt.Errorf("auth token is empty") } // NOTE: we are using ParseUnverified here because the IDP validates the // token's signature when we get the user's groups, we just need to make // sure it's not expired and is well-formed token, _, err := new(jwt.Parser).ParseUnverified(access, &jwt.MapClaims{}) // well-formed-ness check if token == nil || token.Claims == nil || err != nil { return nil, fmt.Errorf("parsing auth token: %v", err) } claims := *token.Claims.(*jwt.MapClaims) // expiry check if exp, ok := claims["exp"]; ok { var expiry int64 switch v := exp.(type) { case string: expiry, err = strconv.ParseInt(v, 10, 64) if err != nil { return nil, fmt.Errorf("parsing exp string: %v", err) } case float64: expiry = int64(v) case int64: expiry = v } if expiry < time.Now().UTC().Unix() { access, refresh, err = a.refreshToken(access, refresh) if err != nil { return nil, fmt.Errorf("token is expired: %w", err) } } } userInfo := UserInfo{ Token: access, RefreshToken: refresh, Groups: []Group{}, } if uid, ok := claims["oid"].(string); ok { userInfo.UserID = uid } if name, ok := claims["name"].(string); ok { userInfo.UserName = name } if userInfo.Groups, err = a.getGroups(access); err != nil { return nil, errors.Wrap(err, "getting groups") } return &userInfo, nil } // cleanCache removes old items from our cache func (a *Auth) cleanCache() { for access, tkn := range a.groupsCache { // if it's been more than 24 hours since the groups were cached if time.Since(tkn.cacheTime) >= 24*time.Hour { // remove it from our cache delete(a.groupsCache, access) } } a.lastCacheClean = time.Now() } // Login redirects a user to login to their configured oAuth authorize endpoint func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { authURL := a.oAuthConfig.AuthCodeURL(a.oAuthConfig.Endpoint.AuthURL) http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) } // Logout clears out the user's cookie, removes the token from our cache, and // redirects user to IdP's logout endpoint func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) { // remove the access token from a.groupsCache if access, err := r.Cookie(a.accessCookieName); err == nil { delete(a.groupsCache, access.Value) } // clear cookie http.SetCookie(w, &http.Cookie{ Name: a.accessCookieName, Value: "", Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: time.Unix(0, 0), }) http.SetCookie(w, &http.Cookie{ Name: a.refreshCookieName, Value: "", Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: time.Unix(0, 0), }) http.Redirect(w, r, fmt.Sprintf("%s?post_logout_redirect_uri=%s/", a.logoutEndpoint, a.fbURL), http.StatusTemporaryRedirect) } // Redirect handles the oAuth /redirect endpoint. It gets an access token and // returns it to the user in the form of a cookie func (a *Auth) Redirect(w http.ResponseWriter, r *http.Request) { token, err := a.oAuthConfig.Exchange(r.Context(), r.FormValue("code"), oauth2.AccessTypeOffline) if err != nil { a.logger.Warnf("getting token from IdP: %+v", err) http.Error(w, "Bad Request", http.StatusBadRequest) return } a.SetCookie(w, token.AccessToken, token.RefreshToken, token.Expiry) http.Redirect(w, r, "/", http.StatusTemporaryRedirect) } // getGroups gets the group membership for a given token from configured IdP func (a *Auth) getGroups(token string) ([]Group, error) { var groups Groups gc, ok := a.groupsCache[token] if ok && (time.Since(gc.cacheTime) < a.cacheTTL) && len(gc.groups) > 0 { return gc.groups, nil } nextLink := a.groupEndpoint for nextLink != "" { req, err := http.NewRequest("GET", nextLink, nil) if err != nil { return nil, errors.Wrap(err, "creating new request to group endpoint") } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) response, err := http.DefaultClient.Do(req) if err != nil { return nil, errors.Wrap(err, "getting group membership info") } if response.StatusCode != http.StatusOK { return nil, fmt.Errorf("getting group membership info: %s", response.Status) } var g Groups if err = json.NewDecoder(response.Body).Decode(&g); err != nil { return groups.Groups, errors.Wrap(err, "failed unmarshalling group membership response") } response.Body.Close() groups.Groups = append(groups.Groups, g.Groups...) nextLink = g.NextLink } if len(groups.Groups) == 0 { return nil, fmt.Errorf("no groups found") } a.groupsCache[token] = cachedGroups{ cacheTime: time.Now(), groups: groups.Groups, } return groups.Groups, nil } func (a *Auth) SetCookie(w http.ResponseWriter, access, refresh string, expiry time.Time) error { http.SetCookie(w, &http.Cookie{ Name: a.refreshCookieName, Value: refresh, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: expiry, }) http.SetCookie(w, &http.Cookie{ Name: a.accessCookieName, Value: access, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: expiry, }) return nil } func (a *Auth) SetGRPCMetadata(ctx context.Context, md metadata.MD, access, refresh string) (context.Context, error) { mCookies := map[string]string{} if c, ok := md["cookie"]; ok { for _, cookie := range c { name, val := parseCookie(cookie) mCookies[name] = val } } mCookies[a.accessCookieName] = access mCookies[a.refreshCookieName] = refresh cookies := []string{} for name, val := range mCookies { cookies = append(cookies, name+"="+val) } md["cookie"] = cookies return metadata.NewIncomingContext(ctx, md), grpc.SetHeader(ctx, md) } func decodeHex(hexstr string) ([]byte, error) { data, err := hex.DecodeString(hexstr) if err != nil { return nil, errors.Wrap(err, "decoding hex string to byte slice") } if len(data) != 32 { return nil, fmt.Errorf("invalid key length") } return data, nil } func (a *Auth) convertIP(configuredIPs []string) error { sz := len(configuredIPs) nets := make([]net.IPNet, sz) for i, ip := range configuredIPs { // skip empty strings if ip == "" { sz-- continue } // for IPs passed without a subnet, append /32 to only allow 1 IP // this step is needed because ParseCIDR method assumes a CIDR address if !strings.Contains(ip, "/") { ip = ip + "/32" } _, subnet, err := net.ParseCIDR(ip) if err != nil { return errors.Wrapf(err, "parsing CIDR for %v", ip) } nets[i] = *subnet } a.allowedNetworks = nets[:sz] return nil } // if IP is in allowed networks, then return true to grant admin permissions func (a *Auth) CheckAllowedNetworks(clientIP string) bool { clientIP = strings.Split(clientIP, ":")[0] convertedIP := net.ParseIP(clientIP) for _, network := range a.allowedNetworks { if network.Contains(convertedIP) { return true } } return false } func parseCookie(cookie string) (name, data string) { vals := strings.Split(cookie, "=") if len(vals) == 0 { vals = []string{"", ""} } else if len(vals) < 2 { vals = append(vals, "") } return vals[0], vals[1] }