Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NET-4993 JWT auth basic acceptance test #2706

Merged
merged 8 commits into from
Aug 9, 2023
253 changes: 252 additions & 1 deletion acceptance/tests/api-gateway/api_gateway_test.go
jm96441n marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func TestAPIGateway_Basic(t *testing.T) {

// On startup, the controller can take upwards of 1m to perform
// leader election so we may need to wait a long time for
// the reconcile loop to run (hence the 1m timeout here).
// the reconcile loop to run (hence the timeout here).
var gatewayAddress string
counter := &retry.Counter{Count: 120, Wait: 2 * time.Second}
retry.RunWith(counter, t, func(r *retry.R) {
Expand Down Expand Up @@ -288,6 +288,257 @@ func TestAPIGateway_Basic(t *testing.T) {
}
}

func TestAPIGateway_JWTAuth_Basic(t *testing.T) {
t.Skip("skipping this test until GW JWT auth is complete")
ctx := suite.Environment().DefaultContext(t)
cfg := suite.Config()

if !cfg.EnableEnterprise {
t.Skipf("skipping this test because -enable-enterprise is not set")
}

helmValues := map[string]string{
"connectInject.enabled": "true",
"connectInject.consulNamespaces.mirroringK8S": "true",
"global.acls.manageSystemACLs": "true", // acls must be enabled for JWT auth to take place
"global.tls.enabled": "true",
"global.logLevel": "trace",
}

releaseName := helpers.RandomName()
consulCluster := consul.NewHelmCluster(t, helmValues, ctx, cfg, releaseName)

consulCluster.Create(t)

// Override the default proxy config settings for this test
consulClient, _ := consulCluster.SetupConsulClient(t, true)
_, _, err := consulClient.ConfigEntries().Set(&api.ProxyConfigEntry{
Kind: api.ProxyDefaults,
Name: api.ProxyConfigGlobal,
Config: map[string]interface{}{
"protocol": "http",
},
}, nil)
require.NoError(t, err)

logger.Log(t, "creating api-gateway resources")
out, err := k8s.RunKubectlAndGetOutputE(t, ctx.KubectlOptions(t), "apply", "-k", "../fixtures/cases/api-gateways/jwt-auth")
require.NoError(t, err, out)
helpers.Cleanup(t, cfg.NoCleanupOnFailure, cfg.NoCleanup, func() {
// Ignore errors here because if the test ran as expected
// the custom resources will have been deleted.
k8s.RunKubectlAndGetOutputE(t, ctx.KubectlOptions(t), "delete", "-k", "../fixtures/cases/api-gateways/jwt-auth")
})

// Create certificate secret, we do this separately since
// applying the secret will make an invalid certificate that breaks other tests
logger.Log(t, "creating certificate secret")
out, err = k8s.RunKubectlAndGetOutputE(t, ctx.KubectlOptions(t), "apply", "-f", "../fixtures/bases/api-gateway/certificate.yaml")
nathancoleman marked this conversation as resolved.
Show resolved Hide resolved
require.NoError(t, err, out)
helpers.Cleanup(t, cfg.NoCleanupOnFailure, cfg.NoCleanup, func() {
// Ignore errors here because if the test ran as expected
// the custom resources will have been deleted.
k8s.RunKubectlAndGetOutputE(t, ctx.KubectlOptions(t), "delete", "-f", "../fixtures/bases/api-gateway/certificate.yaml")
})

// patch certificate with data
logger.Log(t, "patching certificate secret with generated data")
certificate := generateCertificate(t, nil, "gateway.test.local")
k8s.RunKubectl(t, ctx.KubectlOptions(t), "patch", "secret", "certificate", "-p", fmt.Sprintf(`{"data":{"tls.crt":"%s","tls.key":"%s"}}`, base64.StdEncoding.EncodeToString(certificate.CertPEM), base64.StdEncoding.EncodeToString(certificate.PrivateKeyPEM)), "--type=merge")

// We use the static-client pod so that we can make calls to the api gateway
// via kubectl exec without needing a route into the cluster from the test machine.
logger.Log(t, "creating static-client pod")
k8s.DeployKustomize(t, ctx.KubectlOptions(t), cfg.NoCleanupOnFailure, cfg.NoCleanup, cfg.DebugDirectory, "../fixtures/bases/static-client")

k8s.RunKubectl(t, ctx.KubectlOptions(t), "wait", "--for=condition=available", "--timeout=5m", fmt.Sprintf("deploy/%s", "static-server"))
// Grab a kubernetes client so that we can verify binding
// behavior prior to issuing requests through the gateway.
k8sClient := ctx.ControllerRuntimeClient(t)

// On startup, the controller can take upwards of 1m to perform
// leader election so we may need to wait a long time for
// the reconcile loop to run (hence the 2m timeout here).
var (
gatewayAddress string
gatewayClass gwv1beta1.GatewayClass
httpRoute gwv1beta1.HTTPRoute
httpRouteAuth gwv1beta1.HTTPRoute
)

counter := &retry.Counter{Count: 60, Wait: 2 * time.Second}
retry.RunWith(counter, t, func(r *retry.R) {
var gateway gwv1beta1.Gateway
err := k8sClient.Get(context.Background(), types.NamespacedName{Name: "gateway", Namespace: "default"}, &gateway)
require.NoError(r, err)

// check our finalizers
require.Len(r, gateway.Finalizers, 1)
require.EqualValues(r, gatewayFinalizer, gateway.Finalizers[0])

// check our statuses
checkStatusCondition(r, gateway.Status.Conditions, trueCondition("Accepted", "Accepted"))
checkStatusCondition(r, gateway.Status.Conditions, trueCondition("ConsulAccepted", "Accepted"))
require.Len(r, gateway.Status.Listeners, 4)

require.EqualValues(r, 1, gateway.Status.Listeners[0].AttachedRoutes)
checkStatusCondition(r, gateway.Status.Listeners[0].Conditions, trueCondition("Accepted", "Accepted"))
checkStatusCondition(r, gateway.Status.Listeners[0].Conditions, falseCondition("Conflicted", "NoConflicts"))
checkStatusCondition(r, gateway.Status.Listeners[0].Conditions, trueCondition("ResolvedRefs", "ResolvedRefs"))
require.EqualValues(r, 1, gateway.Status.Listeners[1].AttachedRoutes)
checkStatusCondition(r, gateway.Status.Listeners[1].Conditions, trueCondition("Accepted", "Accepted"))
checkStatusCondition(r, gateway.Status.Listeners[1].Conditions, falseCondition("Conflicted", "NoConflicts"))
checkStatusCondition(r, gateway.Status.Listeners[1].Conditions, trueCondition("ResolvedRefs", "ResolvedRefs"))

// check that we have an address to use
require.Len(r, gateway.Status.Addresses, 1)
// now we know we have an address, set it so we can use it
gatewayAddress = gateway.Status.Addresses[0].Value

// gateway class checks
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: "gateway-class"}, &gatewayClass)
require.NoError(r, err)

// check our finalizers
require.Len(r, gatewayClass.Finalizers, 1)
require.EqualValues(r, gatewayClassFinalizer, gatewayClass.Finalizers[0])

// http route checks
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: "http-route", Namespace: "default"}, &httpRoute)
require.NoError(r, err)

// http route checks
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: "http-route-auth", Namespace: "default"}, &httpRouteAuth)
require.NoError(r, err)

