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

* feat: add github device flow for authentication

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

* Improve askpass view

* Add routes to improve clarity of git auth

* Redesign the git auth page

* Refactor to add a page view

* Fix sideways layout

* Remove legacy notify

* Fix git auth redirects

* Add E2E tests

* Fix route documentation

* Fix imports

* Remove unused imports

* Fix E2E web test

* Fix friendly message appearance

* Fix layout shifting for full-screen sign-in

* Fix height going to 100%

* Fix comments
This commit is contained in:
Kyle Carberry
2023-06-29 18:58:01 +00:00
committed by GitHub
parent 7f82eca64a
commit 34467a3289
43 changed files with 2743 additions and 511 deletions
+197
View File
@@ -658,6 +658,103 @@ const docTemplate = `{
}
}
},
"/gitauth/{gitauth}": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Git"
],
"summary": "Get git auth by ID",
"operationId": "get-git-auth-by-id",
"parameters": [
{
"type": "string",
"format": "string",
"description": "Git Provider ID",
"name": "gitauth",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GitAuth"
}
}
}
}
},
"/gitauth/{gitauth}/device": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Git"
],
"summary": "Get git auth device by ID.",
"operationId": "get-git-auth-device-by-id",
"parameters": [
{
"type": "string",
"format": "string",
"description": "Git Provider ID",
"name": "gitauth",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GitAuthDevice"
}
}
}
},
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Git"
],
"summary": "Post git auth device by ID",
"operationId": "post-git-auth-device-by-id",
"parameters": [
{
"type": "string",
"format": "string",
"description": "Git Provider ID",
"name": "gitauth",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/groups/{group}": {
"get": {
"security": [
@@ -7490,15 +7587,78 @@ const docTemplate = `{
}
}
},
"codersdk.GitAuth": {
"type": "object",
"properties": {
"app_install_url": {
"description": "AppInstallURL is the URL to install the app.",
"type": "string"
},
"app_installable": {
"description": "AppInstallable is true if the request for app installs was successful.",
"type": "boolean"
},
"authenticated": {
"type": "boolean"
},
"device": {
"type": "boolean"
},
"installations": {
"description": "AppInstallations are the installations that the user has access to.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.GitAuthAppInstallation"
}
},
"type": {
"type": "string"
},
"user": {
"description": "User is the user that authenticated with the provider.",
"allOf": [
{
"$ref": "#/definitions/codersdk.GitAuthUser"
}
]
}
}
},
"codersdk.GitAuthAppInstallation": {
"type": "object",
"properties": {
"account": {
"$ref": "#/definitions/codersdk.GitAuthUser"
},
"configure_url": {
"type": "string"
},
"id": {
"type": "integer"
}
}
},
"codersdk.GitAuthConfig": {
"type": "object",
"properties": {
"app_install_url": {
"type": "string"
},
"app_installations_url": {
"type": "string"
},
"auth_url": {
"type": "string"
},
"client_id": {
"type": "string"
},
"device_code_url": {
"type": "string"
},
"device_flow": {
"type": "boolean"
},
"id": {
"type": "string"
},
@@ -7525,6 +7685,43 @@ const docTemplate = `{
}
}
},
"codersdk.GitAuthDevice": {
"type": "object",
"properties": {
"device_code": {
"type": "string"
},
"expires_in": {
"type": "integer"
},
"interval": {
"type": "integer"
},
"user_code": {
"type": "string"
},
"verification_uri": {
"type": "string"
}
}
},
"codersdk.GitAuthUser": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"login": {
"type": "string"
},
"name": {
"type": "string"
},
"profile_url": {
"type": "string"
}
}
},
"codersdk.GitProvider": {
"type": "string",
"enum": [
+187
View File
@@ -562,6 +562,93 @@
}
}
},
"/gitauth/{gitauth}": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Git"],
"summary": "Get git auth by ID",
"operationId": "get-git-auth-by-id",
"parameters": [
{
"type": "string",
"format": "string",
"description": "Git Provider ID",
"name": "gitauth",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GitAuth"
}
}
}
}
},
"/gitauth/{gitauth}/device": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Git"],
"summary": "Get git auth device by ID.",
"operationId": "get-git-auth-device-by-id",
"parameters": [
{
"type": "string",
"format": "string",
"description": "Git Provider ID",
"name": "gitauth",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GitAuthDevice"
}
}
}
},
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Git"],
"summary": "Post git auth device by ID",
"operationId": "post-git-auth-device-by-id",
"parameters": [
{
"type": "string",
"format": "string",
"description": "Git Provider ID",
"name": "gitauth",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/groups/{group}": {
"get": {
"security": [
@@ -6705,15 +6792,78 @@
}
}
},
"codersdk.GitAuth": {
"type": "object",
"properties": {
"app_install_url": {
"description": "AppInstallURL is the URL to install the app.",
"type": "string"
},
"app_installable": {
"description": "AppInstallable is true if the request for app installs was successful.",
"type": "boolean"
},
"authenticated": {
"type": "boolean"
},
"device": {
"type": "boolean"
},
"installations": {
"description": "AppInstallations are the installations that the user has access to.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.GitAuthAppInstallation"
}
},
"type": {
"type": "string"
},
"user": {
"description": "User is the user that authenticated with the provider.",
"allOf": [
{
"$ref": "#/definitions/codersdk.GitAuthUser"
}
]
}
}
},
"codersdk.GitAuthAppInstallation": {
"type": "object",
"properties": {
"account": {
"$ref": "#/definitions/codersdk.GitAuthUser"
},
"configure_url": {
"type": "string"
},
"id": {
"type": "integer"
}
}
},
"codersdk.GitAuthConfig": {
"type": "object",
"properties": {
"app_install_url": {
"type": "string"
},
"app_installations_url": {
"type": "string"
},
"auth_url": {
"type": "string"
},
"client_id": {
"type": "string"
},
"device_code_url": {
"type": "string"
},
"device_flow": {
"type": "boolean"
},
"id": {
"type": "string"
},
@@ -6740,6 +6890,43 @@
}
}
},
"codersdk.GitAuthDevice": {
"type": "object",
"properties": {
"device_code": {
"type": "string"
},
"expires_in": {
"type": "integer"
},
"interval": {
"type": "integer"
},
"user_code": {
"type": "string"
},
"verification_uri": {
"type": "string"
}
}
},
"codersdk.GitAuthUser": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"login": {
"type": "string"
},
"name": {
"type": "string"
},
"profile_url": {
"type": "string"
}
}
},
"codersdk.GitProvider": {
"type": "string",
"enum": ["azure-devops", "github", "gitlab", "bitbucket"],
+18 -3
View File
@@ -455,17 +455,23 @@ func New(options *Options) *API {
})
})
// Register callback handlers for each OAuth2 provider.
r.Route("/gitauth", func(r chi.Router) {
for _, gitAuthConfig := range options.GitAuthConfigs {
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
// We don't need to register a callback handler for device auth.
if gitAuthConfig.DeviceAuth != nil {
continue
}
r.Route(fmt.Sprintf("/%s/callback", gitAuthConfig.ID), func(r chi.Router) {
r.Use(
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil),
apiKeyMiddlewareRedirect,
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil),
)
r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
r.Get("/", api.gitAuthCallback(gitAuthConfig))
})
}
})
r.Route("/api/v2", func(r chi.Router) {
api.APIHandler = r
@@ -513,6 +519,15 @@ func New(options *Options) *API {
r.Get("/{fileID}", api.fileByID)
r.Post("/", api.postFile)
})
r.Route("/gitauth/{gitauth}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractGitAuthParam(options.GitAuthConfigs),
)
r.Get("/", api.gitAuthByID)
r.Post("/device", api.postGitAuthDeviceByID)
r.Get("/device", api.gitAuthDeviceByID)
})
r.Route("/organizations", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
+253
View File
@@ -0,0 +1,253 @@
package coderd
import (
"database/sql"
"errors"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
// @Summary Get git auth by ID
// @ID get-git-auth-by-id
// @Security CoderSessionToken
// @Produce json
// @Tags Git
// @Param gitauth path string true "Git Provider ID" format(string)
// @Success 200 {object} codersdk.GitAuth
// @Router /gitauth/{gitauth} [get]
func (api *API) gitAuthByID(w http.ResponseWriter, r *http.Request) {
config := httpmw.GitAuthParam(r)
apiKey := httpmw.APIKey(r)
ctx := r.Context()
res := codersdk.GitAuth{
Authenticated: false,
Device: config.DeviceAuth != nil,
AppInstallURL: config.AppInstallURL,
Type: config.Type.Pretty(),
AppInstallations: []codersdk.GitAuthAppInstallation{},
}
link, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
ProviderID: config.ID,
UserID: apiKey.UserID,
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get git auth link.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, w, http.StatusOK, res)
return
}
var eg errgroup.Group
eg.Go(func() (err error) {
res.Authenticated, res.User, err = config.ValidateToken(ctx, link.OAuthAccessToken)
return err
})
eg.Go(func() (err error) {
res.AppInstallations, res.AppInstallable, err = config.AppInstallations(ctx, link.OAuthAccessToken)
return err
})
err = eg.Wait()
if err != nil {
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate token.",
Detail: err.Error(),
})
return
}
if res.AppInstallations == nil {
res.AppInstallations = []codersdk.GitAuthAppInstallation{}
}
httpapi.Write(ctx, w, http.StatusOK, res)
}
// @Summary Post git auth device by ID
// @ID post-git-auth-device-by-id
// @Security CoderSessionToken
// @Tags Git
// @Param gitauth path string true "Git Provider ID" format(string)
// @Success 204
// @Router /gitauth/{gitauth}/device [post]
func (api *API) postGitAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
config := httpmw.GitAuthParam(r)
var req codersdk.GitAuthDeviceExchange
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if config.DeviceAuth == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Git auth provider does not support device flow.",
})
return
}
token, err := config.DeviceAuth.ExchangeDeviceCode(ctx, req.DeviceCode)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to exchange device code.",
Detail: err.Error(),
})
return
}
_, err = api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
ProviderID: config.ID,
UserID: apiKey.UserID,
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to get git auth link.",
Detail: err.Error(),
})
return
}
_, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{
ProviderID: config.ID,
UserID: apiKey.UserID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OAuthAccessToken: token.AccessToken,
OAuthRefreshToken: token.RefreshToken,
OAuthExpiry: token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to insert git auth link.",
Detail: err.Error(),
})
return
}
} else {
_, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
ProviderID: config.ID,
UserID: apiKey.UserID,
UpdatedAt: database.Now(),
OAuthAccessToken: token.AccessToken,
OAuthRefreshToken: token.RefreshToken,
OAuthExpiry: token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to update git auth link.",
Detail: err.Error(),
})
return
}
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
// @Summary Get git auth device by ID.
// @ID get-git-auth-device-by-id
// @Security CoderSessionToken
// @Produce json
// @Tags Git
// @Param gitauth path string true "Git Provider ID" format(string)
// @Success 200 {object} codersdk.GitAuthDevice
// @Router /gitauth/{gitauth}/device [get]
func (*API) gitAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
config := httpmw.GitAuthParam(r)
ctx := r.Context()
if config.DeviceAuth == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Git auth device flow not supported.",
})
return
}
deviceAuth, err := config.DeviceAuth.AuthorizeDevice(r.Context())
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to authorize device.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, deviceAuth)
}
func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
state = httpmw.OAuth2(r)
apiKey = httpmw.APIKey(r)
)
_, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to get git auth link.",
Detail: err.Error(),
})
return
}
_, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OAuthAccessToken: state.Token.AccessToken,
OAuthRefreshToken: state.Token.RefreshToken,
OAuthExpiry: state.Token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to insert git auth link.",
Detail: err.Error(),
})
return
}
} else {
_, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
UpdatedAt: database.Now(),
OAuthAccessToken: state.Token.AccessToken,
OAuthRefreshToken: state.Token.RefreshToken,
OAuthExpiry: state.Token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to update git auth link.",
Detail: err.Error(),
})
return
}
}
redirect := state.Redirect
if redirect == "" {
// This is a nicely rendered screen on the frontend
redirect = fmt.Sprintf("/gitauth/%s", gitAuthConfig.ID)
}
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}
}
+150 -37
View File
@@ -2,6 +2,7 @@ package gitauth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -11,15 +12,22 @@ import (
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"github.com/google/go-github/v43/github"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
type OAuth2Config interface {
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource
}
// Config is used for authentication for Git operations.
type Config struct {
httpmw.OAuth2Config
OAuth2Config
// ID is a unique identifier for the authenticator.
ID string
// Regex is a regexp that URLs will match against.
@@ -36,6 +44,16 @@ type Config struct {
// returning it to the user. If omitted, tokens will
// not be validated before being returned.
ValidateURL string
// AppInstallURL is for GitHub App's (and hopefully others eventually)
// to provide a link to install the app. There's installation
// of the application, and user authentication. It's possible
// for the user to authenticate but the application to not.
AppInstallURL string
// InstallationsURL is an API endpoint that returns a list of
// installations for the user. This is used for GitHub Apps.
AppInstallationsURL string
// DeviceAuth is set if the provider uses the device flow.
DeviceAuth *DeviceAuth
}
// RefreshToken automatically refreshes the token if expired and permitted.
@@ -58,15 +76,13 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, gitAuthLin
return gitAuthLink, false, nil
}
if c.ValidateURL != "" {
valid, err := c.ValidateToken(ctx, token.AccessToken)
if err != nil {
return gitAuthLink, false, xerrors.Errorf("validate git auth token: %w", err)
}
if !valid {
// The token is no longer valid!
return gitAuthLink, false, nil
}
valid, _, err := c.ValidateToken(ctx, token.AccessToken)
if err != nil {
return gitAuthLink, false, xerrors.Errorf("validate git auth token: %w", err)
}
if !valid {
// The token is no longer valid!
return gitAuthLink, false, nil
}
if token.AccessToken != gitAuthLink.OAuthAccessToken {
@@ -87,26 +103,104 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, gitAuthLin
}
// ValidateToken ensures the Git token provided is valid!
func (c *Config) ValidateToken(ctx context.Context, token string) (bool, error) {
// The user is optionally returned if the provider supports it.
func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *codersdk.GitAuthUser, error) {
if c.ValidateURL == "" {
// Default that the token is valid if no validation URL is provided.
return true, nil, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ValidateURL, nil)
if err != nil {
return false, err
return false, nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := http.DefaultClient.Do(req)
if err != nil {
return false, err
return false, nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized {
// The token is no longer valid!
return false, nil
return false, nil, nil
}
if res.StatusCode != http.StatusOK {
data, _ := io.ReadAll(res.Body)
return false, xerrors.Errorf("status %d: body: %s", res.StatusCode, data)
return false, nil, xerrors.Errorf("status %d: body: %s", res.StatusCode, data)
}
return true, nil
var user *codersdk.GitAuthUser
if c.Type == codersdk.GitProviderGitHub {
var ghUser github.User
err = json.NewDecoder(res.Body).Decode(&ghUser)
if err == nil {
user = &codersdk.GitAuthUser{
Login: ghUser.GetLogin(),
AvatarURL: ghUser.GetAvatarURL(),
ProfileURL: ghUser.GetHTMLURL(),
Name: ghUser.GetName(),
}
}
}
return true, user, nil
}
type AppInstallation struct {
ID int
// Login is the username of the installation.
Login string
// URL is a link to configure the app install.
URL string
}
// AppInstallations returns a list of app installations for the given token.
// If the provider does not support app installations, it returns nil.
func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk.GitAuthAppInstallation, bool, error) {
if c.AppInstallationsURL == "" {
return nil, false, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.AppInstallationsURL, nil)
if err != nil {
return nil, false, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, false, err
}
defer res.Body.Close()
// It's possible the installation URL is misconfigured, so we don't
// want to return an error here.
if res.StatusCode != http.StatusOK {
return nil, false, nil
}
installs := []codersdk.GitAuthAppInstallation{}
if c.Type == codersdk.GitProviderGitHub {
var ghInstalls struct {
Installations []*github.Installation `json:"installations"`
}
err = json.NewDecoder(res.Body).Decode(&ghInstalls)
if err != nil {
return nil, false, err
}
for _, installation := range ghInstalls.Installations {
account := installation.GetAccount()
if account == nil {
continue
}
installs = append(installs, codersdk.GitAuthAppInstallation{
ID: int(installation.GetID()),
ConfigureURL: installation.GetHTMLURL(),
Account: codersdk.GitAuthUser{
Login: account.GetLogin(),
AvatarURL: account.GetAvatarURL(),
ProfileURL: account.GetHTMLURL(),
Name: account.GetName(),
},
})
}
}
return installs, true, nil
}
// ConvertConfig converts the SDK configuration entry format
@@ -148,9 +242,6 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
if entry.ClientID == "" {
return nil, xerrors.Errorf("%q git auth provider: client_id must be provided", entry.ID)
}
if entry.ClientSecret == "" {
return nil, xerrors.Errorf("%q git auth provider: client_secret must be provided", entry.ID)
}
authRedirect, err := accessURL.Parse(fmt.Sprintf("/gitauth/%s/callback", entry.ID))
if err != nil {
return nil, xerrors.Errorf("parse gitauth callback url: %w", err)
@@ -163,7 +254,7 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
}
}
oauth2Config := &oauth2.Config{
oc := &oauth2.Config{
ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret,
Endpoint: endpoint[typ],
@@ -172,32 +263,54 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
}
if entry.AuthURL != "" {
oauth2Config.Endpoint.AuthURL = entry.AuthURL
oc.Endpoint.AuthURL = entry.AuthURL
}
if entry.TokenURL != "" {
oauth2Config.Endpoint.TokenURL = entry.TokenURL
oc.Endpoint.TokenURL = entry.TokenURL
}
if entry.Scopes != nil && len(entry.Scopes) > 0 {
oauth2Config.Scopes = entry.Scopes
oc.Scopes = entry.Scopes
}
if entry.ValidateURL == "" {
entry.ValidateURL = validateURL[typ]
}
var oauthConfig httpmw.OAuth2Config = oauth2Config
// Azure DevOps uses JWT token authentication!
if typ == codersdk.GitProviderAzureDevops {
oauthConfig = newJWTOAuthConfig(oauth2Config)
if entry.AppInstallationsURL == "" {
entry.AppInstallationsURL = appInstallationsURL[typ]
}
configs = append(configs, &Config{
OAuth2Config: oauthConfig,
ID: entry.ID,
Regex: regex,
Type: typ,
NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL,
})
var oauthConfig OAuth2Config = oc
// Azure DevOps uses JWT token authentication!
if typ == codersdk.GitProviderAzureDevops {
oauthConfig = &jwtConfig{oc}
}
cfg := &Config{
OAuth2Config: oauthConfig,
ID: entry.ID,
Regex: regex,
Type: typ,
NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL,
AppInstallationsURL: entry.AppInstallationsURL,
AppInstallURL: entry.AppInstallURL,
}
if entry.DeviceFlow {
if entry.DeviceCodeURL == "" {
entry.DeviceCodeURL = deviceAuthURL[typ]
}
if entry.DeviceCodeURL == "" {
return nil, xerrors.Errorf("git auth provider %q: device auth url must be provided", entry.ID)
}
cfg.DeviceAuth = &DeviceAuth{
ClientID: entry.ClientID,
TokenURL: oc.Endpoint.TokenURL,
Scopes: entry.Scopes,
CodeURL: entry.DeviceCodeURL,
}
}
configs = append(configs, cfg)
}
return configs, nil
}
+9 -7
View File
@@ -143,13 +143,6 @@ func TestConvertYAML(t *testing.T) {
Type: string(codersdk.GitProviderGitHub),
}},
Error: "client_id must be provided",
}, {
Name: "NoClientSecret",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.GitProviderGitHub),
ClientID: "example",
}},
Error: "client_secret must be provided",
}, {
Name: "DuplicateType",
Input: []codersdk.GitAuthConfig{{
@@ -169,6 +162,15 @@ func TestConvertYAML(t *testing.T) {
Regex: `\K`,
}},
Error: "compile regex for git auth provider",
}, {
Name: "NoDeviceURL",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.GitProviderGitLab),
ClientID: "example",
ClientSecret: "example",
DeviceFlow: true,
}},
Error: "device auth url must be provided",
}} {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
+119 -6
View File
@@ -2,13 +2,15 @@ package gitauth
import (
"context"
"encoding/json"
"net/http"
"net/url"
"regexp"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
@@ -36,6 +38,14 @@ var validateURL = map[codersdk.GitProvider]string{
codersdk.GitProviderBitBucket: "https://api.bitbucket.org/2.0/user",
}
var deviceAuthURL = map[codersdk.GitProvider]string{
codersdk.GitProviderGitHub: "https://github.com/login/device/code",
}
var appInstallationsURL = map[codersdk.GitProvider]string{
codersdk.GitProviderGitHub: "https://api.github.com/user/installations",
}
// scope contains defaults for each Git provider.
var scope = map[codersdk.GitProvider][]string{
codersdk.GitProviderAzureDevops: {"vso.code_write"},
@@ -54,13 +64,9 @@ var regex = map[codersdk.GitProvider]*regexp.Regexp{
codersdk.GitProviderGitHub: regexp.MustCompile(`^(https?://)?github\.com(/.*)?$`),
}
// newJWTOAuthConfig creates a new OAuth2 config that uses a custom
// jwtConfig is a new OAuth2 config that uses a custom
// assertion method that works with Azure Devops. See:
// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops
func newJWTOAuthConfig(config *oauth2.Config) httpmw.OAuth2Config {
return &jwtConfig{config}
}
type jwtConfig struct {
*oauth2.Config
}
@@ -89,3 +95,110 @@ func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.Au
)...,
)
}
type DeviceAuth struct {
ClientID string
TokenURL string
Scopes []string
CodeURL string
}
// AuthorizeDevice begins the device authorization flow.
// See: https://tools.ietf.org/html/rfc8628#section-3.1
func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.GitAuthDevice, error) {
if c.CodeURL == "" {
return nil, xerrors.New("oauth2: device code URL not set")
}
codeURL, err := c.formatDeviceCodeURL()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, codeURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r struct {
codersdk.GitAuthDevice
ErrorDescription string `json:"error_description"`
}
err = json.NewDecoder(resp.Body).Decode(&r)
if err != nil {
return nil, err
}
if r.ErrorDescription != "" {
return nil, xerrors.New(r.ErrorDescription)
}
return &r.GitAuthDevice, nil
}
type ExchangeDeviceCodeResponse struct {
*oauth2.Token
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// ExchangeDeviceCode exchanges a device code for an access token.
// The boolean returned indicates whether the device code is still pending
// and the caller should try again.
func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string) (*oauth2.Token, error) {
if c.TokenURL == "" {
return nil, xerrors.New("oauth2: token URL not set")
}
tokenURL, err := c.formatDeviceTokenURL(deviceCode)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, codersdk.ReadBodyAsError(resp)
}
var body ExchangeDeviceCodeResponse
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Error != "" {
return nil, xerrors.New(body.Error)
}
return body.Token, nil
}
func (c *DeviceAuth) formatDeviceTokenURL(deviceCode string) (string, error) {
tok, err := url.Parse(c.TokenURL)
if err != nil {
return "", err
}
tok.RawQuery = url.Values{
"client_id": {c.ClientID},
"device_code": {deviceCode},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
}.Encode()
return tok.String(), nil
}
func (c *DeviceAuth) formatDeviceCodeURL() (string, error) {
cod, err := url.Parse(c.CodeURL)
if err != nil {
return "", err
}
cod.RawQuery = url.Values{
"client_id": {c.ClientID},
"scope": c.Scopes,
}.Encode()
return cod.String(), nil
}
+481
View File
@@ -0,0 +1,481 @@
package coderd_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/google/go-github/v43/github"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/testutil"
)
func TestGitAuthByID(t *testing.T) {
t.Parallel()
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GitAuthConfigs: []*gitauth.Config{{
ID: "test",
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.GitProviderGitHub,
}},
})
coderdtest.CreateFirstUser(t, client)
auth, err := client.GitAuthByID(context.Background(), "test")
require.NoError(t, err)
require.False(t, auth.Authenticated)
})
t.Run("AuthenticatedNoUser", func(t *testing.T) {
// Ensures that a provider that can't obtain a user can
// still return that the provider is authenticated.
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GitAuthConfigs: []*gitauth.Config{{
ID: "test",
OAuth2Config: &testutil.OAuth2Config{},
// AzureDevops doesn't have a user endpoint!
Type: codersdk.GitProviderAzureDevops,
}},
})
coderdtest.CreateFirstUser(t, client)
resp := coderdtest.RequestGitAuthCallback(t, "test", client)
_ = resp.Body.Close()
auth, err := client.GitAuthByID(context.Background(), "test")
require.NoError(t, err)
require.True(t, auth.Authenticated)
})
t.Run("AuthenticatedWithUser", func(t *testing.T) {
t.Parallel()
validateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusOK, github.User{
Login: github.String("kyle"),
AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
})
}))
defer validateSrv.Close()
client := coderdtest.New(t, &coderdtest.Options{
GitAuthConfigs: []*gitauth.Config{{
ID: "test",
ValidateURL: validateSrv.URL,
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.GitProviderGitHub,
}},
})
coderdtest.CreateFirstUser(t, client)
resp := coderdtest.RequestGitAuthCallback(t, "test", client)
_ = resp.Body.Close()
auth, err := client.GitAuthByID(context.Background(), "test")
require.NoError(t, err)
require.True(t, auth.Authenticated)
require.NotNil(t, auth.User)
require.Equal(t, "kyle", auth.User.Login)
})
t.Run("AuthenticatedWithInstalls", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
httpapi.Write(r.Context(), w, http.StatusOK, github.User{
Login: github.String("kyle"),
AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
})
case "/installs":
httpapi.Write(r.Context(), w, http.StatusOK, struct {
Installations []github.Installation `json:"installations"`
}{
Installations: []github.Installation{{
ID: github.Int64(12345678),
Account: &github.User{
Login: github.String("coder"),
},
}},
})
}
}))
defer srv.Close()
client := coderdtest.New(t, &coderdtest.Options{
GitAuthConfigs: []*gitauth.Config{{
ID: "test",
ValidateURL: srv.URL + "/user",
AppInstallationsURL: srv.URL + "/installs",
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.GitProviderGitHub,
}},
})
coderdtest.CreateFirstUser(t, client)
resp := coderdtest.RequestGitAuthCallback(t, "test", client)
_ = resp.Body.Close()
auth, err := client.GitAuthByID(context.Background(), "test")
require.NoError(t, err)
require.True(t, auth.Authenticated)
require.NotNil(t, auth.User)
require.Equal(t, "kyle", auth.User.Login)
require.NotNil(t, auth.AppInstallations)
require.Len(t, auth.AppInstallations, 1)
})
}
func TestGitAuthDevice(t *testing.T) {
t.Parallel()
t.Run("NotSupported", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GitAuthConfigs: []*gitauth.Config{{
ID: "test",
}},
})
coderdtest.CreateFirstUser(t, client)
_, err := client.GitAuthDeviceByID(context.Background(), "test")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("FetchCode", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.GitAuthDevice{
UserCode: "hey",
})
}))
defer srv.Close()
client := coderdtest.New(t, &coderdtest.Options{
GitAuthConfigs: []*gitauth.Config{{
ID: "test",
DeviceAuth: &gitauth.DeviceAuth{
ClientID: "test",
CodeURL: srv.URL,
Scopes: []string{"repo"},
},
}},
})
coderdtest.CreateFirstUser(t, client)
device, err := client.GitAuthDeviceByID(context.Background(), "test")
require.NoError(t, err)
require.Equal(t, "hey", device.UserCode)
})
t.Run("ExchangeCode", func(t *testing.T) {
t.Parallel()
resp := gitauth.ExchangeDeviceCodeResponse{
Error: "authorization_pending",
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusOK, resp)
}))
defer srv.Close()
client := coderdtest.New(t, &coderdtest.Options{
GitAuthConfigs: []*gitauth.Config{{
ID: "test",
DeviceAuth: &gitauth.DeviceAuth{
ClientID: "test",
TokenURL: srv.URL,
Scopes: []string{"repo"},
},
}},
})
coderdtest.CreateFirstUser(t, client)
err := client.GitAuthDeviceExchange(context.Background(), "test", codersdk.GitAuthDeviceExchange{
DeviceCode: "hey",
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Equal(t, "authorization_pending", sdkErr.Detail)
resp = gitauth.ExchangeDeviceCodeResponse{
Token: &oauth2.Token{
AccessToken: "hey",
},
}
err = client.GitAuthDeviceExchange(context.Background(), "test", codersdk.GitAuthDeviceExchange{
DeviceCode: "hey",
})
require.NoError(t, err)
auth, err := client.GitAuthByID(context.Background(), "test")
require.NoError(t, err)
require.True(t, auth.Authenticated)
})
}
// nolint:bodyclose
func TestGitAuthCallback(t *testing.T) {
t.Parallel()
t.Run("NoMatchingConfig", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
_, err := agentClient.GitAuth(context.Background(), "github.com", false)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusNotFound, apiError.StatusCode())
})
t.Run("ReturnsURL", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/gitauth/%s", "github")))
})
t.Run("UnauthorizedCallback", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
})
t.Run("AuthorizedCallback", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
_ = coderdtest.CreateFirstUser(t, client)
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
location, err := resp.Location()
require.NoError(t, err)
require.Equal(t, "/gitauth/github", location.Path)
// Callback again to simulate updating the token.
resp = coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
})
t.Run("ValidateURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
srv := httptest.NewServer(nil)
defer srv.Close()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
ValidateURL: srv.URL,
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
// If the validation URL says unauthorized, the callback
// URL to re-authenticate should be returned.
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
res, err := agentClient.GitAuth(ctx, "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, res.URL)
// If the validation URL gives a non-OK status code, this
// should be treated as an internal server error.
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Something went wrong!"))
})
_, err = agentClient.GitAuth(ctx, "github.com/asd/asd", false)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusInternalServerError, apiError.StatusCode())
require.Equal(t, "validate git auth token: status 403: body: Something went wrong!", apiError.Detail)
})
t.Run("ExpiredNoRefresh", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{
Token: &oauth2.Token{
AccessToken: "token",
RefreshToken: "something",
Expiry: database.Now().Add(-time.Hour),
},
},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
NoRefresh: true,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, token.URL)
// In the configuration, we set our OAuth provider
// to return an expired token. Coder consumes this
// and stores it.
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
// Because the token is expired and `NoRefresh` is specified,
// a redirect URL should be returned again.
token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, token.URL)
})
t.Run("FullFlow", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, token.URL)
// Start waiting for the token callback...
tokenChan := make(chan agentsdk.GitAuthResponse, 1)
go func() {
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", true)
assert.NoError(t, err)
tokenChan <- token
}()
time.Sleep(250 * time.Millisecond)
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
token = <-tokenChan
require.Equal(t, "access_token", token.Username)
token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
})
}
+40
View File
@@ -0,0 +1,40 @@
package httpmw
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/httpapi"
)
type gitAuthParamContextKey struct{}
func GitAuthParam(r *http.Request) *gitauth.Config {
config, ok := r.Context().Value(gitAuthParamContextKey{}).(*gitauth.Config)
if !ok {
panic("developer error: gitauth param middleware not provided")
}
return config
}
func ExtractGitAuthParam(configs []*gitauth.Config) func(next http.Handler) http.Handler {
configByID := make(map[string]*gitauth.Config)
for _, c := range configs {
configByID[c.ID] = c
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
config, ok := configByID[chi.URLParam(r, "gitauth")]
if !ok {
httpapi.ResourceNotFound(w)
return
}
r = r.WithContext(context.WithValue(r.Context(), gitAuthParamContextKey{}, config))
next.ServeHTTP(w, r)
})
}
}
+49
View File
@@ -0,0 +1,49 @@
package httpmw_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/httpmw"
)
//nolint:bodyclose
func TestGitAuthParam(t *testing.T) {
t.Parallel()
t.Run("Found", func(t *testing.T) {
t.Parallel()
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("gitauth", "my-id")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))
res := httptest.NewRecorder()
httpmw.ExtractGitAuthParam([]*gitauth.Config{{
ID: "my-id",
}})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "my-id", httpmw.GitAuthParam(r).ID)
w.WriteHeader(http.StatusOK)
})).ServeHTTP(res, r)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("gitauth", "my-id")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))
res := httptest.NewRecorder()
httpmw.ExtractGitAuthParam([]*gitauth.Config{})(nil).ServeHTTP(res, r)
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
})
}
+4 -4
View File
@@ -67,19 +67,19 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client, authURLOpts map[str
// OIDC errors can be returned as query parameters. This can happen
// if for example we are providing and invalid scope.
// We should terminate the OIDC process if we encounter an error.
oidcError := r.URL.Query().Get("error")
errorMsg := r.URL.Query().Get("error")
errorDescription := r.URL.Query().Get("error_description")
errorURI := r.URL.Query().Get("error_uri")
if oidcError != "" {
if errorMsg != "" {
// Combine the errors into a single string if either is provided.
if errorDescription == "" && errorURI != "" {
errorDescription = fmt.Sprintf("error_uri: %s", errorURI)
} else if errorDescription != "" && errorURI != "" {
errorDescription = fmt.Sprintf("%s, error_uri: %s", errorDescription, errorURI)
}
oidcError = fmt.Sprintf("Encountered error in oidc process: %s", oidcError)
errorMsg = fmt.Sprintf("Encountered error in oidc process: %s", errorMsg)
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: oidcError,
Message: errorMsg,
// This message might be blank. This is ok.
Detail: errorDescription,
})
-8
View File
@@ -320,14 +320,6 @@ func (api *API) templateVersionGitAuth(rw http.ResponseWriter, r *http.Request)
})
return
}
query := redirectURL.Query()
// The frontend uses a BroadcastChannel to notify listening pages for
// Git auth updates if the "notify" query parameter is set.
//
// It's important we do this in the backend, because the same endpoint
// is used for CLI authentication.
query.Add("redirect", "/gitauth?notify")
redirectURL.RawQuery = query.Encode()
provider := codersdk.TemplateVersionGitAuth{
ID: config.ID,
+10 -76
View File
@@ -1902,18 +1902,16 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
if gitAuthLink.OAuthExpiry.Before(database.Now()) && !gitAuthLink.OAuthExpiry.IsZero() {
continue
}
if gitAuthConfig.ValidateURL != "" {
valid, err := gitAuthConfig.ValidateToken(ctx, gitAuthLink.OAuthAccessToken)
if err != nil {
api.Logger.Warn(ctx, "failed to validate git auth token",
slog.F("workspace_owner_id", workspace.OwnerID.String()),
slog.F("validate_url", gitAuthConfig.ValidateURL),
slog.Error(err),
)
}
if !valid {
continue
}
valid, _, err := gitAuthConfig.ValidateToken(ctx, gitAuthLink.OAuthAccessToken)
if err != nil {
api.Logger.Warn(ctx, "failed to validate git auth token",
slog.F("workspace_owner_id", workspace.OwnerID.String()),
slog.F("validate_url", gitAuthConfig.ValidateURL),
slog.Error(err),
)
}
if !valid {
continue
}
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(gitAuthConfig.Type, gitAuthLink.OAuthAccessToken))
return
@@ -1990,70 +1988,6 @@ func formatGitAuthAccessToken(typ codersdk.GitProvider, token string) agentsdk.G
return resp
}
func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
state = httpmw.OAuth2(r)
apiKey = httpmw.APIKey(r)
)
_, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to get git auth link.",
Detail: err.Error(),
})
return
}
_, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OAuthAccessToken: state.Token.AccessToken,
OAuthRefreshToken: state.Token.RefreshToken,
OAuthExpiry: state.Token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to insert git auth link.",
Detail: err.Error(),
})
return
}
} else {
_, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
ProviderID: gitAuthConfig.ID,
UserID: apiKey.UserID,
UpdatedAt: database.Now(),
OAuthAccessToken: state.Token.AccessToken,
OAuthRefreshToken: state.Token.RefreshToken,
OAuthExpiry: state.Token.Expiry,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to update git auth link.",
Detail: err.Error(),
})
return
}
}
redirect := state.Redirect
if redirect == "" {
// This is a nicely rendered screen on the frontend
redirect = "/gitauth"
}
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}
}
// wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func
// is called if a read or write error is encountered.
type wsNetConn struct {
-268
View File
@@ -6,8 +6,6 @@ import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"regexp"
"runtime"
"strconv"
"strings"
@@ -17,14 +15,12 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/coder/provisioner/echo"
@@ -1000,270 +996,6 @@ func TestWorkspaceAgentAppHealth(t *testing.T) {
require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, manifest.Apps[1].Health)
}
// nolint:bodyclose
func TestWorkspaceAgentsGitAuth(t *testing.T) {
t.Parallel()
t.Run("NoMatchingConfig", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
_, err := agentClient.GitAuth(context.Background(), "github.com", false)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusNotFound, apiError.StatusCode())
})
t.Run("ReturnsURL", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/gitauth/%s", "github")))
})
t.Run("UnauthorizedCallback", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
})
t.Run("AuthorizedCallback", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
_ = coderdtest.CreateFirstUser(t, client)
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
location, err := resp.Location()
require.NoError(t, err)
require.Equal(t, "/gitauth", location.Path)
// Callback again to simulate updating the token.
resp = coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
})
t.Run("ValidateURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
srv := httptest.NewServer(nil)
defer srv.Close()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
ValidateURL: srv.URL,
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
// If the validation URL says unauthorized, the callback
// URL to re-authenticate should be returned.
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
res, err := agentClient.GitAuth(ctx, "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, res.URL)
// If the validation URL gives a non-OK status code, this
// should be treated as an internal server error.
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Something went wrong!"))
})
_, err = agentClient.GitAuth(ctx, "github.com/asd/asd", false)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusInternalServerError, apiError.StatusCode())
require.Equal(t, "validate git auth token: status 403: body: Something went wrong!", apiError.Detail)
})
t.Run("ExpiredNoRefresh", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{
Token: &oauth2.Token{
AccessToken: "token",
RefreshToken: "something",
Expiry: database.Now().Add(-time.Hour),
},
},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
NoRefresh: true,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, token.URL)
// In the configuration, we set our OAuth provider
// to return an expired token. Coder consumes this
// and stores it.
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
// Because the token is expired and `NoRefresh` is specified,
// a redirect URL should be returned again.
token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, token.URL)
})
t.Run("FullFlow", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
GitAuthConfigs: []*gitauth.Config{{
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.GitProviderGitHub,
}},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
require.NotEmpty(t, token.URL)
// Start waiting for the token callback...
tokenChan := make(chan agentsdk.GitAuthResponse, 1)
go func() {
token, err := agentClient.GitAuth(context.Background(), "github.com/asd/asd", true)
assert.NoError(t, err)
tokenChan <- token
}()
time.Sleep(250 * time.Millisecond)
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
token = <-tokenChan
require.Equal(t, "access_token", token.Username)
token, err = agentClient.GitAuth(context.Background(), "github.com/asd/asd", false)
require.NoError(t, err)
})
}
func TestWorkspaceAgentReportStats(t *testing.T) {
t.Parallel()