Skip to content

Commit 5b65c34

Browse files
fspijkermankangmingtay
authored andcommitted
fix: Keycloak OAuth Provider (supabase#371)
* Keycloak OAuth Provider * Update README.md * Removed the usage of chooseHost to keep things clear * Allow to use the Keyloak provider in an ID Token grant flow * fix tests Co-authored-by: Kang Ming <kang.ming1996@gmail.com>
1 parent 1dd9bc1 commit 5b65c34

File tree

10 files changed

+307
-3
lines changed

10 files changed

+307
-3
lines changed

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ The default group to assign all new users to.
197197

198198
### External Authentication Providers
199199

200-
We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication.
200+
We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication.
201201

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

@@ -228,7 +228,7 @@ The URI a OAuth2 provider will redirect to with the `code` and `state` values.
228228

229229
`EXTERNAL_X_URL` - `string`
230230

231-
The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` only. Defaults to `https://gitlab.com`.
231+
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/auth/realms/myrealm`
232232

233233
#### Apple OAuth
234234

@@ -513,6 +513,7 @@ Returns the publicly available settings for this gotrue instance.
513513
"github": true,
514514
"gitlab": true,
515515
"google": true,
516+
"keycloak": true,
516517
"linkedin": true,
517518
"notion": true,
518519
"slack": true,
@@ -927,7 +928,8 @@ Get access_token from external oauth provider
927928
query params:
928929

929930
```
930-
provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | linkedin | notion | slack | spotify | twitch | twitter | workos
931+
provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos
932+
931933
scopes=<optional additional scopes depending on the provider (email and name are requested by default)>
932934
```
933935

api/external.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string, query *u
403403
return provider.NewGitlabProvider(config.External.Gitlab, scopes)
404404
case "google":
405405
return provider.NewGoogleProvider(config.External.Google, scopes)
406+
case "keycloak":
407+
return provider.NewKeycloakProvider(config.External.Keycloak, scopes)
406408
case "linkedin":
407409
return provider.NewLinkedinProvider(config.External.Linkedin, scopes)
408410
case "facebook":

api/external_keycloak_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
9+
jwt "github.com/golang-jwt/jwt"
10+
)
11+
12+
const (
13+
keycloakUser string = `{"sub": "keycloaktestid", "name": "Keycloak Test", "email": "keycloak@example.com", "preferred_username": "keycloak", "email_verified": true}`
14+
keycloakUserNoEmail string = `{"sub": "keycloaktestid", "name": "Keycloak Test", "preferred_username": "keycloak", "email_verified": false}`
15+
)
16+
17+
func (ts *ExternalTestSuite) TestSignupExternalKeycloak() {
18+
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=keycloak", nil)
19+
w := httptest.NewRecorder()
20+
ts.API.handler.ServeHTTP(w, req)
21+
ts.Require().Equal(http.StatusFound, w.Code)
22+
u, err := url.Parse(w.Header().Get("Location"))
23+
ts.Require().NoError(err, "redirect url parse failed")
24+
q := u.Query()
25+
ts.Equal(ts.Config.External.Keycloak.RedirectURI, q.Get("redirect_uri"))
26+
ts.Equal(ts.Config.External.Keycloak.ClientID, q.Get("client_id"))
27+
ts.Equal("code", q.Get("response_type"))
28+
ts.Equal("profile email", q.Get("scope"))
29+
30+
claims := ExternalProviderClaims{}
31+
p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
32+
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
33+
return []byte(ts.Config.JWT.Secret), nil
34+
})
35+
ts.Require().NoError(err)
36+
37+
ts.Equal("keycloak", claims.Provider)
38+
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
39+
}
40+
41+
func KeycloakTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
42+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+
switch r.URL.Path {
44+
case "/protocol/openid-connect/token":
45+
*tokenCount++
46+
ts.Equal(code, r.FormValue("code"))
47+
ts.Equal("authorization_code", r.FormValue("grant_type"))
48+
ts.Equal(ts.Config.External.Keycloak.RedirectURI, r.FormValue("redirect_uri"))
49+
50+
w.Header().Add("Content-Type", "application/json")
51+
fmt.Fprint(w, `{"access_token":"keycloak_token","expires_in":100000}`)
52+
case "/protocol/openid-connect/userinfo":
53+
*userCount++
54+
w.Header().Add("Content-Type", "application/json")
55+
fmt.Fprint(w, user)
56+
default:
57+
w.WriteHeader(500)
58+
ts.Fail("unknown keycloak oauth call %s", r.URL.Path)
59+
}
60+
}))
61+
62+
ts.Config.External.Keycloak.URL = server.URL
63+
64+
return server
65+
}
66+
67+
func (ts *ExternalTestSuite) TestSignupExternalKeycloakWithoutURLSetup() {
68+
ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "", "")
69+
tokenCount, userCount := 0, 0
70+
code := "authcode"
71+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
72+
ts.Config.External.Keycloak.URL = ""
73+
defer server.Close()
74+
75+
w := performAuthorizationRequest(ts, "keycloak", code)
76+
ts.Equal(w.Code, http.StatusBadRequest)
77+
}
78+
79+
func (ts *ExternalTestSuite) TestSignupExternalKeycloak_AuthorizationCode() {
80+
ts.Config.DisableSignup = false
81+
ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "http://example.com/avatar", "")
82+
tokenCount, userCount := 0, 0
83+
code := "authcode"
84+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
85+
defer server.Close()
86+
87+
u := performAuthorization(ts, "keycloak", code, "")
88+
89+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "http://example.com/avatar")
90+
}
91+
92+
func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupErrorWhenNoUser() {
93+
ts.Config.DisableSignup = true
94+
tokenCount, userCount := 0, 0
95+
code := "authcode"
96+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
97+
defer server.Close()
98+
99+
u := performAuthorization(ts, "keycloak", code, "")
100+
101+
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "keycloak@example.com")
102+
}
103+
104+
func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupErrorWhenNoEmail() {
105+
ts.Config.DisableSignup = true
106+
tokenCount, userCount := 0, 0
107+
code := "authcode"
108+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUserNoEmail)
109+
defer server.Close()
110+
111+
u := performAuthorization(ts, "keycloak", code, "")
112+
113+
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "keycloak@example.com")
114+
115+
}
116+
117+
func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupSuccessWithPrimaryEmail() {
118+
ts.Config.DisableSignup = true
119+
120+
ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "http://example.com/avatar", "")
121+
122+
tokenCount, userCount := 0, 0
123+
code := "authcode"
124+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
125+
defer server.Close()
126+
127+
u := performAuthorization(ts, "keycloak", code, "")
128+
129+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "http://example.com/avatar")
130+
}
131+
132+
func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakSuccessWhenMatchingToken() {
133+
// name and avatar should be populated from Keycloak API
134+
ts.createUser("keycloaktestid", "keycloak@example.com", "", "http://example.com/avatar", "invite_token")
135+
136+
tokenCount, userCount := 0, 0
137+
code := "authcode"
138+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
139+
defer server.Close()
140+
141+
u := performAuthorization(ts, "keycloak", code, "invite_token")
142+
143+
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "http://example.com/avatar")
144+
}
145+
146+
func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenNoMatchingToken() {
147+
tokenCount, userCount := 0, 0
148+
code := "authcode"
149+
keycloakUser := `{"name":"Keycloak Test","avatar":{"href":"http://example.com/avatar"}}`
150+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
151+
defer server.Close()
152+
153+
w := performAuthorizationRequest(ts, "keycloak", "invite_token")
154+
ts.Require().Equal(http.StatusNotFound, w.Code)
155+
}
156+
157+
func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenWrongToken() {
158+
ts.createUser("keycloaktestid", "keycloak@example.com", "", "", "invite_token")
159+
160+
tokenCount, userCount := 0, 0
161+
code := "authcode"
162+
keycloakUser := `{"name":"Keycloak Test","avatar":{"href":"http://example.com/avatar"}}`
163+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
164+
defer server.Close()
165+
166+
w := performAuthorizationRequest(ts, "keycloak", "wrong_token")
167+
ts.Require().Equal(http.StatusNotFound, w.Code)
168+
}
169+
170+
func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenEmailDoesntMatch() {
171+
ts.createUser("keycloaktestid", "keycloak@example.com", "", "", "invite_token")
172+
173+
tokenCount, userCount := 0, 0
174+
code := "authcode"
175+
keycloakUser := `{"name":"Keycloak Test", "email":"other@example.com", "avatar":{"href":"http://example.com/avatar"}}`
176+
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
177+
defer server.Close()
178+
179+
u := performAuthorization(ts, "keycloak", code, "invite_token")
180+
181+
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
182+
}

api/provider/keycloak.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
8+
"github.com/netlify/gotrue/conf"
9+
"golang.org/x/oauth2"
10+
)
11+
12+
// Keycloak
13+
type keycloakProvider struct {
14+
*oauth2.Config
15+
Host string
16+
}
17+
18+
type keycloakUser struct {
19+
Name string `json:"name"`
20+
Sub string `json:"sub"`
21+
Email string `json:"email"`
22+
EmailVerified bool `json:"email_verified"`
23+
}
24+
25+
// NewKeycloakProvider creates a Keycloak account provider.
26+
func NewKeycloakProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
27+
if err := ext.Validate(); err != nil {
28+
return nil, err
29+
}
30+
31+
oauthScopes := []string{
32+
"profile",
33+
"email",
34+
}
35+
36+
if scopes != "" {
37+
oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
38+
}
39+
40+
if ext.URL == "" {
41+
return nil, errors.New("Unable to find URL for the Keycloak provider")
42+
}
43+
44+
extURLlen := len(ext.URL)
45+
if ext.URL[extURLlen-1] == '/' {
46+
ext.URL = ext.URL[:extURLlen-1]
47+
}
48+
49+
return &keycloakProvider{
50+
Config: &oauth2.Config{
51+
ClientID: ext.ClientID,
52+
ClientSecret: ext.Secret,
53+
Endpoint: oauth2.Endpoint{
54+
AuthURL: ext.URL + "/protocol/openid-connect/auth",
55+
TokenURL: ext.URL + "/protocol/openid-connect/token",
56+
},
57+
RedirectURL: ext.RedirectURI,
58+
Scopes: oauthScopes,
59+
},
60+
Host: ext.URL,
61+
}, nil
62+
}
63+
64+
func (g keycloakProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
65+
return g.Exchange(oauth2.NoContext, code)
66+
}
67+
68+
func (g keycloakProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
69+
var u keycloakUser
70+
71+
if err := makeRequest(ctx, tok, g.Config, g.Host+"/protocol/openid-connect/userinfo", &u); err != nil {
72+
return nil, err
73+
}
74+
75+
if u.Email == "" {
76+
return nil, errors.New("Unable to find email with Keycloak provider")
77+
}
78+
79+
return &UserProvidedData{
80+
Metadata: &Claims{
81+
Issuer: g.Host,
82+
Subject: u.Sub,
83+
Name: u.Name,
84+
Email: u.Email,
85+
EmailVerified: u.EmailVerified,
86+
87+
// To be deprecated
88+
FullName: u.Name,
89+
ProviderId: u.Sub,
90+
},
91+
Emails: []Email{{
92+
Email: u.Email,
93+
Verified: u.EmailVerified,
94+
Primary: true,
95+
}},
96+
}, nil
97+
98+
}

api/settings.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type ProviderSettings struct {
99
Discord bool `json:"discord"`
1010
GitHub bool `json:"github"`
1111
GitLab bool `json:"gitlab"`
12+
Keycloak bool `json:"keycloak"`
1213
Google bool `json:"google"`
1314
Linkedin bool `json:"linkedin"`
1415
Facebook bool `json:"facebook"`
@@ -49,6 +50,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
4950
GitHub: config.External.Github.Enabled,
5051
GitLab: config.External.Gitlab.Enabled,
5152
Google: config.External.Google.Enabled,
53+
Keycloak: config.External.Keycloak.Enabled,
5254
Linkedin: config.External.Linkedin.Enabled,
5355
Facebook: config.External.Facebook.Enabled,
5456
Notion: config.External.Notion.Enabled,

api/settings_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
3636
require.True(t, p.Spotify)
3737
require.True(t, p.Slack)
3838
require.True(t, p.Google)
39+
require.True(t, p.Keycloak)
3940
require.True(t, p.Linkedin)
4041
require.True(t, p.GitHub)
4142
require.True(t, p.GitLab)

api/token.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ func (p *IdTokenGrantParams) getVerifier(ctx context.Context) (*oidc.IDTokenVeri
9090
oAuthProvider = config.External.Google
9191
oAuthProviderClientId = oAuthProvider.ClientID
9292
provider, err = oidc.NewProvider(ctx, "https://accounts.google.com")
93+
case "keycloak":
94+
oAuthProvider = config.External.Keycloak
95+
oAuthProviderClientId = oAuthProvider.ClientID
96+
provider, err = oidc.NewProvider(ctx, oAuthProvider.URL)
9397
default:
9498
return nil, fmt.Errorf("Provider %s doesn't support the id_token grant flow", p.Provider)
9599
}

conf/configuration.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type ProviderConfiguration struct {
9595
Gitlab OAuthProviderConfiguration `json:"gitlab"`
9696
Google OAuthProviderConfiguration `json:"google"`
9797
Notion OAuthProviderConfiguration `json:"notion"`
98+
Keycloak OAuthProviderConfiguration `json:"keycloak"`
9899
Linkedin OAuthProviderConfiguration `json:"linkedin"`
99100
Spotify OAuthProviderConfiguration `json:"spotify"`
100101
Slack OAuthProviderConfiguration `json:"slack"`

example.env

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID=""
126126
GOTRUE_EXTERNAL_SPOTIFY_SECRET=""
127127
GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI="http://localhost:9999/callback"
128128

129+
# Keycloak OAuth config
130+
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED="false"
131+
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=""
132+
GOTRUE_EXTERNAL_KEYCLOAK_SECRET=""
133+
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI="http://localhost:9999/callback"
134+
GOTRUE_EXTERNAL_KEYCLOAK_URL="https://keycloak.example.com/auth/realms/myrealm"
135+
129136
# Linkedin OAuth config
130137
GOTRUE_EXTERNAL_LINKEDIN_ENABLED="true"
131138
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=""

hack/test.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ GOTRUE_EXTERNAL_GITHUB_ENABLED=true
3737
GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=testclientid
3838
GOTRUE_EXTERNAL_GITHUB_SECRET=testsecret
3939
GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=https://identity.services.netlify.com/callback
40+
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED=true
41+
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=testclientid
42+
GOTRUE_EXTERNAL_KEYCLOAK_SECRET=testsecret
43+
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI=https://identity.services.netlify.com/callback
44+
GOTRUE_EXTERNAL_KEYCLOAK_URL=https://keycloak.example.com/auth/realms/myrealm
4045
GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true
4146
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=testclientid
4247
GOTRUE_EXTERNAL_LINKEDIN_SECRET=testsecret

0 commit comments

Comments
 (0)