Files
wg-portal/internal/app/auth/auth_oidc.go
Aram Akhavan 1517041363 fix: fetch user info from OIDC userinfo endpoint (#698)
The OIDC client was only extracting claims from the ID token, but many
OIDC providers (like Authelia) don't include all user information in the
ID token. Fields like 'preferred_username' are typically only available
via the userinfo endpoint.

This fix fetches additional user information from the provider's userinfo
endpoint and merges it with the ID token claims, ensuring that all
required user fields are available for user registration and login.

Fixes #697

Signed-off-by: Aram Akhavan <1147328+kaysond@users.noreply.github.com>
2026-05-28 20:48:37 +02:00

234 lines
7.1 KiB
Go

package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
// OidcAuthenticator is an authenticator for OpenID Connect providers.
type OidcAuthenticator struct {
name string
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
cfg *oauth2.Config
userInfoMapping config.OauthFields
userAdminMapping *config.OauthAdminMapping
registrationEnabled bool
userInfoLogging bool
sensitiveInfoLogging bool
allowedDomains []string
allowedUserGroups []string
endSessionEndpoint string
logoutIdpSession bool
}
func newOidcAuthenticator(
_ context.Context,
callbackUrl string,
cfg *config.OpenIDConnectProvider,
) (*OidcAuthenticator, error) {
var err error
var provider = &OidcAuthenticator{}
provider.name = cfg.ProviderName
provider.provider, err = oidc.NewProvider(context.Background(),
cfg.BaseUrl) // use new context here, see https://github.com/coreos/go-oidc/issues/339
if err != nil {
return nil, fmt.Errorf("failed to create new oidc provider: %w", err)
}
provider.verifier = provider.provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
})
scopes := []string{oidc.ScopeOpenID}
scopes = append(scopes, cfg.ExtraScopes...)
provider.cfg = &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.provider.Endpoint(),
RedirectURL: callbackUrl,
Scopes: scopes,
}
provider.userInfoMapping = getOauthFieldMapping(cfg.FieldMap)
provider.userAdminMapping = &cfg.AdminMapping
provider.registrationEnabled = cfg.RegistrationEnabled
provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
provider.logoutIdpSession = cfg.LogoutIdpSession == nil || *cfg.LogoutIdpSession
var providerMetadata struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err = provider.provider.Claims(&providerMetadata); err != nil {
slog.Debug("OIDC: failed to parse provider metadata", "provider", cfg.ProviderName, "error", err)
} else {
provider.endSessionEndpoint = providerMetadata.EndSessionEndpoint
}
return provider, nil
}
// GetName returns the name of the authenticator.
func (o OidcAuthenticator) GetName() string {
return o.name
}
func (o OidcAuthenticator) GetAllowedDomains() []string {
return o.allowedDomains
}
func (o OidcAuthenticator) GetAllowedUserGroups() []string {
return o.allowedUserGroups
}
func (o OidcAuthenticator) GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool) {
if !o.logoutIdpSession {
return "", false
}
if o.endSessionEndpoint == "" {
slog.Debug("OIDC logout URL generation disabled: provider has no end_session_endpoint", "provider", o.name)
return "", false
}
logoutUrl, err := url.Parse(o.endSessionEndpoint)
if err != nil {
slog.Debug("OIDC logout URL generation failed, unable to parse end_session_endpoint url",
"provider", o.name, "error", err)
return "", false
}
params := logoutUrl.Query()
if idTokenHint != "" {
params.Set("id_token_hint", idTokenHint)
}
if postLogoutRedirectUri != "" {
params.Set("post_logout_redirect_uri", postLogoutRedirectUri)
}
logoutUrl.RawQuery = params.Encode()
return logoutUrl.String(), true
}
// RegistrationEnabled returns whether registration is enabled for this authenticator.
func (o OidcAuthenticator) RegistrationEnabled() bool {
return o.registrationEnabled
}
// GetType returns the type of the authenticator.
func (o OidcAuthenticator) GetType() AuthenticatorType {
return AuthenticatorTypeOidc
}
// AuthCodeURL returns the URL for the OAuth2 flow.
func (o OidcAuthenticator) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return o.cfg.AuthCodeURL(state, opts...)
}
// Exchange exchanges the code for a token.
func (o OidcAuthenticator) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (
*oauth2.Token,
error,
) {
return o.cfg.Exchange(ctx, code, opts...)
}
// GetUserInfo retrieves the user info from the token and the userinfo endpoint.
func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (
map[string]any,
error,
) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: token does not contain id_token", "token", token, "nonce", nonce)
}
return nil, errors.New("token does not contain id_token")
}
idToken, err := o.verifier.Verify(ctx, rawIDToken)
if err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to validate id_token", "token", token, "id_token", rawIDToken, "nonce", nonce,
"error",
err)
}
return nil, fmt.Errorf("failed to validate id_token: %w", err)
}
if idToken.Nonce != nonce {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: id_token nonce mismatch", "token", token, "id_token", idToken, "nonce", nonce)
}
return nil, errors.New("nonce mismatch")
}
var tokenFields map[string]any
if err = idToken.Claims(&tokenFields); err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to parse extra claims", "token", token, "id_token", idToken, "nonce", nonce,
"error",
err)
}
return nil, fmt.Errorf("failed to parse extra claims: %w", err)
}
// Fetch additional user information from the userinfo endpoint
userInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to fetch user info from endpoint", "provider", o.name, "error", err)
}
// Don't fail the entire flow if userinfo endpoint is unavailable;
// ID token claims may be sufficient
slog.Debug("OIDC: proceeding with ID token claims only", "provider", o.name)
} else {
// Parse claims from userinfo endpoint response
var userInfoFields map[string]any
if err = userInfo.Claims(&userInfoFields); err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to parse userinfo claims", "provider", o.name, "error", err)
}
// Don't fail if we can't parse userinfo; continue with ID token claims
slog.Debug("OIDC: proceeding with ID token claims only", "provider", o.name)
} else {
// Merge userinfo fields into tokenFields, preferring ID token claims
for key, value := range userInfoFields {
if _, exists := tokenFields[key]; !exists {
tokenFields[key] = value
}
}
if o.userInfoLogging {
contents, _ := json.Marshal(userInfoFields)
slog.Debug("OIDC: user info from endpoint",
"source", o.name,
"info", string(contents))
}
}
}
if o.userInfoLogging {
contents, _ := json.Marshal(tokenFields)
slog.Debug("OIDC: user info debug",
"source", o.name,
"info", string(contents))
}
return tokenFields, nil
}
// ParseUserInfo parses the user info.
func (o OidcAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
return parseOauthUserInfo(o.userInfoMapping, o.userAdminMapping, raw, "oidc", o.name)
}