+ )
+ if (isAxiosError(exchange.failureReason)) {
+ // See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
+ switch (exchange.failureReason.response?.data?.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
+ }
+ }
+
+ return (
+
+
+
+
+ Copy your one-time code:
+
+ {userCode} {" "}
+
+
+
+ Then open the link below and paste it:
+
+
+
+ Open and Paste
+
+ {status}
+
+ )
+}
+
+export default GitAuthDevicePage
+
+const useStyles = makeStyles((theme) => ({
+ title: {
+ fontSize: theme.spacing(4),
+ fontWeight: 400,
+ lineHeight: "140%",
+ margin: 0,
+ },
+
+ text: {
+ fontSize: 16,
+ color: theme.palette.text.secondary,
+ marginBottom: 0,
+ textAlign: "center",
+ lineHeight: "160%",
+ },
+
+ copyCode: {
+ display: "inline-flex",
+ alignItems: "center",
+ },
+
+ code: {
+ fontWeight: "bold",
+ color: theme.palette.text.primary,
+ },
+
+ lineBreak: {
+ whiteSpace: "nowrap",
+ },
+
+ link: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: 16,
+ gap: theme.spacing(1),
+ margin: theme.spacing(2, 0),
+ },
+
+ status: {
+ display: "flex",
+ alignItems: "center",
+ gap: theme.spacing(1),
+ color: theme.palette.text.disabled,
+ },
+}))
diff --git a/site/vite.config.ts b/site/vite.config.ts
index 6e15f7e0f9531..d8ace5c9c5387 100644
--- a/site/vite.config.ts
+++ b/site/vite.config.ts
@@ -62,6 +62,12 @@ export default defineConfig({
})
},
},
+ // The device route is visual!
+ "^/gitauth/(?!.*(device))": {
+ changeOrigin: true,
+ target: process.env.CODER_HOST || "http://localhost:3000",
+ secure: process.env.NODE_ENV === "production",
+ },
"/swagger": {
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
From 76541912e8a44206023182221092ebc28472676d Mon Sep 17 00:00:00 2001
From: Kyle Carberry
Date: Tue, 27 Jun 2023 16:04:21 +0000
Subject: [PATCH 02/17] Improve askpass view
---
cli/gitaskpass.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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); {
From 16ce6e1c7a71467114c17c5b662203964ea18995 Mon Sep 17 00:00:00 2001
From: Kyle Carberry
Date: Tue, 27 Jun 2023 18:10:16 +0000
Subject: [PATCH 03/17] Add routes to improve clarity of git auth
---
coderd/coderd.go | 31 +++--
coderd/gitauth.go | 201 +++++++++++++++++------------
coderd/gitauth/config.go | 156 +++++++++++++++++-----
coderd/gitauth/oauth.go | 58 ++++-----
coderd/gitauth_test.go | 188 ++++++++++++++++++++++++++-
coderd/httpmw/gitauthparam.go | 40 ++++++
coderd/httpmw/gitauthparam_test.go | 49 +++++++
coderd/workspaceagents.go | 22 ++--
codersdk/authorization.go | 18 ---
codersdk/deployment.go | 26 ++--
codersdk/gitauth.go | 85 ++++++++++++
site/vite.config.ts | 6 -
12 files changed, 671 insertions(+), 209 deletions(-)
create mode 100644 coderd/httpmw/gitauthparam.go
create mode 100644 coderd/httpmw/gitauthparam_test.go
create mode 100644 codersdk/gitauth.go
diff --git a/coderd/coderd.go b/coderd/coderd.go
index 87301524013f5..09911d1f88c44 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -455,23 +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 {
+ // We don't need to register a callback handler for device auth.
+ if gitAuthConfig.DeviceAuth != nil {
+ continue
+ }
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
- r.Use(apiKeyMiddlewareRedirect)
-
- useDeviceAuth := gitAuthConfig.DeviceAuth != nil
- if useDeviceAuth {
- r.Use(api.gitAuthDeviceRedirect(gitAuthConfig))
- r.Post("/exchange", api.postGitAuthExchange(gitAuthConfig))
- } else {
- // If device auth isn't in use, then the git provider is using OAuth2!
- r.Use(httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil))
- r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
- }
+ r.Use(
+ apiKeyMiddlewareRedirect,
+ httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil),
+ )
+ r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
})
}
})
+
r.Route("/api/v2", func(r chi.Router) {
api.APIHandler = r
@@ -519,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
index d65c838733de0..4a5bd87ae6eaa 100644
--- a/coderd/gitauth.go
+++ b/coderd/gitauth.go
@@ -5,7 +5,8 @@ import (
"errors"
"fmt"
"net/http"
- "net/url"
+
+ "golang.org/x/sync/errgroup"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/gitauth"
@@ -14,113 +15,143 @@ import (
"github.com/coder/coder/codersdk"
)
-func (*API) gitAuthDeviceRedirect(gitAuthConfig *gitauth.Config) func(http.Handler) http.Handler {
- route := fmt.Sprintf("/gitauth/%s/device", gitAuthConfig.ID)
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- deviceCode := r.URL.Query().Get("device_code")
- if r.Method != http.MethodGet || deviceCode != "" {
- next.ServeHTTP(w, r)
- return
- }
- // If no device code is provided, redirect to the dashboard with query params!
- deviceAuth, err := gitAuthConfig.DeviceAuth.AuthorizeDevice(r.Context())
- if err != nil {
- httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to authorize device.",
- Detail: err.Error(),
- })
- return
- }
- v := url.Values{
- "device_code": {deviceAuth.DeviceCode},
- "user_code": {deviceAuth.UserCode},
- "expires_in": {fmt.Sprintf("%d", deviceAuth.ExpiresIn)},
- "interval": {fmt.Sprintf("%d", deviceAuth.Interval)},
- "verification_uri": {deviceAuth.VerificationURI},
- }
- http.Redirect(w, r, fmt.Sprintf("%s?%s", route, v.Encode()), http.StatusTemporaryRedirect)
+// gitAuthByID returns the git auth status for the given git auth config ID.
+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,
+ }
+
+ link, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
+ ProviderID: config.ID,
+ UserID: apiKey.UserID,
+ })
+ if err == nil {
+ 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, 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
+ }
}
+
+ httpapi.Write(ctx, w, http.StatusOK, res)
}
-func (api *API) postGitAuthExchange(gitAuthConfig *gitauth.Config) http.HandlerFunc {
- return func(rw http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- apiKey := httpmw.APIKey(r)
+func (api *API) postGitAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ apiKey := httpmw.APIKey(r)
+ config := httpmw.GitAuthParam(r)
- var req codersdk.ExchangeGitAuthRequest
- if !httpapi.Read(ctx, rw, r, &req) {
- return
- }
+ 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
+ }
- if gitAuthConfig.DeviceAuth == nil {
+ 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: "Git auth provider does not support device flow.",
+ Message: "Failed to get git auth link.",
+ Detail: err.Error(),
})
return
}
- token, err := gitAuthConfig.DeviceAuth.ExchangeDeviceCode(ctx, req.DeviceCode)
+ _, 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 exchange device code.",
+ Message: "Failed to insert git auth link.",
Detail: err.Error(),
})
return
}
-
- _, err = api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
- ProviderID: gitAuthConfig.ID,
- UserID: apiKey.UserID,
+ } 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 {
- 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: 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: gitAuthConfig.ID,
- UserID: apiKey.UserID,
- UpdatedAt: database.Now(),
- OAuthAccessToken: token.AccessToken,
- OAuthRefreshToken: token.RefreshToken,
- OAuthExpiry: token.Expiry,
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Failed to update git auth link.",
+ Detail: err.Error(),
})
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
- Message: "Failed to update git auth link.",
- Detail: err.Error(),
- })
- return
- }
+ return
}
- httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
+ httpapi.Write(ctx, rw, http.StatusNoContent, nil)
+}
+
+// gitAuthDeviceByID issues a new device auth code for the given git auth config ID.
+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)
}
-// device get
func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
var (
@@ -179,7 +210,7 @@ func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc
redirect := state.Redirect
if redirect == "" {
// This is a nicely rendered screen on the frontend
- redirect = "/gitauth"
+ 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 bdb9c961eb69f..21ed28f3ee3bd 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,14 @@ type Config struct {
// returning it to the user. If omitted, tokens will
// not be validated before being returned.
ValidateURL string
+ // InstallURL 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
}
@@ -60,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 {
@@ -89,26 +103,98 @@ 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, error) {
+ if c.AppInstallationsURL == "" {
+ return nil, nil
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.AppInstallationsURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
}
- return true, nil
+ defer res.Body.Close()
+
+ installs := []codersdk.GitAuthAppInstallation{}
+ if c.Type == codersdk.GitProviderGitHub {
+ var ghInstalls []github.Installation
+ err = json.NewDecoder(res.Body).Decode(&ghInstalls)
+ if err != nil {
+ return nil, err
+ }
+ for _, installation := range ghInstalls {
+ install := codersdk.GitAuthAppInstallation{
+ ID: int(installation.GetID()),
+ ConfigureURL: installation.GetHTMLURL(),
+ }
+ account := installation.GetAccount()
+ if account != nil {
+ install.Account = &codersdk.GitAuthUser{
+ Login: account.GetLogin(),
+ AvatarURL: account.GetAvatarURL(),
+ ProfileURL: account.GetHTMLURL(),
+ Name: account.GetName(),
+ }
+ }
+ installs = append(installs, install)
+ }
+ }
+ return installs, nil
}
// ConvertConfig converts the SDK configuration entry format
@@ -165,7 +251,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],
@@ -174,31 +260,36 @@ 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 = &jwtConfig{oauth2Config}
+ oauthConfig = &jwtConfig{oc}
}
cfg := &Config{
- OAuth2Config: oauthConfig,
- ID: entry.ID,
- Regex: regex,
- Type: typ,
- NoRefresh: entry.NoRefresh,
- ValidateURL: entry.ValidateURL,
+ OAuth2Config: oauthConfig,
+ ID: entry.ID,
+ Regex: regex,
+ Type: typ,
+ NoRefresh: entry.NoRefresh,
+ ValidateURL: entry.ValidateURL,
+ AppInstallationsURL: entry.AppInstallationsURL,
+ AppInstallURL: entry.AppInstallURL,
}
if entry.DeviceFlow {
@@ -209,9 +300,10 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
return nil, xerrors.Errorf("git auth provider %q: device auth url must be provided", entry.ID)
}
cfg.DeviceAuth = &DeviceAuth{
- config: oauth2Config,
- URL: entry.DeviceAuthURL,
- ID: entry.ID,
+ ClientID: entry.ClientID,
+ TokenURL: entry.TokenURL,
+ Scopes: entry.Scopes,
+ CodeURL: entry.DeviceAuthURL,
}
}
diff --git a/coderd/gitauth/oauth.go b/coderd/gitauth/oauth.go
index 5ad611589017d..6374556fe97e1 100644
--- a/coderd/gitauth/oauth.go
+++ b/coderd/gitauth/oauth.go
@@ -44,6 +44,10 @@ 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"},
@@ -95,26 +99,16 @@ func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.Au
}
type DeviceAuth struct {
- config *oauth2.Config
-
- ID string
- URL string
-}
-
-// DeviceAuthorization is the response from the device authorization endpoint.
-// See: https://tools.ietf.org/html/rfc8628#section-3.2
-type DeviceAuthorization 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"`
+ 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) (*DeviceAuthorization, error) {
- if c.URL == "" {
+func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.GitAuthDevice, error) {
+ if c.CodeURL == "" {
return nil, xerrors.New("oauth2: device code URL not set")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.formatDeviceCodeURL(), nil)
@@ -127,16 +121,22 @@ func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*DeviceAuthorization,
return nil, err
}
defer resp.Body.Close()
- var da DeviceAuthorization
+ var da codersdk.GitAuthDevice
return &da, json.NewDecoder(resp.Body).Decode(&da)
}
+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.URL == "" {
- return nil, xerrors.New("oauth2: device code URL not set")
+ if c.TokenURL == "" {
+ return nil, xerrors.New("oauth2: token URL not set")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.formatDeviceTokenURL(deviceCode), nil)
if err != nil {
@@ -151,11 +151,7 @@ func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string)
if resp.StatusCode != http.StatusOK {
return nil, codersdk.ReadBodyAsError(resp)
}
- var body struct {
- *oauth2.Token
- Error string `json:"error"`
- ErrorDescription string `json:"error_description"`
- }
+ var body ExchangeDeviceCodeResponse
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
return nil, err
@@ -168,13 +164,13 @@ func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string)
func (c *DeviceAuth) formatDeviceTokenURL(deviceCode string) string {
var buf bytes.Buffer
- _, _ = buf.WriteString(c.config.Endpoint.TokenURL)
+ _, _ = buf.WriteString(c.TokenURL)
v := url.Values{
- "client_id": {c.config.ClientID},
+ "client_id": {c.ClientID},
"device_code": {deviceCode},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
}
- if strings.Contains(c.config.Endpoint.TokenURL, "?") {
+ if strings.Contains(c.TokenURL, "?") {
_ = buf.WriteByte('&')
} else {
_ = buf.WriteByte('?')
@@ -185,13 +181,13 @@ func (c *DeviceAuth) formatDeviceTokenURL(deviceCode string) string {
func (c *DeviceAuth) formatDeviceCodeURL() string {
var buf bytes.Buffer
- _, _ = buf.WriteString(c.URL)
+ _, _ = buf.WriteString(c.CodeURL)
v := url.Values{
- "client_id": {c.config.ClientID},
- "scope": c.config.Scopes,
+ "client_id": {c.ClientID},
+ "scope": c.Scopes,
}
- if strings.Contains(c.URL, "?") {
+ if strings.Contains(c.CodeURL, "?") {
_ = buf.WriteByte('&')
} else {
_ = buf.WriteByte('?')
diff --git a/coderd/gitauth_test.go b/coderd/gitauth_test.go
index ddea1357e9422..406866ba5709c 100644
--- a/coderd/gitauth_test.go
+++ b/coderd/gitauth_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/google/go-github/v43/github"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -18,6 +19,7 @@ import (
"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"
@@ -25,8 +27,190 @@ import (
"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)
+ coderdtest.RequestGitAuthCallback(t, "test", client)
+ 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)
+ coderdtest.RequestGitAuthCallback(t, "test", client)
+ 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, []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)
+ coderdtest.RequestGitAuthCallback(t, "test", client)
+ 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 TestWorkspaceAgentsGitAuth(t *testing.T) {
+func TestGitAuthCallback(t *testing.T) {
t.Parallel()
t.Run("NoMatchingConfig", func(t *testing.T) {
t.Parallel()
@@ -127,7 +311,7 @@ func TestWorkspaceAgentsGitAuth(t *testing.T) {
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
location, err := resp.Location()
require.NoError(t, err)
- require.Equal(t, "/gitauth", location.Path)
+ require.Equal(t, "/gitauth/github", location.Path)
// Callback again to simulate updating the token.
resp = coderdtest.RequestGitAuthCallback(t, "github", client)
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/workspaceagents.go b/coderd/workspaceagents.go
index 4a1aa8b633bcd..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
diff --git a/codersdk/authorization.go b/codersdk/authorization.go
index fbb62aa5f8698..4e8a6eed7019f 100644
--- a/codersdk/authorization.go
+++ b/codersdk/authorization.go
@@ -3,7 +3,6 @@ package codersdk
import (
"context"
"encoding/json"
- "fmt"
"net/http"
)
@@ -21,10 +20,6 @@ type AuthorizationRequest struct {
Checks map[string]AuthorizationCheck `json:"checks"`
}
-type ExchangeGitAuthRequest struct {
- DeviceCode string `json:"device_code"`
-}
-
// AuthorizationCheck is used to check if the currently authenticated user (or the specified user) can do a given action to a given set of objects.
//
// @Description AuthorizationCheck is used to check if the currently authenticated user (or the specified user) can do a given action to a given set of objects.
@@ -75,16 +70,3 @@ func (c *Client) AuthCheck(ctx context.Context, req AuthorizationRequest) (Autho
var resp AuthorizationResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
-
-// ExchangeGitAuth exchanges a device code for a git auth token.
-func (c *Client) ExchangeGitAuth(ctx context.Context, provider string, req ExchangeGitAuthRequest) error {
- res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/gitauth/%s/exchange", provider), req)
- if err != nil {
- return err
- }
- defer res.Body.Close()
- if res.StatusCode != http.StatusNoContent {
- return ReadBodyAsError(res)
- }
- return nil
-}
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 5c73f267a7a88..ee33f8aafcaba 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -297,18 +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"`
- DeviceFlow bool `json:"device_flow"`
- DeviceAuthURL string `json:"device_auth_url"`
+ 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"`
+ DeviceAuthURL string `json:"device_auth_url"`
}
type ProvisionerConfig struct {
diff --git a/codersdk/gitauth.go b/codersdk/gitauth.go
new file mode 100644
index 0000000000000..790e0291c23d5
--- /dev/null
+++ b/codersdk/gitauth.go
@@ -0,0 +1,85 @@
+package codersdk
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+type GitAuth struct {
+ Authenticated bool `json:"authenticated"`
+ Device bool `json:"device"`
+
+ // User is the user that authenticated with the provider.
+ User *GitAuthUser `json:"user"`
+ // AppInstallations are the installations that the user has access to.
+ AppInstallations []GitAuthAppInstallation `json:"installations"`
+}
+
+type GitAuthAppInstallation struct {
+ ID int `json:"id"`
+ Account *GitAuthUser `json:"account"`
+ ConfigureURL string `json:"configure_url"`
+}
+
+type GitAuthUser struct {
+ Login string
+ AvatarURL string
+ ProfileURL string
+ Name string
+}
+
+// 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/site/vite.config.ts b/site/vite.config.ts
index d8ace5c9c5387..6e15f7e0f9531 100644
--- a/site/vite.config.ts
+++ b/site/vite.config.ts
@@ -62,12 +62,6 @@ export default defineConfig({
})
},
},
- // The device route is visual!
- "^/gitauth/(?!.*(device))": {
- changeOrigin: true,
- target: process.env.CODER_HOST || "http://localhost:3000",
- secure: process.env.NODE_ENV === "production",
- },
"/swagger": {
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
From 0af6a37dca1591fde788e0ccd830f75c07ca7d63 Mon Sep 17 00:00:00 2001
From: Kyle Carberry
Date: Tue, 27 Jun 2023 22:32:21 +0000
Subject: [PATCH 04/17] Redesign the git auth page
---
cli/server.go | 4 +
coderd/apidoc/docs.go | 6 +
coderd/apidoc/swagger.json | 6 +
coderd/gitauth.go | 4 +-
coderd/gitauth/config.go | 22 +-
codersdk/gitauth.go | 17 +-
docs/api/general.md | 2 +
docs/api/schemas.md | 36 ++-
site/src/AppRouter.tsx | 7 +-
site/src/api/api.ts | 20 +-
site/src/api/typesGenerated.ts | 47 ++-
.../components/Dashboard/DashboardLayout.tsx | 1 +
.../components/SignInLayout/SignInLayout.tsx | 2 +-
site/src/components/Welcome/Welcome.tsx | 2 +-
.../GitAuthDevicePage/GitAuthDevicePage.tsx | 7 +-
site/src/pages/GitAuthPage/GitAuthPage.tsx | 280 ++++++++++++++++--
site/src/testHelpers/entities.ts | 1 +
site/src/xServices/auth/authXService.ts | 7 +
18 files changed, 400 insertions(+), 71 deletions(-)
diff --git a/cli/server.go b/cli/server.go
index e69451a666501..07adc0f620eda 100644
--- a/cli/server.go
+++ b/cli/server.go
@@ -164,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 da3d236337b65..36c2095581d9c 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -7443,6 +7443,12 @@ const docTemplate = `{
"codersdk.GitAuthConfig": {
"type": "object",
"properties": {
+ "app_install_url": {
+ "type": "string"
+ },
+ "app_installations_url": {
+ "type": "string"
+ },
"auth_url": {
"type": "string"
},
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index c3779de6be6e1..0b6323fce65d4 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -6664,6 +6664,12 @@
"codersdk.GitAuthConfig": {
"type": "object",
"properties": {
+ "app_install_url": {
+ "type": "string"
+ },
+ "app_installations_url": {
+ "type": "string"
+ },
"auth_url": {
"type": "string"
},
diff --git a/coderd/gitauth.go b/coderd/gitauth.go
index 4a5bd87ae6eaa..4ed4b2f6f9604 100644
--- a/coderd/gitauth.go
+++ b/coderd/gitauth.go
@@ -24,6 +24,8 @@ func (api *API) gitAuthByID(w http.ResponseWriter, r *http.Request) {
res := codersdk.GitAuth{
Authenticated: false,
Device: config.DeviceAuth != nil,
+ AppInstallURL: config.AppInstallURL,
+ Type: config.Type.Pretty(),
}
link, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
@@ -37,7 +39,7 @@ func (api *API) gitAuthByID(w http.ResponseWriter, r *http.Request) {
return err
})
eg.Go(func() (err error) {
- res.AppInstallations, err = config.AppInstallations(ctx, link.OAuthAccessToken)
+ res.AppInstallations, res.AppInstallable, err = config.AppInstallations(ctx, link.OAuthAccessToken)
return err
})
err = eg.Wait()
diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go
index 21ed28f3ee3bd..c0b79842ea48c 100644
--- a/coderd/gitauth/config.go
+++ b/coderd/gitauth/config.go
@@ -44,7 +44,7 @@ type Config struct {
// returning it to the user. If omitted, tokens will
// not be validated before being returned.
ValidateURL string
- // InstallURL is for GitHub App's (and hopefully others eventually)
+ // 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.
@@ -155,29 +155,31 @@ type AppInstallation struct {
// 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, error) {
+func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk.GitAuthAppInstallation, bool, error) {
if c.AppInstallationsURL == "" {
- return nil, nil
+ return nil, false, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.AppInstallationsURL, nil)
if err != nil {
- return nil, err
+ return nil, false, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := http.DefaultClient.Do(req)
if err != nil {
- return nil, err
+ return nil, false, err
}
defer res.Body.Close()
installs := []codersdk.GitAuthAppInstallation{}
if c.Type == codersdk.GitProviderGitHub {
- var ghInstalls []github.Installation
+ var ghInstalls struct {
+ Installations []*github.Installation `json:"installations"`
+ }
err = json.NewDecoder(res.Body).Decode(&ghInstalls)
if err != nil {
- return nil, err
+ return nil, false, err
}
- for _, installation := range ghInstalls {
+ for _, installation := range ghInstalls.Installations {
install := codersdk.GitAuthAppInstallation{
ID: int(installation.GetID()),
ConfigureURL: installation.GetHTMLURL(),
@@ -194,7 +196,7 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk
installs = append(installs, install)
}
}
- return installs, nil
+ return installs, true, nil
}
// ConvertConfig converts the SDK configuration entry format
@@ -301,7 +303,7 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
}
cfg.DeviceAuth = &DeviceAuth{
ClientID: entry.ClientID,
- TokenURL: entry.TokenURL,
+ TokenURL: oc.Endpoint.TokenURL,
Scopes: entry.Scopes,
CodeURL: entry.DeviceAuthURL,
}
diff --git a/codersdk/gitauth.go b/codersdk/gitauth.go
index 790e0291c23d5..1b52a245e7321 100644
--- a/codersdk/gitauth.go
+++ b/codersdk/gitauth.go
@@ -8,13 +8,18 @@ import (
)
type GitAuth struct {
- Authenticated bool `json:"authenticated"`
- Device bool `json:"device"`
+ 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 {
@@ -24,10 +29,10 @@ type GitAuthAppInstallation struct {
}
type GitAuthUser struct {
- Login string
- AvatarURL string
- ProfileURL string
- Name string
+ 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.
diff --git a/docs/api/general.md b/docs/api/general.md
index 9c57b5f42f33b..f0d6d7f2e1c9f 100644
--- a/docs/api/general.md
+++ b/docs/api/general.md
@@ -200,6 +200,8 @@ 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_auth_url": "string",
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index a084cadd16e90..890a0cd91b65c 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -593,6 +593,8 @@
{
"value": [
{
+ "app_install_url": "string",
+ "app_installations_url": "string",
"auth_url": "string",
"client_id": "string",
"device_auth_url": "string",
@@ -1879,6 +1881,8 @@ 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_auth_url": "string",
@@ -2212,6 +2216,8 @@ 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_auth_url": "string",
@@ -2581,6 +2587,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
```json
{
+ "app_install_url": "string",
+ "app_installations_url": "string",
"auth_url": "string",
"client_id": "string",
"device_auth_url": "string",
@@ -2597,19 +2605,21 @@ 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 | | |
-| `device_auth_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 | | |
+| Name | Type | Required | Restrictions | Description |
+| ----------------------- | --------------- | -------- | ------------ | ----------- |
+| `app_install_url` | string | false | | |
+| `app_installations_url` | string | false | | |
+| `auth_url` | string | false | | |
+| `client_id` | string | false | | |
+| `device_auth_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.GitProvider
diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx
index 621d2a663457b..168d13e2f4b10 100644
--- a/site/src/AppRouter.tsx
+++ b/site/src/AppRouter.tsx
@@ -196,12 +196,7 @@ export const AppRouter: FC = () => {
}>
} />
-
- } />
-
- } />
-
-
+ } />
} />
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index 4e6be6db050fc..6047d71cce0aa 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -799,11 +799,25 @@ export const getExperiments = async (): Promise => {
}
}
-export const exchangeGitAuth = async (
+export const getGitAuthProvider = async (
provider: string,
- req: TypesGen.ExchangeGitAuthRequest,
+): 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(`/gitauth/${provider}/exchange`, req)
+ const resp = await axios.post(`/api/v2/gitauth/${provider}/device`, req)
return resp.data
}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 96b3b026e8d5b..4c8b5bf1da181 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -392,11 +392,6 @@ export interface Entitlements {
readonly require_telemetry: boolean
}
-// From codersdk/authorization.go
-export interface ExchangeGitAuthRequest {
- readonly device_code: string
-}
-
// From codersdk/deployment.go
export type Experiments = Experiment[]
@@ -419,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
@@ -427,6 +440,8 @@ 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[]
@@ -434,6 +449,28 @@ export interface GitAuthConfig {
readonly device_auth_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
export interface GitSSHKey {
readonly user_id: string
diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx
index 6aa8db92785ed..cf85fa65631a1 100644
--- a/site/src/components/Dashboard/DashboardLayout.tsx
+++ b/site/src/components/Dashboard/DashboardLayout.tsx
@@ -110,5 +110,6 @@ const useStyles = makeStyles({
siteContent: {
flex: 1,
paddingBottom: dashboardContentBottomPadding, // Add bottom space since we don't use a footer
+ display: "flex",
},
})
diff --git a/site/src/components/SignInLayout/SignInLayout.tsx b/site/src/components/SignInLayout/SignInLayout.tsx
index 402010451cfb7..022bda5fe8be5 100644
--- a/site/src/components/SignInLayout/SignInLayout.tsx
+++ b/site/src/components/SignInLayout/SignInLayout.tsx
@@ -3,7 +3,7 @@ import { FC, ReactNode } from "react"
export const useStyles = makeStyles((theme) => ({
root: {
- height: "100vh",
+ flex: 1,
display: "flex",
justifyContent: "center",
alignItems: "center",
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/GitAuthDevicePage/GitAuthDevicePage.tsx b/site/src/pages/GitAuthDevicePage/GitAuthDevicePage.tsx
index c861dc8bd7fa8..41744c1595c41 100644
--- a/site/src/pages/GitAuthDevicePage/GitAuthDevicePage.tsx
+++ b/site/src/pages/GitAuthDevicePage/GitAuthDevicePage.tsx
@@ -3,14 +3,13 @@ import CircularProgress from "@mui/material/CircularProgress"
import Link from "@mui/material/Link"
import { makeStyles } from "@mui/styles"
import { useQuery } from "@tanstack/react-query"
-import { exchangeGitAuth } from "api/api"
-import { ApiErrorResponse } from "api/errors"
+import { exchangeGitAuthDevice } from "api/api"
import { isAxiosError } from "axios"
import { Alert } from "components/Alert/Alert"
import { CopyButton } from "components/CopyButton/CopyButton"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import { Welcome } from "components/Welcome/Welcome"
-import { FC, useEffect, useMemo, useState } from "react"
+import { FC, useEffect, useState } from "react"
import { useNavigate, useParams, useSearchParams } from "react-router-dom"
const GitAuthDevicePage: FC = () => {
@@ -38,7 +37,7 @@ const GitAuthDevicePage: FC = () => {
const exchange = useQuery({
queryFn: () =>
- exchangeGitAuth(provider as string, { device_code: deviceCode }),
+ exchangeGitAuthDevice(provider as string, { device_code: deviceCode }),
queryKey: ["gitauth", provider as string, deviceCode],
retry: true,
retryDelay: interval * 1000,
diff --git a/site/src/pages/GitAuthPage/GitAuthPage.tsx b/site/src/pages/GitAuthPage/GitAuthPage.tsx
index 18c251a1e4b14..9e5f7fc349b20 100644
--- a/site/src/pages/GitAuthPage/GitAuthPage.tsx
+++ b/site/src/pages/GitAuthPage/GitAuthPage.tsx
@@ -1,64 +1,302 @@
-import Button from "@mui/material/Button"
+import RefreshIcon from "@mui/icons-material/Refresh"
+import OpenInNewIcon from "@mui/icons-material/OpenInNew"
+import CircularProgress from "@mui/material/CircularProgress"
+import Link from "@mui/material/Link"
+import Tooltip from "@mui/material/Tooltip"
import { makeStyles } from "@mui/styles"
+import { useQuery, useQueryClient } from "@tanstack/react-query"
+import {
+ exchangeGitAuthDevice,
+ getGitAuthDevice,
+ getGitAuthProvider,
+} from "api/api"
+import { isAxiosError } from "axios"
+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 { Link as RouterLink } from "react-router-dom"
+import { useParams } from "react-router-dom"
import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService"
+import { usePermissions } from "hooks"
const GitAuthPage: FC = () => {
const styles = useStyles()
+ const { provider } = useParams()
+ if (!provider) {
+ throw new Error("provider must exist")
+ }
+ const permissions = usePermissions()
+ const queryClient = useQueryClient()
+ const query = useQuery({
+ queryKey: ["gitauth", provider],
+ queryFn: () => getGitAuthProvider(provider),
+ refetchOnWindowFocus: true,
+ })
+
useEffect(() => {
+ if (!query.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()
- }, [])
+ }, [query.data?.authenticated])
+
+ if (query.isLoading || !query.data) {
+ return null
+ }
+
+ if (!query.data.authenticated) {
+ return (
+
+
+
+ {query.data.device && }
+
+ )
+ }
+
+ const hasInstallations = query.data.installations?.length > 0
return (
-
+
- Your Git authentication token will be refreshed to keep you signed in.
+ Hey @{query.data.user?.login} 👋! You are now authenticated with Git.
+ Feel free to close this window!
- )
- if (isAxiosError(exchange.failureReason)) {
- // See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
- switch (exchange.failureReason.response?.data?.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
- }
- }
-
- return (
-
-
-
-
- Copy your one-time code:
-
- {userCode} {" "}
-
-
-
- Then open the link below and paste it:
-
-
-
- Open and Paste
-
- {status}
-
- )
-}
-
-export default GitAuthDevicePage
-
-const useStyles = makeStyles((theme) => ({
- title: {
- fontSize: theme.spacing(4),
- fontWeight: 400,
- lineHeight: "140%",
- margin: 0,
- },
-
- text: {
- fontSize: 16,
- color: theme.palette.text.secondary,
- marginBottom: 0,
- textAlign: "center",
- lineHeight: "160%",
- },
-
- copyCode: {
- display: "inline-flex",
- alignItems: "center",
- },
-
- code: {
- fontWeight: "bold",
- color: theme.palette.text.primary,
- },
-
- lineBreak: {
- whiteSpace: "nowrap",
- },
-
- link: {
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- fontSize: 16,
- gap: theme.spacing(1),
- margin: theme.spacing(2, 0),
- },
-
- status: {
- display: "flex",
- alignItems: "center",
- gap: theme.spacing(1),
- color: theme.palette.text.disabled,
- },
-}))
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 9e5f7fc349b20..b0469f62af609 100644
--- a/site/src/pages/GitAuthPage/GitAuthPage.tsx
+++ b/site/src/pages/GitAuthPage/GitAuthPage.tsx
@@ -1,302 +1,100 @@
-import RefreshIcon from "@mui/icons-material/Refresh"
-import OpenInNewIcon from "@mui/icons-material/OpenInNew"
-import CircularProgress from "@mui/material/CircularProgress"
-import Link from "@mui/material/Link"
-import Tooltip from "@mui/material/Tooltip"
-import { makeStyles } from "@mui/styles"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import {
exchangeGitAuthDevice,
getGitAuthDevice,
getGitAuthProvider,
} from "api/api"
-import { isAxiosError } from "axios"
-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 { useParams } from "react-router-dom"
-import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService"
import { usePermissions } from "hooks"
+import { useEffect } from "react"
+import { redirect, 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 GitAuthPage = () => {
const { provider } = useParams()
if (!provider) {
throw new Error("provider must exist")
}
const permissions = usePermissions()
const queryClient = useQueryClient()
- const query = useQuery({
+ const getGitAuthProviderQuery = useQuery({
queryKey: ["gitauth", provider],
queryFn: () => getGitAuthProvider(provider),
refetchOnWindowFocus: true,
})
- useEffect(() => {
- if (!query.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")
- }, [query.data?.authenticated])
-
- if (query.isLoading || !query.data) {
- return null
- }
-
- if (!query.data.authenticated) {
- return (
-
-
-
- {query.data.device && }
-
- )
- }
-
- const hasInstallations = query.data.installations?.length > 0
-
- return (
-
-
-
- Hey @{query.data.user?.login} 👋! You are now authenticated with Git.
- Feel free to close this window!
-
- )
- if (isAxiosError(exchange.failureReason)) {
- // See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
- switch (exchange.failureReason.response?.data?.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:{" "}
- {exchange.failureReason.message}
-
- )
- break
+ 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")
+ }, [getGitAuthProviderQuery.data?.authenticated])
+
+ if (getGitAuthProviderQuery.isLoading || !getGitAuthProviderQuery.data) {
+ return null
}
- if (!device.data) {
- return
+ let deviceExchangeError: ApiErrorResponse | undefined
+ if (isAxiosError(exchangeGitAuthDeviceQuery.failureReason)) {
+ deviceExchangeError =
+ exchangeGitAuthDeviceQuery.failureReason.response?.data
}
- return (
-
- Hey @{gitAuth.user?.login} 👋! You are now authenticated with Git. Feel
- free to close this window!
+ Hey @{gitAuth.user?.login} 👋!{" "}
+ {(!gitAuth.app_installable || gitAuth.installations.length > 0) &&
+ "You are now authenticated with Git. Feel free to close this window!"}