Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
34 changes: 26 additions & 8 deletions invopop/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,41 @@ 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.Token = token
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
191 changes: 191 additions & 0 deletions invopop/access_session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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 method, even if you intend to
// unmarshal them from a stored source to ensure that there is always a client available.
//
// 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 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
}
en, err := s.client.Access().Enrollment().Authorize(ctx)
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
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
s.client = s.client.WithAuthToken(tok)
}

// 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