diff --git a/README.md b/README.md index 26ed09be..2a2f2e47 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,16 @@ if err != nil { } ``` +Users can also connect the social login account to their existing user: +```go +// A valid Refresh Token of the existing user is required and will be taken from the request header or cookies automatically. +// If allowAllMerge is 'true' the users will be merged also if there is no common identifier between the social provider and the existing user (like email). +url, err := descopeClient.Auth.OAuth().UpdateUser(context.Background(), "google", "https://my-app.com/handle-oauth", true, nil, nil, w) +if err != nil { + // handle error +} +``` + 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) diff --git a/descope/api/client.go b/descope/api/client.go index e6c4369d..6bf2544b 100644 --- a/descope/api/client.go +++ b/descope/api/client.go @@ -61,6 +61,7 @@ var ( oauthSignUpOrIn: "auth/oauth/authorize", oauthSignUp: "auth/oauth/authorize/signup", oauthSignIn: "auth/oauth/authorize/signin", + oauthUpdateUser: "auth/oauth/authorize/update", exchangeTokenOAuth: "auth/oauth/exchange", samlStart: "auth/saml/authorize", exchangeTokenSAML: "auth/saml/exchange", @@ -237,6 +238,7 @@ type authEndpoints struct { oauthSignUpOrIn string oauthSignUp string oauthSignIn string + oauthUpdateUser string exchangeTokenOAuth string samlStart string ssoStart string @@ -485,6 +487,10 @@ func (e *endpoints) OAuthSignUp() string { return path.Join(e.version, e.auth.oauthSignUp) } +func (e *endpoints) OAuthUpdateUser() string { + return path.Join(e.version, e.auth.oauthUpdateUser) +} + func (e *endpoints) ExchangeTokenOAuth() string { return path.Join(e.version, e.auth.exchangeTokenOAuth) } diff --git a/descope/internal/auth/auth.go b/descope/internal/auth/auth.go index 428f3f4b..be70407c 100644 --- a/descope/internal/auth/auth.go +++ b/descope/internal/auth/auth.go @@ -905,6 +905,10 @@ func composeOAuthSignUpURL() string { return api.Routes.OAuthSignUp() } +func composeOAuthUpdateUserURL() string { + return api.Routes.OAuthUpdateUser() +} + func composeOAuthExchangeTokenURL() string { return api.Routes.ExchangeTokenOAuth() } diff --git a/descope/internal/auth/oauth.go b/descope/internal/auth/oauth.go index b8d7fb9a..952798f6 100644 --- a/descope/internal/auth/oauth.go +++ b/descope/internal/auth/oauth.go @@ -3,6 +3,7 @@ package auth import ( "context" "net/http" + "strconv" "github.com/descope/go-sdk/descope" "github.com/descope/go-sdk/descope/api" @@ -18,13 +19,8 @@ type oauthStartResponse struct { URL string `json:"url"` } -func (auth *oauth) start(ctx context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter, authorizeURL string) (url string, err error) { - m := map[string]string{ - "provider": string(provider), - } - if len(redirectURL) > 0 { - m["redirectURL"] = redirectURL - } +func (auth *oauth) startAuth(ctx context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter, authorizeURL string) (url string, err error) { + m := generateOAuthRequestParams(ctx, provider, redirectURL) var pswd string if loginOptions.IsJWTRequired() { pswd, err = getValidRefreshToken(r) @@ -33,7 +29,24 @@ func (auth *oauth) start(ctx context.Context, provider descope.OAuthProvider, re } } - httpResponse, err := auth.client.DoPostRequest(ctx, authorizeURL, loginOptions, &api.HTTPRequest{QueryParams: m}, pswd) + return auth.doStart(ctx, provider, m, pswd, loginOptions, w, authorizeURL) +} + +func (auth *oauth) startUpdate(ctx context.Context, provider descope.OAuthProvider, redirectURL string, allowAllMerge bool, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter, authorizeURL string) (url string, err error) { + m := generateOAuthRequestParams(ctx, provider, redirectURL) + if allowAllMerge { + m["allowAllMerge"] = strconv.FormatBool(allowAllMerge) + } + pswd, err := getValidRefreshToken(r) + if err != nil { + return "", descope.ErrRefreshToken + } + + return auth.doStart(ctx, provider, m, pswd, loginOptions, w, authorizeURL) +} + +func (auth *oauth) doStart(ctx context.Context, provider descope.OAuthProvider, params map[string]string, pswd string, loginOptions *descope.LoginOptions, w http.ResponseWriter, authorizeURL string) (url string, err error) { + httpResponse, err := auth.client.DoPostRequest(ctx, authorizeURL, loginOptions, &api.HTTPRequest{QueryParams: params}, pswd) if err != nil { return } @@ -48,20 +61,23 @@ func (auth *oauth) start(ctx context.Context, provider descope.OAuthProvider, re url = res.URL redirectToURL(url, w) } - return } func (auth *oauth) SignUpOrIn(ctx context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (url string, err error) { - return auth.start(ctx, provider, redirectURL, r, loginOptions, w, composeOAuthSignUpOrInURL()) + return auth.startAuth(ctx, provider, redirectURL, r, loginOptions, w, composeOAuthSignUpOrInURL()) } func (auth *oauth) SignUp(ctx context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (url string, err error) { - return auth.start(ctx, provider, redirectURL, r, loginOptions, w, composeOAuthSignUpURL()) + return auth.startAuth(ctx, provider, redirectURL, r, loginOptions, w, composeOAuthSignUpURL()) } func (auth *oauth) SignIn(ctx context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (url string, err error) { - return auth.start(ctx, provider, redirectURL, r, loginOptions, w, composeOAuthSignInURL()) + return auth.startAuth(ctx, provider, redirectURL, r, loginOptions, w, composeOAuthSignInURL()) +} + +func (auth *oauth) UpdateUser(ctx context.Context, provider descope.OAuthProvider, redirectURL string, allowAllMerge bool, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (url string, err error) { + return auth.startUpdate(ctx, provider, redirectURL, allowAllMerge, r, loginOptions, w, composeOAuthUpdateUserURL()) } func (auth *oauth) Start(ctx context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (url string, err error) { @@ -71,3 +87,14 @@ func (auth *oauth) Start(ctx context.Context, provider descope.OAuthProvider, re func (auth *oauth) ExchangeToken(ctx context.Context, code string, w http.ResponseWriter) (*descope.AuthenticationInfo, error) { return auth.exchangeToken(ctx, code, composeOAuthExchangeTokenURL(), w) } + +func generateOAuthRequestParams(_ context.Context, provider descope.OAuthProvider, redirectURL string) map[string]string { + params := map[string]string{ + "provider": string(provider), + } + if len(redirectURL) > 0 { + params["redirectURL"] = redirectURL + } + + return params +} diff --git a/descope/internal/auth/oauth_test.go b/descope/internal/auth/oauth_test.go index 3d671327..49358406 100644 --- a/descope/internal/auth/oauth_test.go +++ b/descope/internal/auth/oauth_test.go @@ -66,6 +66,33 @@ func TestOAuthSignUpForwardResponse(t *testing.T) { assert.EqualValues(t, http.StatusTemporaryRedirect, w.Result().StatusCode) } +func TestOAuthUpdateUserForwardResponse(t *testing.T) { + uri := "http://test.me" + landingURL := "https://test.com" + provider := descope.OAuthGithub + a, err := newTestAuth(nil, DoRedirect(uri, func(r *http.Request) { + assert.EqualValues(t, fmt.Sprintf("%s?allowAllMerge=%s&provider=%s&redirectURL=%s", composeOAuthUpdateUserURL(), "true", provider, url.QueryEscape(landingURL)), r.URL.RequestURI()) + })) + require.NoError(t, err) + w := httptest.NewRecorder() + urlStr, err := a.OAuth().UpdateUser(context.Background(), provider, landingURL, true, &http.Request{Header: http.Header{"Cookie": []string{"DSR=test"}}}, nil, w) + require.NoError(t, err) + assert.EqualValues(t, uri, urlStr) + assert.EqualValues(t, urlStr, w.Result().Header.Get(descope.RedirectLocationCookieName)) + assert.EqualValues(t, http.StatusTemporaryRedirect, w.Result().StatusCode) +} + +func TestOAuthUpdateUserForwardResponsepNoJWT(t *testing.T) { + uri := "http://test.me" + landingURL := "https://test.com" + provider := descope.OAuthGithub + a, err := newTestAuth(nil, DoRedirect(uri, func(r *http.Request) {})) + require.NoError(t, err) + w := httptest.NewRecorder() + _, err = a.OAuth().UpdateUser(context.Background(), provider, landingURL, true, nil, nil, w) + assert.ErrorIs(t, err, descope.ErrRefreshToken) +} + func TestOAuthStartForwardResponseStepup(t *testing.T) { uri := "http://test.me" landingURL := "https://test.com" diff --git a/descope/sdk/auth.go b/descope/sdk/auth.go index 4627bcd7..6e92626e 100644 --- a/descope/sdk/auth.go +++ b/descope/sdk/auth.go @@ -202,6 +202,13 @@ type OAuth interface { // A successful authentication will result in a callback to the url defined in the current project settings. SignIn(ctx context.Context, provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) + // UserUpdate - Use to start an OAuth authentication using the given OAuthProvider to enable an existing user to sign in also with OAuth method. + // returns an error upon failure and a string represent the redirect URL upon success. + // Uses the response writer to automatically redirect the client to the provider url for authentication. + // A successful authentication will result in a callback to the url defined in the current project settings. + // allowAllMerge - allow updating the existing user also if there is no common identifier between the OAuth and the existing user (like email) + UpdateUser(ctx context.Context, provider descope.OAuthProvider, returnURL string, allowAllMerge bool, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) + // ExchangeToken - Finalize OAuth // code should be extracted from the redirect URL of OAth/SAML authentication flow // **Important:** The redirect URL might not contain a code URL parameter diff --git a/descope/tests/mocks/auth/authenticationmock.go b/descope/tests/mocks/auth/authenticationmock.go index b123da4f..8464d905 100644 --- a/descope/tests/mocks/auth/authenticationmock.go +++ b/descope/tests/mocks/auth/authenticationmock.go @@ -360,55 +360,66 @@ func (m *MockPassword) GetPasswordPolicy(_ context.Context) (*descope.PasswordPo // Mock OAuth type MockOAuth struct { - StartAssert func(provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) + StartAssert func(provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) StartError error StartResponse string - SignUpOrInAssert func(provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) + SignUpOrInAssert func(provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) SignUpOrInError error SignUpOrInResponse string - SignInAssert func(provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) + SignInAssert func(provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) SignInError error SignInResponse string - SignUpAssert func(provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) + SignUpAssert func(provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) SignUpError error SignUpResponse string + UpdateUserAssert func(provider descope.OAuthProvider, redirectURL string, allowAllMerge bool, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) + UpdateUserError error + UpdateUserResponse string + ExchangeTokenAssert func(code string, w http.ResponseWriter) ExchangeTokenError error ExchangeTokenResponse *descope.AuthenticationInfo } -func (m *MockOAuth) Start(_ context.Context, provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { +func (m *MockOAuth) Start(_ context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { if m.StartAssert != nil { - m.StartAssert(provider, returnURL, r, loginOptions, w) + m.StartAssert(provider, redirectURL, r, loginOptions, w) } return m.StartResponse, m.StartError } -func (m *MockOAuth) SignUpOrIn(_ context.Context, provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { +func (m *MockOAuth) SignUpOrIn(_ context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { if m.SignUpOrInAssert != nil { - m.SignUpOrInAssert(provider, returnURL, r, loginOptions, w) + m.SignUpOrInAssert(provider, redirectURL, r, loginOptions, w) } return m.SignUpOrInResponse, m.SignUpOrInError } -func (m *MockOAuth) SignIn(_ context.Context, provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { +func (m *MockOAuth) SignIn(_ context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { if m.SignInAssert != nil { - m.SignInAssert(provider, returnURL, r, loginOptions, w) + m.SignInAssert(provider, redirectURL, r, loginOptions, w) } return m.SignInResponse, m.SignInError } -func (m *MockOAuth) SignUp(_ context.Context, provider descope.OAuthProvider, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { +func (m *MockOAuth) SignUp(_ context.Context, provider descope.OAuthProvider, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { if m.SignUpAssert != nil { - m.SignUpAssert(provider, returnURL, r, loginOptions, w) + m.SignUpAssert(provider, redirectURL, r, loginOptions, w) } return m.SignUpResponse, m.SignUpError } +func (m *MockOAuth) UpdateUser(_ context.Context, provider descope.OAuthProvider, redirectURL string, allowAllMerge bool, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { + if m.UpdateUserAssert != nil { + m.UpdateUserAssert(provider, redirectURL, allowAllMerge, r, loginOptions, w) + } + return m.UpdateUserResponse, m.UpdateUserError +} + func (m *MockOAuth) ExchangeToken(_ context.Context, code string, w http.ResponseWriter) (*descope.AuthenticationInfo, error) { if m.ExchangeTokenAssert != nil { m.ExchangeTokenAssert(code, w) @@ -419,7 +430,7 @@ func (m *MockOAuth) ExchangeToken(_ context.Context, code string, w http.Respons // Mock SAML type MockSAML struct { - StartAssert func(tenant string, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) + StartAssert func(tenant string, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) StartError error StartResponse string @@ -428,9 +439,9 @@ type MockSAML struct { ExchangeTokenResponse *descope.AuthenticationInfo } -func (m *MockSAML) Start(_ context.Context, tenant string, returnURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (redirectURL string, err error) { +func (m *MockSAML) Start(_ context.Context, tenant string, redirectURL string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { if m.StartAssert != nil { - m.StartAssert(tenant, returnURL, r, loginOptions, w) + m.StartAssert(tenant, redirectURL, r, loginOptions, w) } return m.StartResponse, m.StartError } @@ -445,7 +456,7 @@ func (m *MockSAML) ExchangeToken(_ context.Context, code string, w http.Response // Mock SSO type MockSSO struct { - StartAssert func(tenant string, returnURL string, prompt string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) + StartAssert func(tenant string, redirectURL string, prompt string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) StartError error StartResponse string @@ -454,9 +465,9 @@ type MockSSO struct { ExchangeTokenResponse *descope.AuthenticationInfo } -func (m *MockSSO) Start(_ context.Context, tenant string, returnURL string, prompt string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (redirectURL string, err error) { +func (m *MockSSO) Start(_ context.Context, tenant string, redirectURL string, prompt string, r *http.Request, loginOptions *descope.LoginOptions, w http.ResponseWriter) (string, error) { if m.StartAssert != nil { - m.StartAssert(tenant, returnURL, prompt, r, loginOptions, w) + m.StartAssert(tenant, redirectURL, prompt, r, loginOptions, w) } return m.StartResponse, m.StartError }