diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index cce3710df..98f0c89dd 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 # # diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 22e447a5f..658d36306 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 519f0d3ad..a1fb98b70 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,12 @@ debug # Report files sonar-report.json coverage.out +report.json # idea files .idea -# Generated binaries -/_examples/docscan/docscan +# DS_Store files +.DS_Store + + diff --git a/README.md b/README.md index c188abff3..eb8392dde 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 @@ -59,7 +59,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 diff --git a/_examples/.gitignore b/_examples/.gitignore index 4c49bd78f..041d99030 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 16e5ef7ad..099ca7819 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 d263aee9c..ab8c9f29b 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 000000000..dc09949e2 --- /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 000000000..1418f3360 --- /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 000000000..703e89efc --- /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/advanced_identity_profile.go b/_examples/digitalidentity/advanced_identity_profile.go new file mode 100644 index 000000000..86152404b --- /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/certificatehelper.go b/_examples/digitalidentity/certificatehelper.go new file mode 100644 index 000000000..cdcb4f23a --- /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 000000000..71739df2d --- /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 000000000..73d7e730b --- /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 000000000..7425a0d47 --- /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 000000000..25b7d7dcf --- /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 000000000..c866a122a --- /dev/null +++ b/_examples/digitalidentity/login.html @@ -0,0 +1,87 @@ + + + + + + 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 000000000..1a1d9cc14 --- /dev/null +++ b/_examples/digitalidentity/main.go @@ -0,0 +1,146 @@ +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, "Client could't be generated: %v", err) + 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 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/generate-advanced-identity-share", generateAdvancedIdentitySession) + http.HandleFunc("/v2/receipt-info", receipt) + + 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/receipt.go b/_examples/digitalidentity/receipt.go new file mode 100644 index 000000000..770637607 --- /dev/null +++ b/_examples/digitalidentity/receipt.go @@ -0,0 +1,133 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "html/template" + "image" + "image/jpeg" + "io" + "net/http" + "os" +) + +func receipt(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 + } + + userProfile := receiptValue.UserContent.UserProfile + + selfie := userProfile.Selfie() + + var base64URL string + if selfie != nil { + base64URL = selfie.Value().Base64URL() + } + + dob, err := userProfile.DateOfBirth() + if err != nil { + errorPage(w, r.WithContext(context.WithValue( + r.Context(), + contextKey("yotiError"), + fmt.Sprintf("Error parsing Date of Birth attribute. Error %q", err.Error()), + ))) + return + } + + var dateOfBirthString string + if dob != nil { + dateOfBirthString = dob.Value().String() + } + + templateVars := map[string]interface{}{ + "profile": userProfile, + "selfieBase64URL": template.URL(base64URL), + "rememberMeID": receiptValue.RememberMeID, + "dateOfBirth": dateOfBirthString, + } + + var t *template.Template + t, err = template.New("receipt.html"). + Funcs(template.FuncMap{ + "escapeURL": func(s string) template.URL { + return template.URL(s) + }, + "marshalAttribute": func(name string, icon string, property interface{}, prevalue string) interface{} { + return struct { + Name string + Icon string + Prop interface{} + Prevalue string + }{ + name, + icon, + property, + prevalue, + } + }, + "jsonMarshalIndent": func(data interface{}) string { + json, err := json.MarshalIndent(data, "", "\t") + if err != nil { + fmt.Println(err) + } + return string(json) + }, + }). + ParseFiles("receipt.html") + if err != nil { + fmt.Println(err) + return + } + + err = t.Execute(w, templateVars) + + if err != nil { + errorPage(w, r.WithContext(context.WithValue( + r.Context(), + contextKey("yotiError"), + fmt.Sprintf("Error applying the parsed profile template. Error: `%s`", err), + ))) + return + } +} +func decodeImage(imageBytes []byte) image.Image { + decodedImage, _, err := image.Decode(bytes.NewReader(imageBytes)) + + if err != nil { + panic("Error when decoding the image: " + err.Error()) + } + + return decodedImage +} + +func createImage() (file *os.File) { + file, err := os.Create("./images/YotiSelfie.jpeg") + + if err != nil { + panic("Error when creating the image: " + err.Error()) + } + return +} + +func saveImage(img image.Image, file io.Writer) { + var opt jpeg.Options + opt.Quality = 100 + + err := jpeg.Encode(file, img, &opt) + + if err != nil { + panic("Error when saving the image: " + err.Error()) + } +} diff --git a/_examples/digitalidentity/receipt.html b/_examples/digitalidentity/receipt.html new file mode 100644 index 000000000..21ca7d21d --- /dev/null +++ b/_examples/digitalidentity/receipt.html @@ -0,0 +1,151 @@ +{{ define "attribute" }} + {{ if .Prop }} +
+
+
+ + {{ .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/digitalidentity/static/assets/app-store-badge.png b/_examples/digitalidentity/static/assets/app-store-badge.png new file mode 100755 index 000000000..3ec996cc6 Binary files /dev/null and b/_examples/digitalidentity/static/assets/app-store-badge.png differ 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 000000000..84b34068f Binary files /dev/null and b/_examples/digitalidentity/static/assets/app-store-badge@2x.png differ diff --git a/_examples/digitalidentity/static/assets/company-logo.jpg b/_examples/digitalidentity/static/assets/company-logo.jpg new file mode 100644 index 000000000..551474bfe Binary files /dev/null and b/_examples/digitalidentity/static/assets/company-logo.jpg differ diff --git a/_examples/digitalidentity/static/assets/google-play-badge.png b/_examples/digitalidentity/static/assets/google-play-badge.png new file mode 100755 index 000000000..761f237b1 Binary files /dev/null and b/_examples/digitalidentity/static/assets/google-play-badge.png differ diff --git a/_examples/digitalidentity/static/assets/google-play-badge@2x.png b/_examples/digitalidentity/static/assets/google-play-badge@2x.png new file mode 100755 index 000000000..46707cea8 Binary files /dev/null and b/_examples/digitalidentity/static/assets/google-play-badge@2x.png differ diff --git a/_examples/digitalidentity/static/assets/icons/address.svg b/_examples/digitalidentity/static/assets/icons/address.svg new file mode 100755 index 000000000..533152b76 --- /dev/null +++ b/_examples/digitalidentity/static/assets/icons/address.svg @@ -0,0 +1,4 @@ + + + diff --git a/_examples/digitalidentity/static/assets/icons/calendar.svg b/_examples/digitalidentity/static/assets/icons/calendar.svg new file mode 100755 index 000000000..71ce63714 --- /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 000000000..89f55a6fb --- /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 000000000..10fc1de31 --- /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 000000000..67880ef32 --- /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 000000000..94a0ed909 --- /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 000000000..40cbf76d0 --- /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 000000000..adbaad999 --- /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 000000000..62278ecee --- /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 000000000..f6e1d94c8 --- /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 000000000..c60227fab Binary files /dev/null and b/_examples/digitalidentity/static/assets/logo.png differ diff --git a/_examples/digitalidentity/static/assets/logo@2x.png b/_examples/digitalidentity/static/assets/logo@2x.png new file mode 100755 index 000000000..9f29784d1 Binary files /dev/null and b/_examples/digitalidentity/static/assets/logo@2x.png differ diff --git a/_examples/digitalidentity/static/index.css b/_examples/digitalidentity/static/index.css new file mode 100644 index 000000000..14a2bc8ca --- /dev/null +++ b/_examples/digitalidentity/static/index.css @@ -0,0 +1,173 @@ +.yoti-body { + margin: 0; +} + +.yoti-top-section { + display: flex; + flex-direction: column; + + padding: 38px 0; + + background-color: #f7f8f9; + + align-items: center; +} + +.yoti-logo-section { + margin-bottom: 25px; +} + +.yoti-logo-image { + display: block; +} + +.yoti-top-header { + font-family: Roboto, sans-serif; + font-size: 40px; + font-weight: 700; + line-height: 1.2; + margin-top: 0; + margin-bottom: 80px; + text-align: center; + + color: #000; +} + +@media (min-width: 600px) { + .yoti-top-header { + line-height: 1.4; + } +} + +.yoti-sdk-integration-section { + margin: 30px 0; +} + +#yoti-share-button { + width: 250px; + height: 45px; +} + +.yoti-login-or-separator { + text-transform: uppercase; + font-family: Roboto; + font-size: 16px; + font-weight: bold; + line-height: 1.5; + text-align: center; + margin-top: 30px; +} + +.yoti-login-dialog { + display: grid; + + box-sizing: border-box; + width: 100%; + padding: 35px 38px; + + border-radius: 5px; + background: #fff; + + grid-gap: 25px; +} + +@media (min-width: 600px) { + .yoti-login-dialog { + width: 560px; + padding: 35px 88px; + } +} + +.yoti-login-dialog-header { + font-family: Roboto, sans-serif; + font-size: 24px; + font-weight: 700; + line-height: 1.1; + + margin: 0; + + color: #000; +} + +.yoti-input { + font-family: Roboto, sans-serif; + font-size: 16px; + line-height: 1.5; + + box-sizing: border-box; + padding: 12px 15px; + + color: #000; + border: solid 2px #000; + border-radius: 4px; + background-color: #fff; +} + +.yoti-login-actions { + display: flex; + + justify-content: space-between; + align-items: center; +} + +.yoti-login-forgot-button { + font-family: Roboto, sans-serif; + font-size: 16px; + + text-transform: capitalize; +} + +.yoti-login-button { + font-family: Roboto, sans-serif; + font-size: 16px; + + box-sizing: border-box; + width: 145px; + height: 50px; + + text-transform: uppercase; + + color: #fff; + border: 0; + background-color: #000; +} + +.yoti-sponsor-app-section { + display: flex; + flex-direction: column; + + padding: 70px 0; + + align-items: center; +} + +.yoti-sponsor-app-header { + font-family: Roboto, sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 1.2; + + margin: 0; + + text-align: center; + + color: #000; +} + +.yoti-store-buttons-section { + margin-top: 40px; + display: grid; + grid-gap: 10px; + grid-template-columns: 1fr; +} + +@media (min-width: 600px) { + .yoti-store-buttons-section { + grid-template-columns: 1fr 1fr; + grid-gap: 25px; + } +} + +.yoti-app-button-link { + text-decoration: none; +} \ No newline at end of file diff --git a/_examples/digitalidentity/static/profile.css b/_examples/digitalidentity/static/profile.css new file mode 100644 index 000000000..ff5579cdb --- /dev/null +++ b/_examples/digitalidentity/static/profile.css @@ -0,0 +1,431 @@ +.yoti-html { + height: 100%; +} + +.yoti-body { + margin: 0; + height: 100%; +} + +.yoti-icon-profile, +.yoti-icon-phone, +.yoti-icon-email, +.yoti-icon-calendar, +.yoti-icon-verified, +.yoti-icon-address, +.yoti-icon-gender, +.yoti-icon-nationality { + display: inline-block; + height: 28px; + width: 28px; + flex-shrink: 0; +} + +.yoti-icon-profile { + background: no-repeat url('/static/assets/icons/profile.svg'); +} + +.yoti-icon-phone { + background: no-repeat url('/static/assets/icons/phone.svg'); +} + +.yoti-icon-email { + background: no-repeat url('/static/assets/icons/email.svg'); +} + +.yoti-icon-calendar { + background: no-repeat url('/static/assets/icons/calendar.svg'); +} + +.yoti-icon-verified { + background: no-repeat url('/static/assets/icons/verified.svg'); +} + +.yoti-icon-address { + background: no-repeat url('/static/assets/icons/address.svg'); +} + +.yoti-icon-gender { + background: no-repeat url('/static/assets/icons/gender.svg'); +} + +.yoti-icon-nationality { + background: no-repeat url('/static/assets/icons/nationality.svg'); +} + +.yoti-profile-layout { + display: grid; + grid-template-columns: 1fr; +} + +@media (min-width: 1100px) { + .yoti-profile-layout { + grid-template-columns: 360px 1fr; + height: 100%; + } +} + +.yoti-profile-user-section { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: column; + padding: 40px 0; + background-color: #f7f8f9; +} + +@media (min-width: 1100px) { + .yoti-profile-user-section { + display: grid; + grid-template-rows: repeat(3, min-content); + align-items: center; + justify-content: center; + position: relative; + } +} + +.yoti-profile-picture-image { + width: 220px; + height: 220px; + border-radius: 50%; + margin-left: auto; + margin-right: auto; + display: block; +} + +.yoti-profile-picture-powered, +.yoti-profile-picture-account-creation { + font-family: Roboto; + font-size: 14px; + color: #b6bfcb; +} + +.yoti-profile-picture-powered-section { + display: flex; + flex-direction: column; + text-align: center; + align-items: center; +} + +@media (min-width: 1100px) { + .yoti-profile-picture-powered-section { + align-self: start; + } +} + +.yoti-profile-picture-powered { + margin-bottom: 20px; +} + +.yoti-profile-picture-section { + display: flex; + flex-direction: column; + align-items: center; +} + +@media (min-width: 1100px) { + .yoti-profile-picture-section { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 100%; + } +} + +.yoti-logo-image { + margin-bottom: 25px; +} + +.yoti-profile-picture-area { + position: relative; + display: inline-block; +} + +.yoti-profile-picture-verified-icon { + display: block; + background: no-repeat url("/static/assets/icons/verified.svg"); + background-size: cover; + height: 40px; + width: 40px; + position: absolute; + top: 10px; + right: 10px; +} + +.yoti-profile-name { + margin-top: 20px; + font-family: Roboto, sans-serif; + font-size: 24px; + text-align: center; + color: #333b40; +} + +.yoti-attributes-section { + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + + width: 100%; + padding: 40px 0; +} + +.yoti-attributes-section.-condensed { + padding: 0; +} + +@media (min-width: 1100px) { + .yoti-attributes-section { + padding: 60px 0; + align-items: start; + overflow-y: scroll; + } + + .yoti-attributes-section.-condensed { + padding: 0; + } +} + +.yoti-company-logo { + margin-bottom: 40px; +} + +@media (min-width: 1100px) { + .yoti-company-logo { + margin-left: 130px; + } +} + +/* extended layout list */ +.yoti-attribute-list-header, +.yoti-attribute-list-subheader { + display: none; +} + +@media (min-width: 1100px) { + .yoti-attribute-list-header, + .yoti-attribute-list-subheader { + width: 100%; + + display: grid; + grid-template-columns: 200px 1fr 1fr; + grid-template-rows: 40px; + + align-items: center; + text-align: center; + + font-family: Roboto; + font-size: 14px; + color: #b6bfcb; + } +} + +.yoti-attribute-list-header-attribute, +.yoti-attribute-list-header-value { + justify-self: start; + padding: 0 20px; +} + +.yoti-attribute-list-subheader { + grid-template-rows: 30px; +} + +.yoti-attribute-list-subhead-layout { + grid-column: 3; + display: grid; + grid-template-columns: 1fr 1fr 1fr; +} + +.yoti-attribute-list { + display: grid; + width: 100%; +} + +.yoti-attribute-list-item:first-child { + border-top: 2px solid #f7f8f9; +} + +.yoti-attribute-list-item { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: minmax(60px, auto); + border-bottom: 2px solid #f7f8f9; + border-right: none; + border-left: none; +} + +.yoti-attribute-list-item.-condensed { + grid-template-columns: 50% 50%; + padding: 5px 35px; +} + +@media (min-width: 1100px) { + .yoti-attribute-list-item { + display: grid; + grid-template-columns: 200px 1fr 1fr; + grid-template-rows: minmax(80px, auto); + } + + .yoti-attribute-list-item.-condensed { + grid-template-columns: 200px 1fr; + padding: 0 75px; + } +} + +.yoti-attribute-cell { + display: flex; + align-items: center; +} + +.yoti-attribute-name { + grid-column: 1 / 2; + + display: flex; + align-items: center; + justify-content: center; + + border-right: 2px solid #f7f8f9; + + padding: 20px; +} + +@media (min-width: 1100px) { + .yoti-attribute-name { + justify-content: start; + } +} + +.yoti-attribute-name.-condensed { + justify-content: start; +} + +.yoti-attribute-name-cell { + display: flex; + align-items: center; +} + +.yoti-attribute-name-cell-text { + font-family: Roboto, sans-serif; + font-size: 16px; + color: #b6bfcb; + margin-left: 12px; +} + +.yoti-attribute-value { + grid-column: 2 / 3; + + display: flex; + align-items: center; + justify-content: center; + + padding: 20px; +} + +@media (min-width: 1100px) { + .yoti-attribute-value { + justify-content: start; + } +} + +.yoti-attribute-value.-condensed { + justify-content: start; +} + +.yoti-attribute-value-text { + font-family: Roboto, sans-serif; + font-size: 18px; + color: #333b40; + word-break: break-word; +} + +.yoti-attribute-value-text table { + font-size: 14px; + border-spacing: 0; +} + +.yoti-attribute-value-text table td:first-child { + font-weight: bold; +} + +.yoti-attribute-value-text table td { + border-bottom: 1px solid #f7f8f9; + padding: 5px; +} + +.yoti-attribute-value-text img { + width: 100%; +} + +.yoti-attribute-anchors-layout { + grid-column: 1 / 3; + grid-row: 2 / 2; + + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-auto-rows: minmax(40px, auto); + font-family: Roboto, sans-serif; + font-size: 14px; + + background-color: #f7f8f9; + border: 5px solid white; +} + +@media (min-width: 1100px) { + .yoti-attribute-anchors-layout { + grid-column: 3 / 4; + grid-row: 1 / 2; + } +} + +.yoti-attribute-anchors-head { + border-bottom: 1px solid #dde2e5; + display: flex; + align-items: center; + justify-content: center; +} + +@media (min-width: 1100px) { + .yoti-attribute-anchors-head { + display: none; + } +} + +.yoti-attribute-anchors { + display: flex; + align-items: center; + justify-content: center; +} + +.yoti-attribute-anchors-head.-s-v { + grid-column-start: span 1 s-v; +} + +.yoti-attribute-anchors-head.-value { + grid-column-start: span 1 value; +} + +.yoti-attribute-anchors-head.-subtype { + grid-column-start: span 1 subtype; +} + +.yoti-attribute-anchors.-s-v { + grid-column-start: span 1 s-v; +} + +.yoti-attribute-anchors.-value { + grid-column-start: span 1 value; +} + +.yoti-attribute-anchors.-subtype { + grid-column-start: span 1 subtype; +} + +.yoti-edit-section { + padding: 50px 20px; +} + +@media (min-width: 1100px) { + .yoti-edit-section { + padding: 75px 110px; + } +} diff --git a/_examples/docscansandbox/demo_test.go b/_examples/docscansandbox/demo_test.go index b630c7600..3e58d8d29 100644 --- a/_examples/docscansandbox/demo_test.go +++ b/_examples/docscansandbox/demo_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" "net/url" "os" "strings" @@ -61,7 +60,7 @@ func startWebDriver() selenium.WebDriver { } func newSandboxClient() (*sandbox.Client, error) { - key, err := ioutil.ReadFile(os.Getenv("YOTI_KEY_FILE_PATH")) + key, err := os.ReadFile(os.Getenv("YOTI_KEY_FILE_PATH")) if err != nil { return nil, err } diff --git a/_examples/docscansandbox/go.mod b/_examples/docscansandbox/go.mod index 7e73a8ffb..4a0f7ccde 100644 --- a/_examples/docscansandbox/go.mod +++ b/_examples/docscansandbox/go.mod @@ -1,6 +1,6 @@ module docscansandbox -go 1.17 +go 1.19 require ( github.com/cucumber/godog v0.10.0 @@ -21,6 +21,7 @@ require ( github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.6.1 // indirect + google.golang.org/protobuf v1.28.0 // indirect gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) diff --git a/_examples/docscansandbox/go.sum b/_examples/docscansandbox/go.sum index e9da1058e..8385c5b09 100644 --- a/_examples/docscansandbox/go.sum +++ b/_examples/docscansandbox/go.sum @@ -35,11 +35,13 @@ github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 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/google/go-github/v27 v27.0.4/go.mod h1:/0Gr8pJ55COkmv+S/yPKCczSkUPIM/LnFyubufRNIS0= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -136,6 +138,8 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190624190245-7f2218787638/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +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/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -151,7 +155,9 @@ google.golang.org/genproto v0.0.0-20190626174449-989357319d63/go.mod h1:z3L6/3dT google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= diff --git a/_examples/idv/go.mod b/_examples/idv/go.mod index 3c3ed9b52..2dd045e9d 100644 --- a/_examples/idv/go.mod +++ b/_examples/idv/go.mod @@ -1,6 +1,6 @@ module idv -go 1.17 +go 1.19 require ( github.com/getyoti/yoti-go-sdk/v3 v3.0.0 diff --git a/_examples/idv/handlers.session.go b/_examples/idv/handlers.session.go index 2abe7d270..804c673da 100644 --- a/_examples/idv/handlers.session.go +++ b/_examples/idv/handlers.session.go @@ -3,7 +3,6 @@ package main import ( "errors" "fmt" - "io/ioutil" "net/http" "os" @@ -171,7 +170,7 @@ func initialiseDocScanClient() error { var err error sdkID = os.Getenv("YOTI_CLIENT_SDK_ID") keyFilePath := os.Getenv("YOTI_KEY_FILE_PATH") - key, err = ioutil.ReadFile(keyFilePath) + key, err = os.ReadFile(keyFilePath) if err != nil { return fmt.Errorf("failed to get key from YOTI_KEY_FILE_PATH :: %w", err) } diff --git a/_examples/idv/main.go b/_examples/idv/main.go index 3c2d254a6..937fafb3c 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/README.md b/_examples/profile/README.md index c2bd667c3..400110a87 100644 --- a/_examples/profile/README.md +++ b/_examples/profile/README.md @@ -12,7 +12,7 @@ The YotiClient is the SDK entry point. To initialise it you need include the fol ```Go clientSdkID := "your-client-sdk-id" -key, err := ioutil.ReadFile("path/to/your-application-pem-file.pem") +key, err := os.ReadFile("path/to/your-application-pem-file.pem") if err != nil { // handle key load error } diff --git a/_examples/profile/dynamic-share.html b/_examples/profile/dynamic-share.html index ca3dad90a..cf05ca393 100644 --- a/_examples/profile/dynamic-share.html +++ b/_examples/profile/dynamic-share.html @@ -70,7 +70,7 @@

The 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 a136238ad..a80367a6f 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 d25fc9599..dc3aef899 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 9492d49c5..113bd3272 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 { @@ -104,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 8a54cf2fd..21ca7d21d 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/_examples/profilesandbox/go.mod b/_examples/profilesandbox/go.mod index d98f6bc51..377a5a059 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 962d911df..686052460 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 05dd51ec0..d8ef5b038 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 ca839dcaa..44ba57d57 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/consts/version.go b/consts/version.go index 553971b56..50085a5e4 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/cryptoutil/crypto_utils.go b/cryptoutil/crypto_utils.go index fbfacf268..de4d0f94b 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 4b425feea..864e2f3a0 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 000000000..b074712eb --- /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 000000000..3c85d93b3 --- /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 000000000..17bfb51e5 --- /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 000000000..8fae7bdaa --- /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 000000000..a7655d06a --- /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 000000000..b3a6e0863 --- /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 000000000..d1476c4f5 --- /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 000000000..13849a3d6 --- /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 000000000..839a6e116 --- /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 000000000..ed5287ed3 --- /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 000000000..2081b7d6c --- /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 000000000..a380150b7 --- /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 000000000..67b6c2b2e --- /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 000000000..cdc55ce3e --- /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 000000000..24807c93b --- /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 000000000..b0d4b8a4e --- /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 000000000..b209e023a --- /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 000000000..a18ccabad --- /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 000000000..bf2e7dee9 --- /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 000000000..c729e30bc --- /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 000000000..e2daae8a9 --- /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 000000000..47c28eae9 --- /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 000000000..fd9d7f144 --- /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 000000000..2fe620f6c --- /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 000000000..de507ab6a --- /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 000000000..2c3009260 --- /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 000000000..381d4e99e --- /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 000000000..462d863ef --- /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 000000000..3efd2b966 --- /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 000000000..be40920df --- /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 000000000..427563735 --- /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 000000000..926141f97 --- /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 000000000..15a24f998 --- /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 000000000..d6635957e --- /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 000000000..cc9f3d8b3 --- /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 000000000..73346b9af --- /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 000000000..828df2016 --- /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 000000000..693441d0a --- /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 000000000..4dec515ec --- /dev/null +++ b/digitalidentity/policy_builder.go @@ -0,0 +1,261 @@ +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 + advancedIdentityProfileRequirements *json.RawMessage +} + +// Policy represents a dynamic policy for a share +type Policy struct { + attributes []WantedAttribute + authTypes []int + rememberMeID bool + identityProfileRequirements *json.RawMessage + advancedIdentityProfileRequirements *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 +} + +// 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, + advancedIdentityProfileRequirements: b.advancedIdentityProfileRequirements, + }, 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"` + AdvancedIdentityProfileRequirements *json.RawMessage `json:"advanced_identity_profile_requirements,omitempty"` + }{ + 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 new file mode 100644 index 000000000..a7ae6e1ad --- /dev/null +++ b/digitalidentity/policy_builder_test.go @@ -0,0 +1,508 @@ +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)) + } +} + +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/digitalidentity/qr_code.go b/digitalidentity/qr_code.go new file mode 100644 index 000000000..bff20b64c --- /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 000000000..8c9b9aa45 --- /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 000000000..7e805d876 --- /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 000000000..74c289e89 --- /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 000000000..e5bbeabaa --- /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 000000000..420fa6b25 --- /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 000000000..02e2116f9 --- /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 000000000..9fbc0e234 --- /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.12.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 000000000..3cac39334 --- /dev/null +++ b/digitalidentity/service.go @@ -0,0 +1,306 @@ +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" +const errorFailedToGetSignedRequest = "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) { + 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(errorFailedToGetSignedRequest, err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + 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(errorFailedToReadBody, 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(errorFailedToGetSignedRequest, err) + } + + response, err := requests.Execute(httpClient, request) + + if err != nil { + 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(errorFailedToReadBody, 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(errorFailedToGetSignedRequest, err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + 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(errorFailedToReadBody, 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(errorFailedToGetSignedRequest, err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return fetchedQrCode, fmt.Errorf(errorFailedToExecuteRequest, err) + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return fetchedQrCode, fmt.Errorf(errorFailedToReadBody, 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(errorFailedToGetSignedRequest, err) + } + + response, err := requests.Execute(httpClient, request) + if err != nil { + return receipt, fmt.Errorf(errorFailedToExecuteRequest, err) + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return receipt, fmt.Errorf(errorFailedToReadBody, 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(errorFailedToGetSignedRequest, 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 000000000..dbf5e133b --- /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 000000000..70c2d4cf3 --- /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 000000000..fe737d9b9 --- /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 000000000..e27b99fce --- /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 000000000..7f644a2cb --- /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 000000000..b0101e20a --- /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 000000000..86d60e3fc --- /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 000000000..6c873ebc6 --- /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 000000000..a60e301e3 --- /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 000000000..5f98caa1c --- /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 000000000..079007f59 --- /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 000000000..32a4d282a --- /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 000000000..b81b78ee6 --- /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 000000000..855c9c3ae --- /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 000000000..907d8e08d --- /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 000000000..d004b96c9 --- /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 000000000..0a990d1ef --- /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 000000000..3274e80f6 --- /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 000000000..be2225caf --- /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 000000000..3f91d3814 --- /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 b0cf5ed3e..c4aa82dce 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 a52f82d26..5f1587112 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 607558acb..a4876c276 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 a97ffd18c..e7f893087 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 a902cd166..7402e6758 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 58db44fed..ed44cdb79 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 8e9263602..9057bc18f 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 e9fc43a67..a002b96c4 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 ca66f5fb8..c1c87abbf 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 ce6b71cef..9cbde3b61 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 d4bdb9ebd..05070833e 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 60aa07d4c..3f9ae500b 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 e9040bd3d..cc07c0f6c 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 f390a3953..e90fc54f1 100644 --- a/requests/signed_message.go +++ b/requests/signed_message.go @@ -185,6 +185,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 +200,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 +210,6 @@ func (msg SignedRequest) Request() (request *http.Request, err error) { request.Header.Add(key, value) } } + return request, err } diff --git a/requests/signed_message_test.go b/requests/signed_message_test.go index e4cbff589..9fbc0e234 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) { diff --git a/sh/go-build-modtidy.sh b/sh/go-build-modtidy.sh index 46b63f5ad..bef8763e6 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 961c49c50..a64c85b4f 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 @@ -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/fixtures/AdvancedIdentityProfileReport.json b/test/fixtures/AdvancedIdentityProfileReport.json deleted file mode 100644 index 160eeacc8..000000000 --- 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