package sendico import ( "bytes" "context" "encoding/json" "io" "mime/multipart" "net/http" "strconv" "time" "git.sendico.io/tech/sxchnge/product/identity" merrors "git.sendico.io/tech/sxchnge/shared/errors" ) type profileDTO struct { FullName string `json:"full_name"` DateOfBirth string `json:"date_of_birth"` ResidenceCountry string `json:"residence_country"` Email string `json:"email"` CustomerRef string `json:"external_customer_ref"` } type verificationDTO struct { SubjectRef string `json:"subject_ref"` SubmissionRef string `json:"submission_ref"` Status string `json:"status"` Reason string `json:"reason"` Profile profileDTO `json:"profile"` UpgradePending bool `json:"upgrade_pending"` } type policyDTO struct { Tier string `json:"tier"` ProfileRef string `json:"profile_ref"` Entries []limitDTO `json:"entries"` } type limitDTO struct { Operation string `json:"operation"` Period string `json:"period"` Currency string `json:"currency"` Cap string `json:"cap"` Remaining string `json:"remaining"` Used string `json:"used"` Reserved string `json:"reserved"` } func (c *Client) identityRequest(ctx context.Context, subject, suffix, method, contentType string, body []byte, dst any) error { if !validRef(subject) { return merrors.InvalidArgument("invalid bound subject") } token, err := c.token() if err != nil { return err } req, err := http.NewRequestWithContext(ctx, method, c.base+"/identity_facts/"+c.config.OrganizationID+"/proofs/"+subject+suffix, bytes.NewReader(body)) if err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create identity request") } req.Header.Set("Authorization", "Bearer "+token) if contentType != "" { req.Header.Set("Content-Type", contentType) } // Uploads need a longer bound than small sign-up receipts. The base client is // copied so concurrent sign-up operations keep their own four-second timeout. client := *c.http client.Timeout = 60 * time.Second res, err := client.Do(req) if err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "identity provider request failed") } defer func() { _ = res.Body.Close() }() switch res.StatusCode { case http.StatusOK: case http.StatusBadRequest, http.StatusRequestEntityTooLarge: return merrors.InvalidArgument("identity profile or photos were not accepted") case http.StatusConflict: return merrors.DataConflict("identity submission changed; refresh") case http.StatusForbidden: return merrors.PermissionDenied("identity resource unavailable") case http.StatusTooManyRequests: retry := 60 if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && seconds <= 86400 { retry = seconds } return merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", time.Duration(retry)*time.Second, "identity submission temporarily limited") default: return merrors.ServiceUnavailable("identity provider unavailable") } var envelope struct { Status string `json:"status"` Data json.RawMessage `json:"data"` } decoder := json.NewDecoder(io.LimitReader(res.Body, 256*1024+1)) if err := decoder.Decode(&envelope); err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity response") } var extra any if decoder.Decode(&extra) != io.EOF || envelope.Status != "success" { return merrors.ServiceUnavailable("invalid identity response") } if err := json.Unmarshal(envelope.Data, dst); err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity payload") } return nil } func mapVerification(dto verificationDTO, subject string) (identity.Verification, error) { if dto.SubjectRef != subject { return identity.Verification{}, merrors.ServiceUnavailable("identity response scope mismatch") } switch dto.Status { case "unverified", "pending", "approved", "changesRequested", "denied": default: return identity.Verification{}, merrors.ServiceUnavailable("invalid identity status") } if dto.Status == "approved" && dto.UpgradePending { dto.Status = "pending" } if dto.Status != "unverified" && !validRef(dto.SubmissionRef) { return identity.Verification{}, merrors.ServiceUnavailable("invalid identity receipt") } profile := identity.Profile{FullName: dto.Profile.FullName, DateOfBirth: dto.Profile.DateOfBirth, ResidenceCountry: dto.Profile.ResidenceCountry} if dto.Status != "unverified" { if err := profile.Validate(); err != nil { return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid identity profile response") } } return identity.Verification{Status: dto.Status, UpgradePending: dto.UpgradePending, Reason: dto.Reason, SubmissionID: dto.SubmissionRef, Profile: profile}, nil } func (c *Client) Verification(ctx context.Context, subject string) (identity.Verification, error) { var dto verificationDTO if err := c.identityRequest(ctx, subject, "", http.MethodGet, "", nil, &dto); err != nil { return identity.Verification{}, err } return mapVerification(dto, subject) } func (c *Client) SubmitVerification(ctx context.Context, subject, key string, p identity.Profile, f identity.Facts, document, selfie []byte) (identity.Verification, error) { var body bytes.Buffer writer := multipart.NewWriter(&body) profile, err := json.Marshal(profileDTO{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry, Email: f.Email, CustomerRef: f.CustomerRef}) if err != nil { return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrInternal, err, "encode identity profile") } if err = writer.WriteField("profile", string(profile)); err != nil { return identity.Verification{}, err } if err = writer.WriteField("source_submission_ref", key); err != nil { return identity.Verification{}, err } for i, data := range [][]byte{document, selfie} { name := "document" if i == 1 { name = "selfie" } part, writeErr := writer.CreateFormFile(name, name) if writeErr != nil { return identity.Verification{}, writeErr } if _, writeErr = part.Write(data); writeErr != nil { return identity.Verification{}, writeErr } } if err = writer.Close(); err != nil { return identity.Verification{}, err } var dto verificationDTO if err = c.identityRequest(ctx, subject, "", http.MethodPost, writer.FormDataContentType(), body.Bytes(), &dto); err != nil { return identity.Verification{}, err } return mapVerification(dto, subject) } func (c *Client) Limits(ctx context.Context, subject string) (identity.Limits, error) { var dto policyDTO if err := c.identityRequest(ctx, subject, "/limits", http.MethodGet, "", nil, &dto); err != nil { return identity.Limits{}, err } if (dto.Tier != "basic" && dto.Tier != "verified") || dto.ProfileRef == "" || dto.Entries == nil { return identity.Limits{}, merrors.ServiceUnavailable("invalid limits response") } entries := make([]identity.Limit, 0, len(dto.Entries)) for _, v := range dto.Entries { entries = append(entries, identity.Limit{Operation: v.Operation, Period: v.Period, Currency: v.Currency, Cap: v.Cap, Remaining: v.Remaining, Used: v.Used, Reserved: v.Reserved}) } return identity.Limits{Tier: dto.Tier, ProfileRef: dto.ProfileRef, Entries: entries}, nil }