package gitauth import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "golang.org/x/oauth2" "golang.org/x/xerrors" "github.com/google/go-github/v43/github" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" ) type OAuth2Config interface { AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource } // Config is used for authentication for Git operations. type Config struct { OAuth2Config // ID is a unique identifier for the authenticator. ID string // Regex is a regexp that URLs will match against. Regex *regexp.Regexp // Type is the type of provider. Type codersdk.GitProvider // NoRefresh stops Coder from using the refresh token // to renew the access token. // // Some organizations have security policies that require // re-authentication for every token. NoRefresh bool // ValidateURL ensures an access token is valid before // returning it to the user. If omitted, tokens will // not be validated before being returned. ValidateURL string // AppInstallURL is for GitHub App's (and hopefully others eventually) // to provide a link to install the app. There's installation // of the application, and user authentication. It's possible // for the user to authenticate but the application to not. AppInstallURL string // InstallationsURL is an API endpoint that returns a list of // installations for the user. This is used for GitHub Apps. AppInstallationsURL string // DeviceAuth is set if the provider uses the device flow. DeviceAuth *DeviceAuth } // RefreshToken automatically refreshes the token if expired and permitted. // It returns the token and a bool indicating if the token was refreshed. func (c *Config) RefreshToken(ctx context.Context, db database.Store, gitAuthLink database.GitAuthLink) (database.GitAuthLink, bool, error) { // If the token is expired and refresh is disabled, we prompt // the user to authenticate again. if c.NoRefresh && gitAuthLink.OAuthExpiry.Before(database.Now()) { return gitAuthLink, false, nil } token, err := c.TokenSource(ctx, &oauth2.Token{ AccessToken: gitAuthLink.OAuthAccessToken, RefreshToken: gitAuthLink.OAuthRefreshToken, Expiry: gitAuthLink.OAuthExpiry, }).Token() if err != nil { // Even if the token fails to be obtained, we still return false because // we aren't trying to surface an error, we're just trying to obtain a valid token. return gitAuthLink, false, nil } valid, _, err := c.ValidateToken(ctx, token.AccessToken) if err != nil { return gitAuthLink, false, xerrors.Errorf("validate git auth token: %w", err) } if !valid { // The token is no longer valid! return gitAuthLink, false, nil } if token.AccessToken != gitAuthLink.OAuthAccessToken { // Update it gitAuthLink, err = db.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ ProviderID: c.ID, UserID: gitAuthLink.UserID, UpdatedAt: database.Now(), OAuthAccessToken: token.AccessToken, OAuthRefreshToken: token.RefreshToken, OAuthExpiry: token.Expiry, }) if err != nil { return gitAuthLink, false, xerrors.Errorf("update git auth link: %w", err) } } return gitAuthLink, true, nil } // ValidateToken ensures the Git token provided is valid! // The user is optionally returned if the provider supports it. func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *codersdk.GitAuthUser, error) { if c.ValidateURL == "" { // Default that the token is valid if no validation URL is provided. return true, nil, nil } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ValidateURL, nil) if err != nil { return false, nil, err } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) res, err := http.DefaultClient.Do(req) if err != nil { return false, nil, err } defer res.Body.Close() if res.StatusCode == http.StatusUnauthorized { // The token is no longer valid! return false, nil, nil } if res.StatusCode != http.StatusOK { data, _ := io.ReadAll(res.Body) return false, nil, xerrors.Errorf("status %d: body: %s", res.StatusCode, data) } var user *codersdk.GitAuthUser if c.Type == codersdk.GitProviderGitHub { var ghUser github.User err = json.NewDecoder(res.Body).Decode(&ghUser) if err == nil { user = &codersdk.GitAuthUser{ Login: ghUser.GetLogin(), AvatarURL: ghUser.GetAvatarURL(), ProfileURL: ghUser.GetHTMLURL(), Name: ghUser.GetName(), } } } return true, user, nil } type AppInstallation struct { ID int // Login is the username of the installation. Login string // URL is a link to configure the app install. URL string } // AppInstallations returns a list of app installations for the given token. // If the provider does not support app installations, it returns nil. func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk.GitAuthAppInstallation, bool, error) { if c.AppInstallationsURL == "" { return nil, false, nil } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.AppInstallationsURL, nil) if err != nil { return nil, false, err } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) res, err := http.DefaultClient.Do(req) if err != nil { return nil, false, err } defer res.Body.Close() // It's possible the installation URL is misconfigured, so we don't // want to return an error here. if res.StatusCode != http.StatusOK { return nil, false, nil } installs := []codersdk.GitAuthAppInstallation{} if c.Type == codersdk.GitProviderGitHub { var ghInstalls struct { Installations []*github.Installation `json:"installations"` } err = json.NewDecoder(res.Body).Decode(&ghInstalls) if err != nil { return nil, false, err } for _, installation := range ghInstalls.Installations { account := installation.GetAccount() if account == nil { continue } installs = append(installs, codersdk.GitAuthAppInstallation{ ID: int(installation.GetID()), ConfigureURL: installation.GetHTMLURL(), Account: codersdk.GitAuthUser{ Login: account.GetLogin(), AvatarURL: account.GetAvatarURL(), ProfileURL: account.GetHTMLURL(), Name: account.GetName(), }, }) } } return installs, true, nil } // ConvertConfig converts the SDK configuration entry format // to the parsed and ready-to-consume in coderd provider type. func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Config, error) { ids := map[string]struct{}{} configs := []*Config{} for _, entry := range entries { var typ codersdk.GitProvider switch codersdk.GitProvider(entry.Type) { case codersdk.GitProviderAzureDevops: typ = codersdk.GitProviderAzureDevops case codersdk.GitProviderBitBucket: typ = codersdk.GitProviderBitBucket case codersdk.GitProviderGitHub: typ = codersdk.GitProviderGitHub case codersdk.GitProviderGitLab: typ = codersdk.GitProviderGitLab default: return nil, xerrors.Errorf("unknown git provider type: %q", entry.Type) } if entry.ID == "" { // Default to the type. entry.ID = string(typ) } if valid := httpapi.NameValid(entry.ID); valid != nil { return nil, xerrors.Errorf("git auth provider %q doesn't have a valid id: %w", entry.ID, valid) } _, exists := ids[entry.ID] if exists { if entry.ID == string(typ) { return nil, xerrors.Errorf("multiple %s git auth providers provided. you must specify a unique id for each", typ) } return nil, xerrors.Errorf("multiple git providers exist with the id %q. specify a unique id for each", entry.ID) } ids[entry.ID] = struct{}{} if entry.ClientID == "" { return nil, xerrors.Errorf("%q git auth provider: client_id must be provided", entry.ID) } authRedirect, err := accessURL.Parse(fmt.Sprintf("/gitauth/%s/callback", entry.ID)) if err != nil { return nil, xerrors.Errorf("parse gitauth callback url: %w", err) } regex := regex[typ] if entry.Regex != "" { regex, err = regexp.Compile(entry.Regex) if err != nil { return nil, xerrors.Errorf("compile regex for git auth provider %q: %w", entry.ID, entry.Regex) } } oc := &oauth2.Config{ ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, Endpoint: endpoint[typ], RedirectURL: authRedirect.String(), Scopes: scope[typ], } if entry.AuthURL != "" { oc.Endpoint.AuthURL = entry.AuthURL } if entry.TokenURL != "" { oc.Endpoint.TokenURL = entry.TokenURL } if entry.Scopes != nil && len(entry.Scopes) > 0 { oc.Scopes = entry.Scopes } if entry.ValidateURL == "" { entry.ValidateURL = validateURL[typ] } if entry.AppInstallationsURL == "" { entry.AppInstallationsURL = appInstallationsURL[typ] } var oauthConfig OAuth2Config = oc // Azure DevOps uses JWT token authentication! if typ == codersdk.GitProviderAzureDevops { oauthConfig = &jwtConfig{oc} } cfg := &Config{ OAuth2Config: oauthConfig, ID: entry.ID, Regex: regex, Type: typ, NoRefresh: entry.NoRefresh, ValidateURL: entry.ValidateURL, AppInstallationsURL: entry.AppInstallationsURL, AppInstallURL: entry.AppInstallURL, } if entry.DeviceFlow { if entry.DeviceCodeURL == "" { entry.DeviceCodeURL = deviceAuthURL[typ] } if entry.DeviceCodeURL == "" { return nil, xerrors.Errorf("git auth provider %q: device auth url must be provided", entry.ID) } cfg.DeviceAuth = &DeviceAuth{ ClientID: entry.ClientID, TokenURL: oc.Endpoint.TokenURL, Scopes: entry.Scopes, CodeURL: entry.DeviceCodeURL, } } configs = append(configs, cfg) } return configs, nil }