Skip to content

Commit b063b87

Browse files
authored
Merge pull request #1467 from helixml/design/saas-keycloak-extraction
feat(auth): add standalone Keycloak authentication support
2 parents a9afe24 + 81348cd commit b063b87

File tree

17 files changed

+865
-330
lines changed

17 files changed

+865
-330
lines changed

.gitleaksignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ scripts/init-moonlight-web.sh:curl-auth-user:64
2828

2929
# Wolf development certificate (local development only)
3030
wolf/key.pem:private-key:1
31+
32+
# Keycloak docker-compose - PostgreSQL connection string (not a secret, just port/dbname)
33+
docker-compose.keycloak.yaml:generic-api-key:25

Dockerfile.keycloak

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM quay.io/keycloak/keycloak:23.0
2+
3+
# Copy our custom theme into the Keycloak themes directory
4+
COPY themes/helix /opt/keycloak/themes/helix
5+
6+
# Set proper ownership and permissions
7+
USER root
8+
RUN chown -R keycloak:keycloak /opt/keycloak/themes/helix
9+
USER keycloak
10+
11+
# Build the theme cache (optional but recommended for production)
12+
RUN /opt/keycloak/bin/kc.sh build

api/pkg/auth/auth.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package auth
33
import (
44
"context"
55

6-
"github.com/coreos/go-oidc"
6+
"github.com/coreos/go-oidc/v3/oidc"
77
"golang.org/x/oauth2"
88

99
"github.com/helixml/helix/api/pkg/types"
@@ -25,6 +25,11 @@ type Authenticator interface {
2525

2626
RequestPasswordReset(ctx context.Context, email string) error
2727
PasswordResetComplete(ctx context.Context, token, newPassword string) error
28+
29+
// GetOIDCClient returns the OIDC client for this authenticator.
30+
// For KeycloakAuthenticator, this returns the internal OIDC client.
31+
// For HelixAuthenticator, this returns nil (no OIDC support).
32+
GetOIDCClient() OIDC
2833
}
2934

3035
type OIDC interface {

api/pkg/auth/auth_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package auth
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/helixml/helix/api/pkg/config"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"go.uber.org/mock/gomock"
11+
)
12+
13+
func TestHelixAuthenticator_GetOIDCClient(t *testing.T) {
14+
// Create a minimal config for the authenticator
15+
cfg := &config.ServerConfig{
16+
Auth: config.Auth{
17+
Regular: config.Regular{
18+
TokenValidity: 24 * time.Hour,
19+
},
20+
},
21+
}
22+
23+
authenticator, err := NewHelixAuthenticator(cfg, nil, "test-secret", nil)
24+
require.NoError(t, err)
25+
26+
// HelixAuthenticator should return nil for GetOIDCClient
27+
// since it doesn't use OIDC
28+
oidcClient := authenticator.GetOIDCClient()
29+
assert.Nil(t, oidcClient, "HelixAuthenticator.GetOIDCClient() should return nil")
30+
}
31+
32+
func TestAuthenticator_Interface_GetOIDCClient(t *testing.T) {
33+
ctrl := gomock.NewController(t)
34+
defer ctrl.Finish()
35+
36+
// Create a mock authenticator
37+
mockAuth := NewMockAuthenticator(ctrl)
38+
39+
// Create a mock OIDC client
40+
mockOIDC := NewMockOIDC(ctrl)
41+
42+
// Set up expectation - GetOIDCClient returns the mock OIDC client
43+
mockAuth.EXPECT().GetOIDCClient().Return(mockOIDC)
44+
45+
// Call the method
46+
var auth Authenticator = mockAuth
47+
result := auth.GetOIDCClient()
48+
49+
// Verify result
50+
assert.Equal(t, mockOIDC, result, "GetOIDCClient should return the expected OIDC client")
51+
}
52+
53+
func TestAuthenticator_Interface_GetOIDCClient_Nil(t *testing.T) {
54+
ctrl := gomock.NewController(t)
55+
defer ctrl.Finish()
56+
57+
// Create a mock authenticator
58+
mockAuth := NewMockAuthenticator(ctrl)
59+
60+
// Set up expectation - GetOIDCClient returns nil (like HelixAuthenticator)
61+
mockAuth.EXPECT().GetOIDCClient().Return(nil)
62+
63+
// Call the method
64+
var auth Authenticator = mockAuth
65+
result := auth.GetOIDCClient()
66+
67+
// Verify result
68+
assert.Nil(t, result, "GetOIDCClient should return nil when no OIDC client is available")
69+
}

api/pkg/auth/helix_authenticator.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,9 @@ func (h *HelixAuthenticator) GenerateUserToken(_ context.Context, user *types.Us
295295

296296
return tokenString, nil
297297
}
298+
299+
// GetOIDCClient returns nil for HelixAuthenticator as it doesn't use OIDC.
300+
// This satisfies the Authenticator interface.
301+
func (h *HelixAuthenticator) GetOIDCClient() OIDC {
302+
return nil
303+
}

api/pkg/auth/keycloak.go

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ func NewKeycloakAuthenticator(cfg *config.ServerConfig, store store.Store) (*Key
5959
return nil, err
6060
}
6161

62-
keycloakURL, err := url.Parse(cfg.Auth.Keycloak.KeycloakFrontEndURL)
62+
// Use internal KeycloakURL for OIDC discovery (server-to-server communication)
63+
// KeycloakFrontEndURL is used by Keycloak for browser-facing URLs (set via KC_HOSTNAME_URL)
64+
keycloakURL, err := url.Parse(cfg.Auth.Keycloak.KeycloakURL)
6365
if err != nil {
64-
return nil, fmt.Errorf("failed to parse keycloak front end url: %w", err)
66+
return nil, fmt.Errorf("failed to parse keycloak url: %w", err)
6567
}
6668
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
6769
defer cancel()
@@ -71,15 +73,35 @@ func NewKeycloakAuthenticator(cfg *config.ServerConfig, store store.Store) (*Key
7173
// Strip any trailing slashes from the path
7274
keycloakURL.Path = strings.TrimRight(keycloakURL.Path, "/")
7375
keycloakURL.Path = fmt.Sprintf("%s/realms/%s", keycloakURL.Path, cfg.Auth.Keycloak.Realm)
76+
77+
// Build the expected issuer URL from the frontend URL
78+
// This allows the API to connect to Keycloak via internal URL (keycloak:8080)
79+
// while Keycloak is configured with external URL (localhost:8180) for browser access
80+
var expectedIssuer string
81+
if cfg.Auth.Keycloak.KeycloakFrontEndURL != "" && cfg.Auth.Keycloak.KeycloakFrontEndURL != cfg.Auth.Keycloak.KeycloakURL {
82+
frontendURL, err := url.Parse(cfg.Auth.Keycloak.KeycloakFrontEndURL)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to parse keycloak frontend url: %w", err)
85+
}
86+
frontendURL.Path = strings.TrimRight(frontendURL.Path, "/")
87+
frontendURL.Path = fmt.Sprintf("%s/realms/%s", frontendURL.Path, cfg.Auth.Keycloak.Realm)
88+
expectedIssuer = frontendURL.String()
89+
log.Info().
90+
Str("internal_url", keycloakURL.String()).
91+
Str("expected_issuer", expectedIssuer).
92+
Msg("Using separate issuer URL for browser-facing Keycloak")
93+
}
94+
7495
client, err := NewOIDCClient(ctx, OIDCConfig{
75-
ProviderURL: keycloakURL.String(),
76-
ClientID: cfg.Auth.Keycloak.APIClientID,
77-
ClientSecret: cfg.Auth.Keycloak.ClientSecret,
78-
RedirectURL: helixRedirectURL,
79-
AdminUserIDs: cfg.WebServer.AdminUserIDs,
80-
Audience: "account",
81-
Scopes: []string{"openid", "profile", "email"},
82-
Store: store,
96+
ProviderURL: keycloakURL.String(),
97+
ClientID: cfg.Auth.Keycloak.APIClientID,
98+
ClientSecret: cfg.Auth.Keycloak.ClientSecret,
99+
RedirectURL: helixRedirectURL,
100+
AdminUserIDs: cfg.WebServer.AdminUserIDs,
101+
Audience: "account",
102+
Scopes: []string{"openid", "profile", "email"},
103+
Store: store,
104+
ExpectedIssuer: expectedIssuer,
83105
})
84106
if err != nil {
85107
return nil, fmt.Errorf("failed to create keycloak client: %w", err)
@@ -376,14 +398,11 @@ func setRealmConfigurations(gck *gocloak.GoCloak, token string, cfg *config.Keyc
376398
}
377399
}
378400

379-
// Initialize attributes if not set
380-
if realm.Attributes == nil {
381-
realm.Attributes = &map[string]string{}
382-
}
383-
384-
attributes := *realm.Attributes
385-
attributes["frontendUrl"] = cfg.KeycloakFrontEndURL
386-
*realm.Attributes = attributes
401+
// NOTE: We intentionally do NOT set the realm's frontendUrl attribute here.
402+
// The realm's frontendUrl would override the issuer in the OIDC discovery document,
403+
// causing an issuer mismatch when the API verifies tokens.
404+
// Instead, we handle browser redirects by overriding the authorization URL
405+
// in the OIDC client (see AuthURLOverride in NewKeycloakAuthenticator).
387406

388407
// Set login theme to "helix" for both new and existing deployments
389408
if realm.LoginTheme == nil || *realm.LoginTheme != "helix" {
@@ -400,7 +419,6 @@ func setRealmConfigurations(gck *gocloak.GoCloak, token string, cfg *config.Keyc
400419

401420
log.Info().
402421
Str("realm", cfg.Realm).
403-
Str("frontend_url", cfg.KeycloakFrontEndURL).
404422
Str("login_theme", gocloak.PString(realm.LoginTheme)).
405423
Msg("Configured realm")
406424

@@ -597,4 +615,11 @@ func (k *KeycloakAuthenticator) PasswordResetComplete(ctx context.Context, token
597615
return fmt.Errorf("passwordReset: not implemented")
598616
}
599617

618+
// GetOIDCClient returns the OIDC client used by KeycloakAuthenticator.
619+
// This is needed because the server needs access to the OIDC client for
620+
// authentication flows (login redirect, callback, logout, token refresh).
621+
func (k *KeycloakAuthenticator) GetOIDCClient() OIDC {
622+
return k.oidcClient
623+
}
624+
600625
func addr[T any](t T) *T { return &t }

api/pkg/auth/mock.go

Lines changed: 17 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/pkg/auth/oidc.go

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"fmt"
77
"time"
88

9-
"github.com/coreos/go-oidc"
9+
"github.com/coreos/go-oidc/v3/oidc"
1010
"github.com/helixml/helix/api/pkg/store"
1111
"github.com/helixml/helix/api/pkg/types"
1212
"github.com/rs/zerolog/log"
@@ -37,6 +37,12 @@ type OIDCConfig struct {
3737
Audience string
3838
Scopes []string
3939
Store store.Store
40+
// ExpectedIssuer allows the OIDC provider to return a different issuer than the ProviderURL.
41+
// This is useful when the API connects to Keycloak via an internal URL (e.g., keycloak:8080)
42+
// but Keycloak is configured with an external URL (e.g., localhost:8180) for browser access.
43+
// If set, the OIDC client will accept tokens with this issuer even though discovery
44+
// was done via ProviderURL.
45+
ExpectedIssuer string
4046
}
4147

4248
func NewOIDCClient(ctx context.Context, cfg OIDCConfig) (*OIDCClient, error) {
@@ -91,7 +97,21 @@ func NewOIDCClient(ctx context.Context, cfg OIDCConfig) (*OIDCClient, error) {
9197
func (c *OIDCClient) getProvider() (*oidc.Provider, error) {
9298
if c.provider == nil {
9399
log.Trace().Str("provider_url", c.cfg.ProviderURL).Msg("Getting provider")
94-
provider, err := oidc.NewProvider(context.Background(), c.cfg.ProviderURL)
100+
101+
// If ExpectedIssuer is set, use InsecureIssuerURLContext to allow the provider
102+
// to return a different issuer than the discovery URL. This is needed when
103+
// the API connects to Keycloak via an internal URL but Keycloak is configured
104+
// with an external URL for browser access.
105+
ctx := context.Background()
106+
if c.cfg.ExpectedIssuer != "" {
107+
log.Info().
108+
Str("discovery_url", c.cfg.ProviderURL).
109+
Str("expected_issuer", c.cfg.ExpectedIssuer).
110+
Msg("Using InsecureIssuerURLContext to allow different issuer")
111+
ctx = oidc.InsecureIssuerURLContext(ctx, c.cfg.ExpectedIssuer)
112+
}
113+
114+
provider, err := oidc.NewProvider(ctx, c.cfg.ProviderURL)
95115
if err != nil {
96116
// Wrap error to indicate provider not ready (used to return 503 instead of 401)
97117
return nil, fmt.Errorf("%w: %v", ErrProviderNotReady, err)
@@ -108,13 +128,14 @@ func (c *OIDCClient) getOauth2Config() (*oauth2.Config, error) {
108128
log.Error().Err(err).Msg("Failed to get provider")
109129
return nil, err
110130
}
111-
log.Trace().Str("client_id", c.cfg.ClientID).Str("redirect_url", c.cfg.RedirectURL).Interface("endpoints", provider.Endpoint()).Msg("Getting oauth2 config")
131+
endpoint := provider.Endpoint()
132+
log.Trace().Str("client_id", c.cfg.ClientID).Str("redirect_url", c.cfg.RedirectURL).Interface("endpoints", endpoint).Msg("Getting oauth2 config")
112133
c.oauth2Config = &oauth2.Config{
113134
ClientID: c.cfg.ClientID,
114135
ClientSecret: c.cfg.ClientSecret,
115136
RedirectURL: c.cfg.RedirectURL,
116137
Scopes: c.cfg.Scopes,
117-
Endpoint: provider.Endpoint(),
138+
Endpoint: endpoint,
118139
}
119140
}
120141
return c.oauth2Config, nil
@@ -236,14 +257,43 @@ func (c *OIDCClient) ValidateUserToken(ctx context.Context, accessToken string)
236257

237258
userInfo, err := c.GetUserInfo(ctx, accessToken)
238259
if err != nil {
239-
return nil, fmt.Errorf("invalid access token (could not get user): %w", err)
260+
return nil, fmt.Errorf("invalid access token (could not get user info): %w", err)
240261
}
241262

263+
// Try to get the user from the database by their OIDC subject ID
242264
user, err := c.store.GetUser(ctx, &store.GetUserQuery{
243-
Email: userInfo.Email,
265+
ID: userInfo.Subject,
244266
})
245-
if err != nil {
246-
return nil, fmt.Errorf("invalid access token (could not get user): %w", err)
267+
if err != nil && !errors.Is(err, store.ErrNotFound) {
268+
return nil, fmt.Errorf("invalid access token (database error): %w", err)
269+
}
270+
271+
// Extract full name from userinfo
272+
fullName := userInfo.Name
273+
if fullName == "" && userInfo.GivenName != "" && userInfo.FamilyName != "" {
274+
fullName = userInfo.GivenName + " " + userInfo.FamilyName
275+
}
276+
if fullName == "" {
277+
fullName = userInfo.Email
278+
}
279+
280+
// If user doesn't exist, create them (first login after OIDC registration)
281+
if user == nil {
282+
log.Info().
283+
Str("subject", userInfo.Subject).
284+
Str("email", userInfo.Email).
285+
Msg("Creating new user from OIDC token")
286+
287+
user, err = c.store.CreateUser(ctx, &types.User{
288+
ID: userInfo.Subject,
289+
Username: userInfo.Subject,
290+
Email: userInfo.Email,
291+
FullName: fullName,
292+
CreatedAt: time.Now(),
293+
})
294+
if err != nil {
295+
return nil, fmt.Errorf("failed to create user: %w", err)
296+
}
247297
}
248298

249299
// Determine admin status:
@@ -255,7 +305,7 @@ func (c *OIDCClient) ValidateUserToken(ctx context.Context, accessToken string)
255305
ID: userInfo.Subject,
256306
Username: userInfo.Subject,
257307
Email: userInfo.Email,
258-
FullName: userInfo.Name,
308+
FullName: fullName,
259309
Token: accessToken,
260310
TokenType: types.TokenTypeOIDC,
261311
Type: types.OwnerTypeUser,

api/pkg/auth/realm.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1671,7 +1671,6 @@
16711671
"oauth2DeviceCodeLifespan" : "600",
16721672
"parRequestUriLifespan" : "60",
16731673
"clientSessionMaxLifespan" : "0",
1674-
"frontendUrl" : "http://localhost:8080/auth",
16751674
"acr.loa.map" : "{}"
16761675
},
16771676
"keycloakVersion" : "23.0.7",

api/pkg/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ type Regular struct {
170170
type Keycloak struct {
171171
KeycloakEnabled bool `envconfig:"KEYCLOAK_ENABLED" default:"false"`
172172
KeycloakURL string `envconfig:"KEYCLOAK_URL" default:"http://keycloak:8080/auth"`
173-
KeycloakFrontEndURL string `envconfig:"KEYCLOAK_FRONTEND_URL" default:"http://localhost:8080/auth"`
173+
KeycloakFrontEndURL string `envconfig:"KEYCLOAK_FRONTEND_URL" default:"http://localhost:8180/auth"`
174174
ServerURL string `envconfig:"SERVER_URL" description:"The URL the api server is listening on."`
175175
APIClientID string `envconfig:"KEYCLOAK_CLIENT_ID" default:"api"`
176176
ClientSecret string `envconfig:"KEYCLOAK_CLIENT_SECRET"` // If not set, will be looked up using admin API

0 commit comments

Comments
 (0)