diff --git a/README.md b/README.md index b1569d7f..6c6b38ff 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 (WhatsApp)] (#notp-whatsapp) +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 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 + +The user can either `sign up`, `sign in`, or `sign up or in`: + +```go +loginID := "" // OR phone number +res, err := descopeClient.Auth.NOTP().SignUpOrIn(context.Background(), loginID, nil, nil) +if err != nil { + // handle error +} + +// 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 + +```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 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) + ### 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/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) +} 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"`