diff --git a/cmd/root.go b/cmd/root.go index 4c612be..32af3a6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -39,6 +39,7 @@ var rootCmd = &cobra.Command{ config.GithubClientSecret = utils.GetSecret(config.GithubClientSecret, config.GithubClientSecretFile) config.GoogleClientSecret = utils.GetSecret(config.GoogleClientSecret, config.GoogleClientSecretFile) config.GenericClientSecret = utils.GetSecret(config.GenericClientSecret, config.GenericClientSecretFile) + config.TailscaleClientSecret = utils.GetSecret(config.TailscaleClientSecret, config.TailscaleClientSecretFile) // Validate config validator := validator.New() @@ -63,17 +64,19 @@ var rootCmd = &cobra.Command{ // Create OAuth config oauthConfig := types.OAuthConfig{ - GithubClientId: config.GithubClientId, - GithubClientSecret: config.GithubClientSecret, - GoogleClientId: config.GoogleClientId, - GoogleClientSecret: config.GoogleClientSecret, - GenericClientId: config.GenericClientId, - GenericClientSecret: config.GenericClientSecret, - GenericScopes: strings.Split(config.GenericScopes, ","), - GenericAuthURL: config.GenericAuthURL, - GenericTokenURL: config.GenericTokenURL, - GenericUserURL: config.GenericUserURL, - AppURL: config.AppURL, + GithubClientId: config.GithubClientId, + GithubClientSecret: config.GithubClientSecret, + GoogleClientId: config.GoogleClientId, + GoogleClientSecret: config.GoogleClientSecret, + TailscaleClientId: config.TailscaleClientId, + TailscaleClientSecret: config.TailscaleClientSecret, + GenericClientId: config.GenericClientId, + GenericClientSecret: config.GenericClientSecret, + GenericScopes: strings.Split(config.GenericScopes, ","), + GenericAuthURL: config.GenericAuthURL, + GenericTokenURL: config.GenericTokenURL, + GenericUserURL: config.GenericUserURL, + AppURL: config.AppURL, } log.Debug().Msg("Parsed OAuth config") @@ -147,6 +150,9 @@ func init() { rootCmd.Flags().String("google-client-id", "", "Google OAuth client ID.") rootCmd.Flags().String("google-client-secret", "", "Google OAuth client secret.") rootCmd.Flags().String("google-client-secret-file", "", "Google OAuth client secret file.") + rootCmd.Flags().String("tailscale-client-id", "", "Tailscale OAuth client ID.") + rootCmd.Flags().String("tailscale-client-secret", "", "Tailscale OAuth client secret.") + rootCmd.Flags().String("tailscale-client-secret-file", "", "Tailscale OAuth client secret file.") rootCmd.Flags().String("generic-client-id", "", "Generic OAuth client ID.") rootCmd.Flags().String("generic-client-secret", "", "Generic OAuth client secret.") rootCmd.Flags().String("generic-client-secret-file", "", "Generic OAuth client secret file.") @@ -172,6 +178,9 @@ func init() { viper.BindEnv("google-client-id", "GOOGLE_CLIENT_ID") viper.BindEnv("google-client-secret", "GOOGLE_CLIENT_SECRET") viper.BindEnv("google-client-secret-file", "GOOGLE_CLIENT_SECRET_FILE") + viper.BindEnv("tailscale-client-id", "TAILSCALE_CLIENT_ID") + viper.BindEnv("tailscale-client-secret", "TAILSCALE_CLIENT_SECRET") + viper.BindEnv("tailscale-client-secret-file", "TAILSCALE_CLIENT_SECRET_FILE") viper.BindEnv("generic-client-id", "GENERIC_CLIENT_ID") viper.BindEnv("generic-client-secret", "GENERIC_CLIENT_SECRET") viper.BindEnv("generic-client-secret-file", "GENERIC_CLIENT_SECRET_FILE") diff --git a/internal/api/api.go b/internal/api/api.go index 8839ea4..925ba40 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -3,6 +3,7 @@ package api import ( "fmt" "io/fs" + "math/rand/v2" "net/http" "os" "strings" @@ -294,6 +295,21 @@ func (api *API) SetupRoutes() { c.SetCookie("tinyauth_redirect_uri", redirectURI, 3600, "/", api.Domain, api.Config.CookieSecure, true) } + if request.Provider == "tailscale" { + tailscaleQuery, tailscaleQueryErr := query.Values(types.TailscaleQuery{ + Code: (1000 + rand.IntN(9000)), // doesn't need to be secure, just there to avoid caching + }) + if handleApiError(c, "Failed to build query", tailscaleQueryErr) { + return + } + c.JSON(200, gin.H{ + "status": 200, + "message": "Ok", + "url": fmt.Sprintf("%s/api/oauth/callback/tailscale?%s", api.Config.AppURL, tailscaleQuery.Encode()), + }) + return + } + c.JSON(200, gin.H{ "status": 200, "message": "Ok", diff --git a/internal/providers/providers.go b/internal/providers/providers.go index d13f95f..58ef44b 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -17,10 +17,11 @@ func NewProviders(config types.OAuthConfig) *Providers { } type Providers struct { - Config types.OAuthConfig - Github *oauth.OAuth - Google *oauth.OAuth - Generic *oauth.OAuth + Config types.OAuthConfig + Github *oauth.OAuth + Google *oauth.OAuth + Tailscale *oauth.OAuth + Generic *oauth.OAuth } func (providers *Providers) Init() { @@ -46,6 +47,17 @@ func (providers *Providers) Init() { }) providers.Google.Init() } + if providers.Config.TailscaleClientId != "" && providers.Config.TailscaleClientSecret != "" { + log.Info().Msg("Initializing Tailscale OAuth") + providers.Tailscale = oauth.NewOAuth(oauth2.Config{ + ClientID: providers.Config.TailscaleClientId, + ClientSecret: providers.Config.TailscaleClientSecret, + RedirectURL: fmt.Sprintf("%s/api/oauth/callback/tailscale", providers.Config.AppURL), + Scopes: TailscaleScopes(), + Endpoint: TailscaleEndpoint, + }) + providers.Tailscale.Init() + } if providers.Config.GenericClientId != "" && providers.Config.GenericClientSecret != "" { log.Info().Msg("Initializing Generic OAuth") providers.Generic = oauth.NewOAuth(oauth2.Config{ @@ -68,6 +80,8 @@ func (providers *Providers) GetProvider(provider string) *oauth.OAuth { return providers.Github case "google": return providers.Google + case "tailscale": + return providers.Tailscale case "generic": return providers.Generic default: @@ -103,6 +117,19 @@ func (providers *Providers) GetUser(provider string) (string, error) { } log.Debug().Msg("Got email from google") return email, nil + case "tailscale": + if providers.Tailscale == nil { + log.Debug().Msg("Tailscale provider not configured") + return "", nil + } + client := providers.Tailscale.GetClient() + log.Debug().Msg("Got client from tailscale") + email, emailErr := GetTailscaleEmail(client) + if emailErr != nil { + return "", emailErr + } + log.Debug().Msg("Got email from tailscale") + return email, nil case "generic": if providers.Generic == nil { log.Debug().Msg("Generic provider not configured") @@ -129,6 +156,9 @@ func (provider *Providers) GetConfiguredProviders() []string { if provider.Google != nil { providers = append(providers, "google") } + if provider.Tailscale != nil { + providers = append(providers, "tailscale") + } if provider.Generic != nil { providers = append(providers, "generic") } diff --git a/internal/providers/tailscale.go b/internal/providers/tailscale.go new file mode 100644 index 0000000..99a0346 --- /dev/null +++ b/internal/providers/tailscale.go @@ -0,0 +1,56 @@ +package providers + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/rs/zerolog/log" + "golang.org/x/oauth2" +) + +type TailscaleUser struct { + LoginName string `json:"loginName"` +} + +type TailscaleUserInfoResponse struct { + Users []TailscaleUser `json:"users"` +} + +func TailscaleScopes() []string { + return []string{"users:read"} +} + +var TailscaleEndpoint = oauth2.Endpoint{ + TokenURL: "https://api.tailscale.com/api/v2/oauth/token", +} + +func GetTailscaleEmail(client *http.Client) (string, error) { + res, resErr := client.Get("https://api.tailscale.com/api/v2/tailnet/-/users") + + if resErr != nil { + return "", resErr + } + + log.Debug().Msg("Got response from tailscale") + + body, bodyErr := io.ReadAll(res.Body) + + if bodyErr != nil { + return "", bodyErr + } + + log.Debug().Msg("Read body from tailscale") + + var users TailscaleUserInfoResponse + + jsonErr := json.Unmarshal(body, &users) + + if jsonErr != nil { + return "", jsonErr + } + + log.Debug().Msg("Parsed users from tailscale") + + return users.Users[0].LoginName, nil +} diff --git a/internal/types/types.go b/internal/types/types.go index a6f9b4a..ef1afe4 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -19,31 +19,34 @@ type User struct { type Users []User type Config struct { - Port int `mapstructure:"port" validate:"required"` - Address string `validate:"required,ip4_addr" mapstructure:"address"` - Secret string `validate:"required,len=32" mapstructure:"secret"` - SecretFile string `mapstructure:"secret-file"` - AppURL string `validate:"required,url" mapstructure:"app-url"` - Users string `mapstructure:"users"` - UsersFile string `mapstructure:"users-file"` - CookieSecure bool `mapstructure:"cookie-secure"` - GithubClientId string `mapstructure:"github-client-id"` - GithubClientSecret string `mapstructure:"github-client-secret"` - GithubClientSecretFile string `mapstructure:"github-client-secret-file"` - GoogleClientId string `mapstructure:"google-client-id"` - GoogleClientSecret string `mapstructure:"google-client-secret"` - GoogleClientSecretFile string `mapstructure:"google-client-secret-file"` - GenericClientId string `mapstructure:"generic-client-id"` - GenericClientSecret string `mapstructure:"generic-client-secret"` - GenericClientSecretFile string `mapstructure:"generic-client-secret-file"` - GenericScopes string `mapstructure:"generic-scopes"` - GenericAuthURL string `mapstructure:"generic-auth-url"` - GenericTokenURL string `mapstructure:"generic-token-url"` - GenericUserURL string `mapstructure:"generic-user-url"` - DisableContinue bool `mapstructure:"disable-continue"` - OAuthWhitelist string `mapstructure:"oauth-whitelist"` - CookieExpiry int `mapstructure:"cookie-expiry"` - LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"` + Port int `mapstructure:"port" validate:"required"` + Address string `validate:"required,ip4_addr" mapstructure:"address"` + Secret string `validate:"required,len=32" mapstructure:"secret"` + SecretFile string `mapstructure:"secret-file"` + AppURL string `validate:"required,url" mapstructure:"app-url"` + Users string `mapstructure:"users"` + UsersFile string `mapstructure:"users-file"` + CookieSecure bool `mapstructure:"cookie-secure"` + GithubClientId string `mapstructure:"github-client-id"` + GithubClientSecret string `mapstructure:"github-client-secret"` + GithubClientSecretFile string `mapstructure:"github-client-secret-file"` + GoogleClientId string `mapstructure:"google-client-id"` + GoogleClientSecret string `mapstructure:"google-client-secret"` + GoogleClientSecretFile string `mapstructure:"google-client-secret-file"` + TailscaleClientId string `mapstructure:"tailscale-client-id"` + TailscaleClientSecret string `mapstructure:"tailscale-client-secret"` + TailscaleClientSecretFile string `mapstructure:"tailscale-client-secret-file"` + GenericClientId string `mapstructure:"generic-client-id"` + GenericClientSecret string `mapstructure:"generic-client-secret"` + GenericClientSecretFile string `mapstructure:"generic-client-secret-file"` + GenericScopes string `mapstructure:"generic-scopes"` + GenericAuthURL string `mapstructure:"generic-auth-url"` + GenericTokenURL string `mapstructure:"generic-token-url"` + GenericUserURL string `mapstructure:"generic-user-url"` + DisableContinue bool `mapstructure:"disable-continue"` + OAuthWhitelist string `mapstructure:"oauth-whitelist"` + CookieExpiry int `mapstructure:"cookie-expiry"` + LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"` } type UserContext struct { @@ -64,17 +67,19 @@ type APIConfig struct { } type OAuthConfig struct { - GithubClientId string - GithubClientSecret string - GoogleClientId string - GoogleClientSecret string - GenericClientId string - GenericClientSecret string - GenericScopes []string - GenericAuthURL string - GenericTokenURL string - GenericUserURL string - AppURL string + GithubClientId string + GithubClientSecret string + GoogleClientId string + GoogleClientSecret string + TailscaleClientId string + TailscaleClientSecret string + GenericClientId string + GenericClientSecret string + GenericScopes []string + GenericAuthURL string + GenericTokenURL string + GenericUserURL string + AppURL string } type OAuthRequest struct { @@ -101,3 +106,7 @@ type TinyauthLabels struct { OAuthWhitelist []string Users []string } + +type TailscaleQuery struct { + Code int `url:"code"` +} diff --git a/site/src/icons/tailscale.tsx b/site/src/icons/tailscale.tsx new file mode 100644 index 0000000..bcbb7f2 --- /dev/null +++ b/site/src/icons/tailscale.tsx @@ -0,0 +1,55 @@ +import type { SVGProps } from "react"; + +export function TailscaleIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/site/src/pages/login-page.tsx b/site/src/pages/login-page.tsx index 9c31254..1f4b509 100644 --- a/site/src/pages/login-page.tsx +++ b/site/src/pages/login-page.tsx @@ -19,6 +19,7 @@ import { Layout } from "../components/layouts/layout"; import { GoogleIcon } from "../icons/google"; import { GithubIcon } from "../icons/github"; import { OAuthIcon } from "../icons/oauth"; +import { TailscaleIcon } from "../icons/tailscale"; export const LoginPage = () => { const queryString = window.location.search; @@ -146,6 +147,21 @@ export const LoginPage = () => { )} + {oauthProviders.includes("tailscale") && ( + + + + )} {oauthProviders.includes("generic") && (