Skip to content

Commit

Permalink
feat(GraphQL): Allow Multiple JWKUrls for auth. (#7528) (#7581)
Browse files Browse the repository at this point in the history
(cherry picked from commit f4c857b)
  • Loading branch information
minhaj-shakeel authored Mar 16, 2021
1 parent 8691809 commit 7a93c47
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 40 deletions.
105 changes: 76 additions & 29 deletions graphql/authorization/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ var (
type AuthMeta struct {
VerificationKey string
JWKUrl string
jwkSet *jose.JSONWebKeySet
expiryTime time.Time
JWKUrls []string
jwkSet []*jose.JSONWebKeySet
expiryTime []time.Time
RSAPublicKey *rsa.PublicKey `json:"-"` // Ignoring this field
Header string
Namespace string
Expand All @@ -73,11 +74,17 @@ type AuthMeta struct {
func (a *AuthMeta) validate() error {
var fields string

// If JWKUrl is provided, we don't expect (VerificationKey, Algo),
// they are needed only if JWKUrl is not present there.
if a.JWKUrl != "" {
// If JWKUrl/JWKUrls is provided, we don't expect (VerificationKey, Algo),
// they are needed only if JWKUrl/JWKUrls is not present there.
if len(a.JWKUrls) != 0 || a.JWKUrl != "" {

// User cannot provide both JWKUrl and JWKUrls.
if len(a.JWKUrls) != 0 && a.JWKUrl != "" {
return fmt.Errorf("expecting either JWKUrl or JWKUrls, both were given")
}

if a.VerificationKey != "" || a.Algo != "" {
return fmt.Errorf("expecting either JWKUrl or (VerificationKey, Algo), both were given")
return fmt.Errorf("expecting either JWKUrl/JWKUrls or (VerificationKey, Algo), both were given")
}

// Audience should be a required field if JWKUrl is provided.
Expand All @@ -86,7 +93,7 @@ func (a *AuthMeta) validate() error {
}
} else {
if a.VerificationKey == "" {
fields = " `Verification key`/`JWKUrl`"
fields = " `Verification key`/`JWKUrl`/`JWKUrls`"
}

if a.Algo == "" {
Expand Down Expand Up @@ -125,6 +132,15 @@ func Parse(schema string) (*AuthMeta, error) {
return nil, algoErr
}

if meta.JWKUrl != "" {
meta.JWKUrls = append(meta.JWKUrls, meta.JWKUrl)
meta.JWKUrl = ""
}

if len(meta.JWKUrls) != 0 {
meta.expiryTime = make([]time.Time, len(meta.JWKUrls))
meta.jwkSet = make([]*jose.JSONWebKeySet, len(meta.JWKUrls))
}
return &meta, nil
}

Expand Down Expand Up @@ -329,13 +345,15 @@ func GetJwtToken(ctx context.Context) string {
return jwtToken[0]
}

func (a *AuthMeta) validateJWTCustomClaims(jwtStr string) (*CustomClaims, error) {
var token *jwt.Token
// validateThroughJWKUrl validates the JWT token against the given list of JWKUrls.
// It returns an error only if the token is not validated against even one of the
// JWKUrl.
func (a *AuthMeta) validateThroughJWKUrl(jwtStr string) (*jwt.Token, error) {
var err error
// Verification through JWKUrl
if a.JWKUrl != "" {
if a.isExpired() {
err = a.refreshJWK()
var token *jwt.Token
for i := 0; i < len(a.JWKUrls); i++ {
if a.isExpired(i) {
err = a.refreshJWK(i)
if err != nil {
return nil, errors.Wrap(err, "while refreshing JWK from the URL")
}
Expand All @@ -348,12 +366,26 @@ func (a *AuthMeta) validateJWTCustomClaims(jwtStr string) (*CustomClaims, error)
return nil, errors.Errorf("kid not present in JWT")
}

signingKeys := a.jwkSet.Key(kid.(string))
signingKeys := a.jwkSet[i].Key(kid.(string))
if len(signingKeys) == 0 {
return nil, errors.Errorf("Invalid kid")
}
return signingKeys[0].Key, nil
}, jwt.WithoutAudienceValidation())

if err == nil {
return token, nil
}
}
return nil, err
}

func (a *AuthMeta) validateJWTCustomClaims(jwtStr string) (*CustomClaims, error) {
var token *jwt.Token
var err error
// Verification through JWKUrl
if len(a.JWKUrls) != 0 {
token, err = a.validateThroughJWKUrl(jwtStr)
} else {
if a.Algo == "" {
return nil, fmt.Errorf(
Expand Down Expand Up @@ -397,14 +429,29 @@ func (a *AuthMeta) validateJWTCustomClaims(jwtStr string) (*CustomClaims, error)
return claims, nil
}

// FetchJWKs fetches the JSON Web Key set from a JWKUrl. It acquires a Lock over a as some of the
// properties of AuthMeta are modified in the process.
// FetchJWKs fetches the JSON Web Key sets for the JWKUrls. It returns an error if
// the fetching of key is failed even for one of the JWKUrl.
func (a *AuthMeta) FetchJWKs() error {
if a.JWKUrl == "" {
if len(a.JWKUrls) == 0 {
return errors.Errorf("No JWKUrl supplied")
}

req, err := http.NewRequest("GET", a.JWKUrl, nil)
for i := 0; i < len(a.JWKUrls); i++ {
err := a.FetchJWK(i)
if err != nil {
return err
}
}
return nil
}

// FetchJWK fetches the JSON web Key set for the JWKUrl at a given index.
func (a *AuthMeta) FetchJWK(i int) error {
if len(a.JWKUrls) <= i {
return errors.Errorf("not enough JWKUrls")
}

req, err := http.NewRequest("GET", a.JWKUrls[i], nil)
if err != nil {
return err
}
Expand All @@ -430,9 +477,9 @@ func (a *AuthMeta) FetchJWKs() error {
return err
}

a.jwkSet = &jose.JSONWebKeySet{Keys: make([]jose.JSONWebKey, len(jwkArray.JWKs))}
for i, jwk := range jwkArray.JWKs {
err = a.jwkSet.Keys[i].UnmarshalJSON(jwk)
a.jwkSet[i] = &jose.JSONWebKeySet{Keys: make([]jose.JSONWebKey, len(jwkArray.JWKs))}
for k, jwk := range jwkArray.JWKs {
err = a.jwkSet[i].Keys[k].UnmarshalJSON(jwk)
if err != nil {
return err
}
Expand All @@ -447,18 +494,18 @@ func (a *AuthMeta) FetchJWKs() error {
}

if maxAge == 0 {
a.expiryTime = time.Time{}
a.expiryTime[i] = time.Time{}
} else {
a.expiryTime = time.Now().Add(time.Duration(maxAge) * time.Second)
a.expiryTime[i] = time.Now().Add(time.Duration(maxAge) * time.Second)
}

return nil
}

func (a *AuthMeta) refreshJWK() error {
func (a *AuthMeta) refreshJWK(i int) error {
var err error
for i := 0; i < 3; i++ {
err = a.FetchJWKs()
err = a.FetchJWK(i)
if err == nil {
return nil
}
Expand All @@ -471,18 +518,18 @@ func (a *AuthMeta) refreshJWK() error {
// if expiryTime is equal to 0 which means there
// is no expiry time of the JWKs, so it always
// returns false
func (a *AuthMeta) isExpired() bool {
if a.expiryTime.IsZero() {
func (a *AuthMeta) isExpired(i int) bool {
if a.expiryTime[i].IsZero() {
return false
}
return time.Now().After(a.expiryTime)
return time.Now().After(a.expiryTime[i])
}

// initSigningMethod takes the current Algo value, validates it's a supported SigningMethod, then sets the SigningMethod
// field.
func (a *AuthMeta) initSigningMethod() error {
// configurations using JWK URLs do not use signing methods.
if a.JWKUrl != "" {
if len(a.JWKUrls) != 0 || a.JWKUrl != "" {
return nil
}

Expand Down
63 changes: 57 additions & 6 deletions graphql/resolve/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ func TestInvalidAuthInfo(t *testing.T) {
authSchema, err := testutil.AppendJWKAndVerificationKey(sch)
require.NoError(t, err)
_, err = schema.NewHandler(string(authSchema), false)
require.Error(t, err, fmt.Errorf("Expecting either JWKUrl or (VerificationKey, Algo), both were given"))
require.Error(t, err, fmt.Errorf("Expecting either JWKUrl/JWKUrls or (VerificationKey, Algo), both were given"))
}

func TestMissingAudienceWithJWKUrl(t *testing.T) {
Expand All @@ -276,7 +276,6 @@ func TestMissingAudienceWithJWKUrl(t *testing.T) {
require.Error(t, err, fmt.Errorf("required field missing in Dgraph.Authorization: `Audience`"))
}

//Todo(Minhaj): Add a testcase for token without Expiry
func TestVerificationWithJWKUrl(t *testing.T) {
sch, err := ioutil.ReadFile("../e2e/auth/schema.graphql")
require.NoError(t, err, "Unable to read schema file")
Expand All @@ -293,21 +292,73 @@ func TestVerificationWithJWKUrl(t *testing.T) {
require.Equal(t, metainfo.Header, "X-Test-Auth")
require.Equal(t, metainfo.Namespace, "https://xyz.io/jwt/claims")
require.Equal(t, metainfo.VerificationKey, "")
require.Equal(t, metainfo.JWKUrl, "https://www.googleapis.com/service_accounts/v1/jwk/[email protected]")
require.Equal(t, metainfo.JWKUrl, "")
require.Equal(t, metainfo.JWKUrls, []string{"https://dev-hr2kugfp.us.auth0.com/.well-known/jwks.json"})

testCase := struct {
name string
token string
}{
name: `Expired Token`,
token: "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2NzUwM2UwYWVjNTJkZGZiODk2NTIxYjkxN2ZiOGUyMGMxZjMzMDAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vZmlyLXByb2plY3QxLTI1OWU3IiwiYXVkIjoiZmlyLXByb2plY3QxLTI1OWU3IiwiYXV0aF90aW1lIjoxNjAxNDQ0NjM0LCJ1c2VyX2lkIjoiMTdHb3h2dU5CWlc5YTlKU3Z3WXhROFc0bjE2MyIsInN1YiI6IjE3R294dnVOQlpXOWE5SlN2d1l4UThXNG4xNjMiLCJpYXQiOjE2MDE0NDQ2MzQsImV4cCI6MTYwMTQ0ODIzNCwiZW1haWwiOiJtaW5oYWpAZGdyYXBoLmlvIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7ImVtYWlsIjpbIm1pbmhhakBkZ3JhcGguaW8iXX0sInNpZ25faW5fcHJvdmlkZXIiOiJwYXNzd29yZCJ9fQ.q5YmOzOUkZHNjlz53hgLNSVg-brIU9tLJ4jLC0_Xurl5wEbyZ6D_KQ9-UFqbl2HR6R1V5kpaf6eDFR3c83i1PpCbJ4LTjHAf_njQvL75ByERld23lZtKZyEeE6ujdFXL8ne4fI2qenD1Xeqx9AnXbLf7U_CvZpbX3l1wj7p0Lpn7qixi0AztuLSJMLkMfFpaiwyFZQivi4cqtnI25VIsK6a4KIpl1Sk0AHT-lv9PRadd_JDjWAIzD0SfhpZOskaeA9PljVMp-Y3Xscwg_Qc6u1MIBPg1jKO-ngjhWkgEWBoz5F836P7phT60LVBHhYuk-jRN6HSSNWQ3ineuN-jBkg",
name: `Valid Token`,
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjJKdVZuRkc0Q2JBX0E1VVNkenlDMyJ9.eyJnaXZlbl9uYW1lIjoibWluaGFqIiwiZmFtaWx5X25hbWUiOiJzaGFrZWVsIiwibmlja25hbWUiOiJtc3JpaXRkIiwibmFtZSI6Im1pbmhhaiBzaGFrZWVsIiwicGljdHVyZSI6Imh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9hLS9BT2gxNEdnYzVEZ2cyQThWZFNzWUNnc2RlR3lFMHM1d01Gdmd2X1htZDA4Q3B3PXM5Ni1jIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoiMjAyMS0wMy0wOVQxMDowOTozNi4yMDNaIiwiZW1haWwiOiJtc3JpaXRkQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJpc3MiOiJodHRwczovL2Rldi1ocjJrdWdmcC51cy5hdXRoMC5jb20vIiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMDM2NTgyNjIxNzU2NDczNzEwNjQiLCJhdWQiOiJIaGFYa1FWUkJuNWUwSzNEbU1wMnpiakk4aTF3Y3YyZSIsImlhdCI6MTYxNTI4NDU3NywiZXhwIjo1MjE1Mjg0NTc3LCJub25jZSI6IlVtUk9NbTV0WWtoR2NGVjVOWGRhVGtKV1UyWm5ZM0pKUzNSR1ZsWk1jRzFLZUVkMGQzWkdkVTFuYXc9PSJ9.rlVl0tGOCypIts0C52g1qyiNaFV3UnDafJETXTGbt-toWvtCyZsa-JySgwG0DD1rMYm-gdwyJcjJlgwVPQD3ZlkJqbFFNvY4cX5injiOljpVFOHKXdi7tehY9We_vv1KYYpvhGMsE4u7o8tz2wEctdLTXT7omEq7gSdHuDgpM-h-K2RLApU8oyu8YOIqQlrqGgJ7Q8jy-jxMlU7BoZVz38FokjmkSapAAVORsbdEqPgQjeDnjaDQ5bRhxZUMSeKvvpvtVlPaeM1NI4S0R3g0qUGvX6L6qsLZqIilSQUiUaOEo8bLNBFHOxhBbocF-R-x40nSYjdjrEz60A99mz5XAA",
}

md := metadata.New(map[string]string{"authorizationJwt": testCase.token})
ctx := metadata.NewIncomingContext(context.Background(), md)

_, err = metainfo.ExtractCustomClaims(ctx)
require.True(t, strings.Contains(err.Error(), "unable to parse jwt token:token is unverifiable: Keyfunc returned an error"))
require.Nil(t, err)
}

func TestVerificationWithMultipleJWKUrls(t *testing.T) {
sch, err := ioutil.ReadFile("../e2e/auth/schema.graphql")
require.NoError(t, err, "Unable to read schema file")

authSchema, err := testutil.AppendAuthInfoWithMultipleJWKUrls(sch)
require.NoError(t, err)

schema := test.LoadSchemaFromString(t, string(authSchema))
require.NotNil(t, schema.Meta().AuthMeta())

// Verify that authorization information is set correctly.
metainfo := schema.Meta().AuthMeta()
require.Equal(t, metainfo.Algo, "")
require.Equal(t, metainfo.Header, "X-Test-Auth")
require.Equal(t, metainfo.Namespace, "https://xyz.io/jwt/claims")
require.Equal(t, metainfo.VerificationKey, "")
require.Equal(t, metainfo.JWKUrl, "")
require.Equal(t, metainfo.JWKUrls, []string{"https://www.googleapis.com/service_accounts/v1/jwk/[email protected]", "https://dev-hr2kugfp.us.auth0.com/.well-known/jwks.json"})

testCases := []struct {
name string
token string
invalid bool
}{
{
name: `Expired Token`,
token: "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2NzUwM2UwYWVjNTJkZGZiODk2NTIxYjkxN2ZiOGUyMGMxZjMzMDAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vZmlyLXByb2plY3QxLTI1OWU3IiwiYXVkIjoiZmlyLXByb2plY3QxLTI1OWU3IiwiYXV0aF90aW1lIjoxNjAxNDQ0NjM0LCJ1c2VyX2lkIjoiMTdHb3h2dU5CWlc5YTlKU3Z3WXhROFc0bjE2MyIsInN1YiI6IjE3R294dnVOQlpXOWE5SlN2d1l4UThXNG4xNjMiLCJpYXQiOjE2MDE0NDQ2MzQsImV4cCI6MTYwMTQ0ODIzNCwiZW1haWwiOiJtaW5oYWpAZGdyYXBoLmlvIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7ImVtYWlsIjpbIm1pbmhhakBkZ3JhcGguaW8iXX0sInNpZ25faW5fcHJvdmlkZXIiOiJwYXNzd29yZCJ9fQ.q5YmOzOUkZHNjlz53hgLNSVg-brIU9tLJ4jLC0_Xurl5wEbyZ6D_KQ9-UFqbl2HR6R1V5kpaf6eDFR3c83i1PpCbJ4LTjHAf_njQvL75ByERld23lZtKZyEeE6ujdFXL8ne4fI2qenD1Xeqx9AnXbLf7U_CvZpbX3l1wj7p0Lpn7qixi0AztuLSJMLkMfFpaiwyFZQivi4cqtnI25VIsK6a4KIpl1Sk0AHT-lv9PRadd_JDjWAIzD0SfhpZOskaeA9PljVMp-Y3Xscwg_Qc6u1MIBPg1jKO-ngjhWkgEWBoz5F836P7phT60LVBHhYuk-jRN6HSSNWQ3ineuN-jBkg",
invalid: true,
},
{
name: `Valid Token`,
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjJKdVZuRkc0Q2JBX0E1VVNkenlDMyJ9.eyJnaXZlbl9uYW1lIjoibWluaGFqIiwiZmFtaWx5X25hbWUiOiJzaGFrZWVsIiwibmlja25hbWUiOiJtc3JpaXRkIiwibmFtZSI6Im1pbmhhaiBzaGFrZWVsIiwicGljdHVyZSI6Imh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9hLS9BT2gxNEdnYzVEZ2cyQThWZFNzWUNnc2RlR3lFMHM1d01Gdmd2X1htZDA4Q3B3PXM5Ni1jIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoiMjAyMS0wMy0wOVQxMDowOTozNi4yMDNaIiwiZW1haWwiOiJtc3JpaXRkQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJpc3MiOiJodHRwczovL2Rldi1ocjJrdWdmcC51cy5hdXRoMC5jb20vIiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMDM2NTgyNjIxNzU2NDczNzEwNjQiLCJhdWQiOiJIaGFYa1FWUkJuNWUwSzNEbU1wMnpiakk4aTF3Y3YyZSIsImlhdCI6MTYxNTI4NDU3NywiZXhwIjo1MjE1Mjg0NTc3LCJub25jZSI6IlVtUk9NbTV0WWtoR2NGVjVOWGRhVGtKV1UyWm5ZM0pKUzNSR1ZsWk1jRzFLZUVkMGQzWkdkVTFuYXc9PSJ9.rlVl0tGOCypIts0C52g1qyiNaFV3UnDafJETXTGbt-toWvtCyZsa-JySgwG0DD1rMYm-gdwyJcjJlgwVPQD3ZlkJqbFFNvY4cX5injiOljpVFOHKXdi7tehY9We_vv1KYYpvhGMsE4u7o8tz2wEctdLTXT7omEq7gSdHuDgpM-h-K2RLApU8oyu8YOIqQlrqGgJ7Q8jy-jxMlU7BoZVz38FokjmkSapAAVORsbdEqPgQjeDnjaDQ5bRhxZUMSeKvvpvtVlPaeM1NI4S0R3g0qUGvX6L6qsLZqIilSQUiUaOEo8bLNBFHOxhBbocF-R-x40nSYjdjrEz60A99mz5XAA",
invalid: false,
},
}

for _, tcase := range testCases {
t.Run(tcase.name, func(t *testing.T) {
md := metadata.New(map[string]string{"authorizationJwt": tcase.token})
ctx := metadata.NewIncomingContext(context.Background(), md)

_, err := metainfo.ExtractCustomClaims(ctx)
if tcase.invalid {
require.True(t, strings.Contains(err.Error(), "unable to parse jwt token:token is unverifiable: Keyfunc returned an error"))
} else {
require.Nil(t, err)
}
})
}
}

// TODO(arijit): Generate the JWT token instead of using pre generated token.
Expand Down
6 changes: 3 additions & 3 deletions graphql/schema/schemagen.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,9 @@ func NewHandler(input string, apolloServiceQuery bool) (Handler, error) {
return nil, gqlerror.Errorf("No query or mutation found in the generated schema")
}

// If Dgraph.Authorization header is parsed successfully and JWKUrl is present
// then initialise the http client and Fetch the JWKs from the JWKUrl
if metaInfo.authMeta != nil && metaInfo.authMeta.JWKUrl != "" {
// If Dgraph.Authorization header is parsed successfully and JWKUrls is present
// then initialise the http client and Fetch the JWKs from the JWKUrls.
if metaInfo.authMeta != nil && len(metaInfo.authMeta.JWKUrls) != 0 {
metaInfo.authMeta.InitHttpClient()
fetchErr := metaInfo.authMeta.FetchJWKs()
if fetchErr != nil {
Expand Down
2 changes: 1 addition & 1 deletion graphql/schema/wrappers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1231,7 +1231,7 @@ func TestParseSecrets(t *testing.T) {
nil,
"",
nil,
errors.New("required field missing in Dgraph.Authorization: `Verification key`/`JWKUrl` `Algo` `Header` `Namespace`"),
errors.New("required field missing in Dgraph.Authorization: `Verification key`/`JWKUrl`/`JWKUrls` `Algo` `Header` `Namespace`"),
},
{
"Should be able to parse Dgraph.Authorization irrespective of spacing between # and Dgraph.Authorization",
Expand Down
7 changes: 6 additions & 1 deletion testutil/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,12 @@ func AppendAuthInfo(schema []byte, algo, publicKeyFile string, closedByDefault b
}

func AppendAuthInfoWithJWKUrl(schema []byte) ([]byte, error) {
authInfo := `# Dgraph.Authorization {"VerificationKey":"","Header":"X-Test-Auth","jwkurl":"https://www.googleapis.com/service_accounts/v1/jwk/[email protected]", "Namespace":"https://xyz.io/jwt/claims","Algo":"","Audience":["fir-project1-259e7"]}`
authInfo := `# Dgraph.Authorization {"VerificationKey":"","Header":"X-Test-Auth","jwkurl":"https://dev-hr2kugfp.us.auth0.com/.well-known/jwks.json", "Namespace":"https://xyz.io/jwt/claims","Algo":"","Audience":[ "HhaXkQVRBn5e0K3DmMp2zbjI8i1wcv2e"]}`
return append(schema, []byte(authInfo)...), nil
}

func AppendAuthInfoWithMultipleJWKUrls(schema []byte) ([]byte, error) {
authInfo := `# Dgraph.Authorization {"VerificationKey":"","Header":"X-Test-Auth","jwkurls":["https://www.googleapis.com/service_accounts/v1/jwk/[email protected]","https://dev-hr2kugfp.us.auth0.com/.well-known/jwks.json"], "Namespace":"https://xyz.io/jwt/claims","Algo":"","Audience":["fir-project1-259e7", "HhaXkQVRBn5e0K3DmMp2zbjI8i1wcv2e"]}`
return append(schema, []byte(authInfo)...), nil
}

Expand Down

0 comments on commit 7a93c47

Please sign in to comment.