feat: allow external services to be authable (#9996)

* feat: allow external services to be authable

* Refactor external auth config structure for defaults

* Add support for new config properties

* Change the name of external auth

* Move externalauth -> external-auth

* Run gen

* Fix tests

* Fix MW tests

* Fix git auth redirect

* Fix lint

* Fix name

* Allow any ID

* Fix invalid type test

* Fix e2e tests

* Fix comments

* Fix colors

* Allow accepting any type as string

* Run gen

* Fix href
This commit is contained in:
Kyle Carberry
2023-10-03 09:04:39 -05:00
committed by GitHub
parent f62f45a303
commit 45b53c285f
58 changed files with 1140 additions and 1027 deletions

152
coderd/apidoc/docs.go generated
View File

@ -602,7 +602,7 @@ const docTemplate = `{
}
}
},
"/externalauth/{externalauth}": {
"/external-auth/{externalauth}": {
"get": {
"security": [
{
@ -637,7 +637,7 @@ const docTemplate = `{
}
}
},
"/externalauth/{externalauth}/device": {
"/external-auth/{externalauth}/device": {
"get": {
"security": [
{
@ -2768,7 +2768,7 @@ const docTemplate = `{
}
}
},
"/templateversions/{templateversion}/externalauth": {
"/templateversions/{templateversion}/external-auth": {
"get": {
"security": [
{
@ -6725,13 +6725,13 @@ const docTemplate = `{
"clibase.Regexp": {
"type": "object"
},
"clibase.Struct-array_codersdk_GitAuthConfig": {
"clibase.Struct-array_codersdk_ExternalAuthConfig": {
"type": "object",
"properties": {
"value": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.GitAuthConfig"
"$ref": "#/definitions/codersdk.ExternalAuthConfig"
}
}
}
@ -7978,15 +7978,15 @@ const docTemplate = `{
"type": "string"
}
},
"external_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_ExternalAuthConfig"
},
"external_token_encryption_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"git_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig"
},
"http_address": {
"description": "HTTPAddress is a string because it may be set to zero to disable.",
"type": "string"
@ -8203,6 +8203,9 @@ const docTemplate = `{
"device": {
"type": "boolean"
},
"display_name": {
"type": "string"
},
"installations": {
"description": "AppInstallations are the installations that the user has access to.",
"type": "array",
@ -8210,9 +8213,6 @@ const docTemplate = `{
"$ref": "#/definitions/codersdk.ExternalAuthAppInstallation"
}
},
"type": {
"type": "string"
},
"user": {
"description": "User is the user that authenticated with the provider.",
"allOf": [
@ -8237,6 +8237,64 @@ const docTemplate = `{
}
}
},
"codersdk.ExternalAuthConfig": {
"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"
},
"display_icon": {
"description": "DisplayIcon is a URL to an icon to display in the UI.",
"type": "string"
},
"display_name": {
"description": "DisplayName is shown in the UI to identify the auth config.",
"type": "string"
},
"id": {
"description": "ID is a unique identifier for the auth config.\nIt defaults to ` + "`" + `type` + "`" + ` when not provided.",
"type": "string"
},
"no_refresh": {
"type": "boolean"
},
"regex": {
"description": "Regex allows API requesters to match an auth config by\na string (e.g. coder.com) instead of by it's type.\n\nGit clone makes use of this by parsing the URL from:\n'Username for \"https://github.com\":'\nAnd sending it to the Coder server to match against the Regex.",
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"token_url": {
"type": "string"
},
"type": {
"description": "Type is the type of external auth config.",
"type": "string"
},
"validate_url": {
"type": "string"
}
}
},
"codersdk.ExternalAuthDevice": {
"type": "object",
"properties": {
@ -8257,23 +8315,6 @@ const docTemplate = `{
}
}
},
"codersdk.ExternalAuthProvider": {
"type": "string",
"enum": [
"azure-devops",
"github",
"gitlab",
"bitbucket",
"openid-connect"
],
"x-enum-varnames": [
"ExternalAuthProviderAzureDevops",
"ExternalAuthProviderGitHub",
"ExternalAuthProviderGitLab",
"ExternalAuthProviderBitBucket",
"ExternalAuthProviderOpenIDConnect"
]
},
"codersdk.ExternalAuthUser": {
"type": "object",
"properties": {
@ -8330,53 +8371,6 @@ const docTemplate = `{
}
}
},
"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"
},
"no_refresh": {
"type": "boolean"
},
"regex": {
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"token_url": {
"type": "string"
},
"type": {
"type": "string"
},
"validate_url": {
"type": "string"
}
}
},
"codersdk.GitSSHKey": {
"type": "object",
"properties": {
@ -10018,11 +10012,17 @@ const docTemplate = `{
"authenticated": {
"type": "boolean"
},
"display_icon": {
"type": "string"
},
"display_name": {
"type": "string"
},
"id": {
"type": "string"
},
"type": {
"$ref": "#/definitions/codersdk.ExternalAuthProvider"
"type": "string"
}
}
},

View File

@ -512,7 +512,7 @@
}
}
},
"/externalauth/{externalauth}": {
"/external-auth/{externalauth}": {
"get": {
"security": [
{
@ -543,7 +543,7 @@
}
}
},
"/externalauth/{externalauth}/device": {
"/external-auth/{externalauth}/device": {
"get": {
"security": [
{
@ -2430,7 +2430,7 @@
}
}
},
"/templateversions/{templateversion}/externalauth": {
"/templateversions/{templateversion}/external-auth": {
"get": {
"security": [
{
@ -5961,13 +5961,13 @@
"clibase.Regexp": {
"type": "object"
},
"clibase.Struct-array_codersdk_GitAuthConfig": {
"clibase.Struct-array_codersdk_ExternalAuthConfig": {
"type": "object",
"properties": {
"value": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.GitAuthConfig"
"$ref": "#/definitions/codersdk.ExternalAuthConfig"
}
}
}
@ -7130,15 +7130,15 @@
"type": "string"
}
},
"external_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_ExternalAuthConfig"
},
"external_token_encryption_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"git_auth": {
"$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig"
},
"http_address": {
"description": "HTTPAddress is a string because it may be set to zero to disable.",
"type": "string"
@ -7351,6 +7351,9 @@
"device": {
"type": "boolean"
},
"display_name": {
"type": "string"
},
"installations": {
"description": "AppInstallations are the installations that the user has access to.",
"type": "array",
@ -7358,9 +7361,6 @@
"$ref": "#/definitions/codersdk.ExternalAuthAppInstallation"
}
},
"type": {
"type": "string"
},
"user": {
"description": "User is the user that authenticated with the provider.",
"allOf": [
@ -7385,6 +7385,64 @@
}
}
},
"codersdk.ExternalAuthConfig": {
"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"
},
"display_icon": {
"description": "DisplayIcon is a URL to an icon to display in the UI.",
"type": "string"
},
"display_name": {
"description": "DisplayName is shown in the UI to identify the auth config.",
"type": "string"
},
"id": {
"description": "ID is a unique identifier for the auth config.\nIt defaults to `type` when not provided.",
"type": "string"
},
"no_refresh": {
"type": "boolean"
},
"regex": {
"description": "Regex allows API requesters to match an auth config by\na string (e.g. coder.com) instead of by it's type.\n\nGit clone makes use of this by parsing the URL from:\n'Username for \"https://github.com\":'\nAnd sending it to the Coder server to match against the Regex.",
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"token_url": {
"type": "string"
},
"type": {
"description": "Type is the type of external auth config.",
"type": "string"
},
"validate_url": {
"type": "string"
}
}
},
"codersdk.ExternalAuthDevice": {
"type": "object",
"properties": {
@ -7405,23 +7463,6 @@
}
}
},
"codersdk.ExternalAuthProvider": {
"type": "string",
"enum": [
"azure-devops",
"github",
"gitlab",
"bitbucket",
"openid-connect"
],
"x-enum-varnames": [
"ExternalAuthProviderAzureDevops",
"ExternalAuthProviderGitHub",
"ExternalAuthProviderGitLab",
"ExternalAuthProviderBitBucket",
"ExternalAuthProviderOpenIDConnect"
]
},
"codersdk.ExternalAuthUser": {
"type": "object",
"properties": {
@ -7478,53 +7519,6 @@
}
}
},
"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"
},
"no_refresh": {
"type": "boolean"
},
"regex": {
"type": "string"
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"token_url": {
"type": "string"
},
"type": {
"type": "string"
},
"validate_url": {
"type": "string"
}
}
},
"codersdk.GitSSHKey": {
"type": "object",
"properties": {
@ -9065,11 +9059,17 @@
"authenticated": {
"type": "boolean"
},
"display_icon": {
"type": "string"
},
"display_name": {
"type": "string"
},
"id": {
"type": "string"
},
"type": {
"$ref": "#/definitions/codersdk.ExternalAuthProvider"
"type": "string"
}
}
},

View File

@ -547,7 +547,7 @@ func New(options *Options) *API {
// Register callback handlers for each OAuth2 provider.
// We must support gitauth and externalauth for backwards compatibility.
for _, route := range []string{"gitauth", "externalauth"} {
for _, route := range []string{"gitauth", "external-auth"} {
r.Route("/"+route, func(r chi.Router) {
for _, externalAuthConfig := range options.ExternalAuthConfigs {
// We don't need to register a callback handler for device auth.
@ -616,7 +616,7 @@ func New(options *Options) *API {
r.Get("/{fileID}", api.fileByID)
r.Post("/", api.postFile)
})
r.Route("/externalauth/{externalauth}", func(r chi.Router) {
r.Route("/external-auth/{externalauth}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractExternalAuthParam(options.ExternalAuthConfigs),
@ -689,7 +689,7 @@ func New(options *Options) *API {
r.Get("/schema", templateVersionSchemaDeprecated)
r.Get("/parameters", templateVersionParametersDeprecated)
r.Get("/rich-parameters", api.templateVersionRichParameters)
r.Get("/externalauth", api.templateVersionExternalAuth)
r.Get("/external-auth", api.templateVersionExternalAuth)
r.Get("/variables", api.templateVersionVariables)
r.Get("/resources", api.templateVersionResources)
r.Get("/logs", api.templateVersionLogs)

View File

@ -906,7 +906,7 @@ func RequestExternalAuthCallback(t *testing.T, providerID string, client *coders
return http.ErrUseLastResponse
}
state := "somestate"
oauthURL, err := client.URL.Parse(fmt.Sprintf("/externalauth/%s/callback?code=asd&state=%s", providerID, state))
oauthURL, err := client.URL.Parse(fmt.Sprintf("/external-auth/%s/callback?code=asd&state=%s", providerID, state))
require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err)

View File

@ -643,7 +643,7 @@ CREATE TABLE template_versions (
message character varying(1048576) DEFAULT ''::character varying NOT NULL
);
COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of Git auth providers for a specific template version';
COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version';
COMMENT ON COLUMN template_versions.message IS 'Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact.';

View File

@ -22,4 +22,6 @@ FROM
COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.';
COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version';
COMMIT;

View File

@ -1857,7 +1857,7 @@ type TemplateVersionTable struct {
Readme string `db:"readme" json:"readme"`
JobID uuid.UUID `db:"job_id" json:"job_id"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
// IDs of Git auth providers for a specific template version
// IDs of External auth providers for a specific template version
ExternalAuthProviders []string `db:"external_auth_providers" json:"external_auth_providers"`
// Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact.
Message string `db:"message" json:"message"`

View File

@ -23,7 +23,7 @@ import (
// @Tags Git
// @Param externalauth path string true "Git Provider ID" format(string)
// @Success 200 {object} codersdk.ExternalAuth
// @Router /externalauth/{externalauth} [get]
// @Router /external-auth/{externalauth} [get]
func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
config := httpmw.ExternalAuthParam(r)
apiKey := httpmw.APIKey(r)
@ -33,7 +33,7 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
Authenticated: false,
Device: config.DeviceAuth != nil,
AppInstallURL: config.AppInstallURL,
Type: config.Type.Pretty(),
DisplayName: config.DisplayName,
AppInstallations: []codersdk.ExternalAuthAppInstallation{},
}
@ -82,7 +82,7 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
// @Tags Git
// @Param externalauth path string true "External Provider ID" format(string)
// @Success 204
// @Router /externalauth/{externalauth}/device [post]
// @Router /external-auth/{externalauth}/device [post]
func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
@ -169,7 +169,7 @@ func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Reque
// @Tags Git
// @Param externalauth path string true "Git Provider ID" format(string)
// @Success 200 {object} codersdk.ExternalAuthDevice
// @Router /externalauth/{externalauth}/device [get]
// @Router /external-auth/{externalauth}/device [get]
func (*API) externalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
config := httpmw.ExternalAuthParam(r)
ctx := r.Context()
@ -255,7 +255,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht
redirect := state.Redirect
if redirect == "" {
// This is a nicely rendered screen on the frontend
redirect = fmt.Sprintf("/externalauth/%s", externalAuthConfig.ID)
redirect = fmt.Sprintf("/external-auth/%s", externalAuthConfig.ID)
}
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}

View File

@ -15,6 +15,7 @@ import (
"golang.org/x/xerrors"
"github.com/google/go-github/v43/github"
xgithub "golang.org/x/oauth2/github"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
@ -35,9 +36,13 @@ type Config struct {
// ID is a unique identifier for the authenticator.
ID string
// Type is the type of provider.
Type codersdk.ExternalAuthProvider
Type string
// DeviceAuth is set if the provider uses the device flow.
DeviceAuth *DeviceAuth
// DisplayName is the name of the provider to display to the user.
DisplayName string
// DisplayIcon is the path to an image that will be displayed to the user.
DisplayIcon string
// NoRefresh stops Coder from using the refresh token
// to renew the access token.
@ -113,7 +118,7 @@ validate:
// to the read replica in time.
//
// We do an exponential backoff here to give the write time to propagate.
if c.Type == codersdk.ExternalAuthProviderGitHub && r.Wait(retryCtx) {
if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) && r.Wait(retryCtx) {
goto validate
}
// The token is no longer valid!
@ -171,7 +176,7 @@ func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *coders
}
var user *codersdk.ExternalAuthUser
if c.Type == codersdk.ExternalAuthProviderGitHub {
if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) {
var ghUser github.User
err = json.NewDecoder(res.Body).Decode(&ghUser)
if err == nil {
@ -217,7 +222,7 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk
return nil, false, nil
}
installs := []codersdk.ExternalAuthAppInstallation{}
if c.Type == codersdk.ExternalAuthProviderGitHub {
if c.Type == string(codersdk.EnhancedExternalAuthProviderGitHub) {
var ghInstalls struct {
Installations []*github.Installation `json:"installations"`
}
@ -245,50 +250,158 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk
return installs, true, nil
}
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.ExternalAuthDevice, 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.ExternalAuthDevice
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.ExternalAuthDevice, nil
}
type ExchangeDeviceCodeResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
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 &oauth2.Token{
AccessToken: body.AccessToken,
RefreshToken: body.RefreshToken,
Expiry: dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second),
}, 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
}
// ConvertConfig converts the SDK configuration entry format
// to the parsed and ready-to-consume in coderd provider type.
func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Config, error) {
func ConvertConfig(entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([]*Config, error) {
ids := map[string]struct{}{}
configs := []*Config{}
for _, entry := range entries {
var typ codersdk.ExternalAuthProvider
switch codersdk.ExternalAuthProvider(entry.Type) {
case codersdk.ExternalAuthProviderAzureDevops:
typ = codersdk.ExternalAuthProviderAzureDevops
case codersdk.ExternalAuthProviderBitBucket:
typ = codersdk.ExternalAuthProviderBitBucket
case codersdk.ExternalAuthProviderGitHub:
typ = codersdk.ExternalAuthProviderGitHub
case codersdk.ExternalAuthProviderGitLab:
typ = codersdk.ExternalAuthProviderGitLab
default:
return nil, xerrors.Errorf("unknown git provider type: %q", entry.Type)
}
if entry.ID == "" {
// Default to the type.
entry.ID = string(typ)
}
if valid := httpapi.NameValid(entry.ID); valid != nil {
entry := entry
// Applies defaults to the config entry.
// This allows users to very simply state that they type is "GitHub",
// apply their client secret and ID, and have the UI appear nicely.
applyDefaultsToConfig(&entry)
valid := httpapi.NameValid(entry.ID)
if valid != nil {
return nil, xerrors.Errorf("external auth provider %q doesn't have a valid id: %w", entry.ID, valid)
}
if entry.ClientID == "" {
return nil, xerrors.Errorf("%q external auth provider: client_id must be provided", entry.ID)
}
if entry.ClientSecret == "" {
return nil, xerrors.Errorf("%q external auth provider: client_secret must be provided", entry.ID)
}
_, exists := ids[entry.ID]
if exists {
if entry.ID == string(typ) {
return nil, xerrors.Errorf("multiple %s external auth providers provided. you must specify a unique id for each", typ)
if entry.ID == entry.Type {
return nil, xerrors.Errorf("multiple %s external auth providers provided. you must specify a unique id for each", entry.Type)
}
return nil, xerrors.Errorf("multiple git providers exist with the id %q. specify a unique id for each", entry.ID)
return nil, xerrors.Errorf("multiple external auth providers exist with the id %q. specify a unique id for each", entry.ID)
}
ids[entry.ID] = struct{}{}
if entry.ClientID == "" {
return nil, xerrors.Errorf("%q external auth provider: client_id must be provided", entry.ID)
}
authRedirect, err := accessURL.Parse(fmt.Sprintf("/externalauth/%s/callback", entry.ID))
authRedirect, err := accessURL.Parse(fmt.Sprintf("/external-auth/%s/callback", entry.ID))
if err != nil {
return nil, xerrors.Errorf("parse externalauth callback url: %w", err)
return nil, xerrors.Errorf("parse external auth callback url: %w", err)
}
regex := regex[typ]
var regex *regexp.Regexp
if entry.Regex != "" {
regex, err = regexp.Compile(entry.Regex)
if err != nil {
@ -299,30 +412,17 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
oc := &oauth2.Config{
ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret,
Endpoint: endpoint[typ],
RedirectURL: authRedirect.String(),
Scopes: scope[typ],
}
if entry.AuthURL != "" {
oc.Endpoint.AuthURL = entry.AuthURL
}
if entry.TokenURL != "" {
oc.Endpoint.TokenURL = entry.TokenURL
}
if entry.Scopes != nil && len(entry.Scopes) > 0 {
oc.Scopes = entry.Scopes
}
if entry.ValidateURL == "" {
entry.ValidateURL = validateURL[typ]
}
if entry.AppInstallationsURL == "" {
entry.AppInstallationsURL = appInstallationsURL[typ]
Endpoint: oauth2.Endpoint{
AuthURL: entry.AuthURL,
TokenURL: entry.TokenURL,
},
RedirectURL: authRedirect.String(),
Scopes: entry.Scopes,
}
var oauthConfig OAuth2Config = oc
// Azure DevOps uses JWT token authentication!
if typ == codersdk.ExternalAuthProviderAzureDevops {
if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) {
oauthConfig = &jwtConfig{oc}
}
@ -330,17 +430,16 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
OAuth2Config: oauthConfig,
ID: entry.ID,
Regex: regex,
Type: typ,
Type: entry.Type,
NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL,
AppInstallationsURL: entry.AppInstallationsURL,
AppInstallURL: entry.AppInstallURL,
DisplayName: entry.DisplayName,
DisplayIcon: entry.DisplayIcon,
}
if entry.DeviceFlow {
if entry.DeviceCodeURL == "" {
entry.DeviceCodeURL = deviceAuthURL[typ]
}
if entry.DeviceCodeURL == "" {
return nil, xerrors.Errorf("external auth provider %q: device auth url must be provided", entry.ID)
}
@ -356,3 +455,123 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
}
return configs, nil
}
// applyDefaultsToConfig applies defaults to the config entry.
func applyDefaultsToConfig(config *codersdk.ExternalAuthConfig) {
defaults := defaults[codersdk.EnhancedExternalAuthProvider(config.Type)]
if config.AuthURL == "" {
config.AuthURL = defaults.AuthURL
}
if config.TokenURL == "" {
config.TokenURL = defaults.TokenURL
}
if config.ValidateURL == "" {
config.ValidateURL = defaults.ValidateURL
}
if config.AppInstallURL == "" {
config.AppInstallURL = defaults.AppInstallURL
}
if config.AppInstallationsURL == "" {
config.AppInstallationsURL = defaults.AppInstallationsURL
}
if config.Regex == "" {
config.Regex = defaults.Regex
}
if config.Scopes == nil || len(config.Scopes) == 0 {
config.Scopes = defaults.Scopes
}
if config.DeviceCodeURL == "" {
config.DeviceCodeURL = defaults.DeviceCodeURL
}
if config.DisplayName == "" {
config.DisplayName = defaults.DisplayName
}
if config.DisplayIcon == "" {
config.DisplayIcon = defaults.DisplayIcon
}
// Apply defaults if it's still empty...
if config.ID == "" {
config.ID = config.Type
}
if config.DisplayName == "" {
config.DisplayName = config.Type
}
if config.DisplayIcon == "" {
// This is a key emoji.
config.DisplayIcon = "/emojis/1f511.png"
}
}
var defaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.ExternalAuthConfig{
codersdk.EnhancedExternalAuthProviderAzureDevops: {
AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize",
TokenURL: "https://app.vssps.visualstudio.com/oauth2/token",
DisplayName: "Azure DevOps",
DisplayIcon: "/icon/azure-devops.svg",
Regex: `^(https?://)?dev\.azure\.com(/.*)?$`,
Scopes: []string{"vso.code_write"},
},
codersdk.EnhancedExternalAuthProviderBitBucket: {
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
ValidateURL: "https://api.bitbucket.org/2.0/user",
DisplayName: "BitBucket",
DisplayIcon: "/icon/bitbucket.svg",
Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
Scopes: []string{"account", "repository:write"},
},
codersdk.EnhancedExternalAuthProviderGitLab: {
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"},
},
codersdk.EnhancedExternalAuthProviderGitHub: {
AuthURL: xgithub.Endpoint.AuthURL,
TokenURL: xgithub.Endpoint.TokenURL,
ValidateURL: "https://api.github.com/user",
DisplayName: "GitHub",
DisplayIcon: "/icon/github.svg",
Regex: `^(https?://)?github\.com(/.*)?$`,
// "workflow" is required for managing GitHub Actions in a repository.
Scopes: []string{"repo", "workflow"},
DeviceCodeURL: "https://github.com/login/device/code",
AppInstallationsURL: "https://api.github.com/user/installations",
},
}
// 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
type jwtConfig struct {
*oauth2.Config
}
func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...)
}
func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
v := url.Values{
"client_assertion_type": {},
"client_assertion": {c.ClientSecret},
"assertion": {code},
"grant_type": {},
}
if c.RedirectURL != "" {
v.Set("redirect_uri", c.RedirectURL)
}
return c.Config.Exchange(ctx, code,
append(opts,
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
oauth2.SetAuthURLParam("client_assertion", c.ClientSecret),
oauth2.SetAuthURLParam("assertion", code),
oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
oauth2.SetAuthURLParam("code", ""),
)...,
)
}

View File

@ -176,7 +176,7 @@ func TestRefreshToken(t *testing.T) {
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
cfg.Type = codersdk.ExternalAuthProviderGitHub
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
},
})
@ -206,7 +206,7 @@ func TestRefreshToken(t *testing.T) {
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
cfg.Type = codersdk.ExternalAuthProviderGitHub
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
},
})
@ -237,7 +237,7 @@ func TestRefreshToken(t *testing.T) {
}),
},
GitConfigOpt: func(cfg *externalauth.Config) {
cfg.Type = codersdk.ExternalAuthProviderGitHub
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
},
DB: db,
})
@ -266,42 +266,38 @@ func TestConvertYAML(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
Name string
Input []codersdk.GitAuthConfig
Input []codersdk.ExternalAuthConfig
Output []*externalauth.Config
Error string
}{{
Name: "InvalidType",
Input: []codersdk.GitAuthConfig{{
Type: "moo",
}},
Error: "unknown git provider type",
}, {
Name: "InvalidID",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub),
Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
ID: "$hi$",
}},
Error: "doesn't have a valid id",
}, {
Name: "NoClientID",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub),
Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
}},
Error: "client_id must be provided",
}, {
Name: "DuplicateType",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub),
Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
ClientID: "example",
ClientSecret: "example",
}, {
Type: string(codersdk.ExternalAuthProviderGitHub),
Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
ClientID: "example-2",
ClientSecret: "example-2",
}},
Error: "multiple github external auth providers provided",
}, {
Name: "InvalidRegex",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitHub),
Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitHub),
ClientID: "example",
ClientSecret: "example",
Regex: `\K`,
@ -309,8 +305,8 @@ func TestConvertYAML(t *testing.T) {
Error: "compile regex for external auth provider",
}, {
Name: "NoDeviceURL",
Input: []codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitLab),
Input: []codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ClientID: "example",
ClientSecret: "example",
DeviceFlow: true,
@ -332,8 +328,8 @@ func TestConvertYAML(t *testing.T) {
t.Run("CustomScopesAndEndpoint", func(t *testing.T) {
t.Parallel()
config, err := externalauth.ConvertConfig([]codersdk.GitAuthConfig{{
Type: string(codersdk.ExternalAuthProviderGitLab),
config, err := externalauth.ConvertConfig([]codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ClientID: "id",
ClientSecret: "secret",
AuthURL: "https://auth.com",
@ -341,7 +337,7 @@ func TestConvertYAML(t *testing.T) {
Scopes: []string{"read"},
}}, &url.URL{})
require.NoError(t, err)
require.Equal(t, "https://auth.com?client_id=id&redirect_uri=%2Fexternalauth%2Fgitlab%2Fcallback&response_type=code&scope=read", config[0].AuthCodeURL(""))
require.Equal(t, "https://auth.com?client_id=id&redirect_uri=%2Fexternal-auth%2Fgitlab%2Fcallback&response_type=code&scope=read", config[0].AuthCodeURL(""))
})
}

View File

@ -1,212 +0,0 @@
package externalauth
import (
"context"
"encoding/json"
"net/http"
"net/url"
"regexp"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
)
// endpoint contains default SaaS URLs for each Git provider.
var endpoint = map[codersdk.ExternalAuthProvider]oauth2.Endpoint{
codersdk.ExternalAuthProviderAzureDevops: {
AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize",
TokenURL: "https://app.vssps.visualstudio.com/oauth2/token",
},
codersdk.ExternalAuthProviderBitBucket: {
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
},
codersdk.ExternalAuthProviderGitLab: {
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
},
codersdk.ExternalAuthProviderGitHub: github.Endpoint,
}
// validateURL contains defaults for each provider.
var validateURL = map[codersdk.ExternalAuthProvider]string{
codersdk.ExternalAuthProviderGitHub: "https://api.github.com/user",
codersdk.ExternalAuthProviderGitLab: "https://gitlab.com/oauth/token/info",
codersdk.ExternalAuthProviderBitBucket: "https://api.bitbucket.org/2.0/user",
}
var deviceAuthURL = map[codersdk.ExternalAuthProvider]string{
codersdk.ExternalAuthProviderGitHub: "https://github.com/login/device/code",
}
var appInstallationsURL = map[codersdk.ExternalAuthProvider]string{
codersdk.ExternalAuthProviderGitHub: "https://api.github.com/user/installations",
}
// scope contains defaults for each Git provider.
var scope = map[codersdk.ExternalAuthProvider][]string{
codersdk.ExternalAuthProviderAzureDevops: {"vso.code_write"},
codersdk.ExternalAuthProviderBitBucket: {"account", "repository:write"},
codersdk.ExternalAuthProviderGitLab: {"write_repository"},
// "workflow" is required for managing GitHub Actions in a repository.
codersdk.ExternalAuthProviderGitHub: {"repo", "workflow"},
}
// regex provides defaults for each Git provider to match their SaaS host URL.
// This is configurable by each provider.
var regex = map[codersdk.ExternalAuthProvider]*regexp.Regexp{
codersdk.ExternalAuthProviderAzureDevops: regexp.MustCompile(`^(https?://)?dev\.azure\.com(/.*)?$`),
codersdk.ExternalAuthProviderBitBucket: regexp.MustCompile(`^(https?://)?bitbucket\.org(/.*)?$`),
codersdk.ExternalAuthProviderGitLab: regexp.MustCompile(`^(https?://)?gitlab\.com(/.*)?$`),
codersdk.ExternalAuthProviderGitHub: regexp.MustCompile(`^(https?://)?github\.com(/.*)?$`),
}
// 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
type jwtConfig struct {
*oauth2.Config
}
func (c *jwtConfig) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("response_type", "Assertion"))...)
}
func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
v := url.Values{
"client_assertion_type": {},
"client_assertion": {c.ClientSecret},
"assertion": {code},
"grant_type": {},
}
if c.RedirectURL != "" {
v.Set("redirect_uri", c.RedirectURL)
}
return c.Config.Exchange(ctx, code,
append(opts,
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
oauth2.SetAuthURLParam("client_assertion", c.ClientSecret),
oauth2.SetAuthURLParam("assertion", code),
oauth2.SetAuthURLParam("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
oauth2.SetAuthURLParam("code", ""),
)...,
)
}
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.ExternalAuthDevice, 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.ExternalAuthDevice
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.ExternalAuthDevice, nil
}
type ExchangeDeviceCodeResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
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 &oauth2.Token{
AccessToken: body.AccessToken,
RefreshToken: body.RefreshToken,
Expiry: dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second),
}, 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
}

