garm/vendor/github.com/go-macaroon-bakery/macaroon-bakery/v3/httpbakery/client.go
Gabriel Adrian Samfira c61b7fd268
Update go modules
Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
2023-03-12 16:22:37 +02:00

727 lines
24 KiB
Go

package httpbakery
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"golang.org/x/net/publicsuffix"
"gopkg.in/errgo.v1"
"gopkg.in/httprequest.v1"
"gopkg.in/macaroon.v2"
"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery"
"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery/checkers"
)
var unmarshalError = httprequest.ErrorUnmarshaler(&Error{})
// maxDischargeRetries holds the maximum number of times that an HTTP
// request will be retried after a third party caveat has been successfully
// discharged.
const maxDischargeRetries = 3
// DischargeError represents the error when a third party discharge
// is refused by a server.
type DischargeError struct {
// Reason holds the underlying remote error that caused the
// discharge to fail.
Reason *Error
}
func (e *DischargeError) Error() string {
return fmt.Sprintf("third party refused discharge: %v", e.Reason)
}
// IsDischargeError reports whether err is a *DischargeError.
func IsDischargeError(err error) bool {
_, ok := err.(*DischargeError)
return ok
}
// InteractionError wraps an error returned by a call to visitWebPage.
type InteractionError struct {
// Reason holds the actual error returned from visitWebPage.
Reason error
}
func (e *InteractionError) Error() string {
return fmt.Sprintf("cannot start interactive session: %v", e.Reason)
}
// IsInteractionError reports whether err is an *InteractionError.
func IsInteractionError(err error) bool {
_, ok := err.(*InteractionError)
return ok
}
// NewHTTPClient returns an http.Client that ensures
// that headers are sent to the server even when the
// server redirects a GET request. The returned client
// also contains an empty in-memory cookie jar.
//
// See https://github.com/golang/go/issues/4677
func NewHTTPClient() *http.Client {
c := *http.DefaultClient
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) == 0 {
return nil
}
for attr, val := range via[0].Header {
if attr == "Cookie" {
// Cookies are added automatically anyway.
continue
}
if _, ok := req.Header[attr]; !ok {
req.Header[attr] = val
}
}
return nil
}
jar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
if err != nil {
panic(err)
}
c.Jar = jar
return &c
}
// Client holds the context for making HTTP requests
// that automatically acquire and discharge macaroons.
type Client struct {
// Client holds the HTTP client to use. It should have a cookie
// jar configured, and when redirecting it should preserve the
// headers (see NewHTTPClient).
*http.Client
// InteractionMethods holds a slice of supported interaction
// methods, with preferred methods earlier in the slice.
// On receiving an interaction-required error when discharging,
// the Kind method of each Interactor in turn will be called
// and, if the error indicates that the interaction kind is supported,
// the Interact method will be called to complete the discharge.
InteractionMethods []Interactor
// Key holds the client's key. If set, the client will try to
// discharge third party caveats with the special location
// "local" by using this key. See bakery.DischargeAllWithKey and
// bakery.LocalThirdPartyCaveat for more information
Key *bakery.KeyPair
// Logger is used to log information about client activities.
// If it is nil, bakery.DefaultLogger("httpbakery") will be used.
Logger bakery.Logger
}
// An Interactor represents a way of persuading a discharger
// that it should grant a discharge macaroon.
type Interactor interface {
// Kind returns the interaction method name. This corresponds to the
// key in the Error.InteractionMethods type.
Kind() string
// Interact performs the interaction, and returns a token that can be
// used to acquire the discharge macaroon. The location provides
// the third party caveat location to make it possible to use
// relative URLs.
//
// If the given interaction isn't supported by the client for
// the given location, it may return an error with an
// ErrInteractionMethodNotFound cause which will cause the
// interactor to be ignored that time.
Interact(ctx context.Context, client *Client, location string, interactionRequiredErr *Error) (*DischargeToken, error)
}
// DischargeToken holds a token that is intended
// to persuade a discharger to discharge a third
// party caveat.
type DischargeToken struct {
// Kind holds the kind of the token. By convention this
// matches the name of the interaction method used to
// obtain the token, but that's not required.
Kind string `json:"kind"`
// Value holds the value of the token.
Value []byte `json:"value"`
}
// LegacyInteractor may optionally be implemented by Interactor
// implementations that implement the legacy interaction-required
// error protocols.
type LegacyInteractor interface {
// LegacyInteract implements the "visit" half of a legacy discharge
// interaction. The "wait" half will be implemented by httpbakery.
// The location is the location specified by the third party
// caveat.
LegacyInteract(ctx context.Context, client *Client, location string, visitURL *url.URL) error
}
// NewClient returns a new Client containing an HTTP client
// created with NewHTTPClient and leaves all other fields zero.
func NewClient() *Client {
return &Client{
Client: NewHTTPClient(),
}
}
// AddInteractor is a convenience method that appends the given
// interactor to c.InteractionMethods.
// For example, to enable web-browser interaction on
// a client c, do:
//
// c.AddInteractor(httpbakery.WebBrowserWindowInteractor)
func (c *Client) AddInteractor(i Interactor) {
c.InteractionMethods = append(c.InteractionMethods, i)
}
// DischargeAll attempts to acquire discharge macaroons for all the
// third party caveats in m, and returns a slice containing all
// of them bound to m.
//
// If the discharge fails because a third party refuses to discharge a
// caveat, the returned error will have a cause of type *DischargeError.
// If the discharge fails because visitWebPage returns an error,
// the returned error will have a cause of *InteractionError.
//
// The returned macaroon slice will not be stored in the client
// cookie jar (see SetCookie if you need to do that).
func (c *Client) DischargeAll(ctx context.Context, m *bakery.Macaroon) (macaroon.Slice, error) {
return bakery.DischargeAllWithKey(ctx, m, c.AcquireDischarge, c.Key)
}
// DischargeAllUnbound is like DischargeAll except that it does not
// bind the resulting macaroons.
func (c *Client) DischargeAllUnbound(ctx context.Context, ms bakery.Slice) (bakery.Slice, error) {
return ms.DischargeAll(ctx, c.AcquireDischarge, c.Key)
}
// Do is like DoWithContext, except the context is automatically derived.
// If using go version 1.7 or later the context will be taken from the
// given request, otherwise context.Background() will be used.
func (c *Client) Do(req *http.Request) (*http.Response, error) {
return c.do(contextFromRequest(req), req, nil)
}
// DoWithContext sends the given HTTP request and returns its response.
// If the request fails with a discharge-required error, any required
// discharge macaroons will be acquired, and the request will be repeated
// with those attached.
//
// If the required discharges were refused by a third party, an error
// with a *DischargeError cause will be returned.
//
// If interaction is required by the user, the client's InteractionMethods
// will be used to perform interaction. An error
// with a *InteractionError cause will be returned if this interaction
// fails. See WebBrowserWindowInteractor for a possible implementation of
// an Interactor for an interaction method.
//
// DoWithContext may add headers to req.Header.
func (c *Client) DoWithContext(ctx context.Context, req *http.Request) (*http.Response, error) {
return c.do(ctx, req, nil)
}
// DoWithCustomError is like Do except it allows a client
// to specify a custom error function, getError, which is called on the
// HTTP response and may return a non-nil error if the response holds an
// error. If the cause of the returned error is a *Error value and its
// code is ErrDischargeRequired, the macaroon in its Info field will be
// discharged and the request will be repeated with the discharged
// macaroon. If getError returns nil, it should leave the response body
// unchanged.
//
// If getError is nil, DefaultGetError will be used.
//
// This method can be useful when dealing with APIs that
// return their errors in a format incompatible with Error, but the
// need for it should be avoided when creating new APIs,
// as it makes the endpoints less amenable to generic tools.
func (c *Client) DoWithCustomError(req *http.Request, getError func(resp *http.Response) error) (*http.Response, error) {
return c.do(contextFromRequest(req), req, getError)
}
func (c *Client) do(ctx context.Context, req *http.Request, getError func(resp *http.Response) error) (*http.Response, error) {
c.logDebugf(ctx, "client do %s %s {", req.Method, req.URL)
resp, err := c.do1(ctx, req, getError)
c.logDebugf(ctx, "} -> error %#v", err)
return resp, err
}
func (c *Client) do1(ctx context.Context, req *http.Request, getError func(resp *http.Response) error) (*http.Response, error) {
if getError == nil {
getError = DefaultGetError
}
if c.Client.Jar == nil {
return nil, errgo.New("no cookie jar supplied in HTTP client")
}
rreq, ok := newRetryableRequest(c.Client, req)
if !ok {
return nil, fmt.Errorf("request body is not seekable")
}
defer rreq.close()
req.Header.Set(BakeryProtocolHeader, fmt.Sprint(bakery.LatestVersion))
// Make several attempts to do the request, because we might have
// to get through several layers of security. We only retry if
// we get a DischargeRequiredError and succeed in discharging
// the macaroon in it.
retry := 0
for {
resp, err := c.do2(ctx, rreq, getError)
if err == nil || !isDischargeRequiredError(err) {
return resp, errgo.Mask(err, errgo.Any)
}
if retry++; retry > maxDischargeRetries {
return nil, errgo.NoteMask(err, fmt.Sprintf("too many (%d) discharge requests", retry-1), errgo.Any)
}
if err1 := c.HandleError(ctx, req.URL, err); err1 != nil {
return nil, errgo.Mask(err1, errgo.Any)
}
c.logDebugf(ctx, "discharge succeeded; retry %d", retry)
}
}
func (c *Client) do2(ctx context.Context, rreq *retryableRequest, getError func(resp *http.Response) error) (*http.Response, error) {
httpResp, err := rreq.do(ctx)
if err != nil {
return nil, errgo.Mask(err, errgo.Any)
}
err = getError(httpResp)
if err == nil {
c.logInfof(ctx, "HTTP response OK (status %v)", httpResp.Status)
return httpResp, nil
}
httpResp.Body.Close()
return nil, errgo.Mask(err, errgo.Any)
}
// HandleError tries to resolve the given error, which should be a
// response to the given URL, by discharging any macaroon contained in
// it. That is, if the error cause is an *Error and its code is
// ErrDischargeRequired, then it will try to discharge
// err.Info.Macaroon. If the discharge succeeds, the discharged macaroon
// will be saved to the client's cookie jar and ResolveError will return
// nil.
//
// For any other kind of error, the original error will be returned.
func (c *Client) HandleError(ctx context.Context, reqURL *url.URL, err error) error {
respErr, ok := errgo.Cause(err).(*Error)
if !ok {
return err
}
if respErr.Code != ErrDischargeRequired {
return respErr
}
if respErr.Info == nil || respErr.Info.Macaroon == nil {
return errgo.New("no macaroon found in discharge-required response")
}
mac := respErr.Info.Macaroon
macaroons, err := bakery.DischargeAllWithKey(ctx, mac, c.AcquireDischarge, c.Key)
if err != nil {
return errgo.Mask(err, errgo.Any)
}
var cookiePath string
if path := respErr.Info.MacaroonPath; path != "" {
relURL, err := parseURLPath(path)
if err != nil {
c.logInfof(ctx, "ignoring invalid path in discharge-required response: %v", err)
} else {
cookiePath = reqURL.ResolveReference(relURL).Path
}
}
// TODO use a namespace taken from the error response.
cookie, err := NewCookie(nil, macaroons)
if err != nil {
return errgo.Notef(err, "cannot make cookie")
}
cookie.Path = cookiePath
if name := respErr.Info.CookieNameSuffix; name != "" {
cookie.Name = "macaroon-" + name
}
c.Jar.SetCookies(reqURL, []*http.Cookie{cookie})
return nil
}
// DefaultGetError is the default error unmarshaler used by Client.Do.
func DefaultGetError(httpResp *http.Response) error {
if httpResp.StatusCode != http.StatusProxyAuthRequired && httpResp.StatusCode != http.StatusUnauthorized {
return nil
}
// Check for the new protocol discharge error.
if httpResp.StatusCode == http.StatusUnauthorized && httpResp.Header.Get("WWW-Authenticate") != "Macaroon" {
return nil
}
if httpResp.Header.Get("Content-Type") != "application/json" {
return nil
}
var resp Error
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return fmt.Errorf("cannot unmarshal error response: %v", err)
}
return &resp
}
func parseURLPath(path string) (*url.URL, error) {
u, err := url.Parse(path)
if err != nil {
return nil, errgo.Mask(err)
}
if u.Scheme != "" ||
u.Opaque != "" ||
u.User != nil ||
u.Host != "" ||
u.RawQuery != "" ||
u.Fragment != "" {
return nil, errgo.Newf("URL path %q is not clean", path)
}
return u, nil
}
// PermanentExpiryDuration holds the length of time a cookie
// holding a macaroon with no time-before caveat will be
// stored.
const PermanentExpiryDuration = 100 * 365 * 24 * time.Hour
// NewCookie takes a slice of macaroons and returns them
// encoded as a cookie. The slice should contain a single primary
// macaroon in its first element, and any discharges after that.
//
// The given namespace specifies the first party caveat namespace,
// used for deriving the expiry time of the cookie.
func NewCookie(ns *checkers.Namespace, ms macaroon.Slice) (*http.Cookie, error) {
if len(ms) == 0 {
return nil, errgo.New("no macaroons in cookie")
}
// TODO(rog) marshal cookie as binary if version allows.
data, err := json.Marshal(ms)
if err != nil {
return nil, errgo.Notef(err, "cannot marshal macaroons")
}
cookie := &http.Cookie{
Name: fmt.Sprintf("macaroon-%x", ms[0].Signature()),
Value: base64.StdEncoding.EncodeToString(data),
}
expires, found := checkers.MacaroonsExpiryTime(ns, ms)
if !found {
// The macaroon doesn't expire - use a very long expiry
// time for the cookie.
expires = time.Now().Add(PermanentExpiryDuration)
} else if expires.Sub(time.Now()) < time.Minute {
// The macaroon might have expired already, or it's
// got a short duration, so treat it as a session cookie
// by setting Expires to the zero time.
expires = time.Time{}
}
cookie.Expires = expires
// TODO(rog) other fields.
return cookie, nil
}
// SetCookie sets a cookie for the given URL on the given cookie jar
// that will holds the given macaroon slice. The macaroon slice should
// contain a single primary macaroon in its first element, and any
// discharges after that.
//
// The given namespace specifies the first party caveat namespace,
// used for deriving the expiry time of the cookie.
func SetCookie(jar http.CookieJar, url *url.URL, ns *checkers.Namespace, ms macaroon.Slice) error {
cookie, err := NewCookie(ns, ms)
if err != nil {
return errgo.Mask(err)
}
jar.SetCookies(url, []*http.Cookie{cookie})
return nil
}
// MacaroonsForURL returns any macaroons associated with the
// given URL in the given cookie jar.
func MacaroonsForURL(jar http.CookieJar, u *url.URL) []macaroon.Slice {
return cookiesToMacaroons(jar.Cookies(u))
}
func appendURLElem(u, elem string) string {
if strings.HasSuffix(u, "/") {
return u + elem
}
return u + "/" + elem
}
// AcquireDischarge acquires a discharge macaroon from the caveat location as an HTTP URL.
// It fits the getDischarge argument type required by bakery.DischargeAll.
func (c *Client) AcquireDischarge(ctx context.Context, cav macaroon.Caveat, payload []byte) (*bakery.Macaroon, error) {
m, err := c.acquireDischarge(ctx, cav, payload, nil)
if err == nil {
return m, nil
}
cause, ok := errgo.Cause(err).(*Error)
if !ok {
return nil, errgo.NoteMask(err, "cannot acquire discharge", IsInteractionError)
}
if cause.Code != ErrInteractionRequired {
return nil, &DischargeError{
Reason: cause,
}
}
if cause.Info == nil {
return nil, errgo.Notef(err, "interaction-required response with no info")
}
// Make sure the location has a trailing slash so that
// the relative URL calculations work correctly even when
// cav.Location doesn't have a trailing slash.
loc := appendURLElem(cav.Location, "")
token, m, err := c.interact(ctx, loc, cause, payload)
if err != nil {
return nil, errgo.Mask(err, IsDischargeError, IsInteractionError)
}
if m != nil {
// We've acquired the macaroon directly via legacy interaction.
return m, nil
}
// Try to acquire the discharge again, but this time with
// the token acquired by the interaction method.
m, err = c.acquireDischarge(ctx, cav, payload, token)
if err != nil {
return nil, errgo.Mask(err, IsDischargeError, IsInteractionError)
}
return m, nil
}
// acquireDischarge is like AcquireDischarge except that it also
// takes a token acquired from an interaction method.
func (c *Client) acquireDischarge(
ctx context.Context,
cav macaroon.Caveat,
payload []byte,
token *DischargeToken,
) (*bakery.Macaroon, error) {
dclient := newDischargeClient(cav.Location, c)
var req dischargeRequest
req.Id, req.Id64 = maybeBase64Encode(cav.Id)
if token != nil {
req.Token, req.Token64 = maybeBase64Encode(token.Value)
req.TokenKind = token.Kind
}
req.Caveat = base64.RawURLEncoding.EncodeToString(payload)
resp, err := dclient.Discharge(ctx, &req)
if err == nil {
return resp.Macaroon, nil
}
return nil, errgo.Mask(err, errgo.Any)
}
// interact gathers a macaroon by directing the user to interact with a
// web page. The irErr argument holds the interaction-required
// error response.
func (c *Client) interact(ctx context.Context, location string, irErr *Error, payload []byte) (*DischargeToken, *bakery.Macaroon, error) {
if len(c.InteractionMethods) == 0 {
return nil, nil, &InteractionError{
Reason: errgo.New("interaction required but not possible"),
}
}
if irErr.Info.InteractionMethods == nil && irErr.Info.LegacyVisitURL != "" {
// It's an old-style error; deal with it differently.
m, err := c.legacyInteract(ctx, location, irErr)
if err != nil {
return nil, nil, errgo.Mask(err, IsDischargeError, IsInteractionError)
}
return nil, m, nil
}
for _, interactor := range c.InteractionMethods {
c.logDebugf(ctx, "checking interaction method %q", interactor.Kind())
if _, ok := irErr.Info.InteractionMethods[interactor.Kind()]; ok {
c.logDebugf(ctx, "found possible interaction method %q", interactor.Kind())
token, err := interactor.Interact(ctx, c, location, irErr)
if err != nil {
if errgo.Cause(err) == ErrInteractionMethodNotFound {
continue
}
return nil, nil, errgo.Mask(err, IsDischargeError, IsInteractionError)
}
if token == nil {
return nil, nil, errgo.New("interaction method returned an empty token")
}
return token, nil, nil
} else {
c.logDebugf(ctx, "interaction method %q not found in %#v", interactor.Kind(), irErr.Info.InteractionMethods)
}
}
return nil, nil, &InteractionError{
Reason: errgo.Newf("no supported interaction method"),
}
}
func (c *Client) legacyInteract(ctx context.Context, location string, irErr *Error) (*bakery.Macaroon, error) {
visitURL, err := relativeURL(location, irErr.Info.LegacyVisitURL)
if err != nil {
return nil, errgo.Mask(err)
}
waitURL, err := relativeURL(location, irErr.Info.LegacyWaitURL)
if err != nil {
return nil, errgo.Mask(err)
}
methodURLs := map[string]*url.URL{
"interactive": visitURL,
}
if len(c.InteractionMethods) > 1 || c.InteractionMethods[0].Kind() != WebBrowserInteractionKind {
// We have several possible methods or we only support a non-window
// method, so we need to fetch the possible methods supported by the discharger.
methodURLs = legacyGetInteractionMethods(ctx, c.logger(), c, visitURL)
}
for _, interactor := range c.InteractionMethods {
kind := interactor.Kind()
if kind == WebBrowserInteractionKind {
// This is the old name for browser-window interaction.
kind = "interactive"
}
interactor, ok := interactor.(LegacyInteractor)
if !ok {
// Legacy interaction mode isn't supported.
continue
}
visitURL, ok := methodURLs[kind]
if !ok {
continue
}
visitURL, err := relativeURL(location, visitURL.String())
if err != nil {
return nil, errgo.Mask(err)
}
if err := interactor.LegacyInteract(ctx, c, location, visitURL); err != nil {
return nil, &InteractionError{
Reason: errgo.Mask(err, errgo.Any),
}
}
return waitForMacaroon(ctx, c, waitURL)
}
return nil, &InteractionError{
Reason: errgo.Newf("no methods supported"),
}
}
func (c *Client) logDebugf(ctx context.Context, f string, a ...interface{}) {
c.logger().Debugf(ctx, f, a...)
}
func (c *Client) logInfof(ctx context.Context, f string, a ...interface{}) {
c.logger().Infof(ctx, f, a...)
}
func (c *Client) logger() bakery.Logger {
if c.Logger != nil {
return c.Logger
}
return bakery.DefaultLogger("httpbakery")
}
// waitForMacaroon returns a macaroon from a legacy wait endpoint.
func waitForMacaroon(ctx context.Context, client *Client, waitURL *url.URL) (*bakery.Macaroon, error) {
req, err := http.NewRequest("GET", waitURL.String(), nil)
if err != nil {
return nil, errgo.Mask(err)
}
req = req.WithContext(ctx)
httpResp, err := client.Client.Do(req)
if err != nil {
return nil, errgo.Notef(err, "cannot get %q", waitURL)
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusOK {
err := unmarshalError(httpResp)
if err1, ok := err.(*Error); ok {
err = &DischargeError{
Reason: err1,
}
}
return nil, errgo.NoteMask(err, "failed to acquire macaroon after waiting", errgo.Any)
}
var resp WaitResponse
if err := httprequest.UnmarshalJSONResponse(httpResp, &resp); err != nil {
return nil, errgo.Notef(err, "cannot unmarshal wait response")
}
return resp.Macaroon, nil
}
// relativeURL returns newPath relative to an original URL.
func relativeURL(base, new string) (*url.URL, error) {
if new == "" {
return nil, errgo.Newf("empty URL")
}
baseURL, err := url.Parse(base)
if err != nil {
return nil, errgo.Notef(err, "cannot parse URL")
}
newURL, err := url.Parse(new)
if err != nil {
return nil, errgo.Notef(err, "cannot parse URL")
}
return baseURL.ResolveReference(newURL), nil
}
// TODO(rog) move a lot of the code below into server.go, as it's
// much more about server side than client side.
// MacaroonsHeader is the key of the HTTP header that can be used to provide a
// macaroon for request authorization.
const MacaroonsHeader = "Macaroons"
// RequestMacaroons returns any collections of macaroons from the header and
// cookies found in the request. By convention, each slice will contain a
// primary macaroon followed by its discharges.
func RequestMacaroons(req *http.Request) []macaroon.Slice {
mss := cookiesToMacaroons(req.Cookies())
for _, h := range req.Header[MacaroonsHeader] {
ms, err := decodeMacaroonSlice(h)
if err != nil {
// Ignore invalid macaroons.
continue
}
mss = append(mss, ms)
}
return mss
}
// cookiesToMacaroons returns a slice of any macaroons found
// in the given slice of cookies.
func cookiesToMacaroons(cookies []*http.Cookie) []macaroon.Slice {
var mss []macaroon.Slice
for _, cookie := range cookies {
if !strings.HasPrefix(cookie.Name, "macaroon-") {
continue
}
ms, err := decodeMacaroonSlice(cookie.Value)
if err != nil {
// Ignore invalid macaroons.
continue
}
mss = append(mss, ms)
}
return mss
}
// decodeMacaroonSlice decodes a base64-JSON-encoded slice of macaroons from
// the given string.
func decodeMacaroonSlice(value string) (macaroon.Slice, error) {
data, err := macaroon.Base64Decode([]byte(value))
if err != nil {
return nil, errgo.NoteMask(err, "cannot base64-decode macaroons")
}
// TODO(rog) accept binary encoded macaroon cookies.
var ms macaroon.Slice
if err := json.Unmarshal(data, &ms); err != nil {
return nil, errgo.NoteMask(err, "cannot unmarshal macaroons")
}
return ms, nil
}