diff --git a/go.mod b/go.mod index a8fd27af..75ad8164 100644 --- a/go.mod +++ b/go.mod @@ -59,11 +59,10 @@ require ( github.com/theopenlane/entx v0.1.4 github.com/theopenlane/gqlgen-plugins v0.1.0 github.com/theopenlane/httpsling v0.1.0 - github.com/theopenlane/iam v0.1.4 + github.com/theopenlane/iam v0.1.5 github.com/theopenlane/utils v0.1.2 github.com/tursodatabase/libsql-client-go v0.0.0-20240812094001-348a4e45b535 github.com/vektah/gqlparser/v2 v2.5.16 - github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/wundergraph/graphql-go-tools v1.67.4 go.opentelemetry.io/otel v1.29.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 @@ -227,6 +226,7 @@ require ( 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/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect diff --git a/go.sum b/go.sum index 9dd84b23..08351220 100644 --- a/go.sum +++ b/go.sum @@ -545,8 +545,8 @@ github.com/theopenlane/gqlgen-plugins v0.1.0 h1:DL3knGQ+pcoXceSFvmgWzzlgIyPRgl71 github.com/theopenlane/gqlgen-plugins v0.1.0/go.mod h1:sP0nCjrE3KoVT4qfYqMrSK1XZa3Bx2oTIRSdEF7F5l0= 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/iam v0.1.4 h1:02y9wWxfnP0K0iJxiADJNMAi/dB3v3zP3jG1G7KToGI= -github.com/theopenlane/iam v0.1.4/go.mod h1:KZd/k1nEHSzS9wQeQHLqRabJuH3XkF5g8aGqzRhjSmQ= +github.com/theopenlane/iam v0.1.5 h1:CreDFrl8hLTpdQJcs/gFbkk8EuOFClGCClIpCUxPrOg= +github.com/theopenlane/iam v0.1.5/go.mod h1:KZd/k1nEHSzS9wQeQHLqRabJuH3XkF5g8aGqzRhjSmQ= github.com/theopenlane/utils v0.1.2 h1:kFbzvGgz/7b2CNZ/ycAzMBTmCqBJUld/UJxsfXCOgbw= github.com/theopenlane/utils v0.1.2/go.mod h1:37sJeeuIsmMbMFE2nKglmEQUJenTccxh5WxkJtyuZUw= github.com/tursodatabase/libsql-client-go v0.0.0-20240812094001-348a4e45b535 h1:iLjJLq2A5J6L9zrhyNn+fpmxFvtEpYB4XLMr0rX3epI= diff --git a/pkg/tokens/claims.go b/pkg/tokens/claims.go deleted file mode 100644 index 74cc970f..00000000 --- a/pkg/tokens/claims.go +++ /dev/null @@ -1,37 +0,0 @@ -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/pkg/tokens/claims_test.go b/pkg/tokens/claims_test.go deleted file mode 100644 index bb4e939e..00000000 --- a/pkg/tokens/claims_test.go +++ /dev/null @@ -1,35 +0,0 @@ -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/pkg/tokens/config.go b/pkg/tokens/config.go deleted file mode 100644 index 6ea3c448..00000000 --- a/pkg/tokens/config.go +++ /dev/null @@ -1,27 +0,0 @@ -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/pkg/tokens/doc.go b/pkg/tokens/doc.go deleted file mode 100644 index 7bafc663..00000000 --- a/pkg/tokens/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package tokens creates tokenmanager, responsible for signing, issuing, and validating tokens -package tokens diff --git a/pkg/tokens/errors.go b/pkg/tokens/errors.go deleted file mode 100644 index d4aa373c..00000000 --- a/pkg/tokens/errors.go +++ /dev/null @@ -1,216 +0,0 @@ -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/pkg/tokens/expires_test.go b/pkg/tokens/expires_test.go deleted file mode 100644 index ccbb1134..00000000 --- a/pkg/tokens/expires_test.go +++ /dev/null @@ -1,56 +0,0 @@ -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/pkg/tokens/jwks.go b/pkg/tokens/jwks.go deleted file mode 100644 index a3d1f2b3..00000000 --- a/pkg/tokens/jwks.go +++ /dev/null @@ -1,117 +0,0 @@ -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/pkg/tokens/jwks_test.go b/pkg/tokens/jwks_test.go deleted file mode 100644 index 80535bda..00000000 --- a/pkg/tokens/jwks_test.go +++ /dev/null @@ -1,132 +0,0 @@ -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/pkg/tokens/mock.go b/pkg/tokens/mock.go deleted file mode 100644 index 46d39043..00000000 --- a/pkg/tokens/mock.go +++ /dev/null @@ -1,29 +0,0 @@ -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/pkg/tokens/signing.go b/pkg/tokens/signing.go deleted file mode 100644 index 9b1dce60..00000000 --- a/pkg/tokens/signing.go +++ /dev/null @@ -1,50 +0,0 @@ -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/pkg/tokens/testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem b/pkg/tokens/testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem deleted file mode 100644 index d7d108d4..00000000 --- a/pkg/tokens/testdata/01GE6191AQTGMCJ9BN0QC3CCVG.pem +++ /dev/null @@ -1,51 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -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 ------END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/pkg/tokens/testdata/01GE62EXXR0X0561XD53RDFBQJ.pem b/pkg/tokens/testdata/01GE62EXXR0X0561XD53RDFBQJ.pem deleted file mode 100644 index 06e35f25..00000000 --- a/pkg/tokens/testdata/01GE62EXXR0X0561XD53RDFBQJ.pem +++ /dev/null @@ -1,51 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -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 ------END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/pkg/tokens/testdata/jwks.json b/pkg/tokens/testdata/jwks.json deleted file mode 100644 index fc0fa42b..00000000 --- a/pkg/tokens/testdata/jwks.json +++ /dev/null @@ -1 +0,0 @@ -{"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/pkg/tokens/testdata/partial_jwks.json b/pkg/tokens/testdata/partial_jwks.json deleted file mode 100644 index 6ab0d95d..00000000 --- a/pkg/tokens/testdata/partial_jwks.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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/pkg/tokens/tokenmanager.go b/pkg/tokens/tokenmanager.go deleted file mode 100644 index 4b6089f0..00000000 --- a/pkg/tokens/tokenmanager.go +++ /dev/null @@ -1,413 +0,0 @@ -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/pkg/tokens/tokens_test.go b/pkg/tokens/tokens_test.go deleted file mode 100644 index c9d9b659..00000000 --- a/pkg/tokens/tokens_test.go +++ /dev/null @@ -1,367 +0,0 @@ -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/pkg/tokens/urltokens.go b/pkg/tokens/urltokens.go deleted file mode 100644 index 1b70d4e2..00000000 --- a/pkg/tokens/urltokens.go +++ /dev/null @@ -1,292 +0,0 @@ -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/pkg/tokens/urltokens_test.go b/pkg/tokens/urltokens_test.go deleted file mode 100644 index 539ae852..00000000 --- a/pkg/tokens/urltokens_test.go +++ /dev/null @@ -1,261 +0,0 @@ -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/pkg/tokens/validator.go b/pkg/tokens/validator.go deleted file mode 100644 index d3820aee..00000000 --- a/pkg/tokens/validator.go +++ /dev/null @@ -1,107 +0,0 @@ -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 -}