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
4248func NewOIDCClient (ctx context.Context , cfg OIDCConfig ) (* OIDCClient , error ) {
@@ -91,7 +97,21 @@ func NewOIDCClient(ctx context.Context, cfg OIDCConfig) (*OIDCClient, error) {
9197func (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 ,
0 commit comments