diff --git a/go.mod b/go.mod index e37c9a6..5e30dd9 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,10 @@ require ( github.com/a-h/templ v0.3.833 github.com/foolin/goview v0.3.0 github.com/gabriel-vasile/mimetype v1.4.4 + github.com/gorilla/sessions v1.4.0 github.com/invopop/configure v0.8.0 github.com/invopop/gobl v0.113.0 + github.com/labstack/echo-contrib v0.17.4 github.com/labstack/echo/v4 v4.13.4 github.com/magefile/mage v1.15.0 github.com/nats-io/nats.go v1.43.0 @@ -29,6 +31,8 @@ require ( github.com/buger/jsonparser v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/invopop/jsonschema v0.12.0 // indirect github.com/invopop/validation v0.7.0 // indirect github.com/invopop/yaml v0.3.1 // indirect diff --git a/go.sum b/go.sum index 5b48267..bc27dad 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,16 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/invopop/configure v0.8.0 h1:IZO4QsjwsmVIBgEHFzEjtpsRRnUSeKRUBGQdCpjxDek= github.com/invopop/configure v0.8.0/go.mod h1:O8QqC2Oyy86p73Glklvs5oQ6xQLTlSLAaFogulEGPW8= github.com/invopop/gobl v0.113.0 h1:Ap3Kq5bEkNWlVt8zsn2dkA4dhU5skCoMkOTfTHA33DA= @@ -55,6 +63,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= +github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= +github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= github.com/labstack/echo/v4 v4.1.6/go.mod h1:kU/7PwzgNxZH4das4XNsSpBSOD09XIF5YEPzjpkGnGE= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= diff --git a/invopop/access.go b/invopop/access.go index 0ae2630..8d9824e 100644 --- a/invopop/access.go +++ b/invopop/access.go @@ -7,23 +7,50 @@ const ( // AccessService provides a wrapper around the Invopop Access public API. type AccessService service +// NewSession will instantiate a new session object and ensure that it references +// the client which is expected to have been prepared with the base OAuth App +// credentials. +func (s *AccessService) NewSession() *Session { + sess := new(Session) + sess.client = s.client + return sess +} + +// NewSessionWithToken will instantiate a new session object with the provided +// token value. This is a convenience method around NewSession and is intended to +// be used before making a call to Authorize to validate that the token is valid. +func (s *AccessService) NewSessionWithToken(token string) *Session { + sess := s.NewSession() + sess.SetToken(token) + return sess +} + +// NewSessionWithOwnerID will instantiate a new session object with the provided +// owner ID. This is a convenience method around NewSession and is intended to be +// used before making a call to Authorize to validate that the owner ID is valid. +func (s *AccessService) NewSessionWithOwnerID(ownerID string) *Session { + sess := s.NewSession() + sess.SetOwnerID(ownerID) + return sess +} + // Enrollment returns the service for Access Enrollments -func (svc *AccessService) Enrollment() *EnrollmentService { - return (*EnrollmentService)(svc) +func (s *AccessService) Enrollment() *EnrollmentService { + return (*EnrollmentService)(s) } // Workspace returns the service for Access Workspaces -func (svc *AccessService) Workspace() *WorkspaceService { - return (*WorkspaceService)(svc) +func (s *AccessService) Workspace() *WorkspaceService { + return (*WorkspaceService)(s) } // Company returns the service for Access Workspaces // Deprecated: Use Workspace instead. -func (svc *AccessService) Company() *WorkspaceService { - return svc.Workspace() +func (s *AccessService) Company() *WorkspaceService { + return s.Workspace() } // Org returns the service for Access Organizations -func (svc *AccessService) Org() *OrgService { - return (*OrgService)(svc) +func (s *AccessService) Org() *OrgService { + return (*OrgService)(s) } diff --git a/invopop/access_enrollments.go b/invopop/access_enrollments.go index b5d4ee9..f0551bc 100644 --- a/invopop/access_enrollments.go +++ b/invopop/access_enrollments.go @@ -32,7 +32,8 @@ type Enrollment struct { Disabled bool `json:"disabled" title:"Disabled" description:"Whether the enrollment is disabled." example:"false"` - Token string `json:"token" title:"Token" description:"A token that may be used to authenticate the enrollment with API operations."` + Token string `json:"token" title:"Token" description:"A token that may be used to authenticate the enrollment with API operations."` + TokenExpires int64 `json:"token_expires" title:"Token Expires" description:"The expiration unix timestamp of the token." example:"1680000000"` } // authorizeEnrollment is used internally to describe the fields required to confirm diff --git a/invopop/access_session.go b/invopop/access_session.go new file mode 100644 index 0000000..2acdd46 --- /dev/null +++ b/invopop/access_session.go @@ -0,0 +1,222 @@ +package invopop + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +const ( + sessionTokenTTL = 300 // 5 minutes + sessionKey invopopContextKey = "session" +) + +// Session provides an opinionated session management structure for usage alongside +// application enrollments inside Invopop. Content is based on the enrollment provided +// by the Access service. Sessions may be persisted to secure temporary stores, such as +// cookies or key-value databases. +// +// Sessions should be used within your business or domain logic and can be used as a +// replacement of a regular Client instance. +// +// Instantiate sessions using the Access NewSession or related methods, even if you intend to +// unmarshal them from a stored source to ensure that there is always a client available. +// +// Sessions must be authorized before use by calling the Authorize method, which will +// ensure that the token is valid and renewed if necessary. If no token is available in +// the session, but an Enrollment ID or Owner ID is provided, those will be used to +// authorize the session instead. +// +// Important: sessions should only be stored in cookies if the application using them +// will be used independently. For embedded applications, such as those running inside +// the Invopop Console, sessions should be created on each request. +type Session struct { + // EnrollmentID is the unique ID of the enrollment. + EnrollmentID string `json:"eid,omitempty"` + // OwnerID is the unique ID of the entity this enrollment belongs to. + OwnerID string `json:"oid,omitempty"` + // Sandbox indicates whether this enrollment is for a sandbox or live workspace. + Sandbox bool `json:"sbx,omitempty"` + + // Data contains any config data stored inside the enrollment. This should not be + // modified. + Data json.RawMessage `json:"data,omitempty"` + + // Meta contains any metadata stored alongside the base enrollment + // details that might be useful for the app. Use the Set and Get methods to manage + // this map. Only string key-value pairs are supported for simplicity in + // serialization. + Meta map[string]string `json:"meta,omitempty"` + + // RedirectURI is a convenience field to store a redirection URI that may be + // used as part of an authentication flow to redirect the user back to where + // the came from. + RedirectURI string `json:"redirect_uri,omitempty"` + + // Token is the authentication token provided by the enrollment, alongside + // the expiration unix timestamp. + Token string `json:"t,omitempty"` + TokenExpires int64 `json:"exp,omitempty"` + + // extra is used to temporarily store any extra values in the session that may + // be used in the same request and must not be serialized. + extra map[any]any `json:"-"` + + // client contains the invopop client prepared with the session's token, when loaded. + client *Client +} + +// Authorize will try to authorize the session by confirming that +// the token is valid with the Access service, renewing in the process. +// +// Before making unnecessary requests, a check is made to see if the currently +// provided token is still valid based on the expiration timestamp. To force +// renewal, set the TokenExpires field to zero. +// +// If an Enrollment or Owner ID is provided inside the session and no token is +// present, those will be used to try to authorize the session instead. +// +// If no client is available, or no token is present, an error will be returned. +func (s *Session) Authorize(ctx context.Context) error { + if !s.ShouldRenew() { + return nil + } + if s.client == nil { + return fmt.Errorf("%w: no client available in session", ErrAccessDenied) + } + var en *Enrollment + var err error + if s.Token != "" { + en, err = s.client.Access().Enrollment().Authorize(ctx) + } else if s.EnrollmentID != "" { + en, err = s.client.Access().Enrollment().AuthorizeWithID(ctx, s.EnrollmentID) + } else if s.OwnerID != "" { + en, err = s.client.Access().Enrollment().AuthorizeWithOwnerID(ctx, s.OwnerID) + } else { + return fmt.Errorf("%w: no token or enrollment/owner ID provided", ErrAccessDenied) + } + if err != nil { + if IsNotFound(err) { + return fmt.Errorf("%w: application not enrolled", ErrAccessDenied) + } + return err + } + s.SetFromEnrollment(en) + return nil +} + +// Client provides the invopop client prepared with the session's token, which may be nil +// if the session was not initialized with a client or token. +func (s *Session) Client() *Client { + return s.client +} + +// SetFromEnrollment will update the session details based on the provided +// enrollment object. +func (s *Session) SetFromEnrollment(e *Enrollment) { + s.EnrollmentID = e.ID + s.OwnerID = e.OwnerID + s.Sandbox = e.Sandbox + s.Data = e.Data + s.Token = e.Token + s.TokenExpires = e.TokenExpires + if s.client != nil && e.Token != "" { + s.client = s.client.SetAuthToken(e.Token) + } +} + +// Set will store a key-value pair inside the session much like in a context object +// that can be later retrieved using the Get method. This is useful to caching details +// such as a database connection or similar complex object. +func (s *Session) Set(key, value any) { + if s.extra == nil { + s.extra = make(map[any]any) + } + if value == nil { + delete(s.extra, key) + return + } + s.extra[key] = value +} + +// Get will retrieve a value from the session's cache or return nil. +func (s *Session) Get(key any) any { + if s.extra == nil { + return nil + } + return s.extra[key] +} + +// SetToken will update the session's token value and prepare for an authorization +// request to be sent to the Invopop API. +func (s *Session) SetToken(tok string) { + s.Token = tok + s.TokenExpires = 0 + if s.client != nil { + s.client = s.client.WithAuthToken(tok) + } +} + +// SetOwnerID will update the session's owner ID value in preparation for an authorization +// request to be sent to the Invopop API. +func (s *Session) SetOwnerID(oid string) { + s.OwnerID = oid +} + +// Authorized indicates whether the session has a valid token that is not expired. +// The server may have a different state, so this does not guarantee that requests +// will succeed, but is a good pre-check before making calls. +func (s *Session) Authorized() bool { + return s.Token != "" && s.TokenExpires != 0 && time.Now().Unix() < s.TokenExpires +} + +// ShouldRenew indicates whether the session's token is close to expiration +// and should be renewed. +func (s *Session) ShouldRenew() bool { + if s.TokenExpires == 0 { + return true + } + tn := time.Now().Unix() + // renew if less than 5 minutes to expiration + return (s.TokenExpires - tn) < sessionTokenTTL +} + +// CanStore indicates whether the session has sufficient data to be stored. +func (s *Session) CanStore() bool { + return s.EnrollmentID != "" && s.Token != "" +} + +// UnmarshalJSON implements the json.Unmarshaler interface to +// ensure that any token in the payload will be added to the client +// automatically. +func (s *Session) UnmarshalJSON(data []byte) error { + type sessionAlias Session + var sa sessionAlias + if err := json.Unmarshal(data, &sa); err != nil { + return err + } + sa.client = s.client // ensure client preserved + *s = Session(sa) + if s.Token != "" && s.client != nil { + s.client = s.client.SetAuthToken(s.Token) + } + return nil +} + +// Context adds the session to the context so that it can be easily re-used inside +// other parts of the application. Use this sparingly, ideally you want to be passing +// the session directly between method calls, but given that a session may have different +// credentials for each incoming request, the context can be a lot more convenient. +func (s *Session) Context(ctx context.Context) context.Context { + return context.WithValue(ctx, sessionKey, s) +} + +// GetSession tries to extract a session object from the provided context. +func GetSession(ctx context.Context) *Session { + s, ok := ctx.Value(sessionKey).(*Session) + if !ok { + return nil + } + return s +} diff --git a/invopop/errors.go b/invopop/errors.go index cbd2d01..75ad2bc 100644 --- a/invopop/errors.go +++ b/invopop/errors.go @@ -8,6 +8,12 @@ import ( "resty.dev/v3" ) +// Common errors directly exposed by the invopop package and not considered +// response errors. +var ( + ErrAccessDenied = errors.New("access denied") +) + // ResponseError is a wrapper around error responses from the server that will handle // error messages. type ResponseError struct { diff --git a/invopop/invopop.go b/invopop/invopop.go index dfd83cb..2ea8a95 100644 --- a/invopop/invopop.go +++ b/invopop/invopop.go @@ -20,11 +20,11 @@ type Client struct { svc *service } -type invopopClientKey string +type invopopContextKey string const ( - productionHost = "https://api.invopop.com" - clientKey invopopClientKey = "invopop-client" + productionHost = "https://api.invopop.com" + clientKey invopopContextKey = "client" ) // HTTPClient returns the underlying HTTP client (useful for mocking). @@ -115,18 +115,23 @@ func WithOAuthClient(id, secret string) ClientOption { } } -// SetAuthToken will set the authentication token inside a -// new client instance. This is useful for dealing with multiple -// connections that don't necessarily share the same token, such -// as when building apps that use enrollments to authenticate -// sessions. -func (c *Client) SetAuthToken(token string) *Client { +// WithAuthToken will clone the current client with a new authentication token. +// Use this method with applications that have a default client with static +// OAuth credentials, and need to upgrade the connection for a specific enrollment. +func (c *Client) WithAuthToken(token string) *Client { c2 := *c c2.conn = c2.conn.Clone(context.Background()).SetAuthToken(token) c2.svc = &service{client: &c2} return &c2 } +// SetAuthToken will set the authentication token inside a new client +// instance. +// Deprecated: use WithAuthToken instead. +func (c *Client) SetAuthToken(token string) *Client { + return c.WithAuthToken(token) +} + // Context adds the current client model to the context so that it can be // easily re-used inside other parts of the application. Use this sparingly, // ideally you want to be passing the client directly, but given that a client diff --git a/pkg/echopop/echopop.go b/pkg/echopop/echopop.go index 8aec1a4..f10277e 100644 --- a/pkg/echopop/echopop.go +++ b/pkg/echopop/echopop.go @@ -4,88 +4,11 @@ package echopop import ( "net/http" - "strings" "github.com/a-h/templ" - "github.com/invopop/client.go/invopop" "github.com/labstack/echo/v4" ) -// Context keys -const ( - enrollmentKey = "enrollment" - enrollmentStateKey = "state" - invopopClientKey = "invopop-client" -) - -// AuthEnrollment defines a middleware function that will authenticate -// an enrollment with the Invopop API. This middleware will only -// work if the invopop client has been prepared using the OAuth Client -// ID and Secret. -// -// This method supports tokens provided either via the "Authorization" -// header, or a "state" query parameter, and is meant to be used -// by applications that offer a web interface via the Invopop Console. -// -// Enrollments authorized in this way will include a new token with -// additional scopes that can be used to access restricted functionality -// like updating the embedded enrollment data or accessing silo entry -// meta rows. -func AuthEnrollment(ic *invopop.Client) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - ctx := c.Request().Context() - tok := "" - - // extract bearer auth token - ah := strings.Split(c.Request().Header.Get("Authorization"), "Bearer ") - if len(ah) == 2 && ah[1] != "" { - tok = ah[1] - } - if tok == "" { - // try to use OAuth 2.0 state query param - tok = c.QueryParam(enrollmentStateKey) - } - if tok == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "missing auth token") - } - - // override any existing tokens in the connection - ic = ic.SetAuthToken(tok) - - e, err := ic.Access().Enrollment().Authorize(ctx) - if err != nil { - if invopop.IsNotFound(err) { - return echo.NewHTTPError(http.StatusUnauthorized, "enrollment not found").WithInternal(err) - } - return echo.NewHTTPError(http.StatusInternalServerError).WithInternal(err) - } - c.Set(enrollmentKey, e) - c.Set(invopopClientKey, ic.SetAuthToken(e.Token)) - - return next(c) - } - } - -} - -// GetEnrollment retrieves the enrollment object from the context. -func GetEnrollment(c echo.Context) *invopop.Enrollment { - if en, ok := c.Get(enrollmentKey).(*invopop.Enrollment); ok { - return en - } - return nil -} - -// GetClient provides the Invopop client that was prepared with -// the enrollment's auth token. -func GetClient(c echo.Context) *invopop.Client { - if c, ok := c.Get(invopopClientKey).(*invopop.Client); ok { - return c - } - return nil -} - // Render will render the provided Templ Component. // // Usage example: diff --git a/pkg/echopop/enrollments.go b/pkg/echopop/enrollments.go new file mode 100644 index 0000000..be9c5d9 --- /dev/null +++ b/pkg/echopop/enrollments.go @@ -0,0 +1,103 @@ +package echopop + +import ( + "errors" + "fmt" + "net/http" + + "github.com/invopop/client.go/invopop" + "github.com/labstack/echo/v4" +) + +// Context keys +const ( + enrollmentKey = "enrollment" + enrollmentStateKey = "state" + invopopClientKey = "invopop-client" +) + +// LoadEnrollment will try to load the enrollment using the request details +// and provide the enrollment and prepared client in the context. +// +// This method supports tokens provided either via the "Authorization" +// header, or a "state" query parameter, and is meant to be used +// by applications that offer a web interface via the Invopop Console. +// +// Enrollments authorized in this way will include a new token with +// additional scopes that can be used to access restricted functionality +// like updating the embedded enrollment data or accessing silo entry +// meta rows. +// +// We'd recommend using sessions instead of this method for most applications. +func LoadEnrollment(ic *invopop.Client, c echo.Context) error { + ctx := c.Request().Context() + + // Try to read the auth token from standard locations + tok := AuthToken(c) + if tok == "" { + return fmt.Errorf("%w: missing auth token", invopop.ErrAccessDenied) + } + + // override any existing tokens in the connection + ic = ic.SetAuthToken(tok) + + e, err := ic.Access().Enrollment().Authorize(ctx) + if err != nil { + if invopop.IsNotFound(err) { + return fmt.Errorf("%w: enrollment not found", invopop.ErrAccessDenied) + } + return err + } + c.Set(enrollmentKey, e) + c.Set(invopopClientKey, ic.SetAuthToken(e.Token)) + + return nil +} + +// AuthEnrollment defines a middleware function that will authenticate +// an enrollment with the Invopop API. This middleware will only +// work if the invopop client has been prepared using the OAuth Client +// ID and Secret. +// +// This method supports tokens provided either via the "Authorization" +// header, or a "state" query parameter, and is meant to be used +// by applications that offer a web interface via the Invopop Console. +// +// Enrollments authorized in this way will include a new token with +// additional scopes that can be used to access restricted functionality +// like updating the embedded enrollment data or accessing silo entry +// meta rows. +// +// Deprecated: You should provide your own middleware around the +// LoadEnrollment method and provide custom response handling for your +// application. +func AuthEnrollment(ic *invopop.Client) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if err := LoadEnrollment(ic, c); err != nil { + if errors.Is(err, invopop.ErrAccessDenied) { + return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) + } + return echo.NewHTTPError(http.StatusInternalServerError).WithInternal(err) + } + return next(c) + } + } +} + +// GetEnrollment retrieves the enrollment object from the context. +func GetEnrollment(c echo.Context) *invopop.Enrollment { + if en, ok := c.Get(enrollmentKey).(*invopop.Enrollment); ok { + return en + } + return nil +} + +// GetClient provides the Invopop client that was prepared with +// the enrollment's auth token. +func GetClient(c echo.Context) *invopop.Client { + if client, ok := c.Get(invopopClientKey).(*invopop.Client); ok { + return client + } + return nil +} diff --git a/pkg/echopop/service.go b/pkg/echopop/service.go index 8092ada..c18d192 100644 --- a/pkg/echopop/service.go +++ b/pkg/echopop/service.go @@ -9,10 +9,14 @@ import ( "net/http" "os" "path" + "strings" "time" "github.com/foolin/goview" echoview "github.com/foolin/goview/supports/echoview-v4" + "github.com/gorilla/securecookie" + "github.com/gorilla/sessions" + "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/rs/zerolog/log" @@ -21,19 +25,51 @@ import ( // Service provides a wrapper around Echo that makes it a bit easier // to start up a new service that will provide an HTTP server. type Service struct { - echo *echo.Echo + echo *echo.Echo + sessionKey string +} + +// Option defines a configuration option for the Service. +type Option func(s *Service) + +// WithCookieSessionKey sets the session key to be used by the service. This must be +// a sufficiently long random string to ensure the security of the session cookies, +// with a minimum length of 32 bytes recommended. See the GenerateCookieSecret function +// for a way to generate a suitable random string. +func WithCookieSessionKey(key string) Option { + return func(s *Service) { + s.sessionKey = key + } } // NewService instantiates a new echo service using some reasonable -// defaults. -func NewService() *Service { +// defaults. Typical usage example: +// +// svc := echopop.NewService( +// echopop.WithCookieSessionKey("your-randomly-long-secret"), +// ) +// svc.Serve(func(e *echo.Echo) { +// e.StaticFS("/", assets.Content) +// g := e.Group("/api", echopop.LoadSession(ic)) +// g.GET("/test", testHandler) +// }) +func NewService(opts ...Option) *Service { s := &Service{ echo: echo.New(), } + for _, opt := range opts { + opt(s) + } s.echo.Use(logRequest()) s.echo.Use(middleware.Recover()) + if s.sessionKey != "" { + s.echo.Use( + session.Middleware(sessions.NewCookieStore([]byte(s.sessionKey))), + ) + } + return s } @@ -58,6 +94,23 @@ func (s *Service) StaticRootFS(fs fs.FS, root string) { s.echo.StaticFS("/", echo.MustSubFS(fs, root)) } +// AuthToken is a convenience method to extract the authentication token from +// the request context. It will look for a Bearer token in the Authorization +// header, or a "state" query parameter which is often used in OAuth 2.0 flows. +// If no token is found, an empty string is returned. +func AuthToken(c echo.Context) string { + tok := "" + auth := c.Request().Header.Get("Authorization") + if len(auth) > 7 && strings.EqualFold(auth[:7], "bearer ") { + tok = auth[7:] + } + if tok == "" { + // try to use OAuth 2.0 state query param + tok = c.QueryParam(enrollmentStateKey) + } + return tok +} + // Render will prepare the echo templating feature using "goview" // and the recommended defaults for modules. // @@ -124,6 +177,13 @@ func (s *Service) Stop(ctx context.Context) error { return s.echo.Shutdown(ctx) } +// GenerateCookieSecret will generate a sufficiently random secret +// suitable for use with sessions. +func GenerateCookieSecret() string { + key := securecookie.GenerateRandomKey(32) + return fmt.Sprintf("%x", key) +} + func logRequest() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { diff --git a/pkg/echopop/session.go b/pkg/echopop/session.go new file mode 100644 index 0000000..ee673ef --- /dev/null +++ b/pkg/echopop/session.go @@ -0,0 +1,125 @@ +package echopop + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gorilla/sessions" + "github.com/invopop/client.go/invopop" + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/v4" +) + +const ( + sessionCookieName = "_echopop" + sessionCtxKey = "session" +) + +// LoadSession provides the session middleware for usage in routes that may +// use a session. This will only try and prepare the session object based +// on the presence of the `Authorization` header, `state` query parameter, +// or a cookie, and will not enforce that a session is authorized. +// +// Depending on your use-case, you may want to follow this middleware up with an +// Authorize call on the session to ensure that it is valid and populate it with +// data from the enrollment. +// +// While storage in cookies is permitted, as a rule this is not recommended, +// and especially not for embedded applications running inside the Invopop Console +// where sessions stored in cookies would be shared between multiple browser tabs +// potentially showing different workspaces. +func LoadSession(ic *invopop.Client) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + sess := ic.Access().NewSession() + + // Try to extract a token from the headers + if tok := AuthToken(c); tok != "" { + sess.SetToken(tok) + } else { + // Try to extract the session from the cookie + err := extractSessionFromCookie(c, sess) + if err != nil { + return fmt.Errorf("extracting session from cookie: %w", err) + } + } + + // Prepare the session for use in the rest of the context. + c.Set(sessionCtxKey, sess) + + return next(c) + } + } +} + +func extractSessionFromCookie(c echo.Context, sess *invopop.Session) error { + ck, err := session.Get(sessionCookieName, c) + if err != nil { + return fmt.Errorf("from cookie: %w", err) + } + + // If there is something we can parse, try to use it. This implies + // that a previous session was authenticated and stored. + if val, ok := ck.Values[sessionCtxKey]; ok { + str, ok := val.(string) + if !ok { + return fmt.Errorf("unexpected session cookie format") + } + err = json.Unmarshal([]byte(str), sess) + if err != nil { + return fmt.Errorf("unmarshaling session: %w", err) + } + } + + return nil +} + +// GetSession will retrieve the session object from the context assuming that it was already +// prepared using the echopop Service's LoadSession middleware. +func GetSession(c echo.Context) *invopop.Session { + sess, ok := c.Get(sessionCtxKey).(*invopop.Session) + if !ok { + return nil + } + return sess +} + +// StoreSessionCookie will store the session object into a secure cookie in the response headers. +// Cookies can only be used for browser-based page requests as Cookie's HttpOnly flag prevents +// AJAX requests from accessing them. Use the `Authorization` header for API/AJAX +// requests which may include the session also. +func StoreSessionCookie(c echo.Context, sess *invopop.Session) error { + cs, err := session.Get(sessionCookieName, c) + if err != nil { + return fmt.Errorf("preparing session: %w", err) + } + + tn := time.Now().Unix() + if sess.TokenExpires > 0 && tn >= sess.TokenExpires { + // session expired, clear it + cs.Options = &sessions.Options{ + MaxAge: -1, + } + } else { + cs.Options = &sessions.Options{ + Path: "/", + MaxAge: int(sess.TokenExpires - tn), + HttpOnly: true, // cookies cannot be used for AJAX + SameSite: http.SameSiteStrictMode, // prevent CSRF + Secure: true, + } + if cs.Options.MaxAge < 0 { + // Don't allow negative max-age + cs.Options.MaxAge = 0 + } + b, err := json.Marshal(sess) + if err != nil { + return fmt.Errorf("marshaling session: %w", err) + } + cs.Values[sessionCtxKey] = string(b) + } + + return cs.Save(c.Request(), c.Response()) +}