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

Add Beyond Identity passwordless (passkey) Auth Provider #1212

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,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`, `beyondidentity`, `bitbucket`, `discord`, `facebook`, `figma`, `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 Expand Up @@ -459,7 +459,10 @@ 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`, `keycloak`, and `beyondidentity`.
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`.
For `beyondidentity`, set the Issuer from the beyond identity admin console. This will look like `https://auth-{region}.beyondidentity.com/authenticator/v1/tenants/{tenantId}/realms/{realmId}/applications/{applicationId}`.

#### Apple OAuth

Expand Down Expand Up @@ -748,6 +751,7 @@ Returns the publicly available settings for this gotrue instance.
"external": {
"apple": true,
"azure": true,
"beyondidentity": true,
"bitbucket": true,
"discord": true,
"facebook": true,
Expand Down Expand Up @@ -1197,7 +1201,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 | beyondidentity | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | 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 @@ -66,6 +66,13 @@ GOTRUE_EXTERNAL_AZURE_CLIENT_ID=""
GOTRUE_EXTERNAL_AZURE_SECRET=""
GOTRUE_EXTERNAL_AZURE_REDIRECT_URI="https://localhost:9999/callback"

# Beyond Identity OAuth config
GOTRUE_EXTERNAL_BEYONDIDENTITY_ENABLED="false"
GOTRUE_EXTERNAL_BEYONDIDENTITY_CLIENT_ID=""
GOTRUE_EXTERNAL_BEYONDIDENTITY_SECRET=""
GOTRUE_EXTERNAL_BEYONDIDENTITY_REDIRECT_URI="http://localhost:9999/callback"
GOTRUE_EXTERNAL_BEYONDIDENTITY_URL="https://auth-{region}.beyondidentity.com/authenticator/v1/tenants/{tenantId}/realms/{realmId}/applications/{applicationId}"

# Bitbucket OAuth config
GOTRUE_EXTERNAL_BITBUCKET_ENABLED="false"
GOTRUE_EXTERNAL_BITBUCKET_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 @@ -23,6 +23,11 @@ GOTRUE_EXTERNAL_AZURE_ENABLED=true
GOTRUE_EXTERNAL_AZURE_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_AZURE_SECRET=testsecret
GOTRUE_EXTERNAL_AZURE_REDIRECT_URI=https://identity.services.netlify.com/callback
GOTRUE_EXTERNAL_BEYONDIDENTITY_ENABLED=true
GOTRUE_EXTERNAL_BEYONDIDENTITY_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_BEYONDIDENTITY_SECRET=testsecret
GOTRUE_EXTERNAL_BEYONDIDENTITY_REDIRECT_URI=https://identity.services.netlify.com/callback
GOTRUE_EXTERNAL_BEYONDIDENTITY_URL=https://auth-us.beyondidentity.com/authenticator/v1/tenants/tenantId/realms/realmId/applications/applicationId
GOTRUE_EXTERNAL_BITBUCKET_ENABLED=true
GOTRUE_EXTERNAL_BITBUCKET_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_BITBUCKET_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 @@ -515,6 +515,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
return provider.NewAppleProvider(ctx, config.External.Apple)
case "azure":
return provider.NewAzureProvider(config.External.Azure, scopes)
case "beyondidentity":
return provider.NewBeyondIdentityProvider(config.External.BeyondIdentity, scopes)
case "bitbucket":
return provider.NewBitbucketProvider(config.External.Bitbucket)
case "discord":
Expand Down
166 changes: 166 additions & 0 deletions internal/api/external_beyondidentity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package api

import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"

jwt "github.com/golang-jwt/jwt"
)

const (
beyondIdentityUser string = `{"sub": "beyondidentitytestid", "email": "beyondidentity@example.com", "name": "beyondidentity@example.com", "preferred_username": ""}`
)

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

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("beyondidentity", claims.Provider)
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
}

func BeyondIdentityTestSignupSetup(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 "/token":
*tokenCount++
ts.Equal(code, r.FormValue("code"))
ts.Equal("authorization_code", r.FormValue("grant_type"))
ts.Equal(ts.Config.External.BeyondIdentity.RedirectURI, r.FormValue("redirect_uri"))

w.Header().Add("Content-Type", "application/json")
fmt.Fprint(w, `{"access_token":"beyondidentity_token","expires_in":100000}`)
case "/userinfo":
*userCount++
w.Header().Add("Content-Type", "application/json")
fmt.Fprint(w, user)
default:
w.WriteHeader(500)
ts.Fail("unknown beyondidentity oauth call %s", r.URL.Path)
}
}))

ts.Config.External.BeyondIdentity.URL = server.URL

return server
}

