405 lines
12 KiB
Go
405 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
jwt "github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
"github.com/supabase/auth/internal/conf"
|
|
"github.com/supabase/auth/internal/crypto"
|
|
"github.com/supabase/auth/internal/models"
|
|
)
|
|
|
|
type InviteTestSuite struct {
|
|
suite.Suite
|
|
API *API
|
|
Config *conf.GlobalConfiguration
|
|
|
|
token string
|
|
}
|
|
|
|
func TestInvite(t *testing.T) {
|
|
api, config, err := setupAPIForTest()
|
|
require.NoError(t, err)
|
|
|
|
ts := &InviteTestSuite{
|
|
API: api,
|
|
Config: config,
|
|
}
|
|
defer api.db.Close()
|
|
|
|
suite.Run(t, ts)
|
|
}
|
|
|
|
func (ts *InviteTestSuite) SetupTest() {
|
|
models.TruncateAll(ts.API.db)
|
|
|
|
// Setup response recorder with super admin privileges
|
|
ts.token = ts.makeSuperAdmin("")
|
|
}
|
|
|
|
func (ts *InviteTestSuite) makeSuperAdmin(email string) string {
|
|
// Cleanup existing user, if they already exist
|
|
if u, _ := models.FindUserByEmailAndAudience(ts.API.db, email, ts.Config.JWT.Aud); u != nil {
|
|
require.NoError(ts.T(), ts.API.db.Destroy(u), "Error deleting user")
|
|
}
|
|
|
|
u, err := models.NewUser("123456789", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"})
|
|
require.NoError(ts.T(), err, "Error making new user")
|
|
require.NoError(ts.T(), ts.API.db.Create(u))
|
|
|
|
u.Role = "supabase_admin"
|
|
|
|
var token string
|
|
|
|
session, err := models.NewSession(u.ID, nil)
|
|
require.NoError(ts.T(), err)
|
|
require.NoError(ts.T(), ts.API.db.Create(session))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/invite", nil)
|
|
token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.Invite)
|
|
|
|
require.NoError(ts.T(), err, "Error generating access token")
|
|
|
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
|
_, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) {
|
|
return []byte(ts.Config.JWT.Secret), nil
|
|
})
|
|
require.NoError(ts.T(), err, "Error parsing token")
|
|
|
|
return token
|
|
}
|
|
|
|
func (ts *InviteTestSuite) TestInvite() {
|
|
// Request body
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"email": "test@example.com",
|
|
"data": map[string]interface{}{
|
|
"a": 1,
|
|
},
|
|
}))
|
|
|
|
// Setup request
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
|
|
|
// Setup response recorder
|
|
w := httptest.NewRecorder()
|
|
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
assert.Equal(ts.T(), http.StatusOK, w.Code)
|
|
}
|
|
|
|
func (ts *InviteTestSuite) TestInviteAfterSignupShouldNotReturnSensitiveFields() {
|
|
// To allow us to send signup and invite request in succession
|
|
ts.Config.SMTP.MaxFrequency = 5
|
|
// Request body
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"email": "test@example.com",
|
|
"data": map[string]interface{}{
|
|
"a": 1,
|
|
},
|
|
}))
|
|
|
|
// Setup request
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
|
|
|
// Setup response recorder
|
|
w := httptest.NewRecorder()
|
|
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
assert.Equal(ts.T(), http.StatusOK, w.Code)
|
|
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"email": "test@example.com",
|
|
"password": "test123",
|
|
"data": map[string]interface{}{
|
|
"a": 1,
|
|
},
|
|
}))
|
|
|
|
// Setup request
|
|
req = httptest.NewRequest(http.MethodPost, "/signup", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Setup response recorder
|
|
x := httptest.NewRecorder()
|
|
|
|
ts.API.handler.ServeHTTP(x, req)
|
|
|
|
require.Equal(ts.T(), http.StatusOK, x.Code)
|
|
|
|
data := models.User{}
|
|
require.NoError(ts.T(), json.NewDecoder(x.Body).Decode(&data))
|
|
// Sensitive fields
|
|
require.Equal(ts.T(), 0, len(data.Identities))
|
|
require.Equal(ts.T(), 0, len(data.UserMetaData))
|
|
}
|
|
|
|
func (ts *InviteTestSuite) TestInvite_WithoutAccess() {
|
|
// Request body
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"email": "test@example.com",
|
|
"data": map[string]interface{}{
|
|
"a": 1,
|
|
},
|
|
}))
|
|
|
|
// Setup request
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Setup response recorder
|
|
w := httptest.NewRecorder()
|
|
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
assert.Equal(ts.T(), http.StatusUnauthorized, w.Code) // 401 OK because the invite request above has no Authorization header
|
|
}
|
|
|
|
func (ts *InviteTestSuite) TestVerifyInvite() {
|
|
cases := []struct {
|
|
desc string
|
|
email string
|
|
requestBody map[string]interface{}
|
|
expected int
|
|
}{
|
|
{
|
|
"Verify invite with password",
|
|
"test@example.com",
|
|
map[string]interface{}{
|
|
"email": "test@example.com",
|
|
"type": "invite",
|
|
"token": "asdf",
|
|
"password": "testing",
|
|
},
|
|
http.StatusOK,
|
|
},
|
|
{
|
|
"Verify invite with no password",
|
|
"test1@example.com",
|
|
map[string]interface{}{
|
|
"email": "test1@example.com",
|
|
"type": "invite",
|
|
"token": "asdf",
|
|
},
|
|
http.StatusOK,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
ts.Run(c.desc, func() {
|
|
user, err := models.NewUser("", c.email, "", ts.Config.JWT.Aud, nil)
|
|
now := time.Now()
|
|
user.InvitedAt = &now
|
|
user.ConfirmationSentAt = &now
|
|
user.EncryptedPassword = nil
|
|
user.ConfirmationToken = crypto.GenerateTokenHash(c.email, c.requestBody["token"].(string))
|
|
require.NoError(ts.T(), err)
|
|
require.NoError(ts.T(), ts.API.db.Create(user))
|
|
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, user.ID, user.GetEmail(), user.ConfirmationToken, models.ConfirmationToken))
|
|
|
|
// Find test user
|
|
_, err = models.FindUserByEmailAndAudience(ts.API.db, c.email, ts.Config.JWT.Aud)
|
|
require.NoError(ts.T(), err)
|
|
|
|
// Request body
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.requestBody))
|
|
|
|
// Setup request
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Setup response recorder
|
|
w := httptest.NewRecorder()
|
|
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(ts.T(), c.expected, w.Code, w.Body.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func (ts *InviteTestSuite) TestInviteExternalGitlab() {
|
|
tokenCount, userCount := 0, 0
|
|
code := "authcode"
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/oauth/token":
|
|
tokenCount++
|
|
ts.Equal(code, r.FormValue("code"))
|
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
|
ts.Equal(ts.Config.External.Gitlab.RedirectURI, r.FormValue("redirect_uri"))
|
|
|
|
w.Header().Add("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"access_token":"gitlab_token","expires_in":100000}`)
|
|
case "/api/v4/user":
|
|
userCount++
|
|
w.Header().Add("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"name":"Gitlab Test","email":"gitlab@example.com","avatar_url":"http://example.com/avatar","confirmed_at": "2020-01-01T00:00:00.000Z"}`)
|
|
case "/api/v4/user/emails":
|
|
w.Header().Add("Content-Type", "application/json")
|
|
fmt.Fprint(w, `[]`)
|
|
default:
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
ts.Fail("unknown gitlab oauth call %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
ts.Config.External.Gitlab.URL = server.URL
|
|
|
|
// invite user
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(InviteParams{
|
|
Email: "gitlab@example.com",
|
|
}))
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
|
|
|
w := httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
ts.Require().Equal(http.StatusOK, w.Code)
|
|
|
|
// Find test user
|
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "gitlab@example.com", ts.Config.JWT.Aud)
|
|
require.NoError(ts.T(), err)
|
|
|
|
// get redirect url w/ state
|
|
req = httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=gitlab&invite_token="+user.ConfirmationToken, 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()
|
|
state := q.Get("state")
|
|
|
|
// auth server callback
|
|
testURL, err := url.Parse("http://localhost/callback")
|
|
ts.Require().NoError(err)
|
|
v := testURL.Query()
|
|
v.Set("code", code)
|
|
v.Set("state", state)
|
|
testURL.RawQuery = v.Encode()
|
|
req = httptest.NewRequest(http.MethodGet, testURL.String(), 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")
|
|
|
|
// ensure redirect has #access_token=...
|
|
v, err = url.ParseQuery(u.Fragment)
|
|
ts.Require().NoError(err)
|
|
ts.Require().Empty(v.Get("error_description"))
|
|
ts.Require().Empty(v.Get("error"))
|
|
|
|
ts.NotEmpty(v.Get("access_token"))
|
|
ts.NotEmpty(v.Get("refresh_token"))
|
|
ts.NotEmpty(v.Get("expires_in"))
|
|
ts.Equal("bearer", v.Get("token_type"))
|
|
|
|
ts.Equal(1, tokenCount)
|
|
ts.Equal(1, userCount)
|
|
|
|
// ensure user has been created with metadata
|
|
user, err = models.FindUserByEmailAndAudience(ts.API.db, "gitlab@example.com", ts.Config.JWT.Aud)
|
|
ts.Require().NoError(err)
|
|
ts.Equal("Gitlab Test", user.UserMetaData["full_name"])
|
|
ts.Equal("http://example.com/avatar", user.UserMetaData["avatar_url"])
|
|
ts.Equal("gitlab", user.AppMetaData["provider"])
|
|
ts.Equal([]interface{}{"gitlab"}, user.AppMetaData["providers"])
|
|
}
|
|
|
|
func (ts *InviteTestSuite) TestInviteExternalGitlab_MismatchedEmails() {
|
|
tokenCount, userCount := 0, 0
|
|
code := "authcode"
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/oauth/token":
|
|
tokenCount++
|
|
ts.Equal(code, r.FormValue("code"))
|
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
|
ts.Equal(ts.Config.External.Gitlab.RedirectURI, r.FormValue("redirect_uri"))
|
|
|
|
w.Header().Add("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"access_token":"gitlab_token","expires_in":100000}`)
|
|
case "/api/v4/user":
|
|
userCount++
|
|
w.Header().Add("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"name":"Gitlab Test","email":"gitlab+mismatch@example.com","avatar_url":"http://example.com/avatar","confirmed_at": "2020-01-01T00:00:00.000Z"}`)
|
|
case "/api/v4/user/emails":
|
|
w.Header().Add("Content-Type", "application/json")
|
|
fmt.Fprint(w, `[]`)
|
|
default:
|
|
w.WriteHeader(500)
|
|
ts.Fail("unknown gitlab oauth call %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
ts.Config.External.Gitlab.URL = server.URL
|
|
|
|
// invite user
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(InviteParams{
|
|
Email: "gitlab@example.com",
|
|
}))
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
|
|
|
w := httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
ts.Require().Equal(http.StatusOK, w.Code)
|
|
|
|
// Find test user
|
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "gitlab@example.com", ts.Config.JWT.Aud)
|
|
require.NoError(ts.T(), err)
|
|
|
|
// get redirect url w/ state
|
|
req = httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=gitlab&invite_token="+user.ConfirmationToken, 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()
|
|
state := q.Get("state")
|
|
|
|
// auth server callback
|
|
testURL, err := url.Parse("http://localhost/callback")
|
|
ts.Require().NoError(err)
|
|
v := testURL.Query()
|
|
v.Set("code", code)
|
|
v.Set("state", state)
|
|
testURL.RawQuery = v.Encode()
|
|
req = httptest.NewRequest(http.MethodGet, testURL.String(), 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")
|
|
|
|
// ensure redirect has #access_token=...
|
|
v, err = url.ParseQuery(u.RawQuery)
|
|
ts.Require().NoError(err, u.RawQuery)
|
|
ts.Require().NotEmpty(v.Get("error_description"))
|
|
ts.Require().Equal("invalid_request", v.Get("error"))
|
|
}
|