// check our finalizers
require.Len(r, httpRoute.Finalizers, 1)
require.EqualValues(r, gatewayFinalizer, httpRoute.Finalizers[0])

// check parent status
require.Len(r, httpRoute.Status.Parents, 1)
require.EqualValues(r, gatewayClassControllerName, httpRoute.Status.Parents[0].ControllerName)
require.EqualValues(r, "gateway", httpRoute.Status.Parents[0].ParentRef.Name)
checkStatusCondition(r, httpRoute.Status.Parents[0].Conditions, trueCondition("Accepted", "Accepted"))
checkStatusCondition(r, httpRoute.Status.Parents[0].Conditions, trueCondition("ResolvedRefs", "ResolvedRefs"))
checkStatusCondition(r, httpRoute.Status.Parents[0].Conditions, trueCondition("ConsulAccepted", "Accepted"))

// check our finalizers
require.Len(r, httpRouteAuth.Finalizers, 1)
require.EqualValues(r, gatewayFinalizer, httpRouteAuth.Finalizers[0])

// check parent status
require.Len(r, httpRouteAuth.Status.Parents, 1)
require.EqualValues(r, gatewayClassControllerName, httpRouteAuth.Status.Parents[0].ControllerName)
require.EqualValues(r, "gateway", httpRouteAuth.Status.Parents[0].ParentRef.Name)
checkStatusCondition(r, httpRouteAuth.Status.Parents[0].Conditions, trueCondition("Accepted", "Accepted"))
checkStatusCondition(r, httpRouteAuth.Status.Parents[0].Conditions, trueCondition("ResolvedRefs", "ResolvedRefs"))
checkStatusCondition(r, httpRouteAuth.Status.Parents[0].Conditions, trueCondition("ConsulAccepted", "Accepted"))
})