func (ts *ExternalTestSuite) TestSignupExternalBeyondIdentityWithoutURLSetup() {
ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "", "", "")
tokenCount, userCount := 0, 0
code := "authcode"
server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser)
ts.Config.External.BeyondIdentity.URL = ""
defer server.Close()

w := performAuthorizationRequest(ts, "beyondidentity", code)
ts.Equal(w.Code, http.StatusBadRequest)
}

func (ts *ExternalTestSuite) TestSignupExternalBeyondIdentity_AuthorizationCode() {
ts.Config.DisableSignup = false
tokenCount, userCount := 0, 0
code := "authcode"
server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser)
defer server.Close()

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

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "beyondidentity@example.com", "beyondidentity@example.com", "beyondidentitytestid", "")
}

func (ts *ExternalTestSuite) TestSignupExternalBeyondIdentityDisableSignupErrorWhenNoUser() {
ts.Config.DisableSignup = true
tokenCount, userCount := 0, 0
code := "authcode"
server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser)
defer server.Close()

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

assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "beyondidentity@example.com")
}

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

ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "beyondidentity@example.com", "avatar", "")

tokenCount, userCount := 0, 0
code := "authcode"
server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser)
defer server.Close()

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

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "beyondidentity@example.com", "beyondidentity@example.com", "beyondidentitytestid", "avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalBeyondIdentitySuccessWhenMatchingToken() {
ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "beyondidentity@example.com", "avatar", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser)
defer server.Close()

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

assertAuthorizationSuccess(ts, u, tokenCount, userCount, "beyondidentity@example.com", "beyondidentity@example.com", "beyondidentitytestid", "avatar")
}

func (ts *ExternalTestSuite) TestInviteTokenExternalBeyondIdentityErrorWhenNoMatchingToken() {
tokenCount, userCount := 0, 0
code := "authcode"
beyondIdentityUser := `{"sub":"beyondidentitytestid","email":"beyondidentity@example.com"}`
server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser)
defer server.Close()

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

func (ts *ExternalTestSuite) TestInviteTokenExternalBeyondIdentityErrorWhenWrongToken() {
ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "beyondidentity@example.com", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
beyondIdentityUser := `{"sub":"beyondidentitytestid","email":"beyondidentity@example.com"}`
server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser)
defer server.Close()

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

func (ts *ExternalTestSuite) TestInviteTokenExternalBeyondIdentityErrorWhenEmailDoesntMatch() {
ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "", "", "invite_token")

tokenCount, userCount := 0, 0
code := "authcode"
beyondIdentityUser := `{"sub":"beyondidentitytestid","email":"other@example.com"}`
server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser)
defer server.Close()

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

assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
}
103 changes: 103 additions & 0 deletions internal/api/provider/beyondidentity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package provider

import (
"context"
"errors"
"strings"

"github.com/supabase/gotrue/internal/conf"
"golang.org/x/oauth2"
)

// Beyond Identity
type beyondIdentityProvider struct {
*oauth2.Config
Host string
}

type beyondIdentityUser struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}

// NewBeyondIdentityProvider creates a BeyondIdentity account provider.
func NewBeyondIdentityProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {

if err := ext.ValidateOAuth(); err != nil {
return nil, err
}

oauthScopes := []string{
"email",
"openid",
}

if scopes != "" {
oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
}

if ext.URL == "" {
return nil, errors.New("unable to find Issuer URL for the BeyondIdentity provider")
}

extURLlen := len(ext.URL)
if ext.URL[extURLlen-1] == '/' {
ext.URL = ext.URL[:extURLlen-1]
}

return &beyondIdentityProvider{
Config: &oauth2.Config{
ClientID: ext.ClientID[0],
ClientSecret: ext.Secret,
Endpoint: oauth2.Endpoint{
AuthURL: ext.URL + "/authorize",
TokenURL: ext.URL + "/token",
},
RedirectURL: ext.RedirectURI,
Scopes: oauthScopes,
},
Host: ext.URL,
}, nil
}

func (g beyondIdentityProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
return g.Exchange(context.Background(), code)
}

func (g beyondIdentityProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
var u beyondIdentityUser

if err := makeRequest(ctx, tok, g.Config, g.Host+"/userinfo", &u); err != nil {
return nil, err
}

var name string
if u.Name != "" {
name = u.Name
} else {
name = u.Email
}

return &UserProvidedData{
Metadata: &Claims{
Issuer: g.Host,
Subject: u.Sub,
Name: name,
PreferredUsername: u.PreferredUsername,
Email: u.Email,
EmailVerified: true, // if email is returned, the email is verified by beyondidentity already

// To be deprecated
FullName: u.Name,
ProviderId: u.Sub,
},
Emails: []Email{{
Email: u.Email,
Verified: true,
Primary: true,
}},
}, nil

}