diff --git a/app.go b/app.go index cde6fe1..3a50dd2 100644 --- a/app.go +++ b/app.go @@ -1,14 +1,11 @@ package passage import ( - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/pem" "errors" "fmt" "net/http" + jwkLibrary "github.com/lestrrat-go/jwx/jwk" "gopkg.in/resty.v1" ) @@ -18,9 +15,9 @@ type Config struct { } type App struct { - ID string - PublicKey *rsa.PublicKey - Config *Config + ID string + JWKS jwkLibrary.Set + Config *Config } func New(appID string, config *Config) (*App, error) { @@ -33,9 +30,8 @@ func New(appID string, config *Config) (*App, error) { Config: config, } - // Lookup the public key for this App: var err error - app.PublicKey, err = getPublicKey(appID) + app.JWKS, err = app.fetchJWKS() if err != nil { return nil, err } @@ -43,49 +39,7 @@ func New(appID string, config *Config) (*App, error) { return &app, nil } -var publicKeyCache map[string]*rsa.PublicKey = make(map[string]*rsa.PublicKey) - -func getPublicKey(appID string) (*rsa.PublicKey, error) { - // First, check if the App's public key is cached locally: - if cachedPublicKey, ok := publicKeyCache[appID]; ok { - return cachedPublicKey, nil - } - - // If the public key isn't cached locally, we'll need to use the Passage API to lookup the public key: - var responseData struct { - App struct { - PublicKey string `json:"rsa_public_key"` - } `json:"app"` - } - response, err := resty.New().R(). - SetResult(&responseData). - Get("https://api.passage.id/v1/apps/" + appID) - if err != nil { - return nil, errors.New("network error: could not lookup Passage App's public key") - } - if response.StatusCode() == http.StatusNotFound { - return nil, fmt.Errorf("passage App with ID \"%v\" does not exist", appID) - } - if response.StatusCode() != http.StatusOK { - return nil, fmt.Errorf("failed to get lookup Passage App's public key") - } - - // Parse the returned public key string to an rsa.PublicKey: - publicKeyBytes, err := base64.RawURLEncoding.DecodeString(responseData.App.PublicKey) - if err != nil { - return nil, errors.New("could not parse Passage App's public key: expected valid base-64") - } - pemBlock, _ := pem.Decode(publicKeyBytes) - if pemBlock == nil { - return nil, errors.New("could not parse Passage App's public key: missing PEM block") - } - publicKey, err := x509.ParsePKCS1PublicKey(pemBlock.Bytes) - if err != nil { - return nil, errors.New("could not parse Passage App's public key: invalid PKCS #1 public key") - } - - return publicKey, nil -} +var jwkCache map[string]jwkLibrary.Set = make(map[string]jwkLibrary.Set) type ChannelType string diff --git a/authentication.go b/authentication.go index 646db1a..eb93ab0 100644 --- a/authentication.go +++ b/authentication.go @@ -1,11 +1,14 @@ package passage import ( + "context" "errors" + "fmt" "net/http" "strings" "github.com/golang-jwt/jwt" + jwkLibrary "github.com/lestrrat-go/jwx/jwk" ) // AuthenticateRequest determines whether or not to authenticate via header or cookie authentication @@ -33,6 +36,42 @@ func (a *App) AuthenticateRequestWithHeader(r *http.Request) (string, error) { return userID, nil } +// getPublicKey returns the associated public key for a JWT +func (a *App) getPublicKey(token *jwt.Token) (interface{}, error) { + keyID, ok := token.Header["kid"].(string) + if !ok { + return nil, errors.New("expecting JWT header to have string kid") + } + + key, ok := jwkCache[a.ID].LookupKeyID(keyID) + // if key doesn't exist, re-fetch one more time to see if this jwk was just added + if !ok { + a.fetchJWKS() + key, ok := jwkCache[a.ID].LookupKeyID(keyID) + if !ok { + return nil, fmt.Errorf("unable to find key %q", keyID) + } + + var pubKey interface{} + err := key.Raw(&pubKey) + return pubKey, err + } + + var pubKey interface{} + err := key.Raw(&pubKey) + return pubKey, err +} + +// fetchJWKS returns the JWKS for the current app +func (a *App) fetchJWKS() (jwkLibrary.Set, error) { + jwks, err := jwkLibrary.Fetch(context.Background(), fmt.Sprintf("https://auth.passage.id/v1/apps/%v/.well-known/jwks.json", a.ID)) + if err != nil { + return nil, errors.New("failed to fetch jwks") + } + jwkCache[a.ID] = jwks + return jwks, nil +} + // AuthenticateRequestWithCookie fetches a cookie from the request and uses it to authenticate // returns the userID (string) on success, error on failure func (a *App) AuthenticateRequestWithCookie(r *http.Request) (string, error) { @@ -52,13 +91,7 @@ func (a *App) AuthenticateRequestWithCookie(r *http.Request) (string, error) { // ValidateAuthToken determines whether a JWT is valid or not // returns userID (string) on success, error on failure func (a *App) ValidateAuthToken(authToken string) (string, bool) { - // Verify that the authentication token is valid: - parsedToken, err := jwt.Parse(authToken, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - return nil, errors.New("invalid signing algorithm") - } - return a.PublicKey, nil - }) + parsedToken, err := jwt.Parse(authToken, a.getPublicKey) if err != nil { return "", false } diff --git a/authentication_test.go b/authentication_test.go index 1e68f3a..d13aff8 100644 --- a/authentication_test.go +++ b/authentication_test.go @@ -13,6 +13,27 @@ func TestAuthenticationWithCookie(t *testing.T) { req, err := http.NewRequest("GET", "https://example.com", nil) require.Nil(t, err) + psg, err := passage.New("TrWSUbDDTPCKTQDtLA9MO8Ee", &passage.Config{ + HeaderAuth: false, + }) + require.Nil(t, err) + + t.Run("valid auth token", func(t *testing.T) { + req.AddCookie(&http.Cookie{ + Name: "psg_auth_token", + Value: "eyJhbGciOiJSUzI1NiIsImtpZCI6IjlGVGdhaWpXV2hBUFhyTmJNQmMxc1lxWCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJleHAiOjE2ODg0OTA5NzcsImlhdCI6MTY1MjIwMjk3NywiaXNzIjoiVHJXU1ViRERUUENLVFFEdExBOU1POEVlIiwibmJmIjoxNjUyMjAyOTc3LCJzdWIiOiJiRVhJWktZeUFwZ3o1b1dZYzVXTTl2ZkYifQ.RFDUgv4ewmeEnapatQONuNJuofuTKDC7r7gZuvPGWpoX_EJWCgjVuysVt4L8ghUO_ZUuaujEn7loSZAtVVG7NKmivN2hSfCtCoK6JW-y8fn3izlaERl5fldkNdN8rxISlgqtANuPV0xfxtbIoqagV9wCAt2DY53HXDYM13ZRHIDrXgRO3-kiPhp_mO_tUnvHBRZ59DDFd-nqk99ssepT0-uEl-KVcHIQKbt5SfGgM9sR-b30mp6g-PkDDgdpMmS-ZLCNAZTkDclHTlCEdxCTdHS46z6yz6QAVgzhU0Z48q6olBzBEMGn-OC0fbwkYjG-j6xyWhpri0BamAG5I-i5Nw", + }) + + userID, err := psg.AuthenticateRequest(req) + require.Nil(t, err) + assert.NotNil(t, userID) + }) +} + +func TestAuthenticationWithCookiePassage(t *testing.T) { + req, err := http.NewRequest("GET", "https://example.com", nil) + require.Nil(t, err) + psg, err := passage.New("passage", &passage.Config{ HeaderAuth: false, }) diff --git a/go.mod b/go.mod index 0e07044..9d05cdf 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.15 require ( github.com/golang-jwt/jwt v3.2.2+incompatible github.com/joho/godotenv v1.4.0 - github.com/stretchr/testify v1.7.0 + github.com/lestrrat-go/jwx v1.2.24 + github.com/stretchr/testify v1.7.1 gopkg.in/resty.v1 v1.12.0 ) diff --git a/go.sum b/go.sum index 9e54a16..a600ea1 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,51 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d h1:1iy2qD6JEhHKKhUOA9IWs7mjco7lnw2qx8FsRI2wirE= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= +github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM= +github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4= +github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A= +github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= +github.com/lestrrat-go/jwx v1.2.24 h1:N6Qsn6TUsDzz+qgS/1xcfBtkQfnbwW01fLFJpuYgKsg= +github.com/lestrrat-go/jwx v1.2.24/go.mod h1:zoNuZymNl5lgdcu6P7K6ie2QRll5HVfF4xwxBBK1NxY= +github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +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= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f h1:OeJjE6G4dgCY4PIXvIRQbE8+RX+uXZyGhUy/ksMGJoc= +golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=