diff --git a/api/openapi-spec/openapi.yaml b/api/openapi-spec/openapi.yaml index a05ac9c..2452696 100644 --- a/api/openapi-spec/openapi.yaml +++ b/api/openapi-spec/openapi.yaml @@ -1273,6 +1273,62 @@ paths: security: - BearerAuth: [] - ApiKeyAuth: [] + /attendees/identity/{identity}: + get: + tags: + - privileged + summary: List registration ids for an identity + description: |- + List attendee registrations that are owned by a given identity, provided they are still visible to them. + + When a registration is first made, the currently logged in user is recorded in the registration, + and thus assigned as the 'owner' of the registration. + + Note that allowing multiple registrations per user id is configuration dependant. If the configuration + is set to allow only a single registration per user id, this endpoint will always return at most a single + registration. + operationId: listRegistrationsByIdentity + parameters: + - name: identity + in: path + description: identity provider id to look for + required: true + schema: + type: string + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/AttendeeIdList' + '400': + description: Invalid identity (must be nonempty and be alphanumeric) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authorization required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No matching attendees found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: An unexpected error occurred. This includes database errors. A best effort attempt is made to return details in the body. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + security: + - BearerAuth: [] + - ApiKeyAuth: [] /bans: get: tags: diff --git a/internal/service/attendeesrv/interfaces.go b/internal/service/attendeesrv/interfaces.go index 6fcfaad..3c7456c 100644 --- a/internal/service/attendeesrv/interfaces.go +++ b/internal/service/attendeesrv/interfaces.go @@ -52,6 +52,13 @@ type AttendeeService interface { // using this account. IsOwnerFor(ctx context.Context) ([]*entity.Attendee, error) + // IsOwnedByIdentity returns the list of attendees (registrations) that are owned by the + // given identity. + // + // Unless an admin has made changes to the database, this essentially means their registration was made + // using this account. + IsOwnedByIdentity(ctx context.Context, identity string) ([]*entity.Attendee, error) + // FindAttendees runs the search by criteria in the database, then filters and converts the result. FindAttendees(ctx context.Context, criteria *attendee.AttendeeSearchCriteria) (*attendee.AttendeeSearchResultList, error) diff --git a/internal/service/attendeesrv/status.go b/internal/service/attendeesrv/status.go index a8a6a4f..26d2136 100644 --- a/internal/service/attendeesrv/status.go +++ b/internal/service/attendeesrv/status.go @@ -345,6 +345,10 @@ func (s *AttendeeServiceImplData) checkPaidInFull(ctx context.Context, attendee func (s *AttendeeServiceImplData) IsOwnerFor(ctx context.Context) ([]*entity.Attendee, error) { identity := ctxvalues.Subject(ctx) + return s.IsOwnedByIdentity(ctx, identity) +} + +func (s *AttendeeServiceImplData) IsOwnedByIdentity(ctx context.Context, identity string) ([]*entity.Attendee, error) { if identity != "" { return database.GetRepository().FindByIdentity(ctx, identity) } else { diff --git a/internal/web/controller/adminctl/adminctl.go b/internal/web/controller/adminctl/adminctl.go index 0b1f8a3..53d347e 100644 --- a/internal/web/controller/adminctl/adminctl.go +++ b/internal/web/controller/adminctl/adminctl.go @@ -19,17 +19,24 @@ import ( "github.com/go-http-utils/headers" "net/http" "net/url" + "regexp" + "sort" "time" ) var attendeeService attendeesrv.AttendeeService +var identityRegexp *regexp.Regexp + func Create(server chi.Router, attendeeSrv attendeesrv.AttendeeService) { attendeeService = attendeeSrv server.Get("/api/rest/v1/attendees/{id}/admin", filter.HasGroupOrApiToken(config.OidcAdminGroup(), filter.WithTimeout(3*time.Second, getAdminInfoHandler))) server.Put("/api/rest/v1/attendees/{id}/admin", filter.HasGroupOrApiToken(config.OidcAdminGroup(), filter.WithTimeout(3*time.Second, writeAdminInfoHandler))) server.Post("/api/rest/v1/attendees/find", filter.LoggedInOrApiToken(filter.WithTimeout(60*time.Second, findAttendeesHandler))) + server.Get("/api/rest/v1/attendees/identity/{identity}", filter.HasGroupOrApiToken(config.OidcAdminGroup(), filter.WithTimeout(3*time.Second, regsByIdentityHandler))) + + identityRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") } // --- handlers --- @@ -167,6 +174,37 @@ func limitToAttendingStatus(desired []status.Status) []status.Status { } } +func regsByIdentityHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + identity := chi.URLParam(r, "identity") + if !identityRegexp.MatchString(identity) { + regsByIdentityInvalidErrorHandler(ctx, w, r, identity) + return + } + + atts, err := attendeeService.IsOwnedByIdentity(ctx, identity) + if err != nil { + regsByIdentityErrorHandler(ctx, w, r, identity, err) + return + } + if len(atts) == 0 { + regsByIdentityNotFoundErrorHandler(ctx, w, r, identity) + return + } + + dto := attendee.AttendeeIdList{ + Ids: make([]uint, len(atts)), + } + for i, _ := range atts { + dto.Ids[i] = atts[i].ID + } + sort.Slice(dto.Ids, func(i, j int) bool { return dto.Ids[i] < dto.Ids[j] }) + + w.Header().Add(headers.ContentType, media.ContentTypeApplicationJson) + ctlutil.WriteJson(ctx, w, dto) +} + // --- helpers --- func attendeeByIdMustReturnOnError(ctx context.Context, w http.ResponseWriter, r *http.Request) (*entity.Attendee, error) { @@ -235,3 +273,18 @@ func searchReadErrorHandler(ctx context.Context, w http.ResponseWriter, r *http. aulogging.Logger.Ctx(ctx).Warn().WithErr(err).Printf("attendee search failed: %s", err.Error()) ctlutil.ErrorHandler(ctx, w, r, "search.read.error", http.StatusInternalServerError, url.Values{}) } + +func regsByIdentityInvalidErrorHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, identity string) { + aulogging.Logger.Ctx(ctx).Warn().Printf("received invalid identity %s", url.QueryEscape(identity)) + ctlutil.ErrorHandler(ctx, w, r, "attendee.owned.invalid", http.StatusBadRequest, url.Values{"identity": []string{"identity id can only consist of A-Z, a-z, 0-9"}}) +} + +func regsByIdentityErrorHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, identity string, err error) { + aulogging.Logger.Ctx(ctx).Warn().WithErr(err).Printf("could not read registrations for identity %s: %s", identity, err.Error()) + ctlutil.ErrorHandler(ctx, w, r, "attendee.owned.error", http.StatusInternalServerError, url.Values{}) +} + +func regsByIdentityNotFoundErrorHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, identity string) { + aulogging.Logger.Ctx(ctx).Debug().Printf("found no registrations owned by identity %s", identity) + ctlutil.ErrorHandler(ctx, w, r, "attendee.owned.notfound", http.StatusNotFound, url.Values{}) +} diff --git a/internal/web/controller/attendeectl/config_test.go b/internal/web/controller/attendeectl/config_test.go index 05963f8..2b460f2 100644 --- a/internal/web/controller/attendeectl/config_test.go +++ b/internal/web/controller/attendeectl/config_test.go @@ -109,6 +109,10 @@ func (s *MockAttendeeService) IsOwnerFor(ctx context.Context) ([]*entity.Attende return make([]*entity.Attendee, 0), nil } +func (s *MockAttendeeService) IsOwnedByIdentity(ctx context.Context, identity string) ([]*entity.Attendee, error) { + return make([]*entity.Attendee, 0), nil +} + func (s *MockAttendeeService) FindAttendees(ctx context.Context, criteria *attendee.AttendeeSearchCriteria) (*attendee.AttendeeSearchResultList, error) { return &attendee.AttendeeSearchResultList{ Attendees: make([]attendee.AttendeeSearchResult, 0), diff --git a/test/acceptance/admin_acc_test.go b/test/acceptance/admin_acc_test.go index 8c7fe25..d24a69c 100644 --- a/test/acceptance/admin_acc_test.go +++ b/test/acceptance/admin_acc_test.go @@ -1,6 +1,7 @@ package acceptance import ( + "fmt" "github.com/eurofurence/reg-attendee-service/docs" "github.com/eurofurence/reg-attendee-service/internal/api/v1/admin" "github.com/eurofurence/reg-attendee-service/internal/api/v1/attendee" @@ -1449,6 +1450,135 @@ func TestSearch_InvalidJson(t *testing.T) { tstRequireErrorResponse(t, response, http.StatusBadRequest, "search.parse.error", url.Values{}) } +// -- registration ids by identity + +func TestRegsByIdentity_Anon(t *testing.T) { + tstSetup(true, false, true) + defer tstShutdown() + + docs.Given("given there are registrations") + token1 := tstValidUserToken(t, 1) + reg1 := tstBuildValidAttendee("bi1a-") + reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token1) + require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status") + + docs.Given("given an anonymous user") + + docs.When("when they request the list of registrations owned by the registered identity") + response := tstPerformGet("/api/rest/v1/attendees/identity/1234567890", tstNoToken()) + + docs.Then("then the request fails (401) and the error is as expected") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") +} + +func TestRegsByIdentity_User(t *testing.T) { + tstSetup(true, false, true) + defer tstShutdown() + + docs.Given("given there are registrations") + token1 := tstValidUserToken(t, 1) + reg1 := tstBuildValidAttendee("bi10a-") + reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token1) + require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status") + + docs.Given("given a regular user") + token101 := tstValidUserToken(t, 101) + + docs.When("when they request the list of registrations owned by the registered identity") + response := tstPerformGet("/api/rest/v1/attendees/identity/1234567890", token101) + + docs.Then("then the request fails (403) and the error is as expected") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this operation - the attempt has been logged") +} + +func TestRegsByIdentity_Admin_Success(t *testing.T) { + tstSetup(true, false, true) + defer tstShutdown() + + docs.Given("given there are registrations") + token101 := tstValidUserToken(t, 101) + reg1 := tstBuildValidAttendee("bi12a-") + reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token101) + require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status") + + docs.When("when an admin requests the list of registrations owned by a registered user") + response := tstPerformGet("/api/rest/v1/attendees/identity/101", tstValidAdminToken(t)) + + docs.Then("then the request is successful and returns that registration number") + require.Equal(t, http.StatusOK, response.status, "unexpected http response status") + actualResult := attendee.AttendeeIdList{} + tstParseJson(response.body, &actualResult) + require.Equal(t, 1, len(actualResult.Ids)) + actualLocation := fmt.Sprintf("/api/rest/v1/attendees/%d", actualResult.Ids[0]) + require.Equal(t, reg1response.location, actualLocation, "unexpected id returned") +} + +func TestRegsByIdentity_Admin_Deleted(t *testing.T) { + tstSetup(true, false, true) + defer tstShutdown() + + testcase := "bi11f-" + + docs.Given("given a user, who has made a single registration") + token101 := tstValidUserToken(t, 101) + reg1 := tstBuildValidAttendee(testcase) + reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token101) + require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status") + + docs.Given("given that registration has been deleted by an admin") + body := status.StatusChangeDto{ + Status: status.Deleted, + Comment: testcase, + } + statusResponse := tstPerformPost(reg1response.location+"/status", tstRenderJson(body), tstValidAdminToken(t)) + require.Equal(t, http.StatusNoContent, statusResponse.status) + + docs.When("when an admin requests the list of registrations owned by the identity of the user") + response := tstPerformGet("/api/rest/v1/attendees/identity/101", tstValidAdminToken(t)) + + docs.Then("then the request fails (404) and the error is as expected") + tstRequireErrorResponse(t, response, http.StatusNotFound, "attendee.owned.notfound", "") +} + +func TestRegsByIdentity_Admin_Other(t *testing.T) { + tstSetup(true, false, true) + defer tstShutdown() + + docs.Given("given there are registrations") + token101 := tstValidUserToken(t, 101) + reg1 := tstBuildValidAttendee("bi12a-") + reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token101) + require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status") + + docs.When("when an admin requests the list of registrations for a different user") + response := tstPerformGet("/api/rest/v1/attendees/identity/202", tstValidAdminToken(t)) + + docs.Then("then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusNotFound, "attendee.owned.notfound", "") +} + +func TestRegsByIdentity_ApiToken(t *testing.T) { + tstSetup(true, false, true) + defer tstShutdown() + + docs.Given("given there are registrations") + token1 := tstValidUserToken(t, 1) + reg1 := tstBuildValidAttendee("bi20a-") + reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token1) + require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status") + + docs.When("when an api requests the list of registrations owned by the registered identity") + response := tstPerformGet("/api/rest/v1/attendees/identity/1234567890", tstValidApiToken()) + + docs.Then("then the request is successful and returns that registration number") + require.Equal(t, http.StatusOK, response.status, "unexpected http response status") + actualResult := attendee.AttendeeIdList{} + tstParseJson(response.body, &actualResult) + require.Equal(t, 1, len(actualResult.Ids)) + actualLocation := fmt.Sprintf("/api/rest/v1/attendees/%d", actualResult.Ids[0]) + require.Equal(t, reg1response.location, actualLocation, "unexpected id returned") +} + // helper functions func tstRequireAdminInfoMatches(t *testing.T, expected admin.AdminInfoDto, body string) { diff --git a/test/acceptance/tokens_test.go b/test/acceptance/tokens_test.go index 07abb62..10e761d 100644 --- a/test/acceptance/tokens_test.go +++ b/test/acceptance/tokens_test.go @@ -9,7 +9,7 @@ func tstNoToken() string { return "" } -const valid_JWT_is_not_staff_sub1234567890 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjA3NTEyMDgxNiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwOi8vaWRlbnRpdHkubG9jYWxob3N0LyIsImp0aSI6IjQwNmJlM2U0LWY0ZTktNDdiNy1hYzVmLTA2YjkyNzQzMjg0OCIsIm5hbWUiOiJKb2huIERvZSIsIm5vbmNlIjoiMzBjODNjMTNjOTE3OTgwNGFhMGY5YjM5MzQyNTlkNzUiLCJyYXQiOjE2NzUxMTcxNzcsInNpZCI6ImQ3YjhmZTdhLTA3OWEtNDU5Ni04ZTUzLWE2MGY4NmEwOGFjNiIsInN1YiI6IjEyMzQ1Njc4OSJ9.XOy7LUJVsc7VBuintQDQ5asAbhmOEPzYNQwW0cxJhvlQMq77IBx1kUCCbg3_mstMopKQ85Njqhi5BksKpXuviRZE1BAzB5oQvIiB5IPyJrksm9Q5brJan37jclNc1rQN5wwAsGyY5alB4i9EeX4qo-ZWedtQPSdFTvUIOWf7-LpgWvc_xibQnPtbDwe1kkjbj6-fcubvkGI66yOylFGsg01jisYgWIIcV5N29KRffadJ2spk1tSCNvzTw-G4qcWHvBXQf2FUlOeKZSPV21-RwvHaTJYCyLCBt0CLDx847d44qaDBAxdntQI5KnhvEwthw-FvV0mPcgGA4fA-6l8v7A` +const valid_JWT_is_not_staff_sub1234567890 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjA3NTEyMDgxNiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwOi8vaWRlbnRpdHkubG9jYWxob3N0LyIsImp0aSI6IjQwNmJlM2U0LWY0ZTktNDdiNy1hYzVmLTA2YjkyNzQzMjg0OCIsIm5hbWUiOiJKb2huIERvZSIsIm5vbmNlIjoiMzBjODNjMTNjOTE3OTgwNGFhMGY5YjM5MzQyNTlkNzUiLCJyYXQiOjE2NzUxMTcxNzcsInNpZCI6ImQ3YjhmZTdhLTA3OWEtNDU5Ni04ZTUzLWE2MGY4NmEwOGFjNiIsInN1YiI6IjEyMzQ1Njc4OTAifQ.dOE4B-UkCZMpGwEERTD34AvFFM_VJSAMo-N1n3JrusVfcazfq8MBQ0LEr32stUrxAQAhPAaLHr2IlsUxYGhJ-OE5-oDI2n3-7_ixpqMLZKITgEd-RWkF89KSINJ8o53o_IFC8IgdYCIlC60II23TX7gkUJIAEKbRDIK08PgFep7c3LZygj-HZ54X_Q4nIJ5HtTD88XuedQSP9zd79R62dypGvpc38otv4fkw-u_lphDIVO8AzT6ZmscPnBu-oDRJqUlEpfvpcrw84kULpwnw5j1N-D54v4MJ36Y3LYA1_WCyRLECfacbX893CV7Khm4vlSg6fYCC-_PHo-2NMDHHDA` const valid_JWT_is_not_staff_sub101 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjA3NTEyMDgxNiwiZ3JvdXBzIjpbInNvbWVncm91cCJdLCJpYXQiOjE1MTYyMzkwMjIsImlzcyI6Imh0dHA6Ly9pZGVudGl0eS5sb2NhbGhvc3QvIiwianRpIjoiNDA2YmUzZTQtZjRlOS00N2I3LWFjNWYtMDZiOTI3NDMyODQ4IiwibmFtZSI6IkpvaG4gRG9lIiwibm9uY2UiOiIzMGM4M2MxM2M5MTc5ODA0YWEwZjliMzkzNDI1OWQ3NSIsInJhdCI6MTY3NTExNzE3Nywic2lkIjoiZDdiOGZlN2EtMDc5YS00NTk2LThlNTMtYTYwZjg2YTA4YWM2Iiwic3ViIjoiMTAxIn0.ntHz3G7LLtHC3pJ1PoWJoG3mnzg96IIcP3LAV4V1CcKYMFoKVQfh7MiOdRXpiB-_j4QFE7O-za3mynwFqRbF3_Tw_Sp7Zsgk9OUPo2Mk3VBSl9yPIU4pmc8v7nrmaAVOQLyjglVG7NLRWLpx0oIG8SSN0d75PBI5iLyQ0H7Zu0npEu6xekHeAYAg9DHQxqZInzom72aLmHdtG7tOqOgN0XphiK7zmIqm5aCg7R9_J9s0UU0g16_Phxm3DaynufGCjEPE2YrSL7hY9UVT2nfrHO7MvVOEKMG3RaKUDjzqOkLawz9TcUJlUTBc1J-91zYbdXLHYT_2b4EW_qa1C-P3Ow` const valid_JWT_ev_sub102 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjA3NTEyMDgxNiwiZ3JvdXBzIjpbImV2IiwiZnVyIl0sImlhdCI6MTUxNjIzOTAyMiwiaXNzIjoiaHR0cDovL2lkZW50aXR5LmxvY2FsaG9zdC8iLCJqdGkiOiI0MDZiZTNlNC1mNGU5LTQ3YjctYWM1Zi0wNmI5Mjc0MzI4NDgiLCJuYW1lIjoiSm9obiBEb2UiLCJub25jZSI6IjMwYzgzYzEzYzkxNzk4MDRhYTBmOWIzOTM0MjU5ZDc1IiwicmF0IjoxNjc1MTE3MTc3LCJzaWQiOiJkN2I4ZmU3YS0wNzlhLTQ1OTYtOGU1My1hNjBmODZhMDhhYzYiLCJzdWIiOiIxMDIifQ.qzHiYNkcr8Hkqpe86F_C849Z06TS1ZxkFYsiqvFFS__mVkbSS9jbUhCJNfckCc0dZleTfN8L1w7RK0fD1PQR3hsF-Wy4sZE9-ZzW7P1sNmYkmY68w4avpAMs7Fn3_o9Ros25oOqcEbu0d4M43GYDX8dwA729Jtle8N46LjJXhuYG6wz_K59qVd8kTMbUgm5GapWdrQs4Qlswnf_K1G5HXhAi7mrrMZOGejDODeofHPGukY1TZfMfMEUgJmlIn2nn6hu8fyyvpIDgaQpg1LKKw5JYzVi_EAjqz0xzXzvsJ1Tacj2aoXFDCxOawG-6-ID2Q4uPAJvZ9GTdmmePsJuhxw` const valid_JWT_is_not_staff_sub101_unverified = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJleHAiOjIwNzUxMjA4MTYsImdyb3VwcyI6WyJzb21lZ3JvdXAiXSwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwOi8vaWRlbnRpdHkubG9jYWxob3N0LyIsImp0aSI6IjQwNmJlM2U0LWY0ZTktNDdiNy1hYzVmLTA2YjkyNzQzMjg0OCIsIm5hbWUiOiJKb2huIERvZSIsIm5vbmNlIjoiMzBjODNjMTNjOTE3OTgwNGFhMGY5YjM5MzQyNTlkNzUiLCJyYXQiOjE2NzUxMTcxNzcsInNpZCI6ImQ3YjhmZTdhLTA3OWEtNDU5Ni04ZTUzLWE2MGY4NmEwOGFjNiIsInN1YiI6IjEwMSJ9.QewwmuCatUYhcJPk_JZPeOqJOmh0XlbT9CKWPmjXT-ODX-oWZ2Dop3-J2xsMRSbMn23m1mXy8SXcUjIuFFzMcZCZY6O2-HD9igskn6e8yg8WBi2QnP-sOrWfvaLfnVORYwVxyO3o9eeWPhPjDaFVGvg7rzho_IVIXg0LqluN2ID3RcBc5JuzDGwm0YpuC9gJr1I5rDLADbXF3pLVDTGWFXrlln_1vbzhnPvKAJNPFhKwtuIEmKuLC9OgzW4bIjbPHU_A4dCfa7aAZ4D2RId7rBUOyVKIXQR0_K7UwIjx-oJlDyQsj0OSzgGsj6FUMJSZMI8lXOdH1i1haWc7ekbZqg`