diff --git a/.env.example b/.env.example index 770a7e97..baf02c49 100644 --- a/.env.example +++ b/.env.example @@ -171,6 +171,14 @@ TINYAUTH_OAUTH_PROVIDERS_name_USERINFOURL= TINYAUTH_OAUTH_PROVIDERS_name_INSECURE=false # Provider name in UI. TINYAUTH_OAUTH_PROVIDERS_name_NAME= +# Username claim. +TINYAUTH_OAUTH_PROVIDERS_name_CLAIMS_USERNAME= +# Email claim. +TINYAUTH_OAUTH_PROVIDERS_name_CLAIMS_EMAIL= +# Name claim. +TINYAUTH_OAUTH_PROVIDERS_name_CLAIMS_NAME= +# Groups claim. +TINYAUTH_OAUTH_PROVIDERS_name_CLAIMS_GROUPS= # oidc config diff --git a/internal/model/config.go b/internal/model/config.go index 5b077fc5..642c00a9 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -253,18 +253,26 @@ type TailscaleConfig struct { // OAuth/OIDC config type OAuthServiceConfig struct { - ClientID string `description:"OAuth client ID." yaml:"clientId,omitempty"` - ClientSecret string `description:"OAuth client secret." yaml:"clientSecret,omitempty"` - ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile,omitempty"` - Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist,omitempty"` - WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"` - Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"` - RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"` - AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"` - TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"` - UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"` - Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"` - Name string `description:"Provider name in UI." yaml:"name,omitempty"` + ClientID string `description:"OAuth client ID." yaml:"clientId,omitempty"` + ClientSecret string `description:"OAuth client secret." yaml:"clientSecret,omitempty"` + ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile,omitempty"` + Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist,omitempty"` + WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"` + Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"` + RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"` + AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"` + TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"` + UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"` + Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"` + Name string `description:"Provider name in UI." yaml:"name,omitempty"` + Claims OAuthServiceClaimsMap `description:"Map of claims to extract from the userinfo response." yaml:"claims,omitempty"` +} + +type OAuthServiceClaimsMap struct { + Username string `description:"Username claim." yaml:"username,omitempty"` + Email string `description:"Email claim." yaml:"email,omitempty"` + Name string `description:"Name claim." yaml:"name,omitempty"` + Groups string `description:"Groups claim." yaml:"groups,omitempty"` } type OIDCClientConfig struct { diff --git a/internal/service/oauth_extractors.go b/internal/service/oauth_extractors.go index 7ce37fd3..52758fd2 100644 --- a/internal/service/oauth_extractors.go +++ b/internal/service/oauth_extractors.go @@ -21,11 +21,15 @@ type GithubUserinfoResponse struct { ID int `json:"id"` } -func defaultExtractor(client *http.Client, ctx context.Context, url string) (*model.Claims, error) { - return simpleReq[model.Claims](client, ctx, url, nil) +func defaultExtractor(client *http.Client, ctx context.Context, url string, mapClaims MapClaims) (*model.Claims, error) { + claims, err := simpleReq[map[string]any](client, ctx, url, nil) + if err != nil { + return nil, err + } + return new(mapClaims(*claims)), nil } -func githubExtractor(client *http.Client, ctx context.Context, _ string) (*model.Claims, error) { +func githubExtractor(client *http.Client, ctx context.Context, _ string, _ MapClaims) (*model.Claims, error) { var user model.Claims userInfo, err := simpleReq[GithubUserinfoResponse](client, ctx, "https://api.github.com/user", map[string]string{ diff --git a/internal/service/oauth_service.go b/internal/service/oauth_service.go index 9667e513..5a372baf 100644 --- a/internal/service/oauth_service.go +++ b/internal/service/oauth_service.go @@ -10,7 +10,8 @@ import ( "golang.org/x/oauth2" ) -type OAuthUserinfoExtractor func(client *http.Client, ctx context.Context, url string) (*model.Claims, error) +type MapClaims func(claims map[string]any) model.Claims +type OAuthUserinfoExtractor func(client *http.Client, ctx context.Context, url string, mapClaims MapClaims) (*model.Claims, error) type OAuthService struct { serviceCfg model.OAuthServiceConfig @@ -81,7 +82,7 @@ func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, er func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) { client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token)) - return s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL) + return s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL, s.mapClaims) } func (s *OAuthService) GetConfig() model.OAuthServiceConfig { @@ -97,3 +98,26 @@ func (s *OAuthService) UpdateConfig(config model.OAuthServiceConfig) { s.config.Endpoint.TokenURL = config.TokenURL s.config.RedirectURL = config.RedirectURL } + +func (s *OAuthService) mapClaims(claims map[string]any) model.Claims { + return model.Claims{ + Sub: mapClaim[string]("sub", "", claims), + Name: mapClaim[string]("name", s.serviceCfg.Claims.Name, claims), + PreferredUsername: mapClaim[string]("preferred_username", s.serviceCfg.Claims.Username, claims), + Email: mapClaim[string]("email", s.serviceCfg.Claims.Email, claims), + Groups: mapClaim[any]("groups", s.serviceCfg.Claims.Groups, claims), + } +} + +func mapClaim[T any](fallback, override string, kv map[string]any) T { + key := fallback + if override != "" { + key = override + } + v, ok := kv[key].(T) + if !ok { + var zero T + return zero + } + return v +}