View File

@ -34,7 +34,7 @@ func TestExternalAuthByID(t *testing.T) {
ExternalAuthConfigs: []*externalauth.Config{{
ID: "test",
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
coderdtest.CreateFirstUser(t, client)
@ -51,7 +51,7 @@ func TestExternalAuthByID(t *testing.T) {
ID: "test",
OAuth2Config: &testutil.OAuth2Config{},
// AzureDevops doesn't have a user endpoint!
Type: codersdk.ExternalAuthProviderAzureDevops,
Type: codersdk.EnhancedExternalAuthProviderAzureDevops.String(),
}},
})
coderdtest.CreateFirstUser(t, client)
@ -75,7 +75,7 @@ func TestExternalAuthByID(t *testing.T) {
ID: "test",
ValidateURL: validateSrv.URL,
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
coderdtest.CreateFirstUser(t, client)
@ -116,7 +116,7 @@ func TestExternalAuthByID(t *testing.T) {
ValidateURL: srv.URL + "/user",
AppInstallationsURL: srv.URL + "/installs",
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
coderdtest.CreateFirstUser(t, client)
@ -249,7 +249,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
user := coderdtest.CreateFirstUser(t, client)
@ -268,7 +268,7 @@ func TestGitAuthCallback(t *testing.T) {
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("/externalauth/%s", "github")))
require.True(t, strings.HasSuffix(token.URL, fmt.Sprintf("/external-auth/%s", "github")), token.URL)
})
t.Run("UnauthorizedCallback", func(t *testing.T) {
t.Parallel()
@ -278,7 +278,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
resp := coderdtest.RequestExternalAuthCallback(t, "github", client)
@ -292,7 +292,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
_ = coderdtest.CreateFirstUser(t, client)
@ -300,7 +300,7 @@ func TestGitAuthCallback(t *testing.T) {
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
location, err := resp.Location()
require.NoError(t, err)
require.Equal(t, "/externalauth/github", location.Path)
require.Equal(t, "/external-auth/github", location.Path)
// Callback again to simulate updating the token.
resp = coderdtest.RequestExternalAuthCallback(t, "github", client)
@ -319,7 +319,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
user := coderdtest.CreateFirstUser(t, client)
@ -376,7 +376,7 @@ func TestGitAuthCallback(t *testing.T) {
},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
NoRefresh: true,
}},
})
@ -420,7 +420,7 @@ func TestGitAuthCallback(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
user := coderdtest.CreateFirstUser(t, client)

View File

@ -65,9 +65,9 @@ func Test_RoutePatterns(t *testing.T) {
"/api/**",
"/@*/*/apps/**",
"/%40*/*/apps/**",
"/externalauth/*/callback",
"/external-auth/*/callback",
},
output: "^(/api/?|/api/.+/?|/@[^/]+/[^/]+/apps/.+/?|/%40[^/]+/[^/]+/apps/.+/?|/externalauth/[^/]+/callback/?)$",
output: "^(/api/?|/api/.+/?|/@[^/]+/[^/]+/apps/.+/?|/%40[^/]+/[^/]+/apps/.+/?|/external-auth/[^/]+/callback/?)$",
},
{
name: "Slash",

View File

@ -280,7 +280,7 @@ func (api *API) templateVersionRichParameters(rw http.ResponseWriter, r *http.Re
// @Tags Templates
// @Param templateversion path string true "Template version ID" format(uuid)
// @Success 200 {array} codersdk.TemplateVersionExternalAuth
// @Router /templateversions/{templateversion}/externalauth [get]
// @Router /templateversions/{templateversion}/external-auth [get]
func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var (
@ -307,7 +307,7 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
}
// This is the URL that will redirect the user with a state token.
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/externalauth/%s", config.ID))
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", config.ID))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to parse access URL.",
@ -320,6 +320,8 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
ID: config.ID,
Type: config.Type,
AuthenticateURL: redirectURL.String(),
DisplayName: config.DisplayName,
DisplayIcon: config.DisplayIcon,
}
authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{

View File

@ -342,7 +342,7 @@ func TestTemplateVersionsExternalAuth(t *testing.T) {
OAuth2Config: &testutil.OAuth2Config{},
ID: "github",
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.ExternalAuthProviderGitHub,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
})
user := coderdtest.CreateFirstUser(t, client)

View File

@ -26,7 +26,7 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han
"/api/**",
"/@*/*/apps/**",
"/%40*/*/apps/**",
"/externalauth/*/callback",
"/external-auth/*/callback",
}.MustCompile()
var tracer trace.Tracer

