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

Generic OAuth provider #1372

Open
wants to merge 3 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,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`, `generic`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication.

Use the names as the keys underneath `external` to configure each separately.

Expand Down
30 changes: 30 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,36 @@ GOTRUE_EXTERNAL_FIGMA_CLIENT_ID=""
GOTRUE_EXTERNAL_FIGMA_SECRET=""
GOTRUE_EXTERNAL_FIGMA_REDIRECT_URI="https://localhost:9999/callback"

# Generic OAuth config #1
GOTRUE_EXTERNAL_GENERIC1_ENABLED="false"
GOTRUE_EXTERNAL_GENERIC1_CLIENT_ID=""
GOTRUE_EXTERNAL_GENERIC1_SECRET=""
GOTRUE_EXTERNAL_GENERIC1_REDIRECT_URI="http://localhost:9999/callback"
GOTRUE_EXTERNAL_GENERIC1_AUTH_URL="http://localhost:3000/authorize"
GOTRUE_EXTERNAL_GENERIC1_TOKEN_URL="http://localhost:3000/token"
GOTRUE_EXTERNAL_GENERIC1_API_URL="http://localhost:3000/profile"
GOTRUE_EXTERNAL_GENERIC1_USER_DATA_MAPPING="Email:fieldInAPIPayload,AnotherGotrueClaim:another.nested.field"

# Generic OAuth config #2
GOTRUE_EXTERNAL_GENERIC2_ENABLED="false"
GOTRUE_EXTERNAL_GENERIC2_CLIENT_ID=""
GOTRUE_EXTERNAL_GENERIC2_SECRET=""
GOTRUE_EXTERNAL_GENERIC2_REDIRECT_URI="http://localhost:9999/callback"
GOTRUE_EXTERNAL_GENERIC2_AUTH_URL="http://localhost:3000/authorize"
GOTRUE_EXTERNAL_GENERIC2_TOKEN_URL="http://localhost:3000/token"
GOTRUE_EXTERNAL_GENERIC2_API_URL="http://localhost:3000/profile"
GOTRUE_EXTERNAL_GENERIC2_USER_DATA_MAPPING="Email:fieldInAPIPayload,AnotherGotrueClaim:another.nested.field"

# Generic OAuth config #3
GOTRUE_EXTERNAL_GENERIC3_ENABLED="false"
GOTRUE_EXTERNAL_GENERIC3_CLIENT_ID=""
GOTRUE_EXTERNAL_GENERIC3_SECRET=""
GOTRUE_EXTERNAL_GENERIC3_REDIRECT_URI="http://localhost:9999/callback"
GOTRUE_EXTERNAL_GENERIC3_AUTH_URL="http://localhost:3000/authorize"
GOTRUE_EXTERNAL_GENERIC3_TOKEN_URL="http://localhost:3000/token"
GOTRUE_EXTERNAL_GENERIC3_API_URL="http://localhost:3000/profile"
GOTRUE_EXTERNAL_GENERIC3_USER_DATA_MAPPING="Email:fieldInAPIPayload,AnotherGotrueClaim:another.nested.field"

# Gitlab OAuth config
GOTRUE_EXTERNAL_GITLAB_ENABLED="false"
GOTRUE_EXTERNAL_GITLAB_CLIENT_ID=""
Expand Down
24 changes: 24 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,30 @@ GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_LINKEDIN_SECRET=testsecret
GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI=https://identity.services.netlify.com/callback
GOTRUE_EXTERNAL_GENERIC1_ENABLED=true
GOTRUE_EXTERNAL_GENERIC1_CLIENT_ID="generic1_client_id"
GOTRUE_EXTERNAL_GENERIC1_SECRET="generic1_client_secret"
GOTRUE_EXTERNAL_GENERIC1_REDIRECT_URI="https://identity.services.netlify.com/callback"
GOTRUE_EXTERNAL_GENERIC1_AUTH_URL="https://myidentityprovider.example.com/authorize"
GOTRUE_EXTERNAL_GENERIC1_TOKEN_URL="https://myidentityprovider.example.com/token"
GOTRUE_EXTERNAL_GENERIC1_API_URL="https://myidentityprovider.example.com/profile"
GOTRUE_EXTERNAL_GENERIC1_USER_DATA_MAPPING="Subject:id,Email:generic_account.email,EmailVerified:generic_account.is_email_verified,Name:generic_account.profile.nickname,Avatar:generic_account.profile.profile_image_url"
GOTRUE_EXTERNAL_GENERIC2_ENABLED=true
GOTRUE_EXTERNAL_GENERIC2_CLIENT_ID="generic2_client_id"
GOTRUE_EXTERNAL_GENERIC2_SECRET="generic2_client_secret"
GOTRUE_EXTERNAL_GENERIC2_REDIRECT_URI="https://identity.services.netlify.com/callback"
GOTRUE_EXTERNAL_GENERIC2_AUTH_URL="https://myidentityprovider.example.com/authorize"
GOTRUE_EXTERNAL_GENERIC2_TOKEN_URL="https://myidentityprovider.example.com/token"
GOTRUE_EXTERNAL_GENERIC2_API_URL="https://myidentityprovider.example.com/profile"
GOTRUE_EXTERNAL_GENERIC2_USER_DATA_MAPPING="Subject:id,Email:generic_account.email,EmailVerified:generic_account.is_email_verified,Name:generic_account.profile.nickname,Avatar:generic_account.profile.profile_image_url"
GOTRUE_EXTERNAL_GENERIC3_ENABLED=true
GOTRUE_EXTERNAL_GENERIC3_CLIENT_ID="generic3_client_id"
GOTRUE_EXTERNAL_GENERIC3_SECRET="generic3_client_secret"
GOTRUE_EXTERNAL_GENERIC3_REDIRECT_URI="https://identity.services.netlify.com/callback"
GOTRUE_EXTERNAL_GENERIC3_AUTH_URL="https://myidentityprovider.example.com/authorize"
GOTRUE_EXTERNAL_GENERIC3_TOKEN_URL="https://myidentityprovider.example.com/token"
GOTRUE_EXTERNAL_GENERIC3_API_URL="https://myidentityprovider.example.com/profile"
GOTRUE_EXTERNAL_GENERIC3_USER_DATA_MAPPING="Subject:id,Email:generic_account.email,EmailVerified:generic_account.is_email_verified,Name:generic_account.profile.nickname,Avatar:generic_account.profile.profile_image_url"
GOTRUE_EXTERNAL_GITLAB_ENABLED=true
GOTRUE_EXTERNAL_GITLAB_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_GITLAB_SECRET=testsecret
Expand Down
6 changes: 6 additions & 0 deletions internal/api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,12 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
return provider.NewFigmaProvider(config.External.Figma, scopes)
case "fly":
return provider.NewFlyProvider(config.External.Fly, scopes)
case "generic1":
return provider.NewGenericProvider(config.External.Generic1, scopes)
case "generic2":
return provider.NewGenericProvider(config.External.Generic2, scopes)
case "generic3":
return provider.NewGenericProvider(config.External.Generic3, scopes)
case "github":
return provider.NewGithubProvider(config.External.Github, scopes)
case "gitlab":
Expand Down
234 changes: 234 additions & 0 deletions internal/api/external_generic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package api

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"time"

jwt "github.com/golang-jwt/jwt"
"github.com/stretchr/testify/require"
"github.com/supabase/auth/internal/api/provider"
"github.com/supabase/auth/internal/models"
)

func (ts *ExternalTestSuite) TestSignupExternalGeneric() {
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=generic1", 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.Generic1.RedirectURI, q.Get("redirect_uri"))
ts.Equal(ts.Config.External.Generic1.ClientID, []string{q.Get("client_id")})
ts.Equal("code", q.Get("response_type"))

claims := ExternalProviderClaims{}
p := jwt.Parser{ValidMethods: []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("generic1", claims.Provider)
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
}

func GenericTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, emails string) *httptest.Server {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
*tokenCount++
ts.Equal(code, r.FormValue("code"))
ts.Equal("authorization_code", r.FormValue("grant_type"))
ts.Equal(ts.Config.External.Generic1.RedirectURI, r.FormValue("redirect_uri"))
w.Header().Add("Content-Type", "application/json")
fmt.Fprint(w, `{"access_token":"generic_token","expires_in":100000}`)
case "/profile":
*userCount++
var emailList []provider.Email
if err := json.Unmarshal([]byte(emails), &emailList); err != nil {
ts.Fail("Invalid email json %s", emails)
}

var email *provider.Email

for i, e := range emailList {
if len(e.Email) > 0 {
email = &emailList[i]
break
}
}

if email == nil {
w.WriteHeader(400)
return
}

w.Header().Add("Content-Type", "application/json")
fmt.Fprintf(w, `
{
"id":123,
"generic_account": {
"profile": {
"nickname":"Generic Test",
"profile_image_url":"http://example.com/avatar"
},
"email": "%v",
"is_email_valid": %v,
"is_email_verified": %v
}
}`, email.Email, email.Verified, email.Verified)
default:
w.WriteHeader(500)
ts.Fail("unknown generic oauth call %s", r.URL.Path)
}
}))
ts.Config.External.Generic1.URL = server.URL
return server
}

func (ts *ExternalTestSuite) TestSignupExternalGeneric_AuthorizationCode() {
tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()
u := performAuthorization(ts, "generic1", code, "")
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Generic Test", "123", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestSignupExternalGenericDisableSignupErrorWhenNoUser() {
ts.Config.DisableSignup = true
tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "generic1", code, "")

assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "[email protected]")
}

func (ts *ExternalTestSuite) TestSignupExternalGenericDisableSignupErrorWhenEmptyEmail() {
ts.Config.DisableSignup = true
tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "generic1", code, "")

assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "[email protected]")
}

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

ts.createUser("123", "[email protected]", "Generic Test", "http://example.com/avatar", "")

tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "generic1", code, "")

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Generic Test", "123", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalGenericSuccessWhenMatchingToken() {
// name and avatar should be populated from external API
ts.createUser("123", "[email protected]", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "generic1", code, "invite_token")

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Generic Test", "123", "http://example.com/avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalGenericErrorWhenNoMatchingToken() {
tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

w := performAuthorizationRequest(ts, "generic1", "invite_token")
ts.Require().Equal(http.StatusNotFound, w.Code)
}

func (ts *ExternalTestSuite) TestInviteTokenExternalGenericErrorWhenWrongToken() {
ts.createUser("123", "[email protected]", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

w := performAuthorizationRequest(ts, "generic1", "wrong_token")
ts.Require().Equal(http.StatusNotFound, w.Code)
}

func (ts *ExternalTestSuite) TestInviteTokenExternalGenericErrorWhenEmailDoesntMatch() {
ts.createUser("123", "[email protected]", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "generic1", code, "invite_token")

assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
}

func (ts *ExternalTestSuite) TestSignupExternalGenericErrorWhenVerifiedFalse() {
tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": false}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "generic1", code, "")

v, err := url.ParseQuery(u.Fragment)
ts.Require().NoError(err)
ts.Equal("unauthorized_client", v.Get("error"))
ts.Equal("401", v.Get("error_code"))
ts.Equal("Unverified email with generic", v.Get("error_description"))
assertAuthorizationFailure(ts, u, "", "", "")
}

func (ts *ExternalTestSuite) TestSignupExternalGenericErrorWhenUserBanned() {
tokenCount, userCount := 0, 0
code := "authcode"
emails := `[{"email":"[email protected]", "primary": true, "verified": true}]`
server := GenericTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
defer server.Close()

u := performAuthorization(ts, "generic1", code, "")
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "[email protected]", "Generic Test", "123", "http://example.com/avatar")

user, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
t := time.Now().Add(24 * time.Hour)
user.BannedUntil = &t
require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))

u = performAuthorization(ts, "generic1", code, "")
assertAuthorizationFailure(ts, u, "User is unauthorized", "unauthorized_client", "")
}
Loading