diff --git a/README.md b/README.md index 08be82ba4..de6a3deb7 100644 --- a/README.md +++ b/README.md @@ -430,7 +430,7 @@ The default group to assign all new users to. ### External Authentication Providers -We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication. +We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `nextcloud`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication. Use the names as the keys underneath `external` to configure each separately. @@ -461,7 +461,7 @@ The URI a OAuth2 provider will redirect to with the `code` and `state` values. `EXTERNAL_X_URL` - `string` -The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` and `keycloak`. For `gitlab` it defaults to `https://gitlab.com`. For `keycloak` you need to set this to your instance, for example: `https://keycloak.example.com/realms/myrealm` +The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab`, `nextcloud` and `keycloak`. For `gitlab` it defaults to `https://gitlab.com`. For `keycloak` and `nextcloud` you need to set this to your instance, for example: `https://keycloak.example.com/realms/myrealm` #### Apple OAuth @@ -868,8 +868,8 @@ if AUTOCONFIRM is enabled and the sign up is a duplicate, then the endpoint will ```json { - "code":400, - "msg":"User already registered" + "code": 400, + "msg": "User already registered" } ``` @@ -1212,7 +1212,7 @@ Get access_token from external oauth provider query params: ``` -provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos +provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | nextcloud | notion | slack | spotify | twitch | twitter | workos scopes=<optional additional scopes depending on the provider (email and name are requested by default)> ``` diff --git a/example.env b/example.env index e645c96e9..07e697af7 100644 --- a/example.env +++ b/example.env @@ -114,6 +114,13 @@ GOTRUE_EXTERNAL_KAKAO_CLIENT_ID="" GOTRUE_EXTERNAL_KAKAO_SECRET="" GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback" +# Nextcloud OAuth config +GOTRUE_EXTERNAL_NEXTCLOUD_ENABLED="false" +GOTRUE_EXTERNAL_NEXTCLOUD_CLIENT_ID="" +GOTRUE_EXTERNAL_NEXTCLOUD_SECRET="" +GOTRUE_EXTERNAL_NEXTCLOUD_REDIRECT_URI="http://localhost:9999/callback" +GOTRUE_EXTERNAL_NEXTCLOUD_URL="https://nextcloud.example.com/index.php" # May be of form https://cloud.example.org or https://cloud.example.org/index.php + # Notion OAuth config GOTRUE_EXTERNAL_NOTION_ENABLED="false" GOTRUE_EXTERNAL_NOTION_CLIENT_ID="" diff --git a/hack/test.env b/hack/test.env index 35e4b61c8..e0e3053e1 100644 --- a/hack/test.env +++ b/hack/test.env @@ -72,6 +72,11 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED=true GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=testclientid GOTRUE_EXTERNAL_GOOGLE_SECRET=testsecret GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_NEXTCLOUD_ENABLED=true +GOTRUE_EXTERNAL_NEXTCLOUD_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_NEXTCLOUD_SECRET=testsecret +GOTRUE_EXTERNAL_NEXTCLOUD_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_NEXTCLOUD_URL=https://nextcloud.example.com/index.php GOTRUE_EXTERNAL_NOTION_ENABLED=true GOTRUE_EXTERNAL_NOTION_CLIENT_ID=testclientid GOTRUE_EXTERNAL_NOTION_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index 768343d22..e58d3cd52 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -564,6 +564,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewLinkedinProvider(config.External.Linkedin, scopes) case "linkedin_oidc": return provider.NewLinkedinOIDCProvider(config.External.LinkedinOIDC, scopes) + case "nextcloud": + return provider.NewNextcloudProvider(config.External.Nextcloud, scopes) case "notion": return provider.NewNotionProvider(config.External.Notion) case "spotify": diff --git a/internal/api/external_nextcloud_test.go b/internal/api/external_nextcloud_test.go new file mode 100644 index 000000000..5aa6aee94 --- /dev/null +++ b/internal/api/external_nextcloud_test.go @@ -0,0 +1,172 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt/v5" +) + +const ( + nextcloudUser string = `{"ocs":{"data":{"additional_mail":["another@example.com"],"additional_mailScope":["v2-private"],"displayname":"Nextcloud Test","displaynameScope":"v2-private","email":"nextcloud@example.com","emailScope":"v2-private","enabled":true,"id":"123"}}}` + nextcloudUserWrongEmail string = `{"ocs":{"data":{"additional_mail":[],"additional_mailScope":["v2-private"],"displayname":"Nextcloud Test","displaynameScope":"v2-private","email":"other@example.com","emailScope":"v2-private","enabled":true,"id":"123"}}}` + nextcloudUserNoEmail string = `{"ocs":{"data":{"additional_mail":[],"additional_mailScope":["v2-private"],"displayname":"Nextcloud Test","displaynameScope":"v2-private","emailScope":"v2-private","enabled":true,"id":"123"}}}` +) + +func (ts *ExternalTestSuite) TestSignupExternalNextcloud() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=nextcloud", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.Nextcloud.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Nextcloud.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + + claims := ExternalProviderClaims{} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("nextcloud", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func NextcloudTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/apps/oauth2/api/v1/token": + *tokenCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.Nextcloud.RedirectURI, r.FormValue("redirect_uri")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"nextcloud_token","expires_in":100000}`) + case "/ocs/v2.php/cloud/user": + *userCount++ + + // OCS-APIRequest header is needed for that endpoint, so we check it here + ts.Equal("true", r.Header.Get("OCS-APIRequest")) + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, user) + default: + w.WriteHeader(500) + ts.Fail("unknown nextcloud oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.Nextcloud.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalNextcloudAuthorizationCode() { + // emails from Nextcloud don't return confirm status + ts.Config.Mailer.Autoconfirm = true + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser) + defer server.Close() + + u := performAuthorization(ts, "nextcloud", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "nextcloud@example.com", "Nextcloud Test", "123", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalNextcloudDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser) + defer server.Close() + + u := performAuthorization(ts, "nextcloud", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "nextcloud@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalNextcloudDisableSignupErrorWhenEmptyEmail() { + ts.Config.DisableSignup = true + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUserNoEmail) + defer server.Close() + + u := performAuthorization(ts, "nextcloud", code, "") + + assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "nextcloud@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalNextcloudDisableSignupSuccessWithPrimaryEmail() { + ts.Config.DisableSignup = true + + ts.createUser("123", "nextcloud@example.com", "Nextcloud Test", "", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser) + defer server.Close() + + u := performAuthorization(ts, "nextcloud", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "nextcloud@example.com", "Nextcloud Test", "123", "") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudSuccessWhenMatchingToken() { + // name and rest should be populated from Nextcloud API + ts.createUser("123", "nextcloud@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser) + defer server.Close() + + u := performAuthorization(ts, "nextcloud", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "nextcloud@example.com", "Nextcloud Test", "123", "") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "nextcloud", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudErrorWhenWrongToken() { + ts.createUser("123", "nextcloud@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "nextcloud", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudErrorWhenEmailDoesntMatch() { + ts.createUser("123", "nextcloud@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NextcloudTestSignupSetup(ts, &tokenCount, &userCount, code, nextcloudUserWrongEmail) + defer server.Close() + + u := performAuthorization(ts, "nextcloud", code, "invite_token") + + assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "") +} diff --git a/internal/api/provider/nextcloud.go b/internal/api/provider/nextcloud.go new file mode 100644 index 000000000..563614a6a --- /dev/null +++ b/internal/api/provider/nextcloud.go @@ -0,0 +1,135 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/utilities" + "golang.org/x/oauth2" +) + +// Nextcloud + +type nextcloudProvider struct { + *oauth2.Config + Host string +} + +type nextcloudUser struct { + Email string `json:"email"` + Name string `json:"displayname"` + AdditionalEmails []string `json:"additional_mail"` + ID string `json:"id"` + Website string `json:"website"` + Phone string `json:"phone"` + Locale string `json:"locale"` +} + +type nextcloudUserResponse struct { + OCS struct { + Data nextcloudUser `json:"data"` + } `json:"ocs"` +} + +// NewNextcloudProvider creates a Nextcloud account provider. +func NewNextcloudProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + oauthScopes := []string{} + + host := chooseHost(ext.URL, "") // May be https://cloud.example.org or https://cloud.example.org/index.php + return &nextcloudProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: host + "/apps/oauth2/authorize", + TokenURL: host + "/apps/oauth2/api/v1/token", + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + Host: host, + }, nil +} + +func (g nextcloudProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code) +} + +func (g nextcloudProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var resp nextcloudUserResponse + + err := g.makeOCSRequest(ctx, tok, g.Host+"/ocs/v2.php/cloud/user?format=json", &resp) + if err != nil { + return nil, err + } + u := resp.OCS.Data + data := &UserProvidedData{} + + // emails can be confirmed, but we don't have that information + for _, e := range u.AdditionalEmails { + if e != "" { + data.Emails = append(data.Emails, Email{Email: e, Verified: true, Primary: false}) + } + } + + if u.Email != "" { + data.Emails = append(data.Emails, Email{Email: u.Email, Verified: true, Primary: true}) + } + + data.Metadata = &Claims{ + Issuer: g.Host, + Subject: u.ID, + Name: u.Name, + Website: u.Website, + Phone: u.Phone, + Locale: u.Locale, + + // To be deprecated + FullName: u.Name, + ProviderId: u.ID, + } + + return data, nil +} + +func (g nextcloudProvider) makeOCSRequest(ctx context.Context, tok *oauth2.Token, url string, dst interface{}) error { + + // Perform http request, because we neeed to set the Client-Id header + req, err := http.NewRequest("GET", url, nil) + + if err != nil { + return err + } + + req.Header.Set("OCS-APIRequest", "true") + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + + client := &http.Client{Timeout: defaultTimeout} + res, err := client.Do(req) + if err != nil { + return err + } + defer utilities.SafeClose(res.Body) + + bodyBytes, _ := io.ReadAll(res.Body) + res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + return httpError(res.StatusCode, string(bodyBytes)) + } + + if err := json.NewDecoder(res.Body).Decode(dst); err != nil { + return err + } + + return nil + +} diff --git a/internal/api/settings.go b/internal/api/settings.go index bc2f38692..8d0f2b59c 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -18,6 +18,7 @@ type ProviderSettings struct { Kakao bool `json:"kakao"` Linkedin bool `json:"linkedin"` LinkedinOIDC bool `json:"linkedin_oidc"` + Nextcloud bool `json:"nextcloud"` Notion bool `json:"notion"` Spotify bool `json:"spotify"` Slack bool `json:"slack"` @@ -59,6 +60,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Keycloak: config.External.Keycloak.Enabled, Linkedin: config.External.Linkedin.Enabled, LinkedinOIDC: config.External.LinkedinOIDC.Enabled, + Nextcloud: config.External.Nextcloud.Enabled, Notion: config.External.Notion.Enabled, Spotify: config.External.Spotify.Enabled, Slack: config.External.Slack.Enabled, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 767bcf784..d6b7598c0 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -32,6 +32,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Bitbucket) require.True(t, p.Discord) require.True(t, p.Facebook) + require.True(t, p.Nextcloud) require.True(t, p.Notion) require.True(t, p.Spotify) require.True(t, p.Slack) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 5ac871c9e..6a76daf2d 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -321,6 +321,7 @@ type ProviderConfiguration struct { Gitlab OAuthProviderConfiguration `json:"gitlab"` Google OAuthProviderConfiguration `json:"google"` Kakao OAuthProviderConfiguration `json:"kakao"` + Nextcloud OAuthProviderConfiguration `json:"nextcloud"` Notion OAuthProviderConfiguration `json:"notion"` Keycloak OAuthProviderConfiguration `json:"keycloak"` Linkedin OAuthProviderConfiguration `json:"linkedin"` diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 1002d8be1..0337ebbe2 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -114,6 +114,13 @@ GOTRUE_EXTERNAL_KAKAO_CLIENT_ID="" GOTRUE_EXTERNAL_KAKAO_SECRET="" GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback" +# Nextcloud OAuth config +GOTRUE_EXTERNAL_NEXTCLOUD_ENABLED="false" +GOTRUE_EXTERNAL_NEXTCLOUD_CLIENT_ID="" +GOTRUE_EXTERNAL_NEXTCLOUD_SECRET="" +GOTRUE_EXTERNAL_NEXTCLOUD_REDIRECT_URI="https://localhost:9999/callback" +GOTRUE_EXTERNAL_NEXTCLOUD_URL="https://nextcloud.example.com/index.php" + # Notion OAuth config GOTRUE_EXTERNAL_NOTION_ENABLED="false" GOTRUE_EXTERNAL_NOTION_CLIENT_ID=""