// Package gitea provides authentication strategies using Gitea. package gitea import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "sync" "time" "golang.org/x/oauth2" "github.com/dexidp/dex/connector" "github.com/dexidp/dex/pkg/log" ) // Config holds configuration options for gitea logins. type Config struct { BaseURL string `json:"baseURL"` ClientID string `json:"clientID"` ClientSecret string `json:"clientSecret"` RedirectURI string `json:"redirectURI"` Orgs []Org `json:"orgs"` LoadAllGroups bool `json:"loadAllGroups"` UseLoginAsID bool `json:"useLoginAsID"` } // Org holds org-team filters, in which teams are optional. type Org struct { // Organization name in gitea (not slug, full name). Only users in this gitea // organization can authenticate. Name string `json:"name"` // Names of teams in a gitea organization. A user will be able to // authenticate if they are members of at least one of these teams. Users // in the organization can authenticate if this field is omitted from the // config file. Teams []string `json:"teams,omitempty"` } type giteaUser struct { ID int `json:"id"` Name string `json:"full_name"` Username string `json:"login"` Email string `json:"email"` IsAdmin bool `json:"is_admin"` } // Open returns a strategy for logging in through Gitea func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { if c.BaseURL == "" { c.BaseURL = "https://gitea.com" } return &giteaConnector{ baseURL: c.BaseURL, redirectURI: c.RedirectURI, orgs: c.Orgs, clientID: c.ClientID, clientSecret: c.ClientSecret, logger: logger, loadAllGroups: c.LoadAllGroups, useLoginAsID: c.UseLoginAsID, }, nil } type connectorData struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` Expiry time.Time `json:"expiry"` } var ( _ connector.CallbackConnector = (*giteaConnector)(nil) _ connector.RefreshConnector = (*giteaConnector)(nil) ) type giteaConnector struct { baseURL string redirectURI string orgs []Org clientID string clientSecret string logger log.Logger httpClient *http.Client // if set to true and no orgs are configured then connector loads all user claims (all orgs and team) loadAllGroups bool // if set to true will use the user's handle rather than their numeric id as the ID useLoginAsID bool } func (c *giteaConnector) oauth2Config(_ connector.Scopes) *oauth2.Config { giteaEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/login/oauth/authorize", TokenURL: c.baseURL + "/login/oauth/access_token"} return &oauth2.Config{ ClientID: c.clientID, ClientSecret: c.clientSecret, Endpoint: giteaEndpoint, RedirectURL: c.redirectURI, } } func (c *giteaConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { if c.redirectURI != callbackURL { return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL) } return c.oauth2Config(scopes).AuthCodeURL(state), nil } type oauth2Error struct { error string errorDescription string } func (e *oauth2Error) Error() string { if e.errorDescription == "" { return e.error } return e.error + ": " + e.errorDescription } func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} } oauth2Config := c.oauth2Config(s) ctx := r.Context() if c.httpClient != nil { ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) } token, err := oauth2Config.Exchange(ctx, q.Get("code")) if err != nil { return identity, fmt.Errorf("gitea: failed to get token: %v", err) } client := oauth2Config.Client(ctx, token) user, err := c.user(ctx, client) if err != nil { return identity, fmt.Errorf("gitea: get user: %v", err) } username := user.Name if username == "" { username = user.Email } identity = connector.Identity{ UserID: strconv.Itoa(user.ID), Username: username, PreferredUsername: user.Username, Email: user.Email, EmailVerified: true, } if c.useLoginAsID { identity.UserID = user.Username } // Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified. if c.groupsRequired() { groups, err := c.getGroups(ctx, client) if err != nil { return identity, err } identity.Groups = groups } if s.OfflineAccess { data := connectorData{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, Expiry: token.Expiry, } connData, err := json.Marshal(data) if err != nil { return identity, fmt.Errorf("gitea: marshal connector data: %v", err) } identity.ConnectorData = connData } return identity, nil } // Refreshing tokens // https://github.com/golang/oauth2/issues/84#issuecomment-332860871 type tokenNotifyFunc func(*oauth2.Token) error // notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added. type notifyRefreshTokenSource struct { new oauth2.TokenSource mu sync.Mutex // guards t t *oauth2.Token f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted } // Token returns the current token if it's still valid, else will // refresh the current token (using r.Context for HTTP client // information) and return the new one. func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) { s.mu.Lock() defer s.mu.Unlock() if s.t.Valid() { return s.t, nil } t, err := s.new.Token() if err != nil { return nil, err } s.t = t return t, s.f(t) } func (c *giteaConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { if len(ident.ConnectorData) == 0 { return ident, errors.New("gitea: no upstream access token found") } var data connectorData if err := json.Unmarshal(ident.ConnectorData, &data); err != nil { return ident, fmt.Errorf("gitea: unmarshal access token: %v", err) } tok := &oauth2.Token{ AccessToken: data.AccessToken, RefreshToken: data.RefreshToken, Expiry: data.Expiry, } client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{ new: c.oauth2Config(s).TokenSource(ctx, tok), t: tok, f: func(tok *oauth2.Token) error { data := connectorData{ AccessToken: tok.AccessToken, RefreshToken: tok.RefreshToken, Expiry: tok.Expiry, } connData, err := json.Marshal(data) if err != nil { return fmt.Errorf("gitea: marshal connector data: %v", err) } ident.ConnectorData = connData return nil }, }) user, err := c.user(ctx, client) if err != nil { return ident, fmt.Errorf("gitea: get user: %v", err) } username := user.Name if username == "" { username = user.Email } ident.Username = username ident.PreferredUsername = user.Username ident.Email = user.Email // Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified. if c.groupsRequired() { groups, err := c.getGroups(ctx, client) if err != nil { return ident, err } ident.Groups = groups } return ident, nil } // getGroups retrieves Gitea orgs and teams a user is in, if any. func (c *giteaConnector) getGroups(ctx context.Context, client *http.Client) ([]string, error) { if len(c.orgs) > 0 { return c.groupsForOrgs(ctx, client) } else if c.loadAllGroups { return c.userGroups(ctx, client) } return nil, nil } // formatTeamName returns unique team name. // Orgs might have the same team names. To make team name unique it should be prefixed with the org name. func formatTeamName(org string, team string) string { return fmt.Sprintf("%s:%s", org, team) } // groupsForOrgs returns list of groups that user belongs to in approved list func (c *giteaConnector) groupsForOrgs(ctx context.Context, client *http.Client) ([]string, error) { groups, err := c.userGroups(ctx, client) if err != nil { return groups, err } keys := make(map[string]bool) for _, o := range c.orgs { keys[o.Name] = true if o.Teams != nil { for _, t := range o.Teams { keys[formatTeamName(o.Name, t)] = true } } } atLeastOne := false filteredGroups := make([]string, 0) for _, g := range groups { if _, value := keys[g]; value { filteredGroups = append(filteredGroups, g) atLeastOne = true } } if !atLeastOne { return []string{}, fmt.Errorf("gitea: User does not belong to any of the approved groups") } return filteredGroups, nil } type organization struct { ID int64 `json:"id"` Name string `json:"username"` } type team struct { ID int64 `json:"id"` Name string `json:"name"` Organization *organization `json:"organization"` } func (c *giteaConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) { apiURL := c.baseURL + "/api/v1/user/teams" groups := make([]string, 0) page := 1 limit := 20 for { var teams []team req, err := http.NewRequest("GET", fmt.Sprintf("%s?page=%d&limit=%d", apiURL, page, limit), nil) if err != nil { return groups, fmt.Errorf("gitea: new req: %v", err) } req = req.WithContext(ctx) resp, err := client.Do(req) if err != nil { return groups, fmt.Errorf("gitea: get URL %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return groups, fmt.Errorf("gitea: read body: %v", err) } return groups, fmt.Errorf("%s: %s", resp.Status, body) } if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil { return groups, fmt.Errorf("failed to decode response: %v", err) } if len(teams) == 0 { break } for _, t := range teams { groups = append(groups, t.Organization.Name) groups = append(groups, formatTeamName(t.Organization.Name, t.Name)) } page++ } // remove duplicate slice variables keys := make(map[string]struct{}) list := []string{} for _, group := range groups { if _, exists := keys[group]; !exists { keys[group] = struct{}{} list = append(list, group) } } groups = list return groups, nil } // user queries the Gitea API for profile information using the provided client. The HTTP // client is expected to be constructed by the golang.org/x/oauth2 package, which inserts // a bearer token as part of the request. func (c *giteaConnector) user(ctx context.Context, client *http.Client) (giteaUser, error) { var u giteaUser req, err := http.NewRequest("GET", c.baseURL+"/api/v1/user", nil) if err != nil { return u, fmt.Errorf("gitea: new req: %v", err) } req = req.WithContext(ctx) resp, err := client.Do(req) if err != nil { return u, fmt.Errorf("gitea: get URL %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return u, fmt.Errorf("gitea: read body: %v", err) } return u, fmt.Errorf("%s: %s", resp.Status, body) } if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { return u, fmt.Errorf("failed to decode response: %v", err) } return u, nil } // groupsRequired returns whether dex needs to request groups from Gitea. func (c *giteaConnector) groupsRequired() bool { return len(c.orgs) > 0 || c.loadAllGroups }