diff --git a/client_test.go b/client_test.go index 73a1944f2..44ba57d57 100644 --- a/client_test.go +++ b/client_test.go @@ -26,10 +26,10 @@ func (mock *mockHTTPClient) Do(request *http.Request) (*http.Response, error) { } func TestNewClient(t *testing.T) { - key, readErr := os.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) } @@ -48,11 +48,11 @@ func TestNewClient_KeyLoad_Failure(t *testing.T) { } func TestYotiClient_PerformAmlCheck(t *testing.T) { - key, readErr := os.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) { @@ -78,11 +78,11 @@ func TestYotiClient_PerformAmlCheck(t *testing.T) { } func TestYotiClient_CreateShareURL(t *testing.T) { - key, readErr := os.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) { @@ -93,11 +93,11 @@ func TestYotiClient_CreateShareURL(t *testing.T) { }, } - 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/digital_identity_client.go b/digital_identity_client.go index 82b025261..a56110e91 100644 --- a/digital_identity_client.go +++ b/digital_identity_client.go @@ -9,7 +9,7 @@ import ( "github.com/getyoti/yoti-go-sdk/v3/requests" ) -const DefaultURL = "https://api.yoti.com/api/" +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 { @@ -27,14 +27,14 @@ type DigitalIdentityClient struct { } // NewDigitalIdentityClient constructs a Client object -func NewDigitalIdentityClient(sdkID string, key []byte) (*Client, error) { +func NewDigitalIdentityClient(sdkID string, key []byte) (*DigitalIdentityClient, error) { decodedKey, err := cryptoutil.ParseRSAKey(key) if err != nil { return nil, err } - return &Client{ + return &DigitalIdentityClient{ SdkID: sdkID, Key: decodedKey, }, err @@ -54,7 +54,7 @@ func (client *DigitalIdentityClient) getAPIURL() string { return value } - return apiDefaultURL + return DefaultURL } // GetSdkID gets the Client SDK ID attached to this client instance @@ -62,7 +62,12 @@ func (client *DigitalIdentityClient) GetSdkID() string { return client.SdkID } -// CreateShareURL creates a QR code for a specified share session configuration. -func (client *DigitalIdentityClient) CreateShareURL(shareSession *digitalidentity.ShareSession) (share digitalidentity.ShareURL, err error) { - return digitalidentity.CreateShareSession(client.HTTPClient, shareSession, client.GetSdkID(), client.getAPIURL(), client.Key) +// 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) } diff --git a/digital_identity_client_test.go b/digital_identity_client_test.go index 85b351bea..3c85d93b3 100644 --- a/digital_identity_client_test.go +++ b/digital_identity_client_test.go @@ -1,21 +1,23 @@ package yoti import ( + "crypto/rsa" "io" "net/http" "os" "strings" "testing" - "github.com/getyoti/yoti-go-sdk/v3/dynamic" + "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, readErr := os.ReadFile("./test/test-key.pem") - assert.NilError(t, readErr) + key, err := os.ReadFile("./test/test-key.pem") + assert.NilError(t, err) - _, err := NewDigitalIdentityClient("some-sdk-id", key) + _, err = NewDigitalIdentityClient("some-sdk-id", key) assert.NilError(t, err) } @@ -33,29 +35,134 @@ func TestDigitalIDClient_KeyLoad_Failure(t *testing.T) { assert.Check(t, !temporary || !tempError.Temporary()) } -func TestDigitalIDClient_CreateShareURL(t *testing.T) { - key, readErr := os.ReadFile("./test/test-key.pem") - assert.NilError(t, readErr) +func TestYotiClient_CreateShareSession(t *testing.T) { + key, err := os.ReadFile("./test/test-key.pem") + assert.NilError(t, err) - client, clientErr := NewDigitalIdentityClient("some-sdk-id", key) - assert.NilError(t, clientErr) + 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(`{"qrcode":"https://code.yoti.com/some-qr","ref_id":"0"}`)), + 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, policyErr := (&dynamic.PolicyBuilder{}).WithFullName().WithWantedRememberMe().Build() - assert.NilError(t, policyErr) + policy, err := (&digitalidentity.PolicyBuilder{}).WithFullName().WithWantedRememberMe().Build() + assert.NilError(t, err) - scenario, scenarioErr := (&dynamic.ScenarioBuilder{}).WithPolicy(policy).Build() - assert.NilError(t, scenarioErr) + session, err := (&digitalidentity.ShareSessionRequestBuilder{}).WithPolicy(policy).Build() + assert.NilError(t, err) + + result, err := client.CreateShareSession(&session) - result, err := client.CreateShareURL(&scenario) assert.NilError(t, err) - assert.Equal(t, result.ShareURL, "https://code.yoti.com/some-qr") + 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/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..02be33331 --- /dev/null +++ b/digitalidentity/requests/request.go @@ -0,0 +1,38 @@ +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..0a3d16af5 --- /dev/null +++ b/digitalidentity/requests/signed_message.go @@ -0,0 +1,222 @@ +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 +} diff --git a/digitalidentity/requests/signed_message_test.go b/digitalidentity/requests/signed_message_test.go new file mode 100644 index 000000000..301be62fd --- /dev/null +++ b/digitalidentity/requests/signed_message_test.go @@ -0,0 +1,169 @@ +package requests + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "regexp" + "testing" + + "gotest.tools/v3/assert" +) + +const exampleKey = "MIICXgIBAAKBgQCpTiICtL+ujx8D0FquVWIaXg+ajJadN5hsTlGUXymiFAunSZjLjTsoGfSPz8PJm6pG9ax1Qb+R5UsSgTRTcpZTps2RLRWr5oPfD66bz4l38QXPSvfg5o+5kNxyCb8QANitF7Ht/DcpsGpL7anruHg/RgCLCBFRaGAodfuJCCM9zwIDAQABAoGBAIJL7GbSvjZUVVU1E6TZd0+9lhqmGf/S2o5309bxSfQ/oxxSyrHU9nMNTqcjCZXuJCTKS7hOKmXY5mbOYvvZ0xA7DXfOc+A4LGXQl0r3ZMzhHZTPKboUSh16E4WI4pr98KagFdkeB/0KBURM3x5d/6dSKip8ZpEyqVpuc9d1xtvhAkEAxabfsqfb4fgBsrhZ/qt133yB0FBHs1alRxvUXZWbVPTOegKi5KBdPptf2QfCy8WK3An/lg8cFQG78PyNll/P0QJBANtJBUHTuRDCoYLhqZLdSTQ52qOWRNutZ2fho9ZcLquokB4SFFeC2I4T+s3oSJ8SNh9vW1nNeXW6Zipx+zz8O58CQQCjV9qNGf40zDITEhmFxwt967aYgpAO3O9wScaCpM4fMsWkvaMDEKiewec/RBOvNY0hdb3ctJX/olRAv2b/vCTRAkAuLmCnDlnJR9QP5kp6HZRPJWgAT6NMyGYgoIqKmHtTt3oyewhBrdLBiT+moaa5qXIwiJkqfnV377uYcMzCeTRtAkEAwHdhM3v01GprmHqE2kvlKOXNq9CB1Z4j/vXSQxBYoSrFWLv5nW9e69ngX+n7qhvO3Gs9CBoy/oqOLatFZOuFEw==" + +var keyBytes, _ = base64.StdEncoding.DecodeString(exampleKey) +var privateKey, _ = x509.ParsePKCS1PrivateKey(keyBytes) + +func ExampleMergeHeaders() { + left := map[string][]string{"A": {"Value Of A"}} + right := map[string][]string{"B": {"Value Of B"}} + + merged := MergeHeaders(left, right) + fmt.Println(merged["A"]) + fmt.Println(merged["B"]) + // Output: + // [Value Of A] + // [Value Of B] +} + +func TestMergeHeaders_HandleNullCaseGracefully(t *testing.T) { + assert.Equal(t, len(MergeHeaders()), 0) +} + +func ExampleJSONHeaders() { + jsonHeaders, err := json.Marshal(JSONHeaders()) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(jsonHeaders)) + // Output: {"Accept":["application/json"],"Content-Type":["application/json"]} +} + +func ExampleAuthKeyHeader() { + headers, err := json.Marshal(AuthKeyHeader(&privateKey.PublicKey)) + if err != nil { + fmt.Printf("error: %s", err.Error()) + return + } + + fmt.Println(string(headers)) + // Output: {"X-Yoti-Auth-Key":["MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCpTiICtL+ujx8D0FquVWIaXg+ajJadN5hsTlGUXymiFAunSZjLjTsoGfSPz8PJm6pG9ax1Qb+R5UsSgTRTcpZTps2RLRWr5oPfD66bz4l38QXPSvfg5o+5kNxyCb8QANitF7Ht/DcpsGpL7anruHg/RgCLCBFRaGAodfuJCCM9zwIDAQAB"]} +} + +func TestRequestShouldBuildForValid(t *testing.T) { + random := rand.New(rand.NewSource(25)) + key, err := rsa.GenerateKey(random, 1024) + + assert.NilError(t, err) + httpMethod := "GET" + baseURL := "example.com" + endpoint := "/" + + request := SignedRequest{ + Key: key, + HTTPMethod: httpMethod, + BaseURL: baseURL, + Endpoint: endpoint, + } + signed, err := request.Request() + assert.NilError(t, err) + assert.Equal(t, httpMethod, signed.Method) + urlCheck, err := regexp.Match(baseURL+endpoint, []byte(signed.URL.String())) + assert.NilError(t, err) + assert.Check(t, urlCheck) + assert.Check(t, signed.Header.Get("X-Yoti-Auth-Digest") != "") + assert.Equal(t, signed.Header.Get("X-Yoti-SDK"), "Go") + assert.Equal(t, signed.Header.Get("X-Yoti-SDK-Version"), "3.9.0") +} + +func TestRequestShouldAddHeaders(t *testing.T) { + random := rand.New(rand.NewSource(25)) + key, err := rsa.GenerateKey(random, 1024) + + assert.NilError(t, err) + httpMethod := "GET" + baseURL := "example.com" + endpoint := "/" + + request := SignedRequest{ + Key: key, + HTTPMethod: httpMethod, + BaseURL: baseURL, + Endpoint: endpoint, + Headers: JSONHeaders(), + } + signed, err := request.Request() + assert.NilError(t, err) + assert.Check(t, signed.Header["X-Yoti-Auth-Digest"][0] != "") + assert.Equal(t, signed.Header["Accept"][0], "application/json") +} + +func TestSignedRequest_checkMandatories_WhenErrorIsSetReturnIt(t *testing.T) { + msg := &SignedRequest{Error: fmt.Errorf("exampleError")} + assert.Error(t, msg.checkMandatories(), "exampleError") +} + +func TestSignedRequest_checkMandatories_WhenKeyMissing(t *testing.T) { + msg := &SignedRequest{} + assert.Error(t, msg.checkMandatories(), "missing private key") +} + +func TestSignedRequest_checkMandatories_WhenHTTPMethodMissing(t *testing.T) { + msg := &SignedRequest{Key: privateKey} + assert.Error(t, msg.checkMandatories(), "missing HTTPMethod") +} + +func TestSignedRequest_checkMandatories_WhenBaseURLMissing(t *testing.T) { + msg := &SignedRequest{ + Key: privateKey, + HTTPMethod: http.MethodPost, + } + assert.Error(t, msg.checkMandatories(), "missing BaseURL") +} + +func TestSignedRequest_checkMandatories_WhenEndpointMissing(t *testing.T) { + msg := &SignedRequest{ + Key: privateKey, + HTTPMethod: http.MethodPost, + BaseURL: "example.com", + } + assert.Error(t, msg.checkMandatories(), "missing Endpoint") +} + +func ExampleSignedRequest_generateDigest() { + msg := &SignedRequest{ + HTTPMethod: http.MethodPost, + Body: []byte("simple message body"), + } + fmt.Println(msg.generateDigest("endpoint")) + // Output: POST&endpoint&c2ltcGxlIG1lc3NhZ2UgYm9keQ== + +} + +func ExampleSignedRequest_WithPemFile() { + msg := SignedRequest{}.WithPemFile([]byte(` +-----BEGIN RSA PRIVATE KEY----- +` + exampleKey + ` +-----END RSA PRIVATE KEY-----`)) + fmt.Println(AuthKeyHeader(&msg.Key.PublicKey)) + // Output: map[X-Yoti-Auth-Key:[MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCpTiICtL+ujx8D0FquVWIaXg+ajJadN5hsTlGUXymiFAunSZjLjTsoGfSPz8PJm6pG9ax1Qb+R5UsSgTRTcpZTps2RLRWr5oPfD66bz4l38QXPSvfg5o+5kNxyCb8QANitF7Ht/DcpsGpL7anruHg/RgCLCBFRaGAodfuJCCM9zwIDAQAB]] +} + +func TestSignedRequest_WithPemFile_NotPemEncodedShouldError(t *testing.T) { + msg := SignedRequest{}.WithPemFile([]byte("not pem encoded")) + assert.ErrorContains(t, msg.Error, "not PEM-encoded") +} + +func TestSignedRequest_WithPemFile_NotRSAKeyShouldError(t *testing.T) { + msg := SignedRequest{}.WithPemFile([]byte(`-----BEGIN RSA PUBLIC KEY----- +` + exampleKey + ` +-----END RSA PUBLIC KEY-----`)) + assert.ErrorContains(t, msg.Error, "not an RSA Private Key") +} diff --git a/digitalidentity/service.go b/digitalidentity/service.go index eba3a9cc8..3fb2f99c9 100644 --- a/digitalidentity/service.go +++ b/digitalidentity/service.go @@ -3,29 +3,23 @@ package digitalidentity import ( "crypto/rsa" "encoding/json" + "fmt" "io" "net/http" - "github.com/getyoti/yoti-go-sdk/v3/requests" - "github.com/getyoti/yoti-go-sdk/v3/yotierror" + "github.com/getyoti/yoti-go-sdk/v3/digitalidentity/requests" ) -const identitySesssionCreationEndpoint = "/v2/sessions" - -// SessionResult contains the information about a created session -type SessionResult struct { - Id int `json:"id"` - Status string `json:"status"` - Expiry string `json:"expiry"` -} +const identitySessionCreationEndpoint = "/v2/sessions" +const identitySessionRetrieval = "/v2/sessions/%s" // CreateShareSession creates session using the supplied session specification -func CreateShareSession(httpClient requests.HttpClient, shareSession *ShareSession, clientSdkId, apiUrl string, key *rsa.PrivateKey) (share ShareURL, err error) { - endpoint := identitySesssionCreationEndpoint +func CreateShareSession(httpClient requests.HttpClient, shareSessionRequest *ShareSessionRequest, clientSdkId, apiUrl string, key *rsa.PrivateKey) (*ShareSession, error) { + endpoint := identitySessionCreationEndpoint - payload, err := shareSession.MarshalJSON() + payload, err := shareSessionRequest.MarshalJSON() if err != nil { - return share, err + return nil, err } request, err := requests.SignedRequest{ @@ -33,25 +27,56 @@ func CreateShareSession(httpClient requests.HttpClient, shareSession *ShareSessi HTTPMethod: http.MethodPost, BaseURL: apiUrl, Endpoint: endpoint, - Headers: nil, + Headers: requests.AuthHeader(clientSdkId), Body: payload, + Params: map[string]string{"sdkID": clientSdkId}, }.Request() if err != nil { - return share, err + return nil, err } - response, err := requests.Execute(httpClient, request, ShareURLHTTPErrorMessages, yotierror.DefaultHTTPErrorMessages) + response, err := requests.Execute(httpClient, request) if err != nil { - return share, err + return nil, err } - defer response.Body.Close() + defer response.Body.Close() + shareSession := &ShareSession{} responseBytes, err := io.ReadAll(response.Body) if err != nil { - return share, err + return nil, 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, err } - err = json.Unmarshal(responseBytes, &share) + response, err := requests.Execute(httpClient, request) - return share, err + if err != nil { + return nil, err + } + defer response.Body.Close() + shareSession := &ShareSession{} + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(responseBytes, shareSession) + return shareSession, err } diff --git a/digitalidentity/service_test.go b/digitalidentity/service_test.go index a42f300d7..f1ddc6eaf 100644 --- a/digitalidentity/service_test.go +++ b/digitalidentity/service_test.go @@ -29,7 +29,7 @@ func ExampleCreateShareSession() { do: func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 201, - Body: io.NopCloser(strings.NewReader(`{"qrcode":"https://code.yoti.com/CAEaJDQzNzllZDc0LTU0YjItNDkxMy04OTE4LTExYzM2ZDU2OTU3ZDAC","ref_id":"0"}`)), + Body: io.NopCloser(strings.NewReader(`{"id":"0","status":"success","expiry": ""}`)), }, nil }, } @@ -40,7 +40,7 @@ func ExampleCreateShareSession() { return } - session, err := (&ShareSessionBuilder{}).WithPolicy(policy).Build() + session, err := (&ShareSessionRequestBuilder{}).WithPolicy(policy).Build() if err != nil { fmt.Printf("error: %s", err.Error()) return @@ -53,36 +53,14 @@ func ExampleCreateShareSession() { return } - fmt.Printf("QR code: %s", result.ShareURL) - // Output: QR code: https://code.yoti.com/CAEaJDQzNzllZDc0LTU0YjItNDkxMy04OTE4LTExYzM2ZDU2OTU3ZDAC + fmt.Printf("Status code: %s", result.Status) + // Output: Status code: success } -func TestCreateShareURL_Unsuccessful_503(t *testing.T) { - _, err := createShareUrlWithErrorResponse(503, "some service unavailable response") +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, "503: unknown HTTP error - some service unavailable response") - - tempError, temporary := err.(interface { - Temporary() bool - }) - assert.Check(t, temporary && tempError.Temporary()) -} - -func TestCreateShareURL_Unsuccessful_404(t *testing.T) { - _, err := createShareUrlWithErrorResponse(404, "some not found response") - - assert.ErrorContains(t, err, "404: Application was not found - some not found response") - - tempError, temporary := err.(interface { - Temporary() bool - }) - assert.Check(t, !temporary || !tempError.Temporary()) -} - -func TestCreateShareURL_Unsuccessful_400(t *testing.T) { - _, err := createShareUrlWithErrorResponse(400, "some invalid JSON response") - - assert.ErrorContains(t, err, "400: JSON is incorrect, contains invalid data - some invalid JSON response") + assert.ErrorContains(t, err, "INVALID_REQUEST_SIGNATURE") tempError, temporary := err.(interface { Temporary() bool @@ -90,7 +68,7 @@ func TestCreateShareURL_Unsuccessful_400(t *testing.T) { assert.Check(t, !temporary || !tempError.Temporary()) } -func createShareUrlWithErrorResponse(statusCode int, responseBody string) (share ShareURL, err error) { +func createShareSessionWithErrorResponse(statusCode int, responseBody string) (*ShareSession, error) { key := test.GetValidKey("../test/test-key.pem") client := &mockHTTPClient{ @@ -104,12 +82,31 @@ func createShareUrlWithErrorResponse(statusCode int, responseBody string) (share policy, err := (&PolicyBuilder{}).WithFullName().WithWantedRememberMe().Build() if err != nil { - return + return nil, err } - scenario, err := (&ShareSessionBuilder{}).WithPolicy(policy).Build() + session, err := (&ShareSessionRequestBuilder{}).WithPolicy(policy).Build() if err != nil { - return + 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 + }, } - return CreateShareSession(client, &scenario, "sdkId", "https://apiurl", key) + _, err := GetShareSession(client, mockSessionID, mockClientSdkId, mockApiUrl, key) + assert.NilError(t, err) + } 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 index a39823def..7f644a2cb 100644 --- a/digitalidentity/share_session_builder.go +++ b/digitalidentity/share_session_builder.go @@ -4,76 +4,69 @@ import ( "encoding/json" ) -// ShareSessionBuilder builds a session -type ShareSessionBuilder struct { - shareSession ShareSession - err error +// ShareSessionRequestBuilder builds a session +type ShareSessionRequestBuilder struct { + shareSessionRequest ShareSessionRequest + err error } -// ShareSession represents a sharesession -type ShareSession struct { - policy *Policy - redirectUri string +// ShareSessionRequest represents a sharesession +type ShareSessionRequest struct { + policy Policy extensions []interface{} subject *json.RawMessage - shareSessionNotification ShareSessionNotification + shareSessionNotification *ShareSessionNotification + redirectUri string } // WithPolicy attaches a Policy to the ShareSession -func (builder *ShareSessionBuilder) WithPolicy(policy Policy) *ShareSessionBuilder { - builder.shareSession.policy = &policy +func (builder *ShareSessionRequestBuilder) WithPolicy(policy Policy) *ShareSessionRequestBuilder { + builder.shareSessionRequest.policy = policy return builder } // WithExtension adds an extension to the ShareSession -func (builder *ShareSessionBuilder) WithExtension(extension interface{}) *ShareSessionBuilder { - builder.shareSession.extensions = append(builder.shareSession.extensions, extension) +func (builder *ShareSessionRequestBuilder) WithExtension(extension interface{}) *ShareSessionRequestBuilder { + builder.shareSessionRequest.extensions = append(builder.shareSessionRequest.extensions, extension) return builder } // WithNotification sets the callback URL -func (builder *ShareSessionBuilder) WithNotification(notification ShareSessionNotification) *ShareSessionBuilder { - builder.shareSession.shareSessionNotification = notification +func (builder *ShareSessionRequestBuilder) WithNotification(notification *ShareSessionNotification) *ShareSessionRequestBuilder { + builder.shareSessionRequest.shareSessionNotification = notification return builder } // WithRedirectUri sets redirectUri to the ShareSession -func (builder *ShareSessionBuilder) WithRedirectUri(redirectUri string) *ShareSessionBuilder { - builder.shareSession.redirectUri = redirectUri +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 *ShareSessionBuilder) WithSubject(subject json.RawMessage) *ShareSessionBuilder { - builder.shareSession.subject = &subject +func (builder *ShareSessionRequestBuilder) WithSubject(subject json.RawMessage) *ShareSessionRequestBuilder { + builder.shareSessionRequest.subject = &subject return builder } // Build constructs the ShareSession -func (builder *ShareSessionBuilder) Build() (ShareSession, error) { - if builder.shareSession.extensions == nil { - builder.shareSession.extensions = make([]interface{}, 0) - } - if builder.shareSession.policy == nil { - policy, err := (&PolicyBuilder{}).Build() - if err != nil { - return builder.shareSession, err - } - builder.shareSession.policy = &policy +func (builder *ShareSessionRequestBuilder) Build() (ShareSessionRequest, error) { + if builder.shareSessionRequest.extensions == nil { + builder.shareSessionRequest.extensions = make([]interface{}, 0) } - return builder.shareSession, builder.err + return builder.shareSessionRequest, builder.err } // MarshalJSON returns the JSON encoding -func (shareSesssion ShareSession) MarshalJSON() ([]byte, error) { +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"` + 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, + Policy: shareSesssion.policy, Extensions: shareSesssion.extensions, RedirectUri: shareSesssion.redirectUri, Subject: shareSesssion.subject, diff --git a/digitalidentity/share_session_builder_test.go b/digitalidentity/share_session_builder_test.go index e67f27fd2..b0101e20a 100644 --- a/digitalidentity/share_session_builder_test.go +++ b/digitalidentity/share_session_builder_test.go @@ -6,8 +6,8 @@ import ( "github.com/getyoti/yoti-go-sdk/v3/extension" ) -func ExampleShareSessionBuilder() { - shareSession, err := (&ShareSessionBuilder{}).Build() +func ExampleShareSessionRequestBuilder() { + shareSession, err := (&ShareSessionRequestBuilder{}).Build() if err != nil { fmt.Printf("error: %s", err.Error()) return @@ -20,17 +20,17 @@ func ExampleShareSessionBuilder() { } fmt.Println(string(data)) - // Output: {"policy":{"wanted":[],"wanted_auth_types":[],"wanted_remember_me":false},"extensions":[],"redirectUri":"","notification":{"url":"","method":"","verifyTls":null,"headers":null}} + // Output: {"policy":{"wanted":null,"wanted_auth_types":null,"wanted_remember_me":false},"extensions":[],"redirectUri":""} } -func ExampleShareSessionBuilder_WithPolicy() { +func ExampleShareSessionRequestBuilder_WithPolicy() { policy, err := (&PolicyBuilder{}).WithEmail().WithPinAuth().Build() if err != nil { fmt.Printf("error: %s", err.Error()) return } - session, err := (&ShareSessionBuilder{}).WithPolicy(policy).Build() + session, err := (&ShareSessionRequestBuilder{}).WithPolicy(policy).Build() if err != nil { fmt.Printf("error: %s", err.Error()) return @@ -43,10 +43,10 @@ func ExampleShareSessionBuilder_WithPolicy() { } fmt.Println(string(data)) - // Output: {"policy":{"wanted":[{"name":"email_address","accept_self_asserted":false}],"wanted_auth_types":[2],"wanted_remember_me":false},"extensions":[],"redirectUri":"","notification":{"url":"","method":"","verifyTls":null,"headers":null}} + // Output: {"policy":{"wanted":[{"name":"email_address","accept_self_asserted":false}],"wanted_auth_types":[2],"wanted_remember_me":false},"extensions":[],"redirectUri":""} } -func ExampleShareSessionBuilder_WithExtension() { +func ExampleShareSessionRequestBuilder_WithExtension() { policy, err := (&PolicyBuilder{}).WithFullName().Build() if err != nil { fmt.Printf("error: %s", err.Error()) @@ -61,7 +61,7 @@ func ExampleShareSessionBuilder_WithExtension() { return } - session, err := (&ShareSessionBuilder{}).WithExtension(builtExtension).WithPolicy(policy).Build() + session, err := (&ShareSessionRequestBuilder{}).WithExtension(builtExtension).WithPolicy(policy).Build() if err != nil { fmt.Printf("error: %s", err.Error()) return @@ -74,15 +74,15 @@ func ExampleShareSessionBuilder_WithExtension() { } 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":"","notification":{"url":"","method":"","verifyTls":null,"headers":null}} + // 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 ExampleShareSessionBuilder_WithSubject() { +func ExampleShareSessionRequestBuilder_WithSubject() { subject := []byte(`{ "subject_id": "some_subject_id_string" }`) - session, err := (&ShareSessionBuilder{}).WithSubject(subject).Build() + session, err := (&ShareSessionRequestBuilder{}).WithSubject(subject).Build() if err != nil { fmt.Printf("error: %s", err.Error()) return @@ -95,5 +95,5 @@ func ExampleShareSessionBuilder_WithSubject() { } fmt.Println(string(data)) - // Output: {"policy":{"wanted":[],"wanted_auth_types":[],"wanted_remember_me":false},"extensions":[],"redirectUri":"","subject":{"subject_id":"some_subject_id_string"},"notification":{"url":"","method":"","verifyTls":null,"headers":null}} + // 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_notification_builder.go b/digitalidentity/share_session_notification_builder.go index e7b63695d..6c873ebc6 100644 --- a/digitalidentity/share_session_notification_builder.go +++ b/digitalidentity/share_session_notification_builder.go @@ -7,7 +7,7 @@ import ( // ShareSessionNotification specifies the session notification configuration. type ShareSessionNotification struct { url string - method string + method *string verifyTLS *bool headers map[string][]string } @@ -25,7 +25,7 @@ func (b *ShareSessionNotificationBuilder) WithUrl(url string) *ShareSessionNotif // WithMethod set method to Share Session Notification func (b *ShareSessionNotificationBuilder) WithMethod(method string) *ShareSessionNotificationBuilder { - b.shareSessionNotification.method = method + b.shareSessionNotification.method = &method return b } @@ -50,9 +50,9 @@ func (b *ShareSessionNotificationBuilder) Build() (ShareSessionNotification, err func (a *ShareSessionNotification) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { Url string `json:"url"` - Method string `json:"method"` - VerifyTls *bool `json:"verifyTls"` - Headers map[string][]string `json:"headers"` + Method *string `json:"method,omitempty"` + VerifyTls *bool `json:"verifyTls,omitempty"` + Headers map[string][]string `json:"headers,omitempty"` }{ Url: a.url, Method: a.method, diff --git a/digitalidentity/share_session_notification_builder_test.go b/digitalidentity/share_session_notification_builder_test.go index 3b1554097..a60e301e3 100644 --- a/digitalidentity/share_session_notification_builder_test.go +++ b/digitalidentity/share_session_notification_builder_test.go @@ -18,7 +18,7 @@ func ExampleShareSessionNotificationBuilder() { } fmt.Println(string(data)) - // Output: {"url":"","method":"","verifyTls":null,"headers":null} + // Output: {"url":""} } func ExampleShareSessionNotificationBuilder_WithUrl() { @@ -35,7 +35,7 @@ func ExampleShareSessionNotificationBuilder_WithUrl() { } fmt.Println(string(data)) - // Output: {"url":"Custom_Url","method":"","verifyTls":null,"headers":null} + // Output: {"url":"Custom_Url"} } func ExampleShareSessionNotificationBuilder_WithMethod() { @@ -52,7 +52,7 @@ func ExampleShareSessionNotificationBuilder_WithMethod() { } fmt.Println(string(data)) - // Output: {"url":"","method":"CUSTOMMETHOD","verifyTls":null,"headers":null} + // Output: {"url":"","method":"CUSTOMMETHOD"} } func ExampleShareSessionNotificationBuilder_WithVerifyTls() { @@ -70,7 +70,7 @@ func ExampleShareSessionNotificationBuilder_WithVerifyTls() { } fmt.Println(string(data)) - // Output: {"url":"","method":"","verifyTls":true,"headers":null} + // Output: {"url":"","verifyTls":true} } func ExampleShareSessionNotificationBuilder_WithHeaders() { @@ -91,5 +91,5 @@ func ExampleShareSessionNotificationBuilder_WithHeaders() { } fmt.Println(string(data)) - // Output: {"url":"","method":"","verifyTls":null,"headers":{"key":["value"]}} + // Output: {"url":"","headers":{"key":["value"]}} } diff --git a/digitalidentity/share_url.go b/digitalidentity/share_url.go deleted file mode 100644 index 57a25e87a..000000000 --- a/digitalidentity/share_url.go +++ /dev/null @@ -1,16 +0,0 @@ -package digitalidentity - -var ( - // ShareURLHTTPErrorMessages specifies the HTTP error status codes used - // by the Share URL API - ShareURLHTTPErrorMessages = map[int]string{ - 400: "JSON is incorrect, contains invalid data", - 404: "Application was not found", - } -) - -// ShareURL contains a dynamic share QR code -type ShareURL struct { - ShareURL string `json:"qrcode"` - RefID string `json:"ref_id"` -} 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/requests/signed_message.go b/requests/signed_message.go index f390a3953..8a1715f2e 100644 --- a/requests/signed_message.go +++ b/requests/signed_message.go @@ -41,6 +41,13 @@ func JSONHeaders() map[string][]string { } } +// AuthHeader is a header prototype including the App/SDK ID +func AuthHeader(clientSdkId string, key *rsa.PublicKey) map[string][]string { + return map[string][]string{ + "X-Yoti-Auth-Id": {clientSdkId}, + } +} + // AuthKeyHeader is a header prototype including an encoded RSA PublicKey func AuthKeyHeader(key *rsa.PublicKey) map[string][]string { return map[string][]string{ @@ -185,6 +192,7 @@ func (msg SignedRequest) Request() (request *http.Request, err error) { if err != nil { return } + signedDigest, err := msg.signDigest([]byte(msg.generateDigest(endpoint))) if err != nil { return @@ -199,6 +207,7 @@ func (msg SignedRequest) Request() (request *http.Request, err error) { if err != nil { return } + request.Header.Add("X-Yoti-Auth-Digest", signedDigest) request.Header.Add("X-Yoti-SDK", consts.SDKIdentifier) request.Header.Add("X-Yoti-SDK-Version", consts.SDKVersionIdentifier) @@ -208,5 +217,6 @@ func (msg SignedRequest) Request() (request *http.Request, err error) { request.Header.Add(key, value) } } + return request, err } diff --git a/sonar-project.properties b/sonar-project.properties index e6e6a4450..5d3393d39 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,3 +12,4 @@ sonar.go.tests.reportPaths = sonar-report.json sonar.tests = . sonar.test.inclusions = **/*_test.go sonar.coverage.exclusions = test/**/*,_examples/**/* +sonar.cpd.exclusions = digitalidentity/**