View File

@ -59,7 +59,7 @@ func Test_Middleware(t *testing.T) {
{"/%40hi/hi/apps/hi", true},
{"/%40hi/hi/apps/hi/hi", true},
{"/%40hi/hi/apps/hi/hi", true},
{"/externalauth/hi/callback", true},
{"/external-auth/hi/callback", true},
// Other routes that should not be collected.
{"/index.html", false},

View File

@ -2201,6 +2201,13 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
})
return
}
enhancedType := codersdk.EnhancedExternalAuthProvider(externalAuthConfig.Type)
if !enhancedType.Git() {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "External auth provider does not support git.",
})
return
}
workspaceAgent := httpmw.WorkspaceAgent(r)
// We must get the workspace to get the owner ID!
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
@ -2272,13 +2279,13 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
if !valid {
continue
}
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken))
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(enhancedType, externalAuthLink.OAuthAccessToken))
return
}
}
// This is the URL that will redirect the user with a state token.
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/externalauth/%s", externalAuthConfig.ID))
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", externalAuthConfig.ID))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to parse access URL.",
@ -2320,20 +2327,20 @@ func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request)
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken))
httpapi.Write(ctx, rw, http.StatusOK, formatGitAuthAccessToken(enhancedType, externalAuthLink.OAuthAccessToken))
}
// Provider types have different username/password formats.
func formatGitAuthAccessToken(typ codersdk.ExternalAuthProvider, token string) agentsdk.GitAuthResponse {
func formatGitAuthAccessToken(typ codersdk.EnhancedExternalAuthProvider, token string) agentsdk.GitAuthResponse {
var resp agentsdk.GitAuthResponse
switch typ {
case codersdk.ExternalAuthProviderGitLab:
case codersdk.EnhancedExternalAuthProviderGitLab:
// https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication
resp = agentsdk.GitAuthResponse{
Username: "oauth2",
Password: token,
}
case codersdk.ExternalAuthProviderBitBucket:
case codersdk.EnhancedExternalAuthProviderBitBucket:
// https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token
resp = agentsdk.GitAuthResponse{
Username: "x-token-auth",