From 8d0d86f57d5fdb543ac24e554143ed3b388d1687 Mon Sep 17 00:00:00 2001 From: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> Date: Tue, 27 Aug 2024 14:08:23 -0600 Subject: [PATCH] add providers, tokens, and totp packages (#2) * add providers, tokens, and totp packages Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> * generate Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --------- Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --- .buildkite/pipeline.yaml | 1 + .golangci.yaml | 4 +- Taskfile.yaml | 3 + .../_examples/basic/ent/auth_from_mutation.go | 2 +- entfga/_examples/basic/ent/authz_checks.go | 2 +- entfga/_examples/basic/ent/client.go | 2 +- entfga/_examples/basic/ent/ent.go | 2 +- entfga/_examples/basic/ent/gql_node.go | 2 +- .../_examples/basic/ent/organization_query.go | 21 +- .../basic/ent/orgmembership_create.go | 2 +- .../basic/ent/orgmembership_query.go | 21 +- .../basic/ent/orgmembership_update.go | 4 +- entfga/_examples/basic/ent/runtime/runtime.go | 4 +- entfga/_examples/basic/graphapi/gen_server.go | 74 +-- go.mod | 179 +++++-- go.sum | 446 +++++++++++++++--- providers/github/client.go | 122 +++++ providers/github/config.go | 15 + providers/github/context.go | 46 ++ providers/github/context_test.go | 29 ++ providers/github/doc.go | 2 + providers/github/errors.go | 45 ++ providers/github/login.go | 197 ++++++++ providers/github/login_test.go | 185 ++++++++ providers/github/server_test.go | 25 + providers/google/config.go | 15 + providers/google/context.go | 46 ++ providers/google/context_test.go | 26 + providers/google/doc.go | 2 + providers/google/errors.go | 42 ++ providers/google/login.go | 117 +++++ providers/google/login_test.go | 117 +++++ providers/google/server_test.go | 21 + providers/oauth2/context.go | 81 ++++ providers/oauth2/context_test.go | 43 ++ providers/oauth2/doc.go | 2 + providers/oauth2/errors.go | 42 ++ providers/oauth2/login.go | 133 ++++++ providers/oauth2/login_test.go | 207 ++++++++ providers/oauth2/oauth2_test.go | 28 ++ providers/oidc/doc.go | 2 + providers/oidc/oidc.go | 94 ++++ providers/webauthn/config.go | 68 +++ providers/webauthn/doc.go | 2 + providers/webauthn/errors.go | 13 + providers/webauthn/passkey-registration.png | Bin 0 -> 125961 bytes providers/webauthn/user.go | 63 +++ providers/webauthn/user_test.go | 114 +++++ tokens/claims.go | 37 ++ tokens/claims_test.go | 35 ++ tokens/config.go | 27 ++ tokens/doc.go | 2 + tokens/errors.go | 216 +++++++++ tokens/expires_test.go | 56 +++ tokens/jwks.go | 117 +++++ tokens/jwks_test.go | 132 ++++++ tokens/mock.go | 29 ++ tokens/signing.go | 50 ++ .../testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem | 51 ++ .../testdata/01GE62EXXR0X0561XD53RDFBQJ.pem | 51 ++ tokens/testdata/jwks.json | 1 + tokens/testdata/partial_jwks.json | 12 + tokens/tokenmanager.go | 413 ++++++++++++++++ tokens/tokens_test.go | 367 ++++++++++++++ tokens/urltokens.go | 292 ++++++++++++ tokens/urltokens_test.go | 261 ++++++++++ tokens/validator.go | 107 +++++ totp/README.md | 39 ++ totp/config.go | 86 ++++ totp/crypto.go | 72 +++ totp/crypto_test.go | 47 ++ totp/doc.go | 2 + totp/errors.go | 82 ++++ totp/manager.go | 139 ++++++ totp/manager_test.go | 69 +++ totp/testing/README.md | 3 + totp/testing/api/totp_api.go | 7 + totp/testing/main.go | 29 ++ totp/testing/static/css/totp.css | 0 totp/testing/static/js/totp.js | 93 ++++ totp/testing/templates/totp.html | 103 ++++ totp/testing/views/totp_views.go | 91 ++++ totp/totp.go | 376 +++++++++++++++ totp/totp_test.go | 106 +++++ 84 files changed, 6130 insertions(+), 183 deletions(-) create mode 100644 providers/github/client.go create mode 100644 providers/github/config.go create mode 100644 providers/github/context.go create mode 100644 providers/github/context_test.go create mode 100644 providers/github/doc.go create mode 100644 providers/github/errors.go create mode 100644 providers/github/login.go create mode 100644 providers/github/login_test.go create mode 100644 providers/github/server_test.go create mode 100644 providers/google/config.go create mode 100644 providers/google/context.go create mode 100644 providers/google/context_test.go create mode 100644 providers/google/doc.go create mode 100644 providers/google/errors.go create mode 100644 providers/google/login.go create mode 100644 providers/google/login_test.go create mode 100644 providers/google/server_test.go create mode 100644 providers/oauth2/context.go create mode 100644 providers/oauth2/context_test.go create mode 100644 providers/oauth2/doc.go create mode 100644 providers/oauth2/errors.go create mode 100644 providers/oauth2/login.go create mode 100644 providers/oauth2/login_test.go create mode 100644 providers/oauth2/oauth2_test.go create mode 100644 providers/oidc/doc.go create mode 100644 providers/oidc/oidc.go create mode 100644 providers/webauthn/config.go create mode 100644 providers/webauthn/doc.go create mode 100644 providers/webauthn/errors.go create mode 100644 providers/webauthn/passkey-registration.png create mode 100644 providers/webauthn/user.go create mode 100644 providers/webauthn/user_test.go create mode 100644 tokens/claims.go create mode 100644 tokens/claims_test.go create mode 100644 tokens/config.go create mode 100644 tokens/doc.go create mode 100644 tokens/errors.go create mode 100644 tokens/expires_test.go create mode 100644 tokens/jwks.go create mode 100644 tokens/jwks_test.go create mode 100644 tokens/mock.go create mode 100644 tokens/signing.go create mode 100644 tokens/testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem create mode 100644 tokens/testdata/01GE62EXXR0X0561XD53RDFBQJ.pem create mode 100644 tokens/testdata/jwks.json create mode 100644 tokens/testdata/partial_jwks.json create mode 100644 tokens/tokenmanager.go create mode 100644 tokens/tokens_test.go create mode 100644 tokens/urltokens.go create mode 100644 tokens/urltokens_test.go create mode 100644 tokens/validator.go create mode 100644 totp/README.md create mode 100644 totp/config.go create mode 100644 totp/crypto.go create mode 100644 totp/crypto_test.go create mode 100644 totp/doc.go create mode 100644 totp/errors.go create mode 100644 totp/manager.go create mode 100644 totp/manager_test.go create mode 100644 totp/testing/README.md create mode 100644 totp/testing/api/totp_api.go create mode 100644 totp/testing/main.go create mode 100644 totp/testing/static/css/totp.css create mode 100644 totp/testing/static/js/totp.js create mode 100644 totp/testing/templates/totp.html create mode 100644 totp/testing/views/totp_views.go create mode 100644 totp/totp.go create mode 100644 totp/totp_test.go diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml index e4d8669..c32aa80 100644 --- a/.buildkite/pipeline.yaml +++ b/.buildkite/pipeline.yaml @@ -43,6 +43,7 @@ steps: command: ["task", "example:generate"] environment: - "GOTOOLCHAIN=auto" + - "GODEBUG=gotypesalias=0" - group: ":closed_lock_with_key: Security Checks" depends_on: "tests" key: "security" diff --git a/.golangci.yaml b/.golangci.yaml index a82e48c..fa85d0e 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -38,6 +38,4 @@ issues: fix: true exclude-use-default: true exclude-dirs: - - mockery/* - - entfga/_examples/* - - entfga/templates/* + - totp/testing/* diff --git a/Taskfile.yaml b/Taskfile.yaml index caea403..e4941fd 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -5,6 +5,9 @@ includes: taskfile: ./entfga/_examples/basic/ dir: ./entfga/_examples/basic/ +env: + GODEBUG: gotypesalias=0 # remove once the backport fixes the types.Alias bug + tasks: generate: desc: generate the mock fga client diff --git a/entfga/_examples/basic/ent/auth_from_mutation.go b/entfga/_examples/basic/ent/auth_from_mutation.go index 9db66e9..f92f514 100644 --- a/entfga/_examples/basic/ent/auth_from_mutation.go +++ b/entfga/_examples/basic/ent/auth_from_mutation.go @@ -7,8 +7,8 @@ package ent import ( "context" - "github.com/theopenlane/iam/fgax" "github.com/theopenlane/iam/entfga" + "github.com/theopenlane/iam/fgax" ) func (m *OrgMembershipMutation) CreateTuplesFromCreate(ctx context.Context) error { diff --git a/entfga/_examples/basic/ent/authz_checks.go b/entfga/_examples/basic/ent/authz_checks.go index daf258f..5fb313f 100644 --- a/entfga/_examples/basic/ent/authz_checks.go +++ b/entfga/_examples/basic/ent/authz_checks.go @@ -8,8 +8,8 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/theopenlane/iam/auth" - "github.com/theopenlane/iam/fgax" "github.com/theopenlane/iam/entfga/_examples/basic/ent/organization" + "github.com/theopenlane/iam/fgax" ) func (q *OrgMembershipQuery) CheckAccess(ctx context.Context) error { diff --git a/entfga/_examples/basic/ent/client.go b/entfga/_examples/basic/ent/client.go index bc9e3c0..af81a90 100644 --- a/entfga/_examples/basic/ent/client.go +++ b/entfga/_examples/basic/ent/client.go @@ -16,9 +16,9 @@ import ( "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" - "github.com/theopenlane/iam/fgax" "github.com/theopenlane/iam/entfga/_examples/basic/ent/organization" "github.com/theopenlane/iam/entfga/_examples/basic/ent/orgmembership" + "github.com/theopenlane/iam/fgax" "go.uber.org/zap" ) diff --git a/entfga/_examples/basic/ent/ent.go b/entfga/_examples/basic/ent/ent.go index bc8fdd8..bac03b7 100644 --- a/entfga/_examples/basic/ent/ent.go +++ b/entfga/_examples/basic/ent/ent.go @@ -70,7 +70,7 @@ var ( columnCheck sql.ColumnCheck ) -// columnChecker checks if the column exists in the given table. +// checkColumn checks if the column exists in the given table. func checkColumn(table, column string) error { initCheck.Do(func() { columnCheck = sql.NewColumnCheck(map[string]func(string) bool{ diff --git a/entfga/_examples/basic/ent/gql_node.go b/entfga/_examples/basic/ent/gql_node.go index 4fe3014..8eafd26 100644 --- a/entfga/_examples/basic/ent/gql_node.go +++ b/entfga/_examples/basic/ent/gql_node.go @@ -8,9 +8,9 @@ import ( "entgo.io/contrib/entgql" "github.com/99designs/gqlgen/graphql" + "github.com/hashicorp/go-multierror" "github.com/theopenlane/iam/entfga/_examples/basic/ent/organization" "github.com/theopenlane/iam/entfga/_examples/basic/ent/orgmembership" - "github.com/hashicorp/go-multierror" ) // Noder wraps the basic Node method. diff --git a/entfga/_examples/basic/ent/organization_query.go b/entfga/_examples/basic/ent/organization_query.go index a2800c4..6f1b67c 100644 --- a/entfga/_examples/basic/ent/organization_query.go +++ b/entfga/_examples/basic/ent/organization_query.go @@ -8,6 +8,7 @@ import ( "fmt" "math" + "entgo.io/ent" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -63,7 +64,7 @@ func (oq *OrganizationQuery) Order(o ...organization.OrderOption) *OrganizationQ // First returns the first Organization entity from the query. // Returns a *NotFoundError when no Organization was found. func (oq *OrganizationQuery) First(ctx context.Context) (*Organization, error) { - nodes, err := oq.Limit(1).All(setContextOp(ctx, oq.ctx, "First")) + nodes, err := oq.Limit(1).All(setContextOp(ctx, oq.ctx, ent.OpQueryFirst)) if err != nil { return nil, err } @@ -86,7 +87,7 @@ func (oq *OrganizationQuery) FirstX(ctx context.Context) *Organization { // Returns a *NotFoundError when no Organization ID was found. func (oq *OrganizationQuery) FirstID(ctx context.Context) (id string, err error) { var ids []string - if ids, err = oq.Limit(1).IDs(setContextOp(ctx, oq.ctx, "FirstID")); err != nil { + if ids, err = oq.Limit(1).IDs(setContextOp(ctx, oq.ctx, ent.OpQueryFirstID)); err != nil { return } if len(ids) == 0 { @@ -109,7 +110,7 @@ func (oq *OrganizationQuery) FirstIDX(ctx context.Context) string { // Returns a *NotSingularError when more than one Organization entity is found. // Returns a *NotFoundError when no Organization entities are found. func (oq *OrganizationQuery) Only(ctx context.Context) (*Organization, error) { - nodes, err := oq.Limit(2).All(setContextOp(ctx, oq.ctx, "Only")) + nodes, err := oq.Limit(2).All(setContextOp(ctx, oq.ctx, ent.OpQueryOnly)) if err != nil { return nil, err } @@ -137,7 +138,7 @@ func (oq *OrganizationQuery) OnlyX(ctx context.Context) *Organization { // Returns a *NotFoundError when no entities are found. func (oq *OrganizationQuery) OnlyID(ctx context.Context) (id string, err error) { var ids []string - if ids, err = oq.Limit(2).IDs(setContextOp(ctx, oq.ctx, "OnlyID")); err != nil { + if ids, err = oq.Limit(2).IDs(setContextOp(ctx, oq.ctx, ent.OpQueryOnlyID)); err != nil { return } switch len(ids) { @@ -162,7 +163,7 @@ func (oq *OrganizationQuery) OnlyIDX(ctx context.Context) string { // All executes the query and returns a list of Organizations. func (oq *OrganizationQuery) All(ctx context.Context) ([]*Organization, error) { - ctx = setContextOp(ctx, oq.ctx, "All") + ctx = setContextOp(ctx, oq.ctx, ent.OpQueryAll) if err := oq.prepareQuery(ctx); err != nil { return nil, err } @@ -184,7 +185,7 @@ func (oq *OrganizationQuery) IDs(ctx context.Context) (ids []string, err error) if oq.ctx.Unique == nil && oq.path != nil { oq.Unique(true) } - ctx = setContextOp(ctx, oq.ctx, "IDs") + ctx = setContextOp(ctx, oq.ctx, ent.OpQueryIDs) if err = oq.Select(organization.FieldID).Scan(ctx, &ids); err != nil { return nil, err } @@ -202,7 +203,7 @@ func (oq *OrganizationQuery) IDsX(ctx context.Context) []string { // Count returns the count of the given query. func (oq *OrganizationQuery) Count(ctx context.Context) (int, error) { - ctx = setContextOp(ctx, oq.ctx, "Count") + ctx = setContextOp(ctx, oq.ctx, ent.OpQueryCount) if err := oq.prepareQuery(ctx); err != nil { return 0, err } @@ -220,7 +221,7 @@ func (oq *OrganizationQuery) CountX(ctx context.Context) int { // Exist returns true if the query has elements in the graph. func (oq *OrganizationQuery) Exist(ctx context.Context) (bool, error) { - ctx = setContextOp(ctx, oq.ctx, "Exist") + ctx = setContextOp(ctx, oq.ctx, ent.OpQueryExist) switch _, err := oq.FirstID(ctx); { case IsNotFound(err): return false, nil @@ -469,7 +470,7 @@ func (ogb *OrganizationGroupBy) Aggregate(fns ...AggregateFunc) *OrganizationGro // Scan applies the selector query and scans the result into the given value. func (ogb *OrganizationGroupBy) Scan(ctx context.Context, v any) error { - ctx = setContextOp(ctx, ogb.build.ctx, "GroupBy") + ctx = setContextOp(ctx, ogb.build.ctx, ent.OpQueryGroupBy) if err := ogb.build.prepareQuery(ctx); err != nil { return err } @@ -517,7 +518,7 @@ func (os *OrganizationSelect) Aggregate(fns ...AggregateFunc) *OrganizationSelec // Scan applies the selector query and scans the result into the given value. func (os *OrganizationSelect) Scan(ctx context.Context, v any) error { - ctx = setContextOp(ctx, os.ctx, "Select") + ctx = setContextOp(ctx, os.ctx, ent.OpQuerySelect) if err := os.prepareQuery(ctx); err != nil { return err } diff --git a/entfga/_examples/basic/ent/orgmembership_create.go b/entfga/_examples/basic/ent/orgmembership_create.go index 4c6c69b..1d65d36 100644 --- a/entfga/_examples/basic/ent/orgmembership_create.go +++ b/entfga/_examples/basic/ent/orgmembership_create.go @@ -118,7 +118,7 @@ func (omc *OrgMembershipCreate) check() error { if _, ok := omc.mutation.UserID(); !ok { return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "OrgMembership.user_id"`)} } - if _, ok := omc.mutation.OrganizationID(); !ok { + if len(omc.mutation.OrganizationIDs()) == 0 { return &ValidationError{Name: "organization", err: errors.New(`ent: missing required edge "OrgMembership.organization"`)} } return nil diff --git a/entfga/_examples/basic/ent/orgmembership_query.go b/entfga/_examples/basic/ent/orgmembership_query.go index 7b303c7..296964f 100644 --- a/entfga/_examples/basic/ent/orgmembership_query.go +++ b/entfga/_examples/basic/ent/orgmembership_query.go @@ -8,6 +8,7 @@ import ( "fmt" "math" + "entgo.io/ent" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -87,7 +88,7 @@ func (omq *OrgMembershipQuery) QueryOrganization() *OrganizationQuery { // First returns the first OrgMembership entity from the query. // Returns a *NotFoundError when no OrgMembership was found. func (omq *OrgMembershipQuery) First(ctx context.Context) (*OrgMembership, error) { - nodes, err := omq.Limit(1).All(setContextOp(ctx, omq.ctx, "First")) + nodes, err := omq.Limit(1).All(setContextOp(ctx, omq.ctx, ent.OpQueryFirst)) if err != nil { return nil, err } @@ -110,7 +111,7 @@ func (omq *OrgMembershipQuery) FirstX(ctx context.Context) *OrgMembership { // Returns a *NotFoundError when no OrgMembership ID was found. func (omq *OrgMembershipQuery) FirstID(ctx context.Context) (id string, err error) { var ids []string - if ids, err = omq.Limit(1).IDs(setContextOp(ctx, omq.ctx, "FirstID")); err != nil { + if ids, err = omq.Limit(1).IDs(setContextOp(ctx, omq.ctx, ent.OpQueryFirstID)); err != nil { return } if len(ids) == 0 { @@ -133,7 +134,7 @@ func (omq *OrgMembershipQuery) FirstIDX(ctx context.Context) string { // Returns a *NotSingularError when more than one OrgMembership entity is found. // Returns a *NotFoundError when no OrgMembership entities are found. func (omq *OrgMembershipQuery) Only(ctx context.Context) (*OrgMembership, error) { - nodes, err := omq.Limit(2).All(setContextOp(ctx, omq.ctx, "Only")) + nodes, err := omq.Limit(2).All(setContextOp(ctx, omq.ctx, ent.OpQueryOnly)) if err != nil { return nil, err } @@ -161,7 +162,7 @@ func (omq *OrgMembershipQuery) OnlyX(ctx context.Context) *OrgMembership { // Returns a *NotFoundError when no entities are found. func (omq *OrgMembershipQuery) OnlyID(ctx context.Context) (id string, err error) { var ids []string - if ids, err = omq.Limit(2).IDs(setContextOp(ctx, omq.ctx, "OnlyID")); err != nil { + if ids, err = omq.Limit(2).IDs(setContextOp(ctx, omq.ctx, ent.OpQueryOnlyID)); err != nil { return } switch len(ids) { @@ -186,7 +187,7 @@ func (omq *OrgMembershipQuery) OnlyIDX(ctx context.Context) string { // All executes the query and returns a list of OrgMemberships. func (omq *OrgMembershipQuery) All(ctx context.Context) ([]*OrgMembership, error) { - ctx = setContextOp(ctx, omq.ctx, "All") + ctx = setContextOp(ctx, omq.ctx, ent.OpQueryAll) if err := omq.prepareQuery(ctx); err != nil { return nil, err } @@ -208,7 +209,7 @@ func (omq *OrgMembershipQuery) IDs(ctx context.Context) (ids []string, err error if omq.ctx.Unique == nil && omq.path != nil { omq.Unique(true) } - ctx = setContextOp(ctx, omq.ctx, "IDs") + ctx = setContextOp(ctx, omq.ctx, ent.OpQueryIDs) if err = omq.Select(orgmembership.FieldID).Scan(ctx, &ids); err != nil { return nil, err } @@ -226,7 +227,7 @@ func (omq *OrgMembershipQuery) IDsX(ctx context.Context) []string { // Count returns the count of the given query. func (omq *OrgMembershipQuery) Count(ctx context.Context) (int, error) { - ctx = setContextOp(ctx, omq.ctx, "Count") + ctx = setContextOp(ctx, omq.ctx, ent.OpQueryCount) if err := omq.prepareQuery(ctx); err != nil { return 0, err } @@ -244,7 +245,7 @@ func (omq *OrgMembershipQuery) CountX(ctx context.Context) int { // Exist returns true if the query has elements in the graph. func (omq *OrgMembershipQuery) Exist(ctx context.Context) (bool, error) { - ctx = setContextOp(ctx, omq.ctx, "Exist") + ctx = setContextOp(ctx, omq.ctx, ent.OpQueryExist) switch _, err := omq.FirstID(ctx); { case IsNotFound(err): return false, nil @@ -548,7 +549,7 @@ func (omgb *OrgMembershipGroupBy) Aggregate(fns ...AggregateFunc) *OrgMembership // Scan applies the selector query and scans the result into the given value. func (omgb *OrgMembershipGroupBy) Scan(ctx context.Context, v any) error { - ctx = setContextOp(ctx, omgb.build.ctx, "GroupBy") + ctx = setContextOp(ctx, omgb.build.ctx, ent.OpQueryGroupBy) if err := omgb.build.prepareQuery(ctx); err != nil { return err } @@ -596,7 +597,7 @@ func (oms *OrgMembershipSelect) Aggregate(fns ...AggregateFunc) *OrgMembershipSe // Scan applies the selector query and scans the result into the given value. func (oms *OrgMembershipSelect) Scan(ctx context.Context, v any) error { - ctx = setContextOp(ctx, oms.ctx, "Select") + ctx = setContextOp(ctx, oms.ctx, ent.OpQuerySelect) if err := oms.prepareQuery(ctx); err != nil { return err } diff --git a/entfga/_examples/basic/ent/orgmembership_update.go b/entfga/_examples/basic/ent/orgmembership_update.go index 529ad58..f603659 100644 --- a/entfga/_examples/basic/ent/orgmembership_update.go +++ b/entfga/_examples/basic/ent/orgmembership_update.go @@ -81,7 +81,7 @@ func (omu *OrgMembershipUpdate) check() error { return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "OrgMembership.role": %w`, err)} } } - if _, ok := omu.mutation.OrganizationID(); omu.mutation.OrganizationCleared() && !ok { + if omu.mutation.OrganizationCleared() && len(omu.mutation.OrganizationIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "OrgMembership.organization"`) } return nil @@ -188,7 +188,7 @@ func (omuo *OrgMembershipUpdateOne) check() error { return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "OrgMembership.role": %w`, err)} } } - if _, ok := omuo.mutation.OrganizationID(); omuo.mutation.OrganizationCleared() && !ok { + if omuo.mutation.OrganizationCleared() && len(omuo.mutation.OrganizationIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "OrgMembership.organization"`) } return nil diff --git a/entfga/_examples/basic/ent/runtime/runtime.go b/entfga/_examples/basic/ent/runtime/runtime.go index 657660f..8f96ff8 100644 --- a/entfga/_examples/basic/ent/runtime/runtime.go +++ b/entfga/_examples/basic/ent/runtime/runtime.go @@ -46,6 +46,6 @@ func init() { } const ( - Version = "v0.13.1" // Version of ent codegen. - Sum = "h1:uD8QwN1h6SNphdCCzmkMN3feSUzNnVvV/WIkHKMbzOE=" // Sum of ent codegen. + Version = "v0.14.1" // Version of ent codegen. + Sum = "h1:fUERL506Pqr92EPHJqr8EYxbPioflJo6PudkrEA8a/s=" // Sum of ent codegen. ) diff --git a/entfga/_examples/basic/graphapi/gen_server.go b/entfga/_examples/basic/graphapi/gen_server.go index ba825d3..cf0ca15 100644 --- a/entfga/_examples/basic/graphapi/gen_server.go +++ b/entfga/_examples/basic/graphapi/gen_server.go @@ -730,7 +730,7 @@ func (ec *executionContext) _OrgMembership_role(ctx context.Context, field graph } res := resTmp.(enums.Role) fc.Result = res - return ec.marshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, field.Selections, res) + return ec.marshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_OrgMembership_role(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -862,7 +862,7 @@ func (ec *executionContext) _OrgMembership_organization(ctx context.Context, fie } res := resTmp.(*ent.Organization) fc.Result = res - return ec.marshalNOrganization2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganization(ctx, field.Selections, res) + return ec.marshalNOrganization2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganization(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_OrgMembership_organization(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -1210,7 +1210,7 @@ func (ec *executionContext) _Query_node(ctx context.Context, field graphql.Colle } res := resTmp.(ent.Noder) fc.Result = res - return ec.marshalONode2githubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx, field.Selections, res) + return ec.marshalONode2githubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Query_node(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -1265,7 +1265,7 @@ func (ec *executionContext) _Query_nodes(ctx context.Context, field graphql.Coll } res := resTmp.([]ent.Noder) fc.Result = res - return ec.marshalNNode2ᚕgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx, field.Selections, res) + return ec.marshalNNode2ᚕgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Query_nodes(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -3210,7 +3210,7 @@ func (ec *executionContext) unmarshalInputCreateOrgMembershipInput(ctx context.C switch k { case "role": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("role")) - data, err := ec.unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, v) + data, err := ec.unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, v) if err != nil { return it, err } @@ -3285,21 +3285,21 @@ func (ec *executionContext) unmarshalInputOrgMembershipWhereInput(ctx context.Co switch k { case "not": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("not")) - data, err := ec.unmarshalOOrgMembershipWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInput(ctx, v) + data, err := ec.unmarshalOOrgMembershipWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInput(ctx, v) if err != nil { return it, err } it.Not = data case "and": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("and")) - data, err := ec.unmarshalOOrgMembershipWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInputᚄ(ctx, v) + data, err := ec.unmarshalOOrgMembershipWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInputᚄ(ctx, v) if err != nil { return it, err } it.And = data case "or": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("or")) - data, err := ec.unmarshalOOrgMembershipWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInputᚄ(ctx, v) + data, err := ec.unmarshalOOrgMembershipWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInputᚄ(ctx, v) if err != nil { return it, err } @@ -3376,28 +3376,28 @@ func (ec *executionContext) unmarshalInputOrgMembershipWhereInput(ctx context.Co it.IDContainsFold = data case "role": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("role")) - data, err := ec.unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, v) + data, err := ec.unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, v) if err != nil { return it, err } it.Role = data case "roleNEQ": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("roleNEQ")) - data, err := ec.unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, v) + data, err := ec.unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, v) if err != nil { return it, err } it.RoleNEQ = data case "roleIn": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("roleIn")) - data, err := ec.unmarshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRoleᚄ(ctx, v) + data, err := ec.unmarshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRoleᚄ(ctx, v) if err != nil { return it, err } it.RoleIn = data case "roleNotIn": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("roleNotIn")) - data, err := ec.unmarshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRoleᚄ(ctx, v) + data, err := ec.unmarshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRoleᚄ(ctx, v) if err != nil { return it, err } @@ -3593,7 +3593,7 @@ func (ec *executionContext) unmarshalInputOrgMembershipWhereInput(ctx context.Co it.HasOrganization = data case "hasOrganizationWith": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("hasOrganizationWith")) - data, err := ec.unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInputᚄ(ctx, v) + data, err := ec.unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInputᚄ(ctx, v) if err != nil { return it, err } @@ -3620,21 +3620,21 @@ func (ec *executionContext) unmarshalInputOrganizationWhereInput(ctx context.Con switch k { case "not": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("not")) - data, err := ec.unmarshalOOrganizationWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInput(ctx, v) + data, err := ec.unmarshalOOrganizationWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInput(ctx, v) if err != nil { return it, err } it.Not = data case "and": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("and")) - data, err := ec.unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInputᚄ(ctx, v) + data, err := ec.unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInputᚄ(ctx, v) if err != nil { return it, err } it.And = data case "or": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("or")) - data, err := ec.unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInputᚄ(ctx, v) + data, err := ec.unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInputᚄ(ctx, v) if err != nil { return it, err } @@ -3927,7 +3927,7 @@ func (ec *executionContext) unmarshalInputUpdateOrgMembershipInput(ctx context.C switch k { case "role": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("role")) - data, err := ec.unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, v) + data, err := ec.unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, v) if err != nil { return it, err } @@ -4669,7 +4669,7 @@ func (ec *executionContext) marshalNID2ᚕstringᚄ(ctx context.Context, sel ast return ret } -func (ec *executionContext) marshalNNode2ᚕgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx context.Context, sel ast.SelectionSet, v []ent.Noder) graphql.Marshaler { +func (ec *executionContext) marshalNNode2ᚕgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx context.Context, sel ast.SelectionSet, v []ent.Noder) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup isLen1 := len(v) == 1 @@ -4693,7 +4693,7 @@ func (ec *executionContext) marshalNNode2ᚕgithubᚗcomᚋtheopenlaneᚋfgaxᚋ if !isLen1 { defer wg.Done() } - ret[i] = ec.marshalONode2githubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx, sel, v[i]) + ret[i] = ec.marshalONode2githubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx, sel, v[i]) } if isLen1 { f(i) @@ -4707,22 +4707,22 @@ func (ec *executionContext) marshalNNode2ᚕgithubᚗcomᚋtheopenlaneᚋfgaxᚋ return ret } -func (ec *executionContext) unmarshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx context.Context, v interface{}) (enums.Role, error) { +func (ec *executionContext) unmarshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx context.Context, v interface{}) (enums.Role, error) { var res enums.Role err := res.UnmarshalGQL(v) return res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) marshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx context.Context, sel ast.SelectionSet, v enums.Role) graphql.Marshaler { +func (ec *executionContext) marshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx context.Context, sel ast.SelectionSet, v enums.Role) graphql.Marshaler { return v } -func (ec *executionContext) unmarshalNOrgMembershipWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInput(ctx context.Context, v interface{}) (*ent.OrgMembershipWhereInput, error) { +func (ec *executionContext) unmarshalNOrgMembershipWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInput(ctx context.Context, v interface{}) (*ent.OrgMembershipWhereInput, error) { res, err := ec.unmarshalInputOrgMembershipWhereInput(ctx, v) return &res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) marshalNOrganization2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganization(ctx context.Context, sel ast.SelectionSet, v *ent.Organization) graphql.Marshaler { +func (ec *executionContext) marshalNOrganization2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganization(ctx context.Context, sel ast.SelectionSet, v *ent.Organization) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { ec.Errorf(ctx, "the requested element is null which the schema does not allow") @@ -4732,7 +4732,7 @@ func (ec *executionContext) marshalNOrganization2ᚖgithubᚗcomᚋtheopenlane return ec._Organization(ctx, sel, v) } -func (ec *executionContext) unmarshalNOrganizationWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInput(ctx context.Context, v interface{}) (*ent.OrganizationWhereInput, error) { +func (ec *executionContext) unmarshalNOrganizationWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInput(ctx context.Context, v interface{}) (*ent.OrganizationWhereInput, error) { res, err := ec.unmarshalInputOrganizationWhereInput(ctx, v) return &res, graphql.ErrorOnPath(ctx, err) } @@ -5101,14 +5101,14 @@ func (ec *executionContext) marshalOID2ᚖstring(ctx context.Context, sel ast.Se return res } -func (ec *executionContext) marshalONode2githubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx context.Context, sel ast.SelectionSet, v ent.Noder) graphql.Marshaler { +func (ec *executionContext) marshalONode2githubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐNoder(ctx context.Context, sel ast.SelectionSet, v ent.Noder) graphql.Marshaler { if v == nil { return graphql.Null } return ec._Node(ctx, sel, v) } -func (ec *executionContext) unmarshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRoleᚄ(ctx context.Context, v interface{}) ([]enums.Role, error) { +func (ec *executionContext) unmarshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRoleᚄ(ctx context.Context, v interface{}) ([]enums.Role, error) { if v == nil { return nil, nil } @@ -5120,7 +5120,7 @@ func (ec *executionContext) unmarshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheope res := make([]enums.Role, len(vSlice)) for i := range vSlice { ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) - res[i], err = ec.unmarshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, vSlice[i]) + res[i], err = ec.unmarshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, vSlice[i]) if err != nil { return nil, err } @@ -5128,7 +5128,7 @@ func (ec *executionContext) unmarshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheope return res, nil } -func (ec *executionContext) marshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRoleᚄ(ctx context.Context, sel ast.SelectionSet, v []enums.Role) graphql.Marshaler { +func (ec *executionContext) marshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRoleᚄ(ctx context.Context, sel ast.SelectionSet, v []enums.Role) graphql.Marshaler { if v == nil { return graphql.Null } @@ -5155,7 +5155,7 @@ func (ec *executionContext) marshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenl if !isLen1 { defer wg.Done() } - ret[i] = ec.marshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, sel, v[i]) + ret[i] = ec.marshalNOrgMembershipRole2githubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx, sel, v[i]) } if isLen1 { f(i) @@ -5175,7 +5175,7 @@ func (ec *executionContext) marshalOOrgMembershipRole2ᚕgithubᚗcomᚋtheopenl return ret } -func (ec *executionContext) unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx context.Context, v interface{}) (*enums.Role, error) { +func (ec *executionContext) unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx context.Context, v interface{}) (*enums.Role, error) { if v == nil { return nil, nil } @@ -5184,14 +5184,14 @@ func (ec *executionContext) unmarshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheope return res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) marshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx context.Context, sel ast.SelectionSet, v *enums.Role) graphql.Marshaler { +func (ec *executionContext) marshalOOrgMembershipRole2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚋenumsᚐRole(ctx context.Context, sel ast.SelectionSet, v *enums.Role) graphql.Marshaler { if v == nil { return graphql.Null } return v } -func (ec *executionContext) unmarshalOOrgMembershipWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInputᚄ(ctx context.Context, v interface{}) ([]*ent.OrgMembershipWhereInput, error) { +func (ec *executionContext) unmarshalOOrgMembershipWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInputᚄ(ctx context.Context, v interface{}) ([]*ent.OrgMembershipWhereInput, error) { if v == nil { return nil, nil } @@ -5203,7 +5203,7 @@ func (ec *executionContext) unmarshalOOrgMembershipWhereInput2ᚕᚖgithubᚗcom res := make([]*ent.OrgMembershipWhereInput, len(vSlice)) for i := range vSlice { ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) - res[i], err = ec.unmarshalNOrgMembershipWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInput(ctx, vSlice[i]) + res[i], err = ec.unmarshalNOrgMembershipWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInput(ctx, vSlice[i]) if err != nil { return nil, err } @@ -5211,7 +5211,7 @@ func (ec *executionContext) unmarshalOOrgMembershipWhereInput2ᚕᚖgithubᚗcom return res, nil } -func (ec *executionContext) unmarshalOOrgMembershipWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInput(ctx context.Context, v interface{}) (*ent.OrgMembershipWhereInput, error) { +func (ec *executionContext) unmarshalOOrgMembershipWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrgMembershipWhereInput(ctx context.Context, v interface{}) (*ent.OrgMembershipWhereInput, error) { if v == nil { return nil, nil } @@ -5219,7 +5219,7 @@ func (ec *executionContext) unmarshalOOrgMembershipWhereInput2ᚖgithubᚗcomᚋ return &res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInputᚄ(ctx context.Context, v interface{}) ([]*ent.OrganizationWhereInput, error) { +func (ec *executionContext) unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInputᚄ(ctx context.Context, v interface{}) ([]*ent.OrganizationWhereInput, error) { if v == nil { return nil, nil } @@ -5231,7 +5231,7 @@ func (ec *executionContext) unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcom res := make([]*ent.OrganizationWhereInput, len(vSlice)) for i := range vSlice { ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) - res[i], err = ec.unmarshalNOrganizationWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInput(ctx, vSlice[i]) + res[i], err = ec.unmarshalNOrganizationWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInput(ctx, vSlice[i]) if err != nil { return nil, err } @@ -5239,7 +5239,7 @@ func (ec *executionContext) unmarshalOOrganizationWhereInput2ᚕᚖgithubᚗcom return res, nil } -func (ec *executionContext) unmarshalOOrganizationWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋfgaxᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInput(ctx context.Context, v interface{}) (*ent.OrganizationWhereInput, error) { +func (ec *executionContext) unmarshalOOrganizationWhereInput2ᚖgithubᚗcomᚋtheopenlaneᚋiamᚋentfgaᚋ_examplesᚋbasicᚋentᚐOrganizationWhereInput(ctx context.Context, v interface{}) (*ent.OrganizationWhereInput, error) { if v == nil { return nil, nil } diff --git a/go.mod b/go.mod index f1e38f0..b3eb1c4 100644 --- a/go.mod +++ b/go.mod @@ -3,117 +3,194 @@ module github.com/theopenlane/iam go 1.23.0 require ( - entgo.io/ent v0.14.0 + entgo.io/ent v0.14.1 github.com/99designs/gqlgen v0.17.49 github.com/Yamashou/gqlgenc v0.24.0 github.com/alicebob/miniredis/v2 v2.33.0 + github.com/coreos/go-oidc/v3 v3.11.0 + github.com/go-webauthn/webauthn v0.11.1 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/go-github/v63 v63.0.0 + github.com/gorilla/securecookie v1.1.2 + github.com/lestrrat-go/jwx/v2 v2.1.1 + github.com/oklog/ulid/v2 v2.1.0 github.com/openfga/go-sdk v0.5.0 - github.com/openfga/language/pkg/go v0.0.0-20240611203201-b6bbf9c4bb3a + github.com/openfga/language/pkg/go v0.2.0-beta.0 github.com/openfga/openfga v1.5.9 github.com/pkg/errors v0.9.1 + github.com/pquerna/otp v1.4.0 github.com/redis/go-redis/v9 v9.6.1 github.com/samber/lo v1.47.0 github.com/stoewer/go-strcase v1.3.0 github.com/stretchr/testify v1.9.0 - github.com/theopenlane/core v0.1.1 + github.com/theopenlane/core v0.1.2-0.20240827193339-893d6eed110b + github.com/theopenlane/echox v0.1.0 + github.com/theopenlane/utils v0.1.1 + github.com/vmihailenco/msgpack/v5 v5.4.1 go.uber.org/zap v1.27.0 + golang.org/x/oauth2 v0.22.0 golang.org/x/tools v0.24.0 + google.golang.org/api v0.194.0 google.golang.org/protobuf v1.34.2 ) require ( - github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/goccy/go-json v0.10.3 // indirect - github.com/lestrrat-go/blackmagic v1.0.2 // indirect - github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/jwx/v2 v2.1.1 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/segmentio/asm v1.2.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/yuin/gopher-lua v1.1.1 // indirect - golang.org/x/crypto v0.26.0 // indirect - golang.org/x/time v0.6.0 // indirect -) - -require ( - ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect + ariga.io/atlas v0.26.1 // indirect + ariga.io/entcache v0.1.0 // indirect + cloud.google.com/go/auth v0.9.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect + cloud.google.com/go/compute/metadata v0.5.0 // indirect + entgo.io/contrib v0.6.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/XSAM/otelsql v0.32.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/agnivade/levenshtein v1.1.1 // indirect + github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect + github.com/alitto/pond v1.9.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/boombuler/barcode v1.0.2 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/containerd/continuity v0.4.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect + github.com/fatih/color v1.17.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-faster/jx v1.1.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/inflect v0.19.0 // indirect - github.com/google/cel-go v0.20.1 // indirect + github.com/go-openapi/inflect v0.21.0 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/go-webauthn/x v0.1.14 // indirect + github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/goccy/go-yaml v1.12.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/cel-go v0.21.0 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-tpm v0.9.1 // indirect + github.com/google/s2a-go v0.1.8 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/securecookie v1.1.2 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.3 // indirect + github.com/googleapis/gax-go/v2 v2.13.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hashicorp/hcl/v2 v2.19.1 // indirect + github.com/hashicorp/hcl/v2 v2.22.0 // indirect github.com/karlseguin/ccache/v3 v3.0.5 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/hashstructure v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/natefinch/wrap v0.2.0 // indirect - github.com/oklog/ulid/v2 v2.1.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/nyaruka/phonenumbers v1.4.0 // indirect + github.com/ogen-go/ogen v1.3.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/runc v1.1.13 // indirect github.com/openfga/api/proto v0.0.0-20240807201305-c96ec773cae9 // indirect - github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/ory/dockertest v3.3.5+incompatible // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.20.0 // indirect + github.com/posthog/posthog-go v1.2.20 // indirect + github.com/prometheus/client_golang v1.20.2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/ravilushqa/otelgqlgen v0.16.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/sendgrid/rest v2.6.9+incompatible // indirect + github.com/sendgrid/sendgrid-go v3.16.0+incompatible // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/spf13/viper v1.18.2 // indirect + github.com/spf13/viper v1.19.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/theopenlane/echox v0.1.0 - github.com/theopenlane/utils v0.1.0 - github.com/urfave/cli/v2 v2.27.2 // indirect + github.com/theopenlane/dbx v0.1.0 // indirect + github.com/theopenlane/entx v0.1.3 // indirect + github.com/theopenlane/httpsling v0.1.0 // indirect + github.com/tursodatabase/libsql-client-go v0.0.0-20240812094001-348a4e45b535 // indirect + github.com/urfave/cli/v2 v2.27.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect github.com/vektah/gqlparser/v2 v2.5.16 // indirect - github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect - github.com/zclconf/go-cty v1.14.1 // indirect - go.opentelemetry.io/otel v1.28.0 // indirect - go.opentelemetry.io/otel/metric v1.28.0 // indirect - go.opentelemetry.io/otel/trace v1.28.0 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/wundergraph/graphql-go-tools v1.67.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/zclconf/go-cty v1.15.0 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib v1.29.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect + gocloud.dev v0.39.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect - golang.org/x/oauth2 v0.22.0 golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.23.0 // indirect + golang.org/x/sys v0.24.0 // indirect golang.org/x/text v0.17.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240725223205-93522f1f2a9f // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf // indirect + golang.org/x/time v0.6.0 // indirect + golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/grpc v1.65.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect + modernc.org/libc v1.59.9 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.32.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index b3597ab..4ab1fcf 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,51 @@ -ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 h1:GwdJbXydHCYPedeeLt4x/lrlIISQ4JTH1mRWuE5ZZ14= -ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43/go.mod h1:uj3pm+hUTVN/X5yfdBexHlZv+1Xu5u5ZbZx7+CDavNU= -entgo.io/ent v0.14.0 h1:EO3Z9aZ5bXJatJeGqu/EVdnNr6K4mRq3rWe5owt0MC4= -entgo.io/ent v0.14.0/go.mod h1:qCEmo+biw3ccBn9OyL4ZK5dfpwg++l1Gxwac5B1206A= +ariga.io/atlas v0.26.1 h1:UwLn9sXgcuoo9/A3sxXhDqnOImXUcaYb2JqVP0FQciw= +ariga.io/atlas v0.26.1/go.mod h1:KPLc7Zj+nzoXfWshrcY1RwlOh94dsATQEy4UPrF2RkM= +ariga.io/entcache v0.1.0 h1:nfJXzjB5CEvAK6SmjupHREMJrKLakeqU5tG3s4TO6JA= +ariga.io/entcache v0.1.0/go.mod h1:3Z1Sql5bcqPA1YV/jvMlZyh9T+ntSFOclaASAm1TiKQ= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ= +cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc= +cloud.google.com/go/auth v0.9.1 h1:+pMtLEV2k0AXKvs/tGZojuj6QaioxfUjOpMsG5Gtx+w= +cloud.google.com/go/auth v0.9.1/go.mod h1:Sw8ocT5mhhXxFklyhT12Eiy0ed6tTrPMCJjSI8KhYLk= +cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= +cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= +cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4= +cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus= +cloud.google.com/go/kms v1.18.5 h1:75LSlVs60hyHK3ubs2OHd4sE63OAMcM2BdSJc2bkuM4= +cloud.google.com/go/kms v1.18.5/go.mod h1:yXunGUGzabH8rjUPImp2ndHiGolHeWJJ0LODLedicIY= +cloud.google.com/go/longrunning v0.5.12 h1:5LqSIdERr71CqfUsFlJdBpOkBH8FBCFD7P1nTWy3TYE= +cloud.google.com/go/longrunning v0.5.12/go.mod h1:S5hMV8CDJ6r50t2ubVJSKQVv5u0rmik5//KgLO3k4lU= +entgo.io/contrib v0.6.0 h1:xfo4TbJE7sJZWx7BV7YrpSz7IPFvS8MzL3fnfzZjKvQ= +entgo.io/contrib v0.6.0/go.mod h1:3qWIseJ/9Wx2Hu5zVh15FDzv7d/UvKNcYKdViywWCQg= +entgo.io/ent v0.14.1 h1:fUERL506Pqr92EPHJqr8EYxbPioflJo6PudkrEA8a/s= +entgo.io/ent v0.14.1/go.mod h1:MH6XLG0KXpkcDQhKiHfANZSzR55TJyPL5IGNpI8wpco= github.com/99designs/gqlgen v0.17.49 h1:b3hNGexHd33fBSAd4NDT/c3NCcQzcAVkknhN9ym36YQ= github.com/99designs/gqlgen v0.17.49/go.mod h1:tC8YFVZMed81x7UJ7ORUwXF4Kn6SXuucFqQBhN8+BU0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/XSAM/otelsql v0.32.0 h1:vDRE4nole0iOOlTaC/Bn6ti7VowzgxK39n3Ll1Kt7i0= +github.com/XSAM/otelsql v0.32.0/go.mod h1:Ary0hlyVBbaSwo8atZB8Aoothg9s/LBJj/N/p5qDmLM= github.com/Yamashou/gqlgenc v0.24.0 h1:Aeufjb2zF0XxkeSTAVQ+DfiHL+ney/M2ovShZozBmHw= github.com/Yamashou/gqlgenc v0.24.0/go.mod h1:3QQD8ZoeEyVXuzqcMDsl8OfCCCTk+ulaxkvFFQDupIA= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= -github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= -github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= +github.com/alitto/pond v1.9.1 h1:OfCpIrMyrWJpn34f647DcFmUxjK8+7Nu3eoVN/WTP+o= +github.com/alitto/pond v1.9.1/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -26,15 +56,38 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= +github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/brianvoe/gofakeit/v7 v7.0.4 h1:Mkxwz9jYg8Ad8NvT9HA27pCMZGFQo08MK6jD0QTKEww= +github.com/brianvoe/gofakeit/v7 v7.0.4/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg= +github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= +github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= +github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= +github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -47,41 +100,133 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= +github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= -github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= +github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.1.0 h1:ZsW3wD+snOdmTDy9eIVgQdjUpXRRV4rqW8NS3t+20bg= +github.com/go-faster/jx v1.1.0/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= -github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= -github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= -github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-openapi/inflect v0.21.0 h1:FoBjBTQEcbg2cJUWX6uwL9OyIW8eqc9k4KhN4lfbeYk= +github.com/go-openapi/inflect v0.21.0/go.mod h1:INezMuUu7SJQc2AyR3WO0DqqYUJSj8Kb4hBd7WtjlAw= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24= +github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-redis/redismock/v8 v8.0.6 h1:rtuijPgGynsRB2Y7KDACm09WvjHWS4RaG44Nm7rcj4Y= +github.com/go-redis/redismock/v8 v8.0.6/go.mod h1:sDIF73OVsmaKzYe/1FJXGiCQ4+oHYbzjpaL9Vor0sS4= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-webauthn/webauthn v0.11.1 h1:5G/+dg91/VcaJHTtJUfwIlNJkLwbJCcnUc4W8VtkpzA= +github.com/go-webauthn/webauthn v0.11.1/go.mod h1:YXRm1WG0OtUyDFaVAgB5KG7kVqW+6dYCJ7FTQH4SxEE= +github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0= +github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc= +github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ= +github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM= +github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84= -github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.21.0 h1:cl6uW/gxN+Hy50tNYvI691+sXxioCnstFzLp2WO4GCI= +github.com/google/cel-go v0.21.0/go.mod h1:rHUlWCcBKgyEk+eV03RPdZUekPp6YcJwV0FxuUksYxc= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/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/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE= +github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM= +github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= +github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0= +github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= +github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gotestyourself/gotestyourself v2.2.0+incompatible h1:AQwinXlbQR2HvPjQZOmDhRqsv5mZf+Jb1RnSLxcqZcI= +github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -91,25 +236,28 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI= -github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/jensneuse/diffview v1.0.0 h1:4b6FQJ7y3295JUHU3tRko6euyEboL825ZsXeZZM47Z4= +github.com/jensneuse/diffview v1.0.0/go.mod h1:i6IacuD8LnEaPuiyzMHA+Wfz5mAuycMOf3R/orUY9y4= github.com/karlseguin/ccache/v3 v3.0.5 h1:hFX25+fxzNjsRlREYsoGNa2LoVEw5mPF8wkWq/UnevQ= github.com/karlseguin/ccache/v3 v3.0.5/go.mod h1:qxC372+Qn+IBj8Pe3KvGjHPj0sWwEF7AeZVhsNPZ6uY= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= +github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= @@ -122,72 +270,124 @@ github.com/lestrrat-go/jwx/v2 v2.1.1 h1:Y2ltVl8J6izLYFs54BVcpXLv5msSW4o8eXwnzZLI github.com/lestrrat-go/jwx/v2 v2.1.1/go.mod h1:4LvZg7oxu6Q5VJwn7Mk/UwooNRnTHUpXBj2C4j3HNx0= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/hashstructure v1.1.0 h1:P6P1hdjqAAknpY/M1CGipelZgp+4y9ja9kmUZPXP+H0= +github.com/mitchellh/hashstructure v1.1.0/go.mod h1:xUDAozZz0Wmdiufv0uyhnHkUTN6/6d8ulp4AwfLKrmA= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk= github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/nyaruka/phonenumbers v1.4.0 h1:ddhWiHnHCIX3n6ETDA58Zq5dkxkjlvgrDWM2OHHPCzU= +github.com/nyaruka/phonenumbers v1.4.0/go.mod h1:gv+CtldaFz+G3vHHnasBSirAi3O2XLqZzVWz4V1pl2E= +github.com/ogen-go/ogen v1.3.0 h1:c0+CvdbwvKmaHQUqbPpRKflvkiJ/NAsEw3L3HhofDso= +github.com/ogen-go/ogen v1.3.0/go.mod h1:421U7mQVAE+7uaCc4tkq2uT0HDfZL13UTpL16CBrFt0= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/runc v1.1.13 h1:98S2srgG9vw0zWcDpFMn5TRrh8kLxa/5OFUstuUhmRs= +github.com/opencontainers/runc v1.1.13/go.mod h1:R016aXacfp/gwQBYw2FDGa9m+n6atbLWrYY8hNMT/sA= github.com/openfga/api/proto v0.0.0-20240807201305-c96ec773cae9 h1:Y0fIAHrYECcf5lpa/o1AbH21bS7rsco/FoH4A4NGlZE= github.com/openfga/api/proto v0.0.0-20240807201305-c96ec773cae9/go.mod h1:gil5LBD8tSdFQbUkCQdnXsoeU9kDJdJgbGdHkgJfcd0= github.com/openfga/go-sdk v0.5.0 h1:1IuAu6Xf4eBxgc2AyMfosK7QzApxuZ5yi7jmFaftnl0= github.com/openfga/go-sdk v0.5.0/go.mod h1:AoMnFlPw65sU/7O4xOPpCb2vXA8ZD9K9xp2hZjcvt4g= -github.com/openfga/language/pkg/go v0.0.0-20240611203201-b6bbf9c4bb3a h1:0/nEaNWtDb8win9p9GTM3WTt0LcGjg1J/o2LNx6nZ+Y= -github.com/openfga/language/pkg/go v0.0.0-20240611203201-b6bbf9c4bb3a/go.mod h1:mCwEY2IQvyNgfEwbfH0C0ERUwtL8z6UjSAF8zgn5Xbg= +github.com/openfga/language/pkg/go v0.2.0-beta.0 h1:dTvgDkQImfNnH1iDvxnUIbz4INvKr4kS46dI12oAEzM= +github.com/openfga/language/pkg/go v0.2.0-beta.0/go.mod h1:mCwEY2IQvyNgfEwbfH0C0ERUwtL8z6UjSAF8zgn5Xbg= github.com/openfga/openfga v1.5.9 h1:1x+9YdBOzbYPbkEUZjPPYt255GXDUbouC0ConpMRtL8= github.com/openfga/openfga v1.5.9/go.mod h1:1OF1qR8nXdIirtosRZq0mPx5B6nuY5phPGk61Yh+9Lc= +github.com/ory/dockertest v3.3.5+incompatible h1:iLLK6SQwIhcbrG783Dghaaa3WPzGc+4Emza6EbVUUGA= +github.com/ory/dockertest v3.3.5+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= -github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.0 h1:jBzTZ7B099Rg24tny+qngoynol8LtVYlA2bqx3vEloI= -github.com/prometheus/client_golang v1.20.0/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/posthog/posthog-go v1.2.20 h1:gH62ssImK6xRKbYgmaW+sIPqvXBtu6iYjRR3f4lLIoA= +github.com/posthog/posthog-go v1.2.20/go.mod h1:QjlpryJtfYLrZF2GUkAhejH4E7WlDbdKkvOi5hLmkdg= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ= +github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE= +github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg= +github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/ravilushqa/otelgqlgen v0.16.0 h1:tEryUp/Kai5lccgA0Hl0lb/LUdJmqIJT31HwwxaYiUM= +github.com/ravilushqa/otelgqlgen v0.16.0/go.mod h1:72/ZmqsGciNzAEYavQRKqhiRaZ4piW/Fjk4xLnG3SAg= github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= +github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -195,23 +395,34 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/theopenlane/core v0.1.1 h1:nBJ9NEMUu3CqIVLJsydWs4AKyCuJxaY44BMzBZpRZ7g= -github.com/theopenlane/core v0.1.1/go.mod h1:qEaMYQK5iy3wekSbG6TVE7Nf37o1quqtTNJCd1v9n3s= +github.com/theopenlane/core v0.1.2-0.20240827193339-893d6eed110b h1:gPzsFWvLWhvlxgnwJSEekwoFud5lvMtaj/YfWPfefac= +github.com/theopenlane/core v0.1.2-0.20240827193339-893d6eed110b/go.mod h1:ygkBvLQpgKsCWEm5wQS4SXaFuQBpUVm1BcJdqFJG0gE= +github.com/theopenlane/dbx v0.1.0 h1:hjaaruFBwLQ8yHDAN3P68j8hYbdkJE3fb0ynAwTidOY= +github.com/theopenlane/dbx v0.1.0/go.mod h1:SJm0TRYqkqC+Ap+G32zgK96OkujZvtHTqWQXmzo8ZRg= github.com/theopenlane/echox v0.1.0 h1:y4Z2shaODCLwXHsHBrY/EkH/2sIuo49xdIfxx7h+Zvg= github.com/theopenlane/echox v0.1.0/go.mod h1:RaynhPvY9qbLOVlcO7Js1NqZ66+CP9hVBa0c7ehNYA4= -github.com/theopenlane/utils v0.1.0 h1:PjKNn1FuYERYzMuEvdilHJxiiMYkcMlnCqImItrJTK0= -github.com/theopenlane/utils v0.1.0/go.mod h1:37sJeeuIsmMbMFE2nKglmEQUJenTccxh5WxkJtyuZUw= -github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= -github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/theopenlane/entx v0.1.3 h1:nn8rZINR7HSIetYnuygdP19MuQ1rwtW/HJILjwIxfag= +github.com/theopenlane/entx v0.1.3/go.mod h1:zuivUo6xLwo/BfH/DeOpCY/byGxzMkcLOC7h6FqngNQ= +github.com/theopenlane/httpsling v0.1.0 h1:IHWUSo213stJTmHOHjNIg5b3npgpchzMdPMY7jAkimI= +github.com/theopenlane/httpsling v0.1.0/go.mod h1:wOyNfO4moIbmP4stQc9Kasgp+Q4sODo3LOLwvjUe/PA= +github.com/theopenlane/utils v0.1.1 h1:GoPrIE8tmmC1VGlp+QmVTvrgBlHwe8e8FqLw2IPdgmY= +github.com/theopenlane/utils v0.1.1/go.mod h1:37sJeeuIsmMbMFE2nKglmEQUJenTccxh5WxkJtyuZUw= +github.com/tursodatabase/libsql-client-go v0.0.0-20240812094001-348a4e45b535 h1:iLjJLq2A5J6L9zrhyNn+fpmxFvtEpYB4XLMr0rX3epI= +github.com/tursodatabase/libsql-client-go v0.0.0-20240812094001-348a4e45b535/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= +github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -222,18 +433,36 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= -github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +github.com/wundergraph/graphql-go-tools v1.67.4 h1:1QtoftaZz5sScV/J6XLZ/oTfi1lMHp6UmFkYRQfY2/g= +github.com/wundergraph/graphql-go-tools v1.67.4/go.mod h1:UFvflYjB/qnSCdgcHQuE6dTfwZ6viJB7yPnGOtBuibo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= -github.com/zclconf/go-cty v1.14.1 h1:t9fyA35fwjjUMcmL5hLER+e/rEPqrbCK1/OSE4SI9KA= -github.com/zclconf/go-cty v1.14.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= -go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= -go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= -go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= -go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= -go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= -go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= +github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib v1.29.0 h1:fLxD2N918DFRlES8q9iv2yE7iIFlaIMZ7ek0D6qJMqk= +go.opentelemetry.io/contrib v1.29.0/go.mod h1:Tmhw9grdWtmXy6DxZNpIAudzYJqLeEM2P6QTZQSRwU8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo= +go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= +go.opentelemetry.io/otel/sdk/metric v1.28.0 h1:OkuaKgKrgAbYrrY0t92c+cC+2F6hsFNnCQArXCKlg08= +go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -244,32 +473,93 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +gocloud.dev v0.39.0 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds= +gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ= +golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k= +golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= -google.golang.org/genproto/googleapis/api v0.0.0-20240725223205-93522f1f2a9f h1:b1Ln/PG8orm0SsBbHZWke8dDp2lrCD4jSmfglFpTZbk= -google.golang.org/genproto/googleapis/api v0.0.0-20240725223205-93522f1f2a9f/go.mod h1:AHT0dDg3SoMOgZGnZk29b5xTbPHMoEC8qthmBLJCpys= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf h1:liao9UHurZLtiEwBgT9LMOnKYsHze6eA6w1KQCMVN2Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= +golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.194.0 h1:dztZKG9HgtIpbI35FhfuSNR/zmaMVdxNlntHj1sIS4s= +google.golang.org/api v0.194.0/go.mod h1:AgvUFdojGANh3vI+P7EVnxj3AISHllxGCJSFmggmnd0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142 h1:oLiyxGgE+rt22duwci1+TG7bg2/L1LQsXwfjPlmuJA0= +google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142/go.mod h1:G11eXq53iI5Q+kyNOmCvnzBaxEA2Q/Ik5Tj7nqBE8j4= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -277,8 +567,40 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.20.7 h1:skrinQsjxWfvj6nbC3ztZPJy+NuwmB3hV9zX/pthNYQ= +modernc.org/ccgo/v4 v4.20.7/go.mod h1:UOkI3JSG2zT4E2ioHlncSOZsXbuDCZLvPi3uMlZT5GY= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= +modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M= +modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.59.9 h1:k+nNDDakwipimgmJ1D9H466LhFeSkaPPycAs1OZiDmY= +modernc.org/libc v1.59.9/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s= +modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/providers/github/client.go b/providers/github/client.go new file mode 100644 index 0000000..30476c0 --- /dev/null +++ b/providers/github/client.go @@ -0,0 +1,122 @@ +package github + +import ( + "context" + "net/http" + "net/url" + + "github.com/google/go-github/v63/github" +) + +// ClientConfig holds the configuration for the GitHub client +type ClientConfig struct { + BaseURL *url.URL + UploadURL *url.URL + + IsEnterprise bool + IsMock bool +} + +// GitHubInterface defines all necessary methods +// https://godoc.org/github.com/google/go-github/github#NewClient +type GitHubInterface interface { + NewClient(httpClient *http.Client) GitHubClient + GetConfig() *ClientConfig + SetConfig(config *ClientConfig) +} + +// GitHubClient defines all necessary methods used by the client +type GitHubClient struct { + Users githubUserService +} + +// githubUserService defines all necessary methods for the User service +type githubUserService interface { + Get(ctx context.Context, user string) (*github.User, *github.Response, error) + ListEmails(ctx context.Context, opts *github.ListOptions) ([]*github.UserEmail, *github.Response, error) +} + +// GitHubCreator implements GitHubInterface +type GitHubCreator struct { + Config *ClientConfig +} + +// GetConfig returns the current configuration +func (g *GitHubCreator) GetConfig() *ClientConfig { + return g.Config +} + +// SetConfig sets the configuration +func (g *GitHubCreator) SetConfig(config *ClientConfig) { + g.Config = config +} + +// NewClient returns a new GitHubClient +func (g *GitHubCreator) NewClient(httpClient *http.Client) GitHubClient { + client := github.NewClient(httpClient) + + if g.Config.BaseURL != nil { + client.BaseURL = g.Config.BaseURL + } + + if g.Config.UploadURL != nil { + client.UploadURL = g.Config.UploadURL + } + + return GitHubClient{ + Users: client.Users, + } +} + +// GitHubMock implements GitHubInterface +type GitHubMock struct { + Config *ClientConfig +} + +// GetConfig returns the current configuration +func (g *GitHubMock) GetConfig() *ClientConfig { + return g.Config +} + +// SetConfig sets the configuration +func (g *GitHubMock) SetConfig(config *ClientConfig) { + g.Config = config +} + +// NewClient returns a new mock GitHubClient +func (g *GitHubMock) NewClient(httpClient *http.Client) GitHubClient { + return GitHubClient{ + Users: &UsersMock{}, + } +} + +// UsersMock mocks UsersService +type UsersMock struct { + githubUserService //nolint:unused +} + +// Get returns a Github user +func (u *UsersMock) Get(context.Context, string) (*github.User, *github.Response, error) { + resp := &http.Response{StatusCode: http.StatusOK} + + return &github.User{ + Login: github.String("antman"), + ID: github.Int64(1), + }, &github.Response{Response: resp}, nil +} + +// ListEmails returns a mock list of Github user emails +func (u *UsersMock) ListEmails(ctx context.Context, opts *github.ListOptions) ([]*github.UserEmail, *github.Response, error) { + resp := &http.Response{StatusCode: http.StatusOK} + + return []*github.UserEmail{ + { + Email: github.String("antman@theopenlane.io"), + Primary: github.Bool(true), + }, + { + Email: github.String("ant-man@avengers.com"), + Primary: github.Bool(false), + }, + }, &github.Response{Response: resp}, nil +} diff --git a/providers/github/config.go b/providers/github/config.go new file mode 100644 index 0000000..609f52f --- /dev/null +++ b/providers/github/config.go @@ -0,0 +1,15 @@ +package github + +// ProviderConfig represents the configuration settings for a Github Oauth Provider +type ProviderConfig struct { + // ClientID is the public identifier for the GitHub oauth2 client + ClientID string `json:"clientId" koanf:"clientId" jsonschema:"required"` + // ClientSecret is the secret for the GitHub oauth2 client + ClientSecret string `json:"clientSecret" koanf:"clientSecret" jsonschema:"required"` + // ClientEndpoint is the endpoint for the GitHub oauth2 client + ClientEndpoint string `json:"clientEndpoint" koanf:"clientEndpoint" default:"http://localhost:17608"` + // Scopes are the scopes that the GitHub oauth2 client will request + Scopes []string `json:"scopes" koanf:"scopes" jsonschema:"required"` + // RedirectURL is the URL that the GitHub oauth2 client will redirect to after authentication with Github + RedirectURL string `json:"redirectUrl" koanf:"redirectUrl" jsonschema:"required" default:"/v1/github/callback"` +} diff --git a/providers/github/context.go b/providers/github/context.go new file mode 100644 index 0000000..9fdf169 --- /dev/null +++ b/providers/github/context.go @@ -0,0 +1,46 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v63/github" +) + +// unexported key type prevents collisions +type key int + +const ( + userKey key = iota + errorKey key = iota +) + +// WithUser returns a copy of context that stores the GitHub User +func WithUser(ctx context.Context, user *github.User) context.Context { + return context.WithValue(ctx, userKey, user) +} + +// UserFromContext returns the GitHub User from the context +func UserFromContext(ctx context.Context) (*github.User, error) { + user, ok := ctx.Value(userKey).(*github.User) + if !ok { + return nil, ErrContextMissingGithubUser + } + + return user, nil +} + +// WithError returns a copy of context that stores the given error value +func WithError(ctx context.Context, err error) context.Context { + return context.WithValue(ctx, errorKey, err) +} + +// ErrorFromContext returns the error value from the ctx or an error that the +// context was missing an error value +func ErrorFromContext(ctx context.Context) error { + err, ok := ctx.Value(errorKey).(error) + if !ok { + return ErrContextMissingErrorValue + } + + return err +} diff --git a/providers/github/context_test.go b/providers/github/context_test.go new file mode 100644 index 0000000..9114dd5 --- /dev/null +++ b/providers/github/context_test.go @@ -0,0 +1,29 @@ +package github + +import ( + "context" + "testing" + + "github.com/google/go-github/v63/github" + "github.com/stretchr/testify/assert" +) + +func TestContextUser(t *testing.T) { + expectedUser := &github.User{ + ID: github.Int64(917408), + Name: github.String("Meow Meowingtonr"), + } + ctx := WithUser(context.Background(), expectedUser) + user, err := UserFromContext(ctx) + assert.Equal(t, expectedUser, user) + assert.Nil(t, err) +} + +func TestFailedContext(t *testing.T) { + user, err := UserFromContext(context.Background()) + assert.Nil(t, user) + + if assert.NotNil(t, err) { + assert.Equal(t, "context missing github user", err.Error()) + } +} diff --git a/providers/github/doc.go b/providers/github/doc.go new file mode 100644 index 0000000..72b15a6 --- /dev/null +++ b/providers/github/doc.go @@ -0,0 +1,2 @@ +// Package github provides GitHub OAuth2 login and callback handlers. +package github diff --git a/providers/github/errors.go b/providers/github/errors.go new file mode 100644 index 0000000..e73e458 --- /dev/null +++ b/providers/github/errors.go @@ -0,0 +1,45 @@ +package github + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + // ErrServerError returns a generic server error + ErrServerError = errors.New("server error") + + // ErrContextMissingGithubUser is returned when the GitHub user is missing from the context + ErrContextMissingGithubUser = errors.New("context missing github user") + + // ErrFailedConstructingEndpointURL is returned when URL is invalid and unable to be parsed + ErrFailedConstructingEndpointURL = errors.New("error constructing URL") + + // ErrCreatingGithubClient is returned when the GitHub client cannot be created + ErrCreatingGithubClient = errors.New("error creating github client") + + // ErrUnableToGetGithubUser when the user cannot be retrieved from GitHub + ErrUnableToGetGithubUser = errors.New("unable to get github user") + + // ErrPrimaryEmailNotFound when the user's primary email cannot be retrieved from GitHub + ErrPrimaryEmailNotFound = errors.New("unable to get primary email address") + + // ErrContextMissingErrorValue is returned when the context does not have an error value + ErrContextMissingErrorValue = fmt.Errorf("context missing error value") +) + +// DefaultFailureHandler responds with a 400 status code and message parsed from the context +var DefaultFailureHandler = http.HandlerFunc(failureHandler) + +func failureHandler(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // ErrorFromContext always returns some non-nil error + http.Error(w, "", http.StatusBadRequest) +} diff --git a/providers/github/login.go b/providers/github/login.go new file mode 100644 index 0000000..0611adc --- /dev/null +++ b/providers/github/login.go @@ -0,0 +1,197 @@ +package github + +import ( + "context" + "net/http" + "net/url" + + "github.com/google/go-github/v63/github" + "golang.org/x/oauth2" + + oauth2Login "github.com/theopenlane/core/pkg/providers/oauth2" + "github.com/theopenlane/core/pkg/sessions" +) + +const ( + ProviderName = "GITHUB" +) + +// StateHandler checks for a state cookie, if found, adds to context; if missing, a +// random generated value is added to the context and to a (short-lived) state cookie +// issued to the requester - this implements OAuth 2 RFC 6749 10.12 CSRF Protection +func StateHandler(config sessions.CookieConfig, success http.Handler) http.Handler { + return oauth2Login.StateHandler(config, success) +} + +// LoginHandler handles Github login requests by reading the state value from +// the ctx and redirecting requests to the AuthURL with that state value +func LoginHandler(config *oauth2.Config, failure http.Handler) http.Handler { + return oauth2Login.LoginHandler(config, failure) +} + +// CallbackHandler adds the GitHub access token and User to the ctx +func CallbackHandler(config *oauth2.Config, success, failure http.Handler) http.Handler { + success = githubHandler(config, &ClientConfig{IsEnterprise: false, IsMock: false}, success, failure) + + return oauth2Login.CallbackHandler(config, success, failure) +} + +// EnterpriseCallbackHandler handles GitHub Enterprise redirection URI requests +// and adds the GitHub access token and User to the ctx +func EnterpriseCallbackHandler(config *oauth2.Config, success, failure http.Handler) http.Handler { + success = githubHandler(config, &ClientConfig{IsEnterprise: true, IsMock: false}, success, failure) + + return oauth2Login.CallbackHandler(config, success, failure) +} + +// githubHandler gets the OAuth2 Token from the ctx to fetch the corresponding GitHub +// User and add them to the context +func githubHandler(config *oauth2.Config, clientConfig *ClientConfig, success, failure http.Handler) http.Handler { + if failure == nil { + failure = DefaultFailureHandler + } + + fn := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + token, err := oauth2Login.TokenFromContext(ctx) + + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + githubClient, err := getGithubClient(ctx, clientConfig, config, token) + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + user, resp, err := githubClient.Users.Get(ctx, "") + + err = validateResponse(user, resp, err) + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + // Make a request to `user/emails` if the email was not returned (due to privacy) + if user.Email == nil { + user.Email, err = getUserEmails(ctx, githubClient) + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + } + + ctx = WithUser(ctx, user) + success.ServeHTTP(w, req.WithContext(ctx)) + } + + return http.HandlerFunc(fn) +} + +// validateResponse returns an error if we get something unexpected +func validateResponse(user *github.User, resp *github.Response, err error) error { + if err != nil || resp.StatusCode != http.StatusOK { + return ErrUnableToGetGithubUser + } + + if user == nil || user.ID == nil { + return ErrUnableToGetGithubUser + } + + return nil +} + +func getGithubClient(ctx context.Context, clientConfig *ClientConfig, config *oauth2.Config, token *oauth2.Token) (githubClient GitHubClient, err error) { + // create httClient with provided token + httpClient := config.Client(ctx, token) + + // setup client + var g GitHubInterface + + g = &GitHubCreator{} + if clientConfig.IsMock { + g = &GitHubMock{} + } + + // Set params for enterprise Github client + if clientConfig.IsEnterprise { + clientConfig, err = enterpriseGithubClientFromAuthURL(config.Endpoint.AuthURL, clientConfig) + if err != nil { + return + } + } + + // Set config on client + g.SetConfig(clientConfig) + + // Create new Github Client + githubClient = g.NewClient(httpClient) + + return +} + +// enterpriseGithubClientFromAuthURL returns a client config with required settings for GHE instance +func enterpriseGithubClientFromAuthURL(authURL string, config *ClientConfig) (*ClientConfig, error) { + baseURL, err := url.Parse(authURL) + if err != nil { + return config, ErrFailedConstructingEndpointURL + } + + baseURL.Path = "/api/v3/" + config.BaseURL = baseURL + config.UploadURL = baseURL + + return config, nil +} + +// getUserEmails from `user/emails` and return the user's primary email address +func getUserEmails(ctx context.Context, githubClient GitHubClient) (*string, error) { + emails, _, err := githubClient.Users.ListEmails(ctx, nil) + if err != nil { + return nil, err + } + + // Get the primary email + for _, em := range emails { + if em.GetPrimary() { + return em.Email, nil + } + } + + return nil, ErrPrimaryEmailNotFound +} + +// VerifyClientToken checks the client token and returns an error if it is invalid +func VerifyClientToken(ctx context.Context, token *oauth2.Token, config *oauth2.Config, email string, clientConfig *ClientConfig) (err error) { + githubClient, err := getGithubClient(ctx, clientConfig, config, token) + if err != nil { + return err + } + + user, resp, err := githubClient.Users.Get(ctx, "") + if err != nil { + return err + } + + // ensure the emails match + githubEmail, err := getUserEmails(ctx, githubClient) + if err != nil { + return err + } + + if *githubEmail != email { + return ErrUnableToGetGithubUser + } + + return validateResponse(user, resp, err) +} diff --git a/providers/github/login_test.go b/providers/github/login_test.go new file mode 100644 index 0000000..3260377 --- /dev/null +++ b/providers/github/login_test.go @@ -0,0 +1,185 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + oauth2Login "github.com/theopenlane/core/pkg/providers/oauth2" + "github.com/theopenlane/core/pkg/testutils" + + "github.com/google/go-github/v63/github" + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +const ( + SuccessHandlerCalled = "success handler called" + FailureHandlerCalled = "failure handler called" + anytoken = "any-token" +) + +func TestGithubHandler(t *testing.T) { + jsonData := `{"id": 917408, "name": "Sarah Funkytown"}` + emailJSONData := `[{"primary": true, "email": "sfunk@meow.net"}, {"primary": false, "email": "sfunk@woof.net"}]` + + expectedUser := &github.User{ + ID: github.Int64(917408), + Name: github.String("Sarah Funkytown"), + Email: github.String("sfunk@meow.net"), + } + + proxyClient, server := newGithubTestServer("", jsonData, emailJSONData) + + defer server.Close() + + // oauth2 Client will use the proxy client's base Transport + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, proxyClient) + anyToken := &oauth2.Token{AccessToken: anytoken} + ctx = oauth2Login.WithToken(ctx, anyToken) + + config := &oauth2.Config{} + success := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + githubUser, err := UserFromContext(ctx) + assert.Nil(t, err) + assert.Equal(t, expectedUser, githubUser) + fmt.Fprint(w, SuccessHandlerCalled) + } + failure := testutils.AssertFailureNotCalled(t) + + // GithubHandler assert that: + // - Token is read from the ctx and passed to the GitHub API + // - github User is obtained from the GitHub API + // - success handler is called + // - github User is added to the ctx of the success handler + githubHandler := githubHandler(config, &ClientConfig{IsEnterprise: false, IsMock: false}, http.HandlerFunc(success), failure) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + githubHandler.ServeHTTP(w, req.WithContext(ctx)) + assert.Equal(t, SuccessHandlerCalled, w.Body.String()) +} + +func TestMissingCtxToken(t *testing.T) { + config := &oauth2.Config{} + success := testutils.AssertSuccessNotCalled(t) + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + assert.Equal(t, "oauth2: context missing token", err.Error()) + } + + fmt.Fprint(w, FailureHandlerCalled) + } + + // GithubHandler called without Token in ctx, assert that: + // - failure handler is called + // - error about ctx missing token is added to the failure handler ctx + githubHandler := githubHandler(config, &ClientConfig{IsEnterprise: false, IsMock: false}, success, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + githubHandler.ServeHTTP(w, req) + assert.Equal(t, FailureHandlerCalled, w.Body.String()) +} + +func TestErrorGettingUser(t *testing.T) { + proxyClient, server := testutils.NewErrorServer("GitHub Service Down", http.StatusInternalServerError) + defer server.Close() + // oauth2 Client will use the proxy client's base Transport + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, proxyClient) + anyToken := &oauth2.Token{AccessToken: anytoken} + ctx = oauth2Login.WithToken(ctx, anyToken) + + config := &oauth2.Config{} + success := testutils.AssertSuccessNotCalled(t) + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + assert.Equal(t, ErrUnableToGetGithubUser, err) + } + + fmt.Fprint(w, FailureHandlerCalled) + } + + // GithubHandler cannot get GitHub User, assert that: + // - failure handler is called + // - error cannot get GitHub User added to the failure handler ctx + githubHandler := githubHandler(config, &ClientConfig{IsEnterprise: false, IsMock: false}, success, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + githubHandler.ServeHTTP(w, req.WithContext(ctx)) + assert.Equal(t, FailureHandlerCalled, w.Body.String()) +} + +func TestGithubEnterprise(t *testing.T) { + jsonData := `{"id": 917408, "name": "Sarah Funkytown"}` + emailJSONData := `[{"primary": true, "email": "sfunk@meow.net"}, {"primary": false, "email": "sfunk@woof.net"}]` + expectedUser := &github.User{ + ID: github.Int64(917408), + Name: github.String("Sarah Funkytown"), + Email: github.String("sfunk@meow.net"), + } + + proxyClient, server := newGithubTestServer("/api/v3", jsonData, emailJSONData) + + defer server.Close() + + // oauth2 Client will use the proxy client's base Transport + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, proxyClient) + anyToken := &oauth2.Token{AccessToken: anytoken} + ctx = oauth2Login.WithToken(ctx, anyToken) + + config := &oauth2.Config{} + config.Endpoint.AuthURL = "https://github.mattisthebest.com/login/oauth/authorize" + success := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + githubUser, err := UserFromContext(ctx) + assert.Nil(t, err) + assert.Equal(t, expectedUser, githubUser) + fmt.Fprint(w, SuccessHandlerCalled) + } + failure := testutils.AssertFailureNotCalled(t) + + // GithubHandler assert that: + // - Token is read from the ctx and passed to the GitHub API + // - github User is obtained from the GitHub API + // - success handler is called + // - github User is added to the ctx of the success handler + githubHandler := githubHandler(config, &ClientConfig{IsEnterprise: true, IsMock: false}, http.HandlerFunc(success), failure) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + githubHandler.ServeHTTP(w, req.WithContext(ctx)) + assert.Equal(t, SuccessHandlerCalled, w.Body.String()) +} + +func TestValidateResponse(t *testing.T) { + validUser := &github.User{ID: github.Int64(123)} + validResponse := &github.Response{Response: &http.Response{StatusCode: 200}} + invalidResponse := &github.Response{Response: &http.Response{StatusCode: 500}} + + assert.Equal(t, nil, validateResponse(validUser, validResponse, nil)) + assert.Equal(t, ErrUnableToGetGithubUser, validateResponse(validUser, validResponse, ErrServerError)) + assert.Equal(t, ErrUnableToGetGithubUser, validateResponse(validUser, invalidResponse, nil)) + assert.Equal(t, ErrUnableToGetGithubUser, validateResponse(&github.User{}, validResponse, nil)) +} + +func TestEnterpriseGithubClientFromAuthURL(t *testing.T) { + cases := []struct { + authURL string + expClientBaseURL string + }{ + {"https://github.mattisthebest.com/login/oauth/authorize", "https://github.mattisthebest.com/api/v3/"}, + {"http://github.mattisthebest.com/login/oauth/authorize", "http://github.mattisthebest.com/api/v3/"}, + } + for _, c := range cases { + client, err := enterpriseGithubClientFromAuthURL(c.authURL, &ClientConfig{IsEnterprise: true, IsMock: false}) + assert.Nil(t, err) + assert.Equal(t, client.BaseURL.String(), c.expClientBaseURL) + } +} diff --git a/providers/github/server_test.go b/providers/github/server_test.go new file mode 100644 index 0000000..e804ac7 --- /dev/null +++ b/providers/github/server_test.go @@ -0,0 +1,25 @@ +package github + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/theopenlane/core/pkg/testutils" +) + +// newGithubTestServer mocks the GitHub user endpoint and a client +func newGithubTestServer(routePrefix, userData, emailData string) (*http.Client, *httptest.Server) { + client, mux, server := testutils.TestServer() + mux.HandleFunc(routePrefix+"/user", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, userData) + }) + + mux.HandleFunc(routePrefix+"/user/emails", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, emailData) + }) + + return client, server +} diff --git a/providers/google/config.go b/providers/google/config.go new file mode 100644 index 0000000..ac33250 --- /dev/null +++ b/providers/google/config.go @@ -0,0 +1,15 @@ +package google + +// ProviderConfig represents the configuration settings for a Google Oauth Provider +type ProviderConfig struct { + // ClientID is the public identifier for the Google oauth2 client + ClientID string `json:"clientId" koanf:"clientId" jsonschema:"required"` + // ClientSecret is the secret for the Google oauth2 client + ClientSecret string `json:"clientSecret" koanf:"clientSecret" jsonschema:"required"` + // ClientEndpoint is the endpoint for the Google oauth2 client + ClientEndpoint string `json:"clientEndpoint" koanf:"clientEndpoint" default:"http://localhost:17608"` + // Scopes are the scopes that the Google oauth2 client will request + Scopes []string `json:"scopes" koanf:"scopes" jsonschema:"required"` + // RedirectURL is the URL that the Google oauth2 client will redirect to after authentication with Google + RedirectURL string `json:"redirectUrl" koanf:"redirectUrl" jsonschema:"required" default:"/v1/google/callback"` +} diff --git a/providers/google/context.go b/providers/google/context.go new file mode 100644 index 0000000..4eb0b91 --- /dev/null +++ b/providers/google/context.go @@ -0,0 +1,46 @@ +package google + +import ( + "context" + + google "google.golang.org/api/oauth2/v2" +) + +// unexported key type prevents collisions +type key int + +const ( + userKey key = iota + errorKey key = iota +) + +// WithUser returns a copy of ctx that stores the Google Userinfo +func WithUser(ctx context.Context, user *google.Userinfo) context.Context { + return context.WithValue(ctx, userKey, user) +} + +// UserFromContext returns the Google Userinfo from the ctx +func UserFromContext(ctx context.Context) (*google.Userinfo, error) { + user, ok := ctx.Value(userKey).(*google.Userinfo) + if !ok { + return nil, ErrContextMissingGoogleUser + } + + return user, nil +} + +// WithError returns a copy of context that stores the given error value +func WithError(ctx context.Context, err error) context.Context { + return context.WithValue(ctx, errorKey, err) +} + +// ErrorFromContext returns the error value from the ctx or an error that the +// context was missing an error value +func ErrorFromContext(ctx context.Context) error { + err, ok := ctx.Value(errorKey).(error) + if !ok { + return ErrContextMissingErrorValue + } + + return err +} diff --git a/providers/google/context_test.go b/providers/google/context_test.go new file mode 100644 index 0000000..0b96552 --- /dev/null +++ b/providers/google/context_test.go @@ -0,0 +1,26 @@ +package google + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + google "google.golang.org/api/oauth2/v2" +) + +func TestContextUser(t *testing.T) { + expectedUserinfo := &google.Userinfo{Id: "42", Name: "Google User"} + ctx := WithUser(context.Background(), expectedUserinfo) + user, err := UserFromContext(ctx) + assert.Equal(t, expectedUserinfo, user) + assert.Nil(t, err) +} + +func TestFailGettingContext(t *testing.T) { + user, err := UserFromContext(context.Background()) + assert.Nil(t, user) + + if assert.NotNil(t, err) { + assert.Equal(t, "context missing google user", err.Error()) + } +} diff --git a/providers/google/doc.go b/providers/google/doc.go new file mode 100644 index 0000000..4510da3 --- /dev/null +++ b/providers/google/doc.go @@ -0,0 +1,2 @@ +// Package google provides Google OAuth2 login and callback handlers. +package google diff --git a/providers/google/errors.go b/providers/google/errors.go new file mode 100644 index 0000000..df16d87 --- /dev/null +++ b/providers/google/errors.go @@ -0,0 +1,42 @@ +package google + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + // ErrServerError returns a generic server error + ErrServerError = errors.New("server error") + + // ErrContextMissingGoogleUser is returned when the Google user is missing from the context + ErrContextMissingGoogleUser = errors.New("context missing google user") + + // ErrFailedConstructingEndpointURL is returned when URL is invalid and unable to be parsed + ErrFailedConstructingEndpointURL = errors.New("error constructing URL") + + // ErrUnableToGetGoogleUser when the user cannot be retrieved from Google + ErrUnableToGetGoogleUser = errors.New("unable to get google user") + + // ErrCannotValidateGoogleUser when the Google user is invalid + ErrCannotValidateGoogleUser = errors.New("could not validate google user") + + // ErrContextMissingErrorValue is returned when the context does not have an error value + ErrContextMissingErrorValue = fmt.Errorf("context missing error value") +) + +// DefaultFailureHandler responds with a 400 status code and message parsed from the context +var DefaultFailureHandler = http.HandlerFunc(failureHandler) + +func failureHandler(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // ErrorFromContext always returns some non-nil error + http.Error(w, "", http.StatusBadRequest) +} diff --git a/providers/google/login.go b/providers/google/login.go new file mode 100644 index 0000000..6e82784 --- /dev/null +++ b/providers/google/login.go @@ -0,0 +1,117 @@ +package google + +import ( + "context" + "net/http" + + "golang.org/x/oauth2" + google "google.golang.org/api/oauth2/v2" + "google.golang.org/api/option" + + oauth2Login "github.com/theopenlane/core/pkg/providers/oauth2" + "github.com/theopenlane/core/pkg/sessions" +) + +const ( + ProviderName = "GOOGLE" +) + +// StateHandler checks for a state cookie, if found, adds to context; if missing, a +// random generated value is added to the context and to a (short-lived) state cookie +// issued to the requester - this implements OAuth 2 RFC 6749 10.12 CSRF Protection +func StateHandler(config sessions.CookieConfig, success http.Handler) http.Handler { + return oauth2Login.StateHandler(config, success) +} + +// LoginHandler handles Google login requests by reading the state value from +// the ctx and redirecting requests to the AuthURL with that state value +func LoginHandler(config *oauth2.Config, failure http.Handler) http.Handler { + return oauth2Login.LoginHandler(config, failure) +} + +// CallbackHandler handles Google redirection URI requests and adds the Google +// access token and Userinfo to the ctx +func CallbackHandler(config *oauth2.Config, success, failure http.Handler) http.Handler { + success = googleHandler(config, success, failure) + return oauth2Login.CallbackHandler(config, success, failure) +} + +// googleHandler is a http.Handler that gets the OAuth2 Token from the ctx +// to get the corresponding Google Userinfo +func googleHandler(config *oauth2.Config, success, failure http.Handler) http.Handler { + if failure == nil { + failure = DefaultFailureHandler + } + + fn := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + token, err := oauth2Login.TokenFromContext(ctx) + + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + httpClient := config.Client(ctx, token) + + googleService, err := google.NewService(ctx, option.WithHTTPClient(httpClient)) + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + userInfoPlus, err := googleService.Userinfo.Get().Do() + err = validateResponse(userInfoPlus, err) + + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + ctx = WithUser(ctx, userInfoPlus) + success.ServeHTTP(w, req.WithContext(ctx)) + } + + return http.HandlerFunc(fn) +} + +// validateResponse returns an error if we get unexpected things +func validateResponse(user *google.Userinfo, err error) error { + if err != nil { + return ErrUnableToGetGoogleUser + } + + if user == nil || user.Id == "" { + return ErrCannotValidateGoogleUser + } + + return nil +} + +// VerifyClientToken checks the client token and returns an error if it is invalid +func VerifyClientToken(ctx context.Context, token *oauth2.Token, config *oauth2.Config, email string) (err error) { + httpClient := config.Client(ctx, token) + + googleService, err := google.NewService(ctx, option.WithHTTPClient(httpClient)) + if err != nil { + return err + } + + userInfoPlus, err := googleService.Userinfo.Get().Do() + if err != nil { + return err + } + + // ensure the emails match + if userInfoPlus.Email != email { + return ErrUnableToGetGoogleUser + } + + return validateResponse(userInfoPlus, err) +} diff --git a/providers/google/login_test.go b/providers/google/login_test.go new file mode 100644 index 0000000..97e8476 --- /dev/null +++ b/providers/google/login_test.go @@ -0,0 +1,117 @@ +package google + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" + google "google.golang.org/api/oauth2/v2" + + oauth2Login "github.com/theopenlane/core/pkg/providers/oauth2" + "github.com/theopenlane/core/pkg/testutils" +) + +const ( + ErrFailureHandlerCalled = "failure handler called" +) + +func TestGoogleHandler(t *testing.T) { + jsonData := `{"id": "900913", "name": "Rusty Shackleford"}` + expectedUser := &google.Userinfo{Id: "900913", Name: "Rusty Shackleford"} + proxyClient, server := newGoogleTestServer(jsonData) + + defer server.Close() + + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, proxyClient) + anyToken := &oauth2.Token{AccessToken: "any-token"} + ctx = oauth2Login.WithToken(ctx, anyToken) + + config := &oauth2.Config{} + success := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + googleUser, err := UserFromContext(ctx) + assert.Nil(t, err) + // assert required fields; Userinfo contains other raw response info + assert.Equal(t, expectedUser.Id, googleUser.Id) + assert.Equal(t, expectedUser.Id, googleUser.Id) + fmt.Fprintf(w, "success handler called") + } + failure := testutils.AssertFailureNotCalled(t) + + // GoogleHandler assert that: + // - Token is read from the ctx and passed to the Google API + // - google Userinfo is obtained from the Google API + // - success handler is called + // - google Userinfo is added to the ctx of the success handler + googleHandler := googleHandler(config, http.HandlerFunc(success), failure) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + googleHandler.ServeHTTP(w, req.WithContext(ctx)) + assert.Equal(t, "success handler called", w.Body.String()) +} + +func TestMissingCtxToken(t *testing.T) { + config := &oauth2.Config{} + success := testutils.AssertSuccessNotCalled(t) + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + assert.Equal(t, "oauth2: context missing token", err.Error()) + } + + fmt.Fprint(w, ErrFailureHandlerCalled) + } + + // GoogleHandler called without Token in ctx, assert that: + // - failure handler is called + // - error about ctx missing token is added to the failure handler ctx + googleHandler := googleHandler(config, success, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + googleHandler.ServeHTTP(w, req) + assert.Equal(t, ErrFailureHandlerCalled, w.Body.String()) +} + +func TestErrorGettingUser(t *testing.T) { + proxyClient, server := testutils.NewErrorServer("Google Service Down", http.StatusInternalServerError) + defer server.Close() + // oauth2 Client will use the proxy client's base Transport + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, proxyClient) + anyToken := &oauth2.Token{AccessToken: "any-token"} + ctx = oauth2Login.WithToken(ctx, anyToken) + + config := &oauth2.Config{} + success := testutils.AssertSuccessNotCalled(t) + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + assert.Equal(t, ErrUnableToGetGoogleUser, err) + } + + fmt.Fprint(w, ErrFailureHandlerCalled) + } + + // GoogleHandler cannot get Google User, assert that: + // - failure handler is called + // - error cannot get Google User added to the failure handler ctx + googleHandler := googleHandler(config, success, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + googleHandler.ServeHTTP(w, req.WithContext(ctx)) + assert.Equal(t, ErrFailureHandlerCalled, w.Body.String()) +} + +func TestValidateResponse(t *testing.T) { + assert.Equal(t, nil, validateResponse(&google.Userinfo{Id: "123"}, nil)) + assert.Equal(t, ErrUnableToGetGoogleUser, validateResponse(nil, ErrServerError)) + assert.Equal(t, ErrCannotValidateGoogleUser, validateResponse(nil, nil)) + assert.Equal(t, ErrCannotValidateGoogleUser, validateResponse(&google.Userinfo{Name: "Ben"}, nil)) +} diff --git a/providers/google/server_test.go b/providers/google/server_test.go new file mode 100644 index 0000000..bd81f07 --- /dev/null +++ b/providers/google/server_test.go @@ -0,0 +1,21 @@ +package google + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/theopenlane/core/pkg/testutils" +) + +// newGoogleTestServer creates a httptest.Server which mocks the Google +// Userinfo endpoint and a client to holler +func newGoogleTestServer(jsonData string) (*http.Client, *httptest.Server) { + client, mux, server := testutils.TestServer() + mux.HandleFunc("/oauth2/v2/userinfo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, jsonData) + }) + + return client, server +} diff --git a/providers/oauth2/context.go b/providers/oauth2/context.go new file mode 100644 index 0000000..6f04a88 --- /dev/null +++ b/providers/oauth2/context.go @@ -0,0 +1,81 @@ +package oauth2 + +import ( + "context" + + "golang.org/x/oauth2" +) + +// unexported key type prevents collisions +type key int + +const ( + tokenKey key = iota + stateKey key = iota + errorKey key = iota + redirectKey key = iota +) + +// WithState returns a copy of ctx that stores the state value +func WithState(ctx context.Context, state string) context.Context { + return context.WithValue(ctx, stateKey, state) +} + +// StateFromContext returns the state value from the ctx +func StateFromContext(ctx context.Context) (string, error) { + state, ok := ctx.Value(stateKey).(string) + + if !ok { + return "", ErrContextMissingStateValue + } + + return state, nil +} + +// WithToken returns a copy of ctx that stores the Token +func WithToken(ctx context.Context, token *oauth2.Token) context.Context { + return context.WithValue(ctx, tokenKey, token) +} + +// TokenFromContext returns the Token from the ctx +func TokenFromContext(ctx context.Context) (*oauth2.Token, error) { + token, ok := ctx.Value(tokenKey).(*oauth2.Token) + + if !ok { + return nil, ErrContextMissingToken + } + + return token, nil +} + +// WithError returns a copy of context that stores the given error value +func WithError(ctx context.Context, err error) context.Context { + return context.WithValue(ctx, errorKey, err) +} + +// ErrorFromContext returns the error value from the ctx or an error that the +// context was missing an error value +func ErrorFromContext(ctx context.Context) error { + err, ok := ctx.Value(errorKey).(error) + if !ok { + return ErrContextMissingErrorValue + } + + return err +} + +// WithRedirectURL returns a copy of ctx that stores the redirect value +func WithRedirectURL(ctx context.Context, redirect string) context.Context { + return context.WithValue(ctx, redirectKey, redirect) +} + +// RedirectFromContext returns the redirect value from the ctx +func RedirectFromContext(ctx context.Context) string { + redirect, ok := ctx.Value(redirectKey).(string) + + if !ok { + return "" + } + + return redirect +} diff --git a/providers/oauth2/context_test.go b/providers/oauth2/context_test.go new file mode 100644 index 0000000..fb7605d --- /dev/null +++ b/providers/oauth2/context_test.go @@ -0,0 +1,43 @@ +package oauth2 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +func TestContextState(t *testing.T) { + expectedState := "state" + ctx := WithState(context.Background(), expectedState) + state, err := StateFromContext(ctx) + assert.Equal(t, expectedState, state) + assert.Nil(t, err) +} + +func TestMissingState(t *testing.T) { + state, err := StateFromContext(context.Background()) + assert.Equal(t, "", state) + + if assert.NotNil(t, err) { + assert.Equal(t, ErrContextMissingStateValue, ErrContextMissingStateValue) + } +} + +func TestContextToken(t *testing.T) { + expectedToken := &oauth2.Token{AccessToken: "access_token"} + ctx := WithToken(context.Background(), expectedToken) + token, err := TokenFromContext(ctx) + assert.Equal(t, expectedToken, token) + assert.Nil(t, err) +} + +func TestFailTokenContext(t *testing.T) { + token, err := TokenFromContext(context.Background()) + assert.Nil(t, token) + + if assert.NotNil(t, err) { + assert.Equal(t, ErrContextMissingToken, ErrContextMissingToken) + } +} diff --git a/providers/oauth2/doc.go b/providers/oauth2/doc.go new file mode 100644 index 0000000..a55e92c --- /dev/null +++ b/providers/oauth2/doc.go @@ -0,0 +1,2 @@ +// Package oauth2 provides handlers for OAuth2 login and callback requests. +package oauth2 diff --git a/providers/oauth2/errors.go b/providers/oauth2/errors.go new file mode 100644 index 0000000..439b460 --- /dev/null +++ b/providers/oauth2/errors.go @@ -0,0 +1,42 @@ +package oauth2 + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + // ErrContextMissingToken is returned when the context is missing the token value + ErrContextMissingToken = errors.New("oauth2: context missing token") + + // ErrContextMissingStateValue is returned when the context is missing the state value + ErrContextMissingStateValue = errors.New("oauth2: context missing state value") + + // ErrInvalidState is returned when the state parameter is invalid + ErrInvalidState = errors.New("oauth2: invalid oauth2 state parameter") + + // ErrFailedToGenerateToken is returned when a token cannot be generated + ErrFailedToGenerateToken = errors.New("failed to generate token") + + // ErrMissingCodeOrState is returned when the request is missing the code or state query string parameter + ErrMissingCodeOrState = errors.New("oauth2: request missing code or state") + + // ErrContextMissingErrorValue is returned when the context does not have an error value + ErrContextMissingErrorValue = fmt.Errorf("context missing error value") +) + +// DefaultFailureHandler responds with a 400 status code and message parsed from the context +var DefaultFailureHandler = http.HandlerFunc(failureHandler) + +func failureHandler(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // ErrorFromContext always returns some non-nil error + http.Error(w, "", http.StatusBadRequest) +} diff --git a/providers/oauth2/login.go b/providers/oauth2/login.go new file mode 100644 index 0000000..280322e --- /dev/null +++ b/providers/oauth2/login.go @@ -0,0 +1,133 @@ +package oauth2 + +import ( + "net/http" + + "golang.org/x/oauth2" + + "github.com/theopenlane/core/pkg/keygen" + "github.com/theopenlane/core/pkg/sessions" +) + +// StateHandler checks for a state cookie, if found, adds to context; if missing, a +// random generated value is added to the context and to a (short-lived) state cookie +// issued to the requester - this implements OAuth 2 RFC 6749 10.12 CSRF Protection +func StateHandler(config sessions.CookieConfig, success http.Handler) http.Handler { + funk := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + + queryParams := req.URL.Query() + if queryParams.Get("state") != "" { + ctx = WithState(ctx, queryParams.Get("state")) + } else { + val := keygen.GenerateRandomString(32) //nolint:mnd + http.SetCookie(w, sessions.NewCookie(config.Name, val, &config)) + ctx = WithState(ctx, val) + } + + // set redirect + redirect, err := req.Cookie("redirect_to") + if err == nil && redirect.Value != "" { + ctx = WithRedirectURL(ctx, redirect.Value) + } else { + _ = req.ParseForm() //nolint: errcheck + + redirect := req.Form.Get("redirect_uri") + if redirect != "" { + http.SetCookie(w, sessions.NewCookie("redirect_to", redirect, &config)) + ctx = WithRedirectURL(ctx, redirect) + } + } + + success.ServeHTTP(w, req.WithContext(ctx)) + } + + return http.HandlerFunc(funk) +} + +// LoginHandler reads the state value from the context and redirects requests to the AuthURL with that state value +func LoginHandler(config *oauth2.Config, failure http.Handler) http.Handler { + if failure == nil { + failure = DefaultFailureHandler + } + + fn := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + state, err := StateFromContext(ctx) + + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + authURL := config.AuthCodeURL(state) + http.Redirect(w, req, authURL, http.StatusFound) + } + + return http.HandlerFunc(fn) +} + +// CallbackHandler parses the auth code + state and compares it to the state value from the context +func CallbackHandler(config *oauth2.Config, success, failure http.Handler) http.Handler { + if failure == nil { + failure = DefaultFailureHandler + } + + funk := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + + authCode, state, err := parseCallback(req) + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + ownerState, err := StateFromContext(ctx) + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + if state != ownerState || state == "" { + ctx = WithError(ctx, ErrInvalidState) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + token, err := config.Exchange(ctx, authCode) + if err != nil { + ctx = WithError(ctx, err) + failure.ServeHTTP(w, req.WithContext(ctx)) + + return + } + + ctx = WithToken(ctx, token) + success.ServeHTTP(w, req.WithContext(ctx)) + } + + return http.HandlerFunc(funk) +} + +// parseCallback parses code and state parameters from the http.Request and returns them +func parseCallback(req *http.Request) (authCode, state string, err error) { + if err = req.ParseForm(); err != nil { + return "", "", err + } + + authCode = req.Form.Get("code") + state = req.Form.Get("state") + + if authCode == "" || state == "" { + return "", "", ErrMissingCodeOrState + } + + return authCode, state, nil +} diff --git a/providers/oauth2/login_test.go b/providers/oauth2/login_test.go new file mode 100644 index 0000000..02f8410 --- /dev/null +++ b/providers/oauth2/login_test.go @@ -0,0 +1,207 @@ +package oauth2 + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" + + "github.com/theopenlane/core/pkg/testutils" +) + +const ( + FailureHandlerCalled = "failure handler called" + CodePath = "/?code=any_code&state=d4e5f6" + SuccessHandlerCalled = "success handler called" +) + +func TestLoginHandler(t *testing.T) { + expectedState := "state_val" + expectedRedirect := "https://api.example.com/authorize?client_id=client_id&redirect_uri=redirect_url&response_type=code&state=state_val" + config := &oauth2.Config{ + ClientID: "client_id", + ClientSecret: "client_secret", + RedirectURL: "redirect_url", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://api.example.com/authorize", + }, + } + + failure := testutils.AssertFailureNotCalled(t) + + loginHandler := LoginHandler(config, failure) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + + ctx := WithState(context.Background(), expectedState) + loginHandler.ServeHTTP(w, req.WithContext(ctx)) + + assert.Equal(t, http.StatusFound, w.Code) + assert.Equal(t, expectedRedirect, w.Result().Header.Get("Location")) // nolint: bodyclose +} + +func TestLoginState(t *testing.T) { + config := &oauth2.Config{} + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + assert.Equal(t, "oauth2: context missing state value", err.Error()) + } + + fmt.Fprint(w, FailureHandlerCalled) + } + + loginHandler := LoginHandler(config, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) // nolint: noctx + loginHandler.ServeHTTP(w, req.WithContext(req.Context())) + assert.Equal(t, FailureHandlerCalled, w.Body.String()) +} + +func TestCallbackHandler(t *testing.T) { + jsonData := `{ + "access_token":"2YotnFZFEjr1zCsicMWpAA", + "token_type":"example", + "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter":"example_value" + }` + expectedToken := &oauth2.Token{ + AccessToken: "2YotnFZFEjr1zCsicMWpAA", + TokenType: "example", + RefreshToken: "tGzv3JOkF0XG5Qx2TlKWIA", + } + + server := NewAccessTokenServer(t, jsonData) + defer server.Close() + + config := &oauth2.Config{ + Endpoint: oauth2.Endpoint{ + TokenURL: server.URL, + }, + } + + success := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + token, err := TokenFromContext(ctx) + assert.Equal(t, expectedToken.AccessToken, token.AccessToken) + assert.Equal(t, expectedToken.TokenType, token.Type()) + assert.Equal(t, expectedToken.RefreshToken, token.RefreshToken) + // real oauth2.Token populates internal raw field and unmockable Expiry time + assert.Nil(t, err) + fmt.Fprint(w, SuccessHandlerCalled) + } + + failure := testutils.AssertFailureNotCalled(t) + callbackHandler := CallbackHandler(config, http.HandlerFunc(success), failure) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", CodePath, nil) // nolint: noctx + ctx := WithState(context.Background(), "d4e5f6") + callbackHandler.ServeHTTP(w, req.WithContext(ctx)) + assert.Equal(t, SuccessHandlerCalled, w.Body.String()) +} + +func TestParseCallback(t *testing.T) { + config := &oauth2.Config{} + success := testutils.AssertSuccessNotCalled(t) + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + assert.Equal(t, "oauth2: request missing code or state", err.Error()) + } + + fmt.Fprint(w, FailureHandlerCalled) + } + + callbackHandler := CallbackHandler(config, success, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/?code=any_code", nil) + callbackHandler.ServeHTTP(w, req.WithContext(req.Context())) + assert.Equal(t, FailureHandlerCalled, w.Body.String()) + + w = httptest.NewRecorder() + req, _ = http.NewRequestWithContext(req.Context(), "GET", "/?state=any_state", nil) + callbackHandler.ServeHTTP(w, req.WithContext(req.Context())) + assert.Equal(t, FailureHandlerCalled, w.Body.String()) +} + +func TestStateHandler(t *testing.T) { + config := &oauth2.Config{} + success := testutils.AssertSuccessNotCalled(t) + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + assert.Equal(t, "oauth2: context missing state value", err.Error()) + } + + fmt.Fprint(w, FailureHandlerCalled) + } + + callbackHandler := CallbackHandler(config, success, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", CodePath, nil) // nolint: noctx + callbackHandler.ServeHTTP(w, req.WithContext(req.Context())) + assert.Equal(t, FailureHandlerCalled, w.Body.String()) +} + +func TestStateFromContext(t *testing.T) { + config := &oauth2.Config{} + success := testutils.AssertSuccessNotCalled(t) + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + assert.Equal(t, "oauth2: invalid oauth2 state parameter", err.Error()) + } + + fmt.Fprint(w, FailureHandlerCalled) + } + + callbackHandler := CallbackHandler(config, success, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", CodePath, nil) // nolint: noctx + ctx := WithState(context.Background(), "differentState") + callbackHandler.ServeHTTP(w, req.WithContext(ctx)) + assert.Equal(t, FailureHandlerCalled, w.Body.String()) +} + +func TestTokenExchange(t *testing.T) { + _, server := testutils.NewErrorServer("oAuth is no auth'in", http.StatusInternalServerError) + defer server.Close() + + config := &oauth2.Config{ + Endpoint: oauth2.Endpoint{ + TokenURL: server.URL, + }, + } + success := testutils.AssertSuccessNotCalled(t) + failure := func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + err := ErrorFromContext(ctx) + + if assert.NotNil(t, err) { + // error from golang.org/x/oauth2 config.Exchange as provider is down + assert.True(t, strings.HasPrefix(err.Error(), "oauth2: cannot fetch token")) + } + + fmt.Fprint(w, FailureHandlerCalled) + } + + callbackHandler := CallbackHandler(config, success, http.HandlerFunc(failure)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", CodePath, nil) // nolint: noctx + ctx := WithState(context.Background(), "d4e5f6") + callbackHandler.ServeHTTP(w, req.WithContext(ctx)) + assert.Equal(t, FailureHandlerCalled, w.Body.String()) +} diff --git a/providers/oauth2/oauth2_test.go b/providers/oauth2/oauth2_test.go new file mode 100644 index 0000000..1aa1dd5 --- /dev/null +++ b/providers/oauth2/oauth2_test.go @@ -0,0 +1,28 @@ +package oauth2 + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + contentType = "Content-Type" + jsonContentType = "application/json" +) + +// NewAccessTokenServer creates a httptest.Server OAuth2 provider Access Token endpoint +func NewAccessTokenServer(t *testing.T, json string) *httptest.Server { + return NewTestServerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "POST", req.Method) + w.Header().Set(contentType, jsonContentType) + _, _ = w.Write([]byte(json)) + }) +} + +// NewTestServeFunc wraps httptest.Server so it can be used with funcs +func NewTestServerFunc(handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(handler)) +} diff --git a/providers/oidc/doc.go b/providers/oidc/doc.go new file mode 100644 index 0000000..d8bb476 --- /dev/null +++ b/providers/oidc/doc.go @@ -0,0 +1,2 @@ +// Package oidc provides oidc authentication helpers and flow +package oidc diff --git a/providers/oidc/oidc.go b/providers/oidc/oidc.go new file mode 100644 index 0000000..7667090 --- /dev/null +++ b/providers/oidc/oidc.go @@ -0,0 +1,94 @@ +package oidc + +import ( + "context" + "net/http" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + echo "github.com/theopenlane/echox" +) + +type User struct { + OAuth2Token *oauth2.Token + IDToken *IDToken +} + +type UserResponse struct { + AccessToken string + IDToken string + Name string + Email string + Picture string +} + +type IDToken struct { + RawToken string + Claims *Claims +} + +type Claims struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func ExchangeCode(ctx context.Context, r *http.Request, config *oauth2.Config, provider *oidc.Provider) (*User, error) { + state, err := r.Cookie("state") + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, "State cookie is not set in request") + } + + if r.URL.Query().Get("state") != state.Value { + return nil, echo.NewHTTPError(http.StatusBadRequest, "State cookie did not match") + } + + oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Unable to exchange token: "+err.Error()) + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + + if !ok { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "No id_token field in oauth2 token.") + } + + oidcConfig := &oidc.Config{ + ClientID: config.ClientID, + } + + verifier := provider.Verifier(oidcConfig) + idToken, err := verifier.Verify(ctx, rawIDToken) + + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Unable to verify ID Token: "+err.Error()) + } + + nonce, err := r.Cookie("nonce") + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, "Nonce is not provided") + } + + if idToken.Nonce != nonce.Value { + return nil, echo.NewHTTPError(http.StatusBadRequest, "Nonce did not match") + } + + var claims Claims + + if err := idToken.Claims(&claims); err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + user := User{ + OAuth2Token: oauth2Token, + IDToken: &IDToken{ + RawToken: rawIDToken, + Claims: &claims, + }, + } + + return &user, nil +} diff --git a/providers/webauthn/config.go b/providers/webauthn/config.go new file mode 100644 index 0000000..c19e558 --- /dev/null +++ b/providers/webauthn/config.go @@ -0,0 +1,68 @@ +package webauthn + +import ( + "encoding/gob" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + ProviderName = "WEBAUTHN" +) + +// ProviderConfig represents the configuration settings for a Webauthn Provider +type ProviderConfig struct { + // Enabled is the provider enabled + Enabled bool `json:"enabled" koanf:"enabled" default:"true"` + // DisplayName is the site display name + DisplayName string `json:"displayName" koanf:"displayName" jsonschema:"required" default:""` + // RelyingPartyID is the relying party identifier + // set to localhost for development, no port + RelyingPartyID string `json:"relyingPartyId" koanf:"relyingPartyId" jsonschema:"required" default:"localhost"` + // RequestOrigins the origin domain(s) for authentication requests + // include the scheme and port + RequestOrigins []string `json:"requestOrigins" koanf:"requestOrigins" jsonschema:"required" default:"[http://localhost:3001]"` + // MaxDevices is the maximum number of devices that can be associated with a user + MaxDevices int `json:"maxDevices" koanf:"maxDevices" default:"10"` + // EnforceTimeout at the Relying Party / Server. This means if enabled and the user takes too long that even if the browser does not + // enforce a timeout, the server will + EnforceTimeout bool `json:"enforceTimeout" koanf:"enforceTimeout" default:"true"` + // Timeout is the timeout in seconds + Timeout time.Duration `json:"timeout" koanf:"timeout" default:"60s"` + // Debug enables debug mode + Debug bool `json:"debug" koanf:"debug" default:"false"` +} + +// NewWithConfig returns a configured Webauthn Provider +func NewWithConfig(config ProviderConfig) *webauthn.WebAuthn { + if !config.Enabled { + return nil + } + + cfg := &webauthn.Config{ + RPID: config.RelyingPartyID, + RPOrigins: config.RequestOrigins, + RPDisplayName: config.DisplayName, + Debug: config.Debug, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{ + Enforce: config.EnforceTimeout, + Timeout: config.Timeout, + TimeoutUVD: config.Timeout, + }, + Registration: webauthn.TimeoutConfig{ + Enforce: config.EnforceTimeout, + Timeout: config.Timeout, + TimeoutUVD: config.Timeout, + }, + }, + } + + return &webauthn.WebAuthn{Config: cfg} +} + +func init() { + // Register the webauthn.SessionData type with gob so it can be used in sessions + gob.Register(webauthn.SessionData{}) +} diff --git a/providers/webauthn/doc.go b/providers/webauthn/doc.go new file mode 100644 index 0000000..f3e0f83 --- /dev/null +++ b/providers/webauthn/doc.go @@ -0,0 +1,2 @@ +// Package webauthn is a provider package offering Passkey login functionality +package webauthn diff --git a/providers/webauthn/errors.go b/providers/webauthn/errors.go new file mode 100644 index 0000000..2a17bdb --- /dev/null +++ b/providers/webauthn/errors.go @@ -0,0 +1,13 @@ +package webauthn + +import ( + "errors" +) + +var ( + // ErrUserNotFound is returned when the user couldn't be found + ErrUserNotFound = errors.New("user not found") + + // ErrSessionNotFound is returned when the session couldn't be found + ErrSessionNotFound = errors.New("session not found") +) diff --git a/providers/webauthn/passkey-registration.png b/providers/webauthn/passkey-registration.png new file mode 100644 index 0000000000000000000000000000000000000000..6f0dec3972125cfff941f26107cd17227694acda GIT binary patch literal 125961 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ℜdJu;~Bihf}f5`lkP1&qTlw{_o3-RX02se|( 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 literal 0 HcmV?d00001 diff --git a/providers/webauthn/user.go b/providers/webauthn/user.go new file mode 100644 index 0000000..f57bf6f --- /dev/null +++ b/providers/webauthn/user.go @@ -0,0 +1,63 @@ +package webauthn + +import ( + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +type User struct { + ID string + FirstName string + LastName string + DisplayName string + Name string + Email string + WebauthnCredentials []webauthn.Credential `json:"-"` +} + +var Sessions = map[string]*webauthn.SessionData{} +var Users = map[string]*User{} + +// WebAuthnID is the user's webauthn ID +func (u *User) WebAuthnID() []byte { + return []byte(u.ID) +} + +// WebAuthnName is the user's webauthn name +func (u *User) WebAuthnName() string { + return u.Name +} + +// WebAuthnDisplayName is the user's webauthn display name +func (u *User) WebAuthnDisplayName() string { + if u.DisplayName != "" { + return u.DisplayName + } + + return u.Name +} + +// WebAuthnCredentials is the user's webauthn credentials +func (u *User) WebAuthnCredentials() []webauthn.Credential { + return u.WebauthnCredentials +} + +// WebAuthnIcon is the user's webauthn icon +func (u *User) WebAuthnIcon() string { + return "" +} + +// CredentialExcludeList returns a list of credentials to exclude from the webauthn credential list +func (u *User) CredentialExcludeList() []protocol.CredentialDescriptor { + credentialExcludeList := []protocol.CredentialDescriptor{} + + for _, cred := range u.WebauthnCredentials { + descriptor := protocol.CredentialDescriptor{ + Type: protocol.PublicKeyCredentialType, + CredentialID: cred.ID, + } + credentialExcludeList = append(credentialExcludeList, descriptor) + } + + return credentialExcludeList +} diff --git a/providers/webauthn/user_test.go b/providers/webauthn/user_test.go new file mode 100644 index 0000000..b7549f1 --- /dev/null +++ b/providers/webauthn/user_test.go @@ -0,0 +1,114 @@ +package webauthn_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/go-webauthn/webauthn/protocol" + gowebauthn "github.com/go-webauthn/webauthn/webauthn" + + "github.com/theopenlane/core/pkg/providers/webauthn" +) + +func TestUserWebAuthnID(t *testing.T) { + // Create a user instance + user := &webauthn.User{ + ID: "exampleID", + } + + // Call the WebAuthnID method + webAuthnID := user.WebAuthnID() + + // Check if the returned value is correct + expectedWebAuthnID := []byte("exampleID") + assert.Equal(t, expectedWebAuthnID, webAuthnID) +} + +func TestUserWebAuthnName(t *testing.T) { + // Create a user instance + user := &webauthn.User{ + Name: "example", + } + + // Call the WebAuthnID method + webAuthnName := user.WebAuthnName() + + // Check if the returned value is correct + assert.Equal(t, "example", webAuthnName) +} + +func TestWebAuthnDisplayName(t *testing.T) { + testCases := []struct { + testName string + name string + displayName string + expected string + }{ + { + testName: "display name is set", + name: "Noah Kahan", + displayName: "Noah", + expected: "Noah", + }, + { + testName: "display name is empty", + name: "Noah Kahan", + displayName: "", + expected: "Noah Kahan", + }, + } + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + // Create a user instance + user := &webauthn.User{ + DisplayName: tc.displayName, + Name: tc.name, + } + + result := user.WebAuthnDisplayName() + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestWebAuthnCredentials(t *testing.T) { + // Create a user instance + user := &webauthn.User{ + WebauthnCredentials: []gowebauthn.Credential{ + { + ID: []byte("exampleID"), + }, + }, + } + + // Call the WebAuthnCredentials method + creds := user.WebAuthnCredentials() + + // Check if the returned value is correct + assert.NotEmpty(t, creds) + assert.Equal(t, user.WebauthnCredentials, creds) +} + +func TestUserCredentialExcludeList(t *testing.T) { + // Create a user instance + user := &webauthn.User{ + WebauthnCredentials: []gowebauthn.Credential{ + { + ID: []byte("exampleID"), + }, + }, + } + + // Call the CredentialExcludeList method + excludeList := user.CredentialExcludeList() + + // Check if the returned value is correct + expectedExcludeList := []protocol.CredentialDescriptor{ + { + Type: protocol.PublicKeyCredentialType, + CredentialID: []byte("exampleID"), + }, + } + assert.Equal(t, expectedExcludeList, excludeList) +} diff --git a/tokens/claims.go b/tokens/claims.go new file mode 100644 index 0000000..74cc970 --- /dev/null +++ b/tokens/claims.go @@ -0,0 +1,37 @@ +package tokens + +import ( + jwt "github.com/golang-jwt/jwt/v5" + "github.com/oklog/ulid/v2" + + "github.com/theopenlane/utils/ulids" +) + +// Claims implements custom claims and extends the `jwt.RegisteredClaims` struct; we will store user-related elements here (and thus in the JWT Token) for reference / validation +type Claims struct { + jwt.RegisteredClaims + // UserID is the internal generated mapping ID for the user + UserID string `json:"user_id,omitempty"` + // OrgID is the internal generated mapping ID for the organization the JWT token is valid for + OrgID string `json:"org,omitempty"` +} + +// ParseUserID returns the ID of the user from the Subject of the claims +func (c Claims) ParseUserID() ulid.ULID { + userID, err := ulid.Parse(c.UserID) + if err != nil { + return ulids.Null + } + + return userID +} + +// ParseOrgID parses and return the organization ID from the `OrgID` field of the claims +func (c Claims) ParseOrgID() ulid.ULID { + orgID, err := ulid.Parse(c.OrgID) + if err != nil { + return ulids.Null + } + + return orgID +} diff --git a/tokens/claims_test.go b/tokens/claims_test.go new file mode 100644 index 0000000..bb4e939 --- /dev/null +++ b/tokens/claims_test.go @@ -0,0 +1,35 @@ +package tokens_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/theopenlane/utils/ulids" + + "github.com/theopenlane/core/pkg/tokens" +) + +func TestClaimsParseOrgID(t *testing.T) { + claims := &tokens.Claims{} + require.Equal(t, ulids.Null, claims.ParseOrgID()) + + claims.OrgID = "notvalid" + require.Equal(t, ulids.Null, claims.ParseOrgID()) + + orgID := ulids.New() + claims.OrgID = orgID.String() + require.Equal(t, orgID, claims.ParseOrgID()) +} + +func TestClaimsParseUserID(t *testing.T) { + claims := &tokens.Claims{} + require.Equal(t, ulids.Null, claims.ParseUserID()) + + claims.UserID = "notvalid" + require.Equal(t, ulids.Null, claims.ParseUserID()) + + userID := ulids.New() + claims.UserID = userID.String() + require.Equal(t, userID, claims.ParseUserID()) +} diff --git a/tokens/config.go b/tokens/config.go new file mode 100644 index 0000000..6ea3c44 --- /dev/null +++ b/tokens/config.go @@ -0,0 +1,27 @@ +package tokens + +import "time" + +// Config defines the configuration settings for authentication tokens used in the server +type Config struct { + // KID represents the Key ID used in the configuration. + KID string `json:"kid" koanf:"kid" jsonschema:"required"` + // Audience represents the target audience for the tokens. + Audience string `json:"audience" koanf:"audience" jsonschema:"required" default:"https://theopenlane.io"` + // RefreshAudience represents the audience for refreshing tokens. + RefreshAudience string `json:"refreshAudience" koanf:"refreshAudience"` + // Issuer represents the issuer of the tokens + Issuer string `json:"issuer" koanf:"issuer" jsonschema:"required" default:"https://auth.theopenlane.io" ` + // AccessDuration represents the duration of the access token is valid for + AccessDuration time.Duration `json:"accessDuration" koanf:"accessDuration" default:"1h"` + // RefreshDuration represents the duration of the refresh token is valid for + RefreshDuration time.Duration `json:"refreshDuration" koanf:"refreshDuration" default:"2h"` + // RefreshOverlap represents the overlap time for a refresh and access token + RefreshOverlap time.Duration `json:"refreshOverlap" koanf:"refreshOverlap" default:"-15m" ` + // JWKSEndpoint represents the endpoint for the JSON Web Key Set + JWKSEndpoint string `json:"jwksEndpoint" koanf:"jwksEndpoint" default:"https://api.theopenlane.io/.well-known/jwks.json"` + // Keys represents the key pairs used for signing the tokens + Keys map[string]string `json:"keys" koanf:"keys" jsonschema:"required"` + // GenerateKeys is a boolean to determine if the keys should be generated + GenerateKeys bool `json:"generateKeys" koanf:"generateKeys" default:"true"` +} diff --git a/tokens/doc.go b/tokens/doc.go new file mode 100644 index 0000000..7bafc66 --- /dev/null +++ b/tokens/doc.go @@ -0,0 +1,2 @@ +// Package tokens creates tokenmanager, responsible for signing, issuing, and validating tokens +package tokens diff --git a/tokens/errors.go b/tokens/errors.go new file mode 100644 index 0000000..d4aa373 --- /dev/null +++ b/tokens/errors.go @@ -0,0 +1,216 @@ +package tokens + +import ( + "errors" + "fmt" +) + +// Error constants +var ( + // ErrTokenManagerFailedInit returns when the token manager was not correctly provided signing keys + ErrTokenManagerFailedInit = errors.New("token manager not initialized with signing keys") + + // ErrFailedRetrieveClaimsFromToken returns when claims can not be retrieved from an access token + ErrFailedRetrieveClaimsFromToken = errors.New("could not retrieve claims from access token") + + // ErrTokenMissingKid returns when the kid cannot be found in the header of the token + ErrTokenMissingKid = errors.New("token does not have kid in header") + + // ErrFailedParsingKid returns when the kid could not be parsed + ErrFailedParsingKid = errors.New("could not parse kid: %s") + + // ErrUnknownSigningKey returns when the signing key fetched does not match the loaded managed keys + ErrUnknownSigningKey = errors.New("unknown signing key") +) + +var ( + // The below block of error constants are only used in comparison(s) checks with ValidationError and are the inner standard errors which could occur when performing token validation; these won't be referenced in return error messages within the code directly and wrapped with our custom errors + + // ErrTokenMalformed returns when a token is malformed + ErrTokenMalformed = errors.New("token is malformed") + + // ErrTokenUnverifiable is returned when the token could not be verified because of signing problems + ErrTokenUnverifiable = errors.New("token is unverifiable") + + // ErrTokenSignatureInvalid is returned when the signature is invalid + ErrTokenSignatureInvalid = errors.New("token signature is invalid") + + // ErrTokenInvalidAudience is returned when AUD validation failed + ErrTokenInvalidAudience = errors.New("token has invalid audience") + + // ErrTokenExpired is returned when EXP validation failed + ErrTokenExpired = errors.New("token is expired") + + // ErrTokenUsedBeforeIssued is returned when the token is used before issued + ErrTokenUsedBeforeIssued = errors.New("token used before issued") + + // ErrTokenInvalidIssuer is returned when ISS validation failed + ErrTokenInvalidIssuer = errors.New("token has invalid issuer") + + // ErrTokenNotValidYet is returned when NBF validation failed + ErrTokenNotValidYet = errors.New("token is not valid yet") + + // ErrTokenNotValid is returned when the token is invalid + ErrTokenNotValid = errors.New("token is invalid") + + // ErrTokenInvalidID is returned when the token has an invalid id + ErrTokenInvalidID = errors.New("token has invalid id") + + // ErrTokenInvalidClaims is returned when the token has invalid claims + ErrTokenInvalidClaims = errors.New("token has invalid claims") + + // ErrMissingEmail is returned when the token is attempted to be verified but the email is missing + ErrMissingEmail = errors.New("unable to create verification token, email is missing") + + // ErrTokenMissingEmail is returned when the verification is missing an email address + ErrTokenMissingEmail = errors.New("email verification token is missing email address") + + // ErrInvalidSecret is returned when the verification contains of secret of invalid length + ErrInvalidSecret = errors.New("email verification token contains an invalid secret") + + // ErrMissingUserID is returned when a reset token is trying to be created but no user id is provided + ErrMissingUserID = errors.New("unable to create reset token, user id is required") + + // ErrTokenMissingUserID is returned when the reset token is missing the required user id + ErrTokenMissingUserID = errors.New("reset token is missing user id") + + // ErrInviteTokenMissingOrgID is returned when the invite token is missing the org owner ID match + ErrInviteTokenMissingOrgID = errors.New("invite token is missing org id") + + // ErrInviteTokenMissingEmail + ErrInviteTokenMissingEmail = errors.New("invite token is missing email") + + // ErrExpirationIsRequired is returned when signing info is provided a zero-value expiration + ErrExpirationIsRequired = errors.New("signing info requires a non-zero expiration") + + // ErrFailedSigning is returned when an error occurs when trying to generate signing info with expiration + ErrFailedSigning = errors.New("error occurred when attempting to signing info") + + // ErrTokenInvalid is returned when unable to verify the token with the signature and secret provided + ErrTokenInvalid = errors.New("unable to verify token") +) + +// The errors that might occur when parsing and validating a token +const ( + // ValidationErrorMalformed is returned when the token is malformed + ValidationErrorMalformed uint32 = 1 << iota + + // ValidationErrorUnverifiableToken is returned when the token could not be verified because of signing problems + ValidationErrorUnverifiable + + // ValidationErrorSignatureInvalid is returned when the signature is invalid + ValidationErrorSignatureInvalid + + // ValidationErrorAudience is returned when AUD validation failed + ValidationErrorAudience + + // ValidationErrorExpired is returned when EXP validation failed + ValidationErrorExpired + + // ValidationErrorIssuedAt is returned when IAT validation failed + ValidationErrorIssuedAt + + // ValidationErrorIssuer is returned when ISS validation failed + ValidationErrorIssuer + + // ValidationErrorNotValidYet is returned when NBF validation failed + ValidationErrorNotValidYet + + // ValidationErrorID is returned when JTI validation failed + ValidationErrorID + + // ValidationErrorClaimsInvalid is returned when there is a generic claims validation failure + ValidationErrorClaimsInvalid +) + +// NewValidationError is a helper for constructing a ValidationError with a string error message +func NewValidationError(errorText string, errorFlags uint32) *ValidationError { + return &ValidationError{ + text: errorText, + Errors: errorFlags, + } +} + +// ValidationError represents an error from Parse if token is not valid +type ValidationError struct { + Inner error + Errors uint32 + text string +} + +// Error is the implementation of the err interface for ValidationError +func (e ValidationError) Error() string { + i := e.Inner + + switch { + case i != nil: + return e.Inner.Error() + case e.text != "": + return e.text + default: + return "token is invalid" + } +} + +// Unwrap gives errors.Is and errors.As access to the inner errors defined above +func (e *ValidationError) Unwrap() error { + return e.Inner +} + +// Is checks if this ValidationError is of the supplied error. We are first checking for the exact +// error message by comparing the inner error message. If that fails, we compare using the error +// flags. This way we can use custom error messages and leverage errors.Is using the global error +// variables, plus I just learned how to use errors.Is today so this is pretty sweet +func (e *ValidationError) Is(err error) bool { + // Check, if our inner error is a direct match + if errors.Is(errors.Unwrap(e), err) { + return true + } + + // Otherwise, we need to match using our error flags + switch err { + case ErrTokenMalformed: + return e.Errors&ValidationErrorMalformed != 0 + case ErrTokenUnverifiable: + return e.Errors&ValidationErrorUnverifiable != 0 + case ErrTokenSignatureInvalid: + return e.Errors&ValidationErrorSignatureInvalid != 0 + case ErrTokenInvalidAudience: + return e.Errors&ValidationErrorAudience != 0 + case ErrTokenExpired: + return e.Errors&ValidationErrorExpired != 0 + case ErrTokenUsedBeforeIssued: + return e.Errors&ValidationErrorIssuedAt != 0 + case ErrTokenInvalidIssuer: + return e.Errors&ValidationErrorIssuer != 0 + case ErrTokenNotValidYet: + return e.Errors&ValidationErrorNotValidYet != 0 + case ErrTokenInvalidID: + return e.Errors&ValidationErrorID != 0 + case ErrTokenInvalidClaims: + return e.Errors&ValidationErrorClaimsInvalid != 0 + } + + return false +} + +// ParseError is defining a custom error type called `ParseError` +type ParseError struct { + Object string + Value string + Err error +} + +// Error returns the ParseError in string format +func (e *ParseError) Error() string { + return fmt.Sprintf("could not parse %s %s: %v", e.Object, e.Value, e.Err) +} + +// The function newParseError creates a new ParseError object with the given parameters +func newParseError(o string, v string, err error) *ParseError { + return &ParseError{ + Object: o, + Value: v, + Err: err, + } +} diff --git a/tokens/expires_test.go b/tokens/expires_test.go new file mode 100644 index 0000000..ccbb113 --- /dev/null +++ b/tokens/expires_test.go @@ -0,0 +1,56 @@ +package tokens_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/theopenlane/core/pkg/tokens" +) + +const ( + accessToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjAxR1g2NDdTOFBDVkJDUEpIWEdKUjI2UE42IiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xIiwiYXVkIjpbImh0dHA6Ly8xMjcuMC4wLjEiXSwiZXhwIjoxNjgwNjE1MzMwLCJuYmYiOjE2ODA2MTE3MzAsImlhdCI6MTY4MDYxMTczMCwianRpIjoiMDFneDY0N3M4cGN2YmNwamh4Z2pzcG04N3AiLCJuYW1lIjoiSm9obiBEb2UiLCJlbWFpbCI6Impkb2VAZXhhbXBsZS5jb20iLCJvcmciOiIxMjMiLCJwcm9qZWN0IjoiYWJjIiwicGVybWlzc2lvbnMiOlsicmVhZDpkYXRhIiwid3JpdGU6ZGF0YSJdfQ.LLb6c2RdACJmoT3IFgJEwfu2_YJMcKgM2bF3ISF41A37gKTOkBaOe-UuTmjgZ7WEcuQ-cVkht0KI_4zqYYctB_WB9481XoNwff5VgFf3xrPdOYxS00YXQnl09RRqt6Fmca8nvd4mXfdO7uvpyNVuCIqNxBPXdSnRhreSoFB1GtFm42sBPAD7vF-MQUmU0c4PTsbiCfhR1_buH0NYEE1QFp3vYcgoiXOJHh9VStmRscqvLB12AQrcs26G9opdTCCORmvR2W3JLJ_hliHyp-d9lhXmCDFyiGkDEhTAUglqwBjqz5SO1UfAThWJO18PvZl4QPhb724oNT82VPh0DMDwfw" // nolint: gosec + refreshToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjAxR1g2NDdTOFBDVkJDUEpIWEdKUjI2UE42IiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xIiwiYXVkIjpbImh0dHA6Ly8xMjcuMC4wLjEiLCJodHRwOi8vMTI3LjAuMC4xL3YxL3JlZnJlc2giXSwiZXhwIjoxNjgwNjE4OTMwLCJuYmYiOjE2ODA2MTQ0MzAsImlhdCI6MTY4MDYxMTczMCwianRpIjoiMDFneDY0N3M4cGN2YmNwamh4Z2pzcG04N3AifQ.CLHmtZwSPFCPoMBX06D_C3h3WuEonUbvbfWLvtmrMmIwnTwQ4hxsaRJo_a4qI-emp1HNg-yu_7c3VNwjkti-d0c7CAGApTaf5eRdGJ5HGUkI8RDHbbMFaOK86nAFnzdPJ2JLmGtLzvpF9eFXFllDhRiAB-2t0uKcOdN7cFghdwyWXIVJIJNjngF_WUFklmLKnqORtj_tA6UJ6NJnZln34eMGftAHbuH8x-xUiRePHnro4ydS43CKNOgRP8biMHiRR2broBz0apIt30TeQShaBSbmGx__LYdm7RKPJNVHAn_3h_PwwKQG567-Aqabg6TSmpwhXCk_RfUyQVGv2b997w" // nolint: gosec +) + +func TestParse(t *testing.T) { + accessClaims, err := tokens.ParseUnverified(accessToken) + require.NoError(t, err, "could not parse access token") + + refreshClaims, err := tokens.ParseUnverified(refreshToken) + require.NoError(t, err, "could not parse refresh token") + + // We expect the claims and refresh tokens to have the same ID + require.Equal(t, accessClaims.ID, refreshClaims.ID, "access and refresh token had different IDs or the parse was unsuccessful") + + // Check that an error is returned when parsing a bad token + _, err = tokens.ParseUnverified("notarealtoken") + require.Error(t, err, "should not be able to parse a bad token") +} + +func TestExpiresAt(t *testing.T) { + expiration, err := tokens.ExpiresAt(accessToken) + require.NoError(t, err, "could not parse access token") + + // Expect the time to be fetched correctly from the token + expected := time.Date(2023, 4, 4, 13, 35, 30, 0, time.UTC) + require.True(t, expected.Equal(expiration)) + + // Check that an error is returned when parsing a bad token + _, err = tokens.ExpiresAt("notarealtoken") + require.Error(t, err, "should not be able to parse a bad token") +} + +func TestNotBefore(t *testing.T) { + expiration, err := tokens.NotBefore(refreshToken) + require.NoError(t, err, "could not parse access token") + + // Expect the time to be fetched correctly from the token + expected := time.Date(2023, 4, 4, 13, 20, 30, 0, time.UTC) + require.True(t, expected.Equal(expiration)) + + // Check that an error is returned when parsing a bad token + _, err = tokens.NotBefore("notarealtoken") + require.Error(t, err, "should not be able to parse a bad token") +} diff --git a/tokens/jwks.go b/tokens/jwks.go new file mode 100644 index 0000000..a3d1f2b --- /dev/null +++ b/tokens/jwks.go @@ -0,0 +1,117 @@ +package tokens + +import ( + "context" + "fmt" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +// JWKSValidator provides public verification that JWT tokens have been issued by the +// authentication service by checking that the tokens have been signed using +// public keys from a JSON Web Key Set (JWKS). The validator then returns +// specific claims if the token is in fact valid. +type JWKSValidator struct { + validator + keys jwk.Set +} + +// NewJWKSValidator is a constructor for creating a new instance of the `JWKSValidator` +// struct. It takes in a `jwk.Set` containing the JSON Web Key Set (JWKS), as well as the audience and issuer strings. +// It initializes a new `JWKSValidator` with the provided JWKS, audience, and issuer +func NewJWKSValidator(keys jwk.Set, audience, issuer string) *JWKSValidator { + validator := &JWKSValidator{ + validator: validator{ + audience: audience, + issuer: issuer, + }, + keys: keys, + } + validator.validator.keyFunc = validator.keyFunc + + return validator +} + +// keyFunc is a jwt.KeyFunc that selects the RSA public key from the list of managed +// internal keys based on the kid in the token header +func (v *JWKSValidator) keyFunc(token *jwt.Token) (publicKey interface{}, err error) { + // Fetch the kid from the header + kid, ok := token.Header["kid"] + if !ok { + return nil, ErrTokenMissingKid + } + + key, found := v.keys.LookupKeyID(kid.(string)) + if !found { + return nil, ErrUnknownSigningKey + } + + // Per JWT security notice: do not forget to validate alg is expected + if token.Method.Alg() != key.Algorithm().String() { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) //nolint:err113 + } + + // Extract the raw public key from the key material and return it. + if err = key.Raw(&publicKey); err != nil { + return nil, fmt.Errorf("could not extract raw key: %w", err) + } + + return publicKey, nil +} + +// CachedJWKSValidator struct is a type that extends the functionality of the `JWKSValidator` +// struct. It adds caching capabilities to the JWKS validation process. It includes +// a `cache` field of type `*jwk.Cache` to store and retrieve the JWKS, an `endpoint` field to +// specify the endpoint from which to fetch the JWKS, and embeds the `JWKSValidator` struct to +// inherit its methods and fields. The `CachedJWKSValidator` struct also includes additional methods +// `Refresh` and`keyFunc` to handle the caching logic +type CachedJWKSValidator struct { + JWKSValidator + cache *jwk.Cache + endpoint string +} + +// NewCachedJWKSValidator function is a constructor for creating a new instance of the +// `CachedJWKSValidator` struct. It takes in a `context.Context`, a `*jwk.Cache`, an endpoint string, +// an audience string, and an issuer string +func NewCachedJWKSValidator(ctx context.Context, cache *jwk.Cache, endpoint, audience, issuer string) (validator *CachedJWKSValidator, err error) { + validator = &CachedJWKSValidator{ + cache: cache, + endpoint: endpoint, + } + + var keys jwk.Set + + if keys, err = cache.Refresh(ctx, endpoint); err != nil { + return nil, err + } + + validator.JWKSValidator = *NewJWKSValidator(keys, audience, issuer) + validator.validator.keyFunc = validator.keyFunc + + return validator, nil +} + +// Refresh method in the `CachedJWKSValidator` struct is responsible for refreshing the JWKS +// (JSON Web Key Set) cache. It takes in a `context.Context` as a parameter and returns an error if +// the refresh process fails +func (v *CachedJWKSValidator) Refresh(ctx context.Context) (err error) { + if v.JWKSValidator.keys, err = v.cache.Refresh(ctx, v.endpoint); err != nil { + return fmt.Errorf("could not refresh cache from %s: %w", v.endpoint, err) + } + + return nil +} + +// keyFunc method in the `CachedJWKSValidator` struct is responsible for retrieving the public +// key from the JWKS cache based on the `kid` (key ID) in the token header. It first retrieves the +// JWKS from the cache using the `cache.Get` method. Then, it calls the `keyFunc` method of the embedded `JWKSValidator` struct to perform the actual key retrieval and validation. If the JWKS +// cannot be retrieved from the cache, an error is returned +func (v *CachedJWKSValidator) keyFunc(token *jwt.Token) (publicKey interface{}, err error) { + if v.JWKSValidator.keys, err = v.cache.Get(context.Background(), v.endpoint); err != nil { + return nil, fmt.Errorf("could not retrieve JWKS from cache: %w", err) + } + + return v.JWKSValidator.keyFunc(token) +} diff --git a/tokens/jwks_test.go b/tokens/jwks_test.go new file mode 100644 index 0000000..80535bd --- /dev/null +++ b/tokens/jwks_test.go @@ -0,0 +1,132 @@ +package tokens_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/v2/jwk" + + "github.com/theopenlane/core/pkg/tokens" +) + +func (s *TokenTestSuite) TestJWKSValidator() { + // This is a long running test, skip if in short mode + if testing.Short() { + s.T().Skip("skipping long running test in short mode") + } + + // NOTE: this test requires the jwks.json fixture to use the same keys as the + // testdata keys loaded from the PEM file fixtures. + // Create access and refresh tokens to validate. + require := s.Require() + tm, err := tokens.New(s.conf) + require.NoError(err, "could not initialize token manager") + + claims := &tokens.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "01H6PGFB4T34D4WWEXQMAGJNMK", + }, + UserID: "Rusty Shackleford", + OrgID: "01H6PGFG71N0AFEVTK3NJB71T9", + } + + atks, rtks, err := tm.CreateTokenPair(claims) + require.NoError(err, "could not create token pair") + time.Sleep(500 * time.Millisecond) + + // Create a validator from a JWKS key set + jwks, err := jwk.ReadFile("testdata/jwks.json") + require.NoError(err, "could not read jwks from file") + + validator := tokens.NewJWKSValidator(jwks, "http://localhost:3000", "http://localhost:3001") + + parsedClaims, err := validator.Verify(atks) + require.NoError(err, "could not validate access token") + require.Equal(claims, parsedClaims, "parsed claims not returned correctly") + + _, err = validator.Parse(rtks) + require.NoError(err, "could not parse refresh token") +} + +func (s *TokenTestSuite) TestCachedJWKSValidator() { + // This is a long running test, skip if in short mode + if testing.Short() { + s.T().Skip("skipping long running test in short mode") + } + + // Create a test server that initially serves the partial_jwks.json file then + // serves the jwks.json file from then on out. + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + err error + path string + f *os.File + ) + + if requests == 0 { + path = "testdata/partial_jwks.json" + } else { + path = "testdata/jwks.json" + } + + if f, err = os.Open(path); err != nil { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) // nolint: errcheck + + return + } + + requests++ + + w.Header().Add("Content-Type", "application/json") + io.Copy(w, f) // nolint: errcheck + })) + + defer srv.Close() + + // NOTE: this test requires the jwks.json fixture to use the same keys as the + // testdata keys loaded from the PEM file fixtures. + // Create access and refresh tokens to validate. + require := s.Require() + tm, err := tokens.New(s.conf) + require.NoError(err, "could not initialize token manager") + + claims := &tokens.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "01H6PGFB4T34D4WWEXQMAGJNMK", + }, + UserID: "Rusty Shackleford", + OrgID: "01H6PGFG71N0AFEVTK3NJB71T9", + } + + atks, _, err := tm.CreateTokenPair(claims) + require.NoError(err, "could not create token pair") + time.Sleep(500 * time.Millisecond) + + // Create a new cached validator for testing + cache := jwk.NewCache(context.Background()) + cache.Register(srv.URL, jwk.WithMinRefreshInterval(1*time.Minute)) // nolint: errcheck + validator, err := tokens.NewCachedJWKSValidator(context.Background(), cache, srv.URL, "http://localhost:3000", "http://localhost:3001") + require.NoError(err, "could not create new cached JWKS validator") + + // The first attempt to validate the access token should fail since the + // partial_jwks.json fixture does not have the keys that signed the token. + _, err = validator.Verify(atks) + require.EqualError(err, "token is unverifiable: error while executing keyfunc: unknown signing key") + + // After refreshing the cache, the access token should be able to be verified. + err = validator.Refresh(context.Background()) + require.NoError(err, "could not refresh cache") + + actualClaims, err := validator.Verify(atks) + require.NoError(err, "should have been able to verify the access token") + require.Equal(claims, actualClaims, "expected the correct claims to be returned") +} diff --git a/tokens/mock.go b/tokens/mock.go new file mode 100644 index 0000000..46d3904 --- /dev/null +++ b/tokens/mock.go @@ -0,0 +1,29 @@ +package tokens + +type MockValidator struct { + OnVerify func(string) (*Claims, error) + OnParse func(string) (*Claims, error) + Calls map[string]int +} + +var _ Validator = &MockValidator{} + +func (m *MockValidator) Verify(tks string) (*Claims, error) { + m.incr("Verify") + + return m.OnVerify(tks) +} + +func (m *MockValidator) Parse(tks string) (*Claims, error) { + m.incr("Parse") + + return m.OnParse(tks) +} + +func (m *MockValidator) incr(method string) { + if m.Calls == nil { + m.Calls = make(map[string]int) + } + + m.Calls[method]++ +} diff --git a/tokens/signing.go b/tokens/signing.go new file mode 100644 index 0000000..9b1dce6 --- /dev/null +++ b/tokens/signing.go @@ -0,0 +1,50 @@ +package tokens + +import ( + "sync" +) + +var signingMethods = map[string]func() SigningMethod{} +var signingMethodLock = new(sync.RWMutex) + +// SigningMethod can be used add new methods for signing or verifying tokens +type SigningMethod interface { + // Verify returns nil if signature is valid + Verify(signingString, signature string, key interface{}) error + // Sign returns encoded signature or error + Sign(signingString string, key interface{}) (string, error) + // Alg returns the alg identifier for this method (example: 'HS256') + Alg() string +} + +// RegisterSigningMethod registers the "alg" name and a factory function for signing method +func RegisterSigningMethod(alg string, f func() SigningMethod) { + signingMethodLock.Lock() + defer signingMethodLock.Unlock() + + signingMethods[alg] = f +} + +// GetSigningMethod retrieves a signing method from an "alg" string +func GetSigningMethod(alg string) (method SigningMethod) { + signingMethodLock.RLock() + defer signingMethodLock.RUnlock() + + if methodF, ok := signingMethods[alg]; ok { + method = methodF() + } + + return +} + +// GetAlgorithms returns a list of registered "alg" names +func GetAlgorithms() (algs []string) { + signingMethodLock.RLock() + defer signingMethodLock.RUnlock() + + for alg := range signingMethods { + algs = append(algs, alg) + } + + return +} diff --git a/tokens/testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem b/tokens/testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem new file mode 100644 index 0000000..d7d108d --- /dev/null +++ b/tokens/testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/tokens/testdata/01GE62EXXR0X0561XD53RDFBQJ.pem b/tokens/testdata/01GE62EXXR0X0561XD53RDFBQJ.pem new file mode 100644 index 0000000..06e35f2 --- /dev/null +++ b/tokens/testdata/01GE62EXXR0X0561XD53RDFBQJ.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/tokens/testdata/jwks.json b/tokens/testdata/jwks.json new file mode 100644 index 0000000..fc0fa42 --- /dev/null +++ b/tokens/testdata/jwks.json @@ -0,0 +1 @@ +{"keys":[{"alg":"RS256","e":"AQAB","kid":"01GE62EXXR0X0561XD53RDFBQJ","kty":"RSA","n":"xvad3rEUTeC_Fw-iDVVQ-fq0rspWpor-mAq-lqXlEeg6aXnkVkQKwpQ3bfH3M8azdva1MLgOrVooLByzG4rzHdDMsMoX0frVmTfr-c2UutAsYUbILkMXG-SuBeT6737lTngvw6kWBOekkUTBofO8c2XyeC23SMxLwneEK6fikjVSAYz3ls05cL848jSs4F_RVMKTMWdBrGy9vsZZU-YPkXXWR9red8OLyNN1F8tlwzZSKjppb8fSNFyxazne7_OznufGgJD_4oB1bJB-4qG0BUQzSGDdvuAHUeqUT-t9OyViCj5u0Sm23IklPCEzdmCmyIOt9euSsS6RbLBfNKW5COkerjD4i6BeD9ztlHvGZ95lbV1WVM9lp9fORBWym6yFj8jz3TYd26hjib_qttCbGlA4raRvtykTEOsxhe1MGkbnm1Ov1bOHQ-0foXYfHuxGH1VNY93of_ovy4t1aW8N7DqhMHKav7suWpzm_CqpWDle4B02zCCWYQ3C1kj3t-NqyG8vc1TBvAaxFOpN6KSC0t4zIfECYqH1YrFU987r7SWabvxycW-cX9tvmIJOLdOMarIzV0gtlIj1eyviqcoaLA8J8qcJdJbG9HalxdEYH3KO_O0-gY9L-nfWbU8mKWdGQ8_4h6TUqwcknHh4qeGr6VWCgnx536uWLuEBKYFGeEc","use":"sig"},{"alg":"RS256","e":"AQAB","kid":"01GE6191AQTGMCJ9BN0QC3CCVG","kty":"RSA","n":"3V5idMBzDWy0CH7Iuk5sVOMxegNXVtmbMeuE-D2OeJiN1DoMuuNo8ecUf9SQ4r3FZ4lAuotS2VQAQKflSG0hjjUn_FKxQbiXWgul_wNFD3lB1Lc60mH9jK16iO7ySmtZdo-n6d6-5TcQKfwaGoojI4HQLj7DmWRBL9dPmGUqohI-f0fZs_IOe-M3SadmNSB_GEqyGiVKQFLpoPLPFTuPqLa0TlbBh7Ou-6wBm3JNu2MgWH-P9Iz_jsO4p5pX6H6xqJTtDlVlFLeyuiV9ob1CioibF93J4C1X16dnT6THLRrxHfJYbGFBpn3zHpi1aH4rTuNe0JGYz4POH7n5MkGcpTas15jRKHAyT-kCCzBAnUkAWhzgZDUtM1r4nwnExxdOqVj4-vN5uQare3VwvkvxQi1AzTtloV7QUYv_YdF0IjCxGXgcPWTZQfjAeQ5zHTASteVtQsadCMDzQ-ifeWmpK3PHYJocimMbEkDrXWdXq0o74Iv7g2Qu7-rjmpuTTcrI-548Cd5wnxBuxTVS6TyqRZXU9vC5zYFV1ZZtOcs1bzwHkVyOBRB35vG8idDIPZxQz1w-PUMFq4Xl1MNhuITCXnx9nPoziDht3iI0-H-djmmEpwIWOKOPFC7FbBbmhiErtaRbvYIDGf8dfBkWzPleWEBClQOWYGwk1A4o8KP0PZc","use":"sig"}]} \ No newline at end of file diff --git a/tokens/testdata/partial_jwks.json b/tokens/testdata/partial_jwks.json new file mode 100644 index 0000000..6ab0d95 --- /dev/null +++ b/tokens/testdata/partial_jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "alg": "RS256", + "e": "AQAB", + "kid": "01GE6191AQTGMCJ9BN0QC3CCVG", + "kty": "RSA", + "n": "3V5idMBzDWy0CH7Iuk5sVOMxegNXVtmbMeuE-D2OeJiN1DoMuuNo8ecUf9SQ4r3FZ4lAuotS2VQAQKflSG0hjjUn_FKxQbiXWgul_wNFD3lB1Lc60mH9jK16iO7ySmtZdo-n6d6-5TcQKfwaGoojI4HQLj7DmWRBL9dPmGUqohI-f0fZs_IOe-M3SadmNSB_GEqyGiVKQFLpoPLPFTuPqLa0TlbBh7Ou-6wBm3JNu2MgWH-P9Iz_jsO4p5pX6H6xqJTtDlVlFLeyuiV9ob1CioibF93J4C1X16dnT6THLRrxHfJYbGFBpn3zHpi1aH4rTuNe0JGYz4POH7n5MkGcpTas15jRKHAyT-kCCzBAnUkAWhzgZDUtM1r4nwnExxdOqVj4-vN5uQare3VwvkvxQi1AzTtloV7QUYv_YdF0IjCxGXgcPWTZQfjAeQ5zHTASteVtQsadCMDzQ-ifeWmpK3PHYJocimMbEkDrXWdXq0o74Iv7g2Qu7-rjmpuTTcrI-548Cd5wnxBuxTVS6TyqRZXU9vC5zYFV1ZZtOcs1bzwHkVyOBRB35vG8idDIPZxQz1w-PUMFq4Xl1MNhuITCXnx9nPoziDht3iI0-H-djmmEpwIWOKOPFC7FbBbmhiErtaRbvYIDGf8dfBkWzPleWEBClQOWYGwk1A4o8KP0PZc", + "use": "sig" + } + ] +} \ No newline at end of file diff --git a/tokens/tokenmanager.go b/tokens/tokenmanager.go new file mode 100644 index 0000000..4b6089f --- /dev/null +++ b/tokens/tokenmanager.go @@ -0,0 +1,413 @@ +package tokens + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "io" + "net/url" + "os" + "strings" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/oklog/ulid/v2" +) + +const DefaultRefreshAudience = "https://auth.theopenlane.io/v1/refresh" + +// the signing method should match the value returned by the JWKS +var ( + signingMethod = jwt.SigningMethodRS256 + nilID = ulid.ULID{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} +) + +// TokenManager handles the creation and verification of RSA signed JWT tokens. To +// facilitate signing key rollover, TokenManager can accept multiple keys identified by +// a ulid. JWT tokens generated by token managers include a kid ("Key ID") in the header that +// allows the token manager to verify the key with the specified signature. To sign keys +// the token manager will always use the latest private key by ulid. +// +// When the TokenManager creates tokens it will use JWT standard claims as well as +// extended claims based on usage. The standard claims included are exp, nbf +// aud, and sub. On token verification, the exp, nbf, iss and aud claims are validated. + +type TokenManager struct { + validator + refreshAudience string + conf Config + currentKeyID ulid.ULID + currentKey *rsa.PrivateKey + keys map[ulid.ULID]*rsa.PublicKey + kidEntropy io.Reader +} + +var TimeFunc = time.Now + +// Keyfunc will be used by the Parse methods as a callback function to supply the key for verification +type Keyfunc func(*Token) (interface{}, error) + +// Token represents a JWT Token. Different fields will be used depending on whether you're +// creating or parsing/verifying a token +type Token struct { + // Raw is the raw token; populated when you parse a token + Raw string + // Method is the signing metehod of the token + Method SigningMethod + // Header is the first segment of the token + Header map[string]interface{} + // Claims is the second segment of the token + Claims Claims + ClaimBytes []byte + ToBeSignedString string + // Signature is the third segment of the token; populated when you parse a token + Signature string + // Valid is a bool determining if the token is valid; populated when you parse or verify a token + Valid bool +} + +// New creates a TokenManager with the specified keys which should be a mapping of ULID +// strings to paths to files that contain PEM encoded RSA private keys. This input is +// specifically designed for the config environment variable so that keys can be loaded +// from k8s or vault secrets that are mounted as files on disk +func New(conf Config) (tm *TokenManager, err error) { + tm = &TokenManager{ + validator: validator{ + audience: conf.Audience, + issuer: conf.Issuer, + }, + conf: conf, + keys: make(map[ulid.ULID]*rsa.PublicKey), + kidEntropy: &ulid.LockedMonotonicReader{ + MonotonicReader: ulid.Monotonic(rand.Reader, 0), + }, + } + tm.validator.keyFunc = tm.keyFunc + + for kid, path := range conf.Keys { + var keyID ulid.ULID + + if keyID, err = ulid.Parse(kid); err != nil { + return nil, newParseError("kid", kid, err) + } + + // Load the keys from disk + var data []byte + + if data, err = os.ReadFile(path); err != nil { + return nil, newParseError("path - read", path, err) + } + + var key *rsa.PrivateKey + + if key, err = jwt.ParseRSAPrivateKeyFromPEM(data); err != nil { + return nil, newParseError("path - retrieve", path, err) + } + + tm.keys[keyID] = &key.PublicKey + + // Set the current key if it is the latest key + if tm.currentKey == nil || keyID.Time() > tm.currentKeyID.Time() { + tm.currentKey = key + tm.currentKeyID = keyID + } + } + + return tm, nil +} + +// NewWithKey is a constructor function that creates a new instance of the TokenManager struct +// with a specified RSA private key. It takes in the private key as a parameter and initializes the +// TokenManager with the provided key, along with other configuration settings from the TokenConfig +// struct. It returns the created TokenManager instance or an error if there was a problem +// initializing the TokenManager. +func NewWithKey(key *rsa.PrivateKey, conf Config) (tm *TokenManager, err error) { + tm = &TokenManager{ + validator: validator{ + audience: conf.Audience, + issuer: conf.Issuer, + }, + conf: conf, + keys: make(map[ulid.ULID]*rsa.PublicKey), + kidEntropy: &ulid.LockedMonotonicReader{ + MonotonicReader: ulid.Monotonic(rand.Reader, 0), + }, + } + tm.validator.keyFunc = tm.keyFunc + + var kid ulid.ULID + + if kid, err = tm.genKeyID(); err != nil { + return nil, err + } + + tm.keys[kid] = &key.PublicKey + tm.currentKey = key + tm.currentKeyID = kid + + return tm, nil +} + +// Sign an access or refresh token and return the token +func (tm *TokenManager) Sign(token *jwt.Token) (string, error) { + if tm.currentKey == nil || tm.currentKeyID.Compare(nilID) == 0 { + return "", ErrTokenManagerFailedInit + } + + // Add the kid to the header + token.Header["kid"] = tm.currentKeyID.String() + + // Return the signed string + return token.SignedString(tm.currentKey) +} + +// CreateTokenPair returns signed access and refresh tokens for the specified claims in one step since usually you want both access and refresh tokens at the same time +func (tm *TokenManager) CreateTokenPair(claims *Claims) (accessToken, refreshToken string, err error) { + var atk, rtk *jwt.Token + + if atk, err = tm.CreateAccessToken(claims); err != nil { + return "", "", fmt.Errorf("could not create access token: %w", err) + } + + if rtk, err = tm.CreateRefreshToken(atk); err != nil { + return "", "", fmt.Errorf("could not create refresh token: %w", err) + } + + if accessToken, err = tm.Sign(atk); err != nil { + return "", "", fmt.Errorf("could not sign access token: %w", err) + } + + if refreshToken, err = tm.Sign(rtk); err != nil { + return "", "", fmt.Errorf("could not sign refresh token: %w", err) + } + + return +} + +// CreateToken from the claims payload without modifying the claims unless the claims +// are missing required fields that need to be updated +func (tm *TokenManager) CreateToken(claims *Claims) *jwt.Token { + if len(claims.Audience) == 0 { + claims.Audience = jwt.ClaimStrings{tm.audience} + } + + if claims.Issuer == "" { + claims.Issuer = tm.issuer + } + + return jwt.NewWithClaims(signingMethod, claims) +} + +// CreateAccessToken from the credential payload or from an previous token if the access token is being reauthorized from previous credentials or an already issued access token +func (tm *TokenManager) CreateAccessToken(claims *Claims) (_ *jwt.Token, err error) { + // Create the claims for the access token, using access token defaults + now := time.Now() + sub := claims.RegisteredClaims.Subject + + var kid ulid.ULID + + if kid, err = tm.genKeyID(); err != nil { + return nil, err + } + + issueTime := jwt.NewNumericDate(now) + claims.RegisteredClaims = jwt.RegisteredClaims{ + ID: strings.ToLower(kid.String()), // ID is randomly generated and shared between access and refresh + Subject: sub, + Audience: jwt.ClaimStrings{tm.audience}, + Issuer: tm.issuer, + IssuedAt: issueTime, + NotBefore: issueTime, + ExpiresAt: jwt.NewNumericDate(now.Add(tm.conf.AccessDuration)), + } + + return tm.CreateToken(claims), nil +} + +// CreateRefreshToken from the Access token claims with predefined expiration +func (tm *TokenManager) CreateRefreshToken(accessToken *jwt.Token) (refreshToken *jwt.Token, err error) { + accessClaims, ok := accessToken.Claims.(*Claims) + if !ok { + return nil, ErrFailedRetrieveClaimsFromToken + } + + audience := accessClaims.Audience + + // Append the refresh token audience to the audience claims + audience = append(audience, tm.RefreshAudience()) + + // Create claims for the refresh token from the access token defaults + claims := &Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: accessClaims.ID, // ID is randomly generated and shared between access and refresh tokens + Audience: audience, + Issuer: accessClaims.Issuer, + Subject: accessClaims.Subject, + IssuedAt: accessClaims.IssuedAt, + NotBefore: jwt.NewNumericDate(accessClaims.ExpiresAt.Add(tm.conf.RefreshOverlap)), + ExpiresAt: jwt.NewNumericDate(accessClaims.IssuedAt.Add(tm.conf.RefreshDuration)), + }, + OrgID: accessClaims.OrgID, + } + + return tm.CreateToken(claims), nil +} + +// Keys returns the JWKS with public keys for use externally +func (tm *TokenManager) Keys() (keys jwk.Set, err error) { + keys = jwk.NewSet() + for kid, pubkey := range tm.keys { + var key jwk.Key + + if key, err = jwk.FromRaw(pubkey); err != nil { + return nil, err + } + + if err = key.Set(jwk.KeyIDKey, kid.String()); err != nil { + return nil, err + } + + if err = key.Set(jwk.KeyUsageKey, jwk.ForSignature); err != nil { + return nil, err + } + + // NOTE: the algorithm should match the signing method of this package + if err = key.Set(jwk.AlgorithmKey, jwa.RS256); err != nil { + return nil, err + } + + if err = keys.AddKey(key); err != nil { + return nil, err + } + } + + return keys, nil +} + +// RefreshAudience returns the refresh audience for the token manager; The refresh audience in plain-human-speak is the URL where the refresh token should be sent for validation (which is our api endpoint) +func (tm *TokenManager) RefreshAudience() string { + if tm.refreshAudience == "" { + if tm.conf.RefreshAudience != "" { + tm.refreshAudience = tm.conf.RefreshAudience + } + + if aud, err := url.Parse(tm.issuer); err == nil { + tm.refreshAudience = aud.ResolveReference(&url.URL{Path: "/v1/refresh"}).String() + } else { + tm.refreshAudience = DefaultRefreshAudience + } + } + + return tm.refreshAudience +} + +// Config returns the token manager config +func (tm *TokenManager) Config() Config { + return tm.conf +} + +// CurrentKey returns the ulid of the current key being used to sign tokens - this is just the identifier of the key, not the key itself +func (tm *TokenManager) CurrentKey() ulid.ULID { + return tm.currentKeyID +} + +// keyFunc selects the RSA public key from the list of tokenmanager internal keys based on the kid in the token header - if the kid does not exist an error is returned the token is not validated +func (tm *TokenManager) keyFunc(token *jwt.Token) (key interface{}, err error) { + // Per JWT security notice: do not forget to validate alg is expected, else haxorz!~ + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) //nolint:err113 + } + + // Fetch that kid + kid, ok := token.Header["kid"] + if !ok { + return nil, ErrTokenMissingKid + } + + // Parse that kid + var keyID ulid.ULID + + if keyID, err = ulid.Parse(kid.(string)); err != nil { + return nil, ErrFailedParsingKid + } + + // Fetch the key from the list of managed keys + if key, ok = tm.keys[keyID]; !ok { + return nil, ErrUnknownSigningKey + } + + return key, nil +} + +// genKeyID generates a ulid for a key (the identifier of the key) +func (tm *TokenManager) genKeyID() (uid ulid.ULID, err error) { + ms := ulid.Timestamp(time.Now()) + if uid, err = ulid.New(ms, tm.kidEntropy); err != nil { + return uid, fmt.Errorf("could not generate key id: %w", err) + } + + return uid, nil +} + +// ParseUnverified parses a string of tokens and returns the claims and any error encountered +func ParseUnverified(tks string) (claims *jwt.RegisteredClaims, err error) { + claims = &jwt.RegisteredClaims{} + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + + if _, _, err = parser.ParseUnverified(tks, claims); err != nil { + return nil, err + } + + return claims, nil +} + +// ParseUnverifiedTokenClaims parses token claims from an access token +func ParseUnverifiedTokenClaims(tks string) (claims *Claims, err error) { + claims = &Claims{} + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + + if _, _, err = parser.ParseUnverified(tks, claims); err != nil { + return nil, err + } + + return claims, nil +} + +// ExpiresAt parses a JWT token and returns the expiration time if it exists +func ExpiresAt(tks string) (_ time.Time, err error) { + var claims *jwt.RegisteredClaims + + if claims, err = ParseUnverified(tks); err != nil { + return time.Time{}, err + } + + return claims.ExpiresAt.Time, nil +} + +// NotBefore parses a JWT token and returns the "NotBefore" time claim if it exists +func NotBefore(tks string) (_ time.Time, err error) { + var claims *jwt.RegisteredClaims + + if claims, err = ParseUnverified(tks); err != nil { + return time.Time{}, err + } + + return claims.NotBefore.Time, nil +} + +// IsExpired attempts to check if the provided token is expired +func IsExpired(tks string) (bool, error) { + expiration, err := ExpiresAt(tks) + if err != nil { + return true, err + } + + // check if token is expired + if expiration.Before(time.Now()) { + return true, nil + } + + return false, nil +} diff --git a/tokens/tokens_test.go b/tokens/tokens_test.go new file mode 100644 index 0000000..c9d9b65 --- /dev/null +++ b/tokens/tokens_test.go @@ -0,0 +1,367 @@ +package tokens_test + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/theopenlane/utils/ulids" + + "github.com/theopenlane/core/pkg/tokens" +) + +const ( + audience = "http://localhost:3000" + issuer = "http://localhost:3001" +) + +type TokenTestSuite struct { + suite.Suite + testdata map[string]string + conf tokens.Config + expiredConf tokens.Config +} + +func (s *TokenTestSuite) SetupSuite() { + // Create the keys map from the testdata directory to create new token managers. + s.testdata = make(map[string]string) + s.testdata["01GE6191AQTGMCJ9BN0QC3CCVG"] = "testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem" + s.testdata["01GE62EXXR0X0561XD53RDFBQJ"] = "testdata/01GE62EXXR0X0561XD53RDFBQJ.pem" + + s.conf = tokens.Config{ + Keys: s.testdata, + Audience: audience, + Issuer: issuer, + AccessDuration: 1 * time.Hour, + RefreshDuration: 2 * time.Hour, + RefreshOverlap: -15 * time.Minute, + } + + // Some tests require expired tokens to test expiration checking logic. + s.expiredConf = tokens.Config{ + Keys: s.testdata, + Audience: audience, + Issuer: issuer, + AccessDuration: -1 * time.Hour, + RefreshDuration: 2 * time.Hour, + RefreshOverlap: -15 * time.Minute, + } +} + +func (s *TokenTestSuite) TestCreateTokenPair() { + require := s.Require() + tm, err := tokens.New(s.conf) + require.NoError(err, "could not initialize token manager") + + claims := &tokens.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "01H6PGFB4T34D4WWEXQMAGJNMK", + }, + UserID: "Rusty Shackleford", + OrgID: "01H6PGFG71N0AFEVTK3NJB71T9", + } + + atks, rtks, err := tm.CreateTokenPair(claims) + require.NoError(err, "could not create token pair") + require.NotEmpty(atks, "no access token returned") + require.NotEmpty(rtks, "no refresh token returned") + + _, err = tm.Verify(atks) + require.NoError(err, "could not parse or verify claims from *tokens.Claims") + _, err = tm.Parse(rtks) + require.NoError(err, "could not parse refresh token") +} + +func (s *TokenTestSuite) TestTokenManager() { + // This is a long running test, skip if in short mode + if testing.Short() { + s.T().Skip("skipping long running test in short mode") + } + + require := s.Require() + tm, err := tokens.New(s.conf) + require.NoError(err, "could not initialize token manager") + + keys, err := tm.Keys() + require.NoError(err, "could not get jwks keys") + require.Equal(2, keys.Len()) + require.Equal("01GE62EXXR0X0561XD53RDFBQJ", tm.CurrentKey().String()) + + // Create an access token from simple claims + creds := &tokens.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "01H6PGFB4T34D4WWEXQMAGJNMK", + }, + UserID: "Rusty Shackleford", + OrgID: "01H6PGFG71N0AFEVTK3NJB71T9", + } + + accessToken, err := tm.CreateAccessToken(creds) + require.NoError(err, "could not create access token from claims") + require.IsType(&tokens.Claims{}, accessToken.Claims) + + time.Sleep(500 * time.Millisecond) + now := time.Now() + + // Check access token claims + ac := accessToken.Claims.(*tokens.Claims) + require.NotZero(ac.ID) + require.Equal(jwt.ClaimStrings{"http://localhost:3000"}, ac.Audience) + require.Equal("http://localhost:3001", ac.Issuer) + require.True(ac.IssuedAt.Before(now)) + require.True(ac.NotBefore.Before(now)) + require.True(ac.ExpiresAt.After(now)) + require.Equal(creds.Subject, ac.Subject) + require.Equal(creds.UserID, ac.UserID) + require.Equal(creds.OrgID, ac.OrgID) + + // Create a refresh token from the access token + refreshToken, err := tm.CreateRefreshToken(accessToken) + require.NoError(err, "could not create refresh token from access token") + require.IsType(&tokens.Claims{}, refreshToken.Claims) + + // Check refresh token claims + rc := refreshToken.Claims.(*tokens.Claims) + require.Equal(ac.ID, rc.ID, "access and refresh tokens must have same jid") + require.Equal(jwt.ClaimStrings{"http://localhost:3000", "http://localhost:3001/v1/refresh"}, rc.Audience) + require.NotEqual(ac.Audience, rc.Audience, "identical access token and refresh token audience") + require.Equal(ac.Issuer, rc.Issuer) + require.True(rc.IssuedAt.Equal(ac.IssuedAt.Time)) + require.True(rc.NotBefore.After(now)) + require.True(rc.ExpiresAt.After(rc.NotBefore.Time)) + require.Equal(ac.Subject, rc.Subject) + require.Empty(rc.UserID) + require.Equal(ac.OrgID, rc.OrgID) + + // Verify relative nbf and exp claims of access and refresh tokens + require.True(ac.IssuedAt.Equal(rc.IssuedAt.Time), "access and refresh tokens do not have same iss timestamp") + require.Equal(45*time.Minute, rc.NotBefore.Sub(ac.IssuedAt.Time), "refresh token nbf is not 45 minutes after access token iss") + require.Equal(15*time.Minute, ac.ExpiresAt.Sub(rc.NotBefore.Time), "refresh token active does not overlap active token active by 15 minutes") + require.Equal(60*time.Minute, rc.ExpiresAt.Sub(ac.ExpiresAt.Time), "refresh token does not expire 1 hour after access token") + + // Sign the access token + atks, err := tm.Sign(accessToken) + require.NoError(err, "could not sign access token") + + // Sign the refresh token + rtks, err := tm.Sign(refreshToken) + require.NoError(err, "could not sign refresh token") + require.NotEqual(atks, rtks, "identical access and refresh tokens") + + // Validate the access token + _, err = tm.Verify(atks) + require.NoError(err, "could not validate access token") + + // Validate the refresh token (should be invalid because of not before in the future) + _, err = tm.Verify(rtks) + require.Error(err, "refresh token is valid?") +} + +func (s *TokenTestSuite) TestValidTokens() { + require := s.Require() + tm, err := tokens.New(s.conf) + require.NoError(err, "could not initialize token manager") + + // Default creds + creds := &tokens.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "01H6PGFB4T34D4WWEXQMAGJNMK", + }, + UserID: "Rusty Shackleford", + OrgID: "01H6PGFG71N0AFEVTK3NJB71T9", + } + + // TODO: add validation steps and test + _, err = tm.CreateAccessToken(creds) + require.NoError(err) +} + +func (s *TokenTestSuite) TestInvalidTokens() { + // Create the token manager + require := s.Require() + tm, err := tokens.New(s.conf) + require.NoError(err, "could not initialize token manager") + + // Manually create a token to validate with the token manager + now := time.Now() + claims := &tokens.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: ulids.New().String(), // id not validated + Subject: "01H6PGFB4T34D4WWEXQMAGJNMK", // correct subject + Audience: jwt.ClaimStrings{"http://foo.example.com"}, // wrong audience + IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Hour)), // iat not validated + NotBefore: jwt.NewNumericDate(now.Add(15 * time.Minute)), // nbf is validated and is after now + ExpiresAt: jwt.NewNumericDate(now.Add(-30 * time.Minute)), // exp is validated and is before now + }, + UserID: "Rusty Shackleford", + OrgID: "01H6PGFG71N0AFEVTK3NJB71T9", + } + + // Test validation signed with wrong kid + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = "01GE63H600NKHE7B8Y7MHW1VGV" + badkey, err := rsa.GenerateKey(rand.Reader, 1024) //nolint:gosec + require.NoError(err, "could not generate bad rsa keys") + tks, err := token.SignedString(badkey) + require.NoError(err, "could not sign token with bad kid") + + _, err = tm.Verify(tks) + require.EqualError(err, "token is unverifiable: error while executing keyfunc: unknown signing key") + + // Test validation signed with good kid but wrong key + token.Header["kid"] = "01GE62EXXR0X0561XD53RDFBQJ" + tks, err = token.SignedString(badkey) + require.NoError(err, "could not sign token with bad keys and good kid") + + _, err = tm.Verify(tks) + require.EqualError(err, "token signature is invalid: crypto/rsa: verification error") + + // Test time-based validation: nbf + tks, err = tm.Sign(token) + require.NoError(err, "could not sign token with good keys") + + _, err = tm.Verify(tks) + require.EqualError(err, "token has invalid claims: token is expired, token is not valid yet") + + // Test time-based validation: exp + claims.NotBefore = jwt.NewNumericDate(now.Add(-1 * time.Hour)) + tks, err = tm.Sign(jwt.NewWithClaims(jwt.SigningMethodRS256, claims)) // nolint + require.NoError(err, "could not sign token with good keys") + + // Test audience verification + claims.ExpiresAt = jwt.NewNumericDate(now.Add(1 * time.Hour)) + tks, err = tm.Sign(jwt.NewWithClaims(jwt.SigningMethodRS256, claims)) + require.NoError(err, "could not sign token with good keys") + + _, err = tm.Verify(tks) + require.EqualError(err, "token has invalid audience") + + // Token is finally valid + claims.Audience = jwt.ClaimStrings{"http://localhost:3000"} + claims.Issuer = "http://localhost:3001" + tks, err = tm.Sign(jwt.NewWithClaims(jwt.SigningMethodRS256, claims)) + require.NoError(err, "could not sign token with good keys") + _, err = tm.Verify(tks) + require.NoError(err, "claims are still not valid") +} + +// Test that a token signed with an old cert can still be verified - this also tests that the correct signing key is required. +func (s *TokenTestSuite) TestKeyRotation() { + require := s.Require() + + // Create the "old token manager" + conf := tokens.Config{ + Keys: map[string]string{ + "01GE6191AQTGMCJ9BN0QC3CCVG": "testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem", + }, + Audience: audience, + Issuer: issuer, + AccessDuration: 1 * time.Hour, + RefreshDuration: 2 * time.Hour, + RefreshOverlap: -15 * time.Minute, + } + + oldTM, err := tokens.New(conf) + require.NoError(err, "could not initialize old token manager") + + // Create the "new" token manager with the new key + newTM, err := tokens.New(s.conf) + require.NoError(err, "could not initialize new token manager") + + // Create a valid token with the "old token manager" + token, err := oldTM.CreateAccessToken(&tokens.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "01H6PGFB4T34D4WWEXQMAGJNMK", + }, + UserID: "Rusty Shackleford", + OrgID: "01H6PGFG71N0AFEVTK3NJB71T9", + }) + require.NoError(err) + + tks, err := oldTM.Sign(token) + require.NoError(err) + + // Validate token with "new token manager" + _, err = newTM.Verify(tks) + require.NoError(err) + + // A token created by the "new token manager" should not be verified by the old one + tks, err = newTM.Sign(token) + require.NoError(err) + + _, err = oldTM.Verify(tks) + require.Error(err) +} + +// Test that a token can be parsed even if it is expired. This is necessary to parse +// access tokens in order to use a refresh token to extract the claims +func (s *TokenTestSuite) TestParseExpiredToken() { + require := s.Require() + tm, err := tokens.New(s.conf) + require.NoError(err, "could not initialize token manager") + + // Default creds + creds := &tokens.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "01H6PGFB4T34D4WWEXQMAGJNMK", + }, + UserID: "Rusty Shackleford", + OrgID: "01H6PGFG71N0AFEVTK3NJB71T9", + } + + accessToken, err := tm.CreateAccessToken(creds) + require.NoError(err, "could not create access token from claims") + require.IsType(&tokens.Claims{}, accessToken.Claims) + + // Modify claims to be expired + claims := accessToken.Claims.(*tokens.Claims) + claims.IssuedAt = jwt.NewNumericDate(claims.IssuedAt.Add(-24 * time.Hour)) + claims.ExpiresAt = jwt.NewNumericDate(claims.ExpiresAt.Add(-24 * time.Hour)) + claims.NotBefore = jwt.NewNumericDate(claims.NotBefore.Add(-24 * time.Hour)) + accessToken.Claims = claims + + // Create signed token + tks, err := tm.Sign(accessToken) + require.NoError(err, "could not create expired access token from claims") + + // Ensure that verification fails; claims are invalid + pclaims, err := tm.Verify(tks) + require.Error(err, "expired token was somehow validated?") + require.Empty(pclaims, "verify returned claims even after error") + + // Parse token without verifying claims but verifying the signature + pclaims, err = tm.Parse(tks) + require.NoError(err, "claims were validated in parse") + require.NotEmpty(pclaims, "parsing returned empty claims without error") + + // Check claims + require.Equal(claims.ID, pclaims.ID) + require.Equal(claims.ExpiresAt, pclaims.ExpiresAt) + require.Equal(creds.UserID, claims.UserID) + + // Ensure signature is still validated on parse + tks += "abcdefg" + claims, err = tm.Parse(tks) + require.Error(err, "claims were parsed with bad signature") + require.Empty(claims, "bad signature token returned non-empty claims") +} + +// Execute suite as a go test +func TestTokenTestSuite(t *testing.T) { + suite.Run(t, new(TokenTestSuite)) +} + +func TestParseUnverifiedTokenClaims(t *testing.T) { + claims, err := tokens.ParseUnverifiedTokenClaims(accessToken) + require.NoError(t, err, "should not be able to parse a bad token") + require.NotEmpty(t, claims, "should not return empty claims") + + // Should return an error when a bad token is parsed. + _, err = tokens.ParseUnverifiedTokenClaims("notarealtoken") + require.Error(t, err, "should not be able to parse a bad token") +} diff --git a/tokens/urltokens.go b/tokens/urltokens.go new file mode 100644 index 0000000..1b70d4e --- /dev/null +++ b/tokens/urltokens.go @@ -0,0 +1,292 @@ +package tokens + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "time" + + "github.com/oklog/ulid/v2" + "github.com/vmihailenco/msgpack/v5" + + "github.com/theopenlane/utils/ulids" +) + +const ( + nonceLength = 64 + keyLength = 64 + expirationDays = 7 + resetTokenExpirationMinutes = 15 + inviteExpirationDays = 14 +) + +// NewVerificationToken creates a token struct from an email address that expires +// in 7 days +func NewVerificationToken(email string) (token *VerificationToken, err error) { + if email == "" { + return nil, ErrMissingEmail + } + + token = &VerificationToken{ + Email: email, + } + + if token.SigningInfo, err = NewSigningInfo(time.Hour * 24 * expirationDays); err != nil { + return nil, err + } + + return token, nil +} + +// VerificationToken packages an email address with random data and an expiration +// time so that it can be serialized and hashed into a token which can be sent to users +type VerificationToken struct { + Email string `msgpack:"email"` + SigningInfo +} + +// Sign creates a base64 encoded string from the token data so that it can be sent to +// users as part of a URL. The returned secret should be stored in the database so that +// the string can be recomputed when verifying a user provided token. +func (t *VerificationToken) Sign() (string, []byte, error) { + data, err := msgpack.Marshal(t) + if err != nil { + return "", nil, err + } + + return t.signData(data) +} + +// Verify checks that a token was signed with the secret and is not expired +func (t *VerificationToken) Verify(signature string, secret []byte) (err error) { + if t.Email == "" { + return ErrTokenMissingEmail + } + + if t.IsExpired() { + return ErrTokenExpired + } + + if len(secret) != nonceLength+keyLength { + return ErrInvalidSecret + } + + // Serialize the struct with the nonce from the secret + t.Nonce = secret[0:nonceLength] + + var data []byte + + if data, err = msgpack.Marshal(t); err != nil { + return err + } + + return t.verifyData(data, signature, secret) +} + +// NewResetToken creates a token struct from a user ID that expires in 15 minutes +func NewResetToken(id ulid.ULID) (token *ResetToken, err error) { + if ulids.IsZero(id) { + return nil, ErrMissingUserID + } + + token = &ResetToken{ + UserID: id, + } + + if token.SigningInfo, err = NewSigningInfo(time.Minute * resetTokenExpirationMinutes); err != nil { + return nil, err + } + + return token, nil +} + +// ResetToken packages a user ID with random data and an expiration time so that it can +// be serialized and hashed into a token which can be sent to users +type ResetToken struct { + UserID ulid.ULID `msgpack:"user_id"` + SigningInfo +} + +// Sign creates a base64 encoded string from the token data so that it can be sent to +// users as part of a URL. The returned secret should be stored in the database so that +// the string can be recomputed when verifying a user provided token +func (t *ResetToken) Sign() (string, []byte, error) { + data, err := msgpack.Marshal(t) + if err != nil { + return "", nil, err + } + + return t.signData(data) +} + +// Verify checks that a token was signed with the secret and is not expired +func (t *ResetToken) Verify(signature string, secret []byte) (err error) { + if ulids.IsZero(t.UserID) { + return ErrTokenMissingUserID + } + + if t.IsExpired() { + return ErrTokenExpired + } + + if len(secret) != nonceLength+keyLength { + return ErrInvalidSecret + } + + // Serialize the struct with the nonce from the secret + t.Nonce = secret[0:nonceLength] + + var data []byte + + if data, err = msgpack.Marshal(t); err != nil { + return err + } + + return t.verifyData(data, signature, secret) +} + +// NewSigningInfo creates new signing info with a time expiration +func NewSigningInfo(expires time.Duration) (info SigningInfo, err error) { + if expires == 0 { + return info, ErrExpirationIsRequired + } + + info = SigningInfo{ + ExpiresAt: time.Now().UTC().Add(expires).Truncate(time.Microsecond), + Nonce: make([]byte, nonceLength), + } + + if _, err = rand.Read(info.Nonce); err != nil { + return info, ErrFailedSigning + } + + return info, nil +} + +// SigningInfo contains an expiration time and a nonce that is used to sign the token +type SigningInfo struct { + ExpiresAt time.Time `msgpack:"expires_at"` + Nonce []byte `msgpack:"nonce"` +} + +func (d SigningInfo) IsExpired() bool { + return d.ExpiresAt.Before(time.Now()) +} + +// Create a signature from raw data and a nonce. The resulting signature is safe to be used in a URL +func (d SigningInfo) signData(data []byte) (_ string, secret []byte, err error) { + // Compute hash with a random 64 byte key + key := make([]byte, keyLength) + if _, err = rand.Read(key); err != nil { + return "", nil, err + } + + mac := hmac.New(sha256.New, key) + if _, err = mac.Write(data); err != nil { + return "", nil, err + } + + // Include the nonce with the key so that the token can be reconstructed later + secret = make([]byte, nonceLength+keyLength) + copy(secret[0:nonceLength], d.Nonce) + copy(secret[nonceLength:], key) + + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), secret, nil +} + +// Verify data using the signature and secret +func (d SigningInfo) verifyData(data []byte, signature string, secret []byte) (err error) { + // Compute hash to verify the user token + mac := hmac.New(sha256.New, secret[nonceLength:]) + if _, err = mac.Write(data); err != nil { + return err + } + + // Decode the user token + var token []byte + + if token, err = base64.RawURLEncoding.DecodeString(signature); err != nil { + return err + } + + // Check if the recomputed token matches the user token + if !hmac.Equal(mac.Sum(nil), token) { + return ErrTokenInvalid + } + + return nil +} + +// NewOrgInvitationToken creates a token struct from an email address that expires +// in 14 days +func NewOrgInvitationToken(email string, orgID ulid.ULID) (token *OrgInviteToken, err error) { + if email == "" { + return nil, ErrInviteTokenMissingEmail + } + + if ulids.IsZero(orgID) { + return nil, ErrInviteTokenMissingOrgID + } + + token = &OrgInviteToken{ + Email: email, + OrgID: orgID, + } + + if token.SigningInfo, err = NewSigningInfo(time.Hour * 24 * inviteExpirationDays); err != nil { + return nil, err + } + + return token, nil +} + +// OrgInviteToken packages an email address with random data and an expiration +// time so that it can be serialized and hashed into a token which can be sent to users +type OrgInviteToken struct { + Email string `msgpack:"email"` + OrgID ulid.ULID `msgpack:"organization_id"` + SigningInfo +} + +// Sign creates a base64 encoded string from the token data so that it can be sent to +// users as part of a URL. The returned secret should be stored in the database so that +// the string can be recomputed when verifying a user provided token. +func (t *OrgInviteToken) Sign() (string, []byte, error) { + data, err := msgpack.Marshal(t) + if err != nil { + return "", nil, err + } + + return t.signData(data) +} + +// Verify checks that a token was signed with the secret and is not expired +func (t *OrgInviteToken) Verify(signature string, secret []byte) (err error) { + if t.Email == "" { + return ErrInviteTokenMissingEmail + } + + if ulids.IsZero(t.OrgID) { + return ErrInviteTokenMissingOrgID + } + + if t.IsExpired() { + return ErrTokenExpired + } + + if len(secret) != nonceLength+keyLength { + return ErrInvalidSecret + } + + // Serialize the struct with the nonce from the secret + t.Nonce = secret[0:nonceLength] + + var data []byte + + if data, err = msgpack.Marshal(t); err != nil { + return err + } + + return t.verifyData(data, signature, secret) +} diff --git a/tokens/urltokens_test.go b/tokens/urltokens_test.go new file mode 100644 index 0000000..539ae85 --- /dev/null +++ b/tokens/urltokens_test.go @@ -0,0 +1,261 @@ +package tokens_test + +import ( + "bytes" + "testing" + "time" + + "github.com/stretchr/testify/require" + + ulids "github.com/theopenlane/utils/ulids" + + "github.com/theopenlane/core/pkg/tokens" +) + +var rusty = "rusty.shackleford@gmail.com" + +func TestVerificationToken(t *testing.T) { + // Test that the verification token is created correctly + token, err := tokens.NewVerificationToken(rusty) + require.NoError(t, err, "could not create verification token") + require.Equal(t, rusty, token.Email) + require.True(t, token.ExpiresAt.After(time.Now())) + require.Len(t, token.Nonce, 64) + + // Test signing a token + signature, secret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + require.NotEmpty(t, signature) + require.Len(t, secret, 128) + require.True(t, bytes.HasPrefix(secret, token.Nonce)) + + // Signing again should produce a different signature + differentSig, differentSecret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + require.NotEqual(t, signature, differentSig, "expected different signatures") + require.NotEqual(t, secret, differentSecret, "expected different secrets") + + // Verification should fail if the token is missing an email address + verify := &tokens.VerificationToken{ + SigningInfo: tokens.SigningInfo{ + ExpiresAt: time.Now().AddDate(0, 0, 7), + }, + } + require.ErrorIs(t, verify.Verify(signature, secret), tokens.ErrTokenMissingEmail, "expected error when token is missing email address") + + // Verification should fail if the token is expired + verify.Email = rusty + verify.ExpiresAt = time.Now().AddDate(0, 0, -1) + require.ErrorIs(t, verify.Verify(signature, secret), tokens.ErrTokenExpired, "expected error when token is expired") + + // Verification should fail if the email is different + verify.Email = "sfunk@gmail.com" + verify.ExpiresAt = token.ExpiresAt + require.ErrorIs(t, verify.Verify(signature, secret), tokens.ErrTokenInvalid, "expected error when email is different") + + // Verification should fail if the signature is not decodable + verify.Email = rusty + require.Error(t, verify.Verify("^&**(", secret), "expected error when signature is not decodable") + + // Verification should fail if the signature was created with a different secret + require.ErrorIs(t, verify.Verify(differentSig, secret), tokens.ErrTokenInvalid, "expected error when signature was created with a different secret") + + // Should error if the secret has the wrong length + require.ErrorIs(t, verify.Verify(signature, nil), tokens.ErrInvalidSecret, "expected error when secret is nil") + require.ErrorIs(t, verify.Verify(signature, []byte("wronglength")), tokens.ErrInvalidSecret, "expected error when secret is the wrong length") + + // Verification should fail if the wrong secret is used + require.ErrorIs(t, verify.Verify(signature, differentSecret), tokens.ErrTokenInvalid, "expected error when wrong secret is used") + + // Successful verification + require.NoError(t, verify.Verify(signature, secret), "expected successful verification") +} + +func TestResetToken(t *testing.T) { + t.Run("Valid Reset Token", func(t *testing.T) { + // Test that the reset token is created correctly + id := ulids.New() + token, err := tokens.NewResetToken(id) + require.NoError(t, err, "could not create reset token") + + // Test signing a token + signature, secret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + + // Signing again should produce a different signature + differentSig, differentSecret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + require.NotEqual(t, signature, differentSig, "expected different signatures") + require.NotEqual(t, secret, differentSecret, "expected different secrets") + + // Should be able to verify the token + require.NoError(t, token.Verify(signature, secret), "expected successful verification") + }) + + t.Run("Missing ID", func(t *testing.T) { + // Should fail to create a token without an ID + _, err := tokens.NewResetToken(ulids.Null) + require.ErrorIs(t, err, tokens.ErrMissingUserID, "expected error when token is missing ID") + }) + + t.Run("Token Missing User ID", func(t *testing.T) { + // Token with missing user ID should be an error + token := &tokens.ResetToken{} + require.ErrorIs(t, token.Verify("", nil), tokens.ErrTokenMissingUserID, "expected error when token is missing ID") + }) + + t.Run("Token Expired", func(t *testing.T) { + // Token that is expired should be an error + token := &tokens.ResetToken{ + SigningInfo: tokens.SigningInfo{ + ExpiresAt: time.Now().AddDate(0, 0, -1), + }, + UserID: ulids.New(), + } + require.ErrorIs(t, token.Verify("", nil), tokens.ErrTokenExpired, "expected error when token is expired") + }) + + t.Run("Wrong User ID", func(t *testing.T) { + // Sign a valid token + token, err := tokens.NewResetToken(ulids.New()) + require.NoError(t, err, "could not create reset token") + signature, secret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + + // Verification should fail if the user ID is different + token.UserID = ulids.New() + require.ErrorIs(t, token.Verify(signature, secret), tokens.ErrTokenInvalid, "expected error when user ID is different") + }) + + t.Run("Invalid Signature", func(t *testing.T) { + // Sign a valid token + token, err := tokens.NewResetToken(ulids.New()) + require.NoError(t, err, "could not create reset token") + _, secret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + + // Verification should fail if the signature is not decodable + require.Error(t, token.Verify("^&**(", secret), "expected error when signature is not decodable") + + // Verification should fail if the signature was created with a different secret + otherToken, err := tokens.NewResetToken(token.UserID) + require.NoError(t, err, "could not create reset token") + otherSig, _, err := otherToken.Sign() + require.NoError(t, err, "failed to sign token") + require.ErrorIs(t, token.Verify(otherSig, secret), tokens.ErrTokenInvalid, "expected error when signature was created with a different secret") + }) + + t.Run("Invalid Secret", func(t *testing.T) { + // Sign a valid token + token, err := tokens.NewResetToken(ulids.New()) + require.NoError(t, err, "could not create reset token") + signature, _, err := token.Sign() + require.NoError(t, err, "failed to sign token") + + // Should error if the secret has the wrong length + require.ErrorIs(t, token.Verify(signature, nil), tokens.ErrInvalidSecret, "expected error when secret is nil") + require.ErrorIs(t, token.Verify(signature, []byte("wronglength")), tokens.ErrInvalidSecret, "expected error when secret is the wrong length") + + // Verification should fail if the wrong secret is used + otherToken, err := tokens.NewResetToken(token.UserID) + require.NoError(t, err, "could not create reset token") + _, otherSecret, err := otherToken.Sign() + require.NoError(t, err, "failed to sign token") + require.ErrorIs(t, token.Verify(signature, otherSecret), tokens.ErrTokenInvalid, "expected error when wrong secret is used") + }) +} + +func TestInviteToken(t *testing.T) { + t.Run("Valid Reset Token", func(t *testing.T) { + // Test that the reset token is created correctly + orgID := ulids.New() + token, err := tokens.NewOrgInvitationToken(rusty, orgID) + require.NoError(t, err, "could not create reset token") + + // Test signing a token + signature, secret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + + // Signing again should produce a different signature + differentSig, differentSecret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + require.NotEqual(t, signature, differentSig, "expected different signatures") + require.NotEqual(t, secret, differentSecret, "expected different secrets") + + // Should be able to verify the token + require.NoError(t, token.Verify(signature, secret), "expected successful verification") + }) + + t.Run("Missing ID", func(t *testing.T) { + // Should fail to create a token without an ID + _, err := tokens.NewOrgInvitationToken(rusty, ulids.Null) + require.ErrorIs(t, err, tokens.ErrInviteTokenMissingOrgID, "invite token is missing org id") + }) + + t.Run("Missing Email", func(t *testing.T) { + // Should fail to create a token without an ID + _, err := tokens.NewOrgInvitationToken("", ulids.New()) + require.ErrorIs(t, err, tokens.ErrInviteTokenMissingEmail, "invite token is missing email") + }) + + t.Run("Token Expired", func(t *testing.T) { + // Token that is expired should be an error + token := &tokens.OrgInviteToken{ + SigningInfo: tokens.SigningInfo{ + ExpiresAt: time.Now().AddDate(0, 0, -1), + }, + OrgID: ulids.New(), + Email: rusty, + } + require.ErrorIs(t, token.Verify("", nil), tokens.ErrTokenExpired, "expected error when token is expired") + }) + + t.Run("Wrong Org ID", func(t *testing.T) { + // Sign a valid token + token, err := tokens.NewOrgInvitationToken(rusty, ulids.New()) + require.NoError(t, err, "could not create reset token") + signature, secret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + + // Verification should fail if the user ID is different + token.OrgID = ulids.New() + require.ErrorIs(t, token.Verify(signature, secret), tokens.ErrTokenInvalid, "expected error when user ID is different") + }) + + t.Run("Invalid Signature", func(t *testing.T) { + // Sign a valid token + token, err := tokens.NewOrgInvitationToken(rusty, ulids.New()) + require.NoError(t, err, "could not create reset token") + _, secret, err := token.Sign() + require.NoError(t, err, "failed to sign token") + + // Verification should fail if the signature is not decodable + require.Error(t, token.Verify("^&**(", secret), "expected error when signature is not decodable") + + // Verification should fail if the signature was created with a different secret + otherToken, err := tokens.NewOrgInvitationToken(rusty, token.OrgID) + require.NoError(t, err, "could not create reset token") + otherSig, _, err := otherToken.Sign() + require.NoError(t, err, "failed to sign token") + require.ErrorIs(t, token.Verify(otherSig, secret), tokens.ErrTokenInvalid, "expected error when signature was created with a different secret") + }) + + t.Run("Invalid Secret", func(t *testing.T) { + // Sign a valid token + token, err := tokens.NewOrgInvitationToken(rusty, ulids.New()) + require.NoError(t, err, "could not create reset token") + signature, _, err := token.Sign() + require.NoError(t, err, "failed to sign token") + + // Should error if the secret has the wrong length + require.ErrorIs(t, token.Verify(signature, nil), tokens.ErrInvalidSecret, "expected error when secret is nil") + require.ErrorIs(t, token.Verify(signature, []byte("wronglength")), tokens.ErrInvalidSecret, "expected error when secret is the wrong length") + + // Verification should fail if the wrong secret is used + otherToken, err := tokens.NewOrgInvitationToken(rusty, token.OrgID) + require.NoError(t, err, "could not create reset token") + _, otherSecret, err := otherToken.Sign() + require.NoError(t, err, "failed to sign token") + require.ErrorIs(t, token.Verify(signature, otherSecret), tokens.ErrTokenInvalid, "expected error when wrong secret is used") + }) +} diff --git a/tokens/validator.go b/tokens/validator.go new file mode 100644 index 0000000..d3820ae --- /dev/null +++ b/tokens/validator.go @@ -0,0 +1,107 @@ +package tokens + +import ( + "crypto/subtle" + + "github.com/golang-jwt/jwt/v5" +) + +// Validator are able to verify that access and refresh tokens were issued by +// OpenLane and that their claims are valid (e.g. not expired). +type Validator interface { + // Verify an access or a refresh token after parsing and return its claims + Verify(tks string) (claims *Claims, err error) + + // Parse an access or refresh token without verifying claims (e.g. to check an expired token) + Parse(tks string) (claims *Claims, err error) +} + +// validator implements the Validator interface, allowing structs in this package to +// embed the validation code base and supply their own keyFunc; unifying functionality +type validator struct { + audience string + issuer string + keyFunc jwt.Keyfunc +} + +// Verify an access or a refresh token after parsing and return its claims. +func (v *validator) Verify(tks string) (claims *Claims, err error) { + var token *jwt.Token + + if token, err = jwt.ParseWithClaims(tks, &Claims{}, v.keyFunc); err != nil { + return nil, err + } + + var ok bool + + if claims, ok = token.Claims.(*Claims); ok && token.Valid { + if !claims.VerifyAudience(v.audience, true) { + return nil, ErrTokenInvalidAudience + } + + if !claims.VerifyIssuer(v.issuer, true) { + return nil, ErrTokenInvalidIssuer + } + + return claims, nil + } + + return nil, ErrTokenInvalidClaims +} + +// Parse an access or refresh token verifying its signature but without verifying its +// claims. This ensures that valid JWT tokens are still accepted but claims can be +// handled on a case-by-case basis; for example by validating an expired access token +// during reauthentication +func (v *validator) Parse(tks string) (claims *Claims, err error) { + method := GetAlgorithms() + parser := jwt.NewParser(jwt.WithValidMethods(method), jwt.WithoutClaimsValidation()) + claims = &Claims{} + + if _, err = parser.ParseWithClaims(tks, claims, v.keyFunc); err != nil { + return nil, err + } + + return claims, nil +} + +func (c *Claims) VerifyAudience(cmp string, req bool) bool { + return verifyAud(c.Audience, cmp, req) +} + +func (c *Claims) VerifyIssuer(cmp string, req bool) bool { + return verifyIss(c.Issuer, cmp, req) +} + +func verifyIss(iss string, cmp string, required bool) bool { + if iss == "" { + return !required + } + + return subtle.ConstantTimeCompare([]byte(iss), []byte(cmp)) != 0 +} + +func verifyAud(aud []string, cmp string, required bool) bool { + if len(aud) == 0 { + return !required + } + // use a var here to keep constant time compare when looping over a number of claims + result := false + + var stringClaims string + + for _, a := range aud { + if subtle.ConstantTimeCompare([]byte(a), []byte(cmp)) != 0 { + result = true + } + + stringClaims += a + } + + // case where "" is sent in one or many aud claims + if len(stringClaims) == 0 { + return !required + } + + return result +} diff --git a/totp/README.md b/totp/README.md new file mode 100644 index 0000000..ee8f997 --- /dev/null +++ b/totp/README.md @@ -0,0 +1,39 @@ +## `totp` Supports: + +* Generating QR Code images for easy user enrollment +* Time-based One-time Password Algorithm (TOTP) (RFC 6238): Time based OTP, the most commonly used method +* HMAC-based One-time Password Algorithm (HOTP) (RFC 4226): Counter based OTP, which TOTP is based upon +* Generation and Validation of codes for either algorithm + +## Implementing TOTP: + +### User Enrollment + +For an example of a working enrollment work flow, [GitHub has documented theirs](https://help.github.com/articles/configuring-two-factor-authentication-via-a-totp-mobile-app/ +), but the basics are: + +1. Generate new TOTP Key for a User. `key,_ := totp.Generate(...)`. +1. Display the Key's Secret and QR-Code for the User. `key.Secret()` and `key.Image(...)`. +1. Test that the user can successfully use their TOTP. `totp.Validate(...)`. +1. Store TOTP Secret for the User in your backend. `key.Secret()` +1. Provide the user with "recovery codes". (See Recovery Codes bellow) + +### Code Generation + +* In either TOTP or HOTP cases, use the `GenerateCode` function and a counter or + `time.Time` struct to generate a valid code compatible with most implementations. +* For uncommon or custom settings, or to catch unlikely errors, use `GenerateCodeCustom` + in either module. + +### Validation + +1. Prompt and validate User's password as normal. +1. If the user has TOTP enabled, prompt for TOTP passcode. +1. Retrieve the User's TOTP Secret from your backend. +1. Validate the user's passcode. `totp.Validate(...)` + + +### Recovery Codes + +When a user loses access to their TOTP device, they would no longer have access to their account. Because TOTPs are often configured on mobile devices that can be lost, stolen or damaged, this is a common problem. For this reason many providers give their users "backup codes" or "recovery codes". These are a set of one time use codes that can be used instead of the TOTP. These can simply be randomly generated strings that you store in your backend. [Github's documentation provides an overview of the user experience]( +https://help.github.com/articles/downloading-your-two-factor-authentication-recovery-codes/). \ No newline at end of file diff --git a/totp/config.go b/totp/config.go new file mode 100644 index 0000000..0ded70d --- /dev/null +++ b/totp/config.go @@ -0,0 +1,86 @@ +package totp + +const ( + defaultLength = 6 + defaultRecoveryCodeCount = 16 + defaultRecoveryCodeLength = 10 + codePeriod = 30 +) + +type Config struct { + // Enabled is a flag to enable or disable the OTP service + Enabled bool `json:"enabled" koanf:"enabled" default:"true"` + // CodeLength is the length of the OTP code + CodeLength int `json:"codeLength" koanf:"codeLength" default:"6"` + // Issuer is the issuer for TOTP codes + Issuer string `json:"issuer" koanf:"issuer" default:""` + // WithRedis configures the service with a redis client + WithRedis bool `json:"redis" koanf:"redis" default:"true"` + // Secret stores a versioned secret key for cryptography functions + Secret string `json:"secret" koanf:"secret"` + // RecoveryCodeCount is the number of recovery codes to generate + RecoveryCodeCount int `json:"recoveryCodeCount" koanf:"recoveryCodeCount" default:"16"` + // RecoveryCodeLength is the length of a recovery code + RecoveryCodeLength int `json:"recoveryCodeLength" koanf:"recoveryCodeLength" default:"8"` +} + +// NewOTP returns a new OTP validator +func NewOTP(options ...ConfigOption) TOTPManager { + s := OTP{ + codeLength: defaultLength, + ttl: codePeriod, + recoveryCodeCount: defaultRecoveryCodeCount, + recoveryCodeLength: defaultRecoveryCodeLength, + } + + for _, opt := range options { + opt(&s) + } + + return &s +} + +// ConfigOption configures the validator +type ConfigOption func(*OTP) + +// WithCodeLength configures the service with a length for random code generation +func WithCodeLength(length int) ConfigOption { + return func(s *OTP) { + s.codeLength = length + } +} + +// WithIssuer configures the service with a TOTP issuing domain +func WithIssuer(issuer string) ConfigOption { + return func(s *OTP) { + s.issuer = issuer + } +} + +// WithRecoveryCodeCount configures the service with a number of recovery codes to generate +func WithRecoveryCodeCount(count int) ConfigOption { + return func(s *OTP) { + s.recoveryCodeCount = count + } +} + +// WithRecoveryCodeLength configures the service with the length of recovery codes to generate +func WithRecoveryCodeLength(length int) ConfigOption { + return func(s *OTP) { + s.recoveryCodeLength = length + } +} + +// WithSecret sets a new versioned Secret on the client +func WithSecret(x Secret) ConfigOption { + return func(s *OTP) { + s.secrets = append(s.secrets, x) + } +} + +// WithRedis configures the service with a redis client +func WithRedis(db otpRedis) ConfigOption { + return func(s *OTP) { + s.db = db + } +} diff --git a/totp/crypto.go b/totp/crypto.go new file mode 100644 index 0000000..476d2e8 --- /dev/null +++ b/totp/crypto.go @@ -0,0 +1,72 @@ +package totp + +import ( + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "strings" +) + +// Bytes returns securely generated random bytes +func Bytes(length int) ([]byte, error) { + b := make([]byte, length) + + if _, err := rand.Read(b); err != nil { + return nil, err + } + + return b, nil +} + +// BytesFromSample returns securely generated random bytes from a string sample +func BytesFromSample(length int, samples ...string) ([]byte, error) { + sample := strings.Join(samples, "") + if sample == "" { + sample = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + } + + bytes, err := Bytes(length) + if err != nil { + return nil, err + } + + for i, b := range bytes { + bytes[i] = sample[b%byte(len(sample))] + } + + return bytes, nil +} + +// String returns a securely generated random string from an optional sample +func String(length int, samples ...string) (string, error) { + b, err := BytesFromSample(length, samples...) + if err != nil { + return "", err + } + + return string(b), nil +} + +// StringB64 returns a securely generated random string +func StringB64(length int, samples ...string) (string, error) { + b, err := BytesFromSample(length, samples...) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(b), nil +} + +// OTPHash returns a sha512 hash of a string +func OTPHash(s string) (string, error) { + h := sha512.New() + + _, err := h.Write([]byte(s)) + if err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/totp/crypto_test.go b/totp/crypto_test.go new file mode 100644 index 0000000..d3edeed --- /dev/null +++ b/totp/crypto_test.go @@ -0,0 +1,47 @@ +package totp + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestString(t *testing.T) { + samples := "abcdefghijklmnopqrstuv" + + for i := 0; i <= 10; i++ { + ln := 50 + random, err := String(ln, samples) + require.NoError(t, err, "failed to generate random string") + + assert.Len(t, random, 50, "incorrect character count") + + for _, v := range random { + s := string(v) + assert.Contains(t, samples, s, "invalid character used in random string") + } + } +} + +func TestStringB64(t *testing.T) { + b64str, err := StringB64(50) + require.NoError(t, err, "failed to generate random string") + + _, err = base64.StdEncoding.DecodeString(b64str) + require.NoError(t, err, "failed to decode base64 encoded string") +} + +func TestOTPHash(t *testing.T) { + str := "the quick brown fox" + hash, err := OTPHash(str) + require.NoError(t, err, "error generating hash") + + assert.NotEqual(t, str, hash, "string not hashed") + + hash2, err := OTPHash(str) + require.NoError(t, err, "error generating hash") + + assert.Equal(t, hash, hash2, "hashes do not match") +} diff --git a/totp/doc.go b/totp/doc.go new file mode 100644 index 0000000..6548eb3 --- /dev/null +++ b/totp/doc.go @@ -0,0 +1,2 @@ +// Package totp provides code generation for TOTP (RFC 6238) and HOTP (RFC 4226) +package totp diff --git a/totp/errors.go b/totp/errors.go new file mode 100644 index 0000000..f106792 --- /dev/null +++ b/totp/errors.go @@ -0,0 +1,82 @@ +package totp + +import ( + "errors" + "fmt" +) + +// Error represents an error within OTP/TOTP domain +type Error interface { + Error() string + Message() string + Code() ErrCode +} + +// ErrCode is a machine readable code representing an error within the authenticator domain +type ErrCode string + +// ErrInvalidCode represents an error related to an invalid TOTP/OTP code +type ErrInvalidCode string + +func (e ErrInvalidCode) Code() ErrCode { return "invalid_code" } +func (e ErrInvalidCode) Error() string { return fmt.Sprintf("[%s] %s", e.Code(), string(e)) } +func (e ErrInvalidCode) Message() string { return string(e) } + +var ( + // ErrCannotDecodeOTPHash is an error representing a failure to decode an OTP hash + ErrCannotDecodeOTPHash = errors.New("cannot decode otp hash") + + // ErrInvalidOTPHashFormat is an error representing an invalid OTP hash format + ErrInvalidOTPHashFormat = errors.New("invalid otp hash format") + + // ErrFailedToHashCode is an error representing a failure to hash code + ErrFailedToHashCode = errors.New("failed to hash code") + + // ErrCipherTextTooShort is an error representing a ciphertext that is too short + ErrCipherTextTooShort = errors.New("ciphertext too short") + + // ErrFailedToCreateCipherBlock is an error representing a failure to create a cipher block + ErrFailedToCreateCipherBlock = errors.New("failed to create cipher block") + + // ErrCannotDecodeSecret is an error representing a failure to decode a secret + ErrCannotDecodeSecret = errors.New("cannot decode secret") + + // ErrCannotWriteSecret is an error representing a failure to write a secret + ErrCannotWriteSecret = errors.New("cannot write secret") + + // ErrFailedToDetermineSecretVersion is an error representing a failure to determine secret version + ErrFailedToDetermineSecretVersion = errors.New("failed to determine secret version") + + // ErrFailedToCreateCipherText is an error representing a failure to create cipher text + ErrFailedToCreateCipherText = errors.New("failed to create cipher text") + + // ErrNoSecretKeyForVersion is an error representing no secret key for version + ErrNoSecretKeyForVersion = errors.New("no secret key for version") + + // ErrNoSecretKey is an error representing no secret key + ErrNoSecretKey = errors.New("no secret key") + + // ErrFailedToValidateCode is an error representing a failure to validate code + ErrFailedToValidateCode = errors.New("failed to validate code") + + // ErrCodeIsNoLongerValid is an error representing a code that is no longer valid + ErrCodeIsNoLongerValid = errors.New("code is no longer valid") + + // ErrIncorrectCodeProvided is an error representing an incorrect code provided + ErrIncorrectCodeProvided = errors.New("incorrect code provided") + + // ErrCannotDecryptSecret is an error representing a failure to decrypt secret + ErrCannotDecryptSecret = errors.New("cannot decrypt secret") + + // ErrFailedToGetSecretForQR is an error representing a failure to get secret for qr + ErrFailedToGetSecretForQR = errors.New("failed to get secret for qr") + + // ErrFailedToGenerateSecret is an error representing a failure to generate secret + ErrFailedToGenerateSecret = errors.New("failed to generate secret") + + // ErrCannotHashOTPString is an error representing a failure to hash otp string + ErrCannotHashOTPString = errors.New("cannot hash otp string") + + // ErrCannotGenerateRandomString is an error representing a failure to generate random string + ErrCannotGenerateRandomString = errors.New("cannot generate random string") +) diff --git a/totp/manager.go b/totp/manager.go new file mode 100644 index 0000000..24a0897 --- /dev/null +++ b/totp/manager.go @@ -0,0 +1,139 @@ +package totp + +import ( + "context" + "database/sql" + "time" +) + +// TokenState represents a state of a JWT token. +// A token may represent an intermediary state prior +// to authorization (ex. TOTP code is required) +type TokenState string + +// DeliveryMethod represents a mechanism to send messages to users +type DeliveryMethod string + +// TFAOptions represents options a user may use to complete 2FA +type TFAOptions string + +// MessageType describes a classification of a Message +type MessageType string + +// Manager manages the protocol for SMS/Email 2FA codes and TOTP codes +type Manager struct { + TOTPManager TOTPManager +} + +const ( + // OTPEmail allows a user to complete TFA with an OTP code delivered via email + OTPEmail TFAOptions = "otp_email" + // OTPPhone allows a user to complete TFA with an OTP code delivered via phone + OTPPhone TFAOptions = "otp_phone" + // TOTP allows a user to complete TFA with a TOTP device or application + TOTP TFAOptions = "totp" + // Phone is a delivery method for text messages + Phone DeliveryMethod = "phone" + // Email is a delivery method for email + Email = "email" + // OTPAddress is a message containing an OTP code for contact verification + OTPAddress MessageType = "otp_address" + // OTPResend is a message containing an OTP code + OTPResend MessageType = "otp_resend" + // OTPLogin is a message containing an OTP code for login + OTPLogin MessageType = "otp_login" + // OTPSignup is a message containing an OTP code for signup + OTPSignup MessageType = "otp_signup" +) + +// User represents a user who is registered with the service +type User struct { + // ID is a unique ID for the user + ID string + // Phone number associated with the account + Phone sql.NullString + // Email address associated with the account + Email sql.NullString + // TFASecret is a a secret string used to generate 2FA TOTP codes + TFASecret string + // IsPhoneAllowed specifies a user may complete authentication by verifying an OTP code delivered through SMS + IsPhoneOTPAllowed bool + // IsEmailOTPAllowed specifies a user may complete authentication by verifying an OTP code delivered through email + IsEmailOTPAllowed bool + // IsTOTPAllowed specifies a user may complete authentication by verifying a TOTP code + IsTOTPAllowed bool +} + +// DefaultOTPDelivery returns the default OTP delivery method +func (u *User) DefaultOTPDelivery() DeliveryMethod { + if u.Email.String != "" { + return Email + } + + return Phone +} + +// DefaultName returns the default name for a user (email or phone) +func (u *User) DefaultName() string { + if u.Email.String != "" { + return u.Email.String + } + + return u.Phone.String +} + +// Token is a token that provides proof of User authentication +type Token struct { + // Phone is a User's phone number + Phone string `json:"phone_number"` + // CodeHash is the hash of a randomly generated code used + // to validate an OTP code and escalate the token to an + // authorized token + CodeHash string `json:"code,omitempty"` + // Code is the unhashed value of CodeHash. This value is + // not persisted and returned to the client outside of the JWT + // response through an alternative mechanism (e.g. Email). It is + // validated by ensuring the SHA512 hash of the value matches the + // CodeHash embedded in the token + Code string `json:"-"` + // TFAOptions represents available options a user may use to complete + // 2FA. + TFAOptions []TFAOptions `json:"tfa_options"` +} + +// Message is a message to be delivered to a user +type Message struct { + // Type describes the classification of a Message + Type MessageType + // Subject is a human readable subject describe the Message + Subject string + // Delivery type of the message (e.g. phone or email) + Delivery DeliveryMethod + // Vars contains key/value variables to populate + // templated content + Vars map[string]string + // Content of the message + Content string + // Delivery address of the user (e.g. phone or email) + Address string + // ExpiresAt is the latest time we can attempt delivery + ExpiresAt time.Time + // DeliveryAttempts is the total amount of delivery attempts made + DeliveryAttempts int +} + +// TOTPManager manages the protocol for SMS/Email 2FA codes and TOTP codes +type TOTPManager interface { + // TOTPQRString returns a URL string used for TOTP code generation + TOTPQRString(u *User) (string, error) + // TOTPSecret creates a TOTP secret for code generation + TOTPSecret(u *User) (string, error) + // OTPCode creates a random OTP code and hash + OTPCode(address string, method DeliveryMethod) (code, hash string, err error) + // ValidateOTP checks if a User email/sms delivered OTP code is valid + ValidateOTP(code, hash string) error + // ValidateTOTP checks if a User TOTP code is valid + ValidateTOTP(ctx context.Context, user *User, code string) error + // GenerateRecoveryCodes creates a set of recovery codes for a user + GenerateRecoveryCodes() []string +} diff --git a/totp/manager_test.go b/totp/manager_test.go new file mode 100644 index 0000000..a34a613 --- /dev/null +++ b/totp/manager_test.go @@ -0,0 +1,69 @@ +package totp_test + +import ( + "database/sql" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/theopenlane/utils/totp" +) + +func TestDefaultName(t *testing.T) { + userWithEmail := &totp.User{ + Email: sql.NullString{ + String: "jenny@example.com", + Valid: true, + }, + Phone: sql.NullString{ + String: "5558675309", + Valid: true, + }, + } + + userWithPhone := &totp.User{ + Email: sql.NullString{}, + Phone: sql.NullString{ + String: "5558675309", + Valid: true, + }, + } + + // Test when email is not empty + expectedName := "jenny@example.com" + actualName := userWithEmail.DefaultName() + require.Equal(t, expectedName, actualName, "DefaultName() returned incorrect name") + + // Test when email is empty + expectedName = "5558675309" + actualName = userWithPhone.DefaultName() + require.Equal(t, expectedName, actualName, "DefaultName() returned incorrect name") +} +func TestDefaultOTPDelivery(t *testing.T) { + userWithEmail := &totp.User{ + Email: sql.NullString{ + String: "jenny@example.com", + Valid: true, + }, + Phone: sql.NullString{ + String: "5558675309", + Valid: true, + }, + } + + userWithPhone := &totp.User{ + Email: sql.NullString{}, + Phone: sql.NullString{ + String: "5558675309", + Valid: true, + }, + } + + expectedDeliveryWithEmail := totp.DeliveryMethod("email") + actualDeliveryWithEmail := userWithEmail.DefaultOTPDelivery() + require.Equal(t, expectedDeliveryWithEmail, actualDeliveryWithEmail, "DefaultOTPDelivery() returned incorrect delivery method for user with email") + + expectedDeliveryWithPhone := totp.DeliveryMethod("phone") + actualDeliveryWithPhone := userWithPhone.DefaultOTPDelivery() + require.Equal(t, expectedDeliveryWithPhone, actualDeliveryWithPhone, "DefaultOTPDelivery() returned incorrect delivery method for user with phone") +} diff --git a/totp/testing/README.md b/totp/testing/README.md new file mode 100644 index 0000000..487647d --- /dev/null +++ b/totp/testing/README.md @@ -0,0 +1,3 @@ +# Testing TOTP + +Run `go run main.go` to start up a basic small server and embed the templates to test the code + QR generation and validation - just used as a basic testing server to show how the upstream TOTP package works and responds \ No newline at end of file diff --git a/totp/testing/api/totp_api.go b/totp/testing/api/totp_api.go new file mode 100644 index 0000000..c896f91 --- /dev/null +++ b/totp/testing/api/totp_api.go @@ -0,0 +1,7 @@ +package api + +import "net/http" + +func TOTP(w http.ResponseWriter, r *http.Request) { + +} diff --git a/totp/testing/main.go b/totp/testing/main.go new file mode 100644 index 0000000..2e0913a --- /dev/null +++ b/totp/testing/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/theopenlane/utils/totp/testing/views" +) + +const ( + BindIP = "0.0.0.0" + Port = ":3321" +) + +func main() { + u, _ := url.Parse("http://" + BindIP + Port) + fmt.Printf("Server Started: %v\n", u) + + Handlers() + http.ListenAndServe(Port, nil) +} + +func Handlers() { + http.Handle("/templates/", http.StripPrefix("/templates/", http.FileServer(http.Dir("./templates/")))) + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + + http.HandleFunc("/", views.GenerateTOTP) +} diff --git a/totp/testing/static/css/totp.css b/totp/testing/static/css/totp.css new file mode 100644 index 0000000..e69de29 diff --git a/totp/testing/static/js/totp.js b/totp/testing/static/js/totp.js new file mode 100644 index 0000000..2470a9b --- /dev/null +++ b/totp/testing/static/js/totp.js @@ -0,0 +1,93 @@ +var issuer = $("#name-text"); +var accountName = $("#email-text"); +var submitDets = $("#submit-details"); +var btnCont = $("#btn-key__generator"); +var totpCont = $("#totp-generate__container"); +var detailsCont = $(".details-container"); +var keyDetails = $(".input-for__key"); +var haveKeyDets = $("#key-input"); + +totpCont.hide(); +detailsCont.hide(); +keyDetails.hide(); + +function showKeyInput() { + keyDetails.show(); +} + +function displayDetails() { + detailsCont.show(); + btnCont.hide(); +} + +function submitDetails() { + $.ajax({ + method: "POST", + url: "/", + data: { + data_action: "GENERATE KEY", + issuer: issuer.val(), + accountName: accountName.val(), + }, + success: function () { + console.log("issuer: ", issuer.val()); + console.log("accountName: ", accountName.val()); + + issuer.css("display", "none"); + accountName.css("display", "none"); + submitDets.css("display", "none"); + btnCont.hide(); + }, + }); +} + +function showPrint() { + exportBtn.show(); +} + +function printQR() { + var dataCont = document.getElementById("printDV"); + dataCont.style.width = "100%"; + dataCont.style.height = "100%"; + + // Paper and able size + var opt = { + margin: 0.5, + filename: "QR_Code.pdf", + image: { type: "jpeg", quality: 1 }, + html2canvas: { scale: 1 }, + jsPDF: { + unit: "in", + format: "legal", + orientation: "portrait", + precision: "12", + }, + }; + + // Choose the timeManagement and pass it to html2pdf() function and call the save() on it to save as pdf + html2pdf().set(opt).from(dataCont).save(); +} + +function validateOTP() { + // Send a POST request to the server to validate the OTP + console.log("test", $("#otp-input").text() == $("#totp").val()); + if ($("#otp-input").text() == $("#totp").val()) { + $("#otp-status").text("TOTP code is valid!"); + } else { + $("#otp-status").text("Invalid TOTP code!"); + } +} + +function updateTimer() { + var now = new Date(); + var timeLeft = 30 - (now.getSeconds() % 30); + $("#timer").text("Code expires in \n" + timeLeft); + + if (now.getSeconds() % 30 === 0) { + // Wait 1 second and reload the page + setTimeout(function () { + location.reload(); + }, 5); + } +} +setInterval(updateTimer, 1000); diff --git a/totp/testing/templates/totp.html b/totp/testing/templates/totp.html new file mode 100644 index 0000000..30a5481 --- /dev/null +++ b/totp/testing/templates/totp.html @@ -0,0 +1,103 @@ + + + + + + + + + Generate TOTP + + + +

TOTP Generator

+ + + +
+
+ + + + +
+
+ +
+
+ + + + +
+
+ + {{ if .generateSecret }} +
+
+
+

Generated Key:

+

Secret: {{ .generateSecret }}

+ QR Code +
+
+ {{ end }} + +
+
+ + +
+
+ +
+ {{ if .generateTOTP }} +
+
+

Secret Key

+

{{ .key }}

+

QR Code

+ QR code +
+

TOTP

+

{{ .generateTOTP }}

+
+ +

Validation

+ +

+ + + {{ end }} +
+ + +
+ {{ if .haveTOTP }} +
+

Secret Key

+

{{ .genKey }}

+
+

TOTP

+

{{ .haveTOTP }}

+
+ +

Validation

+ +

+ + {{ end }} +
+ + + + + + + + + + + \ No newline at end of file diff --git a/totp/testing/views/totp_views.go b/totp/testing/views/totp_views.go new file mode 100644 index 0000000..7ced27d --- /dev/null +++ b/totp/testing/views/totp_views.go @@ -0,0 +1,91 @@ +package views + +import ( + "bytes" + "encoding/base64" + "fmt" + "html/template" + "image/png" + "net/http" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +var secretBase32 = "" +var qrCodeBase64 = "" + +func GenerateTOTP(w http.ResponseWriter, r *http.Request) { + tmpl := template.Must(template.ParseFiles("./templates/totp.html")) + context := map[string]interface{}{} + + dataAction := r.FormValue("data_action") + issuer := r.FormValue("issuer") + accountName := r.FormValue("accountName") + haveKey := r.FormValue("haveKey") + + if dataAction == "GENERATE KEY" { + fmt.Println("ISSUER: ", issuer) + fmt.Println("ACCOUNT NAME: ", accountName) + + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: issuer, + AccountName: accountName, + Period: 30, + SecretSize: 10, + Algorithm: otp.AlgorithmSHA256, + }) + CheckErr(w, err) + + secretBase32 = key.Secret() + qrCode, err := key.Image(200, 200) + CheckErr(w, err) + + qrCodeBuffer := new(bytes.Buffer) + err = png.Encode(qrCodeBuffer, qrCode) + CheckErr(w, err) + + qrCodeBase64 = base64.StdEncoding.EncodeToString(qrCodeBuffer.Bytes()) + context["generateSecret"] = key.Secret() + context["qrCode"] = qrCodeBase64 + } + + if dataAction == "GENERATE TOTP" { + totpCode, err := TOTPGenerator(secretBase32) + CheckErr(w, err) + + context["generateTOTP"] = totpCode + context["key"] = secretBase32 + context["qr"] = qrCodeBase64 + } + + if dataAction == "HAVE A KEY" { + totpCode, err := TOTPGenerator(haveKey) + CheckErr(w, err) + + context["haveTOTP"] = totpCode + context["genKey"] = haveKey + context["haveKey"] = secretBase32 + } + + tmpl.Execute(w, context) +} + +func TOTPGenerator(secret string) (string, error) { + key, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + return "", err + } + + return key, nil +} + +func CheckErr(w http.ResponseWriter, err error) { + if err != nil { + fmt.Println("Error:", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } +} diff --git a/totp/totp.go b/totp/totp.go new file mode 100644 index 0000000..b24ccc8 --- /dev/null +++ b/totp/totp.go @@ -0,0 +1,376 @@ +package totp + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "github.com/redis/go-redis/v9" +) + +const ( + // keyTTL is the expiration time for a key in redis + keyTTL = 30 * time.Second + // otpExpiration is the expiration time for an OTP code + otpExpiration = 5 * time.Minute + // numericCode is a string of numbers + numericCode = "0123456" + // alphanumericCode is a string of numbers and letters + alphanumericCode = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +) + +// otpRedos is a minimal interface for go-redis with OTP codes +type otpRedis interface { + Get(ctx context.Context, key string) *redis.StringCmd + Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd + Close() error +} + +// Secret stores a versioned secret key for cryptography functions +type Secret struct { + Version int + Key string +} + +// Hash contains a hash of a OTP code +type Hash struct { + CodeHash string `json:"code_hash"` + ExpiresAt int64 `json:"expires_at"` + Address string `json:"address"` + DeliveryMethod DeliveryMethod `json:"delivery_method"` +} + +// OTP is a credential validator for User OTP codes +type OTP struct { + // codeLength is the length of a randomly generated code + codeLength int + ttl int + issuer string + secrets []Secret + db otpRedis + recoveryCodeCount int + recoveryCodeLength int +} + +// OTPCode creates a random code and hash +func (o *OTP) OTPCode(address string, method DeliveryMethod) (code string, hash string, err error) { + c, err := String(o.codeLength, numericCode) + if err != nil { + return "", "", ErrCannotGenerateRandomString + } + + h, err := toOTPHash(c, address, method) + if err != nil { + return "", "", ErrCannotHashOTPString + } + + return c, h, nil +} + +// TOTPSecret assigns a TOTP secret for a user for use in code generation. +// TOTP secrets are encrypted by a pre-configured secret key and decrypted +// only during validation. Encrypted keys are versioned to assist with migrations +// and backwards compatibility in the event an older secret ever needs to +// be deprecated. +func (o *OTP) TOTPSecret(u *User) (string, error) { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: o.issuer, + AccountName: u.DefaultName(), + }) + if err != nil { + return "", ErrFailedToGenerateSecret + } + + encryptedKey, err := o.encrypt(key.Secret()) + if err != nil { + return "", ErrCannotDecryptSecret + } + + return encryptedKey, nil +} + +// TOTPQRString returns a string containing account details for TOTP code generation +func (o *OTP) TOTPQRString(u *User) (string, error) { + // otpauth://totp/TheOpenLane:matt@google.com?secret=JBSWY3DPEHPK3PXP&issuer=TheOpenLane + secret, err := o.decrypt(u.TFASecret) + if err != nil { + return "", ErrFailedToGetSecretForQR + } + + v := url.Values{} + v.Set("secret", secret) + v.Set("issuer", o.issuer) + v.Set("algorithm", otp.AlgorithmSHA1.String()) + v.Set("period", strconv.Itoa(o.ttl)) + v.Set("digits", "6") + otpauth := url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: "/" + o.issuer + ":" + u.DefaultName(), + RawQuery: v.Encode(), + } + + return otpauth.String(), nil +} + +// ValidateOTP checks if a User's OTP code is valid +// users can input secrets sent by SMS or email (needs to be implemented separately) +func (o *OTP) ValidateOTP(code string, hash string) error { + otp, err := FromOTPHash(hash) + if err != nil { + return err + } + + now := time.Now().Unix() + if now >= otp.ExpiresAt { + return ErrInvalidCode("code is expired") + } + + h, err := OTPHash(code) + if err != nil { + return ErrInvalidCode("code submission failed") + } + + if h != otp.CodeHash { + return ErrInvalidCode("incorrect code provided") + } + + return nil +} + +// ValidateTOTP checks if a User's TOTP is valid - first validate the TOTP against the user's secret key +// and then check if the code has been set in redis, indicating that it has been used in the past thirty seconds +// codes that have been validated are cached to prevent immediate reuse +func (o *OTP) ValidateTOTP(ctx context.Context, user *User, code string) error { + secret, err := o.decrypt(user.TFASecret) + if err != nil { + return ErrCannotDecryptSecret + } + + if !totp.Validate(code, secret) { + return ErrIncorrectCodeProvided + } + + key := fmt.Sprintf("%s_%s", user.ID, code) + + // Validated code has previously been used in the past thirty seconds + if err = o.db.Get(ctx, key).Err(); err == nil { + return ErrCodeIsNoLongerValid + } + + // No code found in redis, indicating the code is valid. Set it to the + // DB to prevent reuse + if errors.Is(err, redis.Nil) { + return o.db.Set(ctx, key, true, keyTTL).Err() + } + + return ErrFailedToValidateCode +} + +// latestSecret returns the most recent versioned secret key +func (o *OTP) latestSecret() (Secret, error) { + var secret Secret + + for _, s := range o.secrets { + if s.Version >= secret.Version { + secret = s + } + } + + if secret.Key == "" { + return secret, ErrNoSecretKey + } + + return secret, nil +} + +// secretByVersion returns a versioned secret key +func (o *OTP) secretByVersion(version int) (Secret, error) { + var secret Secret + + for _, s := range o.secrets { + if s.Version == version { + secret = s + break + } + } + + if secret.Key == "" { + return secret, ErrNoSecretKeyForVersion + } + + return secret, nil +} + +// encrypt encrypts a string using the most recent versioned secret key +// in this service and returns the value as a base64 encoded string +// with a versioning prefix +func (o *OTP) encrypt(s string) (string, error) { + secret, err := o.latestSecret() + if err != nil { + return "", err + } + + key := sha256.New() + + if _, err = key.Write([]byte(secret.Key)); err != nil { + return "", ErrCannotWriteSecret + } + + block, err := aes.NewCipher(key.Sum(nil)) + if err != nil { + return "", ErrFailedToCreateCipherBlock + } + + cipherText := make([]byte, aes.BlockSize+len(s)) + + iv := cipherText[:aes.BlockSize] + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", ErrFailedToCreateCipherText + } + + stream := cipher.NewCFBEncrypter(block, iv) // # spellcheck:off + stream.XORKeyStream(cipherText[aes.BlockSize:], []byte(s)) + + return fmt.Sprintf("%v:%s", + secret.Version, + base64.StdEncoding.EncodeToString(cipherText), + ), nil +} + +// decrypt decrypts an encrypted string using a versioned secret +func (o *OTP) decrypt(encryptedTxt string) (string, error) { + v := strings.Split(encryptedTxt, ":")[0] + encryptedTxt = strings.TrimPrefix(encryptedTxt, fmt.Sprintf("%s:", v)) + + version, err := strconv.Atoi(v) + if err != nil { + return "", ErrFailedToDetermineSecretVersion + } + + secret, err := o.secretByVersion(version) + if err != nil { + return "", err + } + + key := sha256.New() + + if _, err = key.Write([]byte(secret.Key)); err != nil { + return "", ErrCannotWriteSecret + } + + block, err := aes.NewCipher(key.Sum(nil)) + if err != nil { + return "", ErrFailedToCreateCipherBlock + } + + if len(encryptedTxt) < aes.BlockSize { + return "", ErrCipherTextTooShort + } + + decoded, err := base64.StdEncoding.DecodeString(encryptedTxt) + if err != nil { + return "", ErrCannotDecodeSecret + } + + iv := decoded[:aes.BlockSize] + decoded = decoded[aes.BlockSize:] + stream := cipher.NewCFBDecrypter(block, iv) + stream.XORKeyStream(decoded, decoded) + + return string(decoded), nil +} + +// toOTPHash creates a hash from a OTP code +func toOTPHash(code, address string, method DeliveryMethod) (string, error) { + codeHash, err := OTPHash(code) + if err != nil { + return "", ErrFailedToHashCode + } + + expiresAt := time.Now().Add(otpExpiration).Unix() + + hash := &Hash{ + CodeHash: codeHash, + Address: address, + DeliveryMethod: method, + ExpiresAt: expiresAt, + } + + b, err := json.Marshal(hash) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// FromOTPHash parses an OTP hash string to individual parts +func FromOTPHash(otpHash string) (*Hash, error) { + decoded, err := base64.RawURLEncoding.DecodeString(otpHash) + if err != nil { + return nil, ErrCannotDecodeOTPHash + } + + var o Hash + + if err = json.Unmarshal(decoded, &o); err != nil { + return nil, ErrInvalidOTPHashFormat + } + + return &o, nil +} + +// GenerateOTP generates a Time-Based One-Time Password (TOTP). +func GenerateOTP(secret string) (string, error) { + secretBytes := []byte(secret) + + // TODO: not from env vars + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: os.Getenv("ISSUER_NAME"), + AccountName: os.Getenv("ACCOUNT_NAME"), + Secret: secretBytes, + // You can customize the TOTP options as needed. + }) + if err != nil { + return "", err + } + + otpCode, err := totp.GenerateCode(key.Secret(), time.Now()) + if err != nil { + return "", err + } + + return otpCode, nil +} + +// GenerateRecoveryCodes generates a list of recovery codes +func (o *OTP) GenerateRecoveryCodes() []string { + codes := []string{} + + // for range o.recoveryCodeCount { // this works in go 1.22 but the linter barfs while its still on 1.21 + for i := 1; i <= o.recoveryCodeCount; i++ { + code, err := String(o.recoveryCodeLength, alphanumericCode) + if err != nil { + continue + } + + codes = append(codes, code) + } + + return codes +} diff --git a/totp/totp_test.go b/totp/totp_test.go new file mode 100644 index 0000000..1a735d4 --- /dev/null +++ b/totp/totp_test.go @@ -0,0 +1,106 @@ +package totp + +import ( + "database/sql" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOTPManager(t *testing.T) { + codeLength := 10 + svc := NewOTP( + WithCodeLength(codeLength), + ) + + code, hash, err := svc.OTPCode("mitb@theopenlane.io", Email) + require.NoErrorf(t, err, "failed to create code: %v", err) + + assert.Len(t, code, codeLength, "incorrect code length") + + err = svc.ValidateOTP(code, hash) + require.NoErrorf(t, err, "failed to validate code: %v", err) +} + +func TestTOTPSecret(t *testing.T) { + svc := NewOTP( + WithIssuer("authenticator.local"), + WithSecret(Secret{Version: 0, Key: "secret-key"}), + ) + user := &User{ + IsTOTPAllowed: true, + IsEmailOTPAllowed: false, + Phone: sql.NullString{ + String: "+17853931234", + Valid: true, + }, + } + + secret, err := svc.TOTPSecret(user) + require.NoError(t, err) + assert.NotNil(t, secret, "no secret generated") +} + +func TestTOTPQRString(t *testing.T) { + svc := NewOTP( + WithIssuer("authenticator.local"), + WithSecret(Secret{ + Version: 1, + Key: "9f0c6da662f018b58b04a093e2dbb2e1d8d54250", + }), + ) + user := &User{ + IsTOTPAllowed: true, + IsEmailOTPAllowed: false, + TFASecret: "1:usrJIgtKY9j58GgLpKIaoJqNbwylphfzyJcoyRRg1Ow52/7j6KoRpky8tFLZlgrY", + Phone: sql.NullString{ + String: "+17853931234", + Valid: true, + }, + } + + qrString, err := svc.TOTPQRString(user) + require.NoError(t, err) + + expectedString := "otpauth://totp/authenticator.local:+17853931234?algorithm=" + + "SHA1&digits=6&issuer=authenticator.local&period=30&secret=" + + "572JFGKOMDRA6KHE5O3ZV62I6BP352E7" + assert.Equal(t, expectedString, qrString, "TOTP QR string does not match") +} + +func TestEncryptsWithLatestSecret(t *testing.T) { + svc := &OTP{ + secrets: []Secret{ + {Version: 0, Key: "key-0"}, + {Version: 1, Key: "key-1"}, + {Version: 2, Key: "key-2"}, + }, + } + secret := "some-secret-value" + s, err := svc.encrypt(secret) + require.NoError(t, err, "failed to encrypt secret") + + assert.NotEqual(t, secret, s, "value not encrypted") + assert.True(t, strings.HasPrefix(s, "2:"), "value not encrypted with latest secret") + + s, err = svc.decrypt(s) + require.NoError(t, err, "failed to decrypt secret") + + assert.Equal(t, secret, s, "value not decrypted") +} +func TestGenerateRecoveryCodes(t *testing.T) { + o := &OTP{ + recoveryCodeCount: 5, + recoveryCodeLength: 8, + } + + codes := o.GenerateRecoveryCodes() + + assert.Len(t, codes, 5, "incorrect number of recovery codes generated") + + for _, code := range codes { + assert.Len(t, code, 8, "incorrect recovery code length") + } +}