diff --git a/cli/cliui/gitauth_test.go b/cli/cliui/gitauth_test.go index 13310ab85ffda..dfe142f99be28 100644 --- a/cli/cliui/gitauth_test.go +++ b/cli/cliui/gitauth_test.go @@ -2,7 +2,6 @@ package cliui_test import ( "context" - "net/url" "sync/atomic" "testing" "time" @@ -33,7 +32,7 @@ func TestGitAuth(t *testing.T) { ID: "github", Type: codersdk.GitProviderGitHub, Authenticated: fetched.Load(), - AuthenticateURL: "https://example.com/gitauth/github?redirect=" + url.QueryEscape("/gitauth?notify"), + AuthenticateURL: "https://example.com/gitauth/github", }}, nil }, FetchInterval: time.Millisecond, diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index 14822b9616293..5bb67adf82416 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -51,9 +51,9 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd { } if token.URL != "" { if err := openURL(inv, token.URL); err == nil { - cliui.Infof(inv.Stderr, "Your browser has been opened to authenticate with Git:\n\n\t%s\n\n", token.URL) + cliui.Infof(inv.Stderr, "Your browser has been opened to authenticate with Git:\n\n%s\n", token.URL) } else { - cliui.Infof(inv.Stderr, "Open the following URL to authenticate with Git:\n\n\t%s\n\n", token.URL) + cliui.Infof(inv.Stderr, "Open the following URL to authenticate with Git:\n\n%s\n", token.URL) } for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); { diff --git a/cli/server.go b/cli/server.go index 3e48b2d318b7e..30d57020c1ecd 100644 --- a/cli/server.go +++ b/cli/server.go @@ -148,6 +148,14 @@ func ReadGitAuthProvidersFromEnv(environ []string) ([]codersdk.GitAuthConfig, er provider.ValidateURL = v.Value case "REGEX": provider.Regex = v.Value + case "DEVICE_FLOW": + b, err := strconv.ParseBool(v.Value) + if err != nil { + return nil, xerrors.Errorf("parse bool: %s", v.Value) + } + provider.DeviceFlow = b + case "DEVICE_CODE_URL": + provider.DeviceCodeURL = v.Value case "NO_REFRESH": b, err := strconv.ParseBool(v.Value) if err != nil { @@ -156,6 +164,10 @@ func ReadGitAuthProvidersFromEnv(environ []string) ([]codersdk.GitAuthConfig, er provider.NoRefresh = b case "SCOPES": provider.Scopes = strings.Split(v.Value, " ") + case "APP_INSTALL_URL": + provider.AppInstallURL = v.Value + case "APP_INSTALLATIONS_URL": + provider.AppInstallationsURL = v.Value } providers[providerNum] = provider } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 872fd022878cf..0985371115763 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -658,6 +658,103 @@ const docTemplate = `{ } } }, + "/gitauth/{gitauth}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Git" + ], + "summary": "Get git auth by ID", + "operationId": "get-git-auth-by-id", + "parameters": [ + { + "type": "string", + "format": "string", + "description": "Git Provider ID", + "name": "gitauth", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GitAuth" + } + } + } + } + }, + "/gitauth/{gitauth}/device": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Git" + ], + "summary": "Get git auth device by ID.", + "operationId": "get-git-auth-device-by-id", + "parameters": [ + { + "type": "string", + "format": "string", + "description": "Git Provider ID", + "name": "gitauth", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GitAuthDevice" + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Git" + ], + "summary": "Post git auth device by ID", + "operationId": "post-git-auth-device-by-id", + "parameters": [ + { + "type": "string", + "format": "string", + "description": "Git Provider ID", + "name": "gitauth", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/groups/{group}": { "get": { "security": [ @@ -7440,15 +7537,78 @@ const docTemplate = `{ } } }, + "codersdk.GitAuth": { + "type": "object", + "properties": { + "app_install_url": { + "description": "AppInstallURL is the URL to install the app.", + "type": "string" + }, + "app_installable": { + "description": "AppInstallable is true if the request for app installs was successful.", + "type": "boolean" + }, + "authenticated": { + "type": "boolean" + }, + "device": { + "type": "boolean" + }, + "installations": { + "description": "AppInstallations are the installations that the user has access to.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.GitAuthAppInstallation" + } + }, + "type": { + "type": "string" + }, + "user": { + "description": "User is the user that authenticated with the provider.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.GitAuthUser" + } + ] + } + } + }, + "codersdk.GitAuthAppInstallation": { + "type": "object", + "properties": { + "account": { + "$ref": "#/definitions/codersdk.GitAuthUser" + }, + "configure_url": { + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, "codersdk.GitAuthConfig": { "type": "object", "properties": { + "app_install_url": { + "type": "string" + }, + "app_installations_url": { + "type": "string" + }, "auth_url": { "type": "string" }, "client_id": { "type": "string" }, + "device_code_url": { + "type": "string" + }, + "device_flow": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -7475,6 +7635,43 @@ const docTemplate = `{ } } }, + "codersdk.GitAuthDevice": { + "type": "object", + "properties": { + "device_code": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "interval": { + "type": "integer" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + } + } + }, + "codersdk.GitAuthUser": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + } + }, "codersdk.GitProvider": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 56db90e9f26e8..b3598dc3e6c86 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -562,6 +562,93 @@ } } }, + "/gitauth/{gitauth}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Git"], + "summary": "Get git auth by ID", + "operationId": "get-git-auth-by-id", + "parameters": [ + { + "type": "string", + "format": "string", + "description": "Git Provider ID", + "name": "gitauth", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GitAuth" + } + } + } + } + }, + "/gitauth/{gitauth}/device": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Git"], + "summary": "Get git auth device by ID.", + "operationId": "get-git-auth-device-by-id", + "parameters": [ + { + "type": "string", + "format": "string", + "description": "Git Provider ID", + "name": "gitauth", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GitAuthDevice" + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Git"], + "summary": "Post git auth device by ID", + "operationId": "post-git-auth-device-by-id", + "parameters": [ + { + "type": "string", + "format": "string", + "description": "Git Provider ID", + "name": "gitauth", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/groups/{group}": { "get": { "security": [ @@ -6661,15 +6748,78 @@ } } }, + "codersdk.GitAuth": { + "type": "object", + "properties": { + "app_install_url": { + "description": "AppInstallURL is the URL to install the app.", + "type": "string" + }, + "app_installable": { + "description": "AppInstallable is true if the request for app installs was successful.", + "type": "boolean" + }, + "authenticated": { + "type": "boolean" + }, + "device": { + "type": "boolean" + }, + "installations": { + "description": "AppInstallations are the installations that the user has access to.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.GitAuthAppInstallation" + } + }, + "type": { + "type": "string" + }, + "user": { + "description": "User is the user that authenticated with the provider.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.GitAuthUser" + } + ] + } + } + }, + "codersdk.GitAuthAppInstallation": { + "type": "object", + "properties": { + "account": { + "$ref": "#/definitions/codersdk.GitAuthUser" + }, + "configure_url": { + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, "codersdk.GitAuthConfig": { "type": "object", "properties": { + "app_install_url": { + "type": "string" + }, + "app_installations_url": { + "type": "string" + }, "auth_url": { "type": "string" }, "client_id": { "type": "string" }, + "device_code_url": { + "type": "string" + }, + "device_flow": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -6696,6 +6846,43 @@ } } }, + "codersdk.GitAuthDevice": { + "type": "object", + "properties": { + "device_code": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "interval": { + "type": "integer" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + } + } + }, + "codersdk.GitAuthUser": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + } + }, "codersdk.GitProvider": { "type": "string", "enum": ["azure-devops", "github", "gitlab", "bitbucket"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 41ddcf4bbda58..d76c691128af7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -455,17 +455,23 @@ func New(options *Options) *API { }) }) + // Register callback handlers for each OAuth2 provider. r.Route("/gitauth", func(r chi.Router) { for _, gitAuthConfig := range options.GitAuthConfigs { - r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) { + // We don't need to register a callback handler for device auth. + if gitAuthConfig.DeviceAuth != nil { + continue + } + r.Route(fmt.Sprintf("/%s/callback", gitAuthConfig.ID), func(r chi.Router) { r.Use( - httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil), apiKeyMiddlewareRedirect, + httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil), ) - r.Get("/callback", api.gitAuthCallback(gitAuthConfig)) + r.Get("/", api.gitAuthCallback(gitAuthConfig)) }) } }) + r.Route("/api/v2", func(r chi.Router) { api.APIHandler = r @@ -513,6 +519,15 @@ func New(options *Options) *API { r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) + r.Route("/gitauth/{gitauth}", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractGitAuthParam(options.GitAuthConfigs), + ) + r.Get("/", api.gitAuthByID) + r.Post("/device", api.postGitAuthDeviceByID) + r.Get("/device", api.gitAuthDeviceByID) + }) r.Route("/organizations", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/gitauth.go b/coderd/gitauth.go new file mode 100644 index 0000000000000..537f0b8794e32 --- /dev/null +++ b/coderd/gitauth.go @@ -0,0 +1,253 @@ +package coderd + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + + "golang.org/x/sync/errgroup" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) + +// @Summary Get git auth by ID +// @ID get-git-auth-by-id +// @Security CoderSessionToken +// @Produce json +// @Tags Git +// @Param gitauth path string true "Git Provider ID" format(string) +// @Success 200 {object} codersdk.GitAuth +// @Router /gitauth/{gitauth} [get] +func (api *API) gitAuthByID(w http.ResponseWriter, r *http.Request) { + config := httpmw.GitAuthParam(r) + apiKey := httpmw.APIKey(r) + ctx := r.Context() + + res := codersdk.GitAuth{ + Authenticated: false, + Device: config.DeviceAuth != nil, + AppInstallURL: config.AppInstallURL, + Type: config.Type.Pretty(), + AppInstallations: []codersdk.GitAuthAppInstallation{}, + } + + link, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: config.ID, + UserID: apiKey.UserID, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, w, http.StatusOK, res) + return + } + var eg errgroup.Group + eg.Go(func() (err error) { + res.Authenticated, res.User, err = config.ValidateToken(ctx, link.OAuthAccessToken) + return err + }) + eg.Go(func() (err error) { + res.AppInstallations, res.AppInstallable, err = config.AppInstallations(ctx, link.OAuthAccessToken) + return err + }) + err = eg.Wait() + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to validate token.", + Detail: err.Error(), + }) + return + } + if res.AppInstallations == nil { + res.AppInstallations = []codersdk.GitAuthAppInstallation{} + } + httpapi.Write(ctx, w, http.StatusOK, res) +} + +// @Summary Post git auth device by ID +// @ID post-git-auth-device-by-id +// @Security CoderSessionToken +// @Tags Git +// @Param gitauth path string true "Git Provider ID" format(string) +// @Success 204 +// @Router /gitauth/{gitauth}/device [post] +func (api *API) postGitAuthDeviceByID(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + config := httpmw.GitAuthParam(r) + + var req codersdk.GitAuthDeviceExchange + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if config.DeviceAuth == nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Git auth provider does not support device flow.", + }) + return + } + + token, err := config.DeviceAuth.ExchangeDeviceCode(ctx, req.DeviceCode) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to exchange device code.", + Detail: err.Error(), + }) + return + } + + _, err = api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: config.ID, + UserID: apiKey.UserID, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + _, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{ + ProviderID: config.ID, + UserID: apiKey.UserID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + OAuthAccessToken: token.AccessToken, + OAuthRefreshToken: token.RefreshToken, + OAuthExpiry: token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to insert git auth link.", + Detail: err.Error(), + }) + return + } + } else { + _, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ + ProviderID: config.ID, + UserID: apiKey.UserID, + UpdatedAt: database.Now(), + OAuthAccessToken: token.AccessToken, + OAuthRefreshToken: token.RefreshToken, + OAuthExpiry: token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update git auth link.", + Detail: err.Error(), + }) + return + } + } + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + +// @Summary Get git auth device by ID. +// @ID get-git-auth-device-by-id +// @Security CoderSessionToken +// @Produce json +// @Tags Git +// @Param gitauth path string true "Git Provider ID" format(string) +// @Success 200 {object} codersdk.GitAuthDevice +// @Router /gitauth/{gitauth}/device [get] +func (*API) gitAuthDeviceByID(rw http.ResponseWriter, r *http.Request) { + config := httpmw.GitAuthParam(r) + ctx := r.Context() + + if config.DeviceAuth == nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Git auth device flow not supported.", + }) + return + } + + deviceAuth, err := config.DeviceAuth.AuthorizeDevice(r.Context()) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to authorize device.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, deviceAuth) +} + +func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + state = httpmw.OAuth2(r) + apiKey = httpmw.APIKey(r) + ) + + _, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get git auth link.", + Detail: err.Error(), + }) + return + } + + _, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + OAuthAccessToken: state.Token.AccessToken, + OAuthRefreshToken: state.Token.RefreshToken, + OAuthExpiry: state.Token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to insert git auth link.", + Detail: err.Error(), + }) + return + } + } else { + _, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ + ProviderID: gitAuthConfig.ID, + UserID: apiKey.UserID, + UpdatedAt: database.Now(), + OAuthAccessToken: state.Token.AccessToken, + OAuthRefreshToken: state.Token.RefreshToken, + OAuthExpiry: state.Token.Expiry, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update git auth link.", + Detail: err.Error(), + }) + return + } + } + + redirect := state.Redirect + if redirect == "" { + // This is a nicely rendered screen on the frontend + redirect = fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID) + } + http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) + } +} diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go index 133af4023fb8f..29d4804dcd538 100644 --- a/coderd/gitauth/config.go +++ b/coderd/gitauth/config.go @@ -2,6 +2,7 @@ package gitauth import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -11,15 +12,22 @@ import ( "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/coderd/httpmw" "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 { - httpmw.OAuth2Config + OAuth2Config // ID is a unique identifier for the authenticator. ID string // Regex is a regexp that URLs will match against. @@ -36,6 +44,16 @@ type Config struct { // 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. @@ -58,15 +76,13 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, gitAuthLin return gitAuthLink, false, nil } - if c.ValidateURL != "" { - 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 - } + 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 { @@ -87,26 +103,104 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, gitAuthLin } // ValidateToken ensures the Git token provided is valid! -func (c *Config) ValidateToken(ctx context.Context, token string) (bool, error) { +// 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, err + return false, nil, err } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) res, err := http.DefaultClient.Do(req) if err != nil { - return false, err + return false, nil, err } defer res.Body.Close() if res.StatusCode == http.StatusUnauthorized { // The token is no longer valid! - return false, nil + return false, nil, nil } if res.StatusCode != http.StatusOK { data, _ := io.ReadAll(res.Body) - return false, xerrors.Errorf("status %d: body: %s", res.StatusCode, data) + 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 } - return true, nil + 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 @@ -148,9 +242,6 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con if entry.ClientID == "" { return nil, xerrors.Errorf("%q git auth provider: client_id must be provided", entry.ID) } - if entry.ClientSecret == "" { - return nil, xerrors.Errorf("%q git auth provider: client_secret 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) @@ -163,7 +254,7 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con } } - oauth2Config := &oauth2.Config{ + oc := &oauth2.Config{ ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, Endpoint: endpoint[typ], @@ -172,32 +263,54 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con } if entry.AuthURL != "" { - oauth2Config.Endpoint.AuthURL = entry.AuthURL + oc.Endpoint.AuthURL = entry.AuthURL } if entry.TokenURL != "" { - oauth2Config.Endpoint.TokenURL = entry.TokenURL + oc.Endpoint.TokenURL = entry.TokenURL } if entry.Scopes != nil && len(entry.Scopes) > 0 { - oauth2Config.Scopes = entry.Scopes + oc.Scopes = entry.Scopes } if entry.ValidateURL == "" { entry.ValidateURL = validateURL[typ] } + if entry.AppInstallationsURL == "" { + entry.AppInstallationsURL = appInstallationsURL[typ] + } - var oauthConfig httpmw.OAuth2Config = oauth2Config + var oauthConfig OAuth2Config = oc // Azure DevOps uses JWT token authentication! if typ == codersdk.GitProviderAzureDevops { - oauthConfig = newJWTOAuthConfig(oauth2Config) + oauthConfig = &jwtConfig{oc} } - configs = append(configs, &Config{ - OAuth2Config: oauthConfig, - ID: entry.ID, - Regex: regex, - Type: typ, - NoRefresh: entry.NoRefresh, - ValidateURL: entry.ValidateURL, - }) + 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 } diff --git a/coderd/gitauth/config_test.go b/coderd/gitauth/config_test.go index 69bb564738056..31d6392341426 100644 --- a/coderd/gitauth/config_test.go +++ b/coderd/gitauth/config_test.go @@ -143,13 +143,6 @@ func TestConvertYAML(t *testing.T) { Type: string(codersdk.GitProviderGitHub), }}, Error: "client_id must be provided", - }, { - Name: "NoClientSecret", - Input: []codersdk.GitAuthConfig{{ - Type: string(codersdk.GitProviderGitHub), - ClientID: "example", - }}, - Error: "client_secret must be provided", }, { Name: "DuplicateType", Input: []codersdk.GitAuthConfig{{ @@ -169,6 +162,15 @@ func TestConvertYAML(t *testing.T) { Regex: `\K`, }}, Error: "compile regex for git auth provider", + }, { + Name: "NoDeviceURL", + Input: []codersdk.GitAuthConfig{{ + Type: string(codersdk.GitProviderGitLab), + ClientID: "example", + ClientSecret: "example", + DeviceFlow: true, + }}, + Error: "device auth url must be provided", }} { tc := tc t.Run(tc.Name, func(t *testing.T) { diff --git a/coderd/gitauth/oauth.go b/coderd/gitauth/oauth.go index c9008dff7697b..1e0748da91fc2 100644 --- a/coderd/gitauth/oauth.go +++ b/coderd/gitauth/oauth.go @@ -2,13 +2,15 @@ package gitauth import ( "context" + "encoding/json" + "net/http" "net/url" "regexp" "golang.org/x/oauth2" "golang.org/x/oauth2/github" + "golang.org/x/xerrors" - "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/codersdk" ) @@ -36,6 +38,14 @@ var validateURL = map[codersdk.GitProvider]string{ codersdk.GitProviderBitBucket: "https://api.bitbucket.org/2.0/user", } +var deviceAuthURL = map[codersdk.GitProvider]string{ + codersdk.GitProviderGitHub: "https://github.com/login/device/code", +} + +var appInstallationsURL = map[codersdk.GitProvider]string{ + codersdk.GitProviderGitHub: "https://api.github.com/user/installations", +} + // scope contains defaults for each Git provider. var scope = map[codersdk.GitProvider][]string{ codersdk.GitProviderAzureDevops: {"vso.code_write"}, @@ -54,13 +64,9 @@ var regex = map[codersdk.GitProvider]*regexp.Regexp{ codersdk.GitProviderGitHub: regexp.MustCompile(`^(https?://)?github\.com(/.*)?$`), } -// newJWTOAuthConfig creates a new OAuth2 config that uses a custom +// jwtConfig is a new OAuth2 config that uses a custom // assertion method that works with Azure Devops. See: // https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops -func newJWTOAuthConfig(config *oauth2.Config) httpmw.OAuth2Config { - return &jwtConfig{config} -} - type jwtConfig struct { *oauth2.Config } @@ -89,3 +95,110 @@ func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.Au )..., ) } + +type DeviceAuth struct { + ClientID string + TokenURL string + Scopes []string + CodeURL string +} + +// AuthorizeDevice begins the device authorization flow. +// See: https://tools.ietf.org/html/rfc8628#section-3.1 +func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.GitAuthDevice, error) { + if c.CodeURL == "" { + return nil, xerrors.New("oauth2: device code URL not set") + } + codeURL, err := c.formatDeviceCodeURL() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, codeURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var r struct { + codersdk.GitAuthDevice + ErrorDescription string `json:"error_description"` + } + err = json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + return nil, err + } + if r.ErrorDescription != "" { + return nil, xerrors.New(r.ErrorDescription) + } + return &r.GitAuthDevice, nil +} + +type ExchangeDeviceCodeResponse struct { + *oauth2.Token + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// ExchangeDeviceCode exchanges a device code for an access token. +// The boolean returned indicates whether the device code is still pending +// and the caller should try again. +func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string) (*oauth2.Token, error) { + if c.TokenURL == "" { + return nil, xerrors.New("oauth2: token URL not set") + } + tokenURL, err := c.formatDeviceTokenURL(deviceCode) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, codersdk.ReadBodyAsError(resp) + } + var body ExchangeDeviceCodeResponse + err = json.NewDecoder(resp.Body).Decode(&body) + if err != nil { + return nil, err + } + if body.Error != "" { + return nil, xerrors.New(body.Error) + } + return body.Token, nil +} + +func (c *DeviceAuth) formatDeviceTokenURL(deviceCode string) (string, error) { + tok, err := url.Parse(c.TokenURL) + if err != nil { + return "", err + } + tok.RawQuery = url.Values{ + "client_id": {c.ClientID}, + "device_code": {deviceCode}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }.Encode() + return tok.String(), nil +} + +func (c *DeviceAuth) formatDeviceCodeURL() (string, error) { + cod, err := url.Parse(c.CodeURL) + if err != nil { + return "", err + } + cod.RawQuery = url.Values{ + "client_id": {c.ClientID}, + "scope": c.Scopes, + }.Encode() + return cod.String(), nil +} diff --git a/coderd/gitauth_test.go b/coderd/gitauth_test.go new file mode 100644 index 0000000000000..9d102aa2150f8 --- /dev/null +++ b/coderd/gitauth_test.go @@ -0,0 +1,481 @@ +package coderd_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "github.com/google/go-github/v43/github" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/testutil" +) + +func TestGitAuthByID(t *testing.T) { + t.Parallel() + t.Run("Unauthenticated", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GitAuthConfigs: []*gitauth.Config{{ + ID: "test", + OAuth2Config: &testutil.OAuth2Config{}, + Type: codersdk.GitProviderGitHub, + }}, + }) + coderdtest.CreateFirstUser(t, client) + auth, err := client.GitAuthByID(context.Background(), "test") + require.NoError(t, err) + require.False(t, auth.Authenticated) + }) + t.Run("AuthenticatedNoUser", func(t *testing.T) { + // Ensures that a provider that can't obtain a user can + // still return that the provider is authenticated. + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GitAuthConfigs: []*gitauth.Config{{ + ID: "test", + OAuth2Config: &testutil.OAuth2Config{}, + // AzureDevops doesn't have a user endpoint! + Type: codersdk.GitProviderAzureDevops, + }}, + }) + coderdtest.CreateFirstUser(t, client) + resp := coderdtest.RequestGitAuthCallback(t, "test", client) + _ = resp.Body.Close() + auth, err := client.GitAuthByID(context.Background(), "test") + require.NoError(t, err) + require.True(t, auth.Authenticated) + }) + t.Run("AuthenticatedWithUser", func(t *testing.T) { + t.Parallel() + validateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(r.Context(), w, http.StatusOK, github.User{ + Login: github.String("kyle"), + AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"), + }) + })) + defer validateSrv.Close() + client := coderdtest.New(t, &coderdtest.Options{ + GitAuthConfigs: []*gitauth.Config{{ + ID: "test", + ValidateURL: validateSrv.URL, + OAuth2Config: &testutil.OAuth2Config{}, + Type: codersdk.GitProviderGitHub, + }}, + }) + coderdtest.CreateFirstUser(t, client) + resp := coderdtest.RequestGitAuthCallback(t, "test", client) + _ = resp.Body.Close() + auth, err := client.GitAuthByID(context.Background(), "test") + require.NoError(t, err) + require.True(t, auth.Authenticated) + require.NotNil(t, auth.User) + require.Equal(t, "kyle", auth.User.Login) + }) + t.Run("AuthenticatedWithInstalls", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/user": + httpapi.Write(r.Context(), w, http.StatusOK, github.User{ + Login: github.String("kyle"), + AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"), + }) + case "/installs": + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Installations []github.Installation `json:"installations"` + }{ + Installations: []github.Installation{{ + ID: github.Int64(12345678), + Account: &github.User{ + Login: github.String("coder"), + }, + }}, + }) + } + })) + defer srv.Close() + client := coderdtest.New(t, &coderdtest.Options{ + GitAuthConfigs: []*gitauth.Config{{ + ID: "test", + ValidateURL: srv.URL + "/user", + AppInstallationsURL: srv.URL + "/installs", + OAuth2Config: &testutil.OAuth2Config{}, + Type: codersdk.GitProviderGitHub, + }}, + }) + coderdtest.CreateFirstUser(t, client) + resp := coderdtest.RequestGitAuthCallback(t, "test", client) + _ = resp.Body.Close() + auth, err := client.GitAuthByID(context.Background(), "test") + require.NoError(t, err) + require.True(t, auth.Authenticated) + require.NotNil(t, auth.User) + require.Equal(t, "kyle", auth.User.Login) + require.NotNil(t, auth.AppInstallations) + require.Len(t, auth.AppInstallations, 1) + }) +} + +func TestGitAuthDevice(t *testing.T) { + t.Parallel() + t.Run("NotSupported", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + GitAuthConfigs: []*gitauth.Config{{ + ID: "test", + }}, + }) + coderdtest.CreateFirstUser(t, client) + _, err := client.GitAuthDeviceByID(context.Background(), "test") + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + t.Run("FetchCode", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.GitAuthDevice{ + UserCode: "hey", + }) + })) + defer srv.Close() + client := coderdtest.New(t, &coderdtest.Options{ + GitAuthConfigs: []*gitauth.Config{{ + ID: "test", + DeviceAuth: &gitauth.DeviceAuth{ + ClientID: "test", + CodeURL: srv.URL, + Scopes: []string{"repo"}, + }, + }}, + }) + coderdtest.CreateFirstUser(t, client) + device, err := client.GitAuthDeviceByID(context.Background(), "test") + require.NoError(t, err) + require.Equal(t, "hey", device.UserCode) + }) + t.Run("ExchangeCode", func(t *testing.T) { + t.Parallel() + resp := gitauth.ExchangeDeviceCodeResponse{ + Error: "authorization_pending", + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(r.Context(), w, http.StatusOK, resp) + })) + defer srv.Close() + client := coderdtest.New(t, &coderdtest.Options{ + GitAuthConfigs: []*gitauth.Config{{ + ID: "test", + DeviceAuth: &gitauth.DeviceAuth{ + ClientID: "test", + TokenURL: srv.URL, + Scopes: []string{"repo"}, + }, + }}, + }) + coderdtest.CreateFirstUser(t, client) + err := client.GitAuthDeviceExchange(context.Background(), "test", codersdk.GitAuthDeviceExchange{ + DeviceCode: "hey", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Equal(t, "authorization_pending", sdkErr.Detail) + + resp = gitauth.ExchangeDeviceCodeResponse{ + Token: &oauth2.Token{ + AccessToken: "hey", + }, + } + + err = client.GitAuthDeviceExchange(context.Background(), "test", codersdk.GitAuthDeviceExchange{ + DeviceCode: "hey", + }) + require.NoError(t, err) + + auth, err := client.GitAuthByID(context.Background(), "test") + require.NoError(t, err) + require.True(t, auth.Authenticated) + }) +} + +// nolint:bodyclose +func TestGitAuthCallback(t *testing.T) { + t.Parallel() + t.Run("NoMatchingConfig", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + _, err := agentClient.GitAuth(context.Background(), "github.com", false) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusNotFound, apiError.StatusCode()) + }) + t.Run("ReturnsURL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/gitauth/%s", "github"))) + }) + t.Run("UnauthorizedCallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + resp := coderdtest.RequestGitAuthCallback(t, "github", client) + require.Equal(t, http.StatusSeeOther, resp.StatusCode) + }) + t.Run("AuthorizedCallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + _ = coderdtest.CreateFirstUser(t, client) + resp := coderdtest.RequestGitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + location, err := resp.Location() + require.NoError(t, err) + require.Equal(t, "/gitauth/github", location.Path) + + // Callback again to simulate updating the token. + resp = coderdtest.RequestGitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + }) + t.Run("ValidateURL", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + srv := httptest.NewServer(nil) + defer srv.Close() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + ValidateURL: srv.URL, + OAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + + resp := coderdtest.RequestGitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + // If the validation URL says unauthorized, the callback + // URL to re-authenticate should be returned. + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + res, err := agentClient.GitAuth(ctx, "github.com/asd/asd", false) + require.NoError(t, err) + require.NotEmpty(t, res.URL) + + // If the validation URL gives a non-OK status code, this + // should be treated as an internal server error. + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Something went wrong!")) + }) + _, err = agentClient.GitAuth(ctx, "github.com/asd/asd", false) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusInternalServerError, apiError.StatusCode()) + require.Equal(t, "validate git auth token: status 403: body: Something went wrong!", apiError.Detail) + }) + + t.Run("ExpiredNoRefresh", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &testutil.OAuth2Config{ + Token: &oauth2.Token{ + AccessToken: "token", + RefreshToken: "something", + Expiry: database.Now().Add(-time.Hour), + }, + }, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + NoRefresh: true, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + + token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.NotEmpty(t, token.URL) + + // In the configuration, we set our OAuth provider + // to return an expired token. Coder consumes this + // and stores it. + resp := coderdtest.RequestGitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + // Because the token is expired and `NoRefresh` is specified, + // a redirect URL should be returned again. + token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.NotEmpty(t, token.URL) + }) + + t.Run("FullFlow", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + GitAuthConfigs: []*gitauth.Config{{ + OAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.GitProviderGitHub, + }}, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + + token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + require.NotEmpty(t, token.URL) + + // Start waiting for the token callback... + tokenChan := make(chan agentsdk.GitAuthResponse, 1) + go func() { + token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", true) + assert.NoError(t, err) + tokenChan <- token + }() + + time.Sleep(250 * time.Millisecond) + + resp := coderdtest.RequestGitAuthCallback(t, "github", client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + token = <-tokenChan + require.Equal(t, "access_token", token.Username) + + token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) + require.NoError(t, err) + }) +} diff --git a/coderd/httpmw/gitauthparam.go b/coderd/httpmw/gitauthparam.go new file mode 100644 index 0000000000000..2ce592d54f98a --- /dev/null +++ b/coderd/httpmw/gitauthparam.go @@ -0,0 +1,40 @@ +package httpmw + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/coderd/httpapi" +) + +type gitAuthParamContextKey struct{} + +func GitAuthParam(r *http.Request) *gitauth.Config { + config, ok := r.Context().Value(gitAuthParamContextKey{}).(*gitauth.Config) + if !ok { + panic("developer error: gitauth param middleware not provided") + } + return config +} + +func ExtractGitAuthParam(configs []*gitauth.Config) func(next http.Handler) http.Handler { + configByID := make(map[string]*gitauth.Config) + for _, c := range configs { + configByID[c.ID] = c + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + config, ok := configByID[chi.URLParam(r, "gitauth")] + if !ok { + httpapi.ResourceNotFound(w) + return + } + + r = r.WithContext(context.WithValue(r.Context(), gitAuthParamContextKey{}, config)) + next.ServeHTTP(w, r) + }) + } +} diff --git a/coderd/httpmw/gitauthparam_test.go b/coderd/httpmw/gitauthparam_test.go new file mode 100644 index 0000000000000..01ea35470f025 --- /dev/null +++ b/coderd/httpmw/gitauthparam_test.go @@ -0,0 +1,49 @@ +package httpmw_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/coderd/httpmw" +) + +//nolint:bodyclose +func TestGitAuthParam(t *testing.T) { + t.Parallel() + t.Run("Found", func(t *testing.T) { + t.Parallel() + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("gitauth", "my-id") + r := httptest.NewRequest(http.MethodGet, "/", nil) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)) + res := httptest.NewRecorder() + + httpmw.ExtractGitAuthParam([]*gitauth.Config{{ + ID: "my-id", + }})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "my-id", httpmw.GitAuthParam(r).ID) + w.WriteHeader(http.StatusOK) + })).ServeHTTP(res, r) + + require.Equal(t, http.StatusOK, res.Result().StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("gitauth", "my-id") + r := httptest.NewRequest(http.MethodGet, "/", nil) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)) + res := httptest.NewRecorder() + + httpmw.ExtractGitAuthParam([]*gitauth.Config{})(nil).ServeHTTP(res, r) + + require.Equal(t, http.StatusNotFound, res.Result().StatusCode) + }) +} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index be2648f474512..0e8251a8faaf8 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -67,19 +67,19 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client, authURLOpts map[str // OIDC errors can be returned as query parameters. This can happen // if for example we are providing and invalid scope. // We should terminate the OIDC process if we encounter an error. - oidcError := r.URL.Query().Get("error") + errorMsg := r.URL.Query().Get("error") errorDescription := r.URL.Query().Get("error_description") errorURI := r.URL.Query().Get("error_uri") - if oidcError != "" { + if errorMsg != "" { // Combine the errors into a single string if either is provided. if errorDescription == "" && errorURI != "" { errorDescription = fmt.Sprintf("error_uri: %s", errorURI) } else if errorDescription != "" && errorURI != "" { errorDescription = fmt.Sprintf("%s, error_uri: %s", errorDescription, errorURI) } - oidcError = fmt.Sprintf("Encountered error in oidc process: %s", oidcError) + errorMsg = fmt.Sprintf("Encountered error in oidc process: %s", errorMsg) httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: oidcError, + Message: errorMsg, // This message might be blank. This is ok. Detail: errorDescription, }) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 7af3029d83e12..df5ae1793ed8b 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -320,14 +320,6 @@ func (api *API) templateVersionGitAuth(rw http.ResponseWriter, r *http.Request) }) return } - query := redirectURL.Query() - // The frontend uses a BroadcastChannel to notify listening pages for - // Git auth updates if the "notify" query parameter is set. - // - // It's important we do this in the backend, because the same endpoint - // is used for CLI authentication. - query.Add("redirect", "/gitauth?notify") - redirectURL.RawQuery = query.Encode() provider := codersdk.TemplateVersionGitAuth{ ID: config.ID, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 25c7861ce4ae4..4ae04cba22b72 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1902,18 +1902,16 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) if gitAuthLink.OAuthExpiry.Before(database.Now()) && !gitAuthLink.OAuthExpiry.IsZero() { continue } - if gitAuthConfig.ValidateURL != "" { - valid, err := gitAuthConfig.ValidateToken(ctx, gitAuthLink.OAuthAccessToken) - if err != nil { - api.Logger.Warn(ctx, "failed to validate git auth token", - slog.F("workspace_owner_id", workspace.OwnerID.String()), - slog.F("validate_url", gitAuthConfig.ValidateURL), - slog.Error(err), - ) - } - if !valid { - continue - } + valid, _, err := gitAuthConfig.ValidateToken(ctx, gitAuthLink.OAuthAccessToken) + if err != nil { + api.Logger.Warn(ctx, "failed to validate git auth token", + slog.F("workspace_owner_id", workspace.OwnerID.String()), + slog.F("validate_url", gitAuthConfig.ValidateURL), + slog.Error(err), + ) + } + if !valid { + continue } httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken)) return @@ -1990,70 +1988,6 @@ func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) agentsdk.G return resp } -func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc { - return func(rw http.ResponseWriter, r *http.Request) { - var ( - ctx = r.Context() - state = httpmw.OAuth2(r) - apiKey = httpmw.APIKey(r) - ) - - _, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ - ProviderID: gitAuthConfig.ID, - UserID: apiKey.UserID, - }) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to get git auth link.", - Detail: err.Error(), - }) - return - } - - _, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{ - ProviderID: gitAuthConfig.ID, - UserID: apiKey.UserID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - OAuthAccessToken: state.Token.AccessToken, - OAuthRefreshToken: state.Token.RefreshToken, - OAuthExpiry: state.Token.Expiry, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to insert git auth link.", - Detail: err.Error(), - }) - return - } - } else { - _, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ - ProviderID: gitAuthConfig.ID, - UserID: apiKey.UserID, - UpdatedAt: database.Now(), - OAuthAccessToken: state.Token.AccessToken, - OAuthRefreshToken: state.Token.RefreshToken, - OAuthExpiry: state.Token.Expiry, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to update git auth link.", - Detail: err.Error(), - }) - return - } - } - - redirect := state.Redirect - if redirect == "" { - // This is a nicely rendered screen on the frontend - redirect = "/gitauth" - } - http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) - } -} - // wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func // is called if a read or write error is encountered. type wsNetConn struct { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 7fcde117fd74a..1fb69015fda67 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -6,8 +6,6 @@ import ( "fmt" "net" "net/http" - "net/http/httptest" - "regexp" "runtime" "strconv" "strings" @@ -17,14 +15,12 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/oauth2" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/provisioner/echo" @@ -1000,270 +996,6 @@ func TestWorkspaceAgentAppHealth(t *testing.T) { require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, manifest.Apps[1].Health) } -// nolint:bodyclose -func TestWorkspaceAgentsGitAuth(t *testing.T) { - t.Parallel() - t.Run("NoMatchingConfig", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - GitAuthConfigs: []*gitauth.Config{}, - }) - user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(authToken), - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - _, err := agentClient.GitAuth(context.Background(), "github.com", false) - var apiError *codersdk.Error - require.ErrorAs(t, err, &apiError) - require.Equal(t, http.StatusNotFound, apiError.StatusCode()) - }) - t.Run("ReturnsURL", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - GitAuthConfigs: []*gitauth.Config{{ - OAuth2Config: &testutil.OAuth2Config{}, - ID: "github", - Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.GitProviderGitHub, - }}, - }) - user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Auth: &proto.Agent_Token{ - Token: authToken, - }, - }}, - }}, - }, - }, - }}, - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) - require.NoError(t, err) - require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/gitauth/%s", "github"))) - }) - t.Run("UnauthorizedCallback", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - GitAuthConfigs: []*gitauth.Config{{ - OAuth2Config: &testutil.OAuth2Config{}, - ID: "github", - Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.GitProviderGitHub, - }}, - }) - resp := coderdtest.RequestGitAuthCallback(t, "github", client) - require.Equal(t, http.StatusSeeOther, resp.StatusCode) - }) - t.Run("AuthorizedCallback", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - GitAuthConfigs: []*gitauth.Config{{ - OAuth2Config: &testutil.OAuth2Config{}, - ID: "github", - Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.GitProviderGitHub, - }}, - }) - _ = coderdtest.CreateFirstUser(t, client) - resp := coderdtest.RequestGitAuthCallback(t, "github", client) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - location, err := resp.Location() - require.NoError(t, err) - require.Equal(t, "/gitauth", location.Path) - - // Callback again to simulate updating the token. - resp = coderdtest.RequestGitAuthCallback(t, "github", client) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - }) - t.Run("ValidateURL", func(t *testing.T) { - t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) - - srv := httptest.NewServer(nil) - defer srv.Close() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - GitAuthConfigs: []*gitauth.Config{{ - ValidateURL: srv.URL, - OAuth2Config: &testutil.OAuth2Config{}, - ID: "github", - Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.GitProviderGitHub, - }}, - }) - user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(authToken), - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - - resp := coderdtest.RequestGitAuthCallback(t, "github", client) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - - // If the validation URL says unauthorized, the callback - // URL to re-authenticate should be returned. - srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - res, err := agentClient.GitAuth(ctx, "github.com/asd/asd", false) - require.NoError(t, err) - require.NotEmpty(t, res.URL) - - // If the validation URL gives a non-OK status code, this - // should be treated as an internal server error. - srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - w.Write([]byte("Something went wrong!")) - }) - _, err = agentClient.GitAuth(ctx, "github.com/asd/asd", false) - var apiError *codersdk.Error - require.ErrorAs(t, err, &apiError) - require.Equal(t, http.StatusInternalServerError, apiError.StatusCode()) - require.Equal(t, "validate git auth token: status 403: body: Something went wrong!", apiError.Detail) - }) - - t.Run("ExpiredNoRefresh", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - GitAuthConfigs: []*gitauth.Config{{ - OAuth2Config: &testutil.OAuth2Config{ - Token: &oauth2.Token{ - AccessToken: "token", - RefreshToken: "something", - Expiry: database.Now().Add(-time.Hour), - }, - }, - ID: "github", - Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.GitProviderGitHub, - NoRefresh: true, - }}, - }) - user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(authToken), - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - - token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) - require.NoError(t, err) - require.NotEmpty(t, token.URL) - - // In the configuration, we set our OAuth provider - // to return an expired token. Coder consumes this - // and stores it. - resp := coderdtest.RequestGitAuthCallback(t, "github", client) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - - // Because the token is expired and `NoRefresh` is specified, - // a redirect URL should be returned again. - token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) - require.NoError(t, err) - require.NotEmpty(t, token.URL) - }) - - t.Run("FullFlow", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - GitAuthConfigs: []*gitauth.Config{{ - OAuth2Config: &testutil.OAuth2Config{}, - ID: "github", - Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.GitProviderGitHub, - }}, - }) - user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(authToken), - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - - token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) - require.NoError(t, err) - require.NotEmpty(t, token.URL) - - // Start waiting for the token callback... - tokenChan := make(chan agentsdk.GitAuthResponse, 1) - go func() { - token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", true) - assert.NoError(t, err) - tokenChan <- token - }() - - time.Sleep(250 * time.Millisecond) - - resp := coderdtest.RequestGitAuthCallback(t, "github", client) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - token = <-tokenChan - require.Equal(t, "access_token", token.Username) - - token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false) - require.NoError(t, err) - }) -} - func TestWorkspaceAgentReportStats(t *testing.T) { t.Parallel() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index dc758e5a76242..c29770f628152 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -297,16 +297,20 @@ type TraceConfig struct { } type GitAuthConfig struct { - ID string `json:"id"` - Type string `json:"type"` - ClientID string `json:"client_id"` - ClientSecret string `json:"-" yaml:"client_secret"` - AuthURL string `json:"auth_url"` - TokenURL string `json:"token_url"` - ValidateURL string `json:"validate_url"` - Regex string `json:"regex"` - NoRefresh bool `json:"no_refresh"` - Scopes []string `json:"scopes"` + ID string `json:"id"` + Type string `json:"type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"-" yaml:"client_secret"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + ValidateURL string `json:"validate_url"` + AppInstallURL string `json:"app_install_url"` + AppInstallationsURL string `json:"app_installations_url"` + Regex string `json:"regex"` + NoRefresh bool `json:"no_refresh"` + Scopes []string `json:"scopes"` + DeviceFlow bool `json:"device_flow"` + DeviceCodeURL string `json:"device_code_url"` } type ProvisionerConfig struct { diff --git a/codersdk/gitauth.go b/codersdk/gitauth.go new file mode 100644 index 0000000000000..c8259771207b8 --- /dev/null +++ b/codersdk/gitauth.go @@ -0,0 +1,90 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +type GitAuth struct { + Authenticated bool `json:"authenticated"` + Device bool `json:"device"` + Type string `json:"type"` + + // User is the user that authenticated with the provider. + User *GitAuthUser `json:"user"` + // AppInstallable is true if the request for app installs was successful. + AppInstallable bool `json:"app_installable"` + // AppInstallations are the installations that the user has access to. + AppInstallations []GitAuthAppInstallation `json:"installations"` + // AppInstallURL is the URL to install the app. + AppInstallURL string `json:"app_install_url"` +} + +type GitAuthAppInstallation struct { + ID int `json:"id"` + Account GitAuthUser `json:"account"` + ConfigureURL string `json:"configure_url"` +} + +type GitAuthUser struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + ProfileURL string `json:"profile_url"` + Name string `json:"name"` +} + +// GitAuthDevice is the response from the device authorization endpoint. +// See: https://tools.ietf.org/html/rfc8628#section-3.2 +type GitAuthDevice struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type GitAuthDeviceExchange struct { + DeviceCode string `json:"device_code"` +} + +func (c *Client) GitAuthDeviceByID(ctx context.Context, provider string) (GitAuthDevice, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/gitauth/%s/device", provider), nil) + if err != nil { + return GitAuthDevice{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return GitAuthDevice{}, ReadBodyAsError(res) + } + var gitauth GitAuthDevice + return gitauth, json.NewDecoder(res.Body).Decode(&gitauth) +} + +// ExchangeGitAuth exchanges a device code for a git auth token. +func (c *Client) GitAuthDeviceExchange(ctx context.Context, provider string, req GitAuthDeviceExchange) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/gitauth/%s/device", provider), req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// GitAuthByID returns the git auth for the given provider by ID. +func (c *Client) GitAuthByID(ctx context.Context, provider string) (GitAuth, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/gitauth/%s", provider), nil) + if err != nil { + return GitAuth{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return GitAuth{}, ReadBodyAsError(res) + } + var gitauth GitAuth + return gitauth, json.NewDecoder(res.Body).Decode(&gitauth) +} diff --git a/docs/api/general.md b/docs/api/general.md index 1655fb9d2fb00..2a2e3d4da4631 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -200,8 +200,12 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "git_auth": { "value": [ { + "app_install_url": "string", + "app_installations_url": "string", "auth_url": "string", "client_id": "string", + "device_code_url": "string", + "device_flow": true, "id": "string", "no_refresh": true, "regex": "string", diff --git a/docs/api/git.md b/docs/api/git.md new file mode 100644 index 0000000000000..0f55fdfba909a --- /dev/null +++ b/docs/api/git.md @@ -0,0 +1,127 @@ +# Git + +## Get git auth by ID + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/gitauth/{gitauth} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /gitauth/{gitauth}` + +### Parameters + +| Name | In | Type | Required | Description | +| --------- | ---- | -------------- | -------- | --------------- | +| `gitauth` | path | string(string) | true | Git Provider ID | + +### Example responses + +> 200 Response + +```json +{ + "app_install_url": "string", + "app_installable": true, + "authenticated": true, + "device": true, + "installations": [ + { + "account": { + "avatar_url": "string", + "login": "string", + "name": "string", + "profile_url": "string" + }, + "configure_url": "string", + "id": 0 + } + ], + "type": "string", + "user": { + "avatar_url": "string", + "login": "string", + "name": "string", + "profile_url": "string" + } +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GitAuth](schemas.md#codersdkgitauth) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get git auth device by ID. + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/gitauth/{gitauth}/device \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /gitauth/{gitauth}/device` + +### Parameters + +| Name | In | Type | Required | Description | +| --------- | ---- | -------------- | -------- | --------------- | +| `gitauth` | path | string(string) | true | Git Provider ID | + +### Example responses + +> 200 Response + +```json +{ + "device_code": "string", + "expires_in": 0, + "interval": 0, + "user_code": "string", + "verification_uri": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GitAuthDevice](schemas.md#codersdkgitauthdevice) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Post git auth device by ID + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/gitauth/{gitauth}/device \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /gitauth/{gitauth}/device` + +### Parameters + +| Name | In | Type | Required | Description | +| --------- | ---- | -------------- | -------- | --------------- | +| `gitauth` | path | string(string) | true | Git Provider ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index f332d03968fb1..c4c054f684881 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -593,8 +593,12 @@ { "value": [ { + "app_install_url": "string", + "app_installations_url": "string", "auth_url": "string", "client_id": "string", + "device_code_url": "string", + "device_flow": true, "id": "string", "no_refresh": true, "regex": "string", @@ -1877,8 +1881,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "git_auth": { "value": [ { + "app_install_url": "string", + "app_installations_url": "string", "auth_url": "string", "client_id": "string", + "device_code_url": "string", + "device_flow": true, "id": "string", "no_refresh": true, "regex": "string", @@ -2208,8 +2216,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "git_auth": { "value": [ { + "app_install_url": "string", + "app_installations_url": "string", "auth_url": "string", "client_id": "string", + "device_code_url": "string", + "device_flow": true, "id": "string", "no_refresh": true, "regex": "string", @@ -2571,12 +2583,81 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `count` | integer | false | | | | `users` | array of [codersdk.User](#codersdkuser) | false | | | +## codersdk.GitAuth + +```json +{ + "app_install_url": "string", + "app_installable": true, + "authenticated": true, + "device": true, + "installations": [ + { + "account": { + "avatar_url": "string", + "login": "string", + "name": "string", + "profile_url": "string" + }, + "configure_url": "string", + "id": 0 + } + ], + "type": "string", + "user": { + "avatar_url": "string", + "login": "string", + "name": "string", + "profile_url": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------- | --------------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------- | +| `app_install_url` | string | false | | App install URL is the URL to install the app. | +| `app_installable` | boolean | false | | App installable is true if the request for app installs was successful. | +| `authenticated` | boolean | false | | | +| `device` | boolean | false | | | +| `installations` | array of [codersdk.GitAuthAppInstallation](#codersdkgitauthappinstallation) | false | | Installations are the installations that the user has access to. | +| `type` | string | false | | | +| `user` | [codersdk.GitAuthUser](#codersdkgitauthuser) | false | | User is the user that authenticated with the provider. | + +## codersdk.GitAuthAppInstallation + +```json +{ + "account": { + "avatar_url": "string", + "login": "string", + "name": "string", + "profile_url": "string" + }, + "configure_url": "string", + "id": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------------- | -------------------------------------------- | -------- | ------------ | ----------- | +| `account` | [codersdk.GitAuthUser](#codersdkgitauthuser) | false | | | +| `configure_url` | string | false | | | +| `id` | integer | false | | | + ## codersdk.GitAuthConfig ```json { + "app_install_url": "string", + "app_installations_url": "string", "auth_url": "string", "client_id": "string", + "device_code_url": "string", + "device_flow": true, "id": "string", "no_refresh": true, "regex": "string", @@ -2589,17 +2670,63 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | --------------- | -------- | ------------ | ----------- | -| `auth_url` | string | false | | | -| `client_id` | string | false | | | -| `id` | string | false | | | -| `no_refresh` | boolean | false | | | -| `regex` | string | false | | | -| `scopes` | array of string | false | | | -| `token_url` | string | false | | | -| `type` | string | false | | | -| `validate_url` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------------- | --------------- | -------- | ------------ | ----------- | +| `app_install_url` | string | false | | | +| `app_installations_url` | string | false | | | +| `auth_url` | string | false | | | +| `client_id` | string | false | | | +| `device_code_url` | string | false | | | +| `device_flow` | boolean | false | | | +| `id` | string | false | | | +| `no_refresh` | boolean | false | | | +| `regex` | string | false | | | +| `scopes` | array of string | false | | | +| `token_url` | string | false | | | +| `type` | string | false | | | +| `validate_url` | string | false | | | + +## codersdk.GitAuthDevice + +```json +{ + "device_code": "string", + "expires_in": 0, + "interval": 0, + "user_code": "string", + "verification_uri": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------- | -------- | ------------ | ----------- | +| `device_code` | string | false | | | +| `expires_in` | integer | false | | | +| `interval` | integer | false | | | +| `user_code` | string | false | | | +| `verification_uri` | string | false | | | + +## codersdk.GitAuthUser + +```json +{ + "avatar_url": "string", + "login": "string", + "name": "string", + "profile_url": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------- | ------ | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `login` | string | false | | | +| `name` | string | false | | | +| `profile_url` | string | false | | | ## codersdk.GitProvider diff --git a/docs/manifest.json b/docs/manifest.json index 00574def08266..c7e56a3a23014 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -451,6 +451,10 @@ "title": "Files", "path": "./api/files.md" }, + { + "title": "Git", + "path": "./api/git.md" + }, { "title": "Insights", "path": "./api/insights.md" diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index 840e232076a3c..b84e9d25caa48 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -1,7 +1,6 @@ import CssBaseline from "@mui/material/CssBaseline" import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles" - -import { MemoryRouter } from "react-router-dom" +import { withRouter } from "storybook-addon-react-router-v6" import { HelmetProvider } from "react-helmet-async" import { dark } from "../src/theme" import "../src/theme/globalFonts" @@ -16,13 +15,7 @@ export const decorators = [ ), - (Story) => { - return ( - - - - ) - }, + withRouter, (Story) => { return ( diff --git a/site/e2e/constants.ts b/site/e2e/constants.ts index 1e56c3d8b3b28..98036d6fe73d5 100644 --- a/site/e2e/constants.ts +++ b/site/e2e/constants.ts @@ -5,3 +5,19 @@ export const defaultPort = 3000 export const username = "admin" export const password = "SomeSecurePassword!" export const email = "admin@coder.com" + +export const gitAuth = { + deviceProvider: "device", + webProvider: "web", + // These ports need to be hardcoded so that they can be + // used in `playwright.config.ts` to set the environment + // variables for the server. + devicePort: 50515, + webPort: 50516, + + authPath: "/auth", + tokenPath: "/token", + codePath: "/code", + validatePath: "/validate", + installationsPath: "/installations", +} diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 2c16aa606fba7..f9d37e3190eee 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -2,6 +2,7 @@ import { expect, Page } from "@playwright/test" import { spawn } from "child_process" import { randomUUID } from "crypto" import path from "path" +import express from "express" import { TarWriter } from "utils/tar" import { Agent, @@ -227,3 +228,34 @@ const createTemplateVersionTar = async ( const randomName = () => { return randomUUID().slice(0, 8) } + +// Awaiter is a helper that allows you to wait for a callback to be called. +// It is useful for waiting for events to occur. +export class Awaiter { + private promise: Promise + private callback?: () => void + + constructor() { + this.promise = new Promise((r) => (this.callback = r)) + } + + public done(): void { + if (this.callback) { + this.callback() + } else { + this.promise = Promise.resolve() + } + } + + public wait(): Promise { + return this.promise + } +} + +export const createServer = async ( + port: number, +): Promise> => { + const e = express() + await new Promise((r) => e.listen(port, r)) + return e +} diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index a8d2472b502cc..0ff9a6fe74622 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from "@playwright/test" import path from "path" -import { defaultPort } from "./constants" +import { defaultPort, gitAuth } from "./constants" export const port = process.env.CODER_E2E_PORT ? Number(process.env.CODER_E2E_PORT) @@ -10,7 +10,11 @@ const coderMain = path.join(__dirname, "../../enterprise/cmd/coder/main.go") export const STORAGE_STATE = path.join(__dirname, ".auth.json") -const config = defineConfig({ +const localURL = (port: number, path: string): string => { + return `http://localhost:${port}${path}` +} + +export default defineConfig({ projects: [ { name: "setup", @@ -39,9 +43,49 @@ const config = defineConfig({ `--provisioner-daemons 10 ` + `--provisioner-daemons-echo ` + `--provisioner-daemon-poll-interval 50ms`, + env: { + ...process.env, + + // This is the test provider for git auth with devices! + CODER_GITAUTH_0_ID: gitAuth.deviceProvider, + CODER_GITAUTH_0_TYPE: "github", + CODER_GITAUTH_0_CLIENT_ID: "client", + CODER_GITAUTH_0_DEVICE_FLOW: "true", + CODER_GITAUTH_0_APP_INSTALL_URL: + "https://github.com/apps/coder/installations/new", + CODER_GITAUTH_0_APP_INSTALLATIONS_URL: localURL( + gitAuth.devicePort, + gitAuth.installationsPath, + ), + CODER_GITAUTH_0_TOKEN_URL: localURL( + gitAuth.devicePort, + gitAuth.tokenPath, + ), + CODER_GITAUTH_0_DEVICE_CODE_URL: localURL( + gitAuth.devicePort, + gitAuth.codePath, + ), + CODER_GITAUTH_0_VALIDATE_URL: localURL( + gitAuth.devicePort, + gitAuth.validatePath, + ), + + CODER_GITAUTH_1_ID: gitAuth.webProvider, + CODER_GITAUTH_1_TYPE: "github", + CODER_GITAUTH_1_CLIENT_ID: "client", + CODER_GITAUTH_1_CLIENT_SECRET: "secret", + CODER_GITAUTH_1_AUTH_URL: localURL(gitAuth.webPort, gitAuth.authPath), + CODER_GITAUTH_1_TOKEN_URL: localURL(gitAuth.webPort, gitAuth.tokenPath), + CODER_GITAUTH_1_DEVICE_CODE_URL: localURL( + gitAuth.webPort, + gitAuth.codePath, + ), + CODER_GITAUTH_1_VALIDATE_URL: localURL( + gitAuth.webPort, + gitAuth.validatePath, + ), + }, port, reuseExistingServer: false, }, }) - -export default config diff --git a/site/e2e/tests/gitAuth.spec.ts b/site/e2e/tests/gitAuth.spec.ts new file mode 100644 index 0000000000000..ab5659f92103b --- /dev/null +++ b/site/e2e/tests/gitAuth.spec.ts @@ -0,0 +1,140 @@ +import { test } from "@playwright/test" +import { gitAuth } from "../constants" +import { Endpoints } from "@octokit/types" +import { GitAuthDevice } from "api/typesGenerated" +import { Awaiter, createServer } from "../helpers" + +// Ensures that a Git auth provider with the device flow functions and completes! +test("git auth device", async ({ page }) => { + const device: GitAuthDevice = { + device_code: "1234", + user_code: "1234-5678", + expires_in: 900, + interval: 1, + verification_uri: "", + } + + // Start a server to mock the GitHub API. + const srv = await createServer(gitAuth.devicePort) + srv.use(gitAuth.validatePath, (req, res) => { + res.write(JSON.stringify(ghUser)) + res.end() + }) + srv.use(gitAuth.codePath, (req, res) => { + res.write(JSON.stringify(device)) + res.end() + }) + srv.use(gitAuth.installationsPath, (req, res) => { + res.write(JSON.stringify(ghInstall)) + res.end() + }) + + const token = { + access_token: "", + error: "authorization_pending", + error_description: "", + } + // First we send a result from the API that the token hasn't been + // authorized yet to ensure the UI reacts properly. + const sentPending = new Awaiter() + srv.use(gitAuth.tokenPath, (req, res) => { + res.write(JSON.stringify(token)) + res.end() + sentPending.done() + }) + + await page.goto(`/gitauth/${gitAuth.deviceProvider}`, { + waitUntil: "networkidle", + }) + await page.getByText(device.user_code).isVisible() + await sentPending.wait() + // Update the token to be valid and ensure the UI updates! + token.error = "" + token.access_token = "hello-world" + await page.waitForSelector("text=1 organization authorized") +}) + +test("git auth web", async ({ baseURL, page }) => { + const srv = await createServer(gitAuth.webPort) + // The GitHub validate endpoint returns the currently authenticated user! + srv.use(gitAuth.validatePath, (req, res) => { + res.write(JSON.stringify(ghUser)) + res.end() + }) + srv.use(gitAuth.tokenPath, (req, res) => { + res.write(JSON.stringify({ access_token: "hello-world" })) + res.end() + }) + srv.use(gitAuth.authPath, (req, res) => { + res.redirect( + `${baseURL}/gitauth/${gitAuth.webProvider}/callback?code=1234&state=` + + req.query.state, + ) + }) + await page.goto(`/gitauth/${gitAuth.webProvider}`, { + waitUntil: "networkidle", + }) + // This endpoint doesn't have the installations URL set intentionally! + await page.waitForSelector("text=You've authenticated with GitHub!") +}) + +const ghUser: Endpoints["GET /user"]["response"]["data"] = { + login: "kylecarbs", + id: 7122116, + node_id: "MDQ6VXNlcjcxMjIxMTY=", + avatar_url: "https://avatars.githubusercontent.com/u/7122116?v=4", + gravatar_id: "", + url: "https://api.github.com/users/kylecarbs", + html_url: "https://github.com/kylecarbs", + followers_url: "https://api.github.com/users/kylecarbs/followers", + following_url: + "https://api.github.com/users/kylecarbs/following{/other_user}", + gists_url: "https://api.github.com/users/kylecarbs/gists{/gist_id}", + starred_url: "https://api.github.com/users/kylecarbs/starred{/owner}{/repo}", + subscriptions_url: "https://api.github.com/users/kylecarbs/subscriptions", + organizations_url: "https://api.github.com/users/kylecarbs/orgs", + repos_url: "https://api.github.com/users/kylecarbs/repos", + events_url: "https://api.github.com/users/kylecarbs/events{/privacy}", + received_events_url: "https://api.github.com/users/kylecarbs/received_events", + type: "User", + site_admin: false, + name: "Kyle Carberry", + company: "@coder", + blog: "https://carberry.com", + location: "Austin, TX", + email: "kyle@carberry.com", + hireable: null, + bio: "hey there", + twitter_username: "kylecarbs", + public_repos: 52, + public_gists: 9, + followers: 208, + following: 31, + created_at: "2014-04-01T02:24:41Z", + updated_at: "2023-06-26T13:03:09Z", +} + +const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = { + installations: [ + { + id: 1, + access_tokens_url: "", + account: ghUser, + app_id: 1, + app_slug: "coder", + created_at: "2014-04-01T02:24:41Z", + events: [], + html_url: "", + permissions: {}, + repositories_url: "", + repository_selection: "all", + single_file_name: "", + suspended_at: null, + suspended_by: null, + target_id: 1, + target_type: "", + updated_at: "2023-06-26T13:03:09Z", + }, + ], + total_count: 1, +} diff --git a/site/package.json b/site/package.json index 13bb643ffcd87..92e5ace8ceece 100644 --- a/site/package.json +++ b/site/package.json @@ -99,6 +99,7 @@ "yup": "0.32.11" }, "devDependencies": { + "@octokit/types": "10.0.0", "@playwright/test": "1.35.1", "@storybook/addon-actions": "7.0.4", "@storybook/addon-essentials": "7.0.4", @@ -149,6 +150,7 @@ "resize-observer": "1.0.4", "semver": "7.3.7", "storybook": "7.0.4", + "storybook-addon-react-router-v6": "1.0.2", "storybook-react-context": "0.6.0", "ts-proto": "1.150.0", "typescript": "4.8.2", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 1a8fa700ae77c..472be48b4de97 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -193,7 +193,7 @@ export const AppRouter: FC = () => { }> } /> - } /> + } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index df8477b65e926..6047d71cce0aa 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -799,6 +799,28 @@ export const getExperiments = async (): Promise => { } } +export const getGitAuthProvider = async ( + provider: string, +): Promise => { + const resp = await axios.get(`/api/v2/gitauth/${provider}`) + return resp.data +} + +export const getGitAuthDevice = async ( + provider: string, +): Promise => { + const resp = await axios.get(`/api/v2/gitauth/${provider}/device`) + return resp.data +} + +export const exchangeGitAuthDevice = async ( + provider: string, + req: TypesGen.GitAuthDeviceExchange, +): Promise => { + const resp = await axios.post(`/api/v2/gitauth/${provider}/device`, req) + return resp.data +} + export const getAuditLogs = async ( options: TypesGen.AuditLogsRequest, ): Promise => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 49bdcdbb326ff..2a8d50aada33a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -414,6 +414,24 @@ export interface GetUsersResponse { readonly count: number } +// From codersdk/gitauth.go +export interface GitAuth { + readonly authenticated: boolean + readonly device: boolean + readonly type: string + readonly user?: GitAuthUser + readonly app_installable: boolean + readonly installations: GitAuthAppInstallation[] + readonly app_install_url: string +} + +// From codersdk/gitauth.go +export interface GitAuthAppInstallation { + readonly id: number + readonly account: GitAuthUser + readonly configure_url: string +} + // From codersdk/deployment.go export interface GitAuthConfig { readonly id: string @@ -422,9 +440,35 @@ export interface GitAuthConfig { readonly auth_url: string readonly token_url: string readonly validate_url: string + readonly app_install_url: string + readonly app_installations_url: string readonly regex: string readonly no_refresh: boolean readonly scopes: string[] + readonly device_flow: boolean + readonly device_code_url: string +} + +// From codersdk/gitauth.go +export interface GitAuthDevice { + readonly device_code: string + readonly user_code: string + readonly verification_uri: string + readonly expires_in: number + readonly interval: number +} + +// From codersdk/gitauth.go +export interface GitAuthDeviceExchange { + readonly device_code: string +} + +// From codersdk/gitauth.go +export interface GitAuthUser { + readonly login: string + readonly avatar_url: string + readonly profile_url: string + readonly name: string } // From codersdk/gitsshkey.go diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx index 6aa8db92785ed..72b9f910f66c0 100644 --- a/site/src/components/Dashboard/DashboardLayout.tsx +++ b/site/src/components/Dashboard/DashboardLayout.tsx @@ -110,5 +110,7 @@ const useStyles = makeStyles({ siteContent: { flex: 1, paddingBottom: dashboardContentBottomPadding, // Add bottom space since we don't use a footer + display: "flex", + flexDirection: "column", }, }) diff --git a/site/src/components/Margins/Margins.tsx b/site/src/components/Margins/Margins.tsx index acfa252bb3815..0e3af4db9440b 100644 --- a/site/src/components/Margins/Margins.tsx +++ b/site/src/components/Margins/Margins.tsx @@ -20,7 +20,6 @@ const useStyles = makeStyles(() => ({ margin: "0 auto", maxWidth: ({ maxWidth }: { maxWidth: number }) => maxWidth, padding: `0 ${sidePadding}px`, - flex: 1, width: "100%", }, })) diff --git a/site/src/components/SignInLayout/SignInLayout.tsx b/site/src/components/SignInLayout/SignInLayout.tsx index 5c9a4eb13f772..cf223d7d8f53e 100644 --- a/site/src/components/SignInLayout/SignInLayout.tsx +++ b/site/src/components/SignInLayout/SignInLayout.tsx @@ -2,8 +2,15 @@ import { makeStyles } from "@mui/styles" import { FC, ReactNode } from "react" export const useStyles = makeStyles((theme) => ({ + "@global": { + // Necessary for when this is on lonely pages! + "html, body, #root, #storybook-root": { + height: "100vh", + }, + }, root: { - height: "100vh", + flex: 1, + height: "-webkit-fill-available", display: "flex", justifyContent: "center", alignItems: "center", @@ -14,8 +21,10 @@ export const useStyles = makeStyles((theme) => ({ alignItems: "center", }, container: { - marginTop: theme.spacing(-8), maxWidth: 385, + display: "flex", + flexDirection: "column", + alignItems: "center", }, footer: { fontSize: 12, diff --git a/site/src/components/Welcome/Welcome.tsx b/site/src/components/Welcome/Welcome.tsx index 6297f7db422c5..660e7cc381f6d 100644 --- a/site/src/components/Welcome/Welcome.tsx +++ b/site/src/components/Welcome/Welcome.tsx @@ -44,7 +44,7 @@ const useStyles = makeStyles((theme) => ({ margin: 0, marginBottom: theme.spacing(2), marginTop: theme.spacing(2), - lineHeight: 1, + lineHeight: 1.25, "& strong": { fontWeight: 600, diff --git a/site/src/pages/GitAuthPage/GitAuthPage.stories.tsx b/site/src/pages/GitAuthPage/GitAuthPage.stories.tsx deleted file mode 100644 index 422bf964de9bc..0000000000000 --- a/site/src/pages/GitAuthPage/GitAuthPage.stories.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { ComponentMeta, Story } from "@storybook/react" -import GitAuthPage from "./GitAuthPage" - -export default { - title: "pages/GitAuthPage", - component: GitAuthPage, -} as ComponentMeta - -const Template: Story = (args) => - -export const Default = Template.bind({}) -Default.args = {} diff --git a/site/src/pages/GitAuthPage/GitAuthPage.tsx b/site/src/pages/GitAuthPage/GitAuthPage.tsx index 18c251a1e4b14..97013b7b13fb4 100644 --- a/site/src/pages/GitAuthPage/GitAuthPage.tsx +++ b/site/src/pages/GitAuthPage/GitAuthPage.tsx @@ -1,64 +1,102 @@ -import Button from "@mui/material/Button" -import { makeStyles } from "@mui/styles" -import { SignInLayout } from "components/SignInLayout/SignInLayout" -import { Welcome } from "components/Welcome/Welcome" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { + exchangeGitAuthDevice, + getGitAuthDevice, + getGitAuthProvider, +} from "api/api" +import { usePermissions } from "hooks" import { FC, useEffect } from "react" -import { Link as RouterLink } from "react-router-dom" +import { useParams } from "react-router-dom" import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService" +import GitAuthPageView from "./GitAuthPageView" +import { ApiErrorResponse } from "api/errors" +import { isAxiosError } from "axios" const GitAuthPage: FC = () => { - const styles = useStyles() + const { provider } = useParams() + if (!provider) { + throw new Error("provider must exist") + } + const permissions = usePermissions() + const queryClient = useQueryClient() + const getGitAuthProviderQuery = useQuery({ + queryKey: ["gitauth", provider], + queryFn: () => getGitAuthProvider(provider), + refetchOnWindowFocus: true, + }) + + const getGitAuthDeviceQuery = useQuery({ + enabled: + Boolean(!getGitAuthProviderQuery.data?.authenticated) && + Boolean(getGitAuthProviderQuery.data?.device), + queryFn: () => getGitAuthDevice(provider), + queryKey: ["gitauth", provider, "device"], + refetchOnMount: false, + }) + const exchangeGitAuthDeviceQuery = useQuery({ + queryFn: () => + exchangeGitAuthDevice(provider, { + device_code: getGitAuthDeviceQuery.data?.device_code || "", + }), + queryKey: ["gitauth", provider, getGitAuthDeviceQuery.data?.device_code], + enabled: Boolean(getGitAuthDeviceQuery.data), + onSuccess: () => { + // Force a refresh of the Git auth status. + queryClient.invalidateQueries(["gitauth", provider]).catch((ex) => { + console.error("invalidate queries", ex) + }) + }, + retry: true, + retryDelay: (getGitAuthDeviceQuery.data?.interval || 5) * 1000, + refetchOnWindowFocus: (query) => + query.state.status === "success" ? false : "always", + }) + useEffect(() => { + if (!getGitAuthProviderQuery.data?.authenticated) { + return + } // This is used to notify the parent window that the Git auth token has been refreshed. // It's critical in the create workspace flow! // eslint-disable-next-line compat/compat -- It actually is supported... not sure why it's complaining. const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL) // The message doesn't matter, any message refreshes the page! bc.postMessage("noop") - window.close() - }, []) + }, [getGitAuthProviderQuery.data?.authenticated]) - return ( - - -

- Your Git authentication token will be refreshed to keep you signed in. -

+ if (getGitAuthProviderQuery.isLoading || !getGitAuthProviderQuery.data) { + return null + } -
- -
-
- ) -} + let deviceExchangeError: ApiErrorResponse | undefined + if (isAxiosError(exchangeGitAuthDeviceQuery.failureReason)) { + deviceExchangeError = + exchangeGitAuthDeviceQuery.failureReason.response?.data + } -export default GitAuthPage + if ( + !getGitAuthProviderQuery.data.authenticated && + !getGitAuthProviderQuery.data.device + ) { + window.location.href = `/gitauth/${provider}/callback` -const useStyles = makeStyles((theme) => ({ - title: { - fontSize: theme.spacing(4), - fontWeight: 400, - lineHeight: "140%", - margin: 0, - }, + return null + } - text: { - fontSize: 16, - color: theme.palette.text.secondary, - marginBottom: theme.spacing(4), - textAlign: "center", - lineHeight: "160%", - }, - - lineBreak: { - whiteSpace: "nowrap", - }, + return ( + { + queryClient.setQueryData(["gitauth", provider], { + ...getGitAuthProviderQuery.data, + authenticated: false, + }) + }} + viewGitAuthConfig={permissions.viewGitAuthConfig} + deviceExchangeError={deviceExchangeError} + gitAuthDevice={getGitAuthDeviceQuery.data} + /> + ) +} - links: { - display: "flex", - justifyContent: "flex-end", - paddingTop: theme.spacing(1), - }, -})) +export default GitAuthPage diff --git a/site/src/pages/GitAuthPage/GitAuthPageView.stories.tsx b/site/src/pages/GitAuthPage/GitAuthPageView.stories.tsx new file mode 100644 index 0000000000000..4f0a9dc45be6c --- /dev/null +++ b/site/src/pages/GitAuthPage/GitAuthPageView.stories.tsx @@ -0,0 +1,119 @@ +import { Meta, StoryFn } from "@storybook/react" +import GitAuthPageView, { GitAuthPageViewProps } from "./GitAuthPageView" + +export default { + title: "pages/GitAuthPageView", + component: GitAuthPageView, +} as Meta + +const Template: StoryFn = (args) => ( + +) + +export const WebAuthenticated = Template.bind({}) +WebAuthenticated.args = { + gitAuth: { + type: "BitBucket", + authenticated: true, + device: false, + installations: [], + app_install_url: "", + app_installable: false, + user: { + avatar_url: "", + login: "kylecarbs", + name: "Kyle Carberry", + profile_url: "", + }, + }, +} + +export const DeviceUnauthenticated = Template.bind({}) +DeviceUnauthenticated.args = { + gitAuth: { + type: "GitHub", + authenticated: false, + device: true, + installations: [], + app_install_url: "", + app_installable: false, + }, + gitAuthDevice: { + device_code: "1234-5678", + expires_in: 900, + interval: 5, + user_code: "ABCD-EFGH", + verification_uri: "", + }, +} + +export const DeviceUnauthenticatedError = Template.bind({}) +DeviceUnauthenticatedError.args = { + gitAuth: { + type: "GitHub", + authenticated: false, + device: true, + installations: [], + app_install_url: "", + app_installable: false, + }, + gitAuthDevice: { + device_code: "1234-5678", + expires_in: 900, + interval: 5, + user_code: "ABCD-EFGH", + verification_uri: "", + }, + deviceExchangeError: { + message: "Error exchanging device code.", + detail: "expired_token", + }, +} + +export const DeviceAuthenticatedNotInstalled = Template.bind({}) +DeviceAuthenticatedNotInstalled.args = { + viewGitAuthConfig: true, + gitAuth: { + type: "GitHub", + authenticated: true, + device: true, + installations: [], + app_install_url: "https://example.com", + app_installable: true, + user: { + avatar_url: "", + login: "kylecarbs", + name: "Kyle Carberry", + profile_url: "", + }, + }, +} + +export const DeviceAuthenticatedInstalled = Template.bind({}) +DeviceAuthenticatedInstalled.args = { + gitAuth: { + type: "GitHub", + authenticated: true, + device: true, + installations: [ + { + configure_url: "https://example.com", + id: 1, + account: { + avatar_url: "https://github.com/coder.png", + login: "coder", + name: "Coder", + profile_url: "https://github.com/coder", + }, + }, + ], + app_install_url: "https://example.com", + app_installable: true, + user: { + avatar_url: "", + login: "kylecarbs", + name: "Kyle Carberry", + profile_url: "", + }, + }, +} diff --git a/site/src/pages/GitAuthPage/GitAuthPageView.tsx b/site/src/pages/GitAuthPage/GitAuthPageView.tsx new file mode 100644 index 0000000000000..4bf1acded55ba --- /dev/null +++ b/site/src/pages/GitAuthPage/GitAuthPageView.tsx @@ -0,0 +1,277 @@ +import OpenInNewIcon from "@mui/icons-material/OpenInNew" +import RefreshIcon from "@mui/icons-material/Refresh" +import CircularProgress from "@mui/material/CircularProgress" +import Link from "@mui/material/Link" +import Tooltip from "@mui/material/Tooltip" +import { makeStyles } from "@mui/styles" +import { ApiErrorResponse } from "api/errors" +import { GitAuth, GitAuthDevice } from "api/typesGenerated" +import { Alert } from "components/Alert/Alert" +import { Avatar } from "components/Avatar/Avatar" +import { CopyButton } from "components/CopyButton/CopyButton" +import { SignInLayout } from "components/SignInLayout/SignInLayout" +import { Welcome } from "components/Welcome/Welcome" +import { FC, useEffect } from "react" +import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService" + +export interface GitAuthPageViewProps { + gitAuth: GitAuth + viewGitAuthConfig: boolean + + gitAuthDevice?: GitAuthDevice + deviceExchangeError?: ApiErrorResponse + + onReauthenticate: () => void +} + +const GitAuthPageView: FC = ({ + deviceExchangeError, + gitAuth, + gitAuthDevice, + onReauthenticate, + viewGitAuthConfig, +}) => { + const styles = useStyles() + + useEffect(() => { + if (!gitAuth.authenticated) { + return + } + // This is used to notify the parent window that the Git auth token has been refreshed. + // It's critical in the create workspace flow! + // eslint-disable-next-line compat/compat -- It actually is supported... not sure why it's complaining. + const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL) + // The message doesn't matter, any message refreshes the page! + bc.postMessage("noop") + }, [gitAuth.authenticated]) + + if (!gitAuth.authenticated) { + return ( + + + + {gitAuth.device && ( + + )} + + ) + } + + const hasInstallations = gitAuth.installations.length > 0 + + // We only want to wrap this with a link if an install URL is available! + let installTheApp: JSX.Element = <>{`install the ${gitAuth.type} App`} + if (gitAuth.app_install_url) { + installTheApp = ( + + {installTheApp} + + ) + } + + return ( + + +

+ Hey @{gitAuth.user?.login} 👋!{" "} + {(!gitAuth.app_installable || gitAuth.installations.length > 0) && + "You are now authenticated with Git. Feel free to close this window!"} +

+ + {gitAuth.installations.length > 0 && ( +
+ {gitAuth.installations.map((install) => { + if (!install.account) { + return + } + return ( + + + + {install.account.login} + + + + ) + })} +   + {gitAuth.installations.length} organization + {gitAuth.installations.length !== 1 && "s are"} authorized +
+ )} + +
+ {!hasInstallations && gitAuth.app_installable && ( + + You must {installTheApp} to clone private repositories. Accounts + will appear here once authorized. + + )} + + {viewGitAuthConfig && + gitAuth.app_install_url && + gitAuth.app_installable && ( + + + {gitAuth.installations.length > 0 + ? "Configure" + : "Install"} the {gitAuth.type} App + + )} + { + onReauthenticate() + }} + > + Reauthenticate + +
+
+ ) +} + +const GitDeviceAuth: FC<{ + gitAuthDevice?: GitAuthDevice + deviceExchangeError?: ApiErrorResponse +}> = ({ gitAuthDevice, deviceExchangeError }) => { + const styles = useStyles() + + let status = ( +

+ + Checking for authentication... +

+ ) + if (deviceExchangeError) { + // See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 + switch (deviceExchangeError.detail) { + case "authorization_pending": + break + case "expired_token": + status = ( + + The one-time code has expired. Refresh to get a new one! + + ) + break + case "access_denied": + status = ( + Access to the Git provider was denied. + ) + break + default: + status = ( + + An unknown error occurred. Please try again:{" "} + {deviceExchangeError.message} + + ) + break + } + } + + if (!gitAuthDevice) { + return + } + + return ( +
+

+ Copy your one-time code:  +

+ {gitAuthDevice.user_code} {" "} + +
+
+ Then open the link below and paste it: +

+
+ + + Open and Paste + +
+ + {status} +
+ ) +} + +export default GitAuthPageView + +const useStyles = makeStyles((theme) => ({ + text: { + fontSize: 16, + color: theme.palette.text.secondary, + textAlign: "center", + lineHeight: "160%", + margin: 0, + }, + + copyCode: { + display: "inline-flex", + alignItems: "center", + }, + + code: { + fontWeight: "bold", + color: theme.palette.text.primary, + }, + + installAlert: { + margin: theme.spacing(2), + }, + + links: { + display: "flex", + gap: theme.spacing(0.5), + margin: theme.spacing(2), + flexDirection: "column", + }, + + link: { + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 16, + gap: theme.spacing(1), + }, + + status: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(1), + color: theme.palette.text.disabled, + }, + + authorizedInstalls: { + display: "flex", + gap: 4, + color: theme.palette.text.disabled, + margin: theme.spacing(4), + }, +})) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index c76f91ccefb2a..f8a5a8092ce29 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1579,6 +1579,7 @@ export const MockPermissions: Permissions = { viewDeploymentValues: true, viewUpdateCheck: true, viewDeploymentStats: true, + viewGitAuthConfig: true, } export const MockAppearance: TypesGen.AppearanceConfig = { diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 8c5ce5dd8edfc..267935b82edb9 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -17,6 +17,7 @@ export const checks = { viewDeploymentValues: "viewDeploymentValues", createGroup: "createGroup", viewUpdateCheck: "viewUpdateCheck", + viewGitAuthConfig: "viewGitAuthConfig", viewDeploymentStats: "viewDeploymentStats", } as const @@ -75,6 +76,12 @@ export const permissionsToCheck = { }, action: "read", }, + [checks.viewGitAuthConfig]: { + object: { + resource_type: "deployment_config", + }, + action: "read", + }, [checks.viewDeploymentStats]: { object: { resource_type: "deployment_stats", diff --git a/site/yarn.lock b/site/yarn.lock index 9db5108cb7f19..5e74cab92418e 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -1902,6 +1902,18 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@octokit/openapi-types@^18.0.0": + version "18.0.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-18.0.0.tgz#f43d765b3c7533fd6fb88f3f25df079c24fccf69" + integrity sha512-V8GImKs3TeQRxRtXFpG2wl19V7444NIOTDF24AWuIbmNaNYOQMWRbjcGDXV5B+0n887fgDcuMNOmlul+k+oJtw== + +"@octokit/types@10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-10.0.0.tgz#7ee19c464ea4ada306c43f1a45d444000f419a4a" + integrity sha512-Vm8IddVmhCgU1fxC1eyinpwqzXPEYu0NrYzD3YZjlGjyftdLBTeqNblRC0jmJmgxbJIsQlyogVeGnrNaaMVzIg== + dependencies: + "@octokit/openapi-types" "^18.0.0" + "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" @@ -10061,6 +10073,11 @@ react-inspector@^6.0.0: resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.1.tgz#1a37f0165d9df81ee804d63259eaaeabe841287d" integrity sha512-cxKSeFTf7jpSSVddm66sKdolG90qURAX3g1roTeaN6x0YEbtWc8JpmFN9+yIqLNH2uEkYerWLtJZIXRIFuBKrg== +react-inspector@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.2.tgz#aa3028803550cb6dbd7344816d5c80bf39d07e9d" + integrity sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ== + react-is@18.1.0: version "18.1.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67" @@ -10986,6 +11003,13 @@ store2@^2.12.0, store2@^2.14.2: resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068" integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w== +storybook-addon-react-router-v6@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/storybook-addon-react-router-v6/-/storybook-addon-react-router-v6-1.0.2.tgz#0f1239de31291821c93e8266079b8f6235a9c717" + integrity sha512-38W+9D2sIrYAi+oRSbsLhR/umNoLVw2DWF84Jp4f/ZoB8Cg0Qtbvwk043oHqzNOpZrfgj0FaV006oaJBVpE8Kw== + dependencies: + react-inspector "^6.0.1" + storybook-react-context@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/storybook-react-context/-/storybook-react-context-0.6.0.tgz#06c7b48dc95f4619cf12e59429305fbd6f2b1373"