Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
43 changes: 35 additions & 8 deletions invopop/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
3 changes: 2 additions & 1 deletion invopop/access_enrollments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
222 changes: 222 additions & 0 deletions invopop/access_session.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions invopop/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 14 additions & 9 deletions invopop/invopop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading