From 9b234d07b7eecfc715e7ccb447aa27ac99b98ca8 Mon Sep 17 00:00:00 2001 From: nikhilPank <49190426+nikhilPank@users.noreply.github.com> Date: Fri, 28 Jul 2023 12:28:51 +0100 Subject: [PATCH 01/11] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 787a4f1e..66a2bd37 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ For each service you will need: ## Support -For any questions or support please email [clientsupport@yoti.com](mailto:clientsupport@yoti.com). +For any questions or support please contact us here: https://support.yoti.com Please provide the following to get you up and working as quickly as possible: * Computer type From 397ff55ab40d74196622206b0c9fa8890c73e14f Mon Sep 17 00:00:00 2001 From: nikhilPank <49190426+nikhilPank@users.noreply.github.com> Date: Fri, 28 Jul 2023 13:47:11 +0100 Subject: [PATCH 02/11] Update ISSUE_TEMPLATE.md --- .github/ISSUE_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index cce3710d..98f0c89d 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -12,6 +12,6 @@ assignees: '' # # There's a better way to get help! # -# Send your questions or issues to sdksupport@yoti.com +# Send your questions or issues to https://support.yoti.com # # From 8607a795d21b3b6922cd7a196ca19aaec3066730 Mon Sep 17 00:00:00 2001 From: mehmet-yoti <111424390+mehmet-yoti@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:38:17 +0000 Subject: [PATCH 03/11] Sdk 2230: Added ShareV2 Create Session, retrieve session, Create Qr, Retrieve Qr, Retrieve Receipt Added ShareV2 Create Session, retrieve session, Create Qr, Retrieve Qr, Retrieve Receipt --- .github/workflows/tests.yaml | 2 +- .gitignore | 4 +- _examples/.gitignore | 8 + _examples/aml/go.mod | 2 +- _examples/aml/main.go | 3 +- _examples/digitalidentity/.env.example | 2 + _examples/digitalidentity/.gitignore | 8 + _examples/digitalidentity/README.md | 46 ++ .../digitalidentity/certificatehelper.go | 175 +++++ _examples/digitalidentity/error.go | 24 + _examples/digitalidentity/error.html | 11 + _examples/digitalidentity/go.mod | 12 + _examples/digitalidentity/go.sum | 11 + _examples/digitalidentity/login.html | 85 +++ _examples/digitalidentity/main.go | 167 +++++ .../static/assets/app-store-badge.png | Bin 0 -> 4077 bytes .../static/assets/app-store-badge@2x.png | Bin 0 -> 8819 bytes .../static/assets/company-logo.jpg | Bin 0 -> 4682 bytes .../static/assets/google-play-badge.png | Bin 0 -> 4957 bytes .../static/assets/google-play-badge@2x.png | Bin 0 -> 11267 bytes .../static/assets/icons/address.svg | 4 + .../static/assets/icons/calendar.svg | 6 + .../static/assets/icons/chevron-down-grey.svg | 9 + .../static/assets/icons/document.svg | 4 + .../static/assets/icons/email.svg | 17 + .../static/assets/icons/gender.svg | 6 + .../static/assets/icons/nationality.svg | 4 + .../static/assets/icons/phone.svg | 4 + .../static/assets/icons/profile.svg | 4 + .../static/assets/icons/verified.svg | 7 + .../digitalidentity/static/assets/logo.png | Bin 0 -> 2988 bytes .../digitalidentity/static/assets/logo@2x.png | Bin 0 -> 5609 bytes _examples/digitalidentity/static/index.css | 173 +++++ _examples/digitalidentity/static/profile.css | 431 +++++++++++ _examples/docscansandbox/demo_test.go | 3 +- _examples/docscansandbox/go.mod | 3 +- _examples/docscansandbox/go.sum | 6 + _examples/idv/go.mod | 2 +- _examples/idv/handlers.session.go | 3 +- _examples/profile/README.md | 2 +- _examples/profile/dynamic-share.html | 2 +- _examples/profile/dynamicshare.go | 3 +- _examples/profile/go.mod | 2 +- _examples/profile/profile.go | 3 +- _examples/profilesandbox/go.mod | 2 +- aml/service.go | 4 +- aml/service_test.go | 8 +- yoti_client.go => client.go | 0 yoti_client_test.go => client_test.go | 38 +- cryptoutil/crypto_utils.go | 48 ++ cryptoutil/crypto_utils_test.go | 3 +- digital_identity_client.go | 88 +++ digital_identity_client_test.go | 168 +++++ digitalidentity/address.go | 52 ++ digitalidentity/application_profile.go | 50 ++ .../attribute/age_verifications.go | 34 + .../attribute/age_verifications_test.go | 42 ++ .../attribute/anchor/anchor_parser.go | 110 +++ .../attribute/anchor/anchor_parser_test.go | 147 ++++ digitalidentity/attribute/anchor/anchors.go | 105 +++ .../attribute/anchor/anchors_test.go | 20 + .../attribute/anchor/signed_timestamp.go | 35 + .../attribute/attribute_details.go | 48 ++ digitalidentity/attribute/attribute_test.go | 36 + digitalidentity/attribute/date_attribute.go | 39 + .../attribute/date_attribute_test.go | 44 ++ digitalidentity/attribute/definition.go | 31 + digitalidentity/attribute/definition_test.go | 18 + .../attribute/document_details_attribute.go | 87 +++ .../document_details_attribute_test.go | 185 +++++ .../attribute/generic_attribute.go | 38 + .../attribute/generic_attribute_test.go | 39 + digitalidentity/attribute/helper_test.go | 21 + digitalidentity/attribute/image_attribute.go | 53 ++ .../attribute/image_attribute_test.go | 106 +++ .../attribute/image_slice_attribute.go | 69 ++ .../attribute/image_slice_attribute_test.go | 61 ++ digitalidentity/attribute/issuance_details.go | 86 +++ .../attribute/issuance_details_test.go | 145 ++++ digitalidentity/attribute/item.go | 14 + digitalidentity/attribute/json_attribute.go | 58 ++ .../attribute/json_attribute_test.go | 76 ++ .../attribute/multivalue_attribute.go | 90 +++ .../attribute/multivalue_attribute_test.go | 157 ++++ digitalidentity/attribute/parser.go | 56 ++ digitalidentity/attribute/parser_test.go | 16 + digitalidentity/attribute/string_attribute.go | 32 + .../attribute/string_attribute_test.go | 22 + digitalidentity/base_profile.go | 75 ++ digitalidentity/policy_builder.go | 250 +++++++ digitalidentity/policy_builder_test.go | 439 +++++++++++ digitalidentity/qr_code.go | 6 + digitalidentity/receipt.go | 19 + digitalidentity/receipt_item_key.go | 7 + digitalidentity/requests/client.go | 10 + digitalidentity/requests/request.go | 40 + digitalidentity/requests/request_test.go | 71 ++ digitalidentity/requests/signed_message.go | 233 ++++++ .../requests/signed_message_test.go | 169 +++++ digitalidentity/service.go | 303 ++++++++ digitalidentity/service_test.go | 148 ++++ digitalidentity/share_receipt.go | 24 + digitalidentity/share_retrieve_qr.go | 10 + digitalidentity/share_session.go | 21 + digitalidentity/share_session_builder.go | 75 ++ digitalidentity/share_session_builder_test.go | 99 +++ digitalidentity/share_session_created.go | 8 + .../share_session_notification_builder.go | 62 ++ ...share_session_notification_builder_test.go | 95 +++ digitalidentity/share_session_qr_code.go | 14 + digitalidentity/source_constraint.go | 105 +++ digitalidentity/user_profile.go | 182 +++++ digitalidentity/user_profile_test.go | 704 ++++++++++++++++++ digitalidentity/wanted_anchor_builder.go | 44 ++ digitalidentity/wanted_anchor_builder_test.go | 25 + digitalidentity/wanted_attribute_builder.go | 81 ++ digitalidentity/wanted_attribute_test.go | 154 ++++ digitalidentity/yotierror/response.go | 56 ++ digitalidentity/yotierror/response_test.go | 68 ++ digitalidentity/yotierror/signed_requests.go | 8 + docscan/client.go | 10 +- docscan/client_test.go | 38 +- docscan/sandbox/client_test.go | 16 +- dynamic/policy_builder.go | 4 +- dynamic/policy_builder_test.go | 4 +- dynamic/service.go | 4 +- dynamic/service_test.go | 6 +- file/file.go | 4 +- go.mod | 2 +- profile/sandbox/client.go | 4 +- profile/sandbox/client_test.go | 10 +- profile/service.go | 4 +- profile/service_test.go | 25 +- requests/signed_message.go | 10 + sh/go-build-modtidy.sh | 2 +- sonar-project.properties | 1 + test/key.go | 4 +- yotierror/response.go | 10 +- yotierror/response_test.go | 18 +- 139 files changed, 7491 insertions(+), 129 deletions(-) create mode 100644 _examples/digitalidentity/.env.example create mode 100644 _examples/digitalidentity/.gitignore create mode 100644 _examples/digitalidentity/README.md create mode 100644 _examples/digitalidentity/certificatehelper.go create mode 100644 _examples/digitalidentity/error.go create mode 100644 _examples/digitalidentity/error.html create mode 100644 _examples/digitalidentity/go.mod create mode 100644 _examples/digitalidentity/go.sum create mode 100644 _examples/digitalidentity/login.html create mode 100644 _examples/digitalidentity/main.go create mode 100755 _examples/digitalidentity/static/assets/app-store-badge.png create mode 100755 _examples/digitalidentity/static/assets/app-store-badge@2x.png create mode 100644 _examples/digitalidentity/static/assets/company-logo.jpg create mode 100755 _examples/digitalidentity/static/assets/google-play-badge.png create mode 100755 _examples/digitalidentity/static/assets/google-play-badge@2x.png create mode 100755 _examples/digitalidentity/static/assets/icons/address.svg create mode 100755 _examples/digitalidentity/static/assets/icons/calendar.svg create mode 100644 _examples/digitalidentity/static/assets/icons/chevron-down-grey.svg create mode 100755 _examples/digitalidentity/static/assets/icons/document.svg create mode 100755 _examples/digitalidentity/static/assets/icons/email.svg create mode 100755 _examples/digitalidentity/static/assets/icons/gender.svg create mode 100755 _examples/digitalidentity/static/assets/icons/nationality.svg create mode 100755 _examples/digitalidentity/static/assets/icons/phone.svg create mode 100755 _examples/digitalidentity/static/assets/icons/profile.svg create mode 100755 _examples/digitalidentity/static/assets/icons/verified.svg create mode 100755 _examples/digitalidentity/static/assets/logo.png create mode 100755 _examples/digitalidentity/static/assets/logo@2x.png create mode 100644 _examples/digitalidentity/static/index.css create mode 100644 _examples/digitalidentity/static/profile.css rename yoti_client.go => client.go (100%) rename yoti_client_test.go => client_test.go (80%) create mode 100644 digital_identity_client.go create mode 100644 digital_identity_client_test.go create mode 100644 digitalidentity/address.go create mode 100644 digitalidentity/application_profile.go create mode 100644 digitalidentity/attribute/age_verifications.go create mode 100644 digitalidentity/attribute/age_verifications_test.go create mode 100644 digitalidentity/attribute/anchor/anchor_parser.go create mode 100644 digitalidentity/attribute/anchor/anchor_parser_test.go create mode 100644 digitalidentity/attribute/anchor/anchors.go create mode 100644 digitalidentity/attribute/anchor/anchors_test.go create mode 100644 digitalidentity/attribute/anchor/signed_timestamp.go create mode 100644 digitalidentity/attribute/attribute_details.go create mode 100644 digitalidentity/attribute/attribute_test.go create mode 100644 digitalidentity/attribute/date_attribute.go create mode 100644 digitalidentity/attribute/date_attribute_test.go create mode 100644 digitalidentity/attribute/definition.go create mode 100644 digitalidentity/attribute/definition_test.go create mode 100644 digitalidentity/attribute/document_details_attribute.go create mode 100644 digitalidentity/attribute/document_details_attribute_test.go create mode 100644 digitalidentity/attribute/generic_attribute.go create mode 100644 digitalidentity/attribute/generic_attribute_test.go create mode 100644 digitalidentity/attribute/helper_test.go create mode 100644 digitalidentity/attribute/image_attribute.go create mode 100644 digitalidentity/attribute/image_attribute_test.go create mode 100644 digitalidentity/attribute/image_slice_attribute.go create mode 100644 digitalidentity/attribute/image_slice_attribute_test.go create mode 100644 digitalidentity/attribute/issuance_details.go create mode 100644 digitalidentity/attribute/issuance_details_test.go create mode 100644 digitalidentity/attribute/item.go create mode 100644 digitalidentity/attribute/json_attribute.go create mode 100644 digitalidentity/attribute/json_attribute_test.go create mode 100644 digitalidentity/attribute/multivalue_attribute.go create mode 100644 digitalidentity/attribute/multivalue_attribute_test.go create mode 100644 digitalidentity/attribute/parser.go create mode 100644 digitalidentity/attribute/parser_test.go create mode 100644 digitalidentity/attribute/string_attribute.go create mode 100644 digitalidentity/attribute/string_attribute_test.go create mode 100644 digitalidentity/base_profile.go create mode 100644 digitalidentity/policy_builder.go create mode 100644 digitalidentity/policy_builder_test.go create mode 100644 digitalidentity/qr_code.go create mode 100644 digitalidentity/receipt.go create mode 100644 digitalidentity/receipt_item_key.go create mode 100644 digitalidentity/requests/client.go create mode 100644 digitalidentity/requests/request.go create mode 100644 digitalidentity/requests/request_test.go create mode 100644 digitalidentity/requests/signed_message.go create mode 100644 digitalidentity/requests/signed_message_test.go create mode 100644 digitalidentity/service.go create mode 100644 digitalidentity/service_test.go create mode 100644 digitalidentity/share_receipt.go create mode 100644 digitalidentity/share_retrieve_qr.go create mode 100644 digitalidentity/share_session.go create mode 100644 digitalidentity/share_session_builder.go create mode 100644 digitalidentity/share_session_builder_test.go create mode 100644 digitalidentity/share_session_created.go create mode 100644 digitalidentity/share_session_notification_builder.go create mode 100644 digitalidentity/share_session_notification_builder_test.go create mode 100644 digitalidentity/share_session_qr_code.go create mode 100644 digitalidentity/source_constraint.go create mode 100644 digitalidentity/user_profile.go create mode 100644 digitalidentity/user_profile_test.go create mode 100644 digitalidentity/wanted_anchor_builder.go create mode 100644 digitalidentity/wanted_anchor_builder_test.go create mode 100644 digitalidentity/wanted_attribute_builder.go create mode 100644 digitalidentity/wanted_attribute_test.go create mode 100644 digitalidentity/yotierror/response.go create mode 100644 digitalidentity/yotierror/response_test.go create mode 100644 digitalidentity/yotierror/signed_requests.go diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 22e447a5..658d3630 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - go-version: [1.17, 1.18, "^1"] + go-version: [1.19, "^1"] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 519f0d3a..842d1127 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,9 @@ debug # Report files sonar-report.json coverage.out +report.json # idea files .idea -# Generated binaries -/_examples/docscan/docscan + diff --git a/_examples/.gitignore b/_examples/.gitignore index 4c49bd78..041d9903 100644 --- a/_examples/.gitignore +++ b/_examples/.gitignore @@ -1 +1,9 @@ .env +# Generated binaries +docscan/docscan +idv/idv +aml/aml +docscansandbox/docscansandbox +profile/profile +profilesandbox/profilesandbox +digitalidentity/digitalidentity \ No newline at end of file diff --git a/_examples/aml/go.mod b/_examples/aml/go.mod index 16e5ef7a..099ca781 100644 --- a/_examples/aml/go.mod +++ b/_examples/aml/go.mod @@ -1,6 +1,6 @@ module aml -go 1.17 +go 1.19 require ( github.com/getyoti/yoti-go-sdk/v3 v3.0.0 diff --git a/_examples/aml/main.go b/_examples/aml/main.go index d263aee9..ab8c9f29 100644 --- a/_examples/aml/main.go +++ b/_examples/aml/main.go @@ -1,7 +1,6 @@ package main import ( - "io/ioutil" "log" "os" "strconv" @@ -19,7 +18,7 @@ var ( func main() { var err error - key, err = ioutil.ReadFile(os.Getenv("YOTI_KEY_FILE_PATH")) + key, err = os.ReadFile(os.Getenv("YOTI_KEY_FILE_PATH")) sdkID = os.Getenv("YOTI_CLIENT_SDK_ID") if err != nil { diff --git a/_examples/digitalidentity/.env.example b/_examples/digitalidentity/.env.example new file mode 100644 index 00000000..dc09949e --- /dev/null +++ b/_examples/digitalidentity/.env.example @@ -0,0 +1,2 @@ +YOTI_CLIENT_SDK_ID= +YOTI_KEY_FILE_PATH= \ No newline at end of file diff --git a/_examples/digitalidentity/.gitignore b/_examples/digitalidentity/.gitignore new file mode 100644 index 00000000..1418f336 --- /dev/null +++ b/_examples/digitalidentity/.gitignore @@ -0,0 +1,8 @@ +/images/YotiSelfie.jpeg + +# Example project generated self-signed certificate +/yotiSelfSignedCert.pem +/yotiSelfSignedKey.pem + +# Compiled binary +/digitalidentity diff --git a/_examples/digitalidentity/README.md b/_examples/digitalidentity/README.md new file mode 100644 index 00000000..703e89ef --- /dev/null +++ b/_examples/digitalidentity/README.md @@ -0,0 +1,46 @@ +## Table of Contents + +1) [Setup](#setup) - +How to initialise the Yoti client + +1) [Running the digitalidentity examples](#running-the-profile-example) - +Running the digitalidentity example + +## Setup + +The YotiClient is the SDK entry point. To initialise it you need include the following snippet inside your endpoint initialisation section: + +```Go +clientSdkID := "your-client-sdk-id" +key, err := os.ReadFile("path/to/your-application-pem-file.pem") +if err != nil { + // handle key load error +} + +client, err := yoti.NewClient( + clientSdkID, + key) +``` + +Where: + +* `"your-client-sdk-id"` is the SDK Client Identifier generated by Yoti Hub in the Key tab when you create your application. + +* `path/to/your-application-pem-file.pem` is the path to the application pem file. It can be downloaded from the Keys tab in the [Yoti Hub](https://hub.yoti.com/). + +Please do not open the pem file as this might corrupt the key, and you will need regenerate your key. + +Keeping your settings and access keys outside your repository is highly recommended. You can use a package like [godotenv](https://github.com/joho/godotenv) to manage environment variables more easily. + + +## Running the DigitalIdentity Example + +1. Change directory to the profile example folder: `cd _examples/digitalidentity` +2. On the [Yoti Hub](https://hub.yoti.com/): + 1. Set the application domain of your app to `localhost:8080` + 2. Set the scenario callback URL to `/digitalidentity` +3. Rename the [.env.example](_examples/digitalidentity/.env.example) file to `.env` and fill in the required configuration values (mentioned in the [Configuration](#configuration) section) +4. Build with `go build` +5. Start the compiled program by running `./digitalidentity` + +Visiting `https://localhost:8080/` should show a webpage with a Yoti button rendered on it diff --git a/_examples/digitalidentity/certificatehelper.go b/_examples/digitalidentity/certificatehelper.go new file mode 100644 index 00000000..cdcb4f23 --- /dev/null +++ b/_examples/digitalidentity/certificatehelper.go @@ -0,0 +1,175 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "log" + "math/big" + "net" + "os" + "strings" + "time" +) + +var ( + validFrom = "" + validFor = 2 * 365 * 24 * time.Hour + isCA = true + rsaBits = 2048 +) + +func publicKey(priv interface{}) interface{} { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} + +func pemBlockForKey(priv interface{}) *pem.Block { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} + case *ecdsa.PrivateKey: + b, err := x509.MarshalECPrivateKey(k) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to marshal ECDSA private key: %v", err) + os.Exit(2) + } + return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + default: + return nil + } +} + +func certificatePresenceCheck(certPath string, keyPath string) (present bool) { + if _, err := os.Stat(certPath); os.IsNotExist(err) { + return false + } + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + return false + } + return true +} + +func generateSelfSignedCertificate(certPath, keyPath, host string) error { + priv, err := rsa.GenerateKey(rand.Reader, rsaBits) + if err != nil { + log.Printf("failed to generate private key: %s", err) + return err + } + + notBefore, err := parseNotBefore(validFrom) + if err != nil { + log.Printf("failed to parse 'Not Before' value of cert using validFrom %q, error was: %s", validFrom, err) + return err + } + + notAfter := notBefore.Add(validFor) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + log.Printf("failed to generate serial number: %s", err) + return err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Yoti"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + hosts := strings.Split(host, ",") + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + if isCA { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) + if err != nil { + log.Printf("Failed to create certificate: %s", err) + return err + } + + err = createPemFile(certPath, derBytes) + if err != nil { + log.Printf("failed to create pem file at %q: %s", certPath, err) + return err + } + log.Printf("written %s\n", certPath) + + err = createKeyFile(keyPath, priv) + if err != nil { + log.Printf("failed to create key file at %q: %s", keyPath, err) + return err + } + log.Printf("written %s\n", keyPath) + + return nil +} + +func createPemFile(certPath string, derBytes []byte) error { + certOut, err := os.Create(certPath) + + if err != nil { + log.Printf("failed to open "+certPath+" for writing: %s", err) + return err + } + + defer certOut.Close() + err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + return err +} + +func createKeyFile(keyPath string, privateKey interface{}) error { + keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + + if err != nil { + log.Print("failed to open "+keyPath+" for writing:", err) + return err + } + + defer keyOut.Close() + err = pem.Encode(keyOut, pemBlockForKey(privateKey)) + + return err +} + +func parseNotBefore(validFrom string) (notBefore time.Time, err error) { + if len(validFrom) == 0 { + notBefore = time.Now() + } else { + notBefore, err = time.Parse("Jan 2 15:04:05 2006", validFrom) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse creation date: %s\n", err) + return time.Time{}, err + } + } + + return notBefore, nil +} diff --git a/_examples/digitalidentity/error.go b/_examples/digitalidentity/error.go new file mode 100644 index 00000000..71739df2 --- /dev/null +++ b/_examples/digitalidentity/error.go @@ -0,0 +1,24 @@ +package main + +import ( + "html/template" + "log" + "net/http" +) + +func errorPage(w http.ResponseWriter, r *http.Request) { + templateVars := map[string]interface{}{ + "yotiError": r.Context().Value(contextKey("yotiError")).(string), + } + log.Printf("%s", templateVars["yotiError"]) + t, err := template.ParseFiles("error.html") + if err != nil { + panic(errParsingTheTemplate + err.Error()) + } + + err = t.Execute(w, templateVars) + if err != nil { + panic(errApplyingTheParsedTemplate + err.Error()) + } + +} diff --git a/_examples/digitalidentity/error.html b/_examples/digitalidentity/error.html new file mode 100644 index 00000000..73d7e730 --- /dev/null +++ b/_examples/digitalidentity/error.html @@ -0,0 +1,11 @@ + + + + + Yoti Example Project - Error + + +

An Error Occurred

+

Error: {{.yotiError}}

+ + \ No newline at end of file diff --git a/_examples/digitalidentity/go.mod b/_examples/digitalidentity/go.mod new file mode 100644 index 00000000..7425a0d4 --- /dev/null +++ b/_examples/digitalidentity/go.mod @@ -0,0 +1,12 @@ +module digitalidentity + +go 1.19 + +require ( + github.com/getyoti/yoti-go-sdk/v3 v3.0.0 + github.com/joho/godotenv v1.3.0 +) + +require google.golang.org/protobuf v1.30.0 // indirect + +replace github.com/getyoti/yoti-go-sdk/v3 => ../../ diff --git a/_examples/digitalidentity/go.sum b/_examples/digitalidentity/go.sum new file mode 100644 index 00000000..25b7d7dc --- /dev/null +++ b/_examples/digitalidentity/go.sum @@ -0,0 +1,11 @@ +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo= diff --git a/_examples/digitalidentity/login.html b/_examples/digitalidentity/login.html new file mode 100644 index 00000000..b3d2729d --- /dev/null +++ b/_examples/digitalidentity/login.html @@ -0,0 +1,85 @@ + + + + + + Yoti client example + + + + +
+
+
+ Yoti +
+ +

Digital Identity Share Example page

+ +

SdkId: {{.yotiClientSdkID}}

+ +
+
+
+ +
+ +
+

The Yoti app is free to download and use:

