Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Nextcloud oauth #1962

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"
}
```

Expand Down Expand Up @@ -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)>
```
Expand Down
7 changes: 7 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
5 changes: 5 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
172 changes: 172 additions & 0 deletions internal/api/external_nextcloud_test.go
Original file line number Diff line number Diff line change
@@ -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":["[email protected]"],"additional_mailScope":["v2-private"],"displayname":"Nextcloud Test","displaynameScope":"v2-private","email":"[email protected]","emailScope":"v2-private","enabled":true,"id":"123"}}}`
nextcloudUserWrongEmail string = `{"ocs":{"data":{"additional_mail":[],"additional_mailScope":["v2-private"],"displayname":"Nextcloud Test","displaynameScope":"v2-private","email":"[email protected]","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, "[email protected]", "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", "[email protected]")
}

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", "[email protected]")
}

func (ts *ExternalTestSuite) TestSignupExternalNextcloudDisableSignupSuccessWithPrimaryEmail() {
ts.Config.DisableSignup = true

ts.createUser("123", "[email protected]", "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, "[email protected]", "Nextcloud Test", "123", "")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalNextcloudSuccessWhenMatchingToken() {
// name and rest should be populated from Nextcloud API
ts.createUser("123", "[email protected]", "", "", "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, "[email protected]", "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", "[email protected]", "", "", "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", "[email protected]", "", "", "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", "")
}
135 changes: 135 additions & 0 deletions internal/api/provider/nextcloud.go
Original file line number Diff line number Diff line change
@@ -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

}
2 changes: 2 additions & 0 deletions internal/api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down
Loading