From 63751eb208896022cf4f1d70bdaa13457d20f420 Mon Sep 17 00:00:00 2001 From: Asaf Shen Date: Wed, 17 Apr 2024 17:45:44 +0300 Subject: [PATCH 1/3] notp --- README.md | 72 ++++++++++++++++--- descope/api/client.go | 24 +++++++ descope/errors.go | 1 + descope/internal/auth/auth.go | 31 ++++++++ descope/internal/auth/enchantedlink.go | 2 +- descope/internal/auth/utils.go | 29 +++++++- descope/sdk/auth.go | 28 ++++++++ .../tests/mocks/auth/authenticationmock.go | 53 ++++++++++++++ descope/types.go | 6 ++ 9 files changed, 234 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b1569d7f..5c0796c3 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,15 @@ These sections show how to use the SDK to perform various authentication/authori 2. [Magic Link](#magic-link) 3. [Enchanted Link](#enchanted-link) 4. [OAuth](#oauth) -5. [SSO (SAML / OIDC)](#sso-saml--oidc) -6. [TOTP Authentication](#totp-authentication) -7. [Passwords](#passwords) -8. [Session Validation](#session-validation) -9. [Roles & Permission Validation](#roles--permission-validation) -10. [Tenant selection](#tenant-selection) -11. [Logging Out](#logging-out) -12. [History](#history) +5. [NOTP] (#notp) +6. [SSO (SAML / OIDC)](#sso-saml--oidc) +7. [TOTP Authentication](#totp-authentication) +8. [Passwords](#passwords) +9. [Session Validation](#session-validation) +10. [Roles & Permission Validation](#roles--permission-validation) +11. [Tenant selection](#tenant-selection) +12. [Logging Out](#logging-out) +13. [History](#history) ## Management Functions @@ -259,6 +260,61 @@ if err != nil { The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) +### NOTP (WhatsApp) + +Using the NOTP (WhatsApp) APIs enables users to login using their WhatsApp account, according the the following process: +a. The user will be redirected to WhatsApp (with QR Code / link) with a pre-filled message containing a 16 length alphanumeric code. +b. The user will send the message to the WhatsApp Application associated with the Descope project. +c. Descope will receive the message, validate the code, and send an approval message back to the user. +d. The user will be logged in after receiving the approval message. + +Note: the NOTP (WhatsApp) authentication method should be configured in the Descope Console before using it. + + +The user can either `sign up`, `sign in` or `sign up or in` + +```go +// If configured globally, the redirect URI is optional. If provided however, it will be used +// instead of any global configuration. +res, err := descopeClient.Auth.EnchantedLink().SignIn(context.Background(), loginID, "http://myapp.com/verify-enchanted-link", nil, nil) +if err != nil { + // handle error +} + +res.RedirectURL // The URL to redirect the user to conversation to the WhatsApp Web Application with the pre-filled message containing the code +res.Image // A QR code image that can be displayed to the user to scan using their mobile device camera app to start the WhatsApp conversation +res.PendingRef // Used to poll for a valid session +``` + +After sending the link, you must poll to receive a valid session using the `PendingRef` from +the previous step. A valid session will be returned only after the user sends the message to the WhatsApp Application associated with the Project with the code. + +```go +// Poll for a certain number of tries / time frame +for i := retriesCount; i > 0; i-- { + authInfo, err := descopeClient.Auth.NOTP().GetSession(context.Background(), res.PendingRef, w) + if err == nil { + // The user successfully authenticated + // The optional `w http.ResponseWriter` adds the session and refresh cookies to the response automatically. + // Otherwise they're available via authInfo + break + } + if errors.Is(err, descope.ErrNOTPUnauthorized) && i > 1 { + // poll again after X seconds + time.Sleep(time.Second * time.Duration(retryInterval)) + continue + } + if err != nil { + // handle error + break + } +} +``` + +The verification process is done using the WhatsApp application by the user sending a message with the token that is included in the link. After sending the message, the user will get an approval message back, and the session polling will receive a valid response. + +The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation) + ### SSO (SAML / OIDC) Users can authenticate to a specific tenant using SAML or OIDC. Configure your SSO (SAML / OIDC) settings on the [Descope console](https://app.descope.com/settings/authentication/sso). To start a flow call: diff --git a/descope/api/client.go b/descope/api/client.go index 3545fa1b..d0b150a8 100644 --- a/descope/api/client.go +++ b/descope/api/client.go @@ -41,6 +41,10 @@ var ( signUpTOTP: "auth/totp/signup", updateTOTP: "auth/totp/update", verifyTOTPCode: "auth/totp/verify", + signUpNOTP: "auth/notp/whatsapp/signup", + signInNOTP: "auth/notp/whatsapp/signin", + signUpOrInNOTP: "auth/notp/whatsapp/signup-in", + getNOTPSession: "auth/notp/pending-session", verifyCode: "auth/otp/verify", signUpPassword: "auth/password/signup", signInPassword: "auth/password/signin", @@ -222,6 +226,10 @@ type authEndpoints struct { signUpTOTP string updateTOTP string verifyTOTPCode string + signUpNOTP string + signInNOTP string + signUpOrInNOTP string + getNOTPSession string verifyCode string signUpPassword string signInPassword string @@ -411,6 +419,22 @@ func (e *endpoints) UpdateTOTP() string { return path.Join(e.version, e.auth.updateTOTP) } +func (e *endpoints) SignUpNOTP() string { + return path.Join(e.version, e.auth.signUpNOTP) +} + +func (e *endpoints) SignInNOTP() string { + return path.Join(e.version, e.auth.signInNOTP) +} + +func (e *endpoints) SignUpOrInNOTP() string { + return path.Join(e.version, e.auth.signUpOrInNOTP) +} + +func (e *endpoints) GetNOTPSession() string { + return path.Join(e.version, e.auth.getNOTPSession) +} + func (e *endpoints) VerifyCode() string { return path.Join(e.version, e.auth.verifyCode) } diff --git a/descope/errors.go b/descope/errors.go index b3c4ac20..8d0105d3 100644 --- a/descope/errors.go +++ b/descope/errors.go @@ -19,6 +19,7 @@ var ( ErrEnchantedLinkUnauthorized = newServerError("E062503") ErrPasswordExpired = newServerError("E062909") ErrTokenExpiredByLoggedOut = newServerError("E064001") + ErrNOTPUnauthorized = newServerError("E066103") // server management ErrManagementUserNotFound = newServerError("E112102") diff --git a/descope/internal/auth/auth.go b/descope/internal/auth/auth.go index 2fa1114f..e69b6a2a 100644 --- a/descope/internal/auth/auth.go +++ b/descope/internal/auth/auth.go @@ -40,6 +40,7 @@ type authenticationService struct { magicLink sdk.MagicLink enchantedLink sdk.EnchantedLink totp sdk.TOTP + notp sdk.NOTP password sdk.Password webAuthn sdk.WebAuthn oauth sdk.OAuth @@ -59,6 +60,7 @@ func NewAuth(conf AuthParams, c *api.Client) (*authenticationService, error) { authenticationService.sso = &sso{authenticationsBase: base} authenticationService.webAuthn = &webAuthn{authenticationsBase: base} authenticationService.totp = &totp{authenticationsBase: base} + authenticationService.notp = ¬p{authenticationsBase: base} authenticationService.password = &password{authenticationsBase: base} return authenticationService, nil } @@ -79,6 +81,10 @@ func (auth *authenticationService) TOTP() sdk.TOTP { return auth.totp } +func (auth *authenticationService) NOTP() sdk.NOTP { + return auth.notp +} + func (auth *authenticationService) Password() sdk.Password { return auth.password } @@ -830,6 +836,15 @@ func getPendingRefFromResponse(httpResponse *api.HTTPResponse) (*descope.Enchant return response, nil } +func getNOTPResponse(httpResponse *api.HTTPResponse) (*descope.NOTPResponse, error) { + var response *descope.NOTPResponse + if err := utils.Unmarshal([]byte(httpResponse.BodyStr), &response); err != nil { + logger.LogError("Failed to load NOTP response from http response", err) + return response, descope.ErrUnexpectedResponse.WithMessage("Failed to load NOTP response") + } + return response, nil +} + func composeURLMethod(base string, method descope.DeliveryMethod) string { return path.Join(base, string(method)) } @@ -854,6 +869,22 @@ func composeUpdateTOTPURL() string { return api.Routes.UpdateTOTP() } +func composeNOTPSignInURL() string { + return api.Routes.SignInNOTP() +} + +func composeNOTPSignUpURL() string { + return api.Routes.SignUpNOTP() +} + +func composeNOTPSignUpOrInURL() string { + return api.Routes.SignUpOrInNOTP() +} + +func composeNOTPGetSession() string { + return api.Routes.GetNOTPSession() +} + func composeVerifyCodeURL(method descope.DeliveryMethod) string { return composeURLMethod(api.Routes.VerifyCode(), method) } diff --git a/descope/internal/auth/enchantedlink.go b/descope/internal/auth/enchantedlink.go index c8ca7ed7..3f48172c 100644 --- a/descope/internal/auth/enchantedlink.go +++ b/descope/internal/auth/enchantedlink.go @@ -68,7 +68,7 @@ func (auth *enchantedLink) SignUpOrIn(ctx context.Context, loginID, URI string, func (auth *enchantedLink) GetSession(ctx context.Context, pendingRef string, w http.ResponseWriter) (*descope.AuthenticationInfo, error) { var err error - httpResponse, err := auth.client.DoPostRequest(ctx, composeGetSession(), newAuthenticationGetMagicLinkSessionBody(pendingRef), nil, "") + httpResponse, err := auth.client.DoPostRequest(ctx, composeGetSession(), newAuthenticationGetSessionBody(pendingRef), nil, "") if err != nil { return nil, err } diff --git a/descope/internal/auth/utils.go b/descope/internal/auth/utils.go index 7a71e9df..a945fed7 100644 --- a/descope/internal/auth/utils.go +++ b/descope/internal/auth/utils.go @@ -80,6 +80,11 @@ type totpSignUpRequestBody struct { User *descope.User `json:"user,omitempty"` } +type notpAuthenticationRequestBody struct { + *authenticationRequestBody `json:",inline"` + LoginOptions *descope.LoginOptions `json:"loginOptions,omitempty"` +} + type otpUpdateEmailRequestBody struct { *descope.UpdateOptions `json:",inline"` LoginID string `json:"loginId,omitempty"` @@ -125,7 +130,7 @@ type magicLinkAuthenticationVerifyRequestBody struct { Token string `json:"token"` } -type authenticationGetMagicLinkSessionBody struct { +type authenticationGetSessionBody struct { PendingRef string `json:"pendingRef"` } @@ -158,6 +163,24 @@ func newOTPUpdateEmailRequestBody(loginID, email string, updateOptions *descope. return &otpUpdateEmailRequestBody{LoginID: loginID, Email: email, UpdateOptions: updateOptions} } +func newNOTPAuthenticationRequestBody(loginID string, loginOptions *descope.LoginOptions) *notpAuthenticationRequestBody { + return ¬pAuthenticationRequestBody{authenticationRequestBody: newSignInRequestBody(loginID, loginOptions), LoginOptions: loginOptions} +} + +func newNOTPAuthenticationSignUpRequestBody(loginID string, user *descope.User, signUpOptions *descope.SignUpOptions) *authenticationSignUpRequestBody { + res := newSignUpRequestBody(descope.MethodSMS, user) + res.User = user + res.LoginID = loginID + if signUpOptions == nil { + signUpOptions = &descope.SignUpOptions{} + } + res.LoginOptions = &descope.LoginOptions{ + CustomClaims: signUpOptions.CustomClaims, + TemplateOptions: signUpOptions.TemplateOptions, + } + return res +} + func newOTPUpdatePhoneRequestBody(loginID, phone string, updateOptions *descope.UpdateOptions) *otpUpdatePhoneRequestBody { return &otpUpdatePhoneRequestBody{LoginID: loginID, Phone: phone, UpdateOptions: updateOptions} } @@ -214,8 +237,8 @@ func newMagicLinkUpdatePhoneRequestBody(loginID, phone string, URI string, cross return &magicLinkUpdatePhoneRequestBody{LoginID: loginID, Phone: phone, URI: URI, CrossDevice: crossDevice, UpdateOptions: updateOptions} } -func newAuthenticationGetMagicLinkSessionBody(pendingRef string) *authenticationGetMagicLinkSessionBody { - return &authenticationGetMagicLinkSessionBody{PendingRef: pendingRef} +func newAuthenticationGetSessionBody(pendingRef string) *authenticationGetSessionBody { + return &authenticationGetSessionBody{PendingRef: pendingRef} } func newExchangeTokenBody(code string) *exchangeTokenBody { diff --git a/descope/sdk/auth.go b/descope/sdk/auth.go index 93853759..88c10f67 100644 --- a/descope/sdk/auth.go +++ b/descope/sdk/auth.go @@ -134,6 +134,33 @@ type TOTP interface { UpdateUser(ctx context.Context, loginID string, request *http.Request) (*descope.TOTPResponse, error) } +type NOTP interface { + // SignIn - Use to login a user with NOTP (WhatsApp) authentication. + // The Login ID should be a phone or empty. If empty, the whatsapp phone number will be used as the login ID in the verification process. + // Use the redirect URL / Image (QR Code) in the response to take the user to the WhatsApp app to scan the QR code. + // The jwt would be returned on the GetSession function after the user has sent the verification message. + // Returns an error upon failure. + SignIn(ctx context.Context, loginID string, r *http.Request, loginOptions *descope.LoginOptions) (*descope.NOTPResponse, error) + + // SignUp - Use to create a new user with NOTP (WhatsApp) authentication. + // The Login ID should be a phone or empty. If empty, the whatsapp phone number will be used as the login ID in the verification process. + // Use the redirect URL / Image (QR Code) in the response to take the user to the WhatsApp app to scan the QR code. + // The jwt would be returned on the GetSession function after the user has sent the verification message. + // Returns an error upon failure. + SignUp(ctx context.Context, loginID string, user *descope.User, signUpOptions *descope.SignUpOptions) (*descope.NOTPResponse, error) + + // SignUpOrIn - Use to login in with NOTP (WhatsApp), if user does not exist, a new user will be created + // The Login ID should be a phone or empty. If empty, the whatsapp phone number will be used as the login ID in the verification process. + // Use the redirect URL / Image (QR Code) in the response to take the user to the WhatsApp app to scan the QR code. + // The jwt would be returned on the GetSession function after the user has sent the verification message. + // Returns an error upon failure. + SignUpOrIn(ctx context.Context, loginID string, signUpOptions *descope.SignUpOptions) (*descope.NOTPResponse, error) + + // GetSession - Use to get a session that was generated by SignIn/SignUp/SignUpOrIn request. + // This function will return a proper JWT only after Verify succeed for this sign-in/sign-up/sign-up-or-in. + GetSession(ctx context.Context, pendingRef string, w http.ResponseWriter) (*descope.AuthenticationInfo, error) +} + type Password interface { // SignUp - Use to create a new user that authenticates with a password. // Use the ResponseWriter (optional) to apply the cookies to the response automatically. @@ -290,6 +317,7 @@ type Authentication interface { EnchantedLink() EnchantedLink OTP() OTP TOTP() TOTP + NOTP() NOTP Password() Password OAuth() OAuth SAML() SAML diff --git a/descope/tests/mocks/auth/authenticationmock.go b/descope/tests/mocks/auth/authenticationmock.go index ec6be8b4..63b41318 100644 --- a/descope/tests/mocks/auth/authenticationmock.go +++ b/descope/tests/mocks/auth/authenticationmock.go @@ -13,6 +13,7 @@ type MockAuthentication struct { *MockEnchantedLink *MockOTP *MockTOTP + *MockNOTP *MockPassword *MockOAuth *MockSAML @@ -37,6 +38,10 @@ func (m *MockAuthentication) TOTP() sdk.TOTP { return m.MockTOTP } +func (m *MockAuthentication) NOTP() sdk.NOTP { + return m.MockNOTP +} + func (m *MockAuthentication) Password() sdk.Password { return m.MockPassword } @@ -293,6 +298,54 @@ func (m *MockTOTP) UpdateUser(_ context.Context, loginID string, r *http.Request return m.UpdateUserResponse, m.UpdateUserError } +// Mock NOTP + +type MockNOTP struct { + SignInAssert func(loginID string, r *http.Request, loginOptions *descope.LoginOptions) + SignInError error + SignInResponse *descope.NOTPResponse + + SignUpAssert func(loginID string, user *descope.User, signUpOptions *descope.SignUpOptions) + SignUpError error + SignUpResponse *descope.NOTPResponse + + SignUpOrInAssert func(loginID string, signUpOptions *descope.SignUpOptions) + SignUpOrInError error + SignUpOrInResponse *descope.NOTPResponse + + GetSessionAssert func(pendingRef string, w http.ResponseWriter) + GetSessionResponse *descope.AuthenticationInfo + GetSessionError error +} + +func (m *MockNOTP) SignIn(_ context.Context, loginID string, r *http.Request, loginOptions *descope.LoginOptions) (*descope.NOTPResponse, error) { + if m.SignInAssert != nil { + m.SignInAssert(loginID, r, loginOptions) + } + return m.SignInResponse, m.SignInError +} + +func (m *MockNOTP) SignUp(_ context.Context, loginID string, user *descope.User, signUpOptions *descope.SignUpOptions) (*descope.NOTPResponse, error) { + if m.SignUpAssert != nil { + m.SignUpAssert(loginID, user, signUpOptions) + } + return m.SignUpResponse, m.SignUpError +} + +func (m *MockNOTP) SignUpOrIn(_ context.Context, loginID string, signUpOptions *descope.SignUpOptions) (*descope.NOTPResponse, error) { + if m.SignUpOrInAssert != nil { + m.SignUpOrInAssert(loginID, signUpOptions) + } + return m.SignUpOrInResponse, m.SignUpOrInError +} + +func (m *MockNOTP) GetSession(_ context.Context, pendingRef string, w http.ResponseWriter) (*descope.AuthenticationInfo, error) { + if m.GetSessionAssert != nil { + m.GetSessionAssert(pendingRef, w) + } + return m.GetSessionResponse, m.GetSessionError +} + // Mock Password type MockPassword struct { diff --git a/descope/types.go b/descope/types.go index 9f1bd26d..ed3141cc 100644 --- a/descope/types.go +++ b/descope/types.go @@ -17,6 +17,12 @@ type TOTPResponse struct { Key string `json:"key,omitempty"` } +type NOTPResponse struct { + RedirectURL string `json:"redirectUrl,omitempty"` + Image string `json:"image,omitempty"` + PendingRef string `json:"pendingRef,omitempty"` // Pending referral code used to poll the authentication info +} + type AuthenticationInfo struct { SessionToken *Token `json:"token,omitempty"` RefreshToken *Token `json:"refreshToken,omitempty"` From a2d02ebc91f3d8c9a122416bd05e217fd301280c Mon Sep 17 00:00:00 2001 From: Asaf Shen Date: Wed, 17 Apr 2024 17:46:19 +0300 Subject: [PATCH 2/3] commit files --- descope/internal/auth/notp.go | 66 ++++++ descope/internal/auth/notp_test.go | 326 +++++++++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 descope/internal/auth/notp.go create mode 100644 descope/internal/auth/notp_test.go diff --git a/descope/internal/auth/notp.go b/descope/internal/auth/notp.go new file mode 100644 index 00000000..11717e66 --- /dev/null +++ b/descope/internal/auth/notp.go @@ -0,0 +1,66 @@ +package auth + +import ( + "context" + "net/http" + + "github.com/descope/go-sdk/descope" +) + +type notp struct { + authenticationsBase +} + +func (auth *notp) SignIn(ctx context.Context, loginID string, r *http.Request, loginOptions *descope.LoginOptions) (*descope.NOTPResponse, error) { + var pswd string + var err error + if loginOptions.IsJWTRequired() { + pswd, err = getValidRefreshToken(r) + if err != nil { + return nil, descope.ErrInvalidStepUpJWT + } + } + httpResponse, err := auth.client.DoPostRequest(ctx, composeNOTPSignInURL(), newNOTPAuthenticationRequestBody(loginID, loginOptions), nil, pswd) + if err != nil { + return nil, err + } + return getNOTPResponse(httpResponse) +} + +func (auth *notp) SignUp(ctx context.Context, loginID string, user *descope.User, signUpOptions *descope.SignUpOptions) (*descope.NOTPResponse, error) { + if user == nil { + user = &descope.User{} + } + if len(user.Phone) == 0 { + user.Phone = loginID + } + + httpResponse, err := auth.client.DoPostRequest(ctx, composeNOTPSignUpURL(), newNOTPAuthenticationSignUpRequestBody(loginID, user, signUpOptions), nil, "") + if err != nil { + return nil, err + } + return getNOTPResponse(httpResponse) +} + +func (auth *notp) SignUpOrIn(ctx context.Context, loginID string, signUpOptions *descope.SignUpOptions) (*descope.NOTPResponse, error) { + if signUpOptions == nil { + signUpOptions = &descope.SignUpOptions{} + } + httpResponse, err := auth.client.DoPostRequest(ctx, composeNOTPSignUpOrInURL(), newNOTPAuthenticationRequestBody(loginID, &descope.LoginOptions{ + CustomClaims: signUpOptions.CustomClaims, + TemplateOptions: signUpOptions.TemplateOptions, + }), nil, "") + if err != nil { + return nil, err + } + return getNOTPResponse(httpResponse) +} + +func (auth *notp) GetSession(ctx context.Context, pendingRef string, w http.ResponseWriter) (*descope.AuthenticationInfo, error) { + var err error + httpResponse, err := auth.client.DoPostRequest(ctx, composeNOTPGetSession(), newAuthenticationGetSessionBody(pendingRef), nil, "") + if err != nil { + return nil, err + } + return auth.generateAuthenticationInfo(httpResponse, w) +} diff --git a/descope/internal/auth/notp_test.go b/descope/internal/auth/notp_test.go new file mode 100644 index 00000000..805dc3ed --- /dev/null +++ b/descope/internal/auth/notp_test.go @@ -0,0 +1,326 @@ +package auth + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/descope/go-sdk/descope" + "github.com/descope/go-sdk/descope/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + image = "image-1" + redirectURL = "url-1" +) + +func TestSignInNOTPEmptyLoginID(t *testing.T) { + phone := "" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"pendingRef": "pr1","image": "image1", "redirectUrl":"redirect-1"}`)), + }, nil + }) + require.NoError(t, err) + _, err = a.NOTP().SignIn(context.Background(), phone, nil, nil) + require.NoError(t, err) +} + +func TestSignInNOTPStepupNoJwt(t *testing.T) { + phone := "+111111111111" + a, err := newTestAuth(nil, nil) + require.NoError(t, err) + _, err = a.NOTP().SignIn(context.Background(), phone, nil, &descope.LoginOptions{Stepup: true}) + require.Error(t, err) + assert.ErrorIs(t, err, descope.ErrInvalidStepUpJWT) +} + +func TestSignInNOTP(t *testing.T) { + phone := "+111111111111" + pendingRefResponse := "pending_ref" + + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + assert.EqualValues(t, composeNOTPSignInURL(), r.URL.RequestURI()) + + m, err := readBodyMap(r) + require.NoError(t, err) + assert.EqualValues(t, phone, m["loginId"]) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"pendingRef": "%s","image": "%s", "redirectUrl":"%s"}`, pendingRefResponse, image, redirectURL))), + }, nil + }) + require.NoError(t, err) + response, err := a.NOTP().SignIn(context.Background(), phone, nil, nil) + require.NoError(t, err) + require.EqualValues(t, pendingRefResponse, response.PendingRef) + require.EqualValues(t, image, response.Image) + require.EqualValues(t, redirectURL, response.RedirectURL) +} + +func TestSignInNOTPStepup(t *testing.T) { + phone := "+111111111111" + pendingRefResponse := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + assert.EqualValues(t, composeNOTPSignInURL(), r.URL.RequestURI()) + + m, err := readBodyMap(r) + require.NoError(t, err) + assert.EqualValues(t, phone, m["loginId"]) + assert.EqualValues(t, map[string]interface{}{"stepup": true, "customClaims": map[string]interface{}{"k1": "v1"}}, m["loginOptions"]) + reqToken := r.Header.Get(api.AuthorizationHeaderName) + splitToken := strings.Split(reqToken, api.BearerAuthorizationPrefix) + require.Len(t, splitToken, 2) + bearer := splitToken[1] + bearers := strings.Split(bearer, ":") + require.Len(t, bearers, 2) + assert.EqualValues(t, "test", bearers[1]) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"pendingRef": "%s","image": "%s"}`, pendingRefResponse, image))), + }, nil + }) + require.NoError(t, err) + response, err := a.NOTP().SignIn(context.Background(), phone, &http.Request{Header: http.Header{"Cookie": []string{"DSR=test"}}}, &descope.LoginOptions{Stepup: true, CustomClaims: map[string]interface{}{"k1": "v1"}}) + require.NoError(t, err) + require.EqualValues(t, pendingRefResponse, response.PendingRef) + require.EqualValues(t, image, response.Image) +} + +func TestSignInNOTPInvalidResponse(t *testing.T) { + phone := "+111111111111" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"pendingRef"`)), + }, nil + }) + require.NoError(t, err) + res, err := a.NOTP().SignIn(context.Background(), phone, nil, nil) + require.Error(t, err) + require.Empty(t, res) +} + +func TestSignUpNOTP(t *testing.T) { + phone := "+111111111111" + pendingRefResponse := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + assert.EqualValues(t, composeNOTPSignUpURL(), r.URL.RequestURI()) + + m, err := readBodyMap(r) + require.NoError(t, err) + assert.EqualValues(t, phone, m["phone"]) + assert.EqualValues(t, phone, m["loginId"]) + assert.EqualValues(t, "test", m["user"].(map[string]interface{})["name"]) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"pendingRef": "%s","image": "%s"}`, pendingRefResponse, image))), + }, nil + }) + require.NoError(t, err) + response, err := a.NOTP().SignUp(context.Background(), phone, &descope.User{Name: "test"}, nil) + require.NoError(t, err) + require.EqualValues(t, pendingRefResponse, response.PendingRef) + require.EqualValues(t, image, response.Image) +} + +func TestSignUpNOTPWithSignUpOptions(t *testing.T) { + phone := "+111111111111" + pendingRefResponse := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + assert.EqualValues(t, composeNOTPSignUpURL(), r.URL.RequestURI()) + + m, err := readBodyMap(r) + require.NoError(t, err) + assert.EqualValues(t, phone, m["phone"]) + assert.EqualValues(t, phone, m["loginId"]) + assert.EqualValues(t, "test", m["user"].(map[string]interface{})["name"]) + assert.EqualValues(t, map[string]interface{}{"customClaims": map[string]interface{}{"aa": "bb"}, "templateOptions": map[string]interface{}{"cc": "dd"}}, m["loginOptions"]) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"pendingRef": "%s","image": "%s"}`, pendingRefResponse, image))), + }, nil + }) + require.NoError(t, err) + response, err := a.NOTP().SignUp(context.Background(), phone, &descope.User{Name: "test"}, &descope.SignUpOptions{ + CustomClaims: map[string]interface{}{"aa": "bb"}, + TemplateOptions: map[string]string{"cc": "dd"}, + }) + require.NoError(t, err) + require.EqualValues(t, pendingRefResponse, response.PendingRef) + require.EqualValues(t, image, response.Image) +} + +func TestSignUpOrInNOTP(t *testing.T) { + phone := "+111111111111" + pendingRefResponse := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + assert.EqualValues(t, composeNOTPSignUpOrInURL(), r.URL.RequestURI()) + + m, err := readBodyMap(r) + require.NoError(t, err) + assert.EqualValues(t, phone, m["loginId"]) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"pendingRef": "%s", "image": "%s"}`, pendingRefResponse, image))), + }, nil + }) + require.NoError(t, err) + response, err := a.NOTP().SignUpOrIn(context.Background(), phone, nil) + require.NoError(t, err) + require.EqualValues(t, pendingRefResponse, response.PendingRef) + require.EqualValues(t, image, response.Image) +} + +func TestSignUpOrInNOTPWithLoginOptions(t *testing.T) { + phone := "+111111111111" + pendingRefResponse := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + assert.EqualValues(t, composeNOTPSignUpOrInURL(), r.URL.RequestURI()) + + m, err := readBodyMap(r) + require.NoError(t, err) + assert.EqualValues(t, phone, m["loginId"]) + assert.EqualValues(t, map[string]interface{}{"customClaims": map[string]interface{}{"aa": "bb"}, "templateOptions": map[string]interface{}{"cc": "dd"}}, m["loginOptions"]) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"pendingRef": "%s", "image": "%s"}`, pendingRefResponse, image))), + }, nil + }) + require.NoError(t, err) + response, err := a.NOTP().SignUpOrIn(context.Background(), phone, &descope.SignUpOptions{ + CustomClaims: map[string]interface{}{"aa": "bb"}, + TemplateOptions: map[string]string{"cc": "dd"}, + }) + require.NoError(t, err) + require.EqualValues(t, pendingRefResponse, response.PendingRef) + require.EqualValues(t, image, response.Image) +} + +func TestSignUpNOTPEmptyLoginID(t *testing.T) { + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"pendingRef": "pr1","image": "image1", "redirectUrl":"redirect-1"}`)), + }, nil + }) + require.NoError(t, err) + _, err = a.NOTP().SignUp(context.Background(), "", &descope.User{Name: "test"}, nil) + require.NoError(t, err) +} + +func TestNOTPGetSession(t *testing.T) { + pendingRef := "pending_ref" + a, err := newTestAuth(nil, DoOk(func(r *http.Request) { + assert.EqualValues(t, composeNOTPGetSession(), r.URL.RequestURI()) + + body, err := readBodyMap(r) + require.NoError(t, err) + assert.EqualValues(t, pendingRef, body["pendingRef"]) + })) + require.NoError(t, err) + w := httptest.NewRecorder() + info, err := a.NOTP().GetSession(context.Background(), pendingRef, w) + require.NoError(t, err) + assert.NotEmpty(t, info.SessionToken.JWT) + require.Len(t, w.Result().Cookies(), 1) // Just the refresh token +} + +func TestNOTPGetSessionGenerateAuthenticationInfoValidDSRCookie(t *testing.T) { + pendingRef := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + cookie := &http.Cookie{Name: descope.RefreshCookieName, Value: jwtRTokenValid} // valid token + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(mockAuthSessionBodyNoRefreshJwt)), + Header: http.Header{"Set-Cookie": []string{cookie.String()}}, + }, nil + }) + a.conf.SessionJWTViaCookie = true + require.NoError(t, err) + + w := httptest.NewRecorder() + info, err := a.NOTP().GetSession(context.Background(), pendingRef, w) + require.NoError(t, err) + assert.NotEmpty(t, info.SessionToken.JWT) + assert.NotEmpty(t, info.RefreshToken.JWT) // make sure refresh token exist +} + +func TestNOTPGetSessionGenerateAuthenticationInfoInValidDSRCookie(t *testing.T) { + pendingRef := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + cookie := &http.Cookie{Name: descope.RefreshCookieName, Value: jwtTokenExpired} // invalid token + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(mockAuthSessionBodyNoRefreshJwt)), + Header: http.Header{"Set-Cookie": []string{cookie.String()}}, + }, nil + }) + a.conf.SessionJWTViaCookie = true + require.NoError(t, err) + + w := httptest.NewRecorder() + _, err = a.NOTP().GetSession(context.Background(), pendingRef, w) + require.Error(t, err) // should get error as Refresh cookie is invalid +} + +func TesNOTPtGetSessionGenerateAuthenticationInfoNoDSRCookie(t *testing.T) { + pendingRef := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(mockAuthSessionBodyNoRefreshJwt)), + }, nil + }) + a.conf.SessionJWTViaCookie = true + require.NoError(t, err) + + w := httptest.NewRecorder() + info, err := a.NOTP().GetSession(context.Background(), pendingRef, w) + require.NoError(t, err) + assert.NotEmpty(t, info.SessionToken.JWT) + assert.Nil(t, info.RefreshToken) // there is no DSR cookie so refresh token is not exist (not on body and not on cookie) +} + +func TestGetNOTPSessionError(t *testing.T) { + pendingRef := "pending_ref" + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusBadGateway}, nil + }) + require.NoError(t, err) + w := httptest.NewRecorder() + _, err = a.NOTP().GetSession(context.Background(), pendingRef, w) + require.Error(t, err) +} + +func TestSignUpNOTPNoUser(t *testing.T) { + phone := "+111111111111" + a, err := newTestAuth(nil, DoOk(func(r *http.Request) { + assert.EqualValues(t, composeNOTPSignUpURL(), r.URL.RequestURI()) + + m, err := readBodyMap(r) + require.NoError(t, err) + assert.EqualValues(t, phone, m["phone"]) + assert.EqualValues(t, phone, m["loginId"]) + assert.EqualValues(t, phone, m["user"].(map[string]interface{})["phone"]) + })) + require.NoError(t, err) + _, err = a.NOTP().SignUp(context.Background(), phone, nil, nil) + require.NoError(t, err) +} +func TestSignUpOrInNOTPNoLoginID(t *testing.T) { + a, err := newTestAuth(nil, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"pendingRef": "pr1","image": "image1", "redirectUrl":"redirect-1"}`)), + }, nil + }) + require.NoError(t, err) + _, err = a.NOTP().SignUpOrIn(context.Background(), "", nil) + require.NoError(t, err) +} From 7371686b9fc0e3cb431a1ca811fe5a645e508e99 Mon Sep 17 00:00:00 2001 From: Asaf Shen Date: Wed, 17 Apr 2024 17:52:35 +0300 Subject: [PATCH 3/3] improve readme --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5c0796c3..6c6b38ff 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ These sections show how to use the SDK to perform various authentication/authori 2. [Magic Link](#magic-link) 3. [Enchanted Link](#enchanted-link) 4. [OAuth](#oauth) -5. [NOTP] (#notp) +5. [NOTP (WhatsApp)] (#notp-whatsapp) 6. [SSO (SAML / OIDC)](#sso-saml--oidc) 7. [TOTP Authentication](#totp-authentication) 8. [Passwords](#passwords) @@ -262,32 +262,32 @@ The session and refresh JWTs should be returned to the caller, and passed with e ### NOTP (WhatsApp) -Using the NOTP (WhatsApp) APIs enables users to login using their WhatsApp account, according the the following process: -a. The user will be redirected to WhatsApp (with QR Code / link) with a pre-filled message containing a 16 length alphanumeric code. +Using the NOTP (WhatsApp) APIs enables users to log in using their WhatsApp account, according to the following process: +a. The user will be redirected to WhatsApp (with a QR Code or link) with a pre-filled message containing a 16-character alphanumeric code. b. The user will send the message to the WhatsApp Application associated with the Descope project. c. Descope will receive the message, validate the code, and send an approval message back to the user. d. The user will be logged in after receiving the approval message. -Note: the NOTP (WhatsApp) authentication method should be configured in the Descope Console before using it. +Note: The NOTP (WhatsApp) authentication method should be configured in the Descope Console before using it - -The user can either `sign up`, `sign in` or `sign up or in` +The user can either `sign up`, `sign in`, or `sign up or in`: ```go -// If configured globally, the redirect URI is optional. If provided however, it will be used -// instead of any global configuration. -res, err := descopeClient.Auth.EnchantedLink().SignIn(context.Background(), loginID, "http://myapp.com/verify-enchanted-link", nil, nil) +loginID := "" // OR phone number +res, err := descopeClient.Auth.NOTP().SignUpOrIn(context.Background(), loginID, nil, nil) if err != nil { // handle error } -res.RedirectURL // The URL to redirect the user to conversation to the WhatsApp Web Application with the pre-filled message containing the code -res.Image // A QR code image that can be displayed to the user to scan using their mobile device camera app to start the WhatsApp conversation -res.PendingRef // Used to poll for a valid session +// The URL to redirect the user to initiate a conversation in the WhatsApp Web Application with the pre-filled message containing the code +res.RedirectURL +// A QR code image that can be displayed to the user to scan using their mobile device camera app to start the WhatsApp conversation +res.Image +// Used to poll for a valid session +res.PendingRef ``` -After sending the link, you must poll to receive a valid session using the `PendingRef` from -the previous step. A valid session will be returned only after the user sends the message to the WhatsApp Application associated with the Project with the code. +After sending the link, you must poll to receive a valid session using the `PendingRef` from the previous step. A valid session will be returned only after the user sends the message to the WhatsApp Application associated with the Project with the code ```go // Poll for a certain number of tries / time frame @@ -311,7 +311,7 @@ for i := retriesCount; i > 0; i-- { } ``` -The verification process is done using the WhatsApp application by the user sending a message with the token that is included in the link. After sending the message, the user will get an approval message back, and the session polling will receive a valid response. +The verification process is conducted using the WhatsApp application by the user sending a message with the token included in the link. After sending the message, the user will receive an approval message back, and the session polling will then receive a valid response The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation)