mirror of https://github.com/coder/coder.git
402 lines
13 KiB
Go
402 lines
13 KiB
Go
package coderd
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/sqlc-dev/pqtype"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/externalauth"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// @Summary Get external auth by ID
|
|
// @ID get-external-auth-by-id
|
|
// @Security CoderSessionToken
|
|
// @Tags Git
|
|
// @Produce json
|
|
// @Param externalauth path string true "Git Provider ID" format(string)
|
|
// @Success 200 {object} codersdk.ExternalAuth
|
|
// @Router /external-auth/{externalauth} [get]
|
|
func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
|
|
config := httpmw.ExternalAuthParam(r)
|
|
apiKey := httpmw.APIKey(r)
|
|
ctx := r.Context()
|
|
|
|
res := codersdk.ExternalAuth{
|
|
Authenticated: false,
|
|
Device: config.DeviceAuth != nil,
|
|
AppInstallURL: config.AppInstallURL,
|
|
DisplayName: config.DisplayName,
|
|
AppInstallations: []codersdk.ExternalAuthAppInstallation{},
|
|
}
|
|
|
|
link, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
|
|
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 external 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.OAuthToken())
|
|
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.ExternalAuthAppInstallation{}
|
|
}
|
|
httpapi.Write(ctx, w, http.StatusOK, res)
|
|
}
|
|
|
|
// deleteExternalAuthByID only deletes the link on the Coder side, does not revoke the token on the provider side.
|
|
//
|
|
// @Summary Delete external auth user link by ID
|
|
// @ID delete-external-auth-user-link-by-id
|
|
// @Security CoderSessionToken
|
|
// @Tags Git
|
|
// @Success 200
|
|
// @Param externalauth path string true "Git Provider ID" format(string)
|
|
// @Router /external-auth/{externalauth} [delete]
|
|
func (api *API) deleteExternalAuthByID(w http.ResponseWriter, r *http.Request) {
|
|
config := httpmw.ExternalAuthParam(r)
|
|
apiKey := httpmw.APIKey(r)
|
|
ctx := r.Context()
|
|
|
|
err := api.Database.DeleteExternalAuthLink(ctx, database.DeleteExternalAuthLinkParams{
|
|
ProviderID: config.ID,
|
|
UserID: apiKey.UserID,
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
httpapi.ResourceNotFound(w)
|
|
return
|
|
}
|
|
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to delete external auth link.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, w, http.StatusOK, "OK")
|
|
}
|
|
|
|
// @Summary Post external auth device by ID
|
|
// @ID post-external-auth-device-by-id
|
|
// @Security CoderSessionToken
|
|
// @Tags Git
|
|
// @Param externalauth path string true "External Provider ID" format(string)
|
|
// @Success 204
|
|
// @Router /external-auth/{externalauth}/device [post]
|
|
func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
apiKey := httpmw.APIKey(r)
|
|
config := httpmw.ExternalAuthParam(r)
|
|
|
|
var req codersdk.ExternalAuthDeviceExchange
|
|
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.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
|
|
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 external auth link.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
_, err = api.Database.InsertExternalAuthLink(ctx, database.InsertExternalAuthLinkParams{
|
|
ProviderID: config.ID,
|
|
UserID: apiKey.UserID,
|
|
CreatedAt: dbtime.Now(),
|
|
UpdatedAt: dbtime.Now(),
|
|
OAuthAccessToken: token.AccessToken,
|
|
OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will set as required
|
|
OAuthRefreshToken: token.RefreshToken,
|
|
OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will set as required
|
|
OAuthExpiry: token.Expiry,
|
|
// No extra data from device auth!
|
|
OAuthExtra: pqtype.NullRawMessage{},
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to insert external auth link.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
} else {
|
|
_, err = api.Database.UpdateExternalAuthLink(ctx, database.UpdateExternalAuthLinkParams{
|
|
ProviderID: config.ID,
|
|
UserID: apiKey.UserID,
|
|
UpdatedAt: dbtime.Now(),
|
|
OAuthAccessToken: token.AccessToken,
|
|
OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required
|
|
OAuthRefreshToken: token.RefreshToken,
|
|
OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
|
|
OAuthExpiry: token.Expiry,
|
|
OAuthExtra: pqtype.NullRawMessage{},
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to update external auth link.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
|
}
|
|
|
|
// @Summary Get external auth device by ID.
|
|
// @ID get-external-auth-device-by-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Git
|
|
// @Param externalauth path string true "Git Provider ID" format(string)
|
|
// @Success 200 {object} codersdk.ExternalAuthDevice
|
|
// @Router /external-auth/{externalauth}/device [get]
|
|
func (*API) externalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
|
|
config := httpmw.ExternalAuthParam(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) externalAuthCallback(externalAuthConfig *externalauth.Config) http.HandlerFunc {
|
|
return func(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
state = httpmw.OAuth2(r)
|
|
apiKey = httpmw.APIKey(r)
|
|
)
|
|
|
|
extra, err := externalAuthConfig.GenerateTokenExtra(state.Token)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to generate token extra.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
_, err = api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
|
|
ProviderID: externalAuthConfig.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 external auth link.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
_, err = api.Database.InsertExternalAuthLink(ctx, database.InsertExternalAuthLinkParams{
|
|
ProviderID: externalAuthConfig.ID,
|
|
UserID: apiKey.UserID,
|
|
CreatedAt: dbtime.Now(),
|
|
UpdatedAt: dbtime.Now(),
|
|
OAuthAccessToken: state.Token.AccessToken,
|
|
OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will set as required
|
|
OAuthRefreshToken: state.Token.RefreshToken,
|
|
OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will set as required
|
|
OAuthExpiry: state.Token.Expiry,
|
|
OAuthExtra: extra,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to insert external auth link.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
} else {
|
|
_, err = api.Database.UpdateExternalAuthLink(ctx, database.UpdateExternalAuthLinkParams{
|
|
ProviderID: externalAuthConfig.ID,
|
|
UserID: apiKey.UserID,
|
|
UpdatedAt: dbtime.Now(),
|
|
OAuthAccessToken: state.Token.AccessToken,
|
|
OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required
|
|
OAuthRefreshToken: state.Token.RefreshToken,
|
|
OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
|
|
OAuthExpiry: state.Token.Expiry,
|
|
OAuthExtra: extra,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to update external auth link.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
redirect := state.Redirect
|
|
if redirect == "" {
|
|
// This is a nicely rendered screen on the frontend. Passing the query param lets the
|
|
// FE know not to enter the authentication loop again, and instead display an error.
|
|
redirect = fmt.Sprintf("/external-auth/%s?redirected=true", externalAuthConfig.ID)
|
|
}
|
|
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
|
|
}
|
|
}
|
|
|
|
// listUserExternalAuths lists all external auths available to a user and
|
|
// their auth links if they exist.
|
|
//
|
|
// @Summary Get user external auths
|
|
// @ID get-user-external-auths
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Git
|
|
// @Success 200 {object} codersdk.ExternalAuthLink
|
|
// @Router /external-auth [get]
|
|
func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
key := httpmw.APIKey(r)
|
|
|
|
links, err := api.Database.GetExternalAuthLinksByUserID(ctx, key.UserID)
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching user's external auths.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// This process of authenticating each external link increases the
|
|
// response time. However, it is necessary to more correctly debug
|
|
// authentication issues.
|
|
// We can do this in parallel if we want to speed it up.
|
|
configs := make(map[string]*externalauth.Config)
|
|
for _, cfg := range api.ExternalAuthConfigs {
|
|
configs[cfg.ID] = cfg
|
|
}
|
|
// Check if the links are authenticated.
|
|
linkMeta := make(map[string]db2sdk.ExternalAuthMeta)
|
|
for i, link := range links {
|
|
if link.OAuthAccessToken != "" {
|
|
cfg, ok := configs[link.ProviderID]
|
|
if ok {
|
|
newLink, valid, err := cfg.RefreshToken(ctx, api.Database, link)
|
|
meta := db2sdk.ExternalAuthMeta{
|
|
Authenticated: valid,
|
|
}
|
|
if err != nil {
|
|
meta.ValidateError = err.Error()
|
|
}
|
|
// Update the link if it was potentially refreshed.
|
|
if err == nil && valid {
|
|
links[i] = newLink
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Note: It would be really nice if we could cfg.Validate() the links and
|
|
// return their authenticated status. To do this, we would also have to
|
|
// refresh expired tokens too. For now, I do not want to cause the excess
|
|
// traffic on this request, so the user will have to do this with a separate
|
|
// call.
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListUserExternalAuthResponse{
|
|
Providers: ExternalAuthConfigs(api.ExternalAuthConfigs),
|
|
Links: db2sdk.ExternalAuths(links, linkMeta),
|
|
})
|
|
}
|
|
|
|
func ExternalAuthConfigs(auths []*externalauth.Config) []codersdk.ExternalAuthLinkProvider {
|
|
out := make([]codersdk.ExternalAuthLinkProvider, 0, len(auths))
|
|
for _, auth := range auths {
|
|
if auth == nil {
|
|
continue
|
|
}
|
|
out = append(out, ExternalAuthConfig(auth))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvider {
|
|
return codersdk.ExternalAuthLinkProvider{
|
|
ID: cfg.ID,
|
|
Type: cfg.Type,
|
|
Device: cfg.DeviceAuth != nil,
|
|
DisplayName: cfg.DisplayName,
|
|
DisplayIcon: cfg.DisplayIcon,
|
|
AllowRefresh: !cfg.NoRefresh,
|
|
AllowValidate: cfg.ValidateURL != "",
|
|
}
|
|
}
|