feat: add github device flow for authentication (#8232)

* feat: add github device flow for authentication

This will allow us to add a GitHub OAuth provider out-of-the-box
to reduce setup requirements.

* Improve askpass view

* Add routes to improve clarity of git auth

* Redesign the git auth page

* Refactor to add a page view

* Fix sideways layout

* Remove legacy notify

* Fix git auth redirects

* Add E2E tests

* Fix route documentation

* Fix imports

* Remove unused imports

* Fix E2E web test

* Fix friendly message appearance

* Fix layout shifting for full-screen sign-in

* Fix height going to 100%

* Fix comments
This commit is contained in:
Kyle Carberry
2023-06-29 18:58:01 +00:00
committed by GitHub
parent 7f82eca64a
commit 34467a3289
43 changed files with 2743 additions and 511 deletions
+1 -2
View File
@@ -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,
+2 -2
View File
@@ -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); {
+12
View File
@@ -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
}
+197
View File
@@ -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": [
@@ -7490,15 +7587,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"
},
@@ -7525,6 +7685,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": [
+187
View File
@@ -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": [
@@ -6705,15 +6792,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"
},
@@ -6740,6 +6890,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"],
+18 -3
View File
@@ -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,
+253
View File
@@ -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)
}
}
+150 -37
View File
@@ -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)
}
return true, nil
var user *codersdk.GitAuthUser
if c.Type == codersdk.GitProviderGitHub {
var ghUser github.User
err = json.NewDecoder(res.Body).Decode(&ghUser)
if err == nil {
user = &codersdk.GitAuthUser{
Login: ghUser.GetLogin(),
AvatarURL: ghUser.GetAvatarURL(),
ProfileURL: ghUser.GetHTMLURL(),
Name: ghUser.GetName(),
}
}
}
return true, user, nil
}
type AppInstallation struct {
ID int
// Login is the username of the installation.
Login string
// URL is a link to configure the app install.
URL string
}
// AppInstallations returns a list of app installations for the given token.
// If the provider does not support app installations, it returns nil.
func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk.GitAuthAppInstallation, bool, error) {
if c.AppInstallationsURL == "" {
return nil, false, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.AppInstallationsURL, nil)
if err != nil {
return nil, false, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, false, err
}
defer res.Body.Close()
// It's possible the installation URL is misconfigured, so we don't
// want to return an error here.
if res.StatusCode != http.StatusOK {
return nil, false, nil
}
installs := []codersdk.GitAuthAppInstallation{}
if c.Type == codersdk.GitProviderGitHub {
var ghInstalls struct {
Installations []*github.Installation `json:"installations"`
}
err = json.NewDecoder(res.Body).Decode(&ghInstalls)
if err != nil {
return nil, false, err
}
for _, installation := range ghInstalls.Installations {
account := installation.GetAccount()
if account == nil {
continue
}
installs = append(installs, codersdk.GitAuthAppInstallation{
ID: int(installation.GetID()),
ConfigureURL: installation.GetHTMLURL(),
Account: codersdk.GitAuthUser{
Login: account.GetLogin(),
AvatarURL: account.GetAvatarURL(),
ProfileURL: account.GetHTMLURL(),
Name: account.GetName(),
},
})
}
}
return installs, true, nil
}
// ConvertConfig converts the SDK configuration entry format
@@ -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]
}
var oauthConfig httpmw.OAuth2Config = oauth2Config
// Azure DevOps uses JWT token authentication!
if typ == codersdk.GitProviderAzureDevops {
oauthConfig = newJWTOAuthConfig(oauth2Config)
if entry.AppInstallationsURL == "" {
entry.AppInstallationsURL = appInstallationsURL[typ]
}
configs = append(configs, &Config{
OAuth2Config: oauthConfig,
ID: entry.ID,
Regex: regex,
Type: typ,
NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL,
})
var oauthConfig OAuth2Config = oc
// Azure DevOps uses JWT token authentication!
if typ == codersdk.GitProviderAzureDevops {
oauthConfig = &jwtConfig{oc}
}
cfg := &Config{
OAuth2Config: oauthConfig,
ID: entry.ID,
Regex: regex,
Type: typ,
NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL,
AppInstallationsURL: entry.AppInstallationsURL,
AppInstallURL: entry.AppInstallURL,
}
if entry.DeviceFlow {
if entry.DeviceCodeURL == "" {
entry.DeviceCodeURL = deviceAuthURL[typ]
}
if entry.DeviceCodeURL == "" {
return nil, xerrors.Errorf("git auth provider %q: device auth url must be provided", entry.ID)
}
cfg.DeviceAuth = &DeviceAuth{
ClientID: entry.ClientID,
TokenURL: oc.Endpoint.TokenURL,
Scopes: entry.Scopes,
CodeURL: entry.DeviceCodeURL,
}
}
configs = append(configs, cfg)
}
return configs, nil
}
+9 -7
View File
@@ -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) {
+119 -6
View File
@@ -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
}
+481
View File
@@ -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)
})
}
+40
View File
@@ -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)
})
}
}
+49
View File
@@ -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)
})
}
+4 -4
View File
@@ -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,
})
-8
View File
@@ -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,
+10 -76
View File
@@ -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 {
-268
View File
@@ -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()
+14 -10
View File
@@ -298,16 +298,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 {
+90
View File
@@ -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)
}
+4
View File
@@ -201,8 +201,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",
+127
View File
@@ -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).
+138 -11
View File
@@ -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",
@@ -1878,8 +1882,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",
@@ -2210,8 +2218,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",
@@ -2574,12 +2586,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",
@@ -2592,17 +2673,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
+4
View File
@@ -450,6 +450,10 @@
"title": "Files",
"path": "./api/files.md"
},
{
"title": "Git",
"path": "./api/git.md"
},
{
"title": "Insights",
"path": "./api/insights.md"
+2 -9
View File
@@ -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 = [
</ThemeProvider>
</StyledEngineProvider>
),
(Story) => {
return (
<MemoryRouter>
<Story />
</MemoryRouter>
)
},
withRouter,
(Story) => {
return (
<HelmetProvider>
+16
View File
@@ -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",
}
+32
View File
@@ -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<void>
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<void> {
return this.promise
}
}
export const createServer = async (
port: number,
): Promise<ReturnType<typeof express>> => {
const e = express()
await new Promise<void>((r) => e.listen(port, r))
return e
}
+48 -4
View File
@@ -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
+140
View File
@@ -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,
}
+2
View File
@@ -100,6 +100,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",
@@ -150,6 +151,7 @@
"resize-observer": "1.0.4",
"semver": "7.5.2",
"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",
+1 -1
View File
@@ -193,7 +193,7 @@ export const AppRouter: FC = () => {
<Route element={<DashboardLayout />}>
<Route index element={<IndexPage />} />
<Route path="gitauth" element={<GitAuthPage />} />
<Route path="gitauth/:provider" element={<GitAuthPage />} />
<Route path="workspaces" element={<WorkspacesPage />} />
+22
View File
@@ -799,6 +799,28 @@ export const getExperiments = async (): Promise<TypesGen.Experiment[]> => {
}
}
export const getGitAuthProvider = async (
provider: string,
): Promise<TypesGen.GitAuth> => {
const resp = await axios.get(`/api/v2/gitauth/${provider}`)
return resp.data
}
export const getGitAuthDevice = async (
provider: string,
): Promise<TypesGen.GitAuthDevice> => {
const resp = await axios.get(`/api/v2/gitauth/${provider}/device`)
return resp.data
}
export const exchangeGitAuthDevice = async (
provider: string,
req: TypesGen.GitAuthDeviceExchange,
): Promise<void> => {
const resp = await axios.post(`/api/v2/gitauth/${provider}/device`, req)
return resp.data
}
export const getAuditLogs = async (
options: TypesGen.AuditLogsRequest,
): Promise<TypesGen.AuditLogResponse> => {
+44
View File
@@ -415,6 +415,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
@@ -423,9 +441,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
@@ -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",
},
})
-1
View File
@@ -20,7 +20,6 @@ const useStyles = makeStyles(() => ({
margin: "0 auto",
maxWidth: ({ maxWidth }: { maxWidth: number }) => maxWidth,
padding: `0 ${sidePadding}px`,
flex: 1,
width: "100%",
},
}))
@@ -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,
+1 -1
View File
@@ -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,
@@ -1,12 +0,0 @@
import { ComponentMeta, Story } from "@storybook/react"
import GitAuthPage from "./GitAuthPage"
export default {
title: "pages/GitAuthPage",
component: GitAuthPage,
} as ComponentMeta<typeof GitAuthPage>
const Template: Story = (args) => <GitAuthPage {...args} />
export const Default = Template.bind({})
Default.args = {}
+85 -47
View File
@@ -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])
if (getGitAuthProviderQuery.isLoading || !getGitAuthProviderQuery.data) {
return null
}
let deviceExchangeError: ApiErrorResponse | undefined
if (isAxiosError(exchangeGitAuthDeviceQuery.failureReason)) {
deviceExchangeError =
exchangeGitAuthDeviceQuery.failureReason.response?.data
}
if (
!getGitAuthProviderQuery.data.authenticated &&
!getGitAuthProviderQuery.data.device
) {
window.location.href = `/gitauth/${provider}/callback`
return null
}
return (
<SignInLayout>
<Welcome message="Authenticated with Git!" />
<p className={styles.text}>
Your Git authentication token will be refreshed to keep you signed in.
</p>
<div className={styles.links}>
<Button component={RouterLink} size="large" to="/workspaces" fullWidth>
Go to workspaces
</Button>
</div>
</SignInLayout>
<GitAuthPageView
gitAuth={getGitAuthProviderQuery.data}
onReauthenticate={() => {
queryClient.setQueryData(["gitauth", provider], {
...getGitAuthProviderQuery.data,
authenticated: false,
})
}}
viewGitAuthConfig={permissions.viewGitAuthConfig}
deviceExchangeError={deviceExchangeError}
gitAuthDevice={getGitAuthDeviceQuery.data}
/>
)
}
export default GitAuthPage
const useStyles = makeStyles((theme) => ({
title: {
fontSize: theme.spacing(4),
fontWeight: 400,
lineHeight: "140%",
margin: 0,
},
text: {
fontSize: 16,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(4),
textAlign: "center",
lineHeight: "160%",
},
lineBreak: {
whiteSpace: "nowrap",
},
links: {
display: "flex",
justifyContent: "flex-end",
paddingTop: theme.spacing(1),
},
}))
@@ -0,0 +1,119 @@
import { Meta, StoryFn } from "@storybook/react"
import GitAuthPageView, { GitAuthPageViewProps } from "./GitAuthPageView"
export default {
title: "pages/GitAuthPageView",
component: GitAuthPageView,
} as Meta<typeof GitAuthPageView>
const Template: StoryFn<GitAuthPageViewProps> = (args) => (
<GitAuthPageView {...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: "",
},
},
}
@@ -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<GitAuthPageViewProps> = ({
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 (
<SignInLayout>
<Welcome message={`Authenticate with ${gitAuth.type}`} />
{gitAuth.device && (
<GitDeviceAuth
deviceExchangeError={deviceExchangeError}
gitAuthDevice={gitAuthDevice}
/>
)}
</SignInLayout>
)
}
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 = (
<Link href={gitAuth.app_install_url} target="_blank" rel="noreferrer">
{installTheApp}
</Link>
)
}
return (
<SignInLayout>
<Welcome message={`You've authenticated with ${gitAuth.type}!`} />
<p className={styles.text}>
Hey @{gitAuth.user?.login} 👋!{" "}
{(!gitAuth.app_installable || gitAuth.installations.length > 0) &&
"You are now authenticated with Git. Feel free to close this window!"}
</p>
{gitAuth.installations.length > 0 && (
<div className={styles.authorizedInstalls}>
{gitAuth.installations.map((install) => {
if (!install.account) {
return
}
return (
<Tooltip key={install.id} title={install.account.login}>
<Link
href={install.account.profile_url}
target="_blank"
rel="noreferrer"
>
<Avatar
size="sm"
src={install.account.avatar_url}
colorScheme="darken"
>
{install.account.login}
</Avatar>
</Link>
</Tooltip>
)
})}
&nbsp;
{gitAuth.installations.length} organization
{gitAuth.installations.length !== 1 && "s are"} authorized
</div>
)}
<div className={styles.links}>
{!hasInstallations && gitAuth.app_installable && (
<Alert severity="warning" className={styles.installAlert}>
You must {installTheApp} to clone private repositories. Accounts
will appear here once authorized.
</Alert>
)}
{viewGitAuthConfig &&
gitAuth.app_install_url &&
gitAuth.app_installable && (
<Link
href={gitAuth.app_install_url}
target="_blank"
rel="noreferrer"
className={styles.link}
>
<OpenInNewIcon fontSize="small" />
{gitAuth.installations.length > 0
? "Configure"
: "Install"} the {gitAuth.type} App
</Link>
)}
<Link
className={styles.link}
href="#"
onClick={() => {
onReauthenticate()
}}
>
<RefreshIcon /> Reauthenticate
</Link>
</div>
</SignInLayout>
)
}
const GitDeviceAuth: FC<{
gitAuthDevice?: GitAuthDevice
deviceExchangeError?: ApiErrorResponse
}> = ({ gitAuthDevice, deviceExchangeError }) => {
const styles = useStyles()
let status = (
<p className={styles.status}>
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
Checking for authentication...
</p>
)
if (deviceExchangeError) {
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
switch (deviceExchangeError.detail) {
case "authorization_pending":
break
case "expired_token":
status = (
<Alert severity="error">
The one-time code has expired. Refresh to get a new one!
</Alert>
)
break
case "access_denied":
status = (
<Alert severity="error">Access to the Git provider was denied.</Alert>
)
break
default:
status = (
<Alert severity="error">
An unknown error occurred. Please try again:{" "}
{deviceExchangeError.message}
</Alert>
)
break
}
}
if (!gitAuthDevice) {
return <CircularProgress />
}
return (
<div>
<p className={styles.text}>
Copy your one-time code:&nbsp;
<div className={styles.copyCode}>
<span className={styles.code}>{gitAuthDevice.user_code}</span>&nbsp;{" "}
<CopyButton text={gitAuthDevice.user_code} />
</div>
<br />
Then open the link below and paste it:
</p>
<div className={styles.links}>
<Link
className={styles.link}
href={gitAuthDevice.verification_uri}
target="_blank"
rel="noreferrer"
>
<OpenInNewIcon fontSize="small" />
Open and Paste
</Link>
</div>
{status}
</div>
)
}
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),
},
}))
+1
View File
@@ -1581,6 +1581,7 @@ export const MockPermissions: Permissions = {
viewDeploymentValues: true,
viewUpdateCheck: true,
viewDeploymentStats: true,
viewGitAuthConfig: true,
}
export const MockDeploymentConfig: Types.DeploymentConfig = {
+7
View File
@@ -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",
+24
View File
@@ -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"
@@ -10979,6 +10996,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"