mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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:
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user