mirror of https://github.com/coder/coder.git
305 lines
9.5 KiB
Go
305 lines
9.5 KiB
Go
package httpmw
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/promoauth"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/cryptorand"
|
|
)
|
|
|
|
type oauth2StateKey struct{}
|
|
|
|
type OAuth2State struct {
|
|
Token *oauth2.Token
|
|
Redirect string
|
|
StateString string
|
|
}
|
|
|
|
// OAuth2 returns the state from an oauth request.
|
|
func OAuth2(r *http.Request) OAuth2State {
|
|
oauth, ok := r.Context().Value(oauth2StateKey{}).(OAuth2State)
|
|
if !ok {
|
|
panic("developer error: oauth middleware not provided")
|
|
}
|
|
return oauth
|
|
}
|
|
|
|
// ExtractOAuth2 is a middleware for automatically redirecting to OAuth
|
|
// URLs, and handling the exchange inbound. Any route that does not have
|
|
// a "code" URL parameter will be redirected.
|
|
// AuthURLOpts are passed to the AuthCodeURL function. If this is nil,
|
|
// the default option oauth2.AccessTypeOffline will be used.
|
|
func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, authURLOpts map[string]string) func(http.Handler) http.Handler {
|
|
opts := make([]oauth2.AuthCodeOption, 0, len(authURLOpts)+1)
|
|
opts = append(opts, oauth2.AccessTypeOffline)
|
|
for k, v := range authURLOpts {
|
|
opts = append(opts, oauth2.SetAuthURLParam(k, v))
|
|
}
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
if client != nil {
|
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
|
|
}
|
|
|
|
// Interfaces can hold a nil value
|
|
if config == nil || reflect.ValueOf(config).IsNil() {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "The oauth2 method requested is not configured!",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 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.
|
|
errorMsg := r.URL.Query().Get("error")
|
|
errorDescription := r.URL.Query().Get("error_description")
|
|
errorURI := r.URL.Query().Get("error_uri")
|
|
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)
|
|
}
|
|
errorMsg = fmt.Sprintf("Encountered error in oidc process: %s", errorMsg)
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: errorMsg,
|
|
// This message might be blank. This is ok.
|
|
Detail: errorDescription,
|
|
})
|
|
return
|
|
}
|
|
|
|
code := r.URL.Query().Get("code")
|
|
state := r.URL.Query().Get("state")
|
|
|
|
if code == "" {
|
|
// If the code isn't provided, we'll redirect!
|
|
var state string
|
|
// If this url param is provided, then a user is trying to merge
|
|
// their account with an OIDC account. Their password would have
|
|
// been required to get to this point, so we do not need to verify
|
|
// their password again.
|
|
oidcMergeState := r.URL.Query().Get("oidc_merge_state")
|
|
if oidcMergeState != "" {
|
|
state = oidcMergeState
|
|
} else {
|
|
var err error
|
|
state, err = cryptorand.String(32)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error generating state string.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
http.SetCookie(rw, &http.Cookie{
|
|
Name: codersdk.OAuth2StateCookie,
|
|
Value: state,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
// Redirect must always be specified, otherwise
|
|
// an old redirect could apply!
|
|
http.SetCookie(rw, &http.Cookie{
|
|
Name: codersdk.OAuth2RedirectCookie,
|
|
Value: r.URL.Query().Get("redirect"),
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
http.Redirect(rw, r, config.AuthCodeURL(state, opts...), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
if state == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "State must be provided.",
|
|
})
|
|
return
|
|
}
|
|
|
|
stateCookie, err := r.Cookie(codersdk.OAuth2StateCookie)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
Message: fmt.Sprintf("Cookie %q must be provided.", codersdk.OAuth2StateCookie),
|
|
})
|
|
return
|
|
}
|
|
if stateCookie.Value != state {
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
Message: "State mismatched.",
|
|
})
|
|
return
|
|
}
|
|
|
|
var redirect string
|
|
stateRedirect, err := r.Cookie(codersdk.OAuth2RedirectCookie)
|
|
if err == nil {
|
|
redirect = stateRedirect.Value
|
|
}
|
|
|
|
oauthToken, err := config.Exchange(ctx, code)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error exchanging Oauth code.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx = context.WithValue(ctx, oauth2StateKey{}, OAuth2State{
|
|
Token: oauthToken,
|
|
Redirect: redirect,
|
|
StateString: state,
|
|
})
|
|
next.ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
type (
|
|
oauth2ProviderAppParamContextKey struct{}
|
|
oauth2ProviderAppSecretParamContextKey struct{}
|
|
)
|
|
|
|
// OAuth2ProviderApp returns the OAuth2 app from the ExtractOAuth2ProviderAppParam handler.
|
|
func OAuth2ProviderApp(r *http.Request) database.OAuth2ProviderApp {
|
|
app, ok := r.Context().Value(oauth2ProviderAppParamContextKey{}).(database.OAuth2ProviderApp)
|
|
if !ok {
|
|
panic("developer error: oauth2 app param middleware not provided")
|
|
}
|
|
return app
|
|
}
|
|
|
|
// ExtractOAuth2ProviderApp grabs an OAuth2 app from the "app" URL parameter. This
|
|
// middleware requires the API key middleware higher in the call stack for
|
|
// authentication.
|
|
func ExtractOAuth2ProviderApp(db database.Store) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// App can come from a URL param, query param, or form value.
|
|
paramID := "app"
|
|
var appID uuid.UUID
|
|
if chi.URLParam(r, paramID) != "" {
|
|
var ok bool
|
|
appID, ok = ParseUUIDParam(rw, r, "app")
|
|
if !ok {
|
|
return
|
|
}
|
|
} else {
|
|
// If not provided by the url, then it is provided according to the
|
|
// oauth 2 spec. This can occur with query params, or in the body as
|
|
// form parameters.
|
|
// This also depends on if you are doing a POST (tokens) or GET (authorize).
|
|
paramAppID := r.URL.Query().Get("client_id")
|
|
if paramAppID == "" {
|
|
// Check the form params!
|
|
if r.ParseForm() == nil {
|
|
paramAppID = r.Form.Get("client_id")
|
|
}
|
|
}
|
|
if paramAppID == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Missing OAuth2 client ID.",
|
|
})
|
|
return
|
|
}
|
|
|
|
var err error
|
|
appID, err = uuid.Parse(paramAppID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid OAuth2 client ID.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
app, err := db.GetOAuth2ProviderAppByID(ctx, appID)
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching OAuth2 app.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
ctx = context.WithValue(ctx, oauth2ProviderAppParamContextKey{}, app)
|
|
next.ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// OAuth2ProviderAppSecret returns the OAuth2 app secret from the
|
|
// ExtractOAuth2ProviderAppSecretParam handler.
|
|
func OAuth2ProviderAppSecret(r *http.Request) database.OAuth2ProviderAppSecret {
|
|
app, ok := r.Context().Value(oauth2ProviderAppSecretParamContextKey{}).(database.OAuth2ProviderAppSecret)
|
|
if !ok {
|
|
panic("developer error: oauth2 app secret param middleware not provided")
|
|
}
|
|
return app
|
|
}
|
|
|
|
// ExtractOAuth2ProviderAppSecret grabs an OAuth2 app secret from the "app" and
|
|
// "secret" URL parameters. This middleware requires the ExtractOAuth2ProviderApp
|
|
// middleware higher in the stack
|
|
func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
secretID, ok := ParseUUIDParam(rw, r, "secretID")
|
|
if !ok {
|
|
return
|
|
}
|
|
app := OAuth2ProviderApp(r)
|
|
secret, err := db.GetOAuth2ProviderAppSecretByID(ctx, secretID)
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching OAuth2 app secret.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
// If the user can read the secret they can probably also read the app it
|
|
// belongs to and they can read this app as well, so it seems safe to give
|
|
// them a more helpful message than a 404 on mismatches.
|
|
if app.ID != secret.AppID {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "App ID does not match secret app ID.",
|
|
})
|
|
return
|
|
}
|
|
ctx = context.WithValue(ctx, oauth2ProviderAppSecretParamContextKey{}, secret)
|
|
next.ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|