// check that the Consul entries were created
entry, _, err := consulClient.ConfigEntries().Get(api.APIGateway, "gateway", nil)
require.NoError(t, err)
gateway := entry.(*api.APIGatewayConfigEntry)

entry, _, err = consulClient.ConfigEntries().Get(api.HTTPRoute, "http-route", nil)
require.NoError(t, err)
consulHTTPRoute := entry.(*api.HTTPRouteConfigEntry)

entry, _, err = consulClient.ConfigEntries().Get(api.HTTPRoute, "http-route-auth", nil)
require.NoError(t, err)
consulHTTPRouteAuth := entry.(*api.HTTPRouteConfigEntry)

// now check the gateway status conditions
checkConsulStatusCondition(t, gateway.Status.Conditions, trueConsulCondition("Accepted", "Accepted"))

// and the route status conditions
checkConsulStatusCondition(t, consulHTTPRoute.Status.Conditions, trueConsulCondition("Bound", "Bound"))
checkConsulStatusCondition(t, consulHTTPRouteAuth.Status.Conditions, trueConsulCondition("Bound", "Bound"))

// finally we check that we can actually route to the service(s) via the gateway
k8sOptions := ctx.KubectlOptions(t)
targetHTTPAddress := fmt.Sprintf("http://%s/v1", gatewayAddress)
targetHTTPAddressAdmin := fmt.Sprintf("http://%s:8080/admin", gatewayAddress)
targetHTTPAddressPet := fmt.Sprintf("http://%s:8080/pet", gatewayAddress)
// valid JWT token with role of "doctor"
doctorToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJQUzI1NiIsImtpZCI6IkMtRTFuQ2p3Z0JDLVB1R00yTzQ2N0ZSRGhLeDhBa1ZjdElTQWJvM3JpZXcifQ.eyJpc3MiOiJsb2NhbCIsInJvbGUiOiJkb2N0b3IifQ.FfgpzjMf8Evh6K-fJ1cLXklfIXOm-vojVbWlPPbGVFtzxZ9hxMxoyAY_G8i36SfGrpUlp-RJ6ohMvprMrEgyRgbenu7u5kkm5iGHW-zpMus4izXRxPELBcpWOGF105HIssT2NYRstXieNR8EVzvGfLdvR0GW8ttEERgseqGvuAfdb4-aNYsysGwUUHbsZjazA6H1rZmWqHdCLOJ2ZwFsIdckO9CadnkyTILpcPUmLYyUVJdtlLGOySb0GG8c_dPML_IR5jSXCSUZt6S2JBNBNBdqukrlqpA-fIaaWft0dbWVMhv8DqPC8znult8dKvLZ1qXeU0itsqqJUyE16ihJjw"
// valid JWT token with role of "pet"
petToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJQUzI1NiIsImtpZCI6IkMtRTFuQ2p3Z0JDLVB1R00yTzQ2N0ZSRGhLeDhBa1ZjdElTQWJvM3JpZXcifQ.eyJpc3MiOiJsb2NhbCIsInJvbGUiOiJwZXQifQ.l94rJayGGTMB426HwEw5ipSjaIHjm-UWDHiBAlB_Slmi814AxAfl_0AdRwSz67UDnkoygKbvPpR5xUB03JCXNshLZuKLegWsBeQg_OJYvZGmFagl5NglBFvH7Jbta4e1eQoAxZI6Xyy1jHbu7jFBjQPVnK8EaRvWoW8Pe8a8rp_5xhub0pomhvRF6Pm5kAS4cMnxvqpVc5Oo5nO7ws_SmoNnbt2Ok14k23Zx5E2EWmGStOfbgFsdbhVbepB2DMzqv1j8jvBbwa_OxCwc_7pEOthOOxRV6L3ZjgbRSB4GumlXAOCBYXD1cRLgrMSrWB1GkefAKu8PV0Ho1px6sI9Evg"

