359 lines
11 KiB
Go
359 lines
11 KiB
Go
package httpbakery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"gopkg.in/errgo.v1"
|
|
"gopkg.in/httprequest.v1"
|
|
|
|
"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery"
|
|
"github.com/go-macaroon-bakery/macaroon-bakery/v3/internal/httputil"
|
|
)
|
|
|
|
// ErrorCode holds an error code that classifies
|
|
// an error returned from a bakery HTTP handler.
|
|
type ErrorCode string
|
|
|
|
func (e ErrorCode) Error() string {
|
|
return string(e)
|
|
}
|
|
|
|
func (e ErrorCode) ErrorCode() ErrorCode {
|
|
return e
|
|
}
|
|
|
|
const (
|
|
ErrBadRequest = ErrorCode("bad request")
|
|
ErrDischargeRequired = ErrorCode("macaroon discharge required")
|
|
ErrInteractionRequired = ErrorCode("interaction required")
|
|
ErrInteractionMethodNotFound = ErrorCode("discharger does not provide an supported interaction method")
|
|
ErrPermissionDenied = ErrorCode("permission denied")
|
|
)
|
|
|
|
var httpReqServer = httprequest.Server{
|
|
ErrorMapper: ErrorToResponse,
|
|
}
|
|
|
|
// WriteError writes the given bakery error to w.
|
|
func WriteError(ctx context.Context, w http.ResponseWriter, err error) {
|
|
httpReqServer.WriteError(ctx, w, err)
|
|
}
|
|
|
|
// Error holds the type of a response from an httpbakery HTTP request,
|
|
// marshaled as JSON.
|
|
//
|
|
// Note: Do not construct Error values with ErrDischargeRequired or
|
|
// ErrInteractionRequired codes directly - use the
|
|
// NewDischargeRequiredError or NewInteractionRequiredError
|
|
// functions instead.
|
|
type Error struct {
|
|
Code ErrorCode `json:",omitempty"`
|
|
Message string `json:",omitempty"`
|
|
Info *ErrorInfo `json:",omitempty"`
|
|
|
|
// version holds the protocol version that was used
|
|
// to create the error (see NewDischargeRequiredError).
|
|
version bakery.Version
|
|
}
|
|
|
|
// ErrorInfo holds additional information provided
|
|
// by an error.
|
|
type ErrorInfo struct {
|
|
// Macaroon may hold a macaroon that, when
|
|
// discharged, may allow access to a service.
|
|
// This field is associated with the ErrDischargeRequired
|
|
// error code.
|
|
Macaroon *bakery.Macaroon `json:",omitempty"`
|
|
|
|
// MacaroonPath holds the URL path to be associated
|
|
// with the macaroon. The macaroon is potentially
|
|
// valid for all URLs under the given path.
|
|
// If it is empty, the macaroon will be associated with
|
|
// the original URL from which the error was returned.
|
|
MacaroonPath string `json:",omitempty"`
|
|
|
|
// CookieNameSuffix holds the desired cookie name suffix to be
|
|
// associated with the macaroon. The actual name used will be
|
|
// ("macaroon-" + CookieName). Clients may ignore this field -
|
|
// older clients will always use ("macaroon-" +
|
|
// macaroon.Signature() in hex).
|
|
CookieNameSuffix string `json:",omitempty"`
|
|
|
|
// The following fields are associated with the
|
|
// ErrInteractionRequired error code.
|
|
|
|
// InteractionMethods holds the set of methods that the
|
|
// third party supports for completing the discharge.
|
|
// See InteractionMethod for a more convenient
|
|
// accessor method.
|
|
InteractionMethods map[string]*json.RawMessage `json:",omitempty"`
|
|
|
|
// LegacyVisitURL holds a URL that the client should visit
|
|
// in a web browser to authenticate themselves.
|
|
// This is deprecated - it is superceded by the InteractionMethods
|
|
// field.
|
|
LegacyVisitURL string `json:"VisitURL,omitempty"`
|
|
|
|
// LegacyWaitURL holds a URL that the client should visit
|
|
// to acquire the discharge macaroon. A GET on
|
|
// this URL will block until the client has authenticated,
|
|
// and then it will return the discharge macaroon.
|
|
// This is deprecated - it is superceded by the InteractionMethods
|
|
// field.
|
|
LegacyWaitURL string `json:"WaitURL,omitempty"`
|
|
}
|
|
|
|
// SetInteraction sets the information for a particular
|
|
// interaction kind to v. The error should be an interaction-required
|
|
// error. This method will panic if v cannot be JSON-marshaled.
|
|
// It is expected that interaction implementations will
|
|
// implement type-safe wrappers for this method,
|
|
// so you should not need to call it directly.
|
|
func (e *Error) SetInteraction(kind string, v interface{}) {
|
|
if e.Info == nil {
|
|
e.Info = new(ErrorInfo)
|
|
}
|
|
if e.Info.InteractionMethods == nil {
|
|
e.Info.InteractionMethods = make(map[string]*json.RawMessage)
|
|
}
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
m := json.RawMessage(data)
|
|
e.Info.InteractionMethods[kind] = &m
|
|
}
|
|
|
|
// InteractionMethod checks whether the error is an InteractionRequired error
|
|
// that implements the method with the given name, and JSON-unmarshals the
|
|
// method-specific data into x.
|
|
func (e *Error) InteractionMethod(kind string, x interface{}) error {
|
|
if e.Info == nil || e.Code != ErrInteractionRequired {
|
|
return errgo.Newf("not an interaction-required error (code %v)", e.Code)
|
|
}
|
|
entry := e.Info.InteractionMethods[kind]
|
|
if entry == nil {
|
|
return errgo.WithCausef(nil, ErrInteractionMethodNotFound, "interaction method %q not found", kind)
|
|
}
|
|
if err := json.Unmarshal(*entry, x); err != nil {
|
|
return errgo.Notef(err, "cannot unmarshal data for interaction method %q", kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
func (e *Error) ErrorCode() ErrorCode {
|
|
return e.Code
|
|
}
|
|
|
|
// ErrorInfo returns additional information
|
|
// about the error.
|
|
// TODO return interface{} here?
|
|
func (e *Error) ErrorInfo() *ErrorInfo {
|
|
return e.Info
|
|
}
|
|
|
|
// ErrorToResponse returns the HTTP status and an error body to be
|
|
// marshaled as JSON for the given error. This allows a third party
|
|
// package to integrate bakery errors into their error responses when
|
|
// they encounter an error with a *bakery.Error cause.
|
|
func ErrorToResponse(ctx context.Context, err error) (int, interface{}) {
|
|
errorBody := errorResponseBody(err)
|
|
var body interface{} = errorBody
|
|
status := http.StatusInternalServerError
|
|
switch errorBody.Code {
|
|
case ErrBadRequest:
|
|
status = http.StatusBadRequest
|
|
case ErrPermissionDenied:
|
|
status = http.StatusUnauthorized
|
|
case ErrDischargeRequired, ErrInteractionRequired:
|
|
switch errorBody.version {
|
|
case bakery.Version0:
|
|
status = http.StatusProxyAuthRequired
|
|
case bakery.Version1, bakery.Version2, bakery.Version3:
|
|
status = http.StatusUnauthorized
|
|
body = httprequest.CustomHeader{
|
|
Body: body,
|
|
SetHeaderFunc: setAuthenticateHeader,
|
|
}
|
|
default:
|
|
panic(fmt.Sprintf("out of range version number %v", errorBody.version))
|
|
}
|
|
}
|
|
return status, body
|
|
}
|
|
|
|
func setAuthenticateHeader(h http.Header) {
|
|
h.Set("WWW-Authenticate", "Macaroon")
|
|
}
|
|
|
|
type errorInfoer interface {
|
|
ErrorInfo() *ErrorInfo
|
|
}
|
|
|
|
type errorCoder interface {
|
|
ErrorCode() ErrorCode
|
|
}
|
|
|
|
// errorResponse returns an appropriate error
|
|
// response for the provided error.
|
|
func errorResponseBody(err error) *Error {
|
|
var errResp Error
|
|
cause := errgo.Cause(err)
|
|
if cause, ok := cause.(*Error); ok {
|
|
// It's an Error already. Preserve the wrapped
|
|
// error message but copy everything else.
|
|
errResp = *cause
|
|
errResp.Message = err.Error()
|
|
return &errResp
|
|
}
|
|
|
|
// It's not an error. Preserve as much info as
|
|
// we can find.
|
|
errResp.Message = err.Error()
|
|
if coder, ok := cause.(errorCoder); ok {
|
|
errResp.Code = coder.ErrorCode()
|
|
}
|
|
if infoer, ok := cause.(errorInfoer); ok {
|
|
errResp.Info = infoer.ErrorInfo()
|
|
}
|
|
return &errResp
|
|
}
|
|
|
|
// NewInteractionRequiredError returns an error of type *Error
|
|
// that requests an interaction from the client in response
|
|
// to the given request. The originalErr value describes the original
|
|
// error - if it is nil, a default message will be provided.
|
|
//
|
|
// This function should be used in preference to creating the Error value
|
|
// directly, as it sets the bakery protocol version correctly in the error.
|
|
//
|
|
// The returned error does not support any interaction kinds.
|
|
// Use kind-specific SetInteraction methods (for example
|
|
// WebBrowserInteractor.SetInteraction) to add supported
|
|
// interaction kinds.
|
|
//
|
|
// Note that WebBrowserInteractor.SetInteraction should always be called
|
|
// for legacy clients to maintain backwards compatibility.
|
|
func NewInteractionRequiredError(originalErr error, req *http.Request) *Error {
|
|
if originalErr == nil {
|
|
originalErr = ErrInteractionRequired
|
|
}
|
|
return &Error{
|
|
Message: originalErr.Error(),
|
|
version: RequestVersion(req),
|
|
Code: ErrInteractionRequired,
|
|
}
|
|
}
|
|
|
|
type DischargeRequiredErrorParams struct {
|
|
// Macaroon holds the macaroon that needs to be discharged
|
|
// by the client.
|
|
Macaroon *bakery.Macaroon
|
|
|
|
// OriginalError holds the reason that the discharge-required
|
|
// error was created. If it's nil, ErrDischargeRequired will
|
|
// be used.
|
|
OriginalError error
|
|
|
|
// CookiePath holds the path for the client to give the cookie
|
|
// holding the discharged macaroon. If it's empty, then a
|
|
// relative path from the request URL path to / will be used if
|
|
// Request is provided, or "/" otherwise.
|
|
CookiePath string
|
|
|
|
// CookieNameSuffix holds the suffix for the client
|
|
// to give the cookie holding the discharged macaroon
|
|
// (after the "macaroon-" prefix).
|
|
// If it's empty, "auth" will be used.
|
|
CookieNameSuffix string
|
|
|
|
// Request holds the request that the error is in response to.
|
|
// It is used to form the cookie path if CookiePath is empty.
|
|
Request *http.Request
|
|
}
|
|
|
|
// NewDischargeRequiredErrorWithVersion returns an error of type *Error
|
|
// that contains a macaroon to the client and acts as a request that the
|
|
// macaroon be discharged to authorize the request.
|
|
//
|
|
// The client is responsible for discharging the macaroon and
|
|
// storing it as a cookie (or including it as a Macaroon header)
|
|
// to be used for the subsequent request.
|
|
func NewDischargeRequiredError(p DischargeRequiredErrorParams) error {
|
|
if p.OriginalError == nil {
|
|
p.OriginalError = ErrDischargeRequired
|
|
}
|
|
if p.CookiePath == "" {
|
|
p.CookiePath = "/"
|
|
if p.Request != nil {
|
|
path, err := httputil.RelativeURLPath(p.Request.URL.Path, "/")
|
|
if err == nil {
|
|
p.CookiePath = path
|
|
}
|
|
}
|
|
}
|
|
if p.CookieNameSuffix == "" {
|
|
p.CookieNameSuffix = "auth"
|
|
}
|
|
return &Error{
|
|
version: p.Macaroon.Version(),
|
|
Message: p.OriginalError.Error(),
|
|
Code: ErrDischargeRequired,
|
|
Info: &ErrorInfo{
|
|
Macaroon: p.Macaroon,
|
|
MacaroonPath: p.CookiePath,
|
|
CookieNameSuffix: p.CookieNameSuffix,
|
|
},
|
|
}
|
|
}
|
|
|
|
// BakeryProtocolHeader is the header that HTTP clients should set
|
|
// to determine the bakery protocol version. If it is 0 or missing,
|
|
// a discharge-required error response will be returned with HTTP status 407;
|
|
// if it is 1, the response will have status 401 with the WWW-Authenticate
|
|
// header set to "Macaroon".
|
|
const BakeryProtocolHeader = "Bakery-Protocol-Version"
|
|
|
|
// RequestVersion determines the bakery protocol version from a client
|
|
// request. If the protocol cannot be determined, or is invalid, the
|
|
// original version of the protocol is used. If a later version is
|
|
// found, the latest known version is used, which is OK because versions
|
|
// are backwardly compatible.
|
|
//
|
|
// TODO as there are no known version 0 clients, default to version 1
|
|
// instead.
|
|
func RequestVersion(req *http.Request) bakery.Version {
|
|
vs := req.Header.Get(BakeryProtocolHeader)
|
|
if vs == "" {
|
|
// No header - use backward compatibility mode.
|
|
return bakery.Version0
|
|
}
|
|
x, err := strconv.Atoi(vs)
|
|
if err != nil || x < 0 {
|
|
// Badly formed header - use backward compatibility mode.
|
|
return bakery.Version0
|
|
}
|
|
v := bakery.Version(x)
|
|
if v > bakery.LatestVersion {
|
|
// Later version than we know about - use the
|
|
// latest version that we can.
|
|
return bakery.LatestVersion
|
|
}
|
|
return v
|
|
}
|
|
|
|
func isDischargeRequiredError(err error) bool {
|
|
respErr, ok := errgo.Cause(err).(*Error)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return respErr.Code == ErrDischargeRequired
|
|
}
|