diff --git a/internal/gitsources/gitea/gitea.go b/internal/gitsources/gitea/gitea.go new file mode 100644 index 0000000..f55cb31 --- /dev/null +++ b/internal/gitsources/gitea/gitea.go @@ -0,0 +1,218 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitea + +import ( + "crypto/tls" + "net/http" + "net/url" + "strconv" + + gitsource "github.com/sorintlab/agola/internal/gitsources" + + "code.gitea.io/sdk/gitea" + "github.com/pkg/errors" +) + +const ( + // TODO(sgotti) The gitea client doesn't provide an easy way to detect http response codes... + // we should probably use our own client implementation + + ClientNotFound = "404 Not Found" +) + +type Opts struct { + URL string + Token string + SkipVerify bool +} + +type Client struct { + client *gitea.Client +} + +// fromCommitStatus converts a gitsource commit status to a gitea commit status +func fromCommitStatus(status gitsource.CommitStatus) gitea.StatusState { + switch status { + case gitsource.CommitStatusPending: + return gitea.StatusPending + case gitsource.CommitStatusSuccess: + return gitea.StatusSuccess + case gitsource.CommitStatusFailed: + return gitea.StatusFailure + default: + return gitea.StatusError + } +} + +func New(opts Opts) (*Client, error) { + client := gitea.NewClient(opts.URL, opts.Token) + if opts.SkipVerify { + httpClient := &http.Client{} + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client.SetHTTPClient(httpClient) + } + return &Client{ + client: client, + }, nil +} + +func (c *Client) LoginPassword(username, password string) (string, error) { + // try to get agola access token if it already exists + var accessToken string + tokens, err := c.client.ListAccessTokens(username, password) + if err == nil { + for _, token := range tokens { + if token.Name == "agola" { + accessToken = token.Sha1 + break + } + } + } + + // create access token + if accessToken == "" { + token, terr := c.client.CreateAccessToken( + username, + password, + gitea.CreateAccessTokenOption{Name: "agola"}, + ) + if terr != nil { + return "", terr + } + accessToken = token.Sha1 + } + + return accessToken, nil +} + +func (c *Client) GetUserInfo() (*gitsource.UserInfo, error) { + user, err := c.client.GetMyUserInfo() + if err != nil { + return nil, err + } + return &gitsource.UserInfo{ + ID: strconv.FormatInt(user.ID, 10), + LoginName: user.UserName, + Email: user.Email, + }, nil +} + +func (c *Client) GetFile(owner, repo, commit, file string) ([]byte, error) { + data, err := c.client.GetFile(owner, repo, commit, file) + return data, err +} + +func (c *Client) CreateDeployKey(owner, repo, title, pubKey string, readonly bool) error { + _, err := c.client.CreateDeployKey(owner, repo, gitea.CreateKeyOption{ + Title: title, + Key: pubKey, + ReadOnly: readonly, + }) + + return errors.Wrapf(err, "error creating deploy key") +} + +func (c *Client) UpdateDeployKey(owner, repo, title, pubKey string, readonly bool) error { + // NOTE(sgotti) gitea has a bug where if we delete and remove the same key with + // the same value it is correctly readded and the admin must force a + // authorizec_keys regeneration on the server. To avoid this we update it only + // when the public key value has changed + keys, err := c.client.ListDeployKeys(owner, repo) + if err != nil { + return errors.Wrapf(err, "error retrieving existing deploy keys") + } + + for _, key := range keys { + if key.Title == title { + if key.Key == pubKey { + return nil + } + if err := c.client.DeleteDeployKey(owner, repo, key.ID); err != nil { + return errors.Wrapf(err, "error removing existing deploy key") + } + } + } + + if _, err := c.client.CreateDeployKey(owner, repo, gitea.CreateKeyOption{ + Title: title, + Key: pubKey, + ReadOnly: readonly, + }); err != nil { + return errors.Wrapf(err, "error creating deploy key") + } + + return nil +} + +func (c *Client) DeleteDeployKey(owner, repo, title string) error { + keys, err := c.client.ListDeployKeys(owner, repo) + if err != nil { + return errors.Wrapf(err, "error retrieving existing deploy keys") + } + + for _, key := range keys { + if key.Title == title { + if err := c.client.DeleteDeployKey(owner, repo, key.ID); err != nil { + return errors.Wrapf(err, "error removing existing deploy key") + } + } + } + + return nil +} + +func (c *Client) CreateRepoWebhook(owner, repo, url, secret string) error { + opts := gitea.CreateHookOption{ + Type: "gitea", + Config: map[string]string{ + "url": url, + "content_type": "json", + "secret": secret, + }, + Events: []string{"push", "pull_request"}, + Active: true, + } + _, err := c.client.CreateRepoHook(owner, repo, opts) + + return errors.Wrapf(err, "error creating repository webhook") +} + +func (c *Client) DeleteRepoWebhook(owner, repo, u string) error { + curURL, err := url.Parse(u) + if err != nil { + return errors.Wrapf(err, "failed to parse url") + } + + hooks, err := c.client.ListRepoHooks(owner, repo) + if err != nil { + return errors.Wrapf(err, "error retrieving repository webhooks") + } + + for _, hook := range hooks { + if hurl, ok := hook.Config["url"]; ok { + u, err := url.Parse(hurl) + if err == nil && u.Host == curURL.Host { + if err := c.client.DeleteRepoHook(owner, repo, hook.ID); err != nil { + return errors.Wrapf(err, "error deleting existing repository webhook") + } + } + } + } + + return nil +} diff --git a/internal/gitsources/gitea/parse.go b/internal/gitsources/gitea/parse.go new file mode 100644 index 0000000..b1a9970 --- /dev/null +++ b/internal/gitsources/gitea/parse.go @@ -0,0 +1,164 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitea + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/sorintlab/agola/internal/services/types" + + "github.com/pkg/errors" +) + +const ( + hookEvent = "X-Gitea-Event" + + hookPush = "push" + hookPullRequest = "pull_request" + + prStateOpen = "open" + + prActionOpen = "opened" + prActionSync = "synchronized" +) + +func (c *Client) ParseWebhook(r *http.Request) (*types.WebhookData, error) { + return parseWebhook(r) +} + +func parseWebhook(r *http.Request) (*types.WebhookData, error) { + switch r.Header.Get(hookEvent) { + case hookPush: + return parsePushHook(r.Body) + case hookPullRequest: + return parsePullRequestHook(r.Body) + default: + return nil, errors.Errorf("unknown webhook event type: %q", r.Header.Get(hookEvent)) + } +} + +func parsePush(r io.Reader) (*pushHook, error) { + push := new(pushHook) + err := json.NewDecoder(r).Decode(push) + return push, err +} + +func parsePullRequest(r io.Reader) (*pullRequestHook, error) { + pr := new(pullRequestHook) + err := json.NewDecoder(r).Decode(pr) + return pr, err +} + +func parsePushHook(payload io.Reader) (*types.WebhookData, error) { + push, err := parsePush(payload) + if err != nil { + return nil, err + } + + return webhookDataFromPush(push) +} + +func parsePullRequestHook(payload io.Reader) (*types.WebhookData, error) { + prhook, err := parsePullRequest(payload) + if err != nil { + return nil, err + } + + // skip non open pull requests + if prhook.PullRequest.State != prStateOpen { + return nil, nil + } + // only accept actions that have new commits + if prhook.Action != prActionOpen && prhook.Action != prActionSync { + return nil, nil + } + + return webhookDataFromPullRequest(prhook), nil +} + +func webhookDataFromPush(hook *pushHook) (*types.WebhookData, error) { + sender := hook.Sender.Username + if sender == "" { + sender = hook.Sender.Login + } + + // common data + whd := &types.WebhookData{ + CommitSHA: hook.After, + Ref: hook.Ref, + CompareLink: hook.Compare, + CommitLink: fmt.Sprintf("%s/commit/%s", hook.Repo.URL, hook.After), + Sender: sender, + + Repo: types.WebhookDataRepo{ + Name: hook.Repo.Name, + Owner: hook.Repo.Owner.Username, + FullName: hook.Repo.FullName, + RepoURL: hook.Repo.URL, + }, + } + + switch { + case strings.HasPrefix(hook.Ref, "refs/heads/"): + whd.Event = types.WebhookEventPush + whd.Branch = strings.TrimPrefix(hook.Ref, "refs/heads/") + whd.BranchLink = fmt.Sprintf("%s/src/branch/%s", hook.Repo.URL, whd.Branch) + if len(hook.Commits) > 0 { + whd.Message = hook.Commits[0].Message + } + case strings.HasPrefix(hook.Ref, "refs/tags/"): + whd.Event = types.WebhookEventTag + whd.Tag = strings.TrimPrefix(hook.Ref, "refs/tags/") + whd.TagLink = fmt.Sprintf("%s/src/tag/%s", hook.Repo.URL, whd.Tag) + whd.Message = fmt.Sprintf("Tag %s", whd.Tag) + default: + // ignore received webhook since it doesn't have a ref we're interested in + return nil, fmt.Errorf("Unsupported webhook ref %q", hook.Ref) + } + + return whd, nil +} + +// helper function that extracts the Build data from a Gitea pull_request hook +func webhookDataFromPullRequest(hook *pullRequestHook) *types.WebhookData { + sender := hook.Sender.Username + if sender == "" { + sender = hook.Sender.Login + } + build := &types.WebhookData{ + Event: types.WebhookEventPullRequest, + CommitSHA: hook.PullRequest.Head.Sha, + Ref: fmt.Sprintf("refs/pull/%d/head", hook.Number), + CommitLink: fmt.Sprintf("%s/commit/%s", hook.Repo.URL, hook.PullRequest.Head.Sha), + Branch: hook.PullRequest.Base.Ref, + Message: hook.PullRequest.Title, + Sender: sender, + PullRequestID: strconv.FormatInt(hook.PullRequest.ID, 10), + PullRequestLink: hook.PullRequest.URL, + + Repo: types.WebhookDataRepo{ + Name: hook.Repo.Name, + Owner: hook.Repo.Owner.Username, + FullName: hook.Repo.FullName, + RepoURL: hook.Repo.URL, + }, + } + return build +} diff --git a/internal/gitsources/gitea/types.go b/internal/gitsources/gitea/types.go new file mode 100644 index 0000000..129a54a --- /dev/null +++ b/internal/gitsources/gitea/types.go @@ -0,0 +1,140 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitea + +type pushHook struct { + Sha string `json:"sha"` + Ref string `json:"ref"` + Before string `json:"before"` + After string `json:"after"` + Compare string `json:"compare_url"` + RefType string `json:"ref_type"` + + Pusher struct { + Name string `json:"name"` + Email string `json:"email"` + Login string `json:"login"` + Username string `json:"username"` + } `json:"pusher"` + + Repo struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + URL string `json:"html_url"` + Private bool `json:"private"` + Owner struct { + Name string `json:"name"` + Email string `json:"email"` + Username string `json:"username"` + } `json:"owner"` + } `json:"repository"` + + Commits []struct { + ID string `json:"id"` + Message string `json:"message"` + URL string `json:"url"` + } `json:"commits"` + + Sender struct { + ID int64 `json:"id"` + Login string `json:"login"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar_url"` + } `json:"sender"` +} + +type pullRequestHook struct { + Action string `json:"action"` + Number int64 `json:"number"` + PullRequest struct { + ID int64 `json:"id"` + User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"full_name"` + Email string `json:"email"` + Avatar string `json:"avatar_url"` + } `json:"user"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + URL string `json:"html_url"` + Mergeable bool `json:"mergeable"` + Merged bool `json:"merged"` + MergeBase string `json:"merge_base"` + Base struct { + Label string `json:"label"` + Ref string `json:"ref"` + Sha string `json:"sha"` + Repo struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + URL string `json:"html_url"` + Private bool `json:"private"` + Owner struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"full_name"` + Email string `json:"email"` + Avatar string `json:"avatar_url"` + } `json:"owner"` + } `json:"repo"` + } `json:"base"` + Head struct { + Label string `json:"label"` + Ref string `json:"ref"` + Sha string `json:"sha"` + Repo struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + URL string `json:"html_url"` + Private bool `json:"private"` + Owner struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"full_name"` + Email string `json:"email"` + Avatar string `json:"avatar_url"` + } `json:"owner"` + } `json:"repo"` + } `json:"head"` + } `json:"pull_request"` + Repo struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + URL string `json:"html_url"` + Private bool `json:"private"` + Owner struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"full_name"` + Email string `json:"email"` + Avatar string `json:"avatar_url"` + } `json:"owner"` + } `json:"repository"` + Sender struct { + ID int64 `json:"id"` + Login string `json:"login"` + Username string `json:"username"` + Name string `json:"full_name"` + Email string `json:"email"` + Avatar string `json:"avatar_url"` + } `json:"sender"` +} diff --git a/internal/gitsources/gitlab/gitlab.go b/internal/gitsources/gitlab/gitlab.go new file mode 100644 index 0000000..2a3371a --- /dev/null +++ b/internal/gitsources/gitlab/gitlab.go @@ -0,0 +1,235 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitlab + +import ( + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "path" + "strconv" + "time" + + "github.com/pkg/errors" + gitsource "github.com/sorintlab/agola/internal/gitsources" + "github.com/sorintlab/agola/internal/services/types" + gitlab "github.com/xanzy/go-gitlab" + "golang.org/x/oauth2" +) + +const ( + // TODO(sgotti) The gitea client doesn't provide an easy way to detect http response codes... + // we should probably use our own client implementation + + ClientNotFound = "404 Not Found" +) + +var ( + GitlabOauth2Scopes = []string{"api"} +) + +type Opts struct { + URL string + Token string + SkipVerify bool + Oauth2ClientID string + Oauth2Secret string +} + +type Client struct { + client *gitlab.Client + URL string + oauth2ClientID string + oauth2Secret string +} + +func New(opts Opts) (*Client, error) { + // copied from net/http until it has a clone function: https://github.com/golang/go/issues/26013 + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{InsecureSkipVerify: opts.SkipVerify}, + } + httpClient := &http.Client{Transport: transport} + client := gitlab.NewOAuthClient(httpClient, opts.Token) + client.SetBaseURL(opts.URL) + + return &Client{ + client: client, + URL: opts.URL, + oauth2ClientID: opts.Oauth2ClientID, + oauth2Secret: opts.Oauth2Secret, + }, nil +} + +func (c *Client) GetOauth2AuthorizationURL(callbackURL, state string) (string, error) { + + var config = &oauth2.Config{ + ClientID: c.oauth2ClientID, + ClientSecret: c.oauth2Secret, + Scopes: GitlabOauth2Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s/oauth/authorize", c.URL), + TokenURL: fmt.Sprintf("%s/oauth/token", c.URL), + }, + RedirectURL: callbackURL, + } + + return config.AuthCodeURL(state), nil +} + +func (c *Client) RequestOauth2Token(callbackURL, code string) (*oauth2.Token, error) { + + var config = &oauth2.Config{ + ClientID: c.oauth2ClientID, + ClientSecret: c.oauth2Secret, + Scopes: GitlabOauth2Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s/oauth/authorize", c.URL), + TokenURL: fmt.Sprintf("%s/oauth/token", c.URL), + }, + RedirectURL: callbackURL, + } + + token, err := config.Exchange(context.TODO(), code) + if err != nil { + return nil, errors.Wrapf(err, "cannot get oauth2 token") + } + return token, nil +} + +func (c *Client) GetUserInfo() (*gitsource.UserInfo, error) { + user, _, err := c.client.Users.CurrentUser() + if err != nil { + return nil, err + } + return &gitsource.UserInfo{ + ID: strconv.Itoa(user.ID), + LoginName: user.Username, + Email: user.Email, + }, nil +} + +func (c *Client) GetFile(owner, repo, commit, file string) ([]byte, error) { + f, _, err := c.client.RepositoryFiles.GetFile(path.Join(owner, repo), file, &gitlab.GetFileOptions{Ref: gitlab.String(commit)}) + data, err := base64.StdEncoding.DecodeString(f.Content) + if err != nil { + return nil, err + } + return data, err +} + +func (c *Client) CreateDeployKey(owner, repo, title, pubKey string, readonly bool) error { + _, _, err := c.client.DeployKeys.AddDeployKey(path.Join(owner, repo), &gitlab.AddDeployKeyOptions{ + Title: gitlab.String(title), + Key: gitlab.String(pubKey), + }) + + return errors.Wrapf(err, "error creating deploy key") +} + +func (c *Client) UpdateDeployKey(owner, repo, title, pubKey string, readonly bool) error { + keys, _, err := c.client.DeployKeys.ListProjectDeployKeys(path.Join(owner, repo), nil) + if err != nil { + return errors.Wrapf(err, "error retrieving existing deploy keys") + } + + for _, key := range keys { + if key.Title == title { + if key.Key == pubKey { + return nil + } + if _, err := c.client.DeployKeys.DeleteDeployKey(path.Join(owner, repo), key.ID); err != nil { + return errors.Wrapf(err, "error removing existing deploy key") + } + } + } + + if _, _, err := c.client.DeployKeys.AddDeployKey(path.Join(owner, repo), &gitlab.AddDeployKeyOptions{ + Title: &title, + Key: &pubKey, + }); err != nil { + return errors.Wrapf(err, "error creating deploy key") + } + + return nil +} + +func (c *Client) DeleteDeployKey(owner, repo, title string) error { + keys, _, err := c.client.DeployKeys.ListProjectDeployKeys(path.Join(owner, repo), nil) + if err != nil { + return errors.Wrapf(err, "error retrieving existing deploy keys") + } + + for _, key := range keys { + if key.Title == title { + if _, err := c.client.DeployKeys.DeleteDeployKey(path.Join(owner, repo), key.ID); err != nil { + return errors.Wrapf(err, "error removing existing deploy key") + } + } + } + + return nil +} + +func (c *Client) CreateRepoWebhook(owner, repo, url, secret string) error { + opts := &gitlab.AddProjectHookOptions{ + URL: gitlab.String(url), + PushEvents: gitlab.Bool(true), + } + _, _, err := c.client.Projects.AddProjectHook(path.Join(owner, repo), opts) + + return errors.Wrapf(err, "error creating repository webhook") +} + +func (c *Client) DeleteRepoWebhook(owner, repo, u string) error { + curURL, err := url.Parse(u) + if err != nil { + return errors.Wrapf(err, "failed to parse url") + } + + hooks, _, err := c.client.Projects.ListProjectHooks(path.Join(owner, repo), nil) + if err != nil { + return errors.Wrapf(err, "error retrieving repository webhooks") + } + + for _, hook := range hooks { + u, err := url.Parse(hook.URL) + if err == nil && u.Host == curURL.Host { + if _, err := c.client.Projects.DeleteProjectHook(path.Join(owner, repo), hook.ID); err != nil { + return errors.Wrapf(err, "error deleting existing repository webhook") + } + } + } + + return nil +} + +func (c *Client) ParseWebhook(r *http.Request) (*types.WebhookData, error) { + hookEvent := "X-Gitea-Event" + return nil, errors.Errorf("unknown webhook event type: %q", r.Header.Get(hookEvent)) +} diff --git a/internal/gitsources/gitsource.go b/internal/gitsources/gitsource.go new file mode 100644 index 0000000..570177e --- /dev/null +++ b/internal/gitsources/gitsource.go @@ -0,0 +1,64 @@ +// Copyright 2019 Sorint.lab +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitsource + +import ( + "net/http" + + "github.com/sorintlab/agola/internal/services/types" + "golang.org/x/oauth2" +) + +type CommitStatus string + +const ( + CommitStatusPending CommitStatus = "pending" + CommitStatusSuccess CommitStatus = "success" + CommitStatusFailed CommitStatus = "failed" +) + +type GitSource interface { + GetFile(owner, repo, commit, file string) ([]byte, error) + DeleteDeployKey(owner, repo, title string) error + CreateDeployKey(owner, repo, title, pubKey string, readonly bool) error + UpdateDeployKey(owner, repo, title, pubKey string, readonly bool) error + DeleteRepoWebhook(owner, repo, url string) error + CreateRepoWebhook(owner, repo, url, secret string) error + ParseWebhook(r *http.Request) (*types.WebhookData, error) +} + +type UserSource interface { + GetUserInfo() (*UserInfo, error) +} + +type PasswordSource interface { + UserSource + LoginPassword(username, password string) (string, error) +} + +type Oauth2Source interface { + UserSource + // Oauth2AuthorizationRequest return the authorization request URL to the + // authorization server + GetOauth2AuthorizationURL(callbackURL, state string) (redirectURL string, err error) + // OauthTokenRequest requests the oauth2 access token to the authorization server + RequestOauth2Token(callbackURL, code string) (*oauth2.Token, error) +} + +type UserInfo struct { + ID string + LoginName string + Email string +}