From 25cef28156a0017c7309af60e643bb0fc16f63d9 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sat, 3 Feb 2024 11:16:29 +0200 Subject: [PATCH] add Amazon AWS Cognito JWK link support for token validation and verification through jwt.LoadAWSCognitoKeys package-level function --- LICENSE | 2 +- README.md | 1 + _examples/aws-cognito-verify/main.go | 41 ++++++ alg.go | 11 ++ jwt.go | 6 +- kid_keys_aws_cognito.go | 198 +++++++++++++++++++++++++++ 6 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 _examples/aws-cognito-verify/main.go create mode 100644 kid_keys_aws_cognito.go diff --git a/LICENSE b/LICENSE index 1301aa0..74123c1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020-2023 Gerasimos Maropoulos +Copyright (c) 2020-2024 Gerasimos Maropoulos Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index a3528db..bdbdfa7 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Import as `import "github.com/kataras/jwt"` and use it as `jwt.XXX`. * [Encryption](#encryption) * [Benchmarks](_benchmarks) * [Examples](_examples) + * [Amazon AWS Cognito Verification](_examples/aws-cognito-verify/main.go) **NEW** * [Basic](_examples/basic/main.go) * [Custom Header](_examples/custom-header/main.go) * [Multiple Key IDs](_examples/multiple-kids/main.go) diff --git a/_examples/aws-cognito-verify/main.go b/_examples/aws-cognito-verify/main.go new file mode 100644 index 0000000..6efb2cd --- /dev/null +++ b/_examples/aws-cognito-verify/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + + "github.com/kataras/jwt" +) + +// |=================================================================================| +// | Amazon's AWS Cognito integration example for token validation and verification. | +// |=================================================================================| + +func main() { + /* + cognitoConfig := jwt.AWSKeysConfiguration{ + Region: "us-west-2", + UserPoolID: "us-west-2_xxx", + } + + keys, err := cognitoConfig.Load() + if err != nil { + panic(err) + } + OR: + */ + keys, err := jwt.LoadAWSCognitoKeys("us-west-2" /* region */, "us-west-2_xxx" /* user pool id */) + if err != nil { + panic(err) // handle error, e.g. pool does not exist in the region. + } + + var tokenToValidate = `xxx.xxx.xxx` // put a token here issued by your own aws cognito user pool to test it. + + var claims jwt.Map // Your own custom claims here. + if err := keys.VerifyToken([]byte(tokenToValidate), &claims); err != nil { + panic(err) // handle error, e.g. token expired, or kid is empty. + } + + for k, v := range claims { + fmt.Printf("%s: %v\n", k, v) + } +} diff --git a/alg.go b/alg.go index 1b194a4..1970acb 100644 --- a/alg.go +++ b/alg.go @@ -169,3 +169,14 @@ var ( EdDSA, } ) + +// parseAlg returns the algorithm by its name or nil. +func parseAlg(name string) Alg { + for _, alg := range allAlgs { + if alg.Name() == name { + return alg + } + } + + return nil +} diff --git a/jwt.go b/jwt.go index 2757695..0386fa2 100644 --- a/jwt.go +++ b/jwt.go @@ -3,7 +3,7 @@ package jwt import ( "bytes" "encoding/json" - "io/ioutil" + "os" "reflect" "time" ) @@ -30,8 +30,8 @@ var CompareHeader HeaderValidator = compareHeader // ReadFile can be used to customize the way the // Must/Load Key function helpers are loading the filenames from. // Example of usage: embedded key pairs. -// Defaults to the `ioutil.ReadFile` which reads the file from the physical disk. -var ReadFile = ioutil.ReadFile +// Defaults to the `os.ReadFile` which reads the file from the physical disk. +var ReadFile = os.ReadFile // Marshal same as json.Marshal. // This variable can be modified to enable custom encoder behavior diff --git a/kid_keys_aws_cognito.go b/kid_keys_aws_cognito.go new file mode 100644 index 0000000..b6603d8 --- /dev/null +++ b/kid_keys_aws_cognito.go @@ -0,0 +1,198 @@ +package jwt + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" +) + +// |=========================================================================| +// | Amazon's AWS Cognito integration for token validation and verification. | +// |=========================================================================| + +// AWSCognitoKeysConfiguration is a configuration for fetching the JSON Web Key Set from AWS Cognito. +// See `LoadAWSCognitoKeys` and its `Load` and `WithClient` methods. +type AWSCognitoKeysConfiguration struct { + Region string `json:"region" yaml:"Region" toml:"Region" env:"AWS_COGNITO_REGION"` // e.g. "us-west-2" + UserPoolID string `json:"user_pool_id" yaml:"UserPoolID" toml:"Region" env:"AWS_COGNITO_USER_POOL_ID"` // e.g. "us-west-2_XXX" + + httpClient HTTPClient +} + +// LoadAWSCognitoKeys loads the AWS Cognito JSON Web Key Set from the given region and user pool ID. +// It returns the Keys object or an error if the request fails. +// It uses the default http.Client to fetch the JSON Web Key Set. +// It is a shortcut for the following: +// +// config := jwt.AWSKeysConfiguration{ +// Region: region, +// UserPoolID: userPoolID, +// } +// return config.Load() +func LoadAWSCognitoKeys(region, userPoolID string) (Keys, error) { + config := AWSCognitoKeysConfiguration{ + Region: region, + UserPoolID: userPoolID, + } + return config.Load() +} + +// WithClient sets the HTTP client to be used for fetching the JSON Web Key Set from AWS Cognito. +// If not set, the default http.Client is used. +func (c *AWSCognitoKeysConfiguration) WithClient(httpClient HTTPClient) *AWSCognitoKeysConfiguration { + c.httpClient = httpClient + return c +} + +// Load fetches the JSON Web Key Set from AWS Cognito and parses it into a jwt.Keys object. +// It returns the Keys object or an error if the request fails. +// If the HTTP client is not set, the default http.Client is used. +// +// Calls the `ParseAWSCognitoKeys` function with the given configuration. +func (c *AWSCognitoKeysConfiguration) Load() (Keys, error) { + httpClient := c.httpClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + return ParseAWSCognitoKeys(httpClient, c.Region, c.UserPoolID) +} + +// JWKSet represents a JSON Web Key Set. +type JWKSet struct { + Keys []*JWK `json:"keys"` +} + +// JWK represents a JSON Web Key. +type JWK struct { + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + Kid string `json:"kid"` + Alg string `json:"alg"` + Use string `json:"use"` +} + +// HTTPClient is an interface that can be used to mock the http.Client. +// It is used to fetch the JSON Web Key Set from AWS Cognito. +type HTTPClient interface { + Get(string) (*http.Response, error) +} + +// ParseAWSCognitoKeys fetches the JSON Web Key Set from AWS Cognito and parses it into a jwt.Keys object. +func ParseAWSCognitoKeys(client HTTPClient, region, userPoolID string) (Keys, error) { + set, err := fetchAWSCognitoJWKSet(client, region, userPoolID) + if err != nil { + return nil, err + } + + return parseAWSCognitoJWKSet(set) +} + +// AWSCognitoError represents an error response from AWS Cognito. +// It implements the error interface. +type AWSCognitoError struct { + StatusCode int + Message string `json:"message"` +} + +// Error returns the error message. +func (e AWSCognitoError) Error() string { + return e.Message +} + +// fetchAWSCognitoJWKSet fetches the JSON Web Key Set from AWS Cognito. +// It returns the JWKSet object or an error if the request fails. +func fetchAWSCognitoJWKSet( + client HTTPClient, + region string, + userPoolID string, +) (*JWKSet, error) { + url := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", region, userPoolID) + + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + fetchErr := AWSCognitoError{ + StatusCode: resp.StatusCode, + } + + err = json.NewDecoder(resp.Body).Decode(&fetchErr) + if err != nil { + return nil, fmt.Errorf("jwt: cannot decode error message: %w", err) + } + + return nil, fetchErr + } + + var jwkSet JWKSet + err = json.NewDecoder(resp.Body).Decode(&jwkSet) + if err != nil { + return nil, err + } + + return &jwkSet, nil +} + +// parseAWSCognitoJWKSet parses the JWKSet object into a jwt.Keys object. +// It returns the Keys object or an error if the parsing fails. +// It filters out unsupported algorithms. +func parseAWSCognitoJWKSet(set *JWKSet) (Keys, error) { + keys := make(Keys, len(set.Keys)) + for _, key := range set.Keys { + alg := parseAlg(key.Alg) + if alg == nil { + continue + } + + publicKey, err := convertJWKToPublicKey(key) + if err != nil { + return nil, err + } + + keys[key.Kid] = &Key{ + ID: key.Kid, + Alg: alg, + Public: publicKey, + } + } + + return keys, nil +} + +// convertJWKToPublicKey converts a JWK object to a *rsa.PublicKey object. +func convertJWKToPublicKey(jwk *JWK) (*rsa.PublicKey, error) { + // decode the n and e values from base64. + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) + if err != nil { + return nil, err + } + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + if err != nil { + return nil, err + } + + // construct a big.Int from the n bytes. + n := new(big.Int).SetBytes(nBytes) + + // construct an int from the e bytes. + var e int + for _, b := range eBytes { + e = e<<8 + int(b) + } + + // construct a *rsa.PublicKey from the n and e values. + pubKey := &rsa.PublicKey{ + N: n, + E: e, + } + + return pubKey, nil +}