@@ -0,0 +1,532 @@
1+package middleware
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "crypto/rsa"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "net/http"
11+ "net/http/httptest"
12+ "regexp"
13+ "strings"
14+ "time"
15+
16+ "github.com/coreos/go-oidc"
17+ "github.com/dgrijalva/jwt-go"
18+ middlewareapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/middleware"
19+ sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
20+ . "github.com/onsi/ginkgo"
21+ . "github.com/onsi/ginkgo/extensions/table"
22+ . "github.com/onsi/gomega"
23+)
24+
25+type noOpKeySet struct {
26+}
27+
28+func (noOpKeySet) VerifySignature(ctx context.Context, jwt string) (payload []byte, err error) {
29+ splitStrings := strings.Split(jwt, ".")
30+ payloadString := splitStrings[1]
31+ return base64.RawURLEncoding.DecodeString(payloadString)
32+}
33+
34+var _ = Describe("JWT Session Suite", func() {
35+ /* token payload:
36+ {
37+ "sub": "1234567890",
38+ "aud": "https://test.myapp.com",
39+ "name": "John Doe",
40+ "email": "john@example.com",
41+ "iss": "https://issuer.example.com",
42+ "iat": 1553691215,
43+ "exp": 1912151821
44+ }
45+ */
46+ const verifiedToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." +
47+ "eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoiaHR0cHM6Ly90ZXN0Lm15YXBwLmNvbSIsIm5hbWUiOiJKb2huIERvZSIsImVtY" +
48+ "WlsIjoiam9obkBleGFtcGxlLmNvbSIsImlzcyI6Imh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwiaWF0IjoxNTUzNjkxMj" +
49+ "E1LCJleHAiOjE5MTIxNTE4MjF9." +
50+ "rLVyzOnEldUq_pNkfa-WiV8TVJYWyZCaM2Am_uo8FGg11zD7l-qmz3x1seTvqpH6Y0Ty00fmv6dJnGnC8WMnPXQiodRTfhBSe" +
51+ "OKZMu0HkMD2sg52zlKkbfLTO6ic5VnbVgwjjrB8am_Ta6w7kyFUaB5C1BsIrrLMldkWEhynbb8"
52+
53+ const verifiedTokenXOAuthBasicBase64 = `ZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnpkV0lpT2lJeE1qTTBOVFkz
54+T0Rrd0lpd2lZWFZrSWpvaWFIUjBjSE02THk5MFpYTjBMbTE1WVhCd0xtTnZiU0lzSW01aGJXVWlP
55+aUpLYjJodUlFUnZaU0lzSW1WdFlXbHNJam9pYW05b2JrQmxlR0Z0Y0d4bExtTnZiU0lzSW1semN5
56+STZJbWgwZEhCek9pOHZhWE56ZFdWeUxtVjRZVzF3YkdVdVkyOXRJaXdpYVdGMElqb3hOVFV6Tmpr
57+eE1qRTFMQ0psZUhBaU9qRTVNVEl4TlRFNE1qRjkuckxWeXpPbkVsZFVxX3BOa2ZhLVdpVjhUVkpZ
58+V3laQ2FNMkFtX3VvOEZHZzExekQ3bC1xbXozeDFzZVR2cXBINlkwVHkwMGZtdjZkSm5HbkM4V01u
59+UFhRaW9kUlRmaEJTZU9LWk11MEhrTUQyc2c1MnpsS2tiZkxUTzZpYzVWbmJWZ3dqanJCOGFtX1Rh
60+Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=`
61+
62+ var verifiedSessionExpiry = time.Unix(1912151821, 0)
63+ var verifiedSession = &sessionsapi.SessionState{
64+ AccessToken: verifiedToken,
65+ IDToken: verifiedToken,
66+ Email: "john@example.com",
67+ User: "1234567890",
68+ ExpiresOn: &verifiedSessionExpiry,
69+ }
70+
71+ // validToken will pass the token regex so can be used to check token fetching
72+ // is valid. It will not pass the OIDC Verifier however.
73+ const validToken = "eyJfoobar.eyJfoobar.12345asdf"
74+
75+ Context("JwtSessionLoader", func() {
76+ var verifier *oidc.IDTokenVerifier
77+ const nonVerifiedToken = validToken
78+
79+ BeforeEach(func() {
80+ keyset := noOpKeySet{}
81+ verifier = oidc.NewVerifier("https://issuer.example.com", keyset,
82+ &oidc.Config{ClientID: "https://test.myapp.com", SkipExpiryCheck: true})
83+ })
84+
85+ type jwtSessionLoaderTableInput struct {
86+ authorizationHeader string
87+ existingSession *sessionsapi.SessionState
88+ expectedSession *sessionsapi.SessionState
89+ }
90+
91+ DescribeTable("with an authorization header",
92+ func(in jwtSessionLoaderTableInput) {
93+ scope := &middlewareapi.RequestScope{
94+ Session: in.existingSession,
95+ }
96+
97+ // Set up the request with the authorization header and a request scope
98+ req := httptest.NewRequest("", "/", nil)
99+ req.Header.Set("Authorization", in.authorizationHeader)
100+ contextWithScope := context.WithValue(req.Context(), requestScopeKey, scope)
101+ req = req.WithContext(contextWithScope)
102+
103+ rw := httptest.NewRecorder()
104+
105+ sessionLoaders := []middlewareapi.TokenToSessionLoader{
106+ {
107+ Verifier: verifier,
108+ },
109+ }
110+
111+ // Create the handler with a next handler that will capture the session
112+ // from the scope
113+ var gotSession *sessionsapi.SessionState
114+ handler := NewJwtSessionLoader(sessionLoaders)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115+ gotSession = r.Context().Value(requestScopeKey).(*middlewareapi.RequestScope).Session
116+ }))
117+ handler.ServeHTTP(rw, req)
118+
119+ Expect(gotSession).To(Equal(in.expectedSession))
120+ },
121+ Entry("<no value>", jwtSessionLoaderTableInput{
122+ authorizationHeader: "",
123+ existingSession: nil,
124+ expectedSession: nil,
125+ }),
126+ Entry("abcdef", jwtSessionLoaderTableInput{
127+ authorizationHeader: "abcdef",
128+ existingSession: nil,
129+ expectedSession: nil,
130+ }),
131+ Entry("abcdef (with existing session)", jwtSessionLoaderTableInput{
132+ authorizationHeader: "abcdef",
133+ existingSession: &sessionsapi.SessionState{User: "user"},
134+ expectedSession: &sessionsapi.SessionState{User: "user"},
135+ }),
136+ Entry("Bearer <verifiedToken>", jwtSessionLoaderTableInput{
137+ authorizationHeader: fmt.Sprintf("Bearer %s", verifiedToken),
138+ existingSession: nil,
139+ expectedSession: verifiedSession,
140+ }),
141+ Entry("Bearer <nonVerifiedToken>", jwtSessionLoaderTableInput{
142+ authorizationHeader: fmt.Sprintf("Bearer %s", nonVerifiedToken),
143+ existingSession: nil,
144+ expectedSession: nil,
145+ }),
146+ Entry("Bearer <verifiedToken> (with existing session)", jwtSessionLoaderTableInput{
147+ authorizationHeader: fmt.Sprintf("Bearer %s", verifiedToken),
148+ existingSession: &sessionsapi.SessionState{User: "user"},
149+ expectedSession: &sessionsapi.SessionState{User: "user"},
150+ }),
151+ Entry("Basic Base64(<nonVerifiedToken>:) (No password)", jwtSessionLoaderTableInput{
152+ authorizationHeader: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6",
153+ existingSession: nil,
154+ expectedSession: nil,
155+ }),
156+ Entry("Basic Base64(<verifiedToken>:x-oauth-basic) (Sentinel password)", jwtSessionLoaderTableInput{
157+ authorizationHeader: fmt.Sprintf("Basic %s", verifiedTokenXOAuthBasicBase64),
158+ existingSession: nil,
159+ expectedSession: verifiedSession,
160+ }),
161+ )
162+
163+ })
164+
165+ Context("getJWTSession", func() {
166+ var j *jwtSessionLoader
167+ const nonVerifiedToken = validToken
168+
169+ BeforeEach(func() {
170+ keyset := noOpKeySet{}
171+ verifier := oidc.NewVerifier("https://issuer.example.com", keyset,
172+ &oidc.Config{ClientID: "https://test.myapp.com", SkipExpiryCheck: true})
173+
174+ j = &jwtSessionLoader{
175+ jwtRegex: regexp.MustCompile(jwtRegexFormat),
176+ sessionLoaders: []middlewareapi.TokenToSessionLoader{
177+ {
178+ Verifier: verifier,
179+ TokenToSession: createSessionStateFromBearerToken,
180+ },
181+ },
182+ }
183+ })
184+
185+ type getJWTSessionTableInput struct {
186+ authorizationHeader string
187+ expectedErr error
188+ expectedSession *sessionsapi.SessionState
189+ }
190+
191+ DescribeTable("with an authorization header",
192+ func(in getJWTSessionTableInput) {
193+ req := httptest.NewRequest("", "/", nil)
194+ req.Header.Set("Authorization", in.authorizationHeader)
195+
196+ session, err := j.getJwtSession(req)
197+ if in.expectedErr != nil {
198+ Expect(err).To(MatchError(in.expectedErr))
199+ } else {
200+ Expect(err).ToNot(HaveOccurred())
201+ }
202+ Expect(session).To(Equal(in.expectedSession))
203+ },
204+ Entry("<no value>", getJWTSessionTableInput{
205+ authorizationHeader: "",
206+ expectedErr: nil,
207+ expectedSession: nil,
208+ }),
209+ Entry("abcdef", getJWTSessionTableInput{
210+ authorizationHeader: "abcdef",
211+ expectedErr: errors.New("invalid authorization header: \"abcdef\""),
212+ expectedSession: nil,
213+ }),
214+ Entry("Bearer abcdef", getJWTSessionTableInput{
215+ authorizationHeader: "Bearer abcdef",
216+ expectedErr: errors.New("no valid bearer token found in authorization header"),
217+ expectedSession: nil,
218+ }),
219+ Entry("Bearer <nonVerifiedToken>", getJWTSessionTableInput{
220+ authorizationHeader: fmt.Sprintf("Bearer %s", nonVerifiedToken),
221+ expectedErr: errors.New("unable to verify jwt token: \"Bearer eyJfoobar.eyJfoobar.12345asdf\""),
222+ expectedSession: nil,
223+ }),
224+ Entry("Bearer <verifiedToken>", getJWTSessionTableInput{
225+ authorizationHeader: fmt.Sprintf("Bearer %s", verifiedToken),
226+ expectedErr: nil,
227+ expectedSession: verifiedSession,
228+ }),
229+ Entry("Basic Base64(<nonVerifiedToken>:) (No password)", getJWTSessionTableInput{
230+ authorizationHeader: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6",
231+ expectedErr: errors.New("unable to verify jwt token: \"Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6\""),
232+ expectedSession: nil,
233+ }),
234+ Entry("Basic Base64(<verifiedToken>:x-oauth-basic) (Sentinel password)", getJWTSessionTableInput{
235+ authorizationHeader: fmt.Sprintf("Basic %s", verifiedTokenXOAuthBasicBase64),
236+ expectedErr: nil,
237+ expectedSession: verifiedSession,
238+ }),
239+ )
240+ })
241+
242+ Context("findBearerTokenFromHeader", func() {
243+ var j *jwtSessionLoader
244+
245+ BeforeEach(func() {
246+ j = &jwtSessionLoader{
247+ jwtRegex: regexp.MustCompile(jwtRegexFormat),
248+ }
249+ })
250+
251+ type findBearerTokenFromHeaderTableInput struct {
252+ header string
253+ expectedErr error
254+ expectedToken string
255+ }
256+
257+ DescribeTable("with a header",
258+ func(in findBearerTokenFromHeaderTableInput) {
259+ token, err := j.findBearerTokenFromHeader(in.header)
260+ if in.expectedErr != nil {
261+ Expect(err).To(MatchError(in.expectedErr))
262+ } else {
263+ Expect(err).ToNot(HaveOccurred())
264+ }
265+ Expect(token).To(Equal(in.expectedToken))
266+ },
267+ Entry("Bearer", findBearerTokenFromHeaderTableInput{
268+ header: "Bearer",
269+ expectedErr: errors.New("invalid authorization header: \"Bearer\""),
270+ expectedToken: "",
271+ }),
272+ Entry("Bearer abc def", findBearerTokenFromHeaderTableInput{
273+ header: "Bearer abc def",
274+ expectedErr: errors.New("invalid authorization header: \"Bearer abc def\""),
275+ expectedToken: "",
276+ }),
277+ Entry("Bearer abcdef", findBearerTokenFromHeaderTableInput{
278+ header: "Bearer abcdef",
279+ expectedErr: errors.New("no valid bearer token found in authorization header"),
280+ expectedToken: "",
281+ }),
282+ Entry("Bearer <valid-token>", findBearerTokenFromHeaderTableInput{
283+ header: fmt.Sprintf("Bearer %s", validToken),
284+ expectedErr: nil,
285+ expectedToken: validToken,
286+ }),
287+ Entry("Basic invalid-base64", findBearerTokenFromHeaderTableInput{
288+ header: "Basic invalid-base64",
289+ expectedErr: errors.New("invalid basic auth token: illegal base64 data at input byte 7"),
290+ expectedToken: "",
291+ }),
292+ Entry("Basic Base64(<validToken>:) (No password)", findBearerTokenFromHeaderTableInput{
293+ header: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6",
294+ expectedErr: nil,
295+ expectedToken: validToken,
296+ }),
297+ Entry("Basic Base64(<validToken>:x-oauth-basic) (Sentinel password)", findBearerTokenFromHeaderTableInput{
298+ header: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6eC1vYXV0aC1iYXNpYw==",
299+ expectedErr: nil,
300+ expectedToken: validToken,
301+ }),
302+ Entry("Basic Base64(any-user:<validToken>) (Matching password)", findBearerTokenFromHeaderTableInput{
303+ header: "Basic YW55LXVzZXI6ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY=",
304+ expectedErr: nil,
305+ expectedToken: validToken,
306+ }),
307+ Entry("Basic Base64(any-user:any-password) (No matches)", findBearerTokenFromHeaderTableInput{
308+ header: "Basic YW55LXVzZXI6YW55LXBhc3N3b3Jk",
309+ expectedErr: errors.New("invalid basic auth token found in authorization header"),
310+ expectedToken: "",
311+ }),
312+ Entry("Basic Base64(any-user any-password) (Invalid format)", findBearerTokenFromHeaderTableInput{
313+ header: "Basic YW55LXVzZXIgYW55LXBhc3N3b3Jk",
314+ expectedErr: errors.New("invalid format: \"any-user any-password\""),
315+ expectedToken: "",
316+ }),
317+ Entry("Something <valid-token>", findBearerTokenFromHeaderTableInput{
318+ header: fmt.Sprintf("Something %s", validToken),
319+ expectedErr: errors.New("no valid bearer token found in authorization header"),
320+ expectedToken: "",
321+ }),
322+ )
323+
324+ })
325+
326+ Context("getBasicToken", func() {
327+ var j *jwtSessionLoader
328+
329+ BeforeEach(func() {
330+ j = &jwtSessionLoader{
331+ jwtRegex: regexp.MustCompile(jwtRegexFormat),
332+ }
333+ })
334+
335+ type getBasicTokenTableInput struct {
336+ token string
337+ expectedErr error
338+ expectedToken string
339+ }
340+
341+ DescribeTable("with a token",
342+ func(in getBasicTokenTableInput) {
343+ token, err := j.getBasicToken(in.token)
344+ if in.expectedErr != nil {
345+ Expect(err).To(MatchError(in.expectedErr))
346+ } else {
347+ Expect(err).ToNot(HaveOccurred())
348+ }
349+ Expect(token).To(Equal(in.expectedToken))
350+ },
351+ Entry("invalid-base64", getBasicTokenTableInput{
352+ token: "invalid-base64",
353+ expectedErr: errors.New("invalid basic auth token: illegal base64 data at input byte 7"),
354+ expectedToken: "",
355+ }),
356+ Entry("Base64(<validToken>:) (No password)", getBasicTokenTableInput{
357+ token: "ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6",
358+ expectedErr: nil,
359+ expectedToken: validToken,
360+ }),
361+ Entry("Base64(<validToken>:x-oauth-basic) (Sentinel password)", getBasicTokenTableInput{
362+ token: "ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6eC1vYXV0aC1iYXNpYw==",
363+ expectedErr: nil,
364+ expectedToken: validToken,
365+ }),
366+ Entry("Base64(any-user:<validToken>) (Matching password)", getBasicTokenTableInput{
367+ token: "YW55LXVzZXI6ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY=",
368+ expectedErr: nil,
369+ expectedToken: validToken,
370+ }),
371+ Entry("Base64(any-user:any-password) (No matches)", getBasicTokenTableInput{
372+ token: "YW55LXVzZXI6YW55LXBhc3N3b3Jk",
373+ expectedErr: errors.New("invalid basic auth token found in authorization header"),
374+ expectedToken: "",
375+ }),
376+ Entry("Base64(any-user any-password) (Invalid format)", getBasicTokenTableInput{
377+ token: "YW55LXVzZXIgYW55LXBhc3N3b3Jk",
378+ expectedErr: errors.New("invalid format: \"any-user any-password\""),
379+ expectedToken: "",
380+ }),
381+ )
382+ })
383+
384+ Context("splitAuthHeader", func() {
385+ type splitAuthTableInput struct {
386+ header string
387+ expectedErr error
388+ expectedTokenType string
389+ expectedTokenValue string
390+ }
391+
392+ DescribeTable("with a header value",
393+ func(in splitAuthTableInput) {
394+ tt, tv, err := splitAuthHeader(in.header)
395+ if in.expectedErr != nil {
396+ Expect(err).To(MatchError(in.expectedErr))
397+ } else {
398+ Expect(err).ToNot(HaveOccurred())
399+ }
400+ Expect(tt).To(Equal(in.expectedTokenType))
401+ Expect(tv).To(Equal(in.expectedTokenValue))
402+ },
403+ Entry("Bearer abcdef", splitAuthTableInput{
404+ header: "Bearer abcdef",
405+ expectedErr: nil,
406+ expectedTokenType: "Bearer",
407+ expectedTokenValue: "abcdef",
408+ }),
409+ Entry("Bearer", splitAuthTableInput{
410+ header: "Bearer",
411+ expectedErr: errors.New("invalid authorization header: \"Bearer\""),
412+ expectedTokenType: "",
413+ expectedTokenValue: "",
414+ }),
415+ Entry("Bearer abc def", splitAuthTableInput{
416+ header: "Bearer abc def",
417+ expectedErr: errors.New("invalid authorization header: \"Bearer abc def\""),
418+ expectedTokenType: "",
419+ expectedTokenValue: "",
420+ }),
421+ )
422+ })
423+
424+ Context("createSessionStateFromBearerToken", func() {
425+ ctx := context.Background()
426+ expiresFuture := time.Now().Add(time.Duration(5) * time.Minute)
427+ verified := true
428+ notVerified := false
429+
430+ type idTokenClaims struct {
431+ Email string `json:"email,omitempty"`
432+ Verified *bool `json:"email_verified,omitempty"`
433+ jwt.StandardClaims
434+ }
435+
436+ type createSessionStateTableInput struct {
437+ idToken idTokenClaims
438+ expectedErr error
439+ expectedUser string
440+ expectedEmail string
441+ expectedExpires *time.Time
442+ }
443+
444+ DescribeTable("when creating a session from an IDToken",
445+ func(in createSessionStateTableInput) {
446+ verifier := oidc.NewVerifier(
447+ "https://issuer.example.com",
448+ noOpKeySet{},
449+ &oidc.Config{ClientID: "asdf1234"},
450+ )
451+
452+ key, err := rsa.GenerateKey(rand.Reader, 2048)
453+ Expect(err).ToNot(HaveOccurred())
454+
455+ rawIDToken, err := jwt.NewWithClaims(jwt.SigningMethodRS256, in.idToken).SignedString(key)
456+ Expect(err).ToNot(HaveOccurred())
457+
458+ // Pass to a dummy Verifier to get an oidc.IDToken from the rawIDToken for our actual test below
459+ idToken, err := verifier.Verify(context.Background(), rawIDToken)
460+ Expect(err).ToNot(HaveOccurred())
461+
462+ session, err := createSessionStateFromBearerToken(ctx, rawIDToken, idToken)
463+ if in.expectedErr != nil {
464+ Expect(err).To(MatchError(in.expectedErr))
465+ Expect(session).To(BeNil())
466+ return
467+ }
468+
469+ Expect(err).ToNot(HaveOccurred())
470+ Expect(session.AccessToken).To(Equal(rawIDToken))
471+ Expect(session.IDToken).To(Equal(rawIDToken))
472+ Expect(session.User).To(Equal(in.expectedUser))
473+ Expect(session.Email).To(Equal(in.expectedEmail))
474+ Expect(session.ExpiresOn.Unix()).To(Equal(in.expectedExpires.Unix()))
475+ Expect(session.RefreshToken).To(BeEmpty())
476+ Expect(session.PreferredUsername).To(BeEmpty())
477+ },
478+ Entry("with no email", createSessionStateTableInput{
479+ idToken: idTokenClaims{
480+ StandardClaims: jwt.StandardClaims{
481+ Audience: "asdf1234",
482+ ExpiresAt: expiresFuture.Unix(),
483+ Id: "id-some-id",
484+ IssuedAt: time.Now().Unix(),
485+ Issuer: "https://issuer.example.com",
486+ NotBefore: 0,
487+ Subject: "123456789",
488+ },
489+ },
490+ expectedErr: nil,
491+ expectedUser: "123456789",
492+ expectedEmail: "123456789",
493+ expectedExpires: &expiresFuture,
494+ }),
495+ Entry("with a verified email", createSessionStateTableInput{
496+ idToken: idTokenClaims{
497+ StandardClaims: jwt.StandardClaims{
498+ Audience: "asdf1234",
499+ ExpiresAt: expiresFuture.Unix(),
500+ Id: "id-some-id",
501+ IssuedAt: time.Now().Unix(),
502+ Issuer: "https://issuer.example.com",
503+ NotBefore: 0,
504+ Subject: "123456789",
505+ },
506+ Email: "foo@example.com",
507+ Verified: &verified,
508+ },
509+ expectedErr: nil,
510+ expectedUser: "123456789",
511+ expectedEmail: "foo@example.com",
512+ expectedExpires: &expiresFuture,
513+ }),
514+ Entry("with a non-verified email", createSessionStateTableInput{
515+ idToken: idTokenClaims{
516+ StandardClaims: jwt.StandardClaims{
517+ Audience: "asdf1234",
518+ ExpiresAt: expiresFuture.Unix(),
519+ Id: "id-some-id",
520+ IssuedAt: time.Now().Unix(),
521+ Issuer: "https://issuer.example.com",
522+ NotBefore: 0,
523+ Subject: "123456789",
524+ },
525+ Email: "foo@example.com",
526+ Verified: ¬Verified,
527+ },
528+ expectedErr: errors.New("email in id_token (foo@example.com) isn't verified"),
529+ }),
530+ )
531+ })
532+})