mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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
@@ -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); {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+197
@@ -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": [
|
||||
|
||||
Generated
+187
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -450,6 +450,10 @@
|
||||
"title": "Files",
|
||||
"path": "./api/files.md"
|
||||
},
|
||||
{
|
||||
"title": "Git",
|
||||
"path": "./api/git.md"
|
||||
},
|
||||
{
|
||||
"title": "Insights",
|
||||
"path": "./api/insights.md"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
{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:
|
||||
<div className={styles.copyCode}>
|
||||
<span className={styles.code}>{gitAuthDevice.user_code}</span> {" "}
|
||||
<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),
|
||||
},
|
||||
}))
|
||||
@@ -1581,6 +1581,7 @@ export const MockPermissions: Permissions = {
|
||||
viewDeploymentValues: true,
|
||||
viewUpdateCheck: true,
|
||||
viewDeploymentStats: true,
|
||||
viewGitAuthConfig: true,
|
||||
}
|
||||
|
||||
export const MockDeploymentConfig: Types.DeploymentConfig = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user