+ +
+ + Download on the App Store + + + + get it on Google Play + +
+
+
+ + + + diff --git a/_examples/digitalidentity/main.go b/_examples/digitalidentity/main.go new file mode 100644 index 00000000..b1cc553e --- /dev/null +++ b/_examples/digitalidentity/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "os" + "path" + + "github.com/getyoti/yoti-go-sdk/v3" + "github.com/getyoti/yoti-go-sdk/v3/digitalidentity" + _ "github.com/joho/godotenv/autoload" +) + +type contextKey string + +var ( + errApplyingTheParsedTemplate = "Error applying the parsed template: " + errParsingTheTemplate = "Error parsing the template: " +) + +func home(w http.ResponseWriter, req *http.Request) { + templateVars := map[string]interface{}{ + "yotiScenarioID": os.Getenv("YOTI_SCENARIO_ID"), + "yotiClientSdkID": os.Getenv("YOTI_CLIENT_SDK_ID")} + t, err := template.ParseFiles("login.html") + + if err != nil { + errorPage(w, req.WithContext(context.WithValue( + req.Context(), + contextKey("yotiError"), + fmt.Sprintf(errParsingTheTemplate+err.Error()), + ))) + return + } + + err = t.Execute(w, templateVars) + if err != nil { + errorPage(w, req.WithContext(context.WithValue( + req.Context(), + contextKey("yotiError"), + fmt.Sprintf(errApplyingTheParsedTemplate+err.Error()), + ))) + return + } +} +func buildDigitalIdentitySessionReq() (sessionSpec *digitalidentity.ShareSessionRequest, err error) { + policy, err := (&digitalidentity.PolicyBuilder{}).WithFullName().WithEmail().WithPhoneNumber().WithSelfie().WithAgeOver(18).WithNationality().WithGender().WithDocumentDetails().WithDocumentImages().WithWantedRememberMe().Build() + if err != nil { + return nil, fmt.Errorf("failed to build policy: %v", err) + } + + subject := []byte(`{ + "subject_id": "unique-user-id-for-examples" + }`) + + sessionReq, err := (&digitalidentity.ShareSessionRequestBuilder{}).WithPolicy(policy).WithRedirectUri("https:/www.yoti.com").WithSubject(subject).Build() + if err != nil { + return nil, fmt.Errorf("failed to build create session request: %v", err) + } + return &sessionReq, nil +} + +func generateSession(w http.ResponseWriter, r *http.Request) { + didClient, err := initialiseDigitalIdentityClient() + if err != nil { + fmt.Fprintf(w, string("Client could't be generated")) + return + } + + sessionReq, err := buildDigitalIdentitySessionReq() + if err != nil { + fmt.Fprintf(w, "failed to build session request: %v", err) + return + } + + shareSession, err := didClient.CreateShareSession(sessionReq) + if err != nil { + fmt.Fprintf(w, "failed to create share session: %v", err) + return + } + + output, err := json.Marshal(shareSession) + if err != nil { + fmt.Fprintf(w, "failed to marshall share session: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(output)) + +} + +func getReceipt(w http.ResponseWriter, r *http.Request) { + didClient, err := initialiseDigitalIdentityClient() + if err != nil { + fmt.Fprintf(w, "Client could't be generated") + return + } + receiptID := r.URL.Query().Get("ReceiptID") + + receiptValue, err := didClient.GetShareReceipt(receiptID) + if err != nil { + fmt.Fprintf(w, "failed to get share receipt: %v", err) + return + } + output, err := json.Marshal(receiptValue) + if err != nil { + fmt.Fprintf(w, "failed to marshal receipt: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(output)) +} + +func initialiseDigitalIdentityClient() (*yoti.DigitalIdentityClient, error) { + var err error + sdkID := os.Getenv("YOTI_CLIENT_SDK_ID") + keyFilePath := os.Getenv("YOTI_KEY_FILE_PATH") + key, err := os.ReadFile(keyFilePath) + if err != nil { + return nil, fmt.Errorf("failed to get key from YOTI_KEY_FILE_PATH :: %w", err) + } + + didClient, err := yoti.NewDigitalIdentityClient(sdkID, key) + if err != nil { + return nil, fmt.Errorf("failed to initialise Share client :: %w", err) + } + + return didClient, nil +} +func main() { + // Check if the cert files are available. + selfSignedCertName := "yotiSelfSignedCert.pem" + selfSignedKeyName := "yotiSelfSignedKey.pem" + certificatePresent := certificatePresenceCheck(selfSignedCertName, selfSignedKeyName) + portNumber := "8080" + // If they are not available, generate new ones. + if !certificatePresent { + err := generateSelfSignedCertificate(selfSignedCertName, selfSignedKeyName, "127.0.0.1:"+portNumber) + if err != nil { + panic("Error when creating https certs: " + err.Error()) + } + } + + http.HandleFunc("/", home) + http.HandleFunc("/v2/generate-share", generateSession) + http.HandleFunc("/v2/receipt-info", getReceipt) + + rootdir, err := os.Getwd() + if err != nil { + log.Fatal("Error: Couldn't get current working directory") + } + http.Handle("/images/", http.StripPrefix("/images", + http.FileServer(http.Dir(path.Join(rootdir, "images/"))))) + http.Handle("/static/", http.StripPrefix("/static", + http.FileServer(http.Dir(path.Join(rootdir, "static/"))))) + + log.Printf("About to listen and serve on %[1]s. Go to https://localhost:%[1]s/", portNumber) + err = http.ListenAndServeTLS(":"+portNumber, selfSignedCertName, selfSignedKeyName, nil) + + if err != nil { + panic("Error when calling `ListenAndServeTLS`: " + err.Error()) + } +} diff --git a/_examples/digitalidentity/static/assets/app-store-badge.png b/_examples/digitalidentity/static/assets/app-store-badge.png new file mode 100755 index 0000000000000000000000000000000000000000..3ec996cc6288d68279c1d735c9d627c64d8a48c6 GIT binary patch literal 4077 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 literal 0 HcmV?d00001 diff --git a/_examples/digitalidentity/static/assets/app-store-badge@2x.png b/_examples/digitalidentity/static/assets/app-store-badge@2x.png new file mode 100755 index 0000000000000000000000000000000000000000..84b34068fc22aa74f388f6db1a583ac6592f5420 GIT binary patch literal 8819 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 MlZs4*lPCscyWU2PMP1t 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 + + diff --git a/_examples/digitalidentity/static/assets/icons/calendar.svg b/_examples/digitalidentity/static/assets/icons/calendar.svg new file mode 100755 index 00000000..71ce6371 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/calendar.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/_examples/digitalidentity/static/assets/icons/chevron-down-grey.svg b/_examples/digitalidentity/static/assets/icons/chevron-down-grey.svg new file mode 100644 index 00000000..89f55a6f --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/chevron-down-grey.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/_examples/digitalidentity/static/assets/icons/document.svg b/_examples/digitalidentity/static/assets/icons/document.svg new file mode 100755 index 00000000..10fc1de3 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/document.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/email.svg b/_examples/digitalidentity/static/assets/icons/email.svg new file mode 100755 index 00000000..67880ef3 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/email.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/_examples/digitalidentity/static/assets/icons/gender.svg b/_examples/digitalidentity/static/assets/icons/gender.svg new file mode 100755 index 00000000..94a0ed90 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/gender.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/_examples/digitalidentity/static/assets/icons/nationality.svg b/_examples/digitalidentity/static/assets/icons/nationality.svg new file mode 100755 index 00000000..40cbf76d --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/nationality.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/phone.svg b/_examples/digitalidentity/static/assets/icons/phone.svg new file mode 100755 index 00000000..adbaad99 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/phone.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/profile.svg b/_examples/digitalidentity/static/assets/icons/profile.svg new file mode 100755 index 00000000..62278ece --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/profile.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/verified.svg b/_examples/digitalidentity/static/assets/icons/verified.svg new file mode 100755 index 00000000..f6e1d94c --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/verified.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/_examples/digitalidentity/static/assets/logo.png b/_examples/digitalidentity/static/assets/logo.png new file mode 100755 index 0000000000000000000000000000000000000000..c60227fabf339e9540e5daac4d2d25e121137752 GIT binary patch literal 2988 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 Yoti app is free to download and use: window.Yoti.Share.init({ "elements": [{ - "domId": "yoti-share-button", + "domId": "yoti-receipt-button", "shareUrl": "{{.yotiShareURL}}", "clientSdkId": "{{.yotiClientSdkID}}", "button": { diff --git a/_examples/profile/dynamicshare.go b/_examples/profile/dynamicshare.go index a136238a..a80367a6 100644 --- a/_examples/profile/dynamicshare.go +++ b/_examples/profile/dynamicshare.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "html/template" - "io/ioutil" "net/http" "os" @@ -38,7 +37,7 @@ func dynamicShare(w http.ResponseWriter, req *http.Request) { func pageFromScenario(w http.ResponseWriter, req *http.Request, title string, scenario dynamic.Scenario) { sdkID := os.Getenv("YOTI_CLIENT_SDK_ID") - key, err := ioutil.ReadFile(os.Getenv("YOTI_KEY_FILE_PATH")) + key, err := os.ReadFile(os.Getenv("YOTI_KEY_FILE_PATH")) if err != nil { errorPage(w, req.WithContext(context.WithValue( req.Context(), diff --git a/_examples/profile/go.mod b/_examples/profile/go.mod index d25fc959..dc3aef89 100644 --- a/_examples/profile/go.mod +++ b/_examples/profile/go.mod @@ -1,6 +1,6 @@ module profile -go 1.17 +go 1.19 require ( github.com/getyoti/yoti-go-sdk/v3 v3.0.0 diff --git a/_examples/profile/profile.go b/_examples/profile/profile.go index 9492d49c..15c3f307 100644 --- a/_examples/profile/profile.go +++ b/_examples/profile/profile.go @@ -9,7 +9,6 @@ import ( "image" "image/jpeg" "io" - "io/ioutil" "net/http" "os" @@ -18,7 +17,7 @@ import ( func profile(w http.ResponseWriter, r *http.Request) { var err error - key, err = ioutil.ReadFile(os.Getenv("YOTI_KEY_FILE_PATH")) + key, err = os.ReadFile(os.Getenv("YOTI_KEY_FILE_PATH")) sdkID = os.Getenv("YOTI_CLIENT_SDK_ID") if err != nil { diff --git a/_examples/profilesandbox/go.mod b/_examples/profilesandbox/go.mod index d98f6bc5..377a5a05 100644 --- a/_examples/profilesandbox/go.mod +++ b/_examples/profilesandbox/go.mod @@ -1,6 +1,6 @@ module profilesandbox -go 1.17 +go 1.19 require ( github.com/getyoti/yoti-go-sdk/v3 v3.0.0 diff --git a/aml/service.go b/aml/service.go index 962d911d..68605246 100644 --- a/aml/service.go +++ b/aml/service.go @@ -4,7 +4,7 @@ import ( "crypto/rsa" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "github.com/getyoti/yoti-go-sdk/v3/requests" @@ -45,7 +45,7 @@ func PerformCheck(httpClient requests.HttpClient, profile Profile, clientSdkId, } var responseBytes []byte - responseBytes, err = ioutil.ReadAll(response.Body) + responseBytes, err = io.ReadAll(response.Body) if err != nil { return } diff --git a/aml/service_test.go b/aml/service_test.go index 05dd51ec..d8ef5b03 100644 --- a/aml/service_test.go +++ b/aml/service_test.go @@ -4,7 +4,7 @@ import ( "crypto/rsa" "errors" "fmt" - "io/ioutil" + "io" "net/http" "strings" "testing" @@ -50,7 +50,7 @@ func TestPerformCheck_WithInvalidJSON(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader("Not a JSON document")), + Body: io.NopCloser(strings.NewReader("Not a JSON document")), }, nil }, } @@ -66,7 +66,7 @@ func TestPerformCheck_Success(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"on_fraud_list":true,"on_pep_list":true,"on_watch_list":true}`)), + Body: io.NopCloser(strings.NewReader(`{"on_fraud_list":true,"on_pep_list":true,"on_watch_list":true}`)), }, nil }, } @@ -87,7 +87,7 @@ func TestPerformCheck_Unsuccessful(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 503, - Body: ioutil.NopCloser(strings.NewReader(responseBody)), + Body: io.NopCloser(strings.NewReader(responseBody)), }, nil }, } diff --git a/yoti_client.go b/client.go similarity index 100% rename from yoti_client.go rename to client.go diff --git a/yoti_client_test.go b/client_test.go similarity index 80% rename from yoti_client_test.go rename to client_test.go index ca839dca..44ba57d5 100644 --- a/yoti_client_test.go +++ b/client_test.go @@ -2,7 +2,7 @@ package yoti import ( "crypto/rsa" - "io/ioutil" + "io" "net/http" "os" "strings" @@ -26,15 +26,15 @@ func (mock *mockHTTPClient) Do(request *http.Request) (*http.Response, error) { } func TestNewClient(t *testing.T) { - key, readErr := ioutil.ReadFile("./test/test-key.pem") - assert.NilError(t, readErr) + key, err := os.ReadFile("./test/test-key.pem") + assert.NilError(t, err) - _, err := NewClient("some-sdk-id", key) + _, err = NewClient("some-sdk-id", key) assert.NilError(t, err) } func TestNewClient_KeyLoad_Failure(t *testing.T) { - key, err := ioutil.ReadFile("test/test-key-invalid-format.pem") + key, err := os.ReadFile("test/test-key-invalid-format.pem") assert.NilError(t, err) _, err = NewClient("", key) @@ -48,17 +48,17 @@ func TestNewClient_KeyLoad_Failure(t *testing.T) { } func TestYotiClient_PerformAmlCheck(t *testing.T) { - key, readErr := ioutil.ReadFile("./test/test-key.pem") - assert.NilError(t, readErr) + key, err := os.ReadFile("./test/test-key.pem") + assert.NilError(t, err) - client, clientErr := NewClient("some-sdk-id", key) - assert.NilError(t, clientErr) + client, err := NewClient("some-sdk-id", key) + assert.NilError(t, err) client.HTTPClient = &mockHTTPClient{ do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"on_fraud_list":true}`)), + Body: io.NopCloser(strings.NewReader(`{"on_fraud_list":true}`)), }, nil }, } @@ -78,26 +78,26 @@ func TestYotiClient_PerformAmlCheck(t *testing.T) { } func TestYotiClient_CreateShareURL(t *testing.T) { - key, readErr := ioutil.ReadFile("./test/test-key.pem") - assert.NilError(t, readErr) + key, err := os.ReadFile("./test/test-key.pem") + assert.NilError(t, err) - client, clientErr := NewClient("some-sdk-id", key) - assert.NilError(t, clientErr) + client, err := NewClient("some-sdk-id", key) + assert.NilError(t, err) client.HTTPClient = &mockHTTPClient{ do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 201, - Body: ioutil.NopCloser(strings.NewReader(`{"qrcode":"https://code.yoti.com/some-qr","ref_id":"0"}`)), + Body: io.NopCloser(strings.NewReader(`{"qrcode":"https://code.yoti.com/some-qr","ref_id":"0"}`)), }, nil }, } - policy, policyErr := (&dynamic.PolicyBuilder{}).WithFullName().WithWantedRememberMe().Build() - assert.NilError(t, policyErr) + policy, err := (&dynamic.PolicyBuilder{}).WithFullName().WithWantedRememberMe().Build() + assert.NilError(t, err) - scenario, scenarioErr := (&dynamic.ScenarioBuilder{}).WithPolicy(policy).Build() - assert.NilError(t, scenarioErr) + scenario, err := (&dynamic.ScenarioBuilder{}).WithPolicy(policy).Build() + assert.NilError(t, err) result, err := client.CreateShareURL(&scenario) assert.NilError(t, err) diff --git a/cryptoutil/crypto_utils.go b/cryptoutil/crypto_utils.go index fbfacf26..de4d0f94 100644 --- a/cryptoutil/crypto_utils.go +++ b/cryptoutil/crypto_utils.go @@ -11,6 +11,8 @@ import ( "fmt" "github.com/getyoti/yoti-go-sdk/v3/util" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotocom" + "google.golang.org/protobuf/proto" ) // ParseRSAKey parses a PKCS1 private key from bytes @@ -114,3 +116,49 @@ func UnwrapKey(wrappedKey string, key *rsa.PrivateKey) (result []byte, err error } return decryptRsa(cipherBytes, key) } + +func decryptAESGCM(cipherText, iv, secret []byte) ([]byte, error) { + block, err := aes.NewCipher(secret) + if err != nil { + return nil, fmt.Errorf("failed to create new aes cipher: %v", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create new gcm cipher: %v", err) + } + + plainText, err := gcm.Open(nil, iv, cipherText, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt receipt key: %v", err) + } + + return plainText, nil +} + +func UnwrapReceiptKey(wrappedReceiptKey []byte, encryptedItemKey []byte, itemKeyIv []byte, key *rsa.PrivateKey) ([]byte, error) { + decryptedItemKey, err := decryptRsa(encryptedItemKey, key) + if err != nil { + return nil, fmt.Errorf("failed to decrypt item key: %v", err) + } + + plainText, err := decryptAESGCM(wrappedReceiptKey, itemKeyIv, decryptedItemKey) + if err != nil { + return nil, fmt.Errorf("failed to decrypt receipt key: %v", err) + } + return plainText, nil +} + +func DecryptReceiptContent(content, receiptContentKey []byte) ([]byte, error) { + if content == nil { + return nil, fmt.Errorf("failed to decrypt receipt content is nil") + } + + decodedData := &yotiprotocom.EncryptedData{} + err := proto.Unmarshal(content, decodedData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshall content: %v", content) + } + + return DecipherAes(receiptContentKey, decodedData.Iv, decodedData.CipherText) +} diff --git a/cryptoutil/crypto_utils_test.go b/cryptoutil/crypto_utils_test.go index 4b425fee..864e2f3a 100644 --- a/cryptoutil/crypto_utils_test.go +++ b/cryptoutil/crypto_utils_test.go @@ -6,7 +6,6 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" - "io/ioutil" "os" "testing" @@ -163,7 +162,7 @@ func TestCryptoutil_UnwrapKey_InvalidBase64ShouldError(t *testing.T) { } func getKey() (key *rsa.PrivateKey) { - keyBytes, err := ioutil.ReadFile("../test/test-key.pem") + keyBytes, err := os.ReadFile("../test/test-key.pem") if err != nil { panic("Error reading the test key: " + err.Error()) } diff --git a/digital_identity_client.go b/digital_identity_client.go new file mode 100644 index 00000000..b074712e --- /dev/null +++ b/digital_identity_client.go @@ -0,0 +1,88 @@ +package yoti + +import ( + "crypto/rsa" + "os" + + "github.com/getyoti/yoti-go-sdk/v3/cryptoutil" + "github.com/getyoti/yoti-go-sdk/v3/digitalidentity" + "github.com/getyoti/yoti-go-sdk/v3/requests" +) + +const DefaultURL = "https://api.yoti.com/share" + +// DigitalIdentityClient represents a client that can communicate with yoti and return information about Yoti users. +type DigitalIdentityClient struct { + // SdkID represents the SDK ID and NOT the App ID. This can be found in the integration section of your + // application hub at https://hub.yoti.com/ + SdkID string + + // Key should be the security key given to you by yoti (see: security keys section of + // https://hub.yoti.com) for more information about how to load your key from a file see: + // https://github.com/getyoti/yoti-go-sdk/blob/master/README.md + Key *rsa.PrivateKey + + apiURL string + HTTPClient requests.HttpClient // Mockable HTTP Client Interface +} + +// NewDigitalIdentityClient constructs a Client object +func NewDigitalIdentityClient(sdkID string, key []byte) (*DigitalIdentityClient, error) { + decodedKey, err := cryptoutil.ParseRSAKey(key) + + if err != nil { + return nil, err + } + + return &DigitalIdentityClient{ + SdkID: sdkID, + Key: decodedKey, + }, err +} + +// OverrideAPIURL overrides the default API URL for this Yoti Client +func (client *DigitalIdentityClient) OverrideAPIURL(apiURL string) { + client.apiURL = apiURL +} + +func (client *DigitalIdentityClient) getAPIURL() string { + if client.apiURL != "" { + return client.apiURL + } + + if value, exists := os.LookupEnv("YOTI_API_URL"); exists && value != "" { + return value + } + + return DefaultURL +} + +// GetSdkID gets the Client SDK ID attached to this client instance +func (client *DigitalIdentityClient) GetSdkID() string { + return client.SdkID +} + +// CreateShareSession creates a sharing session to initiate a sharing process based on a policy +func (client *DigitalIdentityClient) CreateShareSession(shareSessionRequest *digitalidentity.ShareSessionRequest) (shareSession *digitalidentity.ShareSession, err error) { + return digitalidentity.CreateShareSession(client.HTTPClient, shareSessionRequest, client.GetSdkID(), client.getAPIURL(), client.Key) +} + +// GetShareSession retrieves the sharing session. +func (client *DigitalIdentityClient) GetShareSession(sessionID string) (*digitalidentity.ShareSession, error) { + return digitalidentity.GetShareSession(client.HTTPClient, sessionID, client.GetSdkID(), client.getAPIURL(), client.Key) +} + +// CreateShareQrCode generates a sharing session QR code to initiate a sharing process based on session ID +func (client *DigitalIdentityClient) CreateShareQrCode(sessionID string) (share *digitalidentity.QrCode, err error) { + return digitalidentity.CreateShareQrCode(client.HTTPClient, sessionID, client.GetSdkID(), client.getAPIURL(), client.Key) +} + +// Get session QR code based on generated Qr ID +func (client *DigitalIdentityClient) GetQrCode(qrCodeId string) (share digitalidentity.ShareSessionQrCode, err error) { + return digitalidentity.GetShareSessionQrCode(client.HTTPClient, qrCodeId, client.GetSdkID(), client.getAPIURL(), client.Key) +} + +// GetShareReceipt fetches the receipt of the share given a receipt id. +func (client *DigitalIdentityClient) GetShareReceipt(receiptId string) (share digitalidentity.SharedReceiptResponse, err error) { + return digitalidentity.GetShareReceipt(client.HTTPClient, receiptId, client.GetSdkID(), client.getAPIURL(), client.Key) +} diff --git a/digital_identity_client_test.go b/digital_identity_client_test.go new file mode 100644 index 00000000..3c85d93b --- /dev/null +++ b/digital_identity_client_test.go @@ -0,0 +1,168 @@ +package yoti + +import ( + "crypto/rsa" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/digitalidentity" + "github.com/getyoti/yoti-go-sdk/v3/test" + "gotest.tools/v3/assert" +) + +func TestDigitalIDClient(t *testing.T) { + key, err := os.ReadFile("./test/test-key.pem") + assert.NilError(t, err) + + _, err = NewDigitalIdentityClient("some-sdk-id", key) + assert.NilError(t, err) +} + +func TestDigitalIDClient_KeyLoad_Failure(t *testing.T) { + key, err := os.ReadFile("test/test-key-invalid-format.pem") + assert.NilError(t, err) + + _, err = NewDigitalIdentityClient("", key) + + assert.ErrorContains(t, err, "invalid key: not PEM-encoded") + + tempError, temporary := err.(interface { + Temporary() bool + }) + assert.Check(t, !temporary || !tempError.Temporary()) +} + +func TestYotiClient_CreateShareSession(t *testing.T) { + key, err := os.ReadFile("./test/test-key.pem") + assert.NilError(t, err) + + client, err := NewDigitalIdentityClient("some-sdk-id", key) + assert.NilError(t, err) + + client.HTTPClient = &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{"id":"SOME_ID","status":"SOME_STATUS","expiry":"SOME_EXPIRY","created":"SOME_CREATED","updated":"SOME_UPDATED","qrCode":{"id":"SOME_QRCODE_ID"},"receipt":{"id":"SOME_RECEIPT_ID"}}`)), + }, nil + }, + } + + policy, err := (&digitalidentity.PolicyBuilder{}).WithFullName().WithWantedRememberMe().Build() + assert.NilError(t, err) + + session, err := (&digitalidentity.ShareSessionRequestBuilder{}).WithPolicy(policy).Build() + assert.NilError(t, err) + + result, err := client.CreateShareSession(&session) + + assert.NilError(t, err) + assert.Equal(t, result.Status, "SOME_STATUS") +} + +func TestDigitalIDClient_HttpFailure_ReturnsUnKnownHttpError(t *testing.T) { + key := getDigitalValidKey() + client := DigitalIdentityClient{ + HTTPClient: &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 401, + }, nil + }, + }, + Key: key, + } + + _, err := client.GetShareSession("SOME ID") + + assert.ErrorContains(t, err, "unknown HTTP error") + tempError, temporary := err.(interface { + Temporary() bool + }) + assert.Check(t, !temporary || !tempError.Temporary()) +} + +func TestDigitalIDClient_GetSession(t *testing.T) { + key, err := os.ReadFile("./test/test-key.pem") + if err != nil { + t.Fatalf("failed to read pem file :: %v", err) + } + + mockSessionID := "SOME_SESSION_ID" + client, err := NewDigitalIdentityClient("some-sdk-id", key) + if err != nil { + t.Fatalf("failed to build the DigitalIdClient :: %v", err) + } + + client.HTTPClient = &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{"id":"SOME_ID","status":"SOME_STATUS","expiry":"SOME_EXPIRY","created":"SOME_CREATED","updated":"SOME_UPDATED","qrCode":{"id":"SOME_QRCODE_ID"},"receipt":{"id":"SOME_RECEIPT_ID"}}`)), + }, nil + }, + } + + result, err := client.GetShareSession(mockSessionID) + if err != nil { + t.Fatalf("failed to GetShareSesssion :: %v", err) + } + + assert.Equal(t, result.Id, "SOME_ID") + assert.Equal(t, result.Status, "SOME_STATUS") + assert.Equal(t, result.Created, "SOME_CREATED") + +} + +func TestDigitalIDClient_OverrideAPIURL_ShouldSetAPIURL(t *testing.T) { + client := &DigitalIdentityClient{} + + expectedURL := "expectedurl.com" + client.OverrideAPIURL(expectedURL) + + assert.Equal(t, client.getAPIURL(), expectedURL) +} + +func TestDigitalIDClient_GetAPIURLUsesOverriddenBaseUrlOverEnvVariable(t *testing.T) { + client := DigitalIdentityClient{} + client.OverrideAPIURL("overridenBaseUrl") + + os.Setenv("YOTI_API_URL", "envBaseUrl") + result := client.getAPIURL() + + assert.Equal(t, "overridenBaseUrl", result) +} + +func TestDigitalIDClient_GetAPIURLUsesEnvVariable(t *testing.T) { + client := DigitalIdentityClient{} + + os.Setenv("YOTI_API_URL", "envBaseUrl") + result := client.getAPIURL() + + assert.Equal(t, "envBaseUrl", result) +} + +func TestDigitalIDClient_GetAPIURLUsesDefaultUrlAsFallbackWithEmptyEnvValue(t *testing.T) { + client := DigitalIdentityClient{} + + os.Setenv("YOTI_API_URL", "") + result := client.getAPIURL() + + assert.Equal(t, "https://api.yoti.com/share", result) +} + +func TestDigitalIDClient_GetAPIURLUsesDefaultUrlAsFallbackWithNoEnvValue(t *testing.T) { + client := DigitalIdentityClient{} + + os.Unsetenv("YOTI_API_URL") + result := client.getAPIURL() + + assert.Equal(t, "https://api.yoti.com/share", result) +} + +func getDigitalValidKey() *rsa.PrivateKey { + return test.GetValidKey("test/test-key.pem") +} diff --git a/digitalidentity/address.go b/digitalidentity/address.go new file mode 100644 index 00000000..17bfb51e --- /dev/null +++ b/digitalidentity/address.go @@ -0,0 +1,52 @@ +package digitalidentity + +import ( + "reflect" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +func getFormattedAddress(profile *UserProfile, formattedAddress string) *yotiprotoattr.Attribute { + proto := getProtobufAttribute(*profile, consts.AttrStructuredPostalAddress) + + return &yotiprotoattr.Attribute{ + Name: consts.AttrAddress, + Value: []byte(formattedAddress), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: proto.Anchors, + } +} + +func ensureAddressProfile(p *UserProfile) *attribute.StringAttribute { + if structuredPostalAddress, err := p.StructuredPostalAddress(); err == nil { + if (structuredPostalAddress != nil && !reflect.DeepEqual(structuredPostalAddress, attribute.JSONAttribute{})) { + var formattedAddress string + formattedAddress, err = retrieveFormattedAddressFromStructuredPostalAddress(structuredPostalAddress.Value()) + if err == nil && formattedAddress != "" { + return attribute.NewString(getFormattedAddress(p, formattedAddress)) + } + } + } + + return nil +} + +func retrieveFormattedAddressFromStructuredPostalAddress(structuredPostalAddress interface{}) (address string, err error) { + parsedStructuredAddressMap := structuredPostalAddress.(map[string]interface{}) + if formattedAddress, ok := parsedStructuredAddressMap["formatted_address"]; ok { + return formattedAddress.(string), nil + } + return +} + +func getProtobufAttribute(profile UserProfile, key string) *yotiprotoattr.Attribute { + for _, v := range profile.attributeSlice { + if v.Name == key { + return v + } + } + + return nil +} diff --git a/digitalidentity/application_profile.go b/digitalidentity/application_profile.go new file mode 100644 index 00000000..8fae7bda --- /dev/null +++ b/digitalidentity/application_profile.go @@ -0,0 +1,50 @@ +package digitalidentity + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// Attribute names for application attributes +const ( + AttrConstApplicationName = "application_name" + AttrConstApplicationURL = "application_url" + AttrConstApplicationLogo = "application_logo" + AttrConstApplicationReceiptBGColor = "application_receipt_bgcolor" +) + +// ApplicationProfile is the profile of an application with convenience methods +// to access well-known attributes. +type ApplicationProfile struct { + baseProfile +} + +func newApplicationProfile(attributes *yotiprotoattr.AttributeList) ApplicationProfile { + return ApplicationProfile{ + baseProfile{ + attributeSlice: createAttributeSlice(attributes), + }, + } +} + +// ApplicationName is the name of the application +func (p ApplicationProfile) ApplicationName() *attribute.StringAttribute { + return p.GetStringAttribute(AttrConstApplicationName) +} + +// ApplicationURL is the URL where the application is available at +func (p ApplicationProfile) ApplicationURL() *attribute.StringAttribute { + return p.GetStringAttribute(AttrConstApplicationURL) +} + +// ApplicationReceiptBgColor is the background colour that will be displayed on +// each receipt the user gets as a result of a share with the application. +func (p ApplicationProfile) ApplicationReceiptBgColor() *attribute.StringAttribute { + return p.GetStringAttribute(AttrConstApplicationReceiptBGColor) +} + +// ApplicationLogo is the logo of the application that will be displayed to +// those users that perform a share with it. +func (p ApplicationProfile) ApplicationLogo() *attribute.ImageAttribute { + return p.GetImageAttribute(AttrConstApplicationLogo) +} diff --git a/digitalidentity/attribute/age_verifications.go b/digitalidentity/attribute/age_verifications.go new file mode 100644 index 00000000..a7655d06 --- /dev/null +++ b/digitalidentity/attribute/age_verifications.go @@ -0,0 +1,34 @@ +package attribute + +import ( + "strconv" + "strings" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// AgeVerification encapsulates the result of a single age verification +// as part of a share +type AgeVerification struct { + Age int + CheckType string + Result bool + Attribute *yotiprotoattr.Attribute +} + +// NewAgeVerification constructs an AgeVerification from a protobuffer +func NewAgeVerification(attr *yotiprotoattr.Attribute) (verification AgeVerification, err error) { + split := strings.Split(attr.Name, ":") + verification.Age, err = strconv.Atoi(split[1]) + verification.CheckType = split[0] + + if string(attr.Value) == "true" { + verification.Result = true + } else { + verification.Result = false + } + + verification.Attribute = attr + + return +} diff --git a/digitalidentity/attribute/age_verifications_test.go b/digitalidentity/attribute/age_verifications_test.go new file mode 100644 index 00000000..b3a6e086 --- /dev/null +++ b/digitalidentity/attribute/age_verifications_test.go @@ -0,0 +1,42 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestNewAgeVerification_ValueTrue(t *testing.T) { + attribute := &yotiprotoattr.Attribute{ + Name: "age_over:18", + Value: []byte("true"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + ageVerification, err := NewAgeVerification(attribute) + + assert.NilError(t, err) + + assert.Equal(t, ageVerification.Age, 18) + assert.Equal(t, ageVerification.CheckType, "age_over") + assert.Equal(t, ageVerification.Result, true) +} + +func TestNewAgeVerification_ValueFalse(t *testing.T) { + attribute := &yotiprotoattr.Attribute{ + Name: "age_under:30", + Value: []byte("false"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + ageVerification, err := NewAgeVerification(attribute) + + assert.NilError(t, err) + + assert.Equal(t, ageVerification.Age, 30) + assert.Equal(t, ageVerification.CheckType, "age_under") + assert.Equal(t, ageVerification.Result, false) +} diff --git a/digitalidentity/attribute/anchor/anchor_parser.go b/digitalidentity/attribute/anchor/anchor_parser.go new file mode 100644 index 00000000..d1476c4f --- /dev/null +++ b/digitalidentity/attribute/anchor/anchor_parser.go @@ -0,0 +1,110 @@ +package anchor + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotocom" + "google.golang.org/protobuf/proto" +) + +type anchorExtension struct { + Extension string `asn1:"tag:0,utf8"` +} + +var ( + sourceOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 47127, 1, 1, 1} + verifierOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 47127, 1, 1, 2} +) + +// ParseAnchors takes a slice of protobuf anchors, parses them, and returns a slice of Yoti SDK Anchors +func ParseAnchors(protoAnchors []*yotiprotoattr.Anchor) []*Anchor { + var processedAnchors []*Anchor + for _, protoAnchor := range protoAnchors { + parsedCerts := parseCertificates(protoAnchor.OriginServerCerts) + + anchorType, extension := getAnchorValuesFromCertificate(parsedCerts) + + parsedSignedTimestamp, err := parseSignedTimestamp(protoAnchor.SignedTimeStamp) + if err != nil { + continue + } + + processedAnchor := newAnchor(anchorType, parsedCerts, parsedSignedTimestamp, protoAnchor.SubType, extension) + + processedAnchors = append(processedAnchors, processedAnchor) + } + + return processedAnchors +} + +func getAnchorValuesFromCertificate(parsedCerts []*x509.Certificate) (anchorType Type, extension string) { + defaultAnchorType := TypeUnknown + + for _, cert := range parsedCerts { + for _, ext := range cert.Extensions { + var ( + value string + err error + ) + parsedAnchorType, value, err := parseExtension(ext) + if err != nil { + continue + } else if parsedAnchorType == TypeUnknown { + continue + } + return parsedAnchorType, value + } + } + + return defaultAnchorType, "" +} + +func parseExtension(ext pkix.Extension) (anchorType Type, val string, err error) { + anchorType = TypeUnknown + + switch { + case ext.Id.Equal(sourceOID): + anchorType = TypeSource + case ext.Id.Equal(verifierOID): + anchorType = TypeVerifier + default: + return anchorType, "", nil + } + + var ae anchorExtension + _, err = asn1.Unmarshal(ext.Value, &ae) + switch { + case err != nil: + return anchorType, "", fmt.Errorf("unable to unmarshal extension: %v", err) + case len(ae.Extension) == 0: + return anchorType, "", errors.New("empty extension") + default: + val = ae.Extension + } + + return anchorType, val, nil +} + +func parseSignedTimestamp(rawBytes []byte) (*yotiprotocom.SignedTimestamp, error) { + signedTimestamp := &yotiprotocom.SignedTimestamp{} + if err := proto.Unmarshal(rawBytes, signedTimestamp); err != nil { + return signedTimestamp, err + } + + return signedTimestamp, nil +} + +func parseCertificates(rawCerts [][]byte) (result []*x509.Certificate) { + for _, cert := range rawCerts { + parsedCertificate, _ := x509.ParseCertificate(cert) + + result = append(result, parsedCertificate) + } + + return result +} diff --git a/digitalidentity/attribute/anchor/anchor_parser_test.go b/digitalidentity/attribute/anchor/anchor_parser_test.go new file mode 100644 index 00000000..13849a3d --- /dev/null +++ b/digitalidentity/attribute/anchor/anchor_parser_test.go @@ -0,0 +1,147 @@ +package anchor + +import ( + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/test" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" + "gotest.tools/v3/assert" +) + +func assertServerCertSerialNo(t *testing.T, expectedSerialNo string, actualSerialNo *big.Int) { + expectedSerialNoBigInt := new(big.Int) + expectedSerialNoBigInt, ok := expectedSerialNoBigInt.SetString(expectedSerialNo, 10) + assert.Assert(t, ok, "Unexpected error when setting string as big int") + + assert.Equal(t, expectedSerialNoBigInt.Cmp(actualSerialNo), 0) // 0 == equivalent +} + +func createAnchorSliceFromTestFile(t *testing.T, filename string) []*yotiprotoattr.Anchor { + anchorBytes := test.DecodeTestFile(t, filename) + + protoAnchor := &yotiprotoattr.Anchor{} + err2 := proto.Unmarshal(anchorBytes, protoAnchor) + assert.NilError(t, err2) + + protoAnchors := append([]*yotiprotoattr.Anchor{}, protoAnchor) + + return protoAnchors +} + +func TestAnchorParser_parseExtension_ShouldErrorForInvalidExtension(t *testing.T) { + invalidExt := pkix.Extension{ + Id: sourceOID, + } + + _, _, err := parseExtension(invalidExt) + + assert.Check(t, err != nil) + assert.Error(t, err, "unable to unmarshal extension: asn1: syntax error: sequence truncated") +} + +func TestAnchorParser_Passport(t *testing.T) { + anchorSlice := createAnchorSliceFromTestFile(t, "../../../test/fixtures/test_anchor_passport.txt") + + parsedAnchors := ParseAnchors(anchorSlice) + + actualAnchor := parsedAnchors[0] + + assert.Equal(t, actualAnchor.Type(), TypeSource) + + expectedDate := time.Date(2018, time.April, 12, 13, 14, 32, 835537e3, time.UTC) + actualDate := actualAnchor.SignedTimestamp().Timestamp().UTC() + assert.Equal(t, actualDate, expectedDate) + + expectedSubType := "OCR" + assert.Equal(t, actualAnchor.SubType(), expectedSubType) + + expectedValue := "PASSPORT" + assert.Equal(t, actualAnchor.Value(), expectedValue) + + actualSerialNo := actualAnchor.OriginServerCerts()[0].SerialNumber + assertServerCertSerialNo(t, "277870515583559162487099305254898397834", actualSerialNo) +} + +func TestAnchorParser_DrivingLicense(t *testing.T) { + anchorSlice := createAnchorSliceFromTestFile(t, "../../../test/fixtures/test_anchor_driving_license.txt") + + parsedAnchors := ParseAnchors(anchorSlice) + resultAnchor := parsedAnchors[0] + + assert.Equal(t, resultAnchor.Type(), TypeSource) + + expectedDate := time.Date(2018, time.April, 11, 12, 13, 3, 923537e3, time.UTC) + actualDate := resultAnchor.SignedTimestamp().Timestamp().UTC() + assert.Equal(t, actualDate, expectedDate) + + expectedSubType := "" + assert.Equal(t, resultAnchor.SubType(), expectedSubType) + + expectedValue := "DRIVING_LICENCE" + assert.Equal(t, resultAnchor.Value(), expectedValue) + + actualSerialNo := resultAnchor.OriginServerCerts()[0].SerialNumber + assertServerCertSerialNo(t, "46131813624213904216516051554755262812", actualSerialNo) +} + +func TestAnchorParser_UnknownAnchor(t *testing.T) { + anchorSlice := createAnchorSliceFromTestFile(t, "../../../test/fixtures/test_anchor_unknown.txt") + + resultAnchor := ParseAnchors(anchorSlice)[0] + + expectedDate := time.Date(2019, time.March, 5, 10, 45, 11, 840037e3, time.UTC) + actualDate := resultAnchor.SignedTimestamp().Timestamp().UTC() + assert.Equal(t, actualDate, expectedDate) + + expectedSubType := "TEST UNKNOWN SUB TYPE" + expectedType := TypeUnknown + assert.Equal(t, resultAnchor.SubType(), expectedSubType) + assert.Equal(t, resultAnchor.Type(), expectedType) + assert.Equal(t, resultAnchor.Value(), "") +} + +func TestAnchorParser_YotiAdmin(t *testing.T) { + anchorSlice := createAnchorSliceFromTestFile(t, "../../../test/fixtures/test_anchor_yoti_admin.txt") + + resultAnchor := ParseAnchors(anchorSlice)[0] + + assert.Equal(t, resultAnchor.Type(), TypeVerifier) + + expectedDate := time.Date(2018, time.April, 11, 12, 13, 4, 95238e3, time.UTC) + actualDate := resultAnchor.SignedTimestamp().Timestamp().UTC() + assert.Equal(t, actualDate, expectedDate) + + expectedSubType := "" + assert.Equal(t, resultAnchor.SubType(), expectedSubType) + + expectedValue := "YOTI_ADMIN" + assert.Equal(t, resultAnchor.Value(), expectedValue) + + actualSerialNo := resultAnchor.OriginServerCerts()[0].SerialNumber + assertServerCertSerialNo(t, "256616937783084706710155170893983549581", actualSerialNo) +} + +func TestAnchors_None(t *testing.T) { + var anchorSlice []*Anchor + + sources := GetSources(anchorSlice) + assert.Equal(t, len(sources), 0, "GetSources should not return anything with empty anchors") + + verifiers := GetVerifiers(anchorSlice) + assert.Equal(t, len(verifiers), 0, "GetVerifiers should not return anything with empty anchors") +} + +func TestAnchorParser_InvalidSignedTimestamp(t *testing.T) { + var protoAnchors []*yotiprotoattr.Anchor + protoAnchors = append(protoAnchors, &yotiprotoattr.Anchor{ + SignedTimeStamp: []byte("invalidProto"), + }) + parsedAnchors := ParseAnchors(protoAnchors) + + var expectedAnchors []*Anchor + assert.DeepEqual(t, expectedAnchors, parsedAnchors) +} diff --git a/digitalidentity/attribute/anchor/anchors.go b/digitalidentity/attribute/anchor/anchors.go new file mode 100644 index 00000000..839a6e11 --- /dev/null +++ b/digitalidentity/attribute/anchor/anchors.go @@ -0,0 +1,105 @@ +package anchor + +import ( + "crypto/x509" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotocom" +) + +// Anchor is the metadata associated with an attribute. It describes how an attribute has been provided +// to Yoti (SOURCE Anchor) and how it has been verified (VERIFIER Anchor). +// If an attribute has only one SOURCE Anchor with the value set to +// "USER_PROVIDED" and zero VERIFIER Anchors, then the attribute +// is a self-certified one. +type Anchor struct { + anchorType Type + originServerCerts []*x509.Certificate + signedTimestamp SignedTimestamp + subtype string + value string +} + +func newAnchor(anchorType Type, originServerCerts []*x509.Certificate, signedTimestamp *yotiprotocom.SignedTimestamp, subtype string, value string) *Anchor { + return &Anchor{ + anchorType: anchorType, + originServerCerts: originServerCerts, + signedTimestamp: convertSignedTimestamp(signedTimestamp), + subtype: subtype, + value: value, + } +} + +// Type Anchor type, based on the Object Identifier (OID) +type Type int + +const ( + // TypeUnknown - default value + TypeUnknown Type = 1 + iota + // TypeSource - how the anchor has been sourced + TypeSource + // TypeVerifier - how the anchor has been verified + TypeVerifier +) + +// Type of the Anchor - most likely either SOURCE or VERIFIER, but it's +// possible that new Anchor types will be added in future. +func (a Anchor) Type() Type { + return a.anchorType +} + +// OriginServerCerts are the X.509 certificate chain(DER-encoded ASN.1) +// from the service that assigned the attribute. +// +// The first certificate in the chain holds the public key that can be +// used to verify the Signature field; any following entries (zero or +// more) are for intermediate certificate authorities (in order). +// +// The last certificate in the chain must be verified against the Yoti root +// CA certificate. An extension in the first certificate holds the main artifact type, +// e.g. “PASSPORT”, which can be retrieved with .Value(). +func (a Anchor) OriginServerCerts() []*x509.Certificate { + return a.originServerCerts +} + +// SignedTimestamp is the time at which the signature was created. The +// message associated with the timestamp is the marshaled form of +// AttributeSigning (i.e. the same message that is signed in the +// Signature field). This method returns the SignedTimestamp +// object, the actual timestamp as a *time.Time can be called with +// .Timestamp() on the result of this function. +func (a Anchor) SignedTimestamp() SignedTimestamp { + return a.signedTimestamp +} + +// SubType is an indicator of any specific processing method, or +// subcategory, pertaining to an artifact. For example, for a passport, this would be +// either "NFC" or "OCR". +func (a Anchor) SubType() string { + return a.subtype +} + +// Value identifies the provider that either sourced or verified the attribute value. +// The range of possible values is not limited. For a SOURCE anchor, expect a value like +// PASSPORT, DRIVING_LICENSE. For a VERIFIER anchor, expect a value like YOTI_ADMIN. +func (a Anchor) Value() string { + return a.value +} + +// GetSources returns the anchors which identify how and when an attribute value was acquired. +func GetSources(anchors []*Anchor) (sources []*Anchor) { + return filterAnchors(anchors, TypeSource) +} + +// GetVerifiers returns the anchors which identify how and when an attribute value was verified by another provider. +func GetVerifiers(anchors []*Anchor) (sources []*Anchor) { + return filterAnchors(anchors, TypeVerifier) +} + +func filterAnchors(anchors []*Anchor, anchorType Type) (result []*Anchor) { + for _, v := range anchors { + if v.anchorType == anchorType { + result = append(result, v) + } + } + return result +} diff --git a/digitalidentity/attribute/anchor/anchors_test.go b/digitalidentity/attribute/anchor/anchors_test.go new file mode 100644 index 00000000..ed5287ed --- /dev/null +++ b/digitalidentity/attribute/anchor/anchors_test.go @@ -0,0 +1,20 @@ +package anchor + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestFilterAnchors_FilterSources(t *testing.T) { + anchorSlice := []*Anchor{ + {subtype: "a", anchorType: TypeSource}, + {subtype: "b", anchorType: TypeVerifier}, + {subtype: "c", anchorType: TypeSource}, + } + sources := filterAnchors(anchorSlice, TypeSource) + assert.Equal(t, len(sources), 2) + assert.Equal(t, sources[0].subtype, "a") + assert.Equal(t, sources[1].subtype, "c") + +} diff --git a/digitalidentity/attribute/anchor/signed_timestamp.go b/digitalidentity/attribute/anchor/signed_timestamp.go new file mode 100644 index 00000000..2081b7d6 --- /dev/null +++ b/digitalidentity/attribute/anchor/signed_timestamp.go @@ -0,0 +1,35 @@ +package anchor + +import ( + "time" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotocom" +) + +// SignedTimestamp is the object which contains a timestamp +type SignedTimestamp struct { + version int32 + timestamp *time.Time +} + +func convertSignedTimestamp(protoSignedTimestamp *yotiprotocom.SignedTimestamp) SignedTimestamp { + uintTimestamp := protoSignedTimestamp.Timestamp + intTimestamp := int64(uintTimestamp) + unixTime := time.Unix(intTimestamp/1e6, (intTimestamp%1e6)*1e3) + + return SignedTimestamp{ + version: protoSignedTimestamp.Version, + timestamp: &unixTime, + } +} + +// Version indicates both the version of the protobuf message in use, +// as well as the specific hash algorithms. +func (s SignedTimestamp) Version() int32 { + return s.version +} + +// Timestamp is a point in time, to the nearest microsecond. +func (s SignedTimestamp) Timestamp() *time.Time { + return s.timestamp +} diff --git a/digitalidentity/attribute/attribute_details.go b/digitalidentity/attribute/attribute_details.go new file mode 100644 index 00000000..a380150b --- /dev/null +++ b/digitalidentity/attribute/attribute_details.go @@ -0,0 +1,48 @@ +package attribute + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" +) + +// attributeDetails is embedded in each attribute for fields common to all +// attributes +type attributeDetails struct { + name string + contentType string + anchors []*anchor.Anchor + id *string +} + +// Name gets the attribute name +func (a attributeDetails) Name() string { + return a.name +} + +// ID gets the attribute ID +func (a attributeDetails) ID() *string { + return a.id +} + +// ContentType gets the attribute's content type description +func (a attributeDetails) ContentType() string { + return a.contentType +} + +// Anchors are the metadata associated with an attribute. They describe +// how an attribute has been provided to Yoti (SOURCE Anchor) and how +// it has been verified (VERIFIER Anchor). +func (a attributeDetails) Anchors() []*anchor.Anchor { + return a.anchors +} + +// Sources returns the anchors which identify how and when an attribute value +// was acquired. +func (a attributeDetails) Sources() []*anchor.Anchor { + return anchor.GetSources(a.anchors) +} + +// Verifiers returns the anchors which identify how and when an attribute value +// was verified by another provider. +func (a attributeDetails) Verifiers() []*anchor.Anchor { + return anchor.GetVerifiers(a.anchors) +} diff --git a/digitalidentity/attribute/attribute_test.go b/digitalidentity/attribute/attribute_test.go new file mode 100644 index 00000000..67b6c2b2 --- /dev/null +++ b/digitalidentity/attribute/attribute_test.go @@ -0,0 +1,36 @@ +package attribute + +import ( + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestNewThirdPartyAttribute(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_third_party.txt") + + stringAttribute := NewString(protoAttribute) + + assert.Equal(t, stringAttribute.Value(), "test-third-party-attribute-0") + assert.Equal(t, stringAttribute.Name(), "com.thirdparty.id") + + assert.Equal(t, stringAttribute.Sources()[0].Value(), "THIRD_PARTY") + assert.Equal(t, stringAttribute.Sources()[0].SubType(), "orgName") + + assert.Equal(t, stringAttribute.Verifiers()[0].Value(), "THIRD_PARTY") + assert.Equal(t, stringAttribute.Verifiers()[0].SubType(), "orgName") +} + +func TestAttribute_DateOfBirth(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_date_of_birth.txt") + + dateOfBirthAttribute, err := NewDate(protoAttribute) + + assert.NilError(t, err) + + expectedDateOfBirth := time.Date(1970, time.December, 01, 0, 0, 0, 0, time.UTC) + actualDateOfBirth := dateOfBirthAttribute.Value() + + assert.Assert(t, actualDateOfBirth.Equal(expectedDateOfBirth)) +} diff --git a/digitalidentity/attribute/date_attribute.go b/digitalidentity/attribute/date_attribute.go new file mode 100644 index 00000000..cdc55ce3 --- /dev/null +++ b/digitalidentity/attribute/date_attribute.go @@ -0,0 +1,39 @@ +package attribute + +import ( + "time" + + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// DateAttribute is a Yoti attribute which returns a date as *time.Time for its value +type DateAttribute struct { + attributeDetails + value *time.Time +} + +// NewDate creates a new Date attribute +func NewDate(a *yotiprotoattr.Attribute) (*DateAttribute, error) { + parsedTime, err := time.Parse("2006-01-02", string(a.Value)) + if err != nil { + return nil, err + } + + parsedAnchors := anchor.ParseAnchors(a.Anchors) + + return &DateAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: &parsedTime, + }, nil +} + +// Value returns the value of the TimeAttribute as *time.Time +func (a *DateAttribute) Value() *time.Time { + return a.value +} diff --git a/digitalidentity/attribute/date_attribute_test.go b/digitalidentity/attribute/date_attribute_test.go new file mode 100644 index 00000000..24807c93 --- /dev/null +++ b/digitalidentity/attribute/date_attribute_test.go @@ -0,0 +1,44 @@ +package attribute + +import ( + "testing" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestTimeAttribute_NewDate_DateOnly(t *testing.T) { + proto := yotiprotoattr.Attribute{ + Value: []byte("2011-12-25"), + } + + timeAttribute, err := NewDate(&proto) + assert.NilError(t, err) + + assert.Equal(t, *timeAttribute.Value(), time.Date(2011, 12, 25, 0, 0, 0, 0, time.UTC)) +} + +func TestTimeAttribute_DateOfBirth(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_date_of_birth.txt") + + dateOfBirthAttribute, err := NewDate(protoAttribute) + + assert.NilError(t, err) + + expectedDateOfBirth := time.Date(1970, time.December, 01, 0, 0, 0, 0, time.UTC) + actualDateOfBirth := dateOfBirthAttribute.Value() + + assert.Assert(t, actualDateOfBirth.Equal(expectedDateOfBirth)) +} + +func TestNewTime_ShouldReturnErrorForInvalidDate(t *testing.T) { + proto := yotiprotoattr.Attribute{ + Name: "example", + Value: []byte("2006-60-20"), + ContentType: yotiprotoattr.ContentType_DATE, + } + attribute, err := NewDate(&proto) + assert.Check(t, attribute == nil) + assert.ErrorContains(t, err, "month out of range") +} diff --git a/digitalidentity/attribute/definition.go b/digitalidentity/attribute/definition.go new file mode 100644 index 00000000..b0d4b8a4 --- /dev/null +++ b/digitalidentity/attribute/definition.go @@ -0,0 +1,31 @@ +package attribute + +import ( + "encoding/json" +) + +// Definition contains information about the attribute(s) issued by a third party. +type Definition struct { + name string +} + +// Name of the attribute to be issued. +func (a Definition) Name() string { + return a.name +} + +// MarshalJSON returns encoded json +func (a Definition) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Name string `json:"name"` + }{ + Name: a.name, + }) +} + +// NewAttributeDefinition returns a new AttributeDefinition +func NewAttributeDefinition(s string) Definition { + return Definition{ + name: s, + } +} diff --git a/digitalidentity/attribute/definition_test.go b/digitalidentity/attribute/definition_test.go new file mode 100644 index 00000000..b209e023 --- /dev/null +++ b/digitalidentity/attribute/definition_test.go @@ -0,0 +1,18 @@ +package attribute + +import ( + "encoding/json" + "fmt" +) + +func ExampleDefinition_MarshalJSON() { + exampleDefinition := NewAttributeDefinition("exampleDefinition") + marshalledJSON, err := json.Marshal(exampleDefinition) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(marshalledJSON)) + // Output: {"name":"exampleDefinition"} +} diff --git a/digitalidentity/attribute/document_details_attribute.go b/digitalidentity/attribute/document_details_attribute.go new file mode 100644 index 00000000..a18ccaba --- /dev/null +++ b/digitalidentity/attribute/document_details_attribute.go @@ -0,0 +1,87 @@ +package attribute + +import ( + "fmt" + "strings" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +const ( + documentDetailsDateFormatConst = "2006-01-02" +) + +// DocumentDetails represents information extracted from a document provided by the user +type DocumentDetails struct { + DocumentType string + IssuingCountry string + DocumentNumber string + ExpirationDate *time.Time + IssuingAuthority string +} + +// DocumentDetailsAttribute wraps a document details with anchor data +type DocumentDetailsAttribute struct { + attributeDetails + value DocumentDetails +} + +// Value returns the document details struct attached to this attribute +func (attr *DocumentDetailsAttribute) Value() DocumentDetails { + return attr.value +} + +// NewDocumentDetails creates a DocumentDetailsAttribute which wraps a +// DocumentDetails with anchor data +func NewDocumentDetails(a *yotiprotoattr.Attribute) (*DocumentDetailsAttribute, error) { + parsedAnchors := anchor.ParseAnchors(a.Anchors) + details := DocumentDetails{} + err := details.Parse(string(a.Value)) + if err != nil { + return nil, err + } + + return &DocumentDetailsAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: details, + }, nil +} + +// Parse fills a DocumentDetails object from a raw string +func (details *DocumentDetails) Parse(data string) error { + dataSlice := strings.Split(data, " ") + + if len(dataSlice) < 3 { + return fmt.Errorf("Document Details data is invalid, %s", data) + } + for _, section := range dataSlice { + if section == "" { + return fmt.Errorf("Document Details data is invalid %s", data) + } + } + + details.DocumentType = dataSlice[0] + details.IssuingCountry = dataSlice[1] + details.DocumentNumber = dataSlice[2] + if len(dataSlice) > 3 && dataSlice[3] != "-" { + expirationDateData, dateErr := time.Parse(documentDetailsDateFormatConst, dataSlice[3]) + + if dateErr == nil { + details.ExpirationDate = &expirationDateData + } else { + return dateErr + } + } + if len(dataSlice) > 4 { + details.IssuingAuthority = dataSlice[4] + } + + return nil +} diff --git a/digitalidentity/attribute/document_details_attribute_test.go b/digitalidentity/attribute/document_details_attribute_test.go new file mode 100644 index 00000000..bf2e7dee --- /dev/null +++ b/digitalidentity/attribute/document_details_attribute_test.go @@ -0,0 +1,185 @@ +package attribute + +import ( + "fmt" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func ExampleDocumentDetails_Parse() { + raw := "PASSPORT GBR 1234567 2022-09-12" + details := DocumentDetails{} + err := details.Parse(raw) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Printf( + "Document Type: %s, Issuing Country: %s, Document Number: %s, Expiration Date: %s", + details.DocumentType, + details.IssuingCountry, + details.DocumentNumber, + details.ExpirationDate, + ) + // Output: Document Type: PASSPORT, Issuing Country: GBR, Document Number: 1234567, Expiration Date: 2022-09-12 00:00:00 +0000 UTC +} + +func ExampleNewDocumentDetails() { + proto := yotiprotoattr.Attribute{ + Name: "exampleDocumentDetails", + Value: []byte("PASSPORT GBR 1234567 2022-09-12"), + ContentType: yotiprotoattr.ContentType_STRING, + } + attribute, err := NewDocumentDetails(&proto) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Printf( + "Document Type: %s, With %d Anchors", + attribute.Value().DocumentType, + len(attribute.Anchors()), + ) + // Output: Document Type: PASSPORT, With 0 Anchors +} + +func TestDocumentDetailsShouldParseDrivingLicenceWithoutExpiry(t *testing.T) { + drivingLicenceGBR := "PASS_CARD GBR 1234abc - DVLA" + + details := DocumentDetails{} + err := details.Parse(drivingLicenceGBR) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "PASS_CARD") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Assert(t, details.ExpirationDate == nil) + assert.Equal(t, details.IssuingCountry, "GBR") + assert.Equal(t, details.IssuingAuthority, "DVLA") +} + +func TestDocumentDetailsShouldParseRedactedAadhar(t *testing.T) { + aadhaar := "AADHAAR IND ****1234 2016-05-01" + details := DocumentDetails{} + err := details.Parse(aadhaar) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "AADHAAR") + assert.Equal(t, details.DocumentNumber, "****1234") + assert.Equal(t, details.ExpirationDate.Format("2006-01-02"), "2016-05-01") + assert.Equal(t, details.IssuingCountry, "IND") + assert.Equal(t, details.IssuingAuthority, "") +} + +func TestDocumentDetailsShouldParseSpecialCharacters(t *testing.T) { + testData := [][]string{ + {"type country **** - authority", "****"}, + {"type country ~!@#$%^&*()-_=+[]{}|;':,./<>? - authority", "~!@#$%^&*()-_=+[]{}|;':,./<>?"}, + {"type country \"\" - authority", "\"\""}, + {"type country \\ - authority", "\\"}, + {"type country \" - authority", "\""}, + {"type country '' - authority", "''"}, + {"type country ' - authority", "'"}, + } + for _, row := range testData { + details := DocumentDetails{} + err := details.Parse(row[0]) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentNumber, row[1]) + } +} + +func TestDocumentDetailsShouldFailOnDoubleSpace(t *testing.T) { + data := "AADHAAR IND ****1234" + details := DocumentDetails{} + err := details.Parse(data) + assert.Check(t, err != nil) + assert.ErrorContains(t, err, "Document Details data is invalid") +} + +func TestDocumentDetailsShouldParseDrivingLicenceWithExtraAttribute(t *testing.T) { + drivingLicenceGBR := "DRIVING_LICENCE GBR 1234abc 2016-05-01 DVLA someThirdAttribute" + details := DocumentDetails{} + err := details.Parse(drivingLicenceGBR) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "DRIVING_LICENCE") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Equal(t, details.ExpirationDate.Format("2006-01-02"), "2016-05-01") + assert.Equal(t, details.IssuingCountry, "GBR") + assert.Equal(t, details.IssuingAuthority, "DVLA") +} + +func TestDocumentDetailsShouldParseDrivingLicenceWithAllOptionalAttributes(t *testing.T) { + drivingLicenceGBR := "DRIVING_LICENCE GBR 1234abc 2016-05-01 DVLA" + + details := DocumentDetails{} + err := details.Parse(drivingLicenceGBR) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "DRIVING_LICENCE") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Equal(t, details.ExpirationDate.Format("2006-01-02"), "2016-05-01") + assert.Equal(t, details.IssuingCountry, "GBR") + assert.Equal(t, details.IssuingAuthority, "DVLA") +} + +func TestDocumentDetailsShouldParseAadhaar(t *testing.T) { + aadhaar := "AADHAAR IND 1234abc 2016-05-01" + + details := DocumentDetails{} + err := details.Parse(aadhaar) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "AADHAAR") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Equal(t, details.ExpirationDate.Format("2006-01-02"), "2016-05-01") + assert.Equal(t, details.IssuingCountry, "IND") +} + +func TestDocumentDetailsShouldParsePassportWithMandatoryFieldsOnly(t *testing.T) { + passportGBR := "PASSPORT GBR 1234abc" + + details := DocumentDetails{} + err := details.Parse(passportGBR) + if err != nil { + t.Fail() + } + assert.Equal(t, details.DocumentType, "PASSPORT") + assert.Equal(t, details.DocumentNumber, "1234abc") + assert.Assert(t, details.ExpirationDate == nil) + assert.Equal(t, details.IssuingCountry, "GBR") + assert.Equal(t, details.IssuingAuthority, "") +} + +func TestDocumentDetailsShouldErrorOnEmptyString(t *testing.T) { + empty := "" + + details := DocumentDetails{} + err := details.Parse(empty) + assert.ErrorContains(t, err, "Document Details data is invalid") +} + +func TestDocumentDetailsShouldErrorIfLessThan3Words(t *testing.T) { + corrupt := "PASS_CARD GBR" + details := DocumentDetails{} + err := details.Parse(corrupt) + assert.ErrorContains(t, err, "Document Details data is invalid") +} + +func TestDocumentDetailsShouldErrorForInvalidExpirationDate(t *testing.T) { + corrupt := "PASSPORT GBR 1234abc X016-05-01" + details := DocumentDetails{} + err := details.Parse(corrupt) + assert.ErrorContains(t, err, "cannot parse") +} diff --git a/digitalidentity/attribute/generic_attribute.go b/digitalidentity/attribute/generic_attribute.go new file mode 100644 index 00000000..c729e30b --- /dev/null +++ b/digitalidentity/attribute/generic_attribute.go @@ -0,0 +1,38 @@ +package attribute + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// GenericAttribute is a Yoti attribute which returns a generic value +type GenericAttribute struct { + attributeDetails + value interface{} +} + +// NewGeneric creates a new generic attribute +func NewGeneric(a *yotiprotoattr.Attribute) *GenericAttribute { + value, err := parseValue(a.ContentType, a.Value) + + if err != nil { + return nil + } + + var parsedAnchors = anchor.ParseAnchors(a.Anchors) + + return &GenericAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: value, + } +} + +// Value returns the value of the GenericAttribute as an interface +func (a *GenericAttribute) Value() interface{} { + return a.value +} diff --git a/digitalidentity/attribute/generic_attribute_test.go b/digitalidentity/attribute/generic_attribute_test.go new file mode 100644 index 00000000..e2daae8a --- /dev/null +++ b/digitalidentity/attribute/generic_attribute_test.go @@ -0,0 +1,39 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestNewGeneric_ShouldParseUnknownTypeAsString(t *testing.T) { + value := []byte("value") + protoAttr := yotiprotoattr.Attribute{ + ContentType: yotiprotoattr.ContentType_UNDEFINED, + Value: value, + } + parsed := NewGeneric(&protoAttr) + + stringValue, ok := parsed.Value().(string) + assert.Check(t, ok) + + assert.Equal(t, stringValue, string(value)) +} + +func TestGeneric_ContentType(t *testing.T) { + attribute := GenericAttribute{ + attributeDetails: attributeDetails{ + contentType: "contentType", + }, + } + + assert.Equal(t, attribute.ContentType(), "contentType") +} + +func TestNewGeneric_ShouldReturnNilForInvalidProtobuf(t *testing.T) { + invalid := NewGeneric(&yotiprotoattr.Attribute{ + ContentType: yotiprotoattr.ContentType_JSON, + }) + assert.Check(t, invalid == nil) +} diff --git a/digitalidentity/attribute/helper_test.go b/digitalidentity/attribute/helper_test.go new file mode 100644 index 00000000..47c28eae --- /dev/null +++ b/digitalidentity/attribute/helper_test.go @@ -0,0 +1,21 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/test" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" + "gotest.tools/v3/assert" +) + +func createAttributeFromTestFile(t *testing.T, filename string) *yotiprotoattr.Attribute { + attributeBytes := test.DecodeTestFile(t, filename) + + attributeStruct := &yotiprotoattr.Attribute{} + + err2 := proto.Unmarshal(attributeBytes, attributeStruct) + assert.NilError(t, err2) + + return attributeStruct +} diff --git a/digitalidentity/attribute/image_attribute.go b/digitalidentity/attribute/image_attribute.go new file mode 100644 index 00000000..fd9d7f14 --- /dev/null +++ b/digitalidentity/attribute/image_attribute.go @@ -0,0 +1,53 @@ +package attribute + +import ( + "errors" + + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// ImageAttribute is a Yoti attribute which returns an image as its value +type ImageAttribute struct { + attributeDetails + value media.Media +} + +// NewImage creates a new Image attribute +func NewImage(a *yotiprotoattr.Attribute) (*ImageAttribute, error) { + imageValue, err := parseImageValue(a.ContentType, a.Value) + if err != nil { + return nil, err + } + + parsedAnchors := anchor.ParseAnchors(a.Anchors) + + return &ImageAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: imageValue, + }, nil +} + +// Value returns the value of the ImageAttribute as media.Media +func (a *ImageAttribute) Value() media.Media { + return a.value +} + +func parseImageValue(contentType yotiprotoattr.ContentType, byteValue []byte) (media.Media, error) { + switch contentType { + case yotiprotoattr.ContentType_JPEG: + return media.JPEGImage(byteValue), nil + + case yotiprotoattr.ContentType_PNG: + return media.PNGImage(byteValue), nil + + default: + return nil, errors.New("cannot create Image with unsupported type") + } +} diff --git a/digitalidentity/attribute/image_attribute_test.go b/digitalidentity/attribute/image_attribute_test.go new file mode 100644 index 00000000..2fe620f6 --- /dev/null +++ b/digitalidentity/attribute/image_attribute_test.go @@ -0,0 +1,106 @@ +package attribute + +import ( + "encoding/base64" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestImageAttribute_Image_Png(t *testing.T) { + attributeName := consts.AttrSelfie + byteValue := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: byteValue, + ContentType: yotiprotoattr.ContentType_PNG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + assert.DeepEqual(t, selfie.Value().Data(), byteValue) +} + +func TestImageAttribute_Image_Jpeg(t *testing.T) { + attributeName := consts.AttrSelfie + byteValue := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: byteValue, + ContentType: yotiprotoattr.ContentType_JPEG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + assert.DeepEqual(t, selfie.Value().Data(), byteValue) +} + +func TestImageAttribute_Image_Default(t *testing.T) { + attributeName := consts.AttrSelfie + byteValue := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: byteValue, + ContentType: yotiprotoattr.ContentType_PNG, + Anchors: []*yotiprotoattr.Anchor{}, + } + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + assert.DeepEqual(t, selfie.Value().Data(), byteValue) +} + +func TestImageAttribute_Base64Selfie_Png(t *testing.T) { + attributeName := consts.AttrSelfie + imageBytes := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: imageBytes, + ContentType: yotiprotoattr.ContentType_PNG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + base64ImageExpectedValue := base64.StdEncoding.EncodeToString(imageBytes) + + expectedBase64Selfie := "data:image/png;base64," + base64ImageExpectedValue + + base64Selfie := selfie.Value().Base64URL() + + assert.Equal(t, base64Selfie, expectedBase64Selfie) +} + +func TestImageAttribute_Base64URL_Jpeg(t *testing.T) { + attributeName := consts.AttrSelfie + imageBytes := []byte("value") + + var attributeImage = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: imageBytes, + ContentType: yotiprotoattr.ContentType_JPEG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + selfie, err := NewImage(attributeImage) + assert.NilError(t, err) + + base64ImageExpectedValue := base64.StdEncoding.EncodeToString(imageBytes) + + expectedBase64Selfie := "data:image/jpeg;base64," + base64ImageExpectedValue + + base64Selfie := selfie.Value().Base64URL() + + assert.Equal(t, base64Selfie, expectedBase64Selfie) +} diff --git a/digitalidentity/attribute/image_slice_attribute.go b/digitalidentity/attribute/image_slice_attribute.go new file mode 100644 index 00000000..de507ab6 --- /dev/null +++ b/digitalidentity/attribute/image_slice_attribute.go @@ -0,0 +1,69 @@ +package attribute + +import ( + "errors" + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// ImageSliceAttribute is a Yoti attribute which returns a slice of images as its value +type ImageSliceAttribute struct { + attributeDetails + value []media.Media +} + +// NewImageSlice creates a new ImageSlice attribute +func NewImageSlice(a *yotiprotoattr.Attribute) (*ImageSliceAttribute, error) { + if a.ContentType != yotiprotoattr.ContentType_MULTI_VALUE { + return nil, errors.New("creating an Image Slice attribute with content types other than MULTI_VALUE is not supported") + } + + parsedMultiValue, err := parseMultiValue(a.Value) + + if err != nil { + return nil, err + } + + var imageSliceValue []media.Media + if parsedMultiValue != nil { + imageSliceValue, err = CreateImageSlice(parsedMultiValue) + if err != nil { + return nil, err + } + } + + return &ImageSliceAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: anchor.ParseAnchors(a.Anchors), + id: &a.EphemeralId, + }, + value: imageSliceValue, + }, nil +} + +// CreateImageSlice takes a slice of Items, and converts them into a slice of images +func CreateImageSlice(items []*Item) (result []media.Media, err error) { + for _, item := range items { + + switch i := item.Value.(type) { + case media.PNGImage: + result = append(result, i) + case media.JPEGImage: + result = append(result, i) + default: + return nil, fmt.Errorf("unexpected item type %T", i) + } + } + + return result, nil +} + +// Value returns the value of the ImageSliceAttribute +func (a *ImageSliceAttribute) Value() []media.Media { + return a.value +} diff --git a/digitalidentity/attribute/image_slice_attribute_test.go b/digitalidentity/attribute/image_slice_attribute_test.go new file mode 100644 index 00000000..2c300926 --- /dev/null +++ b/digitalidentity/attribute/image_slice_attribute_test.go @@ -0,0 +1,61 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func assertIsExpectedImage(t *testing.T, image media.Media, imageMIMEType string, expectedBase64URLLast10 string) { + assert.Equal(t, image.MIME(), imageMIMEType) + + actualBase64URL := image.Base64URL() + + ActualBase64URLLast10Chars := actualBase64URL[len(actualBase64URL)-10:] + + assert.Equal(t, ActualBase64URLLast10Chars, expectedBase64URLLast10) +} + +func assertIsExpectedDocumentImagesAttribute(t *testing.T, actualDocumentImages []media.Media, anchor *anchor.Anchor) { + + assert.Equal(t, len(actualDocumentImages), 2, "This Document Images attribute should have two images") + + assertIsExpectedImage(t, actualDocumentImages[0], media.ImageTypeJPEG, "vWgD//2Q==") + assertIsExpectedImage(t, actualDocumentImages[1], media.ImageTypeJPEG, "38TVEH/9k=") + + expectedValue := "NATIONAL_ID" + assert.Equal(t, anchor.Value(), expectedValue) + + expectedSubType := "STATE_ID" + assert.Equal(t, anchor.SubType(), expectedSubType) +} + +func TestAttribute_NewImageSlice(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_multivalue.txt") + + documentImagesAttribute, err := NewImageSlice(protoAttribute) + + assert.NilError(t, err) + + assertIsExpectedDocumentImagesAttribute(t, documentImagesAttribute.Value(), documentImagesAttribute.Anchors()[0]) +} + +func TestAttribute_ImageSliceNotCreatedWithNonMultiValueType(t *testing.T) { + attributeName := "attributeName" + attributeValueString := "value" + attributeValue := []byte(attributeValueString) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + _, err := NewImageSlice(attr) + + assert.Assert(t, err != nil, "Expected error when creating image slice from attribute which isn't of multi-value type") +} diff --git a/digitalidentity/attribute/issuance_details.go b/digitalidentity/attribute/issuance_details.go new file mode 100644 index 00000000..381d4e99 --- /dev/null +++ b/digitalidentity/attribute/issuance_details.go @@ -0,0 +1,86 @@ +package attribute + +import ( + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoshare" + "google.golang.org/protobuf/proto" +) + +// IssuanceDetails contains information about the attribute(s) issued by a third party +type IssuanceDetails struct { + token string + expiryDate *time.Time + attributes []Definition +} + +// Token is the issuance token that can be used to retrieve the user's stored details. +// These details will be used to issue attributes on behalf of an organisation to that user. +func (i IssuanceDetails) Token() string { + return i.token +} + +// ExpiryDate is the timestamp at which the request for the attribute value +// from third party will expire. Will be nil if not provided. +func (i IssuanceDetails) ExpiryDate() *time.Time { + return i.expiryDate +} + +// Attributes information about the attributes the third party would like to issue. +func (i IssuanceDetails) Attributes() []Definition { + return i.attributes +} + +// ParseIssuanceDetails takes the Third Party Attribute object and converts it into an IssuanceDetails struct +func ParseIssuanceDetails(thirdPartyAttributeBytes []byte) (*IssuanceDetails, error) { + thirdPartyAttributeStruct := &yotiprotoshare.ThirdPartyAttribute{} + if err := proto.Unmarshal(thirdPartyAttributeBytes, thirdPartyAttributeStruct); err != nil { + return nil, fmt.Errorf("unable to parse ThirdPartyAttribute value: %q. Error: %q", string(thirdPartyAttributeBytes), err) + } + + var issuingAttributesProto = thirdPartyAttributeStruct.GetIssuingAttributes() + var issuingAttributeDefinitions = parseIssuingAttributeDefinitions(issuingAttributesProto.GetDefinitions()) + + expiryDate, dateParseErr := parseExpiryDate(issuingAttributesProto.ExpiryDate) + + var issuanceTokenBytes = thirdPartyAttributeStruct.GetIssuanceToken() + + if len(issuanceTokenBytes) == 0 { + return nil, errors.New("Issuance Token is invalid") + } + + base64EncodedToken := base64.StdEncoding.EncodeToString(issuanceTokenBytes) + + return &IssuanceDetails{ + token: base64EncodedToken, + expiryDate: expiryDate, + attributes: issuingAttributeDefinitions, + }, dateParseErr +} + +func parseIssuingAttributeDefinitions(definitions []*yotiprotoshare.Definition) (issuingAttributes []Definition) { + for _, definition := range definitions { + attributeDefinition := Definition{ + name: definition.Name, + } + issuingAttributes = append(issuingAttributes, attributeDefinition) + } + + return issuingAttributes +} + +func parseExpiryDate(expiryDateString string) (*time.Time, error) { + if expiryDateString == "" { + return nil, nil + } + + parsedTime, err := time.Parse(time.RFC3339Nano, expiryDateString) + if err != nil { + return nil, err + } + + return &parsedTime, err +} diff --git a/digitalidentity/attribute/issuance_details_test.go b/digitalidentity/attribute/issuance_details_test.go new file mode 100644 index 00000000..462d863e --- /dev/null +++ b/digitalidentity/attribute/issuance_details_test.go @@ -0,0 +1,145 @@ +package attribute + +import ( + "encoding/base64" + "testing" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/test" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoshare" + "google.golang.org/protobuf/proto" + + "gotest.tools/v3/assert" + + is "gotest.tools/v3/assert/cmp" +) + +func TestShouldParseThirdPartyAttributeCorrectly(t *testing.T) { + var thirdPartyAttributeBytes = test.GetTestFileBytes(t, "../../test/fixtures/test_third_party_issuance_details.txt") + issuanceDetails, err := ParseIssuanceDetails(thirdPartyAttributeBytes) + + assert.NilError(t, err) + assert.Equal(t, issuanceDetails.Attributes()[0].Name(), "com.thirdparty.id") + assert.Equal(t, issuanceDetails.Token(), "c29tZUlzc3VhbmNlVG9rZW4=") + assert.Equal(t, + issuanceDetails.ExpiryDate().Format("2006-01-02T15:04:05.000Z"), + "2019-10-15T22:04:05.123Z") +} + +func TestShouldLogWarningIfErrorInParsingExpiryDate(t *testing.T) { + var tokenValue = "41548a175dfaw" + thirdPartyAttribute := &yotiprotoshare.ThirdPartyAttribute{ + IssuanceToken: []byte(tokenValue), + IssuingAttributes: &yotiprotoshare.IssuingAttributes{ + ExpiryDate: "2006-13-02T15:04:05.000Z", + }, + } + + marshalled, err := proto.Marshal(thirdPartyAttribute) + + assert.NilError(t, err) + + var tokenBytes = []byte(tokenValue) + var expectedBase64Token = base64.StdEncoding.EncodeToString(tokenBytes) + + result, err := ParseIssuanceDetails(marshalled) + assert.Equal(t, expectedBase64Token, result.Token()) + assert.Assert(t, is.Nil(result.ExpiryDate())) + assert.Equal(t, "parsing time \"2006-13-02T15:04:05.000Z\": month out of range", err.Error()) +} + +func TestIssuanceDetails_parseExpiryDate_ShouldParseAllRFC3339Formats(t *testing.T) { + table := []struct { + Input string + Expected time.Time + }{ + { + Input: "2006-01-02T22:04:05Z", + Expected: time.Date(2006, 01, 02, 22, 4, 5, 0, time.UTC), + }, + { + Input: "2010-05-20T10:44:25Z", + Expected: time.Date(2010, 5, 20, 10, 44, 25, 0, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.1Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 100e6, time.UTC), + }, + { + Input: "2012-03-06T04:20:07.5Z", + Expected: time.Date(2012, 3, 6, 4, 20, 7, 500e6, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.12Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 120e6, time.UTC), + }, + { + Input: "2013-03-04T20:43:55.56Z", + Expected: time.Date(2013, 3, 4, 20, 43, 55, 560e6, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.123Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 123e6, time.UTC), + }, + { + Input: "2007-04-07T17:34:11.784Z", + Expected: time.Date(2007, 4, 7, 17, 34, 11, 784e6, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.1234Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 123400e3, time.UTC), + }, + { + Input: "2017-09-14T16:54:30.4784Z", + Expected: time.Date(2017, 9, 14, 16, 54, 30, 478400e3, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.12345Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 123450e3, time.UTC), + }, + { + Input: "2009-06-07T14:20:30.74622Z", + Expected: time.Date(2009, 6, 7, 14, 20, 30, 746220e3, time.UTC), + }, + { + Input: "2006-01-02T22:04:05.123456Z", + Expected: time.Date(2006, 1, 2, 22, 4, 5, 123456e3, time.UTC), + }, + { + Input: "2008-10-25T06:50:55.643562Z", + Expected: time.Date(2008, 10, 25, 6, 50, 55, 643562e3, time.UTC), + }, + { + Input: "2002-10-02T10:00:00-05:00", + Expected: time.Date(2002, 10, 2, 10, 0, 0, 0, time.FixedZone("-0500", -5*60*60)), + }, + { + Input: "2002-10-02T10:00:00+11:00", + Expected: time.Date(2002, 10, 2, 10, 0, 0, 0, time.FixedZone("+1100", 11*60*60)), + }, + { + Input: "1920-03-13T19:50:53.999999Z", + Expected: time.Date(1920, 3, 13, 19, 50, 53, 999999e3, time.UTC), + }, + { + Input: "1920-03-13T19:50:54.000001Z", + Expected: time.Date(1920, 3, 13, 19, 50, 54, 1e3, time.UTC), + }, + } + + for _, row := range table { + func(input string, expected time.Time) { + expiryDate, err := parseExpiryDate(input) + assert.NilError(t, err) + assert.Equal(t, expiryDate.UTC(), expected.UTC()) + }(row.Input, row.Expected) + } +} + +func TestInvalidProtobufThrowsError(t *testing.T) { + result, err := ParseIssuanceDetails([]byte("invalid")) + + assert.Assert(t, is.Nil(result)) + + assert.ErrorContains(t, err, "unable to parse ThirdPartyAttribute value") +} diff --git a/digitalidentity/attribute/item.go b/digitalidentity/attribute/item.go new file mode 100644 index 00000000..3efd2b96 --- /dev/null +++ b/digitalidentity/attribute/item.go @@ -0,0 +1,14 @@ +package attribute + +import ( + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// Item is a structure which contains information about an attribute value +type Item struct { + // ContentType is the content of the item. + ContentType yotiprotoattr.ContentType + + // Value is the underlying data of the item. + Value interface{} +} diff --git a/digitalidentity/attribute/json_attribute.go b/digitalidentity/attribute/json_attribute.go new file mode 100644 index 00000000..be40920d --- /dev/null +++ b/digitalidentity/attribute/json_attribute.go @@ -0,0 +1,58 @@ +package attribute + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// JSONAttribute is a Yoti attribute which returns an interface as its value +type JSONAttribute struct { + attributeDetails + // value returns the value of a JSON attribute in the form of an interface + value map[string]interface{} +} + +// NewJSON creates a new JSON attribute +func NewJSON(a *yotiprotoattr.Attribute) (*JSONAttribute, error) { + var interfaceValue map[string]interface{} + decoder := json.NewDecoder(bytes.NewReader(a.Value)) + decoder.UseNumber() + err := decoder.Decode(&interfaceValue) + if err != nil { + err = fmt.Errorf("unable to parse JSON value: %q. Error: %q", a.Value, err) + return nil, err + } + + parsedAnchors := anchor.ParseAnchors(a.Anchors) + + return &JSONAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: interfaceValue, + }, nil +} + +// unmarshallJSON unmarshalls JSON into an interface +func unmarshallJSON(byteValue []byte) (result map[string]interface{}, err error) { + var unmarshalledJSON map[string]interface{} + err = json.Unmarshal(byteValue, &unmarshalledJSON) + + if err != nil { + return nil, err + } + + return unmarshalledJSON, err +} + +// Value returns the value of the JSONAttribute as an interface. +func (a *JSONAttribute) Value() map[string]interface{} { + return a.value +} diff --git a/digitalidentity/attribute/json_attribute_test.go b/digitalidentity/attribute/json_attribute_test.go new file mode 100644 index 00000000..42756373 --- /dev/null +++ b/digitalidentity/attribute/json_attribute_test.go @@ -0,0 +1,76 @@ +package attribute + +import ( + "fmt" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func ExampleNewJSON() { + proto := yotiprotoattr.Attribute{ + Name: "exampleJSON", + Value: []byte(`{"foo":"bar"}`), + ContentType: yotiprotoattr.ContentType_JSON, + } + attribute, err := NewJSON(&proto) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + fmt.Println(attribute.Value()) + // Output: map[foo:bar] +} + +func TestNewJSON_ShouldReturnNilForInvalidJSON(t *testing.T) { + proto := yotiprotoattr.Attribute{ + Name: "exampleJSON", + Value: []byte("Not a json document"), + ContentType: yotiprotoattr.ContentType_JSON, + } + attribute, err := NewJSON(&proto) + assert.Check(t, attribute == nil) + assert.ErrorContains(t, err, "unable to parse JSON value") +} + +func TestYotiClient_UnmarshallJSONValue_InvalidValueThrowsError(t *testing.T) { + invalidStructuredAddress := []byte("invalidBool") + + _, err := unmarshallJSON(invalidStructuredAddress) + + assert.Assert(t, err != nil) +} + +func TestYotiClient_UnmarshallJSONValue_ValidValue(t *testing.T) { + const ( + countryIso = "IND" + nestedValue = "NestedValue" + ) + + var structuredAddress = []byte(` + { + "address_format": 2, + "building": "House No.86-A", + "state": "Punjab", + "postal_code": "141012", + "country_iso": "` + countryIso + `", + "country": "India", + "formatted_address": "House No.86-A\nRajgura Nagar\nLudhina\nPunjab\n141012\nIndia", + "1": + { + "1-1": + { + "1-1-1": "` + nestedValue + `" + } + } + } + `) + + parsedStructuredAddress, err := unmarshallJSON(structuredAddress) + assert.NilError(t, err, "Failed to parse structured address") + + actualCountryIso := parsedStructuredAddress["country_iso"] + + assert.Equal(t, countryIso, actualCountryIso) +} diff --git a/digitalidentity/attribute/multivalue_attribute.go b/digitalidentity/attribute/multivalue_attribute.go new file mode 100644 index 00000000..926141f9 --- /dev/null +++ b/digitalidentity/attribute/multivalue_attribute.go @@ -0,0 +1,90 @@ +package attribute + +import ( + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" +) + +// MultiValueAttribute is a Yoti attribute which returns a multi-valued attribute +type MultiValueAttribute struct { + attributeDetails + items []*Item +} + +// NewMultiValue creates a new MultiValue attribute +func NewMultiValue(a *yotiprotoattr.Attribute) (*MultiValueAttribute, error) { + attributeItems, err := parseMultiValue(a.Value) + + if err != nil { + return nil, err + } + + return &MultiValueAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: anchor.ParseAnchors(a.Anchors), + id: &a.EphemeralId, + }, + items: attributeItems, + }, nil +} + +// parseMultiValue recursively unmarshals and converts Multi Value bytes into a slice of Items +func parseMultiValue(data []byte) ([]*Item, error) { + var attributeItems []*Item + protoMultiValueStruct, err := unmarshallMultiValue(data) + + if err != nil { + return nil, err + } + + for _, multiValueItem := range protoMultiValueStruct.Values { + var value *Item + if multiValueItem.ContentType == yotiprotoattr.ContentType_MULTI_VALUE { + parsedInnerMultiValueItems, err := parseMultiValue(multiValueItem.Data) + + if err != nil { + return nil, fmt.Errorf("unable to parse multi-value data: %v", err) + } + + value = &Item{ + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Value: parsedInnerMultiValueItems, + } + } else { + itemValue, err := parseValue(multiValueItem.ContentType, multiValueItem.Data) + + if err != nil { + return nil, fmt.Errorf("unable to parse data within a multi-value attribute. Content type: %q, data: %q, error: %v", + multiValueItem.ContentType, multiValueItem.Data, err) + } + + value = &Item{ + ContentType: multiValueItem.ContentType, + Value: itemValue, + } + } + attributeItems = append(attributeItems, value) + } + + return attributeItems, nil +} + +func unmarshallMultiValue(bytes []byte) (*yotiprotoattr.MultiValue, error) { + multiValueStruct := &yotiprotoattr.MultiValue{} + + if err := proto.Unmarshal(bytes, multiValueStruct); err != nil { + return nil, fmt.Errorf("unable to parse MULTI_VALUE value: %q. Error: %q", string(bytes), err) + } + + return multiValueStruct, nil +} + +// Value returns the value of the MultiValueAttribute as a string +func (a *MultiValueAttribute) Value() []*Item { + return a.items +} diff --git a/digitalidentity/attribute/multivalue_attribute_test.go b/digitalidentity/attribute/multivalue_attribute_test.go new file mode 100644 index 00000000..15a24f99 --- /dev/null +++ b/digitalidentity/attribute/multivalue_attribute_test.go @@ -0,0 +1,157 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func marshallMultiValue(t *testing.T, multiValue *yotiprotoattr.MultiValue) []byte { + marshalled, err := proto.Marshal(multiValue) + + assert.NilError(t, err) + + return marshalled +} + +func createMultiValueAttribute(t *testing.T, multiValueItemSlice []*yotiprotoattr.MultiValue_Value) (*MultiValueAttribute, error) { + var multiValueStruct = &yotiprotoattr.MultiValue{ + Values: multiValueItemSlice, + } + + var marshalledMultiValueData = marshallMultiValue(t, multiValueStruct) + attributeName := "nestedMultiValue" + + var protoAttribute = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: marshalledMultiValueData, + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Anchors: []*yotiprotoattr.Anchor{}, + } + + return NewMultiValue(protoAttribute) +} + +func TestAttribute_MultiValueNotCreatedWithNonMultiValueType(t *testing.T) { + attributeName := "attributeName" + attributeValueString := "value" + attributeValue := []byte(attributeValueString) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + _, err := NewMultiValue(attr) + + assert.Assert(t, err != nil, "Expected error when creating multi value from attribute which isn't of multi-value type") +} + +func TestAttribute_NewMultiValue(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_multivalue.txt") + + multiValueAttribute, err := NewMultiValue(protoAttribute) + + assert.NilError(t, err) + + documentImagesAttributeItems, err := CreateImageSlice(multiValueAttribute.Value()) + assert.NilError(t, err) + + assertIsExpectedDocumentImagesAttribute(t, documentImagesAttributeItems, multiValueAttribute.Anchors()[0]) +} + +func TestAttribute_InvalidMultiValueNotReturned(t *testing.T) { + var invalidMultiValueItem = &yotiprotoattr.MultiValue_Value{ + ContentType: yotiprotoattr.ContentType_DATE, + Data: []byte("invalid"), + } + + var stringMultiValueItem = &yotiprotoattr.MultiValue_Value{ + ContentType: yotiprotoattr.ContentType_STRING, + Data: []byte("string"), + } + + var multiValueItemSlice = []*yotiprotoattr.MultiValue_Value{invalidMultiValueItem, stringMultiValueItem} + + var multiValueStruct = &yotiprotoattr.MultiValue{ + Values: multiValueItemSlice, + } + + var marshalledMultiValueData = marshallMultiValue(t, multiValueStruct) + attributeName := "nestedMultiValue" + + var protoAttribute = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: marshalledMultiValueData, + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Anchors: []*yotiprotoattr.Anchor{}, + } + + multiValueAttr, err := NewMultiValue(protoAttribute) + assert.Check(t, err != nil) + + assert.Assert(t, is.Nil(multiValueAttr)) +} + +func TestAttribute_NestedMultiValue(t *testing.T) { + var innerMultiValueProtoValue = createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_multivalue.txt").Value + + var stringMultiValueItem = &yotiprotoattr.MultiValue_Value{ + ContentType: yotiprotoattr.ContentType_STRING, + Data: []byte("string"), + } + + var multiValueItem = &yotiprotoattr.MultiValue_Value{ + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Data: innerMultiValueProtoValue, + } + + var multiValueItemSlice = []*yotiprotoattr.MultiValue_Value{stringMultiValueItem, multiValueItem} + + multiValueAttribute, err := createMultiValueAttribute(t, multiValueItemSlice) + + assert.NilError(t, err) + + for key, value := range multiValueAttribute.Value() { + switch key { + case 0: + value0 := value.Value + + assert.Equal(t, value0.(string), "string") + case 1: + value1 := value.Value + + innerItems, ok := value1.([]*Item) + assert.Assert(t, ok) + + for innerKey, item := range innerItems { + switch innerKey { + case 0: + assertIsExpectedImage(t, item.Value.(media.Media), media.ImageTypeJPEG, "vWgD//2Q==") + + case 1: + assertIsExpectedImage(t, item.Value.(media.Media), media.ImageTypeJPEG, "38TVEH/9k=") + } + } + } + } +} + +func TestAttribute_MultiValueGenericGetter(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_multivalue.txt") + multiValueAttribute, err := NewMultiValue(protoAttribute) + assert.NilError(t, err) + + // We need to cast, since GetAttribute always returns generic attributes + multiValueAttributeValue := multiValueAttribute.Value() + imageSlice, err := CreateImageSlice(multiValueAttributeValue) + assert.NilError(t, err) + + assertIsExpectedDocumentImagesAttribute(t, imageSlice, multiValueAttribute.Anchors()[0]) +} diff --git a/digitalidentity/attribute/parser.go b/digitalidentity/attribute/parser.go new file mode 100644 index 00000000..d6635957 --- /dev/null +++ b/digitalidentity/attribute/parser.go @@ -0,0 +1,56 @@ +package attribute + +import ( + "fmt" + "strconv" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +func parseValue(contentType yotiprotoattr.ContentType, byteValue []byte) (interface{}, error) { + switch contentType { + case yotiprotoattr.ContentType_DATE: + parsedTime, err := time.Parse("2006-01-02", string(byteValue)) + + if err == nil { + return &parsedTime, nil + } + + return nil, fmt.Errorf("unable to parse date value: %q. Error: %q", string(byteValue), err) + + case yotiprotoattr.ContentType_JSON: + unmarshalledJSON, err := unmarshallJSON(byteValue) + + if err == nil { + return unmarshalledJSON, nil + } + + return nil, fmt.Errorf("unable to parse JSON value: %q. Error: %q", string(byteValue), err) + + case yotiprotoattr.ContentType_STRING: + return string(byteValue), nil + + case yotiprotoattr.ContentType_MULTI_VALUE: + return parseMultiValue(byteValue) + + case yotiprotoattr.ContentType_INT: + var stringValue = string(byteValue) + intValue, err := strconv.Atoi(stringValue) + if err == nil { + return intValue, nil + } + + return nil, fmt.Errorf("unable to parse INT value: %q. Error: %q", string(byteValue), err) + + case yotiprotoattr.ContentType_JPEG, + yotiprotoattr.ContentType_PNG: + return parseImageValue(contentType, byteValue) + + case yotiprotoattr.ContentType_UNDEFINED: + return string(byteValue), nil + + default: + return string(byteValue), nil + } +} diff --git a/digitalidentity/attribute/parser_test.go b/digitalidentity/attribute/parser_test.go new file mode 100644 index 00000000..cc9f3d8b --- /dev/null +++ b/digitalidentity/attribute/parser_test.go @@ -0,0 +1,16 @@ +package attribute + +import ( + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "gotest.tools/v3/assert" +) + +func TestParseValue_ShouldParseInt(t *testing.T) { + parsed, err := parseValue(yotiprotoattr.ContentType_INT, []byte("7")) + assert.NilError(t, err) + integer, ok := parsed.(int) + assert.Check(t, ok) + assert.Equal(t, integer, 7) +} diff --git a/digitalidentity/attribute/string_attribute.go b/digitalidentity/attribute/string_attribute.go new file mode 100644 index 00000000..73346b9a --- /dev/null +++ b/digitalidentity/attribute/string_attribute.go @@ -0,0 +1,32 @@ +package attribute + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute/anchor" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// StringAttribute is a Yoti attribute which returns a string as its value +type StringAttribute struct { + attributeDetails + value string +} + +// NewString creates a new String attribute +func NewString(a *yotiprotoattr.Attribute) *StringAttribute { + parsedAnchors := anchor.ParseAnchors(a.Anchors) + + return &StringAttribute{ + attributeDetails: attributeDetails{ + name: a.Name, + contentType: a.ContentType.String(), + anchors: parsedAnchors, + id: &a.EphemeralId, + }, + value: string(a.Value), + } +} + +// Value returns the value of the StringAttribute as a string +func (a *StringAttribute) Value() string { + return a.value +} diff --git a/digitalidentity/attribute/string_attribute_test.go b/digitalidentity/attribute/string_attribute_test.go new file mode 100644 index 00000000..828df201 --- /dev/null +++ b/digitalidentity/attribute/string_attribute_test.go @@ -0,0 +1,22 @@ +package attribute + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestStringAttribute_NewThirdPartyAttribute(t *testing.T) { + protoAttribute := createAttributeFromTestFile(t, "../../test/fixtures/test_attribute_third_party.txt") + + stringAttribute := NewString(protoAttribute) + + assert.Equal(t, stringAttribute.Value(), "test-third-party-attribute-0") + assert.Equal(t, stringAttribute.Name(), "com.thirdparty.id") + + assert.Equal(t, stringAttribute.Sources()[0].Value(), "THIRD_PARTY") + assert.Equal(t, stringAttribute.Sources()[0].SubType(), "orgName") + + assert.Equal(t, stringAttribute.Verifiers()[0].Value(), "THIRD_PARTY") + assert.Equal(t, stringAttribute.Verifiers()[0].SubType(), "orgName") +} diff --git a/digitalidentity/base_profile.go b/digitalidentity/base_profile.go new file mode 100644 index 00000000..693441d0 --- /dev/null +++ b/digitalidentity/base_profile.go @@ -0,0 +1,75 @@ +package digitalidentity + +import ( + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +type baseProfile struct { + attributeSlice []*yotiprotoattr.Attribute +} + +// GetAttribute retrieve an attribute by name on the Yoti profile. Will return nil if attribute is not present. +func (p baseProfile) GetAttribute(attributeName string) *attribute.GenericAttribute { + for _, a := range p.attributeSlice { + if a.Name == attributeName { + return attribute.NewGeneric(a) + } + } + return nil +} + +// GetAttributeByID retrieve an attribute by ID on the Yoti profile. Will return nil if attribute is not present. +func (p baseProfile) GetAttributeByID(attributeID string) *attribute.GenericAttribute { + for _, a := range p.attributeSlice { + if a.EphemeralId == attributeID { + return attribute.NewGeneric(a) + } + } + return nil +} + +// GetAttributes retrieve a list of attributes by name on the Yoti profile. Will return an empty list of attribute is not present. +func (p baseProfile) GetAttributes(attributeName string) []*attribute.GenericAttribute { + var attributes []*attribute.GenericAttribute + for _, a := range p.attributeSlice { + if a.Name == attributeName { + attributes = append(attributes, attribute.NewGeneric(a)) + } + } + return attributes +} + +// GetStringAttribute retrieves a string attribute by name. Will return nil if attribute is not present. +func (p baseProfile) GetStringAttribute(attributeName string) *attribute.StringAttribute { + for _, a := range p.attributeSlice { + if a.Name == attributeName { + return attribute.NewString(a) + } + } + return nil +} + +// GetImageAttribute retrieves an image attribute by name. Will return nil if attribute is not present. +func (p baseProfile) GetImageAttribute(attributeName string) *attribute.ImageAttribute { + for _, a := range p.attributeSlice { + if a.Name == attributeName { + imageAttribute, err := attribute.NewImage(a) + + if err == nil { + return imageAttribute + } + } + } + return nil +} + +// GetJSONAttribute retrieves a JSON attribute by name. Will return nil if attribute is not present. +func (p baseProfile) GetJSONAttribute(attributeName string) (*attribute.JSONAttribute, error) { + for _, a := range p.attributeSlice { + if a.Name == attributeName { + return attribute.NewJSON(a) + } + } + return nil, nil +} diff --git a/digitalidentity/policy_builder.go b/digitalidentity/policy_builder.go new file mode 100644 index 00000000..ef9688bd --- /dev/null +++ b/digitalidentity/policy_builder.go @@ -0,0 +1,250 @@ +package digitalidentity + +import ( + "encoding/json" + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/yotierror" +) + +const ( + authTypeSelfieConst = 1 + authTypePinConst = 2 +) + +// PolicyBuilder constructs a json payload specifying the dynamic policy +// for a dynamic scenario +type PolicyBuilder struct { + wantedAttributes map[string]WantedAttribute + wantedAuthTypes map[int]bool + isWantedRememberMe bool + err error + identityProfileRequirements *json.RawMessage +} + +// Policy represents a dynamic policy for a share +type Policy struct { + attributes []WantedAttribute + authTypes []int + rememberMeID bool + identityProfileRequirements *json.RawMessage +} + +// WithWantedAttribute adds an attribute from WantedAttributeBuilder to the policy +func (b *PolicyBuilder) WithWantedAttribute(attribute WantedAttribute) *PolicyBuilder { + if b.wantedAttributes == nil { + b.wantedAttributes = make(map[string]WantedAttribute) + } + var key string + if attribute.derivation != "" { + key = attribute.derivation + } else { + key = attribute.name + } + b.wantedAttributes[key] = attribute + return b +} + +// WithWantedAttributeByName adds an attribute by its name. This is not the preferred +// way of adding an attribute - instead use the other methods below. +// Options allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithWantedAttributeByName(name string, options ...interface{}) *PolicyBuilder { + attributeBuilder := (&WantedAttributeBuilder{}).WithName(name) + + for _, option := range options { + switch value := option.(type) { + case SourceConstraint: + attributeBuilder.WithConstraint(&value) + case constraintInterface: + attributeBuilder.WithConstraint(value) + default: + panic(fmt.Sprintf("not a valid option type, %v", value)) + } + } + + attribute, err := attributeBuilder.Build() + if err != nil { + b.err = yotierror.MultiError{This: err, Next: b.err} + } + b.WithWantedAttribute(attribute) + return b +} + +// WithFamilyName adds the family name attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithFamilyName(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrFamilyName, options...) +} + +// WithGivenNames adds the given names attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithGivenNames(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrGivenNames, options...) +} + +// WithFullName adds the full name attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithFullName(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrFullName, options...) +} + +// WithDateOfBirth adds the date of birth attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithDateOfBirth(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrDateOfBirth, options...) +} + +// WithGender adds the gender attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithGender(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrGender, options...) +} + +// WithPostalAddress adds the postal address attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithPostalAddress(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrAddress, options...) +} + +// WithStructuredPostalAddress adds the structured postal address attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithStructuredPostalAddress(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrStructuredPostalAddress, options...) +} + +// WithNationality adds the nationality attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithNationality(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrNationality, options...) +} + +// WithPhoneNumber adds the phone number attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithPhoneNumber(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrMobileNumber, options...) +} + +// WithSelfie adds the selfie attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithSelfie(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrSelfie, options...) +} + +// WithEmail adds the email address attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithEmail(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrEmailAddress, options...) +} + +// WithDocumentImages adds the document images attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithDocumentImages(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrDocumentImages, options...) +} + +// WithDocumentDetails adds the document details attribute, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithDocumentDetails(options ...interface{}) *PolicyBuilder { + return b.WithWantedAttributeByName(consts.AttrDocumentDetails, options...) +} + +// WithAgeDerivedAttribute is a helper method for setting age based derivations +// Prefer to use WithAgeOver and WithAgeUnder instead of using this directly. +// "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithAgeDerivedAttribute(derivation string, options ...interface{}) *PolicyBuilder { + var attributeBuilder WantedAttributeBuilder + attributeBuilder. + WithName(consts.AttrDateOfBirth). + WithDerivation(derivation) + + for _, option := range options { + switch value := option.(type) { + case SourceConstraint: + attributeBuilder.WithConstraint(&value) + case constraintInterface: + attributeBuilder.WithConstraint(value) + default: + panic(fmt.Sprintf("not a valid option type, %v", value)) + } + } + + attr, err := attributeBuilder.Build() + if err != nil { + b.err = yotierror.MultiError{This: err, Next: b.err} + } + return b.WithWantedAttribute(attr) +} + +// WithAgeOver sets this dynamic policy as requesting whether the user is older than a certain age. +// "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithAgeOver(age int, options ...interface{}) *PolicyBuilder { + return b.WithAgeDerivedAttribute(fmt.Sprintf(consts.AttrAgeOver, age), options...) +} + +// WithAgeUnder sets this dynamic policy as requesting whether the user is younger +// than a certain age, "options" allows one or more options to be specified e.g. SourceConstraint +func (b *PolicyBuilder) WithAgeUnder(age int, options ...interface{}) *PolicyBuilder { + return b.WithAgeDerivedAttribute(fmt.Sprintf(consts.AttrAgeUnder, age), options...) +} + +// WithWantedRememberMe sets the Policy as requiring a "Remember Me ID" +func (b *PolicyBuilder) WithWantedRememberMe() *PolicyBuilder { + b.isWantedRememberMe = true + return b +} + +// WithWantedAuthType sets this dynamic policy as requiring a specific authentication type +func (b *PolicyBuilder) WithWantedAuthType(wantedAuthType int) *PolicyBuilder { + if b.wantedAuthTypes == nil { + b.wantedAuthTypes = make(map[int]bool) + } + b.wantedAuthTypes[wantedAuthType] = true + return b +} + +// WithSelfieAuth sets this dynamic policy as requiring Selfie-based authentication +func (b *PolicyBuilder) WithSelfieAuth() *PolicyBuilder { + return b.WithWantedAuthType(authTypeSelfieConst) +} + +// WithPinAuth sets this dynamic policy as requiring PIN authentication +func (b *PolicyBuilder) WithPinAuth() *PolicyBuilder { + return b.WithWantedAuthType(authTypePinConst) +} + +// WithIdentityProfileRequirements adds Identity Profile Requirements to the policy. Must be valid JSON. +func (b *PolicyBuilder) WithIdentityProfileRequirements(identityProfile json.RawMessage) *PolicyBuilder { + b.identityProfileRequirements = &identityProfile + return b +} + +// Build constructs a dynamic policy object +func (b *PolicyBuilder) Build() (Policy, error) { + return Policy{ + attributes: b.attributesAsList(), + authTypes: b.authTypesAsList(), + rememberMeID: b.isWantedRememberMe, + identityProfileRequirements: b.identityProfileRequirements, + }, b.err +} + +func (b *PolicyBuilder) attributesAsList() []WantedAttribute { + attributeList := make([]WantedAttribute, 0) + for _, attr := range b.wantedAttributes { + attributeList = append(attributeList, attr) + } + return attributeList +} + +func (b *PolicyBuilder) authTypesAsList() []int { + authTypeList := make([]int, 0) + for auth, boolValue := range b.wantedAuthTypes { + if boolValue { + authTypeList = append(authTypeList, auth) + } + } + return authTypeList +} + +// MarshalJSON returns the JSON encoding +func (policy *Policy) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Wanted []WantedAttribute `json:"wanted"` + WantedAuthTypes []int `json:"wanted_auth_types"` + WantedRememberMe bool `json:"wanted_remember_me"` + IdentityProfileRequirements *json.RawMessage `json:"identity_profile_requirements,omitempty"` + }{ + Wanted: policy.attributes, + WantedAuthTypes: policy.authTypes, + WantedRememberMe: policy.rememberMeID, + IdentityProfileRequirements: policy.identityProfileRequirements, + }) +} diff --git a/digitalidentity/policy_builder_test.go b/digitalidentity/policy_builder_test.go new file mode 100644 index 00000000..e2bc075a --- /dev/null +++ b/digitalidentity/policy_builder_test.go @@ -0,0 +1,439 @@ +package digitalidentity + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/yotierror" + "gotest.tools/v3/assert" +) + +func ExamplePolicyBuilder_WithFamilyName() { + policy, err := (&PolicyBuilder{}).WithFamilyName().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.attributes[0].MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"name":"family_name","accept_self_asserted":false} +} + +func ExamplePolicyBuilder_WithDocumentDetails() { + policy, err := (&PolicyBuilder{}).WithDocumentDetails().Build() + if err != nil { + return + } + data, err := policy.attributes[0].MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"name":"document_details","accept_self_asserted":false} +} + +func ExamplePolicyBuilder_WithDocumentImages() { + policy, err := (&PolicyBuilder{}).WithDocumentImages().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.attributes[0].MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"name":"document_images","accept_self_asserted":false} +} + +func ExamplePolicyBuilder_WithSelfie() { + policy, err := (&PolicyBuilder{}).WithSelfie().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.attributes[0].MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"name":"selfie","accept_self_asserted":false} +} + +func ExamplePolicyBuilder_WithAgeOver() { + constraint, err := (&SourceConstraintBuilder{}).WithDrivingLicence("").Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + policy, err := (&PolicyBuilder{}).WithAgeOver(18, constraint).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.attributes[0].MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"name":"date_of_birth","derivation":"age_over:18","constraints":[{"type":"SOURCE","preferred_sources":{"anchors":[{"name":"DRIVING_LICENCE","sub_type":""}],"soft_preference":false}}],"accept_self_asserted":false} +} + +func ExamplePolicyBuilder_WithSelfieAuth() { + policy, err := (&PolicyBuilder{}).WithSelfieAuth().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[],"wanted_auth_types":[1],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder_WithWantedRememberMe() { + policy, err := (&PolicyBuilder{}).WithWantedRememberMe().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[],"wanted_auth_types":[],"wanted_remember_me":true} +} + +func ExamplePolicyBuilder_WithFullName() { + constraint, err := (&SourceConstraintBuilder{}).WithPassport("").Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + policy, err := (&PolicyBuilder{}).WithFullName(&constraint).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + marshalledJSON, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(marshalledJSON)) + // Output: {"wanted":[{"name":"full_name","constraints":[{"type":"SOURCE","preferred_sources":{"anchors":[{"name":"PASSPORT","sub_type":""}],"soft_preference":false}}],"accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder() { + policy, err := (&PolicyBuilder{}).WithFullName(). + WithPinAuth().WithWantedRememberMe().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"full_name","accept_self_asserted":false}],"wanted_auth_types":[2],"wanted_remember_me":true} +} + +func ExamplePolicyBuilder_WithAgeUnder() { + policy, err := (&PolicyBuilder{}).WithAgeUnder(18).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"date_of_birth","derivation":"age_under:18","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder_WithGivenNames() { + policy, err := (&PolicyBuilder{}).WithGivenNames().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"given_names","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder_WithDateOfBirth() { + policy, err := (&PolicyBuilder{}).WithDateOfBirth().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"date_of_birth","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder_WithGender() { + policy, err := (&PolicyBuilder{}).WithGender().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"gender","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder_WithPostalAddress() { + policy, err := (&PolicyBuilder{}).WithPostalAddress().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"postal_address","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder_WithStructuredPostalAddress() { + policy, err := (&PolicyBuilder{}).WithStructuredPostalAddress().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"structured_postal_address","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder_WithNationality() { + policy, err := (&PolicyBuilder{}).WithNationality().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"nationality","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func ExamplePolicyBuilder_WithPhoneNumber() { + policy, err := (&PolicyBuilder{}).WithPhoneNumber().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[{"name":"phone_number","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false} +} + +func TestDigitalIdentityBuilder_WithWantedAttributeByName_WithSourceConstraint(t *testing.T) { + attributeName := "attributeName" + builder := &PolicyBuilder{} + sourceConstraint, err := (&SourceConstraintBuilder{}).Build() + assert.NilError(t, err) + + builder.WithWantedAttributeByName( + attributeName, + sourceConstraint, + ) + + policy, err := builder.Build() + assert.NilError(t, err) + assert.Equal(t, len(policy.attributes), 1) + assert.Equal(t, policy.attributes[0].name, attributeName) + assert.Equal(t, len(policy.attributes[0].constraints), 1) +} + +func TestDigitalIdentityBuilder_WithWantedAttributeByName_InvalidOptionsShouldPanic(t *testing.T) { + attributeName := "attributeName" + builder := &PolicyBuilder{} + invalidOption := "invalidOption" + + defer func() { + r := recover().(string) + assert.Check(t, strings.Contains(r, "not a valid option type")) + }() + + builder.WithWantedAttributeByName( + attributeName, + invalidOption, + ) + + t.Error("Expected Panic") + +} + +func TestDigitalIdentityBuilder_WithWantedAttributeByName_ShouldPropagateErrors(t *testing.T) { + builder := &PolicyBuilder{} + + builder.WithWantedAttributeByName("") + builder.WithWantedAttributeByName("") + + _, err := builder.Build() + + assert.Error(t, err, "wanted attribute names must not be empty, wanted attribute names must not be empty") + assert.Error(t, err.(yotierror.MultiError).Unwrap(), "wanted attribute names must not be empty") +} + +func TestDigitalIdentityBuilder_WithAgeDerivedAttribute_WithSourceConstraint(t *testing.T) { + builder := &PolicyBuilder{} + sourceConstraint, err := (&SourceConstraintBuilder{}).Build() + assert.NilError(t, err) + + builder.WithAgeDerivedAttribute( + fmt.Sprintf(consts.AttrAgeOver, 18), + sourceConstraint, + ) + + policy, err := builder.Build() + assert.NilError(t, err) + assert.Equal(t, len(policy.attributes), 1) + assert.Equal(t, policy.attributes[0].name, consts.AttrDateOfBirth) + assert.Equal(t, len(policy.attributes[0].constraints), 1) +} + +func TestDigitalIdentityBuilder_WithAgeDerivedAttribute_WithConstraintInterface(t *testing.T) { + builder := &PolicyBuilder{} + var constraint constraintInterface + sourceConstraint, err := (&SourceConstraintBuilder{}).Build() + constraint = &sourceConstraint + assert.NilError(t, err) + + builder.WithAgeDerivedAttribute( + fmt.Sprintf(consts.AttrAgeOver, 18), + constraint, + ) + + policy, err := builder.Build() + assert.NilError(t, err) + assert.Equal(t, len(policy.attributes), 1) + assert.Equal(t, policy.attributes[0].name, consts.AttrDateOfBirth) + assert.Equal(t, len(policy.attributes[0].constraints), 1) +} + +func TestDigitalIdentityBuilder_WithAgeDerivedAttribute_InvalidOptionsShouldPanic(t *testing.T) { + builder := &PolicyBuilder{} + invalidOption := "invalidOption" + + defer func() { + r := recover().(string) + assert.Check(t, strings.Contains(r, "not a valid option type")) + }() + + builder.WithAgeDerivedAttribute( + fmt.Sprintf(consts.AttrAgeOver, 18), + invalidOption, + ) + + t.Error("Expected Panic") + +} + +func TestDigitalIdentityBuilder_WithIdentityProfileRequirements_ShouldFailForInvalidJSON(t *testing.T) { + identityProfile := []byte(`{ + "trust_framework": UK_TFIDA", + , + }`) + + policy, err := (&PolicyBuilder{}).WithIdentityProfileRequirements(identityProfile).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + _, err = policy.MarshalJSON() + if err == nil { + t.Error("expected an error") + } + var marshallerErr *json.MarshalerError + if !errors.As(err, &marshallerErr) { + t.Errorf("wanted err to be of type '%v', got: '%v'", reflect.TypeOf(marshallerErr), reflect.TypeOf(err)) + } +} diff --git a/digitalidentity/qr_code.go b/digitalidentity/qr_code.go new file mode 100644 index 00000000..bff20b64 --- /dev/null +++ b/digitalidentity/qr_code.go @@ -0,0 +1,6 @@ +package digitalidentity + +type QrCode struct { + Id string `json:"id"` + Uri string `json:"uri"` +} diff --git a/digitalidentity/receipt.go b/digitalidentity/receipt.go new file mode 100644 index 00000000..8c9b9aa4 --- /dev/null +++ b/digitalidentity/receipt.go @@ -0,0 +1,19 @@ +package digitalidentity + +type Content struct { + Profile []byte `json:"profile"` + ExtraData []byte `json:"extraData"` +} + +type ReceiptResponse struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + Timestamp string `json:"timestamp"` + RememberMeID string `json:"rememberMeId,omitempty"` + ParentRememberMeID string `json:"parentRememberMeId,omitempty"` + Content *Content `json:"content,omitempty"` + OtherPartyContent *Content `json:"otherPartyContent,omitempty"` + WrappedItemKeyId string `json:"wrappedItemKeyId"` + WrappedKey []byte `json:"wrappedKey"` + Error string `json:"error"` +} diff --git a/digitalidentity/receipt_item_key.go b/digitalidentity/receipt_item_key.go new file mode 100644 index 00000000..7e805d87 --- /dev/null +++ b/digitalidentity/receipt_item_key.go @@ -0,0 +1,7 @@ +package digitalidentity + +type ReceiptItemKeyResponse struct { + ID string `json:"id"` + Iv []byte `json:"iv"` + Value []byte `json:"value"` +} diff --git a/digitalidentity/requests/client.go b/digitalidentity/requests/client.go new file mode 100644 index 00000000..74c289e8 --- /dev/null +++ b/digitalidentity/requests/client.go @@ -0,0 +1,10 @@ +package requests + +import ( + "net/http" +) + +// HttpClient is a mockable HTTP Client Interface +type HttpClient interface { + Do(*http.Request) (*http.Response, error) +} diff --git a/digitalidentity/requests/request.go b/digitalidentity/requests/request.go new file mode 100644 index 00000000..e5bbeaba --- /dev/null +++ b/digitalidentity/requests/request.go @@ -0,0 +1,40 @@ +package requests + +import ( + "net/http" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/digitalidentity/yotierror" +) + +// Execute makes a request to the specified endpoint, with an optional payload +func Execute(httpClient HttpClient, request *http.Request) (response *http.Response, err error) { + + if response, err = doRequest(request, httpClient); err != nil { + + return + } + + statusCodeIsFailure := response.StatusCode >= 300 || response.StatusCode < 200 + + if statusCodeIsFailure { + return response, yotierror.NewResponseError(response) + } + + return response, nil +} + +func doRequest(request *http.Request, httpClient HttpClient) (*http.Response, error) { + httpClient = ensureHttpClientTimeout(httpClient) + return httpClient.Do(request) +} + +func ensureHttpClientTimeout(httpClient HttpClient) HttpClient { + if httpClient == nil { + httpClient = &http.Client{ + Timeout: time.Second * 10, + } + } + + return httpClient +} diff --git a/digitalidentity/requests/request_test.go b/digitalidentity/requests/request_test.go new file mode 100644 index 00000000..420fa6b2 --- /dev/null +++ b/digitalidentity/requests/request_test.go @@ -0,0 +1,71 @@ +package requests + +import ( + "net/http" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +type mockHTTPClient struct { + do func(*http.Request) (*http.Response, error) +} + +func (mock *mockHTTPClient) Do(request *http.Request) (*http.Response, error) { + if mock.do != nil { + return mock.do(request) + } + return nil, nil +} + +func TestExecute_Success(t *testing.T) { + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + }, nil + }, + } + + request := &http.Request{ + Method: http.MethodGet, + } + + response, err := Execute(client, request) + + assert.NilError(t, err) + assert.Equal(t, response.StatusCode, 200) +} + +func TestExecute_Failure(t *testing.T) { + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 400, + }, nil + }, + } + + request := &http.Request{ + Method: http.MethodGet, + } + + _, err := Execute(client, request) + assert.ErrorContains(t, err, "unknown HTTP error") +} + +func TestEnsureHttpClientTimeout_NilHTTPClientShouldUse10sTimeout(t *testing.T) { + result := ensureHttpClientTimeout(nil).(*http.Client) + + assert.Equal(t, 10*time.Second, result.Timeout) +} + +func TestEnsureHttpClientTimeout(t *testing.T) { + httpClient := &http.Client{ + Timeout: time.Minute * 12, + } + result := ensureHttpClientTimeout(httpClient).(*http.Client) + + assert.Equal(t, 12*time.Minute, result.Timeout) +} diff --git a/digitalidentity/requests/signed_message.go b/digitalidentity/requests/signed_message.go new file mode 100644 index 00000000..02e2116f --- /dev/null +++ b/digitalidentity/requests/signed_message.go @@ -0,0 +1,233 @@ +package requests + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/consts" +) + +// MergeHeaders merges two or more header prototypes together from left to right +func MergeHeaders(headers ...map[string][]string) map[string][]string { + if len(headers) == 0 { + return make(map[string][]string) + } + out := headers[0] + for _, element := range headers[1:] { + for k, v := range element { + out[k] = v + } + } + return out +} + +// JSONHeaders is a header prototype for JSON based requests +func JSONHeaders() map[string][]string { + return map[string][]string{ + "Content-Type": {"application/json"}, + "Accept": {"application/json"}, + } +} + +// AuthHeader is a header prototype including the App/SDK ID +func AuthHeader(clientSdkId string) map[string][]string { + return map[string][]string{ + "X-Yoti-Auth-Id": {clientSdkId}, + } +} + +// AuthKeyHeader is a header prototype including an encoded RSA PublicKey +func AuthKeyHeader(key *rsa.PublicKey) map[string][]string { + return map[string][]string{ + "X-Yoti-Auth-Key": { + base64.StdEncoding.EncodeToString( + func(a []byte, _ error) []byte { + return a + }(x509.MarshalPKIXPublicKey(key)), + ), + }, + } +} + +// SignedRequest is a builder for constructing a http.Request with Yoti signing +type SignedRequest struct { + Key *rsa.PrivateKey + HTTPMethod string + BaseURL string + Endpoint string + Headers map[string][]string + Params map[string]string + Body []byte + Error error +} + +func (msg *SignedRequest) signDigest(digest []byte) (string, error) { + hash := sha256.Sum256(digest) + signed, err := rsa.SignPKCS1v15(rand.Reader, msg.Key, crypto.SHA256, hash[:]) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signed), nil +} + +func getTimestamp() string { + return strconv.FormatInt(time.Now().Unix()*1000, 10) +} + +func getNonce() (string, error) { + nonce := make([]byte, 16) + _, err := rand.Read(nonce) + return fmt.Sprintf("%X-%X-%X-%X-%X", nonce[0:4], nonce[4:6], nonce[6:8], nonce[8:10], nonce[10:]), err +} + +// WithPemFile loads the private key from a PEM file reader +func (msg SignedRequest) WithPemFile(in []byte) SignedRequest { + block, _ := pem.Decode(in) + if block == nil { + msg.Error = errors.New("input is not PEM-encoded") + return msg + } + if block.Type != "RSA PRIVATE KEY" { + msg.Error = errors.New("input is not an RSA Private Key") + return msg + } + + msg.Key, msg.Error = x509.ParsePKCS1PrivateKey(block.Bytes) + return msg +} + +func (msg *SignedRequest) addParametersToEndpoint() (string, error) { + if msg.Params == nil { + msg.Params = make(map[string]string) + } + // Add Timestamp/Nonce + if _, ok := msg.Params["nonce"]; !ok { + nonce, err := getNonce() + if err != nil { + return "", err + } + msg.Params["nonce"] = nonce + } + if _, ok := msg.Params["timestamp"]; !ok { + msg.Params["timestamp"] = getTimestamp() + } + + endpoint := msg.Endpoint + if !strings.Contains(endpoint, "?") { + endpoint = endpoint + "?" + } else { + endpoint = endpoint + "&" + } + + var firstParam = true + for param, value := range msg.Params { + var formatString = "%s&%s=%s" + if firstParam { + formatString = "%s%s=%s" + } + endpoint = fmt.Sprintf(formatString, endpoint, param, value) + firstParam = false + } + + return endpoint, nil +} + +func (msg *SignedRequest) generateDigest(endpoint string) (digest string) { + // Generate the message digest + if msg.Body != nil { + digest = fmt.Sprintf( + "%s&%s&%s", + msg.HTTPMethod, + endpoint, + base64.StdEncoding.EncodeToString(msg.Body), + ) + } else { + digest = fmt.Sprintf("%s&%s", + msg.HTTPMethod, + endpoint, + ) + } + return +} + +func (msg *SignedRequest) checkMandatories() error { + if msg.Error != nil { + return msg.Error + } + if msg.Key == nil { + return fmt.Errorf("missing private key") + } + if msg.HTTPMethod == "" { + return fmt.Errorf("missing HTTPMethod") + } + if msg.BaseURL == "" { + return fmt.Errorf("missing BaseURL") + } + if msg.Endpoint == "" { + return fmt.Errorf("missing Endpoint") + } + return nil +} + +// Request builds a http.Request with signature headers +func (msg SignedRequest) Request() (request *http.Request, err error) { + err = msg.checkMandatories() + if err != nil { + return + } + + endpoint, err := msg.addParametersToEndpoint() + if err != nil { + return + } + + signedDigest, err := msg.signDigest([]byte(msg.generateDigest(endpoint))) + if err != nil { + return + } + + // Construct the HTTP Request + request, err = http.NewRequest( + msg.HTTPMethod, + msg.BaseURL+endpoint, + bytes.NewReader(msg.Body), + ) + if err != nil { + return + } + + request.Header.Add("X-Yoti-Auth-Digest", signedDigest) + request.Header.Add("X-Yoti-SDK", consts.SDKIdentifier) + request.Header.Add("X-Yoti-SDK-Version", consts.SDKVersionIdentifier) + + for key, values := range msg.Headers { + for _, value := range values { + request.Header.Add(key, value) + } + } + + return request, err +} + +func Base64ToBase64URL(base64Str string) string { + decoded, err := base64.StdEncoding.DecodeString(base64Str) + if err != nil { + return "" + } + + base64URL := base64.URLEncoding.EncodeToString(decoded) + + return base64URL +} diff --git a/digitalidentity/requests/signed_message_test.go b/digitalidentity/requests/signed_message_test.go new file mode 100644 index 00000000..301be62f --- /dev/null +++ b/digitalidentity/requests/signed_message_test.go @@ -0,0 +1,169 @@ +package requests + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "regexp" + "testing" + + "gotest.tools/v3/assert" +) + +const exampleKey = "MIICXgIBAAKBgQCpTiICtL+ujx8D0FquVWIaXg+ajJadN5hsTlGUXymiFAunSZjLjTsoGfSPz8PJm6pG9ax1Qb+R5UsSgTRTcpZTps2RLRWr5oPfD66bz4l38QXPSvfg5o+5kNxyCb8QANitF7Ht/DcpsGpL7anruHg/RgCLCBFRaGAodfuJCCM9zwIDAQABAoGBAIJL7GbSvjZUVVU1E6TZd0+9lhqmGf/S2o5309bxSfQ/oxxSyrHU9nMNTqcjCZXuJCTKS7hOKmXY5mbOYvvZ0xA7DXfOc+A4LGXQl0r3ZMzhHZTPKboUSh16E4WI4pr98KagFdkeB/0KBURM3x5d/6dSKip8ZpEyqVpuc9d1xtvhAkEAxabfsqfb4fgBsrhZ/qt133yB0FBHs1alRxvUXZWbVPTOegKi5KBdPptf2QfCy8WK3An/lg8cFQG78PyNll/P0QJBANtJBUHTuRDCoYLhqZLdSTQ52qOWRNutZ2fho9ZcLquokB4SFFeC2I4T+s3oSJ8SNh9vW1nNeXW6Zipx+zz8O58CQQCjV9qNGf40zDITEhmFxwt967aYgpAO3O9wScaCpM4fMsWkvaMDEKiewec/RBOvNY0hdb3ctJX/olRAv2b/vCTRAkAuLmCnDlnJR9QP5kp6HZRPJWgAT6NMyGYgoIqKmHtTt3oyewhBrdLBiT+moaa5qXIwiJkqfnV377uYcMzCeTRtAkEAwHdhM3v01GprmHqE2kvlKOXNq9CB1Z4j/vXSQxBYoSrFWLv5nW9e69ngX+n7qhvO3Gs9CBoy/oqOLatFZOuFEw==" + +var keyBytes, _ = base64.StdEncoding.DecodeString(exampleKey) +var privateKey, _ = x509.ParsePKCS1PrivateKey(keyBytes) + +func ExampleMergeHeaders() { + left := map[string][]string{"A": {"Value Of A"}} + right := map[string][]string{"B": {"Value Of B"}} + + merged := MergeHeaders(left, right) + fmt.Println(merged["A"]) + fmt.Println(merged["B"]) + // Output: + // [Value Of A] + // [Value Of B] +} + +func TestMergeHeaders_HandleNullCaseGracefully(t *testing.T) { + assert.Equal(t, len(MergeHeaders()), 0) +} + +func ExampleJSONHeaders() { + jsonHeaders, err := json.Marshal(JSONHeaders()) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(jsonHeaders)) + // Output: {"Accept":["application/json"],"Content-Type":["application/json"]} +} + +func ExampleAuthKeyHeader() { + headers, err := json.Marshal(AuthKeyHeader(&privateKey.PublicKey)) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(headers)) + // Output: {"X-Yoti-Auth-Key":["MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCpTiICtL+ujx8D0FquVWIaXg+ajJadN5hsTlGUXymiFAunSZjLjTsoGfSPz8PJm6pG9ax1Qb+R5UsSgTRTcpZTps2RLRWr5oPfD66bz4l38QXPSvfg5o+5kNxyCb8QANitF7Ht/DcpsGpL7anruHg/RgCLCBFRaGAodfuJCCM9zwIDAQAB"]} +} + +func TestRequestShouldBuildForValid(t *testing.T) { + random := rand.New(rand.NewSource(25)) + key, err := rsa.GenerateKey(random, 1024) + + assert.NilError(t, err) + httpMethod := "GET" + baseURL := "example.com" + endpoint := "/" + + request := SignedRequest{ + Key: key, + HTTPMethod: httpMethod, + BaseURL: baseURL, + Endpoint: endpoint, + } + signed, err := request.Request() + assert.NilError(t, err) + assert.Equal(t, httpMethod, signed.Method) + urlCheck, err := regexp.Match(baseURL+endpoint, []byte(signed.URL.String())) + assert.NilError(t, err) + assert.Check(t, urlCheck) + assert.Check(t, signed.Header.Get("X-Yoti-Auth-Digest") != "") + assert.Equal(t, signed.Header.Get("X-Yoti-SDK"), "Go") + assert.Equal(t, signed.Header.Get("X-Yoti-SDK-Version"), "3.9.0") +} + +func TestRequestShouldAddHeaders(t *testing.T) { + random := rand.New(rand.NewSource(25)) + key, err := rsa.GenerateKey(random, 1024) + + assert.NilError(t, err) + httpMethod := "GET" + baseURL := "example.com" + endpoint := "/" + + request := SignedRequest{ + Key: key, + HTTPMethod: httpMethod, + BaseURL: baseURL, + Endpoint: endpoint, + Headers: JSONHeaders(), + } + signed, err := request.Request() + assert.NilError(t, err) + assert.Check(t, signed.Header["X-Yoti-Auth-Digest"][0] != "") + assert.Equal(t, signed.Header["Accept"][0], "application/json") +} + +func TestSignedRequest_checkMandatories_WhenErrorIsSetReturnIt(t *testing.T) { + msg := &SignedRequest{Error: fmt.Errorf("exampleError")} + assert.Error(t, msg.checkMandatories(), "exampleError") +} + +func TestSignedRequest_checkMandatories_WhenKeyMissing(t *testing.T) { + msg := &SignedRequest{} + assert.Error(t, msg.checkMandatories(), "missing private key") +} + +func TestSignedRequest_checkMandatories_WhenHTTPMethodMissing(t *testing.T) { + msg := &SignedRequest{Key: privateKey} + assert.Error(t, msg.checkMandatories(), "missing HTTPMethod") +} + +func TestSignedRequest_checkMandatories_WhenBaseURLMissing(t *testing.T) { + msg := &SignedRequest{ + Key: privateKey, + HTTPMethod: http.MethodPost, + } + assert.Error(t, msg.checkMandatories(), "missing BaseURL") +} + +func TestSignedRequest_checkMandatories_WhenEndpointMissing(t *testing.T) { + msg := &SignedRequest{ + Key: privateKey, + HTTPMethod: http.MethodPost, + BaseURL: "example.com", + } + assert.Error(t, msg.checkMandatories(), "missing Endpoint") +} + +func ExampleSignedRequest_generateDigest() { + msg := &SignedRequest{ + HTTPMethod: http.MethodPost, + Body: []byte("simple message body"), + } + fmt.Println(msg.generateDigest("endpoint")) + // Output: POST&endpoint&c2ltcGxlIG1lc3NhZ2UgYm9keQ== + +} + +func ExampleSignedRequest_WithPemFile() { + msg := SignedRequest{}.WithPemFile([]byte(` +-----BEGIN RSA PRIVATE KEY----- +` + exampleKey + ` +-----END RSA PRIVATE KEY-----`)) + fmt.Println(AuthKeyHeader(&msg.Key.PublicKey)) + // Output: map[X-Yoti-Auth-Key:[MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCpTiICtL+ujx8D0FquVWIaXg+ajJadN5hsTlGUXymiFAunSZjLjTsoGfSPz8PJm6pG9ax1Qb+R5UsSgTRTcpZTps2RLRWr5oPfD66bz4l38QXPSvfg5o+5kNxyCb8QANitF7Ht/DcpsGpL7anruHg/RgCLCBFRaGAodfuJCCM9zwIDAQAB]] +} + +func TestSignedRequest_WithPemFile_NotPemEncodedShouldError(t *testing.T) { + msg := SignedRequest{}.WithPemFile([]byte("not pem encoded")) + assert.ErrorContains(t, msg.Error, "not PEM-encoded") +} + +func TestSignedRequest_WithPemFile_NotRSAKeyShouldError(t *testing.T) { + msg := SignedRequest{}.WithPemFile([]byte(`-----BEGIN RSA PUBLIC KEY----- +` + exampleKey + ` +-----END RSA PUBLIC KEY-----`)) + assert.ErrorContains(t, msg.Error, "not an RSA Private Key") +} diff --git a/digitalidentity/service.go b/digitalidentity/service.go new file mode 100644 index 00000000..444a6560 --- /dev/null +++ b/digitalidentity/service.go @@ -0,0 +1,303 @@ +package digitalidentity + +import ( + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/getyoti/yoti-go-sdk/v3/cryptoutil" + "github.com/getyoti/yoti-go-sdk/v3/digitalidentity/requests" + "github.com/getyoti/yoti-go-sdk/v3/extra" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" +) + +const identitySessionCreationEndpoint = "/v2/sessions" +const identitySessionRetrieval = "/v2/sessions/%s" +const identitySessionQrCodeCreation = "/v2/sessions/%s/qr-codes" +const identitySessionQrCodeRetrieval = "/v2/qr-codes/%s" +const identitySessionReceiptRetrieval = "/v2/receipts/%s" +const identitySessionReceiptKeyRetrieval = "/v2/wrapped-item-keys/%s" + +// CreateShareSession creates session using the supplied session specification +func CreateShareSession(httpClient requests.HttpClient, shareSessionRequest *ShareSessionRequest, clientSdkId, apiUrl string, key *rsa.PrivateKey) (*ShareSession, error) { + endpoint := identitySessionCreationEndpoint + + payload, err := shareSessionRequest.MarshalJSON() + if err != nil { + return nil, err + } + + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodPost, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: requests.AuthHeader(clientSdkId), + Body: payload, + Params: map[string]string{"sdkID": clientSdkId}, + }.Request() + if err != nil { + return nil, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %v", err) + } + + defer response.Body.Close() + shareSession := &ShareSession{} + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + err = json.Unmarshal(responseBytes, shareSession) + return shareSession, err +} + +// GetShareSession get session info using the supplied sessionID parameter +func GetShareSession(httpClient requests.HttpClient, sessionID string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (*ShareSession, error) { + endpoint := fmt.Sprintf(identitySessionRetrieval, sessionID) + + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodGet, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: requests.AuthHeader(clientSdkId), + Params: map[string]string{"sdkID": clientSdkId}, + }.Request() + if err != nil { + return nil, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + + if err != nil { + return nil, fmt.Errorf("failed to execute request: %v", err) + } + defer response.Body.Close() + shareSession := &ShareSession{} + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + err = json.Unmarshal(responseBytes, shareSession) + return shareSession, err +} + +// CreateShareQrCode generates a sharing qr code using the supplied sessionID parameter +func CreateShareQrCode(httpClient requests.HttpClient, sessionID string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (*QrCode, error) { + endpoint := fmt.Sprintf(identitySessionQrCodeCreation, sessionID) + + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodPost, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: requests.AuthHeader(clientSdkId), + Body: nil, + Params: map[string]string{"sdkID": clientSdkId}, + }.Request() + if err != nil { + return nil, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %v", err) + } + + defer response.Body.Close() + qrCode := &QrCode{} + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + err = json.Unmarshal(responseBytes, qrCode) + return qrCode, err +} + +// GetShareSessionQrCode is used to fetch the qr code by id. +func GetShareSessionQrCode(httpClient requests.HttpClient, qrCodeId string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (fetchedQrCode ShareSessionQrCode, err error) { + endpoint := fmt.Sprintf(identitySessionQrCodeRetrieval, qrCodeId) + headers := requests.AuthHeader(clientSdkId) + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodGet, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: headers, + }.Request() + if err != nil { + return fetchedQrCode, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return fetchedQrCode, fmt.Errorf("failed to execute request: %v", err) + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return fetchedQrCode, fmt.Errorf("failed to read response body: %v", err) + } + + err = json.Unmarshal(responseBytes, &fetchedQrCode) + + return fetchedQrCode, err +} + +// GetReceipt fetches receipt info using a receipt id. +func getReceipt(httpClient requests.HttpClient, receiptId string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (receipt ReceiptResponse, err error) { + receiptUrl := requests.Base64ToBase64URL(receiptId) + endpoint := fmt.Sprintf(identitySessionReceiptRetrieval, receiptUrl) + + headers := requests.AuthHeader(clientSdkId) + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodGet, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: headers, + }.Request() + if err != nil { + return receipt, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return receipt, fmt.Errorf("failed to execute request: %v", err) + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return receipt, fmt.Errorf("failed to read response body: %v", err) + } + + err = json.Unmarshal(responseBytes, &receipt) + + return receipt, err +} + +// GetReceiptItemKey retrieves the receipt item key for a receipt item key id. +func getReceiptItemKey(httpClient requests.HttpClient, receiptItemKeyId string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (receiptItemKey ReceiptItemKeyResponse, err error) { + endpoint := fmt.Sprintf(identitySessionReceiptKeyRetrieval, receiptItemKeyId) + headers := requests.AuthHeader(clientSdkId) + request, err := requests.SignedRequest{ + Key: key, + HTTPMethod: http.MethodGet, + BaseURL: apiUrl, + Endpoint: endpoint, + Headers: headers, + }.Request() + if err != nil { + return receiptItemKey, fmt.Errorf("failed to get signed request: %v", err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return receiptItemKey, err + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return receiptItemKey, err + } + + err = json.Unmarshal(responseBytes, &receiptItemKey) + + return receiptItemKey, err +} + +func GetShareReceipt(httpClient requests.HttpClient, receiptId string, clientSdkId, apiUrl string, key *rsa.PrivateKey) (receipt SharedReceiptResponse, err error) { + receiptResponse, err := getReceipt(httpClient, receiptId, clientSdkId, apiUrl, key) + if err != nil { + return receipt, fmt.Errorf("failed to get receipt: %v", err) + } + + itemKeyId := receiptResponse.WrappedItemKeyId + + encryptedItemKeyResponse, err := getReceiptItemKey(httpClient, itemKeyId, clientSdkId, apiUrl, key) + if err != nil { + return receipt, fmt.Errorf("failed to get receipt item key: %v", err) + } + + receiptContentKey, err := cryptoutil.UnwrapReceiptKey(receiptResponse.WrappedKey, encryptedItemKeyResponse.Value, encryptedItemKeyResponse.Iv, key) + if err != nil { + return receipt, fmt.Errorf("failed to unwrap receipt content key: %v", err) + } + + attrData, aextra, err := decryptReceiptContent(receiptResponse.Content, receiptContentKey) + if err != nil { + return receipt, fmt.Errorf("failed to decrypt receipt content: %v", err) + } + + applicationProfile := newApplicationProfile(attrData) + extraDataValue, err := extra.NewExtraData(aextra) + if err != nil { + return receipt, fmt.Errorf("failed to build application extra data: %v", err) + } + + uattrData, uextra, err := decryptReceiptContent(receiptResponse.OtherPartyContent, receiptContentKey) + if err != nil { + return receipt, fmt.Errorf("failed to decrypt receipt other party content: %v", err) + } + + userProfile := newUserProfile(uattrData) + userExtraDataValue, err := extra.NewExtraData(uextra) + if err != nil { + return receipt, fmt.Errorf("failed to build other party extra data: %v", err) + } + + return SharedReceiptResponse{ + ID: receiptResponse.ID, + SessionID: receiptResponse.SessionID, + RememberMeID: receiptResponse.RememberMeID, + ParentRememberMeID: receiptResponse.ParentRememberMeID, + Timestamp: receiptResponse.Timestamp, + UserContent: UserContent{ + UserProfile: userProfile, + ExtraData: userExtraDataValue, + }, + ApplicationContent: ApplicationContent{ + ApplicationProfile: applicationProfile, + ExtraData: extraDataValue, + }, + Error: receiptResponse.Error, + }, nil +} + +func decryptReceiptContent(content *Content, key []byte) (attrData *yotiprotoattr.AttributeList, aextra []byte, err error) { + + if content != nil { + if len(content.Profile) > 0 { + aattr, err := cryptoutil.DecryptReceiptContent(content.Profile, key) + if err != nil { + return nil, nil, fmt.Errorf("failed to decrypt content profile: %v", err) + } + + attrData = &yotiprotoattr.AttributeList{} + if err := proto.Unmarshal(aattr, attrData); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal attribute list: %v", err) + } + } + + if len(content.ExtraData) > 0 { + aextra, err = cryptoutil.DecryptReceiptContent(content.ExtraData, key) + if err != nil { + return nil, nil, fmt.Errorf("failed to decrypt receipt content extra data: %v", err) + } + } + + } + + return attrData, aextra, nil +} diff --git a/digitalidentity/service_test.go b/digitalidentity/service_test.go new file mode 100644 index 00000000..dbf5e133 --- /dev/null +++ b/digitalidentity/service_test.go @@ -0,0 +1,148 @@ +package digitalidentity + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/getyoti/yoti-go-sdk/v3/test" + "gotest.tools/v3/assert" +) + +type mockHTTPClient struct { + do func(*http.Request) (*http.Response, error) +} + +func (mock *mockHTTPClient) Do(request *http.Request) (*http.Response, error) { + if mock.do != nil { + return mock.do(request) + } + return nil, nil +} + +func ExampleCreateShareSession() { + key := test.GetValidKey("../test/test-key.pem") + + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{"id":"0","status":"success","expiry": ""}`)), + }, nil + }, + } + + policy, err := (&PolicyBuilder{}).WithFullName().WithWantedRememberMe().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + session, err := (&ShareSessionRequestBuilder{}).WithPolicy(policy).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + result, err := CreateShareSession(client, &session, "sdkId", "https://apiurl", key) + + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Printf("Status code: %s", result.Status) + // Output: Status code: success +} + +func TestCreateShareURL_Unsuccessful_401(t *testing.T) { + _, err := createShareSessionWithErrorResponse(401, `{"id":"8f6a9dfe72128de20909af0d476769b6","status":401,"error":"INVALID_REQUEST_SIGNATURE","message":"Invalid request signature"}`) + + assert.ErrorContains(t, err, "INVALID_REQUEST_SIGNATURE") + + tempError, temporary := err.(interface { + Temporary() bool + }) + assert.Check(t, !temporary || !tempError.Temporary()) +} + +func createShareSessionWithErrorResponse(statusCode int, responseBody string) (*ShareSession, error) { + key := test.GetValidKey("../test/test-key.pem") + + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }, + } + + policy, err := (&PolicyBuilder{}).WithFullName().WithWantedRememberMe().Build() + if err != nil { + return nil, err + } + session, err := (&ShareSessionRequestBuilder{}).WithPolicy(policy).Build() + if err != nil { + return nil, err + } + + return CreateShareSession(client, &session, "sdkId", "https://apiurl", key) +} + +func TestGetShareSession(t *testing.T) { + key := test.GetValidKey("../test/test-key.pem") + mockSessionID := "SOME_SESSION_ID" + mockClientSdkId := "SOME_CLIENT_SDK_ID" + mockApiUrl := "https://example.com/api" + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{"id":"SOME_ID","status":"SOME_STATUS","expiry":"SOME_EXPIRY","created":"SOME_CREATED","updated":"SOME_UPDATED","qrCode":{"id":"SOME_QRCODE_ID"},"receipt":{"id":"SOME_RECEIPT_ID"}}`)), + }, nil + }, + } + + _, err := GetShareSession(client, mockSessionID, mockClientSdkId, mockApiUrl, key) + assert.NilError(t, err) + +} + +func TestCreateShareQrCode(t *testing.T) { + key := test.GetValidKey("../test/test-key.pem") + mockSessionID := "SOME_SESSION_ID" + + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, nil + }, + } + + _, err := CreateShareQrCode(client, mockSessionID, "sdkId", "https://apiurl", key) + assert.NilError(t, err) +} + +func TestGetQrCode(t *testing.T) { + key := test.GetValidKey("../test/test-key.pem") + mockQrId := "SOME_QR_CODE_ID" + mockClientSdkId := "SOME_CLIENT_SDK_ID" + mockApiUrl := "https://example.com/api" + client := &mockHTTPClient{ + do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, nil + }, + } + + _, err := GetShareSessionQrCode(client, mockQrId, mockClientSdkId, mockApiUrl, key) + assert.NilError(t, err) + +} diff --git a/digitalidentity/share_receipt.go b/digitalidentity/share_receipt.go new file mode 100644 index 00000000..70c2d4cf --- /dev/null +++ b/digitalidentity/share_receipt.go @@ -0,0 +1,24 @@ +package digitalidentity + +import "github.com/getyoti/yoti-go-sdk/v3/extra" + +type SharedReceiptResponse struct { + ID string + SessionID string + RememberMeID string + ParentRememberMeID string + Timestamp string + Error string + UserContent UserContent + ApplicationContent ApplicationContent +} + +type ApplicationContent struct { + ApplicationProfile ApplicationProfile + ExtraData *extra.Data +} + +type UserContent struct { + UserProfile UserProfile + ExtraData *extra.Data +} diff --git a/digitalidentity/share_retrieve_qr.go b/digitalidentity/share_retrieve_qr.go new file mode 100644 index 00000000..fe737d9b --- /dev/null +++ b/digitalidentity/share_retrieve_qr.go @@ -0,0 +1,10 @@ +package digitalidentity + +type ShareSessionFetchedQrCode struct { + ID string `json:"id"` + Expiry string `json:"expiry"` + Policy string `json:"policy"` + Extensions []interface{} `json:"extensions"` + Session ShareSessionCreated `json:"session"` + RedirectURI string `json:"redirectUri"` +} diff --git a/digitalidentity/share_session.go b/digitalidentity/share_session.go new file mode 100644 index 00000000..e27b99fc --- /dev/null +++ b/digitalidentity/share_session.go @@ -0,0 +1,21 @@ +package digitalidentity + +// ShareSession contains information about the session. +type ShareSession struct { + Id string `json:"id"` + Status string `json:"status"` + Expiry string `json:"expiry"` + Created string `json:"created"` + Updated string `json:"updated"` + QrCode qrCode `json:"qrCode"` + Receipt *receipt `json:"receipt"` +} + +type qrCode struct { + Id string `json:"id"` +} + +// receipt containing the receipt id as a string. +type receipt struct { + Id string `json:"id"` +} diff --git a/digitalidentity/share_session_builder.go b/digitalidentity/share_session_builder.go new file mode 100644 index 00000000..7f644a2c --- /dev/null +++ b/digitalidentity/share_session_builder.go @@ -0,0 +1,75 @@ +package digitalidentity + +import ( + "encoding/json" +) + +// ShareSessionRequestBuilder builds a session +type ShareSessionRequestBuilder struct { + shareSessionRequest ShareSessionRequest + err error +} + +// ShareSessionRequest represents a sharesession +type ShareSessionRequest struct { + policy Policy + extensions []interface{} + subject *json.RawMessage + shareSessionNotification *ShareSessionNotification + redirectUri string +} + +// WithPolicy attaches a Policy to the ShareSession +func (builder *ShareSessionRequestBuilder) WithPolicy(policy Policy) *ShareSessionRequestBuilder { + builder.shareSessionRequest.policy = policy + return builder +} + +// WithExtension adds an extension to the ShareSession +func (builder *ShareSessionRequestBuilder) WithExtension(extension interface{}) *ShareSessionRequestBuilder { + builder.shareSessionRequest.extensions = append(builder.shareSessionRequest.extensions, extension) + return builder +} + +// WithNotification sets the callback URL +func (builder *ShareSessionRequestBuilder) WithNotification(notification *ShareSessionNotification) *ShareSessionRequestBuilder { + builder.shareSessionRequest.shareSessionNotification = notification + return builder +} + +// WithRedirectUri sets redirectUri to the ShareSession +func (builder *ShareSessionRequestBuilder) WithRedirectUri(redirectUri string) *ShareSessionRequestBuilder { + builder.shareSessionRequest.redirectUri = redirectUri + return builder +} + +// WithSubject adds a subject to the ShareSession. Must be valid JSON. +func (builder *ShareSessionRequestBuilder) WithSubject(subject json.RawMessage) *ShareSessionRequestBuilder { + builder.shareSessionRequest.subject = &subject + return builder +} + +// Build constructs the ShareSession +func (builder *ShareSessionRequestBuilder) Build() (ShareSessionRequest, error) { + if builder.shareSessionRequest.extensions == nil { + builder.shareSessionRequest.extensions = make([]interface{}, 0) + } + return builder.shareSessionRequest, builder.err +} + +// MarshalJSON returns the JSON encoding +func (shareSesssion ShareSessionRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Policy Policy `json:"policy"` + Extensions []interface{} `json:"extensions"` + RedirectUri string `json:"redirectUri"` + Subject *json.RawMessage `json:"subject,omitempty"` + Notification *ShareSessionNotification `json:"notification,omitempty"` + }{ + Policy: shareSesssion.policy, + Extensions: shareSesssion.extensions, + RedirectUri: shareSesssion.redirectUri, + Subject: shareSesssion.subject, + Notification: shareSesssion.shareSessionNotification, + }) +} diff --git a/digitalidentity/share_session_builder_test.go b/digitalidentity/share_session_builder_test.go new file mode 100644 index 00000000..b0101e20 --- /dev/null +++ b/digitalidentity/share_session_builder_test.go @@ -0,0 +1,99 @@ +package digitalidentity + +import ( + "fmt" + + "github.com/getyoti/yoti-go-sdk/v3/extension" +) + +func ExampleShareSessionRequestBuilder() { + shareSession, err := (&ShareSessionRequestBuilder{}).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := shareSession.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"policy":{"wanted":null,"wanted_auth_types":null,"wanted_remember_me":false},"extensions":[],"redirectUri":""} +} + +func ExampleShareSessionRequestBuilder_WithPolicy() { + policy, err := (&PolicyBuilder{}).WithEmail().WithPinAuth().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + session, err := (&ShareSessionRequestBuilder{}).WithPolicy(policy).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := session.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"policy":{"wanted":[{"name":"email_address","accept_self_asserted":false}],"wanted_auth_types":[2],"wanted_remember_me":false},"extensions":[],"redirectUri":""} +} + +func ExampleShareSessionRequestBuilder_WithExtension() { + policy, err := (&PolicyBuilder{}).WithFullName().Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + builtExtension, err := (&extension.TransactionalFlowExtensionBuilder{}). + WithContent("Transactional Flow Extension"). + Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + session, err := (&ShareSessionRequestBuilder{}).WithExtension(builtExtension).WithPolicy(policy).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := session.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"policy":{"wanted":[{"name":"full_name","accept_self_asserted":false}],"wanted_auth_types":[],"wanted_remember_me":false},"extensions":[{"type":"TRANSACTIONAL_FLOW","content":"Transactional Flow Extension"}],"redirectUri":""} +} + +func ExampleShareSessionRequestBuilder_WithSubject() { + subject := []byte(`{ + "subject_id": "some_subject_id_string" + }`) + + session, err := (&ShareSessionRequestBuilder{}).WithSubject(subject).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := session.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"policy":{"wanted":null,"wanted_auth_types":null,"wanted_remember_me":false},"extensions":[],"redirectUri":"","subject":{"subject_id":"some_subject_id_string"}} +} diff --git a/digitalidentity/share_session_created.go b/digitalidentity/share_session_created.go new file mode 100644 index 00000000..86d60e3f --- /dev/null +++ b/digitalidentity/share_session_created.go @@ -0,0 +1,8 @@ +package digitalidentity + +// ShareSessionCreated Share Session QR Result +type ShareSessionCreated struct { + ID string `json:"id"` + Satus string `json:"status"` + Expiry string `json:"expiry"` +} diff --git a/digitalidentity/share_session_notification_builder.go b/digitalidentity/share_session_notification_builder.go new file mode 100644 index 00000000..6c873ebc --- /dev/null +++ b/digitalidentity/share_session_notification_builder.go @@ -0,0 +1,62 @@ +package digitalidentity + +import ( + "encoding/json" +) + +// ShareSessionNotification specifies the session notification configuration. +type ShareSessionNotification struct { + url string + method *string + verifyTLS *bool + headers map[string][]string +} + +// ShareSessionNotificationBuilder builds Share Session Notification +type ShareSessionNotificationBuilder struct { + shareSessionNotification ShareSessionNotification +} + +// WithUrl setsUrl to Share Session Notification +func (b *ShareSessionNotificationBuilder) WithUrl(url string) *ShareSessionNotificationBuilder { + b.shareSessionNotification.url = url + return b +} + +// WithMethod set method to Share Session Notification +func (b *ShareSessionNotificationBuilder) WithMethod(method string) *ShareSessionNotificationBuilder { + b.shareSessionNotification.method = &method + return b +} + +// WithVerifyTLS sets whether TLS should be verified for notifications. +func (b *ShareSessionNotificationBuilder) WithVerifyTls(verifyTls bool) *ShareSessionNotificationBuilder { + b.shareSessionNotification.verifyTLS = &verifyTls + return b +} + +// WithHeaders set headers to Share Session Notification +func (b *ShareSessionNotificationBuilder) WithHeaders(headers map[string][]string) *ShareSessionNotificationBuilder { + b.shareSessionNotification.headers = headers + return b +} + +// Build constructs the Share Session Notification Builder +func (b *ShareSessionNotificationBuilder) Build() (ShareSessionNotification, error) { + return b.shareSessionNotification, nil +} + +// MarshalJSON returns the JSON encoding +func (a *ShareSessionNotification) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Url string `json:"url"` + Method *string `json:"method,omitempty"` + VerifyTls *bool `json:"verifyTls,omitempty"` + Headers map[string][]string `json:"headers,omitempty"` + }{ + Url: a.url, + Method: a.method, + VerifyTls: a.verifyTLS, + Headers: a.headers, + }) +} diff --git a/digitalidentity/share_session_notification_builder_test.go b/digitalidentity/share_session_notification_builder_test.go new file mode 100644 index 00000000..a60e301e --- /dev/null +++ b/digitalidentity/share_session_notification_builder_test.go @@ -0,0 +1,95 @@ +package digitalidentity + +import ( + "fmt" +) + +func ExampleShareSessionNotificationBuilder() { + shareSessionNotify, err := (&ShareSessionNotificationBuilder{}).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := shareSessionNotify.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"url":""} +} + +func ExampleShareSessionNotificationBuilder_WithUrl() { + shareSessionNotify, err := (&ShareSessionNotificationBuilder{}).WithUrl("Custom_Url").Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := shareSessionNotify.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"url":"Custom_Url"} +} + +func ExampleShareSessionNotificationBuilder_WithMethod() { + shareSessionNotify, err := (&ShareSessionNotificationBuilder{}).WithMethod("CUSTOMMETHOD").Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := shareSessionNotify.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"url":"","method":"CUSTOMMETHOD"} +} + +func ExampleShareSessionNotificationBuilder_WithVerifyTls() { + + shareSessionNotify, err := (&ShareSessionNotificationBuilder{}).WithVerifyTls(true).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := shareSessionNotify.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"url":"","verifyTls":true} +} + +func ExampleShareSessionNotificationBuilder_WithHeaders() { + + headers := make(map[string][]string) + headers["key"] = append(headers["key"], "value") + + shareSessionNotify, err := (&ShareSessionNotificationBuilder{}).WithHeaders(headers).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := shareSessionNotify.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"url":"","headers":{"key":["value"]}} +} diff --git a/digitalidentity/share_session_qr_code.go b/digitalidentity/share_session_qr_code.go new file mode 100644 index 00000000..5f98caa1 --- /dev/null +++ b/digitalidentity/share_session_qr_code.go @@ -0,0 +1,14 @@ +package digitalidentity + +type ShareSessionQrCode struct { + ID string `json:"id"` + Expiry string `json:"expiry"` + Policy string `json:"policy"` + Extensions []interface{} `json:"extensions"` + Session struct { + ID string `json:"id"` + Status string `json:"status"` + Expiry string `json:"expiry"` + } `json:"session"` + RedirectURI string `json:"redirectUri"` +} diff --git a/digitalidentity/source_constraint.go b/digitalidentity/source_constraint.go new file mode 100644 index 00000000..079007f5 --- /dev/null +++ b/digitalidentity/source_constraint.go @@ -0,0 +1,105 @@ +package digitalidentity + +import ( + "encoding/json" + + "github.com/getyoti/yoti-go-sdk/v3/yotierror" +) + +// Anchor name constants +const ( + AnchorDrivingLicenceConst = "DRIVING_LICENCE" + AnchorPassportConst = "PASSPORT" + AnchorNationalIDConst = "NATIONAL_ID" + AnchorPassCardConst = "PASS_CARD" +) + +// SourceConstraint describes a requirement or preference for a particular set +// of anchors +type SourceConstraint struct { + anchors []WantedAnchor + softPreference bool +} + +// SourceConstraintBuilder builds a source constraint +type SourceConstraintBuilder struct { + sourceConstraint SourceConstraint + err error +} + +// WithAnchorByValue is a helper method which builds an anchor and adds it to +// the source constraint +func (b *SourceConstraintBuilder) WithAnchorByValue(value, subtype string) *SourceConstraintBuilder { + anchor, err := (&WantedAnchorBuilder{}). + WithValue(value). + WithSubType(subtype). + Build() + if err != nil { + b.err = yotierror.MultiError{This: err, Next: b.err} + } + + return b.WithAnchor(anchor) +} + +// WithAnchor adds an anchor to the preference list +func (b *SourceConstraintBuilder) WithAnchor(anchor WantedAnchor) *SourceConstraintBuilder { + b.sourceConstraint.anchors = append(b.sourceConstraint.anchors, anchor) + return b +} + +// WithPassport adds a passport anchor +func (b *SourceConstraintBuilder) WithPassport(subtype string) *SourceConstraintBuilder { + return b.WithAnchorByValue(AnchorPassportConst, subtype) +} + +// WithDrivingLicence adds a Driving Licence anchor +func (b *SourceConstraintBuilder) WithDrivingLicence(subtype string) *SourceConstraintBuilder { + return b.WithAnchorByValue(AnchorDrivingLicenceConst, subtype) +} + +// WithNationalID adds a national ID anchor +func (b *SourceConstraintBuilder) WithNationalID(subtype string) *SourceConstraintBuilder { + return b.WithAnchorByValue(AnchorNationalIDConst, subtype) +} + +// WithPasscard adds a passcard anchor +func (b *SourceConstraintBuilder) WithPasscard(subtype string) *SourceConstraintBuilder { + return b.WithAnchorByValue(AnchorPassCardConst, subtype) +} + +// WithSoftPreference sets this constraint as a 'soft requirement' if the +// parameter is true, and a hard requirement if it is false. +func (b *SourceConstraintBuilder) WithSoftPreference(soft bool) *SourceConstraintBuilder { + b.sourceConstraint.softPreference = soft + return b +} + +// Build builds a SourceConstraint +func (b *SourceConstraintBuilder) Build() (SourceConstraint, error) { + if b.sourceConstraint.anchors == nil { + b.sourceConstraint.anchors = make([]WantedAnchor, 0) + } + return b.sourceConstraint, b.err +} + +func (constraint *SourceConstraint) isConstraint() bool { + return true +} + +// MarshalJSON returns the JSON encoding +func (constraint *SourceConstraint) MarshalJSON() ([]byte, error) { + type PreferenceList struct { + Anchors []WantedAnchor `json:"anchors"` + SoftPreference bool `json:"soft_preference"` + } + return json.Marshal(&struct { + Type string `json:"type"` + PreferredSources PreferenceList `json:"preferred_sources"` + }{ + Type: "SOURCE", + PreferredSources: PreferenceList{ + Anchors: constraint.anchors, + SoftPreference: constraint.softPreference, + }, + }) +} diff --git a/digitalidentity/user_profile.go b/digitalidentity/user_profile.go new file mode 100644 index 00000000..32a4d282 --- /dev/null +++ b/digitalidentity/user_profile.go @@ -0,0 +1,182 @@ +package digitalidentity + +import ( + "strings" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/profile/attribute" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" +) + +// UserProfile represents the details retrieved for a particular user. Consists of +// Yoti attributes: a small piece of information about a Yoti user such as a +// photo of the user or the user's date of birth. +type UserProfile struct { + baseProfile +} + +// Creates a new Profile struct +func newUserProfile(attributes *yotiprotoattr.AttributeList) UserProfile { + return UserProfile{ + baseProfile{ + attributeSlice: createAttributeSlice(attributes), + }, + } +} + +func createAttributeSlice(protoAttributeList *yotiprotoattr.AttributeList) (result []*yotiprotoattr.Attribute) { + if protoAttributeList != nil { + result = append(result, protoAttributeList.Attributes...) + } + + return result +} + +// Selfie is a photograph of the user. Will be nil if not provided by Yoti. +func (p UserProfile) Selfie() *attribute.ImageAttribute { + return p.GetImageAttribute(consts.AttrSelfie) +} + +// GetSelfieAttributeByID retrieve a Selfie attribute by ID on the Yoti profile. +// This attribute is a photograph of the user. +// Will return nil if attribute is not present. +func (p UserProfile) GetSelfieAttributeByID(attributeID string) (*attribute.ImageAttribute, error) { + for _, a := range p.attributeSlice { + if a.EphemeralId == attributeID { + return attribute.NewImage(a) + } + } + return nil, nil +} + +// GivenNames corresponds to secondary names in passport, and first/middle names in English. Will be nil if not provided by Yoti. +func (p UserProfile) GivenNames() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrGivenNames) +} + +// FamilyName corresponds to primary name in passport, and surname in English. Will be nil if not provided by Yoti. +func (p UserProfile) FamilyName() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrFamilyName) +} + +// FullName represents the user's full name. +// If family_name/given_names are present, the value will be equal to the string 'given_names + " " family_name'. +// Will be nil if not provided by Yoti. +func (p UserProfile) FullName() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrFullName) +} + +// MobileNumber represents the user's mobile phone number, as verified at registration time. +// The value will be a number in E.164 format (i.e. '+' for international prefix and no spaces, e.g. "+447777123456"). +// Will be nil if not provided by Yoti. +func (p UserProfile) MobileNumber() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrMobileNumber) +} + +// EmailAddress represents the user's verified email address. Will be nil if not provided by Yoti. +func (p UserProfile) EmailAddress() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrEmailAddress) +} + +// DateOfBirth represents the user's date of birth. Will be nil if not provided by Yoti. +// Has an err value which will be filled if there is an error parsing the date. +func (p UserProfile) DateOfBirth() (*attribute.DateAttribute, error) { + for _, a := range p.attributeSlice { + if a.Name == consts.AttrDateOfBirth { + return attribute.NewDate(a) + } + } + return nil, nil +} + +// Address represents the user's address. Will be nil if not provided by Yoti. +func (p UserProfile) Address() *attribute.StringAttribute { + addressAttribute := p.GetStringAttribute(consts.AttrAddress) + if addressAttribute == nil { + return ensureAddressProfile(&p) + } + + return addressAttribute +} + +// StructuredPostalAddress represents the user's address in a JSON format. +// Will be nil if not provided by Yoti. This can be accessed as a +// map[string]string{} using a type assertion, e.g.: +// structuredPostalAddress := structuredPostalAddressAttribute.Value().(map[string]string{}) +func (p UserProfile) StructuredPostalAddress() (*attribute.JSONAttribute, error) { + return p.GetJSONAttribute(consts.AttrStructuredPostalAddress) +} + +// Gender corresponds to the gender in the registered document; the value will be one of the strings "MALE", "FEMALE", "TRANSGENDER" or "OTHER". +// Will be nil if not provided by Yoti. +func (p UserProfile) Gender() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrGender) +} + +// Nationality corresponds to the nationality in the passport. +// The value is an ISO-3166-1 alpha-3 code with ICAO9303 (passport) extensions. +// Will be nil if not provided by Yoti. +func (p UserProfile) Nationality() *attribute.StringAttribute { + return p.GetStringAttribute(consts.AttrNationality) +} + +// DocumentImages returns a slice of document images cropped from the image in the capture page. +// There can be multiple images as per the number of regions in the capture in this attribute. +// Will be nil if not provided by Yoti. +func (p UserProfile) DocumentImages() (*attribute.ImageSliceAttribute, error) { + for _, a := range p.attributeSlice { + if a.Name == consts.AttrDocumentImages { + return attribute.NewImageSlice(a) + } + } + return nil, nil +} + +// GetDocumentImagesAttributeByID retrieve a Document Images attribute by ID on the Yoti profile. +// This attribute consists of a slice of document images cropped from the image in the capture page. +// There can be multiple images as per the number of regions in the capture in this attribute. +// Will return nil if attribute is not present. +func (p UserProfile) GetDocumentImagesAttributeByID(attributeID string) (*attribute.ImageSliceAttribute, error) { + for _, a := range p.attributeSlice { + if a.EphemeralId == attributeID { + return attribute.NewImageSlice(a) + } + } + return nil, nil +} + +// DocumentDetails represents information extracted from a document provided by the user. +// Will be nil if not provided by Yoti. +func (p UserProfile) DocumentDetails() (*attribute.DocumentDetailsAttribute, error) { + for _, a := range p.attributeSlice { + if a.Name == consts.AttrDocumentDetails { + return attribute.NewDocumentDetails(a) + } + } + return nil, nil +} + +// IdentityProfileReport represents the JSON object containing identity assertion and the +// verification report. Will be nil if not provided by Yoti. +func (p UserProfile) IdentityProfileReport() (*attribute.JSONAttribute, error) { + return p.GetJSONAttribute(consts.AttrIdentityProfileReport) +} + +// AgeVerifications returns a slice of age verifications for the user. +// Will be an empty slice if not provided by Yoti. +func (p UserProfile) AgeVerifications() (out []attribute.AgeVerification, err error) { + ageUnderString := strings.Replace(consts.AttrAgeUnder, "%d", "", -1) + ageOverString := strings.Replace(consts.AttrAgeOver, "%d", "", -1) + + for _, a := range p.attributeSlice { + if strings.HasPrefix(a.Name, ageUnderString) || + strings.HasPrefix(a.Name, ageOverString) { + verification, err := attribute.NewAgeVerification(a) + if err != nil { + return nil, err + } + out = append(out, verification) + } + } + return out, err +} diff --git a/digitalidentity/user_profile_test.go b/digitalidentity/user_profile_test.go new file mode 100644 index 00000000..b81b78ee --- /dev/null +++ b/digitalidentity/user_profile_test.go @@ -0,0 +1,704 @@ +package digitalidentity + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/getyoti/yoti-go-sdk/v3/consts" + "github.com/getyoti/yoti-go-sdk/v3/file" + "github.com/getyoti/yoti-go-sdk/v3/media" + "github.com/getyoti/yoti-go-sdk/v3/yotiprotoattr" + "google.golang.org/protobuf/proto" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +const ( + attributeName = "test_attribute_name" + attributeValueString = "value" + + documentImagesAttributeID = "document-images-attribute-id-123" + selfieAttributeID = "selfie-attribute-id-123" + fullNameAttributeID = "full-name-id-123" +) + +var attributeValue = []byte(attributeValueString) + +func getUserProfile() UserProfile { + userProfile := createProfileWithMultipleAttributes( + createDocumentImagesAttribute(documentImagesAttributeID), + createSelfieAttribute(yotiprotoattr.ContentType_JPEG, selfieAttributeID), + createStringAttribute("full_name", []byte("John Smith"), []*yotiprotoattr.Anchor{}, fullNameAttributeID)) + + return userProfile +} + +func ExampleUserProfile_GetAttributeByID() { + userProfile := getUserProfile() + fullNameAttribute := userProfile.GetAttributeByID("full-name-id-123") + value := fullNameAttribute.Value().(string) + + fmt.Println(value) + // Output: John Smith +} + +func ExampleUserProfile_GetDocumentImagesAttributeByID() { + userProfile := getUserProfile() + documentImagesAttribute, err := userProfile.GetDocumentImagesAttributeByID("document-images-attribute-id-123") + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(*documentImagesAttribute.ID()) + // Output: document-images-attribute-id-123 +} + +func ExampleUserProfile_GetSelfieAttributeByID() { + userProfile := getUserProfile() + selfieAttribute, err := userProfile.GetSelfieAttributeByID("selfie-attribute-id-123") + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(*selfieAttribute.ID()) + // Output: selfie-attribute-id-123 +} + +func createProfileWithSingleAttribute(attr *yotiprotoattr.Attribute) UserProfile { + var attributeSlice []*yotiprotoattr.Attribute + attributeSlice = append(attributeSlice, attr) + + return UserProfile{ + baseProfile{ + attributeSlice: attributeSlice, + }, + } +} + +func createAppProfileWithSingleAttribute(attr *yotiprotoattr.Attribute) ApplicationProfile { + var attributeSlice []*yotiprotoattr.Attribute + attributeSlice = append(attributeSlice, attr) + + return ApplicationProfile{ + baseProfile{ + attributeSlice: attributeSlice, + }, + } +} + +func createProfileWithMultipleAttributes(list ...*yotiprotoattr.Attribute) UserProfile { + return UserProfile{ + baseProfile{ + attributeSlice: list, + }, + } +} + +func TestProfile_AgeVerifications(t *testing.T) { + ageOver14 := &yotiprotoattr.Attribute{ + Name: "age_over:14", + Value: []byte("true"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + ageUnder18 := &yotiprotoattr.Attribute{ + Name: "age_under:18", + Value: []byte("true"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + ageOver18 := &yotiprotoattr.Attribute{ + Name: "age_over:18", + Value: []byte("false"), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithMultipleAttributes(ageOver14, ageUnder18, ageOver18) + ageVerifications, err := profile.AgeVerifications() + + assert.NilError(t, err) + assert.Equal(t, len(ageVerifications), 3) + + assert.Equal(t, ageVerifications[0].Age, 14) + assert.Equal(t, ageVerifications[0].CheckType, "age_over") + assert.Equal(t, ageVerifications[0].Result, true) + + assert.Equal(t, ageVerifications[1].Age, 18) + assert.Equal(t, ageVerifications[1].CheckType, "age_under") + assert.Equal(t, ageVerifications[1].Result, true) + + assert.Equal(t, ageVerifications[2].Age, 18) + assert.Equal(t, ageVerifications[2].CheckType, "age_over") + assert.Equal(t, ageVerifications[2].Result, false) +} + +func TestProfile_GetAttribute_EmptyString(t *testing.T) { + emptyString := "" + attributeValue := []byte(emptyString) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + assert.Equal(t, att.Name(), attributeName) + assert.Equal(t, att.Value().(string), emptyString) +} + +func TestProfile_GetApplicationAttribute(t *testing.T) { + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createProfileWithSingleAttribute(attr) + applicationAttribute := appProfile.GetAttribute(attributeName) + assert.Equal(t, applicationAttribute.Name(), attributeName) +} + +func TestProfile_GetApplicationName(t *testing.T) { + attributeValue := "APPLICATION NAME" + var attr = &yotiprotoattr.Attribute{ + Name: "application_name", + Value: []byte(attributeValue), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createAppProfileWithSingleAttribute(attr) + assert.Equal(t, attributeValue, appProfile.ApplicationName().Value()) +} + +func TestProfile_GetApplicationURL(t *testing.T) { + attributeValue := "APPLICATION URL" + var attr = &yotiprotoattr.Attribute{ + Name: "application_url", + Value: []byte(attributeValue), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createAppProfileWithSingleAttribute(attr) + assert.Equal(t, attributeValue, appProfile.ApplicationURL().Value()) +} + +func TestProfile_GetApplicationLogo(t *testing.T) { + attributeValue := "APPLICATION LOGO" + var attr = &yotiprotoattr.Attribute{ + Name: "application_logo", + Value: []byte(attributeValue), + ContentType: yotiprotoattr.ContentType_JPEG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createAppProfileWithSingleAttribute(attr) + assert.Equal(t, 16, len(appProfile.ApplicationLogo().Value().Data())) +} + +func TestProfile_GetApplicationBGColor(t *testing.T) { + attributeValue := "BG VALUE" + var attr = &yotiprotoattr.Attribute{ + Name: "application_receipt_bgcolor", + Value: []byte(attributeValue), + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + appProfile := createAppProfileWithSingleAttribute(attr) + assert.Equal(t, attributeValue, appProfile.ApplicationReceiptBgColor().Value()) +} + +func TestProfile_GetAttribute_Int(t *testing.T) { + intValues := [5]int{0, 1, 123, -10, -1} + + for _, integer := range intValues { + assertExpectedIntegerIsReturned(t, integer) + } +} + +func assertExpectedIntegerIsReturned(t *testing.T, intValue int) { + intAsString := strconv.Itoa(intValue) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: []byte(intAsString), + ContentType: yotiprotoattr.ContentType_INT, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + assert.Equal(t, att.Value().(int), intValue) +} + +func TestProfile_GetAttribute_InvalidInt_ReturnsNil(t *testing.T) { + invalidIntValue := "1985-01-01" + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: []byte(invalidIntValue), + ContentType: yotiprotoattr.ContentType_INT, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + + att := result.GetAttribute(attributeName) + + assert.Assert(t, is.Nil(att)) +} + +func TestProfile_EmptyStringIsAllowed(t *testing.T) { + emptyString := "" + attrValue := []byte(emptyString) + + var attr = &yotiprotoattr.Attribute{ + Name: consts.AttrGender, + Value: attrValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithSingleAttribute(attr) + att := profile.Gender() + + assert.Equal(t, att.Value(), emptyString) +} + +func TestProfile_GetAttribute_Time(t *testing.T) { + dateStringValue := "1985-01-01" + expectedDate := time.Date(1985, time.January, 1, 0, 0, 0, 0, time.UTC) + + attributeValueTime := []byte(dateStringValue) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValueTime, + ContentType: yotiprotoattr.ContentType_DATE, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + assert.Equal(t, expectedDate, att.Value().(*time.Time).UTC()) +} + +func TestProfile_GetAttribute_Jpeg(t *testing.T) { + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_JPEG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithSingleAttribute(attr) + att := profile.GetAttribute(attributeName) + + expected := media.JPEGImage(attributeValue) + result := att.Value().(media.JPEGImage) + + assert.DeepEqual(t, expected, result) + assert.Equal(t, expected.Base64URL(), result.Base64URL()) +} + +func TestProfile_GetAttribute_Png(t *testing.T) { + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_PNG, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithSingleAttribute(attr) + att := profile.GetAttribute(attributeName) + + expected := media.PNGImage(attributeValue) + result := att.Value().(media.PNGImage) + + assert.DeepEqual(t, expected, result) + assert.Equal(t, expected.Base64URL(), result.Base64URL()) +} + +func TestProfile_GetAttribute_Bool(t *testing.T) { + var initialBoolValue = true + attrValue := []byte(strconv.FormatBool(initialBoolValue)) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attrValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + boolValue, err := strconv.ParseBool(att.Value().(string)) + + assert.NilError(t, err) + assert.Equal(t, initialBoolValue, boolValue) +} + +func TestProfile_GetAttribute_JSON(t *testing.T) { + addressFormat := "2" + + var structuredAddressBytes = []byte(` + { + "address_format": "` + addressFormat + `", + "building": "House No.86-A" + }`) + + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: structuredAddressBytes, + ContentType: yotiprotoattr.ContentType_JSON, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + retrievedAttributeMap := att.Value().(map[string]interface{}) + actualAddressFormat := retrievedAttributeMap["address_format"] + + assert.Equal(t, actualAddressFormat, addressFormat) +} + +func TestProfile_GetAttribute_Undefined(t *testing.T) { + var attr = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att := result.GetAttribute(attributeName) + + assert.Equal(t, att.Name(), attributeName) + assert.Equal(t, att.Value().(string), attributeValueString) +} + +func TestProfile_GetAttribute_ReturnsNil(t *testing.T) { + userProfile := UserProfile{ + baseProfile{ + attributeSlice: []*yotiprotoattr.Attribute{}, + }, + } + + result := userProfile.GetAttribute("attributeName") + + assert.Assert(t, is.Nil(result)) +} + +func TestProfile_GetAttributeByID(t *testing.T) { + attributeID := "att-id-123" + + var attr1 = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + EphemeralId: attributeID, + } + var attr2 = &yotiprotoattr.Attribute{ + Name: attributeName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + EphemeralId: "non-matching-attribute-ID", + } + + profile := createProfileWithMultipleAttributes(attr1, attr2) + + result := profile.GetAttributeByID(attributeID) + assert.DeepEqual(t, result.ID(), &attributeID) +} + +func TestProfile_GetAttributeByID_ReturnsNil(t *testing.T) { + userProfile := UserProfile{ + baseProfile{ + attributeSlice: []*yotiprotoattr.Attribute{}, + }, + } + + result := userProfile.GetAttributeByID("attributeName") + + assert.Assert(t, is.Nil(result)) +} + +func TestProfile_GetDocumentImagesAttributeByID_ReturnsNil(t *testing.T) { + userProfile := UserProfile{ + baseProfile{ + attributeSlice: []*yotiprotoattr.Attribute{}, + }, + } + + result, err := userProfile.GetDocumentImagesAttributeByID("attributeName") + assert.NilError(t, err) + assert.Assert(t, is.Nil(result)) +} + +func TestProfile_GetSelfieAttributeByID_ReturnsNil(t *testing.T) { + userProfile := UserProfile{ + baseProfile{ + attributeSlice: []*yotiprotoattr.Attribute{}, + }, + } + + result, err := userProfile.GetSelfieAttributeByID("attributeName") + assert.NilError(t, err) + assert.Assert(t, is.Nil(result)) +} + +func TestProfile_StringAttribute(t *testing.T) { + nationalityName := consts.AttrNationality + + var as = &yotiprotoattr.Attribute{ + Name: nationalityName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(as) + + assert.Equal(t, result.Nationality().Value(), attributeValueString) + + assert.Equal(t, result.Nationality().ContentType(), yotiprotoattr.ContentType_STRING.String()) +} + +func TestProfile_AttributeProperty_RetrievesAttribute(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_PNG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + selfie := result.Selfie() + + assert.Equal(t, selfie.Name(), consts.AttrSelfie) + assert.DeepEqual(t, attributeValue, selfie.Value().Data()) + assert.Equal(t, selfie.ContentType(), yotiprotoattr.ContentType_PNG.String()) +} + +func TestProfile_DocumentDetails_RetrievesAttribute(t *testing.T) { + documentDetailsName := consts.AttrDocumentDetails + attributeValue := []byte("PASSPORT GBR 1234567") + + var protoAttribute = &yotiprotoattr.Attribute{ + Name: documentDetailsName, + Value: attributeValue, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: make([]*yotiprotoattr.Anchor, 0), + } + + result := createProfileWithSingleAttribute(protoAttribute) + documentDetails, err := result.DocumentDetails() + assert.NilError(t, err) + + assert.Equal(t, documentDetails.Value().DocumentType, "PASSPORT") +} + +func TestProfile_DocumentImages_RetrievesAttribute(t *testing.T) { + protoAttribute := createDocumentImagesAttribute("attr-id") + + result := createProfileWithSingleAttribute(protoAttribute) + documentImages, err := result.DocumentImages() + assert.NilError(t, err) + + assert.Equal(t, documentImages.Name(), consts.AttrDocumentImages) +} + +func TestProfile_AttributesReturnsNilWhenNotPresent(t *testing.T) { + documentImagesName := consts.AttrDocumentImages + multiValue, err := proto.Marshal(&yotiprotoattr.MultiValue{}) + assert.NilError(t, err) + + protoAttribute := &yotiprotoattr.Attribute{ + Name: documentImagesName, + Value: multiValue, + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Anchors: make([]*yotiprotoattr.Anchor, 0), + } + + result := createProfileWithSingleAttribute(protoAttribute) + + DoB, err := result.DateOfBirth() + assert.Check(t, DoB == nil) + assert.Check(t, err == nil) + assert.Check(t, result.Address() == nil) +} + +func TestMissingPostalAddress_UsesFormattedAddress(t *testing.T) { + var formattedAddressText = `House No.86-A\nRajgura Nagar\nLudhina\nPunjab\n141012\nIndia` + + var structuredAddressBytes = []byte(` + { + "address_format": 2, + "building": "House No.86-A", + "formatted_address": "` + formattedAddressText + `" + } + `) + + var jsonAttribute = &yotiprotoattr.Attribute{ + Name: consts.AttrStructuredPostalAddress, + Value: structuredAddressBytes, + ContentType: yotiprotoattr.ContentType_JSON, + Anchors: []*yotiprotoattr.Anchor{}, + } + + profile := createProfileWithSingleAttribute(jsonAttribute) + + ensureAddressProfile(&profile) + + escapedFormattedAddressText := strings.Replace(formattedAddressText, `\n`, "\n", -1) + + profileAddress := profile.Address().Value() + assert.Equal(t, profileAddress, escapedFormattedAddressText, "Address does not equal the expected formatted address.") + + structuredPostalAddress, err := profile.StructuredPostalAddress() + assert.NilError(t, err) + assert.Equal(t, structuredPostalAddress.ContentType(), "JSON") +} + +func TestAttributeImage_Image_Png(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_PNG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + selfie := result.Selfie() + + assert.DeepEqual(t, selfie.Value().Data(), attributeValue) +} + +func TestAttributeImage_Image_Jpeg(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_JPEG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + selfie := result.Selfie() + + assert.DeepEqual(t, selfie.Value().Data(), attributeValue) +} + +func TestAttributeImage_Image_Default(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_PNG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + selfie := result.Selfie() + + assert.DeepEqual(t, selfie.Value().Data(), attributeValue) +} +func TestAttributeImage_Base64Selfie_Png(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_PNG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + base64ImageExpectedValue := base64.StdEncoding.EncodeToString(attributeValue) + expectedBase64Selfie := "data:image/png;base64," + base64ImageExpectedValue + base64Selfie := result.Selfie().Value().Base64URL() + + assert.Equal(t, base64Selfie, expectedBase64Selfie) +} + +func TestAttributeImage_Base64URL_Jpeg(t *testing.T) { + attributeImage := createSelfieAttribute(yotiprotoattr.ContentType_JPEG, "id") + + result := createProfileWithSingleAttribute(attributeImage) + + base64ImageExpectedValue := base64.StdEncoding.EncodeToString(attributeValue) + + expectedBase64Selfie := "data:image/jpeg;base64," + base64ImageExpectedValue + + base64Selfie := result.Selfie().Value().Base64URL() + + assert.Equal(t, base64Selfie, expectedBase64Selfie) +} + +func TestProfile_IdentityProfileReport_RetrievesAttribute(t *testing.T) { + identityProfileReportJSON, err := file.ReadFile("../test/fixtures/RTWIdentityProfileReport.json") + assert.NilError(t, err) + + var attr = &yotiprotoattr.Attribute{ + Name: consts.AttrIdentityProfileReport, + Value: identityProfileReportJSON, + ContentType: yotiprotoattr.ContentType_JSON, + Anchors: []*yotiprotoattr.Anchor{}, + } + + result := createProfileWithSingleAttribute(attr) + att, err := result.IdentityProfileReport() + assert.NilError(t, err) + + retrievedIdentityProfile := att.Value() + gotProof := retrievedIdentityProfile["proof"] + + assert.Equal(t, gotProof, "") +} + +func TestProfileAllowsMultipleAttributesWithSameName(t *testing.T) { + firstAttribute := createStringAttribute("full_name", []byte("some_value"), []*yotiprotoattr.Anchor{}, "id") + secondAttribute := createStringAttribute("full_name", []byte("some_other_value"), []*yotiprotoattr.Anchor{}, "id") + + var attributeSlice []*yotiprotoattr.Attribute + attributeSlice = append(attributeSlice, firstAttribute, secondAttribute) + + var profile = UserProfile{ + baseProfile{ + attributeSlice: attributeSlice, + }, + } + + var fullNames = profile.GetAttributes("full_name") + + assert.Assert(t, is.Equal(len(fullNames), 2)) + assert.Assert(t, is.Equal(fullNames[0].Value().(string), "some_value")) + assert.Assert(t, is.Equal(fullNames[1].Value().(string), "some_other_value")) +} + +func createStringAttribute(name string, value []byte, anchors []*yotiprotoattr.Anchor, attributeID string) *yotiprotoattr.Attribute { + return &yotiprotoattr.Attribute{ + Name: name, + Value: value, + ContentType: yotiprotoattr.ContentType_STRING, + Anchors: anchors, + EphemeralId: attributeID, + } +} + +func createSelfieAttribute(contentType yotiprotoattr.ContentType, attributeID string) *yotiprotoattr.Attribute { + var attributeImage = &yotiprotoattr.Attribute{ + Name: consts.AttrSelfie, + Value: attributeValue, + ContentType: contentType, + Anchors: []*yotiprotoattr.Anchor{}, + EphemeralId: attributeID, + } + return attributeImage +} + +func createDocumentImagesAttribute(attributeID string) *yotiprotoattr.Attribute { + multiValue, err := proto.Marshal(&yotiprotoattr.MultiValue{}) + if err != nil { + panic(err) + } + + protoAttribute := &yotiprotoattr.Attribute{ + Name: consts.AttrDocumentImages, + Value: multiValue, + ContentType: yotiprotoattr.ContentType_MULTI_VALUE, + Anchors: make([]*yotiprotoattr.Anchor, 0), + EphemeralId: attributeID, + } + return protoAttribute +} diff --git a/digitalidentity/wanted_anchor_builder.go b/digitalidentity/wanted_anchor_builder.go new file mode 100644 index 00000000..855c9c3a --- /dev/null +++ b/digitalidentity/wanted_anchor_builder.go @@ -0,0 +1,44 @@ +package digitalidentity + +import ( + "encoding/json" +) + +// WantedAnchor specifies a preferred anchor for a user's details +type WantedAnchor struct { + name string + subType string +} + +// WantedAnchorBuilder describes a desired anchor for user profile data +type WantedAnchorBuilder struct { + wantedAnchor WantedAnchor +} + +// WithValue sets the anchor's name +func (b *WantedAnchorBuilder) WithValue(name string) *WantedAnchorBuilder { + b.wantedAnchor.name = name + return b +} + +// WithSubType sets the anchors subtype +func (b *WantedAnchorBuilder) WithSubType(subType string) *WantedAnchorBuilder { + b.wantedAnchor.subType = subType + return b +} + +// Build constructs the anchor from the builder's specification +func (b *WantedAnchorBuilder) Build() (WantedAnchor, error) { + return b.wantedAnchor, nil +} + +// MarshalJSON ... +func (a *WantedAnchor) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Name string `json:"name"` + SubType string `json:"sub_type"` + }{ + Name: a.name, + SubType: a.subType, + }) +} diff --git a/digitalidentity/wanted_anchor_builder_test.go b/digitalidentity/wanted_anchor_builder_test.go new file mode 100644 index 00000000..907d8e08 --- /dev/null +++ b/digitalidentity/wanted_anchor_builder_test.go @@ -0,0 +1,25 @@ +package digitalidentity + +import ( + "fmt" +) + +func ExampleWantedAnchorBuilder() { + aadhaarAnchor, err := (&WantedAnchorBuilder{}). + WithValue("NATIONAL_ID"). + WithSubType("AADHAAR"). + Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + aadhaarJSON, err := aadhaarAnchor.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println("Aadhaar:", string(aadhaarJSON)) + // Output: Aadhaar: {"name":"NATIONAL_ID","sub_type":"AADHAAR"} +} diff --git a/digitalidentity/wanted_attribute_builder.go b/digitalidentity/wanted_attribute_builder.go new file mode 100644 index 00000000..d004b96c --- /dev/null +++ b/digitalidentity/wanted_attribute_builder.go @@ -0,0 +1,81 @@ +package digitalidentity + +import ( + "encoding/json" + "errors" +) + +type constraintInterface interface { + MarshalJSON() ([]byte, error) + isConstraint() bool // This function is not used but makes inheritance explicit +} + +// WantedAttributeBuilder generates the payload for specifying a single wanted +// attribute as part of a dynamic scenario +type WantedAttributeBuilder struct { + attr WantedAttribute +} + +// WantedAttribute represents a wanted attribute in a dynamic sharing policy +type WantedAttribute struct { + name string + derivation string + constraints []constraintInterface + acceptSelfAsserted bool + Optional bool +} + +// WithName sets the name of the wanted attribute +func (builder *WantedAttributeBuilder) WithName(name string) *WantedAttributeBuilder { + builder.attr.name = name + return builder +} + +// WithDerivation sets the derivation +func (builder *WantedAttributeBuilder) WithDerivation(derivation string) *WantedAttributeBuilder { + builder.attr.derivation = derivation + return builder +} + +// WithConstraint adds a constraint to a wanted attribute +func (builder *WantedAttributeBuilder) WithConstraint(constraint constraintInterface) *WantedAttributeBuilder { + builder.attr.constraints = append(builder.attr.constraints, constraint) + return builder +} + +// WithAcceptSelfAsserted allows self-asserted user details, such as those from Aadhar +func (builder *WantedAttributeBuilder) WithAcceptSelfAsserted(accept bool) *WantedAttributeBuilder { + builder.attr.acceptSelfAsserted = accept + return builder +} + +// Build generates the wanted attribute's specification +func (builder *WantedAttributeBuilder) Build() (WantedAttribute, error) { + if builder.attr.constraints == nil { + builder.attr.constraints = make([]constraintInterface, 0) + } + + var err error + if len(builder.attr.name) == 0 { + err = errors.New("wanted attribute names must not be empty") + } + + return builder.attr, err +} + +// MarshalJSON returns the JSON encoding +func (attr *WantedAttribute) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Name string `json:"name"` + Derivation string `json:"derivation,omitempty"` + Constraints []constraintInterface `json:"constraints,omitempty"` + AcceptSelfAsserted bool `json:"accept_self_asserted"` + Optional bool `json:"optional,omitempty"` + }{ + Name: attr.name, + Derivation: attr.derivation, + Constraints: attr.constraints, + AcceptSelfAsserted: attr.acceptSelfAsserted, + Optional: attr.Optional, + }) +} diff --git a/digitalidentity/wanted_attribute_test.go b/digitalidentity/wanted_attribute_test.go new file mode 100644 index 00000000..0a990d1e --- /dev/null +++ b/digitalidentity/wanted_attribute_test.go @@ -0,0 +1,154 @@ +package digitalidentity + +import ( + "encoding/json" + "fmt" + "testing" + + "gotest.tools/v3/assert" +) + +func ExampleWantedAttributeBuilder_WithName() { + builder := (&WantedAttributeBuilder{}).WithName("TEST NAME") + attribute, err := builder.Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(attribute.name) + // Output: TEST NAME +} + +func ExampleWantedAttributeBuilder_WithDerivation() { + attribute, err := (&WantedAttributeBuilder{}). + WithDerivation("TEST DERIVATION"). + WithName("TEST NAME"). + Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(attribute.derivation) + // Output: TEST DERIVATION +} + +func ExampleWantedAttributeBuilder_WithConstraint() { + constraint, err := (&SourceConstraintBuilder{}). + Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + attribute, err := (&WantedAttributeBuilder{}). + WithName("TEST NAME"). + WithConstraint(&constraint). + Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + marshalledJSON, err := attribute.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(marshalledJSON)) + // Output: {"name":"TEST NAME","constraints":[{"type":"SOURCE","preferred_sources":{"anchors":[],"soft_preference":false}}],"accept_self_asserted":false} +} + +func ExampleWantedAttributeBuilder_WithAcceptSelfAsserted() { + attribute, err := (&WantedAttributeBuilder{}). + WithName("TEST NAME"). + WithAcceptSelfAsserted(true). + Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + marshalledJSON, err := attribute.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(marshalledJSON)) + // Output: {"name":"TEST NAME","accept_self_asserted":true} +} + +func ExampleWantedAttributeBuilder_WithAcceptSelfAsserted_false() { + attribute, err := (&WantedAttributeBuilder{}). + WithName("TEST NAME"). + WithAcceptSelfAsserted(false). + Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + marshalledJSON, err := attribute.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(marshalledJSON)) + // Output: {"name":"TEST NAME","accept_self_asserted":false} +} + +func ExampleWantedAttributeBuilder_optional_true() { + attribute, err := (&WantedAttributeBuilder{}). + WithName("TEST NAME"). + WithAcceptSelfAsserted(false). + Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + attribute.Optional = true + + marshalledJSON, err := attribute.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(marshalledJSON)) + // Output: {"name":"TEST NAME","accept_self_asserted":false,"optional":true} +} + +func TestWantedAttributeBuilder_Optional_IsOmittedByDefault(t *testing.T) { + attribute, err := (&WantedAttributeBuilder{}). + WithName("TEST NAME"). + Build() + if err != nil { + t.Errorf("error: %s", err.Error()) + } + + marshalledJSON, err := attribute.MarshalJSON() + if err != nil { + t.Errorf("error: %s", err.Error()) + } + + attributeMap := unmarshalJSONIntoMap(t, marshalledJSON) + + optional := attributeMap["optional"] + + if optional != nil { + t.Errorf("expected `optional` to be nil, but was: '%v'", optional) + } +} + +func unmarshalJSONIntoMap(t *testing.T, byteValue []byte) (result map[string]interface{}) { + var unmarshalled interface{} + err := json.Unmarshal(byteValue, &unmarshalled) + assert.NilError(t, err) + + return unmarshalled.(map[string]interface{}) +} diff --git a/digitalidentity/yotierror/response.go b/digitalidentity/yotierror/response.go new file mode 100644 index 00000000..3274e80f --- /dev/null +++ b/digitalidentity/yotierror/response.go @@ -0,0 +1,56 @@ +package yotierror + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +var ( + defaultUnknownErrorCodeConst = "UNKNOWN_ERROR" + defaultUnknownErrorMessageConst = "unknown HTTP error" +) + +// Error indicates errors related to the Yoti API. +type Error struct { + Id string `json:"id"` + Status int `json:"status"` + ErrorCode string `json:"error"` + Message string `json:"message"` +} + +func (e Error) Error() string { + return e.ErrorCode + " - " + e.Message +} + +// NewResponseError creates a new Error +func NewResponseError(response *http.Response) *Error { + err := &Error{ + ErrorCode: defaultUnknownErrorCodeConst, + Message: defaultUnknownErrorMessageConst, + } + if response == nil { + return err + } + err.Status = response.StatusCode + if response.Body == nil { + return err + } + defer response.Body.Close() + b, e := io.ReadAll(response.Body) + if e != nil { + err.Message = fmt.Sprintf(defaultUnknownErrorMessageConst+": %q", e) + return err + } + e = json.Unmarshal(b, err) + if e != nil { + err.Message = fmt.Sprintf(defaultUnknownErrorMessageConst+": %q", e) + } + return err +} + +// Temporary indicates this ErrorCode is a temporary ErrorCode +func (e Error) Temporary() bool { + return e.Status >= 500 +} diff --git a/digitalidentity/yotierror/response_test.go b/digitalidentity/yotierror/response_test.go new file mode 100644 index 00000000..be2225ca --- /dev/null +++ b/digitalidentity/yotierror/response_test.go @@ -0,0 +1,68 @@ +package yotierror + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +var ( + expectedErr = Error{ + Id: "8f6a9dfe72128de20909af0d476769b6", + Status: 401, + ErrorCode: "INVALID_REQUEST_SIGNATURE", + Message: "Invalid request signature", + } +) + +func TestError_ShouldReturnFormattedError(t *testing.T) { + jsonBytes := json.RawMessage(`{"id":"8f6a9dfe72128de20909af0d476769b6","status":401,"error":"INVALID_REQUEST_SIGNATURE","message":"Invalid request signature"}`) + + err := NewResponseError( + &http.Response{ + StatusCode: 401, + Body: io.NopCloser(bytes.NewReader(jsonBytes)), + }, + ) + + assert.ErrorIs(t, *err, expectedErr) +} + +func TestError_ShouldReturnFormattedError_ReturnWrappedErrorWhenInvalidJSON(t *testing.T) { + response := &http.Response{ + StatusCode: 400, + Body: io.NopCloser(strings.NewReader("some invalid JSON")), + } + err := NewResponseError( + response, + ) + + assert.ErrorContains(t, err, "unknown HTTP error") +} + +func TestError_ShouldReturnTemporaryForServerError(t *testing.T) { + response := &http.Response{ + StatusCode: 500, + } + err := NewResponseError( + response, + ) + + assert.Check(t, err.Temporary()) +} + +func TestError_ShouldNotReturnTemporaryForClientError(t *testing.T) { + response := &http.Response{ + StatusCode: 400, + } + err := NewResponseError( + response, + ) + + assert.Check(t, !err.Temporary()) +} diff --git a/digitalidentity/yotierror/signed_requests.go b/digitalidentity/yotierror/signed_requests.go new file mode 100644 index 00000000..3f91d381 --- /dev/null +++ b/digitalidentity/yotierror/signed_requests.go @@ -0,0 +1,8 @@ +package yotierror + +const ( + // InvalidRequestSignature can be returned by any endpoint that requires a signed request. + InvalidRequestSignature = "INVALID_REQUEST_SIGNATURE" + // InvalidAuthHeader can be returned by any endpoint that requires a signed request. + InvalidAuthHeader = "INVALID_AUTH_HEADER" +) diff --git a/docscan/client.go b/docscan/client.go index b0cf5ed3..c4aa82dc 100644 --- a/docscan/client.go +++ b/docscan/client.go @@ -5,7 +5,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "os" "strconv" @@ -96,7 +96,7 @@ func (c *Client) CreateSession(sessionSpec *create.SessionSpecification) (*creat } var responseBytes []byte - responseBytes, err = ioutil.ReadAll(response.Body) + responseBytes, err = io.ReadAll(response.Body) if err != nil { return nil, err } @@ -131,7 +131,7 @@ func (c *Client) GetSession(sessionID string) (*retrieve.GetSessionResult, error } var responseBytes []byte - responseBytes, err = ioutil.ReadAll(response.Body) + responseBytes, err = io.ReadAll(response.Body) if err != nil { return nil, err } @@ -199,7 +199,7 @@ func (c *Client) GetMediaContent(sessionID, mediaID string) (media.Media, error) } var responseBytes []byte - responseBytes, err = ioutil.ReadAll(response.Body) + responseBytes, err = io.ReadAll(response.Body) if err != nil { return nil, err } @@ -269,7 +269,7 @@ func (c *Client) GetSupportedDocumentsWithNonLatin(includeNonLatin bool) (*suppo } var responseBytes []byte - responseBytes, err = ioutil.ReadAll(response.Body) + responseBytes, err = io.ReadAll(response.Body) if err != nil { return nil, err } diff --git a/docscan/client_test.go b/docscan/client_test.go index a52f82d2..5f158711 100644 --- a/docscan/client_test.go +++ b/docscan/client_test.go @@ -7,7 +7,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "os" "strings" @@ -44,7 +44,7 @@ func TestClient_CreateSession(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusCreated, - Body: ioutil.NopCloser(strings.NewReader(jsonResponse)), + Body: io.NopCloser(strings.NewReader(jsonResponse)), }, nil }, } @@ -122,7 +122,7 @@ func TestClient_GetSession(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(jsonResponse)), + Body: io.NopCloser(strings.NewReader(jsonResponse)), }, nil }, } @@ -181,7 +181,7 @@ func TestClient_GetSession_ShouldReturnJsonError(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader("some-invalid-json")), + Body: io.NopCloser(strings.NewReader("some-invalid-json")), }, nil }, } @@ -258,7 +258,7 @@ func TestClient_GetMediaContent(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, - Body: ioutil.NopCloser(bytes.NewReader(jpegImage)), + Body: io.NopCloser(bytes.NewReader(jpegImage)), Header: map[string][]string{"Content-Type": {media.ImageTypeJPEG}}, }, nil }, @@ -312,7 +312,7 @@ func TestClient_GetMediaContent_NoContentType(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, - Body: ioutil.NopCloser(bytes.NewReader(jpegImage)), + Body: io.NopCloser(bytes.NewReader(jpegImage)), Header: map[string][]string{}, }, nil }, @@ -438,7 +438,7 @@ func TestClient_GetSupportedDocuments(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, - Body: ioutil.NopCloser(bytes.NewReader(jsonBytes)), + Body: io.NopCloser(bytes.NewReader(jsonBytes)), }, nil }, } @@ -488,7 +488,7 @@ func TestClient_GetSupportedDocuments_ShouldReturnResponseError(t *testing.T) { } func TestNewClient(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -504,7 +504,7 @@ func TestNewClient_EmptySdkID(t *testing.T) { } func TestClient_GetSession_EmptySessionID(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -515,7 +515,7 @@ func TestClient_GetSession_EmptySessionID(t *testing.T) { } func TestClient_DeleteSession_EmptySessionID(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -526,7 +526,7 @@ func TestClient_DeleteSession_EmptySessionID(t *testing.T) { } func TestClient_GetMediaContent_EmptySessionID(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -537,7 +537,7 @@ func TestClient_GetMediaContent_EmptySessionID(t *testing.T) { } func TestClient_GetMediaContent_EmptyMediaID(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -548,7 +548,7 @@ func TestClient_GetMediaContent_EmptyMediaID(t *testing.T) { } func TestClient_DeleteMediaContent_EmptySessionID(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -559,7 +559,7 @@ func TestClient_DeleteMediaContent_EmptySessionID(t *testing.T) { } func TestClient_DeleteMediaContent_EmptyMediaID(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -570,7 +570,7 @@ func TestClient_DeleteMediaContent_EmptyMediaID(t *testing.T) { } func Test_EmptySdkID(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -581,7 +581,7 @@ func Test_EmptySdkID(t *testing.T) { } func TestNewClient_KeyLoad_Failure(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key-invalid-format.pem") + key, err := os.ReadFile("../test/test-key-invalid-format.pem") assert.NilError(t, err) _, err = NewClient("sdkID", key) @@ -595,7 +595,7 @@ func TestNewClient_KeyLoad_Failure(t *testing.T) { } func TestClient_UsesDefaultApiUrl(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("sdkID", key) @@ -605,7 +605,7 @@ func TestClient_UsesDefaultApiUrl(t *testing.T) { } func TestClient_UsesEnvVariable(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) os.Setenv("YOTI_DOC_SCAN_API_URL", "envBaseUrl") @@ -617,7 +617,7 @@ func TestClient_UsesEnvVariable(t *testing.T) { } func TestClient_UsesOverrideApiUrlOverEnvVariable(t *testing.T) { - key, err := ioutil.ReadFile("../test/test-key.pem") + key, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) os.Setenv("YOTI_DOC_SCAN_API_URL", "envBaseUrl") diff --git a/docscan/sandbox/client_test.go b/docscan/sandbox/client_test.go index 607558ac..a4876c27 100644 --- a/docscan/sandbox/client_test.go +++ b/docscan/sandbox/client_test.go @@ -6,7 +6,7 @@ import ( "crypto/rsa" "encoding/json" "errors" - "io/ioutil" + "io" "net/http" "os" "strings" @@ -33,7 +33,7 @@ func TestClient_ConfigureSessionResponse_ShouldReturnErrorIfNotCreated(t *testin do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 400, - Body: ioutil.NopCloser(strings.NewReader("")), + Body: io.NopCloser(strings.NewReader("")), }, nil }, }, @@ -54,7 +54,7 @@ func TestClient_ConfigureSessionResponse_ShouldReturnFormattedErrorWithResponse( response := &http.Response{ StatusCode: 400, - Body: ioutil.NopCloser(bytes.NewReader(jsonBytes)), + Body: io.NopCloser(bytes.NewReader(jsonBytes)), } client := Client{ @@ -71,7 +71,7 @@ func TestClient_ConfigureSessionResponse_ShouldReturnFormattedErrorWithResponse( errorResponse := err.(*yotierror.Error).Response assert.Equal(t, response, errorResponse) - body, err := ioutil.ReadAll(errorResponse.Body) + body, err := io.ReadAll(errorResponse.Body) assert.NilError(t, err) assert.Equal(t, string(body), string(jsonBytes)) } @@ -95,7 +95,7 @@ func TestClient_ConfigureSessionResponse_ShouldReturnJsonError(t *testing.T) { } func TestNewClient_ConfigureSessionResponse_Success(t *testing.T) { - key, err := ioutil.ReadFile("../../test/test-key.pem") + key, err := os.ReadFile("../../test/test-key.pem") assert.NilError(t, err) client, err := NewClient("ClientSDKID", key) @@ -114,7 +114,7 @@ func TestNewClient_ConfigureSessionResponse_Success(t *testing.T) { } func TestNewClient_KeyLoad_Failure(t *testing.T) { - key, err := ioutil.ReadFile("../../test/test-key-invalid-format.pem") + key, err := os.ReadFile("../../test/test-key-invalid-format.pem") assert.NilError(t, err) _, err = NewClient("", key) @@ -171,7 +171,7 @@ func TestClient_ConfigureApplicationResponse_ShouldReturnErrorIfNotCreated(t *te do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 401, - Body: ioutil.NopCloser(strings.NewReader("")), + Body: io.NopCloser(strings.NewReader("")), }, nil }, }, @@ -287,7 +287,7 @@ func TestClient_ConfigureSessionResponseUsesDefaultUrlAsFallbackWithNoEnvValue(t } func createSandboxClient(t *testing.T, constructorApiURL string) (client Client) { - keyBytes, fileErr := ioutil.ReadFile("../../test/test-key.pem") + keyBytes, fileErr := os.ReadFile("../../test/test-key.pem") assert.NilError(t, fileErr) pemFile, parseErr := cryptoutil.ParseRSAKey(keyBytes) diff --git a/dynamic/policy_builder.go b/dynamic/policy_builder.go index a97ffd18..e7f89308 100644 --- a/dynamic/policy_builder.go +++ b/dynamic/policy_builder.go @@ -61,7 +61,7 @@ func (b *PolicyBuilder) WithWantedAttributeByName(name string, options ...interf case constraintInterface: attributeBuilder.WithConstraint(value) default: - panic(fmt.Sprintf("Not a valid option type, %v", value)) + panic(fmt.Sprintf("not a valid option type, %v", value)) } } @@ -154,7 +154,7 @@ func (b *PolicyBuilder) WithAgeDerivedAttribute(derivation string, options ...in case constraintInterface: attributeBuilder.WithConstraint(value) default: - panic(fmt.Sprintf("Not a valid option type, %v", value)) + panic(fmt.Sprintf("not a valid option type, %v", value)) } } diff --git a/dynamic/policy_builder_test.go b/dynamic/policy_builder_test.go index a902cd16..7402e675 100644 --- a/dynamic/policy_builder_test.go +++ b/dynamic/policy_builder_test.go @@ -338,7 +338,7 @@ func TestDynamicPolicyBuilder_WithWantedAttributeByName_InvalidOptionsShouldPani defer func() { r := recover().(string) - assert.Check(t, strings.Contains(r, "Not a valid option type")) + assert.Check(t, strings.Contains(r, "not a valid option type")) }() builder.WithWantedAttributeByName( @@ -404,7 +404,7 @@ func TestDynamicPolicyBuilder_WithAgeDerivedAttribute_InvalidOptionsShouldPanic( defer func() { r := recover().(string) - assert.Check(t, strings.Contains(r, "Not a valid option type")) + assert.Check(t, strings.Contains(r, "not a valid option type")) }() builder.WithAgeDerivedAttribute( diff --git a/dynamic/service.go b/dynamic/service.go index 58db44fe..ed44cdb7 100644 --- a/dynamic/service.go +++ b/dynamic/service.go @@ -4,7 +4,7 @@ import ( "crypto/rsa" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "github.com/getyoti/yoti-go-sdk/v3/requests" @@ -44,7 +44,7 @@ func CreateShareURL(httpClient requests.HttpClient, scenario *Scenario, clientSd return share, err } - responseBytes, err := ioutil.ReadAll(response.Body) + responseBytes, err := io.ReadAll(response.Body) if err != nil { return } diff --git a/dynamic/service_test.go b/dynamic/service_test.go index 8e926360..9057bc18 100644 --- a/dynamic/service_test.go +++ b/dynamic/service_test.go @@ -2,7 +2,7 @@ package dynamic import ( "fmt" - "io/ioutil" + "io" "net/http" "strings" "testing" @@ -29,7 +29,7 @@ func ExampleCreateShareURL() { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 201, - Body: ioutil.NopCloser(strings.NewReader(`{"qrcode":"https://code.yoti.com/CAEaJDQzNzllZDc0LTU0YjItNDkxMy04OTE4LTExYzM2ZDU2OTU3ZDAC","ref_id":"0"}`)), + Body: io.NopCloser(strings.NewReader(`{"qrcode":"https://code.yoti.com/CAEaJDQzNzllZDc0LTU0YjItNDkxMy04OTE4LTExYzM2ZDU2OTU3ZDAC","ref_id":"0"}`)), }, nil }, } @@ -97,7 +97,7 @@ func createShareUrlWithErrorResponse(statusCode int, responseBody string) (share do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: statusCode, - Body: ioutil.NopCloser(strings.NewReader(responseBody)), + Body: io.NopCloser(strings.NewReader(responseBody)), }, nil }, } diff --git a/file/file.go b/file/file.go index e9fc43a6..a002b96c 100644 --- a/file/file.go +++ b/file/file.go @@ -1,7 +1,7 @@ package file import ( - "io/ioutil" + "io" "os" ) @@ -12,7 +12,7 @@ func ReadFile(filename string) ([]byte, error) { return nil, err } - buffer, err := ioutil.ReadAll(file) + buffer, err := io.ReadAll(file) if err != nil { return nil, err } diff --git a/go.mod b/go.mod index ca66f5fb..c1c87abb 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,4 @@ require ( require github.com/google/go-cmp v0.5.5 // indirect -go 1.17 +go 1.19 diff --git a/profile/sandbox/client.go b/profile/sandbox/client.go index ce6b71ce..9cbde3b6 100644 --- a/profile/sandbox/client.go +++ b/profile/sandbox/client.go @@ -4,7 +4,7 @@ import ( "crypto/rsa" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "os" @@ -65,7 +65,7 @@ func (client *Client) SetupSharingProfile(tokenRequest TokenRequest) (token stri return } if response.StatusCode != http.StatusCreated { - body, _ := ioutil.ReadAll(response.Body) + body, _ := io.ReadAll(response.Body) return "", fmt.Errorf("Sharing Profile not created (HTTP %d) %s", response.StatusCode, string(body)) } diff --git a/profile/sandbox/client_test.go b/profile/sandbox/client_test.go index d4bdb9eb..05070833 100644 --- a/profile/sandbox/client_test.go +++ b/profile/sandbox/client_test.go @@ -3,7 +3,7 @@ package sandbox import ( "crypto/rand" "crypto/rsa" - "io/ioutil" + "io" "net/http" "os" "strings" @@ -24,7 +24,7 @@ func TestClient_SetupSharingProfile_ShouldReturnErrorIfProfileNotCreated(t *test do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 401, - Body: ioutil.NopCloser(strings.NewReader("")), + Body: io.NopCloser(strings.NewReader("")), }, nil }, }, @@ -45,7 +45,7 @@ func TestClient_SetupSharingProfile_Success(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 201, - Body: ioutil.NopCloser(strings.NewReader(`{"token":"` + expectedToken + `"}`)), + Body: io.NopCloser(strings.NewReader(`{"token":"` + expectedToken + `"}`)), }, nil }, }, @@ -99,7 +99,7 @@ func TestClient_SetupSharingProfileUsesDefaultUrlAsFallbackWithNoEnvValue(t *tes } func createSandboxClient(t *testing.T, constructorBaseUrl string) (client Client) { - keyBytes, fileErr := ioutil.ReadFile("../../test/test-key.pem") + keyBytes, fileErr := os.ReadFile("../../test/test-key.pem") assert.NilError(t, fileErr) pemFile, parseErr := cryptoutil.ParseRSAKey(keyBytes) @@ -138,7 +138,7 @@ func mockHttpClientCreatedResponse() *mockHTTPClient { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 201, - Body: ioutil.NopCloser(strings.NewReader(`{"token":"tokenValue"}`)), + Body: io.NopCloser(strings.NewReader(`{"token":"tokenValue"}`)), }, nil }, } diff --git a/profile/service.go b/profile/service.go index 60aa07d4..3f9ae500 100644 --- a/profile/service.go +++ b/profile/service.go @@ -5,7 +5,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "strconv" "time" @@ -53,7 +53,7 @@ func GetActivityDetails(httpClient requests.HttpClient, token, clientSdkId, apiU return activity, err } - responseBytes, err := ioutil.ReadAll(response.Body) + responseBytes, err := io.ReadAll(response.Body) if err != nil { return } diff --git a/profile/service_test.go b/profile/service_test.go index e9040bd3..cc07c0f6 100644 --- a/profile/service_test.go +++ b/profile/service_test.go @@ -7,9 +7,10 @@ import ( "crypto/rand" "crypto/rsa" "encoding/base64" - "io/ioutil" + "io" "net/http" "net/url" + "os" "strings" "testing" "time" @@ -109,7 +110,7 @@ func TestProfileService_GetActivityDetails(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `","other_party_profile_content": "` + otherPartyProfileContent + `","remember_me_id":"` + rememberMeID + `", "sharing_outcome":"SUCCESS", "timestamp":"2006-01-02T15:04:05.999999Z"}}`)), + Body: io.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `","other_party_profile_content": "` + otherPartyProfileContent + `","remember_me_id":"` + rememberMeID + `", "sharing_outcome":"SUCCESS", "timestamp":"2006-01-02T15:04:05.999999Z"}}`)), }, nil }, } @@ -164,7 +165,7 @@ func TestProfileService_SharingFailure_ReturnsSpecificFailure(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"session_data":"session_data","receipt":{"receipt_id": null,"other_party_profile_content": null,"policy_uri":null,"personal_key":null,"remember_me_id":null, "sharing_outcome":"FAILURE","timestamp":"2016-09-23T13:04:11Z"},"error_details":{"error_code":"SOME_ERROR","description":"SOME_DESCRIPTION"}}`)), + Body: io.NopCloser(strings.NewReader(`{"session_data":"session_data","receipt":{"receipt_id": null,"other_party_profile_content": null,"policy_uri":null,"personal_key":null,"remember_me_id":null, "sharing_outcome":"FAILURE","timestamp":"2016-09-23T13:04:11Z"},"error_details":{"error_code":"SOME_ERROR","description":"SOME_DESCRIPTION"}}`)), }, nil }, } @@ -197,7 +198,7 @@ func TestProfileService_SharingFailure_ReturnsGenericErrorWhenErrorCodeIsNull(t do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"session_data":"session_data","receipt":{"receipt_id": null,"other_party_profile_content": null,"policy_uri":null,"personal_key":null,"remember_me_id":null, "sharing_outcome":"FAILURE","timestamp":"2016-09-23T13:04:11Z"},"error_details":{}}`)), + Body: io.NopCloser(strings.NewReader(`{"session_data":"session_data","receipt":{"receipt_id": null,"other_party_profile_content": null,"policy_uri":null,"personal_key":null,"remember_me_id":null, "sharing_outcome":"FAILURE","timestamp":"2016-09-23T13:04:11Z"},"error_details":{}}`)), }, nil }, } @@ -226,7 +227,7 @@ func TestProfileService_SharingFailure_ReturnsGenericFailure(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"session_data":"session_data","receipt":{"receipt_id": null,"other_party_profile_content": null,"policy_uri":null,"personal_key":null,"remember_me_id":null, "sharing_outcome":"FAILURE","timestamp":"2016-09-23T13:04:11Z"}}`)), + Body: io.NopCloser(strings.NewReader(`{"session_data":"session_data","receipt":{"receipt_id": null,"other_party_profile_content": null,"policy_uri":null,"personal_key":null,"remember_me_id":null, "sharing_outcome":"FAILURE","timestamp":"2016-09-23T13:04:11Z"}}`)), }, nil }, } @@ -276,7 +277,7 @@ func TestProfileService_ParentRememberMeID(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + + Body: io.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `","other_party_profile_content": "` + otherPartyProfileContent + `","parent_remember_me_id":"` + parentRememberMeID + `", "sharing_outcome":"SUCCESS", "timestamp":"2006-01-02T15:04:05.999999Z"}}`)), @@ -310,7 +311,7 @@ func TestProfileService_ParseWithoutProfile_Success(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `",` + + Body: io.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `",` + otherPartyProfileContent + `"remember_me_id":"` + rememberMeID + `", "sharing_outcome":"SUCCESS", "timestamp":"` + timestampString + `", "receipt_id":"` + receiptID + `"}}`)), }, nil }, @@ -329,7 +330,7 @@ func TestProfileService_ShouldParseAndDecryptExtraDataContent(t *testing.T) { otherPartyProfileContent := "ChCZAib1TBm9Q5GYfFrS1ep9EnAwQB5shpAPWLBgZgFgt6bCG3S5qmZHhrqUbQr3yL6yeLIDwbM7x4nuT/MYp+LDXgmFTLQNYbDTzrEzqNuO2ZPn9Kpg+xpbm9XtP7ZLw3Ep2BCmSqtnll/OdxAqLb4DTN4/wWdrjnFC+L/oQEECu646" rememberMeID := "remember_me_id0123456789" - pemBytes, err := ioutil.ReadFile("../test/test-key.pem") + pemBytes, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) dataEntries := make([]*yotiprotoshare.DataEntry, 0) @@ -347,7 +348,7 @@ func TestProfileService_ShouldParseAndDecryptExtraDataContent(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + + Body: io.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `","other_party_profile_content": "` + otherPartyProfileContent + `","extra_data_content": "` + extraDataContent + `","remember_me_id":"` + rememberMeID + `", "sharing_outcome":"SUCCESS", "timestamp":"2006-01-02T15:04:05.999999Z"}}`)), }, nil @@ -374,7 +375,7 @@ func TestProfileService_ShouldCarryOnProcessingIfIssuanceTokenIsNotPresent(t *te List: dataEntries, } - pemBytes, err := ioutil.ReadFile("../test/test-key.pem") + pemBytes, err := os.ReadFile("../test/test-key.pem") assert.NilError(t, err) extraDataContent := createExtraDataContent(t, protoExtraData) @@ -387,7 +388,7 @@ func TestProfileService_ShouldCarryOnProcessingIfIssuanceTokenIsNotPresent(t *te do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + + Body: io.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `","other_party_profile_content": "` + otherPartyProfileContent + `","extra_data_content": "` + extraDataContent + `","remember_me_id":"` + rememberMeID + `", "sharing_outcome":"SUCCESS"}}`)), }, nil @@ -417,7 +418,7 @@ func TestProfileService_ParseWithoutRememberMeID_Success(t *testing.T) { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `",` + + Body: io.NopCloser(strings.NewReader(`{"receipt":{"wrapped_receipt_key": "` + test.WrappedReceiptKey + `",` + otherPartyProfileContent + `"sharing_outcome":"SUCCESS", "timestamp":"2006-01-02T15:04:05.999999Z"}}`)), }, nil }, diff --git a/requests/signed_message.go b/requests/signed_message.go index f390a395..8a1715f2 100644 --- a/requests/signed_message.go +++ b/requests/signed_message.go @@ -41,6 +41,13 @@ func JSONHeaders() map[string][]string { } } +// AuthHeader is a header prototype including the App/SDK ID +func AuthHeader(clientSdkId string, key *rsa.PublicKey) map[string][]string { + return map[string][]string{ + "X-Yoti-Auth-Id": {clientSdkId}, + } +} + // AuthKeyHeader is a header prototype including an encoded RSA PublicKey func AuthKeyHeader(key *rsa.PublicKey) map[string][]string { return map[string][]string{ @@ -185,6 +192,7 @@ func (msg SignedRequest) Request() (request *http.Request, err error) { if err != nil { return } + signedDigest, err := msg.signDigest([]byte(msg.generateDigest(endpoint))) if err != nil { return @@ -199,6 +207,7 @@ func (msg SignedRequest) Request() (request *http.Request, err error) { if err != nil { return } + request.Header.Add("X-Yoti-Auth-Digest", signedDigest) request.Header.Add("X-Yoti-SDK", consts.SDKIdentifier) request.Header.Add("X-Yoti-SDK-Version", consts.SDKVersionIdentifier) @@ -208,5 +217,6 @@ func (msg SignedRequest) Request() (request *http.Request, err error) { request.Header.Add(key, value) } } + return request, err } diff --git a/sh/go-build-modtidy.sh b/sh/go-build-modtidy.sh index 46b63f5a..bef8763e 100755 --- a/sh/go-build-modtidy.sh +++ b/sh/go-build-modtidy.sh @@ -2,5 +2,5 @@ go build ./... for d in _examples/*/; do - (cd "$d" && go mod tidy -compat=1.17) + (cd "$d" && go mod tidy -compat=1.19) done diff --git a/sonar-project.properties b/sonar-project.properties index 961c49c5..39857580 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,3 +12,4 @@ sonar.go.tests.reportPaths = sonar-report.json sonar.tests = . sonar.test.inclusions = **/*_test.go sonar.coverage.exclusions = test/**/*,_examples/**/* +sonar.cpd.exclusions = digitalidentity/** diff --git a/test/key.go b/test/key.go index d70ceb74..54e3f494 100644 --- a/test/key.go +++ b/test/key.go @@ -2,14 +2,14 @@ package test import ( "crypto/rsa" - "io/ioutil" + "os" "github.com/getyoti/yoti-go-sdk/v3/cryptoutil" ) // GetValidKey returns a parsed RSA Private Key from a test key func GetValidKey(filepath string) (key *rsa.PrivateKey) { - keyBytes, err := ioutil.ReadFile(filepath) + keyBytes, err := os.ReadFile(filepath) if err != nil { panic("Error reading the test key: " + err.Error()) } diff --git a/yotierror/response.go b/yotierror/response.go index e15806cd..75c689d2 100644 --- a/yotierror/response.go +++ b/yotierror/response.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "strings" ) @@ -65,8 +65,8 @@ func formatResponseMessage(response *http.Response, httpErrorMessages ...map[int return defaultMessage } - body, _ := ioutil.ReadAll(response.Body) - response.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + body, _ := io.ReadAll(response.Body) + response.Body = io.NopCloser(bytes.NewBuffer(body)) var errorDO DataObject jsonError := json.Unmarshal(body, &errorDO) @@ -104,8 +104,8 @@ func formatHTTPError(message string, statusCode int, body []byte) string { func handleHTTPError(response *http.Response, errorMessages ...map[int]string) string { var body []byte if response.Body != nil { - body, _ = ioutil.ReadAll(response.Body) - response.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + body, _ = io.ReadAll(response.Body) + response.Body = io.NopCloser(bytes.NewBuffer(body)) } else { body = make([]byte, 0) } diff --git a/yotierror/response_test.go b/yotierror/response_test.go index 55be0426..f94810e1 100644 --- a/yotierror/response_test.go +++ b/yotierror/response_test.go @@ -3,7 +3,7 @@ package yotierror import ( "bytes" "encoding/json" - "io/ioutil" + "io" "net/http" "strings" "testing" @@ -27,7 +27,7 @@ func TestError_ShouldReturnFormattedError(t *testing.T) { err = NewResponseError( &http.Response{ StatusCode: 401, - Body: ioutil.NopCloser(bytes.NewReader(jsonBytes)), + Body: io.NopCloser(bytes.NewReader(jsonBytes)), }, ) @@ -44,7 +44,7 @@ func TestError_ShouldReturnFormattedErrorCodeAndMessageOnly(t *testing.T) { err = NewResponseError( &http.Response{ StatusCode: 400, - Body: ioutil.NopCloser(bytes.NewReader(jsonBytes)), + Body: io.NopCloser(bytes.NewReader(jsonBytes)), }, ) @@ -64,7 +64,7 @@ func TestError_ShouldReturnFormattedError_ReturnWrappedErrorByDefault(t *testing func TestError_ShouldReturnFormattedError_ReturnWrappedErrorWhenInvalidJSON(t *testing.T) { response := &http.Response{ StatusCode: 400, - Body: ioutil.NopCloser(strings.NewReader("some invalid JSON")), + Body: io.NopCloser(strings.NewReader("some invalid JSON")), } err := NewResponseError( response, @@ -75,7 +75,7 @@ func TestError_ShouldReturnFormattedError_ReturnWrappedErrorWhenInvalidJSON(t *t errorResponse := err.Response assert.Equal(t, response, errorResponse) - body, readErr := ioutil.ReadAll(errorResponse.Body) + body, readErr := io.ReadAll(errorResponse.Body) assert.NilError(t, readErr) assert.Equal(t, string(body), "some invalid JSON") @@ -85,7 +85,7 @@ func TestError_ShouldReturnFormattedError_IgnoreUnknownErrorItems(t *testing.T) jsonString := "{\"message\": \"some message\", \"code\": \"SOME_CODE\", \"error\": [{\"some_key\": \"some value\"}]}" response := &http.Response{ StatusCode: 400, - Body: ioutil.NopCloser(strings.NewReader(jsonString)), + Body: io.NopCloser(strings.NewReader(jsonString)), } err := NewResponseError( response, @@ -96,7 +96,7 @@ func TestError_ShouldReturnFormattedError_IgnoreUnknownErrorItems(t *testing.T) errorResponse := err.Response assert.Equal(t, response, errorResponse) - body, readErr := ioutil.ReadAll(errorResponse.Body) + body, readErr := io.ReadAll(errorResponse.Body) assert.NilError(t, readErr) assert.Equal(t, string(body), jsonString) @@ -105,7 +105,7 @@ func TestError_ShouldReturnFormattedError_IgnoreUnknownErrorItems(t *testing.T) func TestError_ShouldReturnCustomErrorForCode(t *testing.T) { response := &http.Response{ StatusCode: 404, - Body: ioutil.NopCloser(strings.NewReader("some body")), + Body: io.NopCloser(strings.NewReader("some body")), } err := NewResponseError( response, @@ -118,7 +118,7 @@ func TestError_ShouldReturnCustomErrorForCode(t *testing.T) { func TestError_ShouldReturnCustomDefaultError(t *testing.T) { response := &http.Response{ StatusCode: 500, - Body: ioutil.NopCloser(strings.NewReader("some body")), + Body: io.NopCloser(strings.NewReader("some body")), } err := NewResponseError( response, From 35a17a0ae5cede560a7db1e86eb8e8d34151c9b2 Mon Sep 17 00:00:00 2001 From: mehmet-yoti Date: Thu, 4 Jan 2024 16:52:51 +0000 Subject: [PATCH 04/11] SDK:2230-Expose-share-v2-api-version-update --- digitalidentity/requests/signed_message_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/digitalidentity/requests/signed_message_test.go b/digitalidentity/requests/signed_message_test.go index 301be62f..e4cbff58 100644 --- a/digitalidentity/requests/signed_message_test.go +++ b/digitalidentity/requests/signed_message_test.go @@ -80,7 +80,7 @@ func TestRequestShouldBuildForValid(t *testing.T) { assert.Check(t, urlCheck) assert.Check(t, signed.Header.Get("X-Yoti-Auth-Digest") != "") assert.Equal(t, signed.Header.Get("X-Yoti-SDK"), "Go") - assert.Equal(t, signed.Header.Get("X-Yoti-SDK-Version"), "3.9.0") + assert.Equal(t, signed.Header.Get("X-Yoti-SDK-Version"), "3.11.0") } func TestRequestShouldAddHeaders(t *testing.T) { From 7dc79f00996d90c19814d4adeb3240b64bdf7d88 Mon Sep 17 00:00:00 2001 From: Iancu Fofiu <34542028+fofiuiancu@users.noreply.github.com> Date: Mon, 8 Jan 2024 16:42:27 +0000 Subject: [PATCH 05/11] =?UTF-8?q?SDK-2370:=20Added=20support=20for=20Advan?= =?UTF-8?q?ced=20Identity=20Profiles=20Requirements=20f=E2=80=A6=20(#302)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * SDK-2370: Added support for Advanced Identity Profiles Requirements for Share V2 * SDK-2370: renamed file and fixed spelling --------- Co-authored-by: System Administrator <> --- .gitignore | 3 + .../advanced_identity_profile.go | 70 +++ _examples/digitalidentity/login.html | 4 +- _examples/digitalidentity/main.go | 27 +- _examples/digitalidentity/receipt.go | 136 +++++ _examples/digitalidentity/receipt.html | 151 +++++ _examples/idv/main.go | 2 +- _examples/profile/profile.go | 2 +- _examples/profile/profile.html | 2 +- digitalidentity/policy_builder.go | 53 +- digitalidentity/policy_builder_test.go | 69 +++ .../AdvancedIdentityProfileReport.json | 541 ------------------ 12 files changed, 470 insertions(+), 590 deletions(-) create mode 100644 _examples/digitalidentity/advanced_identity_profile.go create mode 100644 _examples/digitalidentity/receipt.go create mode 100644 _examples/digitalidentity/receipt.html delete mode 100644 test/fixtures/AdvancedIdentityProfileReport.json diff --git a/.gitignore b/.gitignore index 842d1127..a1fb98b7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ report.json # idea files .idea +# DS_Store files +.DS_Store + diff --git a/_examples/digitalidentity/advanced_identity_profile.go b/_examples/digitalidentity/advanced_identity_profile.go new file mode 100644 index 00000000..86152404 --- /dev/null +++ b/_examples/digitalidentity/advanced_identity_profile.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/getyoti/yoti-go-sdk/v3/digitalidentity" +) + +var advancedIdentityProfile = []byte(`{ + "profiles": [ + { + "trust_framework": "YOTI_GLOBAL", + "schemes": [ + { + "label": "LB321", + "type": "IDENTITY", + "objective": "AL_L1" + } + ] + } + ] + }`) + +func buildAdvancedIdentitySessionReq() (sessionSpec *digitalidentity.ShareSessionRequest, err error) { + policy, err := (&digitalidentity.PolicyBuilder{}).WithAdvancedIdentityProfileRequirements(advancedIdentityProfile).Build() + if err != nil { + return nil, fmt.Errorf("failed to build Advanced Identity Requirements policy: %v", err) + } + + subject := []byte(`{ + "subject_id": "unique-user-id-for-examples" + }`) + + sessionReq, err := (&digitalidentity.ShareSessionRequestBuilder{}).WithPolicy(policy).WithRedirectUri("https://localhost:8080/v2/receipt-info").WithSubject(subject).Build() + if err != nil { + return nil, fmt.Errorf("failed to build create session request: %v", err) + } + return &sessionReq, nil +} + +func generateAdvancedIdentitySession(w http.ResponseWriter, r *http.Request) { + didClient, err := initialiseDigitalIdentityClient() + if err != nil { + fmt.Fprintf(w, "Client could't be generated: %v", err) + return + } + + sessionReq, err := buildAdvancedIdentitySessionReq() + if err != nil { + fmt.Fprintf(w, "failed to build session request: %v", err) + return + } + + shareSession, err := didClient.CreateShareSession(sessionReq) + if err != nil { + fmt.Fprintf(w, "failed to create share session: %v", err) + return + } + + output, err := json.Marshal(shareSession) + if err != nil { + fmt.Fprintf(w, "failed to marshall share session: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(output)) + +} diff --git a/_examples/digitalidentity/login.html b/_examples/digitalidentity/login.html index b3d2729d..c866a122 100644 --- a/_examples/digitalidentity/login.html +++ b/_examples/digitalidentity/login.html @@ -52,7 +52,9 @@

The Yoti app is free to download and use: +
+
+ + {{ .Name }} +
+
+ +
+
+ {{ if eq .Prop.Name "document_details" }} + + + + + + + + + + + + + + + + + + + + + +
Document Type{{ .Prop.Value.DocumentType }}
Issuing Country{{ .Prop.Value.IssuingCountry }}
Document Number{{ .Prop.Value.DocumentNumber }}
Expiration Date{{ .Prop.Value.ExpirationDate }}
Issuing Authority{{ .Prop.Value.IssuingAuthority }}
+ {{ else if eq .Prop.Name "document_images" }} + {{ range .Prop.Value }} + + {{ end }} + {{ else if eq .Prop.Name "structured_postal_address" }} + + {{ range $key, $value := .Prop.Value }} + + + + + {{ end }} +
{{ $key }}{{ $value }}
+ {{ else if eq .Prop.Name "identity_profile_report" }} + + {{ range $key, $value := .Prop.Value }} + + + + + {{ end }} +
{{ $key }}{{ jsonMarshalIndent $value }}
+ {{ else }} + {{ .Prevalue }} + {{ .Prop.Value }} + {{ end }} +
+
+
+
S / V
+
Value
+
Sub type
+ {{ range .Prop.Sources }} +
Source
+
{{ .Value }}
+
{{ .SubType }}
+ {{ end }} + {{ range .Prop.Verifiers }} +
Verifier
+
{{ .Value }}
+
{{ .SubType }}
+ {{ end }} +
+ + {{ end }} +{{end}} + + + + + + + + Yoti client example + + + + + +
+
+
+ Powered by + Yoti +
+ {{ if .profile.Selfie }} +
+ Yoti + +
+ {{ end }} + + {{ if .profile.FullName }} +
+ {{ .profile.FullName.Value }} +
+ {{ end }} +
+ +
+ +
+
Attribute
+
Value
+
Anchors
+
+ +
+
+
S / V
+
Value
+
Sub type
+
+
+ +
+ {{ if .profile.GivenNames }} {{ template "attribute" marshalAttribute "Given names" "yoti-icon-profile" .profile.GivenNames "" }} {{ end }} + {{ if .profile.FamilyName }} {{ template "attribute" marshalAttribute "Family names" "yoti-icon-profile" .profile.FamilyName "" }} {{ end }} + {{ if .profile.MobileNumber }} {{ template "attribute" marshalAttribute "Mobile number" "yoti-icon-phone" .profile.MobileNumber "" }} {{ end }} + {{ if .profile.EmailAddress }} {{ template "attribute" marshalAttribute "Email address" "yoti-icon-email" .profile.EmailAddress "" }} {{ end }} + {{ if .profile.DateOfBirth }} {{ template "attribute" marshalAttribute "Date of birth" "yoti-icon-calendar" .profile.DateOfBirth "" }} {{ end }} + {{ if .profile.GetAttribute "age_over:18"}} {{ template "attribute" marshalAttribute "Age verified" "yoti-icon-verified" (.profile.GetAttribute "age_over:18") "Age Verification/" }} {{ end }} + {{ if .profile.Address }} {{ template "attribute" marshalAttribute "Address" "yoti-icon-address" .profile.Address "" }} {{ end }} + {{ if .profile.StructuredPostalAddress }} {{ template "attribute" marshalAttribute "Structured Address" "yoti-icon-address" .profile.StructuredPostalAddress "" }} {{ end }} + {{ if .profile.Gender }} {{ template "attribute" marshalAttribute "Gender" "yoti-icon-gender" .profile.Gender "" }} {{ end }} + {{ if .profile.DocumentDetails }} {{ template "attribute" marshalAttribute "Document Details" "yoti-icon-document" .profile.DocumentDetails "" }} {{ end }} + {{ if .profile.DocumentImages }} {{ template "attribute" marshalAttribute "Document Images" "yoti-icon-profile" .profile.DocumentImages "" }} {{ end }} + {{ if .profile.IdentityProfileReport }} {{ template "attribute" marshalAttribute "Identity Profile Report" "yoti-icon-profile" .profile.IdentityProfileReport "" }} {{ end }} +
+ +
+
+ + + diff --git a/_examples/idv/main.go b/_examples/idv/main.go index 3c2d254a..937fafb3 100644 --- a/_examples/idv/main.go +++ b/_examples/idv/main.go @@ -21,7 +21,7 @@ func main() { router = gin.Default() router.SetFuncMap(template.FuncMap{ - "jsonMarshallIndent": func(data interface{}) string { + "jsonMarshalIndent": func(data interface{}) string { json, err := json.MarshalIndent(data, "", "\t") if err != nil { fmt.Println(err) diff --git a/_examples/profile/profile.go b/_examples/profile/profile.go index 15c3f307..113bd327 100644 --- a/_examples/profile/profile.go +++ b/_examples/profile/profile.go @@ -103,7 +103,7 @@ func profile(w http.ResponseWriter, r *http.Request) { prevalue, } }, - "jsonMarshallIndent": func(data interface{}) string { + "jsonMarshalIndent": func(data interface{}) string { json, err := json.MarshalIndent(data, "", "\t") if err != nil { fmt.Println(err) diff --git a/_examples/profile/profile.html b/_examples/profile/profile.html index 8a54cf2f..21ca7d21 100644 --- a/_examples/profile/profile.html +++ b/_examples/profile/profile.html @@ -51,7 +51,7 @@ {{ range $key, $value := .Prop.Value }} {{ $key }} - {{ jsonMarshallIndent $value }} + {{ jsonMarshalIndent $value }} {{ end }} diff --git a/digitalidentity/policy_builder.go b/digitalidentity/policy_builder.go index ef9688bd..4dec515e 100644 --- a/digitalidentity/policy_builder.go +++ b/digitalidentity/policy_builder.go @@ -16,19 +16,21 @@ const ( // PolicyBuilder constructs a json payload specifying the dynamic policy // for a dynamic scenario type PolicyBuilder struct { - wantedAttributes map[string]WantedAttribute - wantedAuthTypes map[int]bool - isWantedRememberMe bool - err error - identityProfileRequirements *json.RawMessage + wantedAttributes map[string]WantedAttribute + wantedAuthTypes map[int]bool + isWantedRememberMe bool + err error + identityProfileRequirements *json.RawMessage + advancedIdentityProfileRequirements *json.RawMessage } // Policy represents a dynamic policy for a share type Policy struct { - attributes []WantedAttribute - authTypes []int - rememberMeID bool - identityProfileRequirements *json.RawMessage + attributes []WantedAttribute + authTypes []int + rememberMeID bool + identityProfileRequirements *json.RawMessage + advancedIdentityProfileRequirements *json.RawMessage } // WithWantedAttribute adds an attribute from WantedAttributeBuilder to the policy @@ -206,13 +208,20 @@ func (b *PolicyBuilder) WithIdentityProfileRequirements(identityProfile json.Raw return b } +// WithAdvancedIdentityProfileRequirements adds Advanced Identity Profile Requirements to the policy. Must be valid JSON. +func (b *PolicyBuilder) WithAdvancedIdentityProfileRequirements(advancedIdentityProfile json.RawMessage) *PolicyBuilder { + b.advancedIdentityProfileRequirements = &advancedIdentityProfile + return b +} + // Build constructs a dynamic policy object func (b *PolicyBuilder) Build() (Policy, error) { return Policy{ - attributes: b.attributesAsList(), - authTypes: b.authTypesAsList(), - rememberMeID: b.isWantedRememberMe, - identityProfileRequirements: b.identityProfileRequirements, + attributes: b.attributesAsList(), + authTypes: b.authTypesAsList(), + rememberMeID: b.isWantedRememberMe, + identityProfileRequirements: b.identityProfileRequirements, + advancedIdentityProfileRequirements: b.advancedIdentityProfileRequirements, }, b.err } @@ -237,14 +246,16 @@ func (b *PolicyBuilder) authTypesAsList() []int { // MarshalJSON returns the JSON encoding func (policy *Policy) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { - Wanted []WantedAttribute `json:"wanted"` - WantedAuthTypes []int `json:"wanted_auth_types"` - WantedRememberMe bool `json:"wanted_remember_me"` - IdentityProfileRequirements *json.RawMessage `json:"identity_profile_requirements,omitempty"` + Wanted []WantedAttribute `json:"wanted"` + WantedAuthTypes []int `json:"wanted_auth_types"` + WantedRememberMe bool `json:"wanted_remember_me"` + IdentityProfileRequirements *json.RawMessage `json:"identity_profile_requirements,omitempty"` + AdvancedIdentityProfileRequirements *json.RawMessage `json:"advanced_identity_profile_requirements,omitempty"` }{ - Wanted: policy.attributes, - WantedAuthTypes: policy.authTypes, - WantedRememberMe: policy.rememberMeID, - IdentityProfileRequirements: policy.identityProfileRequirements, + Wanted: policy.attributes, + WantedAuthTypes: policy.authTypes, + WantedRememberMe: policy.rememberMeID, + IdentityProfileRequirements: policy.identityProfileRequirements, + AdvancedIdentityProfileRequirements: policy.advancedIdentityProfileRequirements, }) } diff --git a/digitalidentity/policy_builder_test.go b/digitalidentity/policy_builder_test.go index e2bc075a..a7ae6e1a 100644 --- a/digitalidentity/policy_builder_test.go +++ b/digitalidentity/policy_builder_test.go @@ -437,3 +437,72 @@ func TestDigitalIdentityBuilder_WithIdentityProfileRequirements_ShouldFailForInv t.Errorf("wanted err to be of type '%v', got: '%v'", reflect.TypeOf(marshallerErr), reflect.TypeOf(err)) } } + +func ExamplePolicyBuilder_WithAdvancedIdentityProfileRequirements() { + advancedIdentityProfile := []byte(`{ + "profiles": [ + { + "trust_framework": "UK_TFIDA", + "schemes": [ + { + "label": "LB912", + "type": "RTW" + }, + { + "label": "LB777", + "type": "DBS", + "objective": "BASIC" + } + ] + }, + { + "trust_framework": "YOTI_GLOBAL", + "schemes": [ + { + "label": "LB321", + "type": "IDENTITY", + "objective": "AL_L1", + "config": {} + } + ] + } + ] + }`) + + policy, err := (&PolicyBuilder{}).WithAdvancedIdentityProfileRequirements(advancedIdentityProfile).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + data, err := policy.MarshalJSON() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(data)) + // Output: {"wanted":[],"wanted_auth_types":[],"wanted_remember_me":false,"advanced_identity_profile_requirements":{"profiles":[{"trust_framework":"UK_TFIDA","schemes":[{"label":"LB912","type":"RTW"},{"label":"LB777","type":"DBS","objective":"BASIC"}]},{"trust_framework":"YOTI_GLOBAL","schemes":[{"label":"LB321","type":"IDENTITY","objective":"AL_L1","config":{}}]}]}} +} + +func TestPolicyBuilder_WithAdvancedIdentityProfileRequirements_ShouldFailForInvalidJSON(t *testing.T) { + advancedIdentityProfile := []byte(`{ + "trust_framework": UK_TFIDA", + , + }`) + + policy, err := (&PolicyBuilder{}).WithAdvancedIdentityProfileRequirements(advancedIdentityProfile).Build() + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + _, err = policy.MarshalJSON() + if err == nil { + t.Error("expected an error") + } + var marshallerErr *json.MarshalerError + if !errors.As(err, &marshallerErr) { + t.Errorf("wanted err to be of type '%v', got: '%v'", reflect.TypeOf(marshallerErr), reflect.TypeOf(err)) + } +} diff --git a/test/fixtures/AdvancedIdentityProfileReport.json b/test/fixtures/AdvancedIdentityProfileReport.json deleted file mode 100644 index 160eeacc..00000000 --- a/test/fixtures/AdvancedIdentityProfileReport.json +++ /dev/null @@ -1,541 +0,0 @@ -{ - "identity_assertion": { - "current_name": { - "given_names": "LAURENCE GUY", - "first_name": "LAURENCE", - "middle_name": "GUY", - "family_name": "WITHERS", - "full_name": "LAURENCE GUY WITHERS" - }, - "date_of_birth": "1981-10-05", - "current_address": { - "address": { - "address_format": 1, - "care_of": "", - "sub_building": "", - "building_number": "25", - "building": "", - "street": "25 Test Street", - "landmark": "", - "address_line1": "25 Test Street", - "address_line2": "London", - "address_line3": "EC3M 5LY", - "address_line4": "", - "address_line5": "", - "address_line6": "", - "locality": "", - "town_city": "London", - "subdistrict": "", - "district": "", - "state": "", - "postal_code": "EC3M 5LY", - "post_office": "", - "country_iso": "GBR", - "country": "United Kingdom", - "formatted_address": "25 Test Street\\nLondon\\nEC3M 5LY\\nLondon\\nEC3M 5LY\\nUnited Kingdom", - "udprn": "" - }, - "move_in": "" - } - }, - "verification_report": { - "report_id": "7fd51c9f-4131-4665-b44d-59f8aa888003", - "timestamp": "2023-10-04T11:31:15Z", - "subject_id": "ITEST", - "address_verification": { - "current_address_verified": true, - "evidence_links": [ - "5df924ad-904e-4a88-9d34-889599d29795" - ] - }, - "trust_framework": "UK_TFIDA", - "schemes_compliance": [ - { - "scheme": { - "type": "DBS", - "objective": "STANDARD", - "label": "", - "config": null - }, - "requirements_met": true, - "requirements_not_met_info": "", - "requirements_not_met_details": [] - } - ], - "assurance_process": { - "level_of_assurance": "HIGH", - "policy": "GPG45", - "procedure": "H1A", - "assurance": [ - { - "type": "EVIDENCE_STRENGTH", - "classification": "4", - "evidence_links": [ - "0c3e309e-0b14-4207-8190-109c7cf7cb78" - ] - }, - { - "type": "EVIDENCE_VALIDITY", - "classification": "3", - "evidence_links": [ - "0c3e309e-0b14-4207-8190-109c7cf7cb78" - ] - }, - { - "type": "IDENTITY_FRAUD", - "classification": "1", - "evidence_links": [ - "22542183-8363-41bf-af6e-c5e099eb26d3" - ] - }, - { - "type": "VERIFICATION", - "classification": "3", - "evidence_links": [ - "0c3e309e-0b14-4207-8190-109c7cf7cb78", - "2bc147ed-e23e-4df1-9dec-61295e04770a" - ] - } - ] - }, - "evidence": { - "face": { - "evidence_id": "2bc147ed-e23e-4df1-9dec-61295e04770a", - "initial_liveness": { - "type": "ZOOM", - "timestamp": "2023-10-04T11:31:15Z" - }, - "last_matched_liveness": { - "type": "ZOOM", - "timestamp": "2023-10-04T11:31:15Z" - }, - "verifying_org": "", - "resource_ids": [], - "check_ids": [], - "user_activity_ids": [ - "DID_TRACKING_ID" - ], - "selfie_attribute_id": "" - }, - "documents": [ - { - "evidence_id": "0c3e309e-0b14-4207-8190-109c7cf7cb78", - "timestamp": "2023-10-04T11:31:15Z", - "document_fields": { - "full_name": "LAURENCE GUY WITHERS", - "date_of_birth": "1981-10-05", - "nationality": "GBR", - "given_names": "LAURENCE GUY", - "first_name": "", - "middle_name": "", - "family_name": "WITHERS", - "place_of_birth": "", - "country_of_birth": "", - "gender": "MALE", - "name_prefix": "", - "name_suffix": "", - "first_name_alias": "", - "middle_name_alias": "", - "family_name_alias": "", - "weight": "", - "height": "", - "eye_color": "", - "structured_postal_address": null, - "document_type": "PASSPORT", - "issuing_country": "GBR", - "document_number": "546697970", - "expiration_date": "2027-05-06", - "date_of_issue": "2017-03-06", - "issuing_authority": "HMPO", - "mrz": { - "type": 2, - "line1": "P", - "authentication_report": { - "report_id": "68a952f2-d675-405c-a4fa-adea7424414b", - "timestamp": "2023-10-04T11:31:15Z", - "level": "HIGH", - "policy": "GPG44", - "trust_framework": "UK_TFIDA" - }, - "profile_match_report": null, - "verification_reports": [ - { - "report_id": "7fd51c9f-4131-4665-b44d-59f8aa888003", - "timestamp": "2023-10-04T11:31:15Z", - "subject_id": "ITEST", - "address_verification": { - "current_address_verified": true, - "evidence_links": [ - "5df924ad-904e-4a88-9d34-889599d29795" - ] - }, - "trust_framework": "UK_TFIDA", - "schemes_compliance": [ - { - "scheme": { - "type": "DBS", - "objective": "STANDARD", - "label": "", - "config": null - }, - "requirements_met": true, - "requirements_not_met_info": "", - "requirements_not_met_details": [] - } - ], - "assurance_process": { - "level_of_assurance": "HIGH", - "policy": "GPG45", - "procedure": "H1A", - "assurance": [ - { - "type": "EVIDENCE_STRENGTH", - "classification": "4", - "evidence_links": [ - "0c3e309e-0b14-4207-8190-109c7cf7cb78" - ] - }, - { - "type": "EVIDENCE_VALIDITY", - "classification": "3", - "evidence_links": [ - "0c3e309e-0b14-4207-8190-109c7cf7cb78" - ] - }, - { - "type": "IDENTITY_FRAUD", - "classification": "1", - "evidence_links": [ - "22542183-8363-41bf-af6e-c5e099eb26d3" - ] - }, - { - "type": "VERIFICATION", - "classification": "3", - "evidence_links": [ - "0c3e309e-0b14-4207-8190-109c7cf7cb78", - "2bc147ed-e23e-4df1-9dec-61295e04770a" - ] - } - ] - }, - "evidence": { - "face": { - "evidence_id": "2bc147ed-e23e-4df1-9dec-61295e04770a", - "initial_liveness": { - "type": "ZOOM", - "timestamp": "2023-10-04T11:31:15Z" - }, - "last_matched_liveness": { - "type": "ZOOM", - "timestamp": "2023-10-04T11:31:15Z" - }, - "verifying_org": "", - "resource_ids": [], - "check_ids": [], - "user_activity_ids": [ - "DID_TRACKING_ID" - ], - "selfie_attribute_id": "" - }, - "documents": [ - { - "evidence_id": "0c3e309e-0b14-4207-8190-109c7cf7cb78", - "timestamp": "2023-10-04T11:31:15Z", - "document_fields": { - "full_name": "LAURENCE GUY WITHERS", - "date_of_birth": "1981-10-05", - "nationality": "GBR", - "given_names": "LAURENCE GUY", - "first_name": "", - "middle_name": "", - "family_name": "WITHERS", - "place_of_birth": "", - "country_of_birth": "", - "gender": "MALE", - "name_prefix": "", - "name_suffix": "", - "first_name_alias": "", - "middle_name_alias": "", - "family_name_alias": "", - "weight": "", - "height": "", - "eye_color": "", - "structured_postal_address": null, - "document_type": "PASSPORT", - "issuing_country": "GBR", - "document_number": "546697970", - "expiration_date": "2027-05-06", - "date_of_issue": "2017-03-06", - "issuing_authority": "HMPO", - "mrz": { - "type": 2, - "line1": "P Date: Tue, 9 Jan 2024 09:18:56 +0300 Subject: [PATCH 06/11] SDK-2422 fixed code smells,updated version for release --- digitalidentity/service.go | 35 ++++++++++++++++++--------------- requests/signed_message_test.go | 2 +- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/digitalidentity/service.go b/digitalidentity/service.go index 444a6560..7215dcb1 100644 --- a/digitalidentity/service.go +++ b/digitalidentity/service.go @@ -20,6 +20,9 @@ const identitySessionQrCodeCreation = "/v2/sessions/%s/qr-codes" const identitySessionQrCodeRetrieval = "/v2/qr-codes/%s" const identitySessionReceiptRetrieval = "/v2/receipts/%s" const identitySessionReceiptKeyRetrieval = "/v2/wrapped-item-keys/%s" +const errorFailedToGetSignedReceipt = "failed to get signed request: %v" +const errorFailedToExecuteRequest = "failed to execute request: %v" +const errorFailedToReadBody = "failed to read response body: %v" // CreateShareSession creates session using the supplied session specification func CreateShareSession(httpClient requests.HttpClient, shareSessionRequest *ShareSessionRequest, clientSdkId, apiUrl string, key *rsa.PrivateKey) (*ShareSession, error) { @@ -40,12 +43,12 @@ func CreateShareSession(httpClient requests.HttpClient, shareSessionRequest *Sha Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return nil, fmt.Errorf("failed to get signed request: %v", err) + return nil, fmt.Errorf(errorFailedToGetSignedReceipt, err) } response, err := requests.Execute(httpClient, request) if err != nil { - return nil, fmt.Errorf("failed to execute request: %v", err) + return nil, fmt.Errorf(errorFailedToExecuteRequest, err) } defer response.Body.Close() @@ -53,7 +56,7 @@ func CreateShareSession(httpClient requests.HttpClient, shareSessionRequest *Sha responseBytes, err := io.ReadAll(response.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %v", err) + return nil, fmt.Errorf(errorFailedToReadBody, err) } err = json.Unmarshal(responseBytes, shareSession) return shareSession, err @@ -72,19 +75,19 @@ func GetShareSession(httpClient requests.HttpClient, sessionID string, clientSdk Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return nil, fmt.Errorf("failed to get signed request: %v", err) + return nil, fmt.Errorf(errorFailedToGetSignedReceipt, err) } response, err := requests.Execute(httpClient, request) if err != nil { - return nil, fmt.Errorf("failed to execute request: %v", err) + return nil, fmt.Errorf(errorFailedToExecuteRequest, err) } defer response.Body.Close() shareSession := &ShareSession{} responseBytes, err := io.ReadAll(response.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %v", err) + return nil, fmt.Errorf(errorFailedToReadBody, err) } err = json.Unmarshal(responseBytes, shareSession) return shareSession, err @@ -104,19 +107,19 @@ func CreateShareQrCode(httpClient requests.HttpClient, sessionID string, clientS Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return nil, fmt.Errorf("failed to get signed request: %v", err) + return nil, fmt.Errorf(errorFailedToGetSignedReceipt, err) } response, err := requests.Execute(httpClient, request) if err != nil { - return nil, fmt.Errorf("failed to execute request: %v", err) + return nil, fmt.Errorf(errorFailedToExecuteRequest, err) } defer response.Body.Close() qrCode := &QrCode{} responseBytes, err := io.ReadAll(response.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %v", err) + return nil, fmt.Errorf(errorFailedToReadBody, err) } err = json.Unmarshal(responseBytes, qrCode) return qrCode, err @@ -134,18 +137,18 @@ func GetShareSessionQrCode(httpClient requests.HttpClient, qrCodeId string, clie Headers: headers, }.Request() if err != nil { - return fetchedQrCode, fmt.Errorf("failed to get signed request: %v", err) + return fetchedQrCode, fmt.Errorf(errorFailedToGetSignedReceipt, err) } response, err := requests.Execute(httpClient, request) if err != nil { - return fetchedQrCode, fmt.Errorf("failed to execute request: %v", err) + return fetchedQrCode, fmt.Errorf(errorFailedToExecuteRequest, err) } defer response.Body.Close() responseBytes, err := io.ReadAll(response.Body) if err != nil { - return fetchedQrCode, fmt.Errorf("failed to read response body: %v", err) + return fetchedQrCode, fmt.Errorf(errorFailedToReadBody, err) } err = json.Unmarshal(responseBytes, &fetchedQrCode) @@ -167,18 +170,18 @@ func getReceipt(httpClient requests.HttpClient, receiptId string, clientSdkId, a Headers: headers, }.Request() if err != nil { - return receipt, fmt.Errorf("failed to get signed request: %v", err) + return receipt, fmt.Errorf(errorFailedToGetSignedReceipt, err) } response, err := requests.Execute(httpClient, request) if err != nil { - return receipt, fmt.Errorf("failed to execute request: %v", err) + return receipt, fmt.Errorf(errorFailedToExecuteRequest, err) } defer response.Body.Close() responseBytes, err := io.ReadAll(response.Body) if err != nil { - return receipt, fmt.Errorf("failed to read response body: %v", err) + return receipt, fmt.Errorf(errorFailedToReadBody, err) } err = json.Unmarshal(responseBytes, &receipt) @@ -198,7 +201,7 @@ func getReceiptItemKey(httpClient requests.HttpClient, receiptItemKeyId string, Headers: headers, }.Request() if err != nil { - return receiptItemKey, fmt.Errorf("failed to get signed request: %v", err) + return receiptItemKey, fmt.Errorf(errorFailedToGetSignedReceipt, err) } response, err := requests.Execute(httpClient, request) diff --git a/requests/signed_message_test.go b/requests/signed_message_test.go index e4cbff58..9fbc0e23 100644 --- a/requests/signed_message_test.go +++ b/requests/signed_message_test.go @@ -80,7 +80,7 @@ func TestRequestShouldBuildForValid(t *testing.T) { assert.Check(t, urlCheck) assert.Check(t, signed.Header.Get("X-Yoti-Auth-Digest") != "") assert.Equal(t, signed.Header.Get("X-Yoti-SDK"), "Go") - assert.Equal(t, signed.Header.Get("X-Yoti-SDK-Version"), "3.11.0") + assert.Equal(t, signed.Header.Get("X-Yoti-SDK-Version"), "3.12.0") } func TestRequestShouldAddHeaders(t *testing.T) { From 90f43ba7964cfc7727d1f3f0d8f05be865f55a21 Mon Sep 17 00:00:00 2001 From: mehmet-yoti Date: Tue, 9 Jan 2024 09:20:50 +0300 Subject: [PATCH 07/11] SDK-2422 fixed code smells,updated version for release --- README.md | 2 +- consts/version.go | 2 +- digitalidentity/requests/signed_message_test.go | 2 +- sonar-project.properties | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8fbea9a9..eb8392dd 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ import "github.com/getyoti/yoti-go-sdk/v3" or add the following line to your go.mod file (check https://github.com/getyoti/yoti-go-sdk/releases for the latest version) ``` -require github.com/getyoti/yoti-go-sdk/v3 v3.11.0 +require github.com/getyoti/yoti-go-sdk/v3 v3.12.0 ``` ## Setup diff --git a/consts/version.go b/consts/version.go index 553971b5..50085a5e 100644 --- a/consts/version.go +++ b/consts/version.go @@ -2,5 +2,5 @@ package consts const ( SDKIdentifier = "Go" - SDKVersionIdentifier = "3.11.0" + SDKVersionIdentifier = "3.12.0" ) diff --git a/digitalidentity/requests/signed_message_test.go b/digitalidentity/requests/signed_message_test.go index e4cbff58..9fbc0e23 100644 --- a/digitalidentity/requests/signed_message_test.go +++ b/digitalidentity/requests/signed_message_test.go @@ -80,7 +80,7 @@ func TestRequestShouldBuildForValid(t *testing.T) { assert.Check(t, urlCheck) assert.Check(t, signed.Header.Get("X-Yoti-Auth-Digest") != "") assert.Equal(t, signed.Header.Get("X-Yoti-SDK"), "Go") - assert.Equal(t, signed.Header.Get("X-Yoti-SDK-Version"), "3.11.0") + assert.Equal(t, signed.Header.Get("X-Yoti-SDK-Version"), "3.12.0") } func TestRequestShouldAddHeaders(t *testing.T) { diff --git a/sonar-project.properties b/sonar-project.properties index 39857580..a64c85b4 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.organization = getyoti sonar.projectKey = getyoti:go sonar.projectName = Go SDK -sonar.projectVersion = 3.11.0 +sonar.projectVersion = 3.12.0 sonar.exclusions = **/yotiprotoattr/*.go,**/yotiprotocom/*.go,**/yotiprotoshare/*.go,**/**_test.go,_examples/**/* sonar.links.scm = https://github.com/getyoti/yoti-go-sdk sonar.host.url = https://sonarcloud.io From 90be4ef33389357300f4078c395a37edee1d6112 Mon Sep 17 00:00:00 2001 From: mehmet-yoti Date: Tue, 9 Jan 2024 09:52:39 +0300 Subject: [PATCH 08/11] SDK-2422 added test --- requests/signed_message_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/requests/signed_message_test.go b/requests/signed_message_test.go index 9fbc0e23..f23dae2a 100644 --- a/requests/signed_message_test.go +++ b/requests/signed_message_test.go @@ -167,3 +167,35 @@ func TestSignedRequest_WithPemFile_NotRSAKeyShouldError(t *testing.T) { -----END RSA PUBLIC KEY-----`)) assert.ErrorContains(t, msg.Error, "not an RSA Private Key") } + +// TestAuthHeader tests the AuthHeader function. +func TestAuthHeader(t *testing.T) { + tests := []struct { + name string + clientSdkId string + expected map[string][]string + }{ + { + name: "valid client SDK ID", + clientSdkId: "testSdkId", + expected: map[string][]string{"X-Yoti-Auth-Id": {"testSdkId"}}, + }, + { + name: "empty client SDK ID", + clientSdkId: "", + expected: map[string][]string{"X-Yoti-Auth-Id": {""}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + got := AuthHeader(tt.clientSdkId, &privateKey.PublicKey) + + for key, value := range tt.expected { + if gotValue, exists := got[key]; !exists || len(gotValue) != 1 || gotValue[0] != value[0] { + t.Errorf("AuthHeader() = %v, want %v", got, tt.expected) + } + } + }) + } +} From a0deee2e9812d5aa2a52aeccb7bf8a9f6e852bfa Mon Sep 17 00:00:00 2001 From: mehmet-yoti Date: Tue, 9 Jan 2024 13:28:22 +0300 Subject: [PATCH 09/11] SDK-2422 removed unused function and its test --- digitalidentity/service.go | 2 +- requests/signed_message.go | 7 ------- requests/signed_message_test.go | 32 -------------------------------- 3 files changed, 1 insertion(+), 40 deletions(-) diff --git a/digitalidentity/service.go b/digitalidentity/service.go index 7215dcb1..243c06e3 100644 --- a/digitalidentity/service.go +++ b/digitalidentity/service.go @@ -20,7 +20,7 @@ const identitySessionQrCodeCreation = "/v2/sessions/%s/qr-codes" const identitySessionQrCodeRetrieval = "/v2/qr-codes/%s" const identitySessionReceiptRetrieval = "/v2/receipts/%s" const identitySessionReceiptKeyRetrieval = "/v2/wrapped-item-keys/%s" -const errorFailedToGetSignedReceipt = "failed to get signed request: %v" +const errorFailedToGetSignedRequest = "failed to get signed request: %v" const errorFailedToExecuteRequest = "failed to execute request: %v" const errorFailedToReadBody = "failed to read response body: %v" diff --git a/requests/signed_message.go b/requests/signed_message.go index 8a1715f2..e90fc54f 100644 --- a/requests/signed_message.go +++ b/requests/signed_message.go @@ -41,13 +41,6 @@ func JSONHeaders() map[string][]string { } } -// AuthHeader is a header prototype including the App/SDK ID -func AuthHeader(clientSdkId string, key *rsa.PublicKey) map[string][]string { - return map[string][]string{ - "X-Yoti-Auth-Id": {clientSdkId}, - } -} - // AuthKeyHeader is a header prototype including an encoded RSA PublicKey func AuthKeyHeader(key *rsa.PublicKey) map[string][]string { return map[string][]string{ diff --git a/requests/signed_message_test.go b/requests/signed_message_test.go index f23dae2a..9fbc0e23 100644 --- a/requests/signed_message_test.go +++ b/requests/signed_message_test.go @@ -167,35 +167,3 @@ func TestSignedRequest_WithPemFile_NotRSAKeyShouldError(t *testing.T) { -----END RSA PUBLIC KEY-----`)) assert.ErrorContains(t, msg.Error, "not an RSA Private Key") } - -// TestAuthHeader tests the AuthHeader function. -func TestAuthHeader(t *testing.T) { - tests := []struct { - name string - clientSdkId string - expected map[string][]string - }{ - { - name: "valid client SDK ID", - clientSdkId: "testSdkId", - expected: map[string][]string{"X-Yoti-Auth-Id": {"testSdkId"}}, - }, - { - name: "empty client SDK ID", - clientSdkId: "", - expected: map[string][]string{"X-Yoti-Auth-Id": {""}}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - got := AuthHeader(tt.clientSdkId, &privateKey.PublicKey) - - for key, value := range tt.expected { - if gotValue, exists := got[key]; !exists || len(gotValue) != 1 || gotValue[0] != value[0] { - t.Errorf("AuthHeader() = %v, want %v", got, tt.expected) - } - } - }) - } -} From f3a1654e5dfb8d616bbb52759575696db8f1ea07 Mon Sep 17 00:00:00 2001 From: mehmet-yoti Date: Tue, 9 Jan 2024 13:31:01 +0300 Subject: [PATCH 10/11] SDK-2422 removed unused function and its test --- digitalidentity/service.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/digitalidentity/service.go b/digitalidentity/service.go index 243c06e3..3cac3933 100644 --- a/digitalidentity/service.go +++ b/digitalidentity/service.go @@ -43,7 +43,7 @@ func CreateShareSession(httpClient requests.HttpClient, shareSessionRequest *Sha Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return nil, fmt.Errorf(errorFailedToGetSignedReceipt, err) + return nil, fmt.Errorf(errorFailedToGetSignedRequest, err) } response, err := requests.Execute(httpClient, request) @@ -75,7 +75,7 @@ func GetShareSession(httpClient requests.HttpClient, sessionID string, clientSdk Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return nil, fmt.Errorf(errorFailedToGetSignedReceipt, err) + return nil, fmt.Errorf(errorFailedToGetSignedRequest, err) } response, err := requests.Execute(httpClient, request) @@ -107,7 +107,7 @@ func CreateShareQrCode(httpClient requests.HttpClient, sessionID string, clientS Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return nil, fmt.Errorf(errorFailedToGetSignedReceipt, err) + return nil, fmt.Errorf(errorFailedToGetSignedRequest, err) } response, err := requests.Execute(httpClient, request) @@ -137,7 +137,7 @@ func GetShareSessionQrCode(httpClient requests.HttpClient, qrCodeId string, clie Headers: headers, }.Request() if err != nil { - return fetchedQrCode, fmt.Errorf(errorFailedToGetSignedReceipt, err) + return fetchedQrCode, fmt.Errorf(errorFailedToGetSignedRequest, err) } response, err := requests.Execute(httpClient, request) @@ -170,7 +170,7 @@ func getReceipt(httpClient requests.HttpClient, receiptId string, clientSdkId, a Headers: headers, }.Request() if err != nil { - return receipt, fmt.Errorf(errorFailedToGetSignedReceipt, err) + return receipt, fmt.Errorf(errorFailedToGetSignedRequest, err) } response, err := requests.Execute(httpClient, request) @@ -201,7 +201,7 @@ func getReceiptItemKey(httpClient requests.HttpClient, receiptItemKeyId string, Headers: headers, }.Request() if err != nil { - return receiptItemKey, fmt.Errorf(errorFailedToGetSignedReceipt, err) + return receiptItemKey, fmt.Errorf(errorFailedToGetSignedRequest, err) } response, err := requests.Execute(httpClient, request) From 709b992dd876ba86ea6fd2669fed8a32e5264959 Mon Sep 17 00:00:00 2001 From: Mehmet Ali Sepici Date: Tue, 27 Feb 2024 02:18:02 +0300 Subject: [PATCH 11/11] SDK-2422 resolved selfie image problem --- _examples/digitalidentity/receipt.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/_examples/digitalidentity/receipt.go b/_examples/digitalidentity/receipt.go index 63fb4e6e..77063760 100644 --- a/_examples/digitalidentity/receipt.go +++ b/_examples/digitalidentity/receipt.go @@ -30,13 +30,10 @@ func receipt(w http.ResponseWriter, r *http.Request) { userProfile := receiptValue.UserContent.UserProfile selfie := userProfile.Selfie() + var base64URL string if selfie != nil { base64URL = selfie.Value().Base64URL() - - decodedImage := decodeImage(selfie.Value().Data()) - file := createImage() - saveImage(decodedImage, file) } dob, err := userProfile.DateOfBirth()