// check that intentions keep our connection from happening
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, targetHTTPAddress)
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, targetHTTPAddressAdmin)
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, "-H", doctorToken, targetHTTPAddressAdmin)
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, "-H", petToken, targetHTTPAddressAdmin)

k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, targetHTTPAddressPet)
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, "-H", doctorToken, targetHTTPAddressPet)
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, "-H", petToken, targetHTTPAddressPet)

// Now we create the allow intention.
_, _, err = consulClient.ConfigEntries().Set(&api.ServiceIntentionsConfigEntry{
Kind: api.ServiceIntentions,
Name: "static-server",
Sources: []*api.SourceIntention{
{
Name: "gateway",
Action: api.IntentionActionAllow,
},
},
}, nil)
require.NoError(t, err)

_, _, err = consulClient.ConfigEntries().Set(&api.ServiceIntentionsConfigEntry{
Kind: api.ServiceIntentions,
Name: "static-server-protected",
Sources: []*api.SourceIntention{
{
Name: "gateway",
Action: api.IntentionActionAllow,
},
},
}, nil)
require.NoError(t, err)

// Test that we can make a call to the api gateway
logger.Log(t, "trying calls to api gateway http")
k8s.CheckStaticServerConnectionSuccessful(t, k8sOptions, StaticClientName, targetHTTPAddress)

// ensure that overrides -> route extension -> default by making a request to the admin route with a JWT that has an issuer of "local" and a "role" of "doctor"
// we can see that:
// * the "iss" verification in the gateway override takes precedence over the "iss" verification in the route filter
// * the "role" verification in the route extension takes precedence over the "role" verification in the gateway default
// should fail because we're missing JWT
logger.Log(t, "trying calls to api gateway /admin should fail without JWT token")
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, targetHTTPAddressAdmin)

// should fail because we use the token with the wrong role and correct issuer
logger.Log(t, "trying calls to api gateway /admin should fail with wrong role")
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, "-H", petToken, targetHTTPAddressAdmin)

// will succeed because we use the token with the correct role and the correct issuer
logger.Log(t, "trying calls to api gateway /admin should succeed with JWT token with correct role")
k8s.CheckStaticServerConnectionSuccessful(t, k8sOptions, StaticClientName, "-H", doctorToken, targetHTTPAddressAdmin)

// ensure that overrides -> route extension -> default by making a request to the admin route with a JWT that has an issuer of "local" and a "role" of "pet"
// the route does not define
// we can see that:
// * the "iss" verification in the gateway override takes precedence over the "iss" verification in the route filter
// * the "role" verification in the route extension takes precedence over the "role" verification in the gateway default
// should fail because we're missing JWT
logger.Log(t, "trying calls to api gateway /pet should fail without JWT token")
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, targetHTTPAddressPet)

// should fail because we use the token with the wrong role and correct issuer
logger.Log(t, "trying calls to api gateway /pet should fail with wrong role")
k8s.CheckStaticServerHTTPConnectionFailing(t, k8sOptions, StaticClientName, "-H", doctorToken, targetHTTPAddressPet)

// will succeed because we use the token with the correct role and the correct issuer
logger.Log(t, "trying calls to api gateway /pet should succeed with JWT token with correct role")
k8s.CheckStaticServerConnectionSuccessful(t, k8sOptions, StaticClientName, "-H", petToken, targetHTTPAddressPet)
}

func checkStatusCondition(t require.TestingT, conditions []metav1.Condition, toCheck metav1.Condition) {
for _, c := range conditions {
if c.Type == toCheck.Type {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0

apiVersion: gateway.networking.k8s.io/v1beta1
kind: Gateway
metadata:
name: gateway
spec:
gatewayClassName: gateway-class
listeners:
- protocol: HTTP
port: 8080
name: http-auth
allowedRoutes:
namespaces:
from: "All"
- protocol: HTTP
port: 80
name: http
allowedRoutes:
namespaces:
from: "All"
- protocol: TCP
port: 81
name: tcp
allowedRoutes:
namespaces:
from: "All"
- protocol: HTTPS
port: 443
name: https
tls:
certificateRefs:
- name: "certificate"
allowedRoutes:
namespaces:
from: "All"
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
apiVersion: consul.hashicorp.com/v1alpha1
kind: ConsulGatewayPolicy
metadata:
name: my-policy
spec:
targetRef:
name: gateway
kind: Gateway
group: gateway.networking.kuberenetes.io
sectionName: http
override:
Providers:
- Provider: "local"
VerifyClaims:
- Path:
- "iss"
Value: "local"
default:
Providers:
- Provider: "local"
VerifyClaims:
- Path:
- "iss"
Value: "local"
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0

apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: http-route-auth
spec:
parentRefs:
- name: gateway
sectionName: http-auth
rules:
- matches:
- path:
type: PathPrefix
value: "/admin"
backendRefs:
- name: static-server
port: 80
filters:
- type: ExtensionRef
extensionRef:
group: consul.hashicorp.com
kind: HTTPRouteAuthFilter
name: route-jwt-auth-filter
- matches:
- path:
type: PathPrefix
value: "/pet"
backendRefs:
- name: static-server
port: 80
jm96441n marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0

apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
name: http-route
spec:
parentRefs:
- name: gateway
sectionName: http
rules:
- matches:
- path:
type: PathPrefix
value: "/v1"
backendRefs:
- name: static-server
port: 80
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: consul.hashicorp.com/v1alpha1
kind: JWTProvider
metadata:
name: local
spec:
issuer: local
jsonWebKeySet:
local:
jwks: "ewogICAgImtleXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicCI6ICI5TTlWSVhJR0hpR3FlTnhseEJ2V0xFV09oUFh3dXhXZUpod01uM3dGdG9STEtfZmF6VWxjWEc1cUViLTdpMXo3VmlPUWVZRnh6WUZYTS1pbVU3OVFRa1dTVUVSazR2dHZuc2R5UnpUSnVPc3A0ZUhuWFVMSHJPOU51NkJ5bC1VeVprMzFvSnFGeGllM0pHQXlRLUM2OVF2NVFkVjFZV0hfVDkyTzk4d1hYZGMiLAogICAgICAgICAgICAia3R5IjogIlJTQSIsCiAgICAgICAgICAgICJxIjogInFIVnZBb3h0ckgxUTVza25veXNMMkhvbC1ubnU3ZlM3Mjg4clRGdE9jeG9Jb29nWXBKVTljemxwcjctSlo2bjc0TUViVHBBMHRkSUR5TEtQQ0xIN3JKTFRrZzBDZVZNQWpmY01zdkRUcWdFOHNBWE42bzd2ZjYya2hwcExYOHVCU3JxSHkyV1JhZXJsbDROU09hcmRGSkQ2MWhHSVF2cEpXRk4xazFTV3pWcyIsCiAgICAgICAgICAgICJkIjogIlp3elJsVklRZkg5ekZ6d1hOZ2hEMHhkZVctalBCbmRkWnJNZ0wwQ2JjeXZZYlg2X1c0ajlhM1dmYWpobmI2bTFILW9CWjRMczVmNXNRVTB2ZFJ2ZG1laFItUG43aWNRcUdURFNKUTYtdWVtNm15UVRWaEo2UmZiM0lINVJ2VDJTOXUzcVFDZWFadWN3aXFoZ1RCbFhnOWFfV0pwVHJYNFhPQ3JCR1ZsTng3Z2JETVJOamNEN0FnRkZ3S2p2TEZVdDRLTkZmdEJqaFF0TDFLQ2VwblNmamtvRm1RUTVlX3RSS2ozX2U1V3pNSkJkekpQejNkR2YxZEk3OF9wYmJFbmFMcWhqNWg0WUx2UU5JUUhVcURYSGx4ZDc1Qlh3aFJReE1nUDRfd1EwTFk2cVRKNGFDa2Q0RDJBTUtqMzJqeVFiVTRKTE9jQjFNMnZBRWFyc2NTU3l0USIsCiAgICAgICAgICAgICJlIjogIkFRQUIiLAogICAgICAgICAgICAidXNlIjogInNpZyIsCiAgICAgICAgICAgICJraWQiOiAiQy1FMW5DandnQkMtUHVHTTJPNDY3RlJEaEt4OEFrVmN0SVNBYm8zcmlldyIsCiAgICAgICAgICAgICJxaSI6ICJ0N2VOQjhQV21xVHdKREZLQlZKZExrZnJJT2drMFJ4MnREODBGNHB5cjhmNzRuNGlVWXFmWG1haVZtbGx2c2FlT3JlNHlIczQ4UE45NVZsZlVvS3Z6ZEJFaDNZTDFINGZTOGlYYXNzNGJiVnVuWHR4U0hMZFFPYUNZYUplSmhBbGMyUWQ4elR0NFFQWk9yRWVWLVJTYU0tN095ekkwUWtSSF9tcmk1YmRrOXMiLAogICAgICAgICAgICAiZHAiOiAiYnBLckQtVXhrRENDal81MFZLU0NFeE1Ec1Zob2VBZm1tNjMxb1o5aDhUTkZ4TUU1YVptbUJ2VzBJUG9wMm1PUF9qTW9FVWxfUG1RYUlBOEgtVEdqTFp2QTMxSlZBeFN3TU5aQzdwaVFPRjYzVnhneTZUTzlmb1hENVdndC1oLUNxU1N6T2V3eFdmUWNTMmpMcTA3NUFxOTYwTnA2SHhjbE8weUdRN1JDSlpjIiwKICAgICAgICAgICAgImFsZyI6ICJQUzI1NiIsCiAgICAgICAgICAgICJkcSI6ICJpdVZveGwwckFKSEM1c2JzbTZpZWQ3c2ZIVXIwS2Rja0hiVFBLb0lPU1BFcU5YaXBlT3BrWkdEdU55NWlDTXNyRnNHaDFrRW9kTkhZdE40ay1USm5KSDliV296SGdXbGloNnN2R1V0Zi1raFMxWC16ckxaMTJudzlyNDRBbjllWG54bjFaVXMxZm5OakltM3dtZ083algyTWxIeVlNVUZVd0RMd09xNEFPUWsiLAogICAgICAgICAgICAibiI6ICJvUmhjeUREdmp3NFZ4SHRRNTZhRDlNSmRTaWhWSk1nTHd1b2FCQVhhc0RjVDNEWVZjcENlVGxDMVBPdzdPNW1Ec2ZSWVFtcGpoendyRDVZWU8yeDE4REl4czdyNTNJdFMxRy1ybnQxQ1diVE9fUzFJT01DR2xxYzh5VWJnLUhSUkRETXQyb2V3TjJoRGtxYlBKVFJNbXpjRkpNMHRpTm1RZVVMcWViZEVYaWVUblJMT1BkMWg2ZmJycVNLS01mSXlIbGZ1WXFQc1VWSEdkMVBESGljZ3NMazFtZDhtYTNIS1hWM0hJdzZrdUV6R0hQb1gxNHo4YWF6RFFZWndUR3ZxVGlPLUdRUlVDZUJueVo4bVhyWnRmSjNqVk83UUhXcEx3MlM1VDVwVTRwcE0xQXppWTFxUDVfY3ZpOTNZT2Zrb09PalRTX3V3RENZWGFxWjB5bTJHYlEiCiAgICAgICAgfQogICAgXQp9Cg=="
Loading