Compare commits

...

34 Commits

Author SHA1 Message Date
Stavros
97830a309b chore: bump version 2025-02-02 19:36:12 +02:00
Stavros
fe594d2755 fix: do not crash when docker is not connected 2025-02-02 19:34:02 +02:00
Stavros
b3aac26644 chore: update gitignore 2025-02-02 14:28:12 +02:00
Stavros
c37f66abb9 feat: strip go executable for smaller size 2025-02-02 13:31:35 +02:00
Stavros
2c4f086008 chore: bump version 2025-02-01 16:30:31 +02:00
Stavros
6e5f882e0b feat: tailscale oauth 2025-02-01 16:28:39 +02:00
Stavros
99268f80c9 fix: check available providers correctly 2025-02-01 15:29:26 +02:00
Stavros
dcd816b6c6 fix: parse generic oauth provider config correctly 2025-01-31 17:28:59 +02:00
Stavros
381f6ef76f Merge pull request #24 from steveiliop56/feat/docker-labels
Feat/docker labels
2025-01-30 17:14:07 +02:00
Stavros
8a8ba18ded refactor: remove label value from logging 2025-01-30 17:13:38 +02:00
Stavros
29f0a94faf feat: finalize logic 2025-01-30 17:11:31 +02:00
Stavros
6602e8140b wip 2025-01-29 22:06:52 +02:00
Stavros
2385599c80 fix: omit port from cookie domain configuration 2025-01-29 17:55:21 +02:00
Stavros
6f184856f1 chore: bump version 2025-01-28 19:10:36 +02:00
Stavros
e2e3b3bdc6 refactor: use window.location.href for redirects 2025-01-28 19:08:00 +02:00
Stavros
3efcb26db1 refactor: remove sensitive info logging even in debug mode 2025-01-28 17:36:06 +02:00
Stavros
c54267f50d fix: parse users correctly 2025-01-26 22:40:55 +02:00
Stavros
4de12ce5c1 fix: no need to log that the provider is empty 2025-01-26 21:36:41 +02:00
Stavros
0cf0aafc14 fix: configure secrets before config validation 2025-01-26 21:13:26 +02:00
Stavros
80ea43184c chore: update readme 2025-01-26 20:52:35 +02:00
Stavros
3c4dffd479 chore: bump version 2025-01-26 20:52:06 +02:00
Stavros
f19f40f9fc feat: add secret file 2025-01-26 20:47:08 +02:00
Stavros
a243f22ac8 refactor: users are not a requirement when using oauth 2025-01-26 20:45:34 +02:00
Stavros
08d382c981 feat: add debug log level 2025-01-26 20:23:09 +02:00
Stavros
94f7debb10 feat: secrets file 2025-01-26 19:51:58 +02:00
Stavros
3b50d9303b refactor: use cookie store correctly 2025-01-26 19:51:58 +02:00
Stavros
d67133aca7 fix: get correct username from query params 2025-01-26 19:51:58 +02:00
Stavros
989ea8f229 refactor: rename email back to username 2025-01-26 19:51:58 +02:00
Stavros
708006decf refactor: move disable continue screen logic back to the continue screen 2025-01-26 19:51:14 +02:00
Stavros
682a918812 refactor: don't store oauth token in cookie 2025-01-26 11:05:11 +02:00
Stavros
389248cfe1 refactor: change cli about text 2025-01-25 21:11:56 +02:00
Stavros
81d25061df refactor: move disable continue logic in login screen 2025-01-25 21:11:09 +02:00
Stavros
f59697955d chore: update readme 2025-01-25 20:47:26 +02:00
Stavros
47d8f1e5aa chore: update utility commands 2025-01-25 20:36:04 +02:00
30 changed files with 984 additions and 328 deletions

12
.gitignore vendored
View File

@@ -8,4 +8,14 @@ tinyauth
docker-compose.test.yml
# users file
users.txt
users.txt
# secret test file
secret.txt
secret_oauth.txt
# vscode
.vscode
# apple stuff
.DS_Store

View File

@@ -35,7 +35,7 @@ COPY ./cmd ./cmd
COPY ./internal ./internal
COPY --from=site-builder /site/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build
RUN CGO_ENABLED=0 go build -ldflags "-s -w"
# Runner
FROM alpine:3.21 AS runner

View File

@@ -14,7 +14,7 @@
<br />
Tinyauth is a simple authentication middleware that adds simple email/password login to all of your docker apps. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
> [!WARNING]
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.

View File

@@ -1,15 +1,21 @@
package cmd
import (
"os"
"strings"
"time"
cmd "tinyauth/cmd/user"
"tinyauth/internal/api"
"tinyauth/internal/assets"
"tinyauth/internal/auth"
"tinyauth/internal/docker"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -17,65 +23,73 @@ import (
var rootCmd = &cobra.Command{
Use: "tinyauth",
Short: "An extremely simple traefik forward auth proxy.",
Long: `Tinyauth is an extremely simple traefik forward-auth login screen that makes securing your apps easy.`,
Short: "The simplest way to protect your apps with a login screen.",
Long: `Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.`,
Run: func(cmd *cobra.Command, args []string) {
// Logger
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
// Get config
log.Info().Msg("Parsing config")
var config types.Config
parseErr := viper.Unmarshal(&config)
HandleError(parseErr, "Failed to parse config")
// Secrets
config.Secret = utils.GetSecret(config.Secret, config.SecretFile)
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
log.Info().Msg("Validating config")
validator := validator.New()
validateErr := validator.Struct(config)
HandleError(validateErr, "Invalid config")
HandleError(validateErr, "Failed to validate config")
// Parse users
// Logger
log.Logger = log.Level(zerolog.Level(config.LogLevel))
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
// Users
log.Info().Msg("Parsing users")
users, usersErr := utils.GetUsers(config.Users, config.UsersFile)
if config.UsersFile == "" && config.Users == "" {
log.Fatal().Msg("No users provided")
if (len(users) == 0 || usersErr != nil) && !utils.OAuthConfigured(config) {
log.Fatal().Err(usersErr).Msg("Failed to parse users")
}
usersString := config.Users
if config.UsersFile != "" {
log.Info().Msg("Reading users from file")
usersFromFile, readErr := utils.GetUsersFromFile(config.UsersFile)
HandleError(readErr, "Failed to read users from file")
usersFromFileParsed := utils.ParseFileToLine(usersFromFile)
if usersString != "" {
usersString = usersString + "," + usersFromFileParsed
} else {
usersString = usersFromFileParsed
}
}
users, parseErr := utils.ParseUsers(usersString)
HandleError(parseErr, "Failed to parse users")
// Create oauth whitelist
oauthWhitelist := utils.ParseCommaString(config.OAuthWhitelist)
oauthWhitelist := strings.Split(config.OAuthWhitelist, ",")
log.Debug().Msg("Parsed OAuth whitelist")
// 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: utils.ParseCommaString(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")
// Create docker service
docker := docker.NewDocker()
// Initialize docker
dockerErr := docker.Init()
HandleError(dockerErr, "Failed to initialize docker")
// Create auth service
auth := auth.NewAuth(users, oauthWhitelist)
auth := auth.NewAuth(docker, users, oauthWhitelist)
// Create OAuth providers service
providers := providers.NewProviders(oauthConfig)
@@ -125,16 +139,23 @@ func init() {
rootCmd.Flags().Int("port", 3000, "Port to run the server on.")
rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.")
rootCmd.Flags().String("secret", "", "Secret to use for the cookie.")
rootCmd.Flags().String("secret-file", "", "Path to a file containing the secret.")
rootCmd.Flags().String("app-url", "", "The tinyauth URL.")
rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:bcrypt-hashed-password.")
rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:bcrypt-hashed-password.")
rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:hash.")
rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:hash.")
rootCmd.Flags().Bool("cookie-secure", false, "Send cookie over secure connection only.")
rootCmd.Flags().String("github-client-id", "", "Github OAuth client ID.")
rootCmd.Flags().String("github-client-secret", "", "Github OAuth client secret.")
rootCmd.Flags().String("github-client-secret-file", "", "Github OAuth client secret file.")
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.")
rootCmd.Flags().String("generic-scopes", "", "Generic OAuth scopes.")
rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.")
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
@@ -142,25 +163,34 @@ func init() {
rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.")
rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.")
rootCmd.Flags().Int("cookie-expiry", 86400, "Cookie expiration time in seconds.")
rootCmd.Flags().Int("log-level", 1, "Log level.")
viper.BindEnv("port", "PORT")
viper.BindEnv("address", "ADDRESS")
viper.BindEnv("secret", "SECRET")
viper.BindEnv("secret-file", "SECRET_FILE")
viper.BindEnv("app-url", "APP_URL")
viper.BindEnv("users", "USERS")
viper.BindEnv("users-file", "USERS_FILE")
viper.BindEnv("cookie-secure", "COOKIE_SECURE")
viper.BindEnv("github-client-id", "GITHUB_CLIENT_ID")
viper.BindEnv("github-client-secret", "GITHUB_CLIENT_SECRET")
viper.BindEnv("github-client-secret-file", "GITHUB_CLIENT_SECRET_FILE")
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")
viper.BindEnv("generic-scopes", "GENERIC_SCOPES")
viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL")
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
viper.BindEnv("oauth-whitelist", "WHITELIST")
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
viper.BindEnv("cookie-expiry", "COOKIE_EXPIRY")
viper.BindEnv("log-level", "LOG_LEVEL")
viper.BindPFlags(rootCmd.Flags())
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"github.com/charmbracelet/huh"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
@@ -21,6 +22,8 @@ var CreateCmd = &cobra.Command{
Short: "Create a user",
Long: `Create a user either interactively or by passing flags.`,
Run: func(cmd *cobra.Command, args []string) {
log.Logger = log.Level(zerolog.InfoLevel)
if interactive {
form := huh.NewForm(
huh.NewGroup(

View File

@@ -8,12 +8,17 @@ import (
)
func UserCmd() *cobra.Command {
// Create the user command
userCmd := &cobra.Command{
Use: "user",
Use: "user",
Short: "User utilities",
Long: `Utilities for creating and verifying tinyauth compatible users.`,
Long: `Utilities for creating and verifying tinyauth compatible users.`,
}
// Add subcommands
userCmd.AddCommand(create.CreateCmd)
userCmd.AddCommand(verify.VerifyCmd)
// Return the user command
return userCmd
}
}

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/charmbracelet/huh"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
@@ -19,12 +20,14 @@ var user string
var VerifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify a user is set up correctly",
Long: `Verify a user is set up correctly meaning that it has a correct password.`,
Long: `Verify a user is set up correctly meaning that it has a correct username and password.`,
Run: func(cmd *cobra.Command, args []string) {
log.Logger = log.Level(zerolog.InfoLevel)
if interactive {
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("User (user:hash)").Value(&user).Validate((func(s string) error {
huh.NewInput().Title("User (username:hash)").Value(&user).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
@@ -86,5 +89,5 @@ func init() {
VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
VerifyCmd.Flags().StringVar(&username, "username", "", "Username")
VerifyCmd.Flags().StringVar(&password, "password", "", "Password")
VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (user:hash combination)")
VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (username:hash combination)")
}

17
go.mod
View File

@@ -14,6 +14,7 @@ require (
)
require (
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bytedance/sonic v1.12.7 // indirect
@@ -27,14 +28,22 @@ require (
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v27.5.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.2.2 // indirect
@@ -51,12 +60,16 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -67,6 +80,10 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.13.0 // indirect

69
go.sum
View File

@@ -1,3 +1,5 @@
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -32,10 +34,20 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.5.1+incompatible h1:4PYU5dnBYqRQi0294d1FBECqT9ECWeQAIfE8q4YnPY8=
github.com/docker/docker v27.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -48,6 +60,11 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -59,6 +76,8 @@ github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -79,10 +98,13 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -108,6 +130,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -119,8 +143,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@@ -138,6 +168,7 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@@ -151,9 +182,11 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -168,31 +201,67 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -3,6 +3,7 @@ package api
import (
"fmt"
"io/fs"
"math/rand/v2"
"net/http"
"os"
"strings"
@@ -42,17 +43,22 @@ type API struct {
func (api *API) Init() {
gin.SetMode(gin.ReleaseMode)
log.Debug().Msg("Setting up router")
router := gin.New()
router.Use(zerolog())
log.Debug().Msg("Setting up assets")
dist, distErr := fs.Sub(assets.Assets, "dist")
if distErr != nil {
log.Fatal().Err(distErr).Msg("Failed to get UI assets")
}
log.Debug().Msg("Setting up file server")
fileServer := http.FileServer(http.FS(dist))
log.Debug().Msg("Setting up cookie store")
store := cookie.NewStore([]byte(api.Config.Secret))
log.Debug().Msg("Getting domain")
domain, domainErr := utils.GetRootURL(api.Config.AppURL)
if domainErr != nil {
@@ -90,18 +96,36 @@ func (api *API) Init() {
func (api *API) SetupRoutes() {
api.Router.GET("/api/auth", func(c *gin.Context) {
userContext, userContextErr := api.Hooks.UseUserContext(c)
log.Debug().Msg("Checking auth")
userContext := api.Hooks.UseUserContext(c)
if userContextErr != nil {
log.Error().Err(userContextErr).Msg("Failed to get user context")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
host := c.Request.Header.Get("X-Forwarded-Host")
if userContext.IsLoggedIn {
log.Debug().Msg("Authenticated")
appAllowed, appAllowedErr := api.Auth.ResourceAllowed(userContext, host)
log.Debug().Bool("appAllowed", appAllowed).Msg("Checking if user is allowed")
if api.handleError(c, "Failed to check if resource is allowed", appAllowedErr) {
return
}
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
queries, queryErr := query.Values(types.UnauthorizedQuery{
Username: userContext.Username,
Resource: strings.Split(host, ".")[0],
})
if api.handleError(c, "Failed to build query", queryErr) {
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, queries.Encode()))
}
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
@@ -109,13 +133,12 @@ func (api *API) SetupRoutes() {
return
}
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
host := c.Request.Header.Get("X-Forwarded-Host")
queries, queryErr := query.Values(types.LoginQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
})
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
if queryErr != nil {
log.Error().Err(queryErr).Msg("Failed to build query")
c.JSON(501, gin.H{
@@ -142,9 +165,12 @@ func (api *API) SetupRoutes() {
return
}
user := api.Auth.GetUser(login.Email)
log.Debug().Msg("Got login request")
user := api.Auth.GetUser(login.Username)
if user == nil {
log.Debug().Str("username", login.Username).Msg("User not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -153,6 +179,7 @@ func (api *API) SetupRoutes() {
}
if !api.Auth.CheckPassword(*user, login.Password) {
log.Debug().Str("username", login.Username).Msg("Password incorrect")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -160,9 +187,12 @@ func (api *API) SetupRoutes() {
return
}
session := sessions.Default(c)
session.Set("tinyauth_sid", fmt.Sprintf("email:%s", login.Email))
session.Save()
log.Debug().Msg("Password correct, logging in")
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Provider: "username",
})
c.JSON(200, gin.H{
"status": 200,
@@ -171,9 +201,9 @@ func (api *API) SetupRoutes() {
})
api.Router.POST("/api/logout", func(c *gin.Context) {
session := sessions.Default(c)
session.Delete("tinyauth_sid")
session.Save()
api.Auth.DeleteSessionCookie(c)
log.Debug().Msg("Cleaning up redirect cookie")
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
@@ -184,39 +214,40 @@ func (api *API) SetupRoutes() {
})
api.Router.GET("/api/status", func(c *gin.Context) {
userContext, userContextErr := api.Hooks.UseUserContext(c)
log.Debug().Msg("Checking status")
userContext := api.Hooks.UseUserContext(c)
if userContextErr != nil {
log.Error().Err(userContextErr).Msg("Failed to get user context")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
configuredProviders := api.Providers.GetConfiguredProviders()
if api.Auth.UserAuthConfigured() {
configuredProviders = append(configuredProviders, "username")
}
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthenticated")
c.JSON(200, gin.H{
"status": 200,
"message": "Unauthenticated",
"email": "",
"username": "",
"isLoggedIn": false,
"oauth": false,
"provider": "",
"configuredProviders": api.Providers.GetConfiguredProviders(),
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
return
}
log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated")
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
"email": userContext.Email,
"username": userContext.Username,
"isLoggedIn": userContext.IsLoggedIn,
"oauth": userContext.OAuth,
"provider": userContext.Provider,
"configuredProviders": api.Providers.GetConfiguredProviders(),
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
})
@@ -242,6 +273,8 @@ func (api *API) SetupRoutes() {
return
}
log.Debug().Msg("Got OAuth request")
provider := api.Providers.GetProvider(request.Provider)
if provider == nil {
@@ -252,14 +285,34 @@ func (api *API) SetupRoutes() {
return
}
log.Debug().Str("provider", request.Provider).Msg("Got provider")
authURL := provider.GetAuthURL()
log.Debug().Msg("Got auth URL")
redirectURI := c.Query("redirect_uri")
if redirectURI != "" {
log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie")
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 api.handleError(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",
@@ -272,10 +325,12 @@ func (api *API) SetupRoutes() {
bindErr := c.BindUri(&providerName)
if handleApiError(c, "Failed to bind URI", bindErr) {
if api.handleError(c, "Failed to bind URI", bindErr) {
return
}
log.Debug().Interface("provider", providerName.Provider).Msg("Got provider name")
code := c.Query("code")
if code == "" {
@@ -284,39 +339,50 @@ func (api *API) SetupRoutes() {
return
}
log.Debug().Msg("Got code")
provider := api.Providers.GetProvider(providerName.Provider)
log.Debug().Str("provider", providerName.Provider).Msg("Got provider")
if provider == nil {
c.Redirect(http.StatusPermanentRedirect, "/not-found")
return
}
token, tokenErr := provider.ExchangeToken(code)
_, tokenErr := provider.ExchangeToken(code)
if handleApiError(c, "Failed to exchange token", tokenErr) {
log.Debug().Msg("Got token")
if api.handleError(c, "Failed to exchange token", tokenErr) {
return
}
email, emailErr := api.Providers.GetUser(providerName.Provider)
if handleApiError(c, "Failed to get user", emailErr) {
log.Debug().Str("email", email).Msg("Got email")
if api.handleError(c, "Failed to get user", emailErr) {
return
}
if !api.Auth.EmailWhitelisted(email) {
log.Warn().Str("email", email).Msg("Email not whitelisted")
unauthorizedQuery, unauthorizedQueryErr := query.Values(types.UnauthorizedQuery{
Email: email,
Username: email,
})
if handleApiError(c, "Failed to build query", unauthorizedQueryErr) {
if api.handleError(c, "Failed to build query", unauthorizedQueryErr) {
return
}
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, unauthorizedQuery.Encode()))
}
session := sessions.Default(c)
session.Set("tinyauth_sid", fmt.Sprintf("%s:%s", providerName.Provider, token))
session.Save()
log.Debug().Msg("Email whitelisted")
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: email,
Provider: providerName.Provider,
})
redirectURI, redirectURIErr := c.Cookie("tinyauth_redirect_uri")
@@ -327,13 +393,17 @@ func (api *API) SetupRoutes() {
})
}
log.Debug().Str("redirectURI", redirectURI).Msg("Got redirect URI")
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
redirectQuery, redirectQueryErr := query.Values(types.LoginQuery{
RedirectURI: redirectURI,
})
if handleApiError(c, "Failed to build query", redirectQueryErr) {
log.Debug().Msg("Got redirect query")
if api.handleError(c, "Failed to build query", redirectQueryErr) {
return
}
@@ -346,6 +416,15 @@ func (api *API) Run() {
api.Router.Run(fmt.Sprintf("%s:%d", api.Config.Address, api.Config.Port))
}
func (api *API) handleError(c *gin.Context, msg string, err error) bool {
if err != nil {
log.Error().Err(err).Msg(msg)
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/error", api.Config.AppURL))
return true
}
return false
}
func zerolog() gin.HandlerFunc {
return func(c *gin.Context) {
tStart := time.Now()
@@ -369,12 +448,3 @@ func zerolog() gin.HandlerFunc {
}
}
}
func handleApiError(c *gin.Context, msg string, err error) bool {
if err != nil {
log.Error().Err(err).Msg(msg)
c.Redirect(http.StatusPermanentRedirect, "/error")
return true
}
return false
}

View File

@@ -1 +1 @@
v1.0.0
v2.1.1

View File

@@ -1,13 +1,21 @@
package auth
import (
"slices"
"strings"
"tinyauth/internal/docker"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
func NewAuth(userList types.Users, oauthWhitelist []string) *Auth {
func NewAuth(docker *docker.Docker, userList types.Users, oauthWhitelist []string) *Auth {
return &Auth{
Docker: docker,
Users: userList,
OAuthWhitelist: oauthWhitelist,
}
@@ -15,12 +23,13 @@ func NewAuth(userList types.Users, oauthWhitelist []string) *Auth {
type Auth struct {
Users types.Users
Docker *docker.Docker
OAuthWhitelist []string
}
func (auth *Auth) GetUser(email string) *types.User {
func (auth *Auth) GetUser(username string) *types.User {
for _, user := range auth.Users {
if user.Email == email {
if user.Username == username {
return &user
}
}
@@ -43,3 +52,103 @@ func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
}
return false
}
func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) {
log.Debug().Msg("Creating session cookie")
sessions := sessions.Default(c)
log.Debug().Msg("Setting session cookie")
sessions.Set("username", data.Username)
sessions.Set("provider", data.Provider)
sessions.Save()
}
func (auth *Auth) DeleteSessionCookie(c *gin.Context) {
log.Debug().Msg("Deleting session cookie")
sessions := sessions.Default(c)
sessions.Clear()
sessions.Save()
}
func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) {
log.Debug().Msg("Getting session cookie")
sessions := sessions.Default(c)
cookieUsername := sessions.Get("username")
cookieProvider := sessions.Get("provider")
username, usernameOk := cookieUsername.(string)
provider, providerOk := cookieProvider.(string)
log.Debug().Str("username", username).Str("provider", provider).Msg("Parsed cookie")
if !usernameOk || !providerOk {
log.Warn().Msg("Session cookie invalid")
return types.SessionCookie{}, nil
}
return types.SessionCookie{
Username: username,
Provider: provider,
}, nil
}
func (auth *Auth) UserAuthConfigured() bool {
return len(auth.Users) > 0
}
func (auth *Auth) ResourceAllowed(context types.UserContext, host string) (bool, error) {
isConnected := auth.Docker.DockerConnected()
if !isConnected {
log.Debug().Msg("Docker not connected, allowing access")
return true, nil
}
appId := strings.Split(host, ".")[0]
containers, containersErr := auth.Docker.GetContainers()
if containersErr != nil {
return false, containersErr
}
log.Debug().Msg("Got containers")
for _, container := range containers {
inspect, inspectErr := auth.Docker.InspectContainer(container.ID)
if inspectErr != nil {
return false, inspectErr
}
containerName := strings.Split(inspect.Name, "/")[1]
if containerName == appId {
log.Debug().Str("container", containerName).Msg("Found container")
labels := utils.GetTinyauthLabels(inspect.Config.Labels)
log.Debug().Msg("Got labels")
if context.OAuth && len(labels.OAuthWhitelist) != 0 {
log.Debug().Msg("Checking OAuth whitelist")
if slices.Contains(labels.OAuthWhitelist, context.Username) {
return true, nil
}
return false, nil
}
if len(labels.Users) != 0 {
log.Debug().Msg("Checking users")
if slices.Contains(labels.Users, context.Username) {
return true, nil
}
return false, nil
}
}
}
log.Debug().Msg("No matching container found, allowing access")
return true, nil
}

View File

@@ -0,0 +1,6 @@
package constants
var TinyauthLabels = []string{
"tinyauth.oauth.whitelist",
"tinyauth.users",
}

56
internal/docker/docker.go Normal file
View File

@@ -0,0 +1,56 @@
package docker
import (
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
)
func NewDocker() *Docker {
return &Docker{}
}
type Docker struct {
Client *client.Client
Context context.Context
}
func (docker *Docker) Init() error {
apiClient, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return err
}
docker.Context = context.Background()
docker.Client = apiClient
return nil
}
func (docker *Docker) GetContainers() ([]types.Container, error) {
containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{})
if err != nil {
return nil, err
}
return containers, nil
}
func (docker *Docker) InspectContainer(containerId string) (types.ContainerJSON, error) {
inspect, err := docker.Client.ContainerInspect(docker.Context, containerId)
if err != nil {
return types.ContainerJSON{}, err
}
return inspect, nil
}
func (docker *Docker) DockerConnected() bool {
_, err := docker.Client.Ping(docker.Context)
return err == nil
}

View File

@@ -1,14 +1,12 @@
package hooks
import (
"strings"
"tinyauth/internal/auth"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
"github.com/rs/zerolog/log"
)
func NewHooks(auth *auth.Auth, providers *providers.Providers) *Hooks {
@@ -23,103 +21,60 @@ type Hooks struct {
Providers *providers.Providers
}
func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext, error) {
session := sessions.Default(c)
sessionCookie := session.Get("tinyauth_sid")
func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
cookie, cookiErr := hooks.Auth.GetSessionCookie(c)
if sessionCookie == nil {
if cookiErr != nil {
log.Error().Err(cookiErr).Msg("Failed to get session cookie")
return types.UserContext{
Email: "",
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
}
}
data, dataOk := sessionCookie.(string)
if !dataOk {
return types.UserContext{
Email: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
}
split := strings.Split(data, ":")
if len(split) != 2 {
return types.UserContext{
Email: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
}
sessionType := split[0]
sessionValue := split[1]
if sessionType == "email" {
user := hooks.Auth.GetUser(sessionValue)
if user == nil {
if cookie.Provider == "username" {
log.Debug().Msg("Provider is username")
if hooks.Auth.GetUser(cookie.Username) != nil {
log.Debug().Msg("User exists")
return types.UserContext{
Email: "",
Username: cookie.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "",
}
}
}
log.Debug().Msg("Provider is not username")
provider := hooks.Providers.GetProvider(cookie.Provider)
if provider != nil {
log.Debug().Msg("Provider exists")
if !hooks.Auth.EmailWhitelisted(cookie.Username) {
log.Error().Str("email", cookie.Username).Msg("Email is not whitelisted")
hooks.Auth.DeleteSessionCookie(c)
return types.UserContext{
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
}
}
log.Debug().Msg("Email is whitelisted")
return types.UserContext{
Email: sessionValue,
Username: cookie.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "",
}, nil
}
provider := hooks.Providers.GetProvider(sessionType)
if provider == nil {
return types.UserContext{
Email: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
}
provider.Token = &oauth2.Token{
AccessToken: sessionValue,
}
email, emailErr := hooks.Providers.GetUser(sessionType)
if emailErr != nil {
return types.UserContext{
Email: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
}
if !hooks.Auth.EmailWhitelisted(email) {
session.Delete("tinyauth_sid")
session.Save()
return types.UserContext{
Email: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
OAuth: true,
Provider: cookie.Provider,
}
}
return types.UserContext{
Email: email,
IsLoggedIn: true,
OAuth: true,
Provider: sessionType,
}, nil
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}
}

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"io"
"net/http"
"github.com/rs/zerolog/log"
)
type GenericUserInfoResponse struct {
@@ -17,12 +19,16 @@ func GetGenericEmail(client *http.Client, url string) (string, error) {
return "", resErr
}
log.Debug().Msg("Got response from generic provider")
body, bodyErr := io.ReadAll(res.Body)
if bodyErr != nil {
return "", bodyErr
}
log.Debug().Msg("Read body from generic provider")
var user GenericUserInfoResponse
jsonErr := json.Unmarshal(body, &user)
@@ -31,5 +37,7 @@ func GetGenericEmail(client *http.Client, url string) (string, error) {
return "", jsonErr
}
log.Debug().Msg("Parsed user from generic provider")
return user.Email, nil
}

View File

@@ -5,6 +5,8 @@ import (
"errors"
"io"
"net/http"
"github.com/rs/zerolog/log"
)
type GithubUserInfoResponse []struct {
@@ -23,12 +25,16 @@ func GetGithubEmail(client *http.Client) (string, error) {
return "", resErr
}
log.Debug().Msg("Got response from github")
body, bodyErr := io.ReadAll(res.Body)
if bodyErr != nil {
return "", bodyErr
}
log.Debug().Msg("Read body from github")
var emails GithubUserInfoResponse
jsonErr := json.Unmarshal(body, &emails)
@@ -37,6 +43,8 @@ func GetGithubEmail(client *http.Client) (string, error) {
return "", jsonErr
}
log.Debug().Msg("Parsed emails from github")
for _, email := range emails {
if email.Primary {
return email.Email, nil

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"io"
"net/http"
"github.com/rs/zerolog/log"
)
type GoogleUserInfoResponse struct {
@@ -21,12 +23,16 @@ func GetGoogleEmail(client *http.Client) (string, error) {
return "", resErr
}
log.Debug().Msg("Got response from google")
body, bodyErr := io.ReadAll(res.Body)
if bodyErr != nil {
return "", bodyErr
}
log.Debug().Msg("Read body from google")
var user GoogleUserInfoResponse
jsonErr := json.Unmarshal(body, &user)
@@ -35,5 +41,7 @@ func GetGoogleEmail(client *http.Client) (string, error) {
return "", jsonErr
}
log.Debug().Msg("Parsed user from google")
return user.Email, nil
}

View File

@@ -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:
@@ -79,33 +93,55 @@ func (providers *Providers) GetUser(provider string) (string, error) {
switch provider {
case "github":
if providers.Github == nil {
log.Debug().Msg("Github provider not configured")
return "", nil
}
client := providers.Github.GetClient()
log.Debug().Msg("Got client from github")
email, emailErr := GetGithubEmail(client)
if emailErr != nil {
return "", emailErr
}
log.Debug().Msg("Got email from github")
return email, nil
case "google":
if providers.Google == nil {
log.Debug().Msg("Google provider not configured")
return "", nil
}
client := providers.Google.GetClient()
log.Debug().Msg("Got client from google")
email, emailErr := GetGoogleEmail(client)
if emailErr != nil {
return "", emailErr
}
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")
return "", nil
}
client := providers.Generic.GetClient()
log.Debug().Msg("Got client from generic")
email, emailErr := GetGenericEmail(client, providers.Config.GenericUserURL)
if emailErr != nil {
return "", emailErr
}
log.Debug().Msg("Got email from generic")
return email, nil
default:
return "", nil
@@ -120,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")
}

View File

@@ -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
}

View File

@@ -7,42 +7,50 @@ type LoginQuery struct {
}
type LoginRequest struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
type User struct {
Email string
Username string
Password string
}
type Users []User
type Config struct {
Port int `validate:"number" mapstructure:"port"`
Address string `mapstructure:"address, ip4_addr"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
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"`
GoogleClientId string `mapstructure:"google-client-id"`
GoogleClientSecret string `mapstructure:"google-client-secret"`
GenericClientId string `mapstructure:"generic-client-id"`
GenericClientSecret string `mapstructure:"generic-client-secret"`
GenericScopes string `mapstructure:"generic-scopes"`
GenericAuthURL string `mapstructure:"generic-auth-url"`
GenericTokenURL string `mapstructure:"generic-token-url"`
GenericUserURL string `mapstructure:"generic-user-info-url"`
DisableContinue bool `mapstructure:"disable-continue"`
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
CookieExpiry int `mapstructure:"cookie-expiry"`
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 {
Email string
Username string
IsLoggedIn bool
OAuth bool
Provider string
@@ -59,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 {
@@ -83,5 +93,20 @@ type OAuthProviders struct {
}
type UnauthorizedQuery struct {
Email string `url:"email"`
Username string `url:"username"`
Resource string `url:"resource"`
}
type SessionCookie struct {
Username string
Provider string
}
type TinyauthLabels struct {
OAuthWhitelist []string
Users []string
}
type TailscaleQuery struct {
Code int `url:"code"`
}

View File

@@ -4,11 +4,16 @@ import (
"errors"
"net/url"
"os"
"slices"
"strings"
"tinyauth/internal/constants"
"tinyauth/internal/types"
"github.com/rs/zerolog/log"
)
func ParseUsers(users string) (types.Users, error) {
log.Debug().Msg("Parsing users")
var usersParsed types.Users
userList := strings.Split(users, ",")
@@ -22,11 +27,13 @@ func ParseUsers(users string) (types.Users, error) {
return types.Users{}, errors.New("invalid user format")
}
usersParsed = append(usersParsed, types.User{
Email: userSplit[0],
Username: userSplit[0],
Password: userSplit[1],
})
}
log.Debug().Msg("Parsed users")
return usersParsed, nil
}
@@ -37,21 +44,21 @@ func GetRootURL(urlSrc string) (string, error) {
return "", parseErr
}
urlSplitted := strings.Split(urlParsed.Host, ".")
urlSplitted := strings.Split(urlParsed.Hostname(), ".")
urlFinal := strings.Join(urlSplitted[1:], ".")
return urlFinal, nil
}
func GetUsersFromFile(usersFile string) (string, error) {
_, statErr := os.Stat(usersFile)
func ReadFile(file string) (string, error) {
_, statErr := os.Stat(file)
if statErr != nil {
return "", statErr
}
data, readErr := os.ReadFile(usersFile)
data, readErr := os.ReadFile(file)
if readErr != nil {
return "", readErr
@@ -69,15 +76,73 @@ func ParseFileToLine(content string) string {
continue
}
users = append(users, line)
users = append(users, strings.TrimSpace(line))
}
return strings.Join(users, ",")
}
func ParseCommaString(str string) []string {
if str == "" {
return []string{}
func GetSecret(conf string, file string) string {
if conf == "" && file == "" {
return ""
}
return strings.Split(str, ",")
if conf != "" {
return conf
}
contents, err := ReadFile(file)
if err != nil {
return ""
}
return contents
}
func GetUsers(conf string, file string) (types.Users, error) {
var users string
if conf == "" && file == "" {
return types.Users{}, errors.New("no users provided")
}
if conf != "" {
log.Debug().Msg("Using users from config")
users += conf
}
if file != "" {
fileContents, fileErr := ReadFile(file)
if fileErr == nil {
log.Debug().Msg("Using users from file")
if users != "" {
users += ","
}
users += ParseFileToLine(fileContents)
}
}
return ParseUsers(users)
}
func OAuthConfigured(config types.Config) bool {
return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "")
}
func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels {
var tinyauthLabels types.TinyauthLabels
for label, value := range labels {
if slices.Contains(constants.TinyauthLabels, label) {
log.Debug().Str("label", label).Msg("Found label")
switch label {
case "tinyauth.oauth.whitelist":
tinyauthLabels.OAuthWhitelist = strings.Split(value, ",")
case "tinyauth.users":
tinyauthLabels.Users = strings.Split(value, ",")
}
}
}
return tinyauthLabels
}

View File

@@ -4,7 +4,6 @@ import (
"os"
"time"
"tinyauth/cmd"
"tinyauth/internal/assets"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -12,8 +11,7 @@ import (
func main() {
// Logger
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger()
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
// Run cmd
cmd.Execute()

Binary file not shown.

View File

@@ -0,0 +1,55 @@
import type { SVGProps } from "react";
export function TailscaleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={24}
height={24}
{...props}
>
<style>{".st0{opacity:0.2;fill:#CCCAC9;}.st1{fill:#FFFFFF;}"}</style>
<g>
<g>
<path
className="st0"
d="M65.6,127.7c35.3,0,63.9-28.6,63.9-63.9S100.9,0,65.6,0S1.8,28.6,1.8,63.9S30.4,127.7,65.6,127.7z"
/>
<path
className="st1"
d="M65.6,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9S1.8,219,1.8,254.2S30.4,318.1,65.6,318.1z"
/>
<path
className="st0"
d="M65.6,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9S1.8,412.9,1.8,448.1S30.4,512,65.6,512z"
/>
<path
className="st1"
d="M257.2,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S221.9,318.1,257.2,318.1z"
/>
<path
className="st1"
d="M257.2,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S221.9,512,257.2,512z"
/>
<path
className="st0"
d="M257.2,127.7c35.3,0,63.9-28.6,63.9-63.9S292.5,0,257.2,0s-63.9,28.6-63.9,63.9S221.9,127.7,257.2,127.7z"
/>
<path
className="st0"
d="M446.4,127.7c35.3,0,63.9-28.6,63.9-63.9S481.6,0,446.4,0c-35.3,0-63.9,28.6-63.9,63.9S411.1,127.7,446.4,127.7z"
/>
<path
className="st1"
d="M446.4,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S411.1,318.1,446.4,318.1z"
/>
<path
className="st0"
d="M446.4,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S411.1,512,446.4,512z"
/>
</g>
</g>
</svg>
);
}

View File

@@ -1,8 +1,9 @@
import { Button, Paper, Text } from "@mantine/core";
import { Button, Code, Paper, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { Navigate } from "react-router";
import { useUserContext } from "../context/user-context";
import { Layout } from "../components/layouts/layout";
import { ReactNode } from "react";
export const ContinuePage = () => {
const queryString = window.location.search;
@@ -12,11 +13,11 @@ export const ContinuePage = () => {
const { isLoggedIn, disableContinue } = useUserContext();
if (!isLoggedIn) {
return <Navigate to="/login" />;
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
}
if (disableContinue && redirectUri !== "null") {
window.location.replace(redirectUri!);
if (redirectUri === "null") {
return <Navigate to="/" />;
}
const redirect = () => {
@@ -26,31 +27,62 @@ export const ContinuePage = () => {
color: "blue",
});
setTimeout(() => {
window.location.replace(redirectUri!);
window.location.href = redirectUri!;
}, 500);
};
const urlParsed = URL.parse(redirectUri!);
if (
window.location.protocol === "https:" &&
urlParsed!.protocol === "http:"
) {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Insecure Redirect
</Text>
<Text>
Your are logged in but trying to redirect from <Code>https</Code> to{" "}
<Code>http</Code>, please click the button to redirect.
</Text>
<Button fullWidth mt="xl" onClick={redirect}>
Continue
</Button>
</ContinuePageLayout>
);
}
if (disableContinue) {
window.location.href = redirectUri!;
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Redirecting
</Text>
<Text>You should be redirected to your app soon.</Text>
</ContinuePageLayout>
);
}
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Continue
</Text>
<Text>Click the button to continue to your app.</Text>
<Button fullWidth mt="xl" onClick={redirect}>
Continue
</Button>
</ContinuePageLayout>
);
};
export const ContinuePageLayout = ({ children }: { children: ReactNode }) => {
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
{redirectUri !== "null" ? (
<>
<Text size="xl" fw={700}>
Continue
</Text>
<Text>Click the button to continue to your app.</Text>
<Button fullWidth mt="xl" onClick={redirect}>
Continue
</Button>
</>
) : (
<>
<Text size="xl" fw={700}>
Logged in
</Text>
<Text>You are now signed in and can use your apps.</Text>
</>
)}
{children}
</Paper>
</Layout>
);

View File

@@ -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;
@@ -26,13 +27,16 @@ export const LoginPage = () => {
const redirectUri = params.get("redirect_uri");
const { isLoggedIn, configuredProviders } = useUserContext();
const oauthProviders = configuredProviders.filter(
(value) => value !== "username",
);
if (isLoggedIn) {
return <Navigate to="/logout" />;
}
const schema = z.object({
email: z.string().email(),
username: z.string(),
password: z.string(),
});
@@ -41,7 +45,7 @@ export const LoginPage = () => {
const form = useForm({
mode: "uncontrolled",
initialValues: {
email: "",
username: "",
password: "",
},
validate: zodResolver(schema),
@@ -54,7 +58,7 @@ export const LoginPage = () => {
onError: () => {
notifications.show({
title: "Failed to login",
message: "Check your email and password",
message: "Check your username and password",
color: "red",
});
},
@@ -65,8 +69,12 @@ export const LoginPage = () => {
color: "green",
});
setTimeout(() => {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
});
if (redirectUri === "null") {
window.location.replace("/");
} else {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}
}, 500);
},
});
@@ -84,7 +92,14 @@ export const LoginPage = () => {
});
},
onSuccess: (data) => {
window.location.replace(data.data.url);
notifications.show({
title: "Redirecting",
message: "Redirecting to your OAuth provider",
color: "blue",
});
setTimeout(() => {
window.location.href = data.data.url;
}, 500);
},
});
@@ -96,18 +111,13 @@ export const LoginPage = () => {
<Layout>
<Title ta="center">Tinyauth</Title>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
{configuredProviders.length === 0 && (
<Text size="lg" mb="md" fw={500} ta="center">
Welcome back, please login
</Text>
)}
{configuredProviders.length > 0 && (
{oauthProviders.length > 0 && (
<>
<Text size="lg" fw={500} ta="center">
Welcome back, login with
</Text>
<Grid mb="md" mt="md" align="center" justify="center">
{configuredProviders.includes("google") && (
{oauthProviders.includes("google") && (
<Grid.Col span="content">
<Button
radius="xl"
@@ -122,7 +132,7 @@ export const LoginPage = () => {
</Button>
</Grid.Col>
)}
{configuredProviders.includes("github") && (
{oauthProviders.includes("github") && (
<Grid.Col span="content">
<Button
radius="xl"
@@ -137,7 +147,22 @@ export const LoginPage = () => {
</Button>
</Grid.Col>
)}
{configuredProviders.includes("generic") && (
{oauthProviders.includes("tailscale") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<TailscaleIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("tailscale")}
loading={loginOAuthMutation.isLoading}
>
Tailscale
</Button>
</Grid.Col>
)}
{oauthProviders.includes("generic") && (
<Grid.Col span="content">
<Button
radius="xl"
@@ -153,40 +178,44 @@ export const LoginPage = () => {
</Grid.Col>
)}
</Grid>
<Divider
label="Or continue with email"
labelPosition="center"
my="lg"
/>
{configuredProviders.includes("username") && (
<Divider
label="Or continue with password"
labelPosition="center"
my="lg"
/>
)}
</>
)}
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
label="Email"
placeholder="user@example.com"
required
disabled={loginMutation.isLoading}
key={form.key("email")}
{...form.getInputProps("email")}
/>
<PasswordInput
label="Password"
placeholder="password"
required
mt="md"
disabled={loginMutation.isLoading}
key={form.key("password")}
{...form.getInputProps("password")}
/>
<Button
fullWidth
mt="xl"
type="submit"
loading={loginMutation.isLoading}
>
Login
</Button>
</form>
{configuredProviders.includes("username") && (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
label="Username"
placeholder="user@example.com"
required
disabled={loginMutation.isLoading}
key={form.key("username")}
{...form.getInputProps("username")}
/>
<PasswordInput
label="Password"
placeholder="password"
required
mt="md"
disabled={loginMutation.isLoading}
key={form.key("password")}
{...form.getInputProps("password")}
/>
<Button
fullWidth
mt="xl"
type="submit"
loading={loginMutation.isLoading}
>
Login
</Button>
</form>
)}
</Paper>
</Layout>
);

View File

@@ -8,7 +8,7 @@ import { Layout } from "../components/layouts/layout";
import { capitalize } from "../utils/utils";
export const LogoutPage = () => {
const { isLoggedIn, email, oauth, provider } = useUserContext();
const { isLoggedIn, username, oauth, provider } = useUserContext();
if (!isLoggedIn) {
return <Navigate to="/login" />;
@@ -32,7 +32,7 @@ export const LogoutPage = () => {
color: "green",
});
setTimeout(() => {
window.location.reload();
window.location.replace("/login");
}, 500);
},
});
@@ -44,9 +44,9 @@ export const LogoutPage = () => {
Logout
</Text>
<Text>
You are currently logged in as <Code>{email}</Code>
{oauth && ` using ${capitalize(provider)}`}. Click the button below to
log out.
You are currently logged in as <Code>{username}</Code>
{oauth && ` using ${capitalize(provider)} OAuth`}. Click the button
below to log out.
</Text>
<Button
fullWidth

View File

@@ -1,20 +1,14 @@
import { Button, Code, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router";
export const UnauthorizedPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const email = params.get("email");
const username = params.get("username");
const resource = params.get("resource");
const { isLoggedIn } = useUserContext();
if (isLoggedIn) {
return <Navigate to="/" />;
}
if (email === "null") {
if (username === "null") {
return <Navigate to="/" />;
}
@@ -25,8 +19,14 @@ export const UnauthorizedPage = () => {
Unauthorized
</Text>
<Text>
The user with email address <Code>{email}</Code> is not authorized to
login.
The user with username <Code>{username}</Code> is not authorized to{" "}
{resource !== "null" ? (
<span>
access the <Code>{resource}</Code> resource.
</span>
) : (
"login."
)}
</Text>
<Button
fullWidth

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
export const userContextSchema = z.object({
isLoggedIn: z.boolean(),
email: z.string(),
username: z.string(),
oauth: z.boolean(),
provider: z.string(),
configuredProviders: z.array(z.string()),