Compare commits

..

10 Commits

Author SHA1 Message Date
Stavros 5fabc8be49 refactor: use ansi colors in cli 2026-07-02 22:54:08 +03:00
Stavros 8a88367f12 Merge branch 'main' into refactor/cli 2026-07-02 22:35:36 +03:00
Stavros c8b31c54a0 chore: remove prettier from frontend 2026-07-02 22:23:52 +03:00
Stavros 97daac3b8b fix: review comments 2026-07-02 18:17:01 +03:00
Stavros 92172af095 feat: add some colors to cli commands 2026-07-02 17:49:56 +03:00
Stavros 54992d4f6f refactor: use fmt print instead of log fatal on errors 2026-07-02 15:36:04 +03:00
Stavros 04b93fa107 fix: remove shutdown from serve error path 2026-07-02 15:07:04 +03:00
Stavros a6c716c4e2 fix: ensure data paths are set correctly in docker, fixes #958 (#959) 2026-07-01 16:12:46 +03:00
Stavros ffafb5bff5 feat: add a reconnect to the initial ldap connection (#928) 2026-06-30 15:57:41 +03:00
Stavros bb867ea5f4 docs: update readme with openid certification badge 2026-06-29 01:35:06 +03:00
20 changed files with 684 additions and 417 deletions
+3
View File
@@ -51,3 +51,6 @@ config.certify.yml
# deepsec # deepsec
/.deepsec /.deepsec
# jetbrains
/.idea/
+7 -5
View File
@@ -52,15 +52,17 @@ WORKDIR /tinyauth
COPY --from=builder /tinyauth/tinyauth ./ COPY --from=builder /tinyauth/tinyauth ./
RUN mkdir -p /data
EXPOSE 3000 EXPOSE 3000
# Make the data directory with a non-root user
RUN addgroup tinyauth && adduser -DH tinyauth -G tinyauth
RUN mkdir -p /data/resources /data/oidc /data/tailscale
RUN chown -R tinyauth:tinyauth /data
VOLUME ["/data"] VOLUME ["/data"]
ENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db # Tell tinyauth that it's running in a container and where to find the data directory
ENV RUNTIME_ENV=docker
ENV TINYAUTH_RESOURCES_PATH=/data/resources
ENV PATH=$PATH:/tinyauth ENV PATH=$PATH:/tinyauth
+8 -6
View File
@@ -40,13 +40,16 @@ COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN mkdir -p data
RUN CGO_ENABLED=0 go build -ldflags "${LDFLAGS} \ RUN CGO_ENABLED=0 go build -ldflags "${LDFLAGS} \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \ -X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \ -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
# Make the data directory with a non-root user
RUN addgroup tinyauth && adduser -DH tinyauth -G tinyauth
RUN mkdir -p /data/resources /data/oidc /data/tailscale
RUN chown -R tinyauth:tinyauth /data
# Runner # Runner
FROM gcr.io/distroless/static-debian12:latest AS runner FROM gcr.io/distroless/static-debian12:latest AS runner
@@ -55,15 +58,14 @@ WORKDIR /tinyauth
COPY --from=builder /tinyauth/tinyauth ./ COPY --from=builder /tinyauth/tinyauth ./
# Since it's distroless, we need to copy the data directory from the builder stage # Since it's distroless, we need to copy the data directory from the builder stage
COPY --from=builder /tinyauth/data /data COPY --from=builder /data /data
EXPOSE 3000 EXPOSE 3000
VOLUME ["/data"] VOLUME ["/data"]
ENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db # Tell tinyauth that it's running in a container and where to find the data directory
ENV RUNTIME_ENV=docker
ENV TINYAUTH_RESOURCES_PATH=/data/resources
ENV PATH=$PATH:/tinyauth ENV PATH=$PATH:/tinyauth
+10 -3
View File
@@ -16,6 +16,8 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
.DEFAULT_GOAL := binary .DEFAULT_GOAL := binary
.PHONY: deps clean-data clean-webui webui binary binary-linux-amd64 binary-linux-arm64 test vet test-race dev dev-infisical prod prod-infisical sql generate docker docker-distroless
# Deps # Deps
deps: deps:
cd frontend && pnpm ci cd frontend && pnpm ci
@@ -58,12 +60,10 @@ binary-linux-arm64:
$(MAKE) binary $(MAKE) binary
# Go test # Go test
.PHONY: test
test: test:
go test -v ./... go test -v ./...
# Go vet # Go vet
.PHONY: vet
vet: vet:
go vet ./... go vet ./...
@@ -88,7 +88,6 @@ prod-infisical:
infisical run --env=dev -- docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans infisical run --env=dev -- docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans
# SQL # SQL
.PHONY: sql
sql: sql:
sqlc generate sqlc generate
@@ -96,3 +95,11 @@ sql:
generate: generate:
go run ./gen go run ./gen
go generate ./internal/repository/... go generate ./internal/repository/...
# Docker image
docker:
docker buildx build -t tinyauthapp/tinyauth:dev --build-arg=VERSION=$(TAG_NAME) --build-arg=COMMIT_HASH=$(COMMIT_HASH) --build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) -f Dockerfile .
# Docker image distroless
docker-distroless:
docker buildx build -t tinyauthapp/tinyauth:dev-distroless --build-arg=VERSION=$(TAG_NAME) --build-arg=COMMIT_HASH=$(COMMIT_HASH) --build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) -f Dockerfile.distroless .
+5 -1
View File
@@ -1,7 +1,7 @@
<div align="center"> <div align="center">
<img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png"> <img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png">
<h1>Tinyauth</h1> <h1>Tinyauth</h1>
<p>The tiniest authentication and authorization server you have ever seen.</p> <p>The tiniest OpenID Certified™ authorization and authentication server you have ever seen.</p>
</div> </div>
<div align="center"> <div align="center">
@@ -28,6 +28,10 @@ Tinyauth is the simplest and tiniest authentication and authorization server you
> [!NOTE] > [!NOTE]
> This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag. > This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag.
As of 2026-06-25, Tinyauth v5.1.0 is OpenID Certified™ for Basic OP. You can find the certification details [here](https://openid.net/certification-old/certified-openid-providers-profiles/), test suite available [here](https://www.certification.openid.net/plan-detail.html?public=true&plan=H0qhpsOcQkxUE).
<img alt="OpenID Certified" width="200" src="https://openid.net/wordpress-content/uploads/2016/05/oid-l-certification-mark-l-cmyk-150dpi-90mm.jpg" />
## Getting Started ## Getting Started
You can get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started). There is also an available [docker-compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities (keep in mind that this file lives in the development branch so it may have updates that are not yet released). You can get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started). There is also an available [docker-compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities (keep in mind that this file lives in the development branch so it may have updates that are not yet released).
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"fmt"
"strings"
"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/model"
"gopkg.in/yaml.v3"
)
func configCmd(tconfig *model.Config, loaders []cli.ResourceLoader) *cli.Command {
return &cli.Command{
Name: "config",
Description: "Dump the current configuration in YAML format, useful for debugging",
Configuration: tconfig,
Resources: loaders,
Run: func(_ []string) error {
buf := strings.Builder{}
fmt.Fprint(&buf, "Your current configuration in YAML is:\n\n")
yout, err := yaml.Marshal(&tconfig)
if err != nil {
return fmt.Errorf("failed to marshal yaml: %w", err)
}
for l := range strings.SplitSeq(string(yout), "\n") {
if l == "" {
continue
}
if strings.HasPrefix(strings.TrimLeft(l, " "), "- ") {
buf.WriteString(greenStyle.Render(l))
buf.WriteString("\n")
continue
}
lp := strings.SplitN(l, ":", 2)
buf.WriteString(redStyle.Render(lp[0]))
buf.WriteString(grayStyle.Render(":"))
if len(lp) == 2 {
buf.WriteString(greenStyle.Render(lp[1]))
}
buf.WriteString("\n")
}
fmt.Println(buf.String())
return nil
},
}
}
+77 -17
View File
@@ -7,8 +7,10 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils"
"gopkg.in/yaml.v3"
) )
func createOidcClientCmd() *cli.Command { func createOidcClientCmd() *cli.Command {
@@ -38,33 +40,91 @@ func createOidcClientCmd() *cli.Command {
uclientName := strings.ToUpper(clientName) uclientName := strings.ToUpper(clientName)
lclientName := strings.ToLower(clientName) lclientName := strings.ToLower(clientName)
builder := strings.Builder{} buf := strings.Builder{}
// header // header
fmt.Fprintf(&builder, "Created credentials for client %s\n\n", clientName) fmt.Fprintf(&buf, "Created '%s' OIDC client.\n\n", clientName)
// credentials // credentials
fmt.Fprintf(&builder, "Client Name: %s\n", clientName) fmt.Fprintf(&buf, "Credentials:\n\n")
fmt.Fprintf(&builder, "Client ID: %s\n", clientId) fmt.Fprintf(&buf, "Client Name: %s\n", clientName)
fmt.Fprintf(&builder, "Client Secret: %s\n\n", clientSecret) fmt.Fprintf(&buf, "Client ID: %s\n", clientId)
fmt.Fprintf(&buf, "Client Secret: %s\n\n", clientSecret)
// env variables // end variables
fmt.Fprint(&builder, "Environment variables:\n\n") fmt.Fprintf(&buf, "Environment variables:\n\n")
fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_CLIENTID=%s\n", uclientName, clientId) renderToBuf(&buf, []kv{
fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET=%s\n", uclientName, clientSecret) {
fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_NAME=%s\n\n", uclientName, utils.Capitalize(lclientName)) k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_CLIENTID", uclientName),
v: clientId,
},
{
k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET", uclientName),
v: clientSecret,
},
{
k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_NAME", uclientName),
v: utils.Capitalize(lclientName),
},
}, "=")
fmt.Fprintf(&buf, "\n")
// cli flags // cli flags
fmt.Fprint(&builder, "CLI flags:\n\n") fmt.Fprintf(&buf, "CLI flags:\n\n")
fmt.Fprintf(&builder, "--oidc.clients.%s.clientid=%s\n", lclientName, clientId) renderToBuf(&buf, []kv{
fmt.Fprintf(&builder, "--oidc.clients.%s.clientsecret=%s\n", lclientName, clientSecret) {
fmt.Fprintf(&builder, "--oidc.clients.%s.name=%s\n\n", lclientName, utils.Capitalize(lclientName)) k: fmt.Sprintf("--oidc-clients-%s-clientid", lclientName),
v: clientId,
},
{
k: fmt.Sprintf("--oidc-clients-%s-clientsecret", lclientName),
v: clientSecret,
},
{
k: fmt.Sprintf("--oidc-clients-%s-name", lclientName),
v: utils.Capitalize(lclientName),
},
}, "=")
fmt.Fprintf(&buf, "\n")
// yaml config
fmt.Fprintf(&buf, "YAML config:\n\n")
yout, err := yaml.Marshal(&model.OIDCConfig{
Clients: map[string]model.OIDCClientConfig{
lclientName: {
ClientID: clientId,
ClientSecret: clientSecret,
Name: utils.Capitalize(lclientName),
},
},
})
if err != nil {
return fmt.Errorf("failed to marshal yaml: %w", err)
}
for l := range strings.SplitSeq(string(yout), "\n") {
if l == "" {
continue
}
lp := strings.SplitN(l, ":", 2)
buf.WriteString(redStyle.Render(lp[0]))
buf.WriteString(grayStyle.Render(":"))
if len(lp) == 2 {
buf.WriteString("")
buf.WriteString(greenStyle.Render(lp[1]))
}
buf.WriteString("\n")
}
buf.WriteString("\n")
// footer // footer
fmt.Fprintln(&builder, "You can use either option to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.") fmt.Fprintln(&buf, "You can use any of the above options to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.")
// print // print
out := builder.String() out := buf.String()
fmt.Print(out) fmt.Print(out)
return nil return nil
}, },
+58 -15
View File
@@ -3,11 +3,11 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -34,62 +34,105 @@ func createUserCmd() *cli.Command {
&cli.FlagLoader{}, &cli.FlagLoader{},
} }
return &cli.Command{ cmd := &cli.Command{
Name: "create", Name: "create",
Description: "Create a user", Description: "Create a user",
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { }
log := logger.NewLogger().WithSimpleConfig()
log.Init()
cmd.Run = func(_ []string) error {
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error { huh.NewInput().Title("Username").Value(&tCfg.Username).Validate(func(s string) error {
if s == "" { if s == "" {
return errors.New("username cannot be empty") return errors.New("username cannot be empty")
} }
if strings.Contains(s, ":") {
return errors.New("username cannot contain ':'")
}
return nil return nil
})), }),
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error { huh.NewInput().Title("Password").Value(&tCfg.Password).Validate(func(s string) error {
if s == "" { if s == "" {
return errors.New("password cannot be empty") return errors.New("password cannot be empty")
} }
return nil return nil
})), }),
huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker), huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker),
), ),
) )
theme := new(themeBase) theme := new(themeBase)
err := form.WithTheme(theme).Run()
err := form.WithTheme(theme).Run()
if err != nil { if err != nil {
return fmt.Errorf("failed to run interactive prompt: %w", err) return fmt.Errorf("failed to run interactive prompt: %w", err)
} }
} }
if tCfg.Username == "" || tCfg.Password == "" { if tCfg.Username == "" || tCfg.Password == "" {
cmd.PrintHelp(os.Stdout)
return errors.New("username and password cannot be empty") return errors.New("username and password cannot be empty")
} }
log.App.Info().Str("username", tCfg.Username).Msg("Creating user") if strings.Contains(tCfg.Username, ":") {
return errors.New("username cannot contain ':'")
}
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost) passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return fmt.Errorf("failed to hash password: %w", err) return fmt.Errorf("failed to hash password: %w", err)
} }
// If docker format is enabled, escape the dollar sign // Only the docker compose output needs $ escaped, the raw hash is correct everywhere else
passwdStr := string(passwd) passwdStr := string(passwd)
outputStr := passwdStr
if tCfg.Docker { if tCfg.Docker {
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$") outputStr = strings.ReplaceAll(passwdStr, "$", "$$")
} }
log.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created") user := fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)
escapedUser := fmt.Sprintf("%s:%s", tCfg.Username, outputStr)
escapedUser = `"` + escapedUser + `"`
buf := strings.Builder{}
// header
fmt.Fprintf(&buf, "Created user '%s'.\n\n", tCfg.Username)
// environment variable
fmt.Fprint(&buf, "Environment variable:\n\n")
renderToBuf(&buf, []kv{
{"TINYAUTH_AUTH_USERS", escapedUser},
}, "=")
// cli flags
fmt.Fprint(&buf, "\nCLI flags:\n\n")
renderToBuf(&buf, []kv{
{"--auth.users", escapedUser},
}, "=")
// yaml config
fmt.Fprint(&buf, "\nYAML config:\n\n")
buf.WriteString(redStyle.Render("auth"))
buf.WriteString(grayStyle.Render(":"))
buf.WriteString("\n")
buf.WriteString(redStyle.Render(" users"))
buf.WriteString(grayStyle.Render(":"))
buf.WriteString(" ")
buf.WriteString(greenStyle.Render(user))
buf.WriteString("\n\n")
// footer
fmt.Fprint(&buf, "Use your config option of choice to add the user to Tinyauth and then restart.")
fmt.Println(buf.String())
return nil return nil
},
} }
return cmd
} }
+20 -10
View File
@@ -7,7 +7,6 @@ import (
"strings" "strings"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/mdp/qrterminal/v3" "github.com/mdp/qrterminal/v3"
@@ -34,15 +33,14 @@ func generateTotpCmd() *cli.Command {
&cli.FlagLoader{}, &cli.FlagLoader{},
} }
return &cli.Command{ cmd := &cli.Command{
Name: "generate", Name: "generate",
Description: "Generate a TOTP secret", Description: "Generate a TOTP secret",
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { }
log := logger.NewLogger().WithSimpleConfig()
log.Init()
cmd.Run = func(_ []string) error {
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
@@ -63,6 +61,11 @@ func generateTotpCmd() *cli.Command {
} }
} }
if tCfg.User == "" {
cmd.PrintHelp(os.Stdout)
return fmt.Errorf("user is required")
}
user, err := utils.ParseUser(tCfg.User) user, err := utils.ParseUser(tCfg.User)
if err != nil { if err != nil {
@@ -89,9 +92,7 @@ func generateTotpCmd() *cli.Command {
secret := key.Secret() secret := key.Secret()
log.App.Info().Str("secret", secret).Msg("Generated TOTP secret") fmt.Printf("Scan the following QR code with your authenticator app (e.g., Google Authenticator, 2fauth, Microsoft Authenticator):\n\n")
log.App.Info().Msg("Generated QR code")
config := qrterminal.Config{ config := qrterminal.Config{
Level: qrterminal.L, Level: qrterminal.L,
@@ -110,9 +111,18 @@ func generateTotpCmd() *cli.Command {
user.Password = strings.ReplaceAll(user.Password, "$", "$$") user.Password = strings.ReplaceAll(user.Password, "$", "$$")
} }
log.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.") userStr := fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)
fmt.Print("\nOr add the following TOTP secret to your authenticator app: ")
fmt.Print(greenStyle.Render(secret))
fmt.Print("\n\n")
fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username)
fmt.Print(greenStyle.Render(userStr))
fmt.Print("\n")
return nil return nil
},
} }
return cmd
} }
+73 -16
View File
@@ -2,18 +2,21 @@ package main
import ( import (
"fmt" "fmt"
"os"
"strings"
"charm.land/huh/v2" "charm.land/huh/v2"
"charm.land/lipgloss/v2"
"github.com/tinyauthapp/tinyauth/internal/bootstrap" "github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/loaders" "github.com/tinyauthapp/tinyauth/internal/utils/loaders"
"github.com/rs/zerolog/log"
"github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/cli"
) )
func main() { func main() {
tConfig := model.NewDefaultConfiguration() env := model.DetectRuntimeEnv()
tConfig := model.NewDefaultConfiguration(env)
loaders := []cli.ResourceLoader{ loaders := []cli.ResourceLoader{
&loaders.FileLoader{}, &loaders.FileLoader{},
@@ -33,77 +36,104 @@ func main() {
cmdUser := &cli.Command{ cmdUser := &cli.Command{
Name: "user", Name: "user",
Description: "Manage Tinyauth users", Description: "Manage users",
} }
cmdTotp := &cli.Command{ cmdTotp := &cli.Command{
Name: "totp", Name: "totp",
Description: "Manage Tinyauth TOTP users", Description: "Manage TOTP users",
} }
cmdOidc := &cli.Command{ cmdOidc := &cli.Command{
Name: "oidc", Name: "oidc",
Description: "Manage Tinyauth OIDC clients", Description: "Manage OIDC clients",
} }
err := cmdTinyauth.AddCommand(versionCmd()) helpCmd := &cli.Command{
Name: "help",
Description: "Show the help message",
Run: func(_ []string) error {
return cmdTinyauth.PrintHelp(os.Stdout)
},
}
err := cmdTinyauth.AddCommand(helpCmd)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add version command") fatalf(err, "Failed to add help command")
}
err = cmdTinyauth.AddCommand(versionCmd())
if err != nil {
fatalf(err, "Failed to add version command")
}
err = cmdTinyauth.AddCommand(configCmd(tConfig, loaders))
if err != nil {
fatalf(err, "Failed to add config command")
} }
err = cmdUser.AddCommand(verifyUserCmd()) err = cmdUser.AddCommand(verifyUserCmd())
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add verify command") fatalf(err, "Failed to add user verify command")
} }
err = cmdTinyauth.AddCommand(healthcheckCmd()) err = cmdTinyauth.AddCommand(healthcheckCmd())
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add healthcheck command") fatalf(err, "Failed to add healthcheck command")
} }
err = cmdTotp.AddCommand(generateTotpCmd()) err = cmdTotp.AddCommand(generateTotpCmd())
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add generate command") fatalf(err, "Failed to add totp generate command")
} }
err = cmdUser.AddCommand(createUserCmd()) err = cmdUser.AddCommand(createUserCmd())
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add create command") fatalf(err, "Failed to add create user command")
} }
err = cmdOidc.AddCommand(createOidcClientCmd()) err = cmdOidc.AddCommand(createOidcClientCmd())
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add create command") fatalf(err, "Failed to add create oidc client command")
} }
err = cmdTinyauth.AddCommand(cmdUser) err = cmdTinyauth.AddCommand(cmdUser)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add user command") fatalf(err, "Failed to add user command")
} }
err = cmdTinyauth.AddCommand(cmdTotp) err = cmdTinyauth.AddCommand(cmdTotp)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add totp command") fatalf(err, "Failed to add totp command")
} }
err = cmdTinyauth.AddCommand(cmdOidc) err = cmdTinyauth.AddCommand(cmdOidc)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to add oidc command") fatalf(err, "Failed to add oidc command")
} }
err = cli.Execute(cmdTinyauth) err = cli.Execute(cmdTinyauth)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to execute command") if strings.Contains(err.Error(), "command not found") {
fmt.Println("Command not found. Use 'tinyauth help' to see available commands.")
return
}
if strings.Contains(err.Error(), "is not runnable") {
return
}
fatalf(err, "Failed to execute command")
} }
} }
@@ -124,3 +154,30 @@ type themeBase struct{}
func (t *themeBase) Theme(isDark bool) *huh.Styles { func (t *themeBase) Theme(isDark bool) *huh.Styles {
return huh.ThemeBase(isDark) return huh.ThemeBase(isDark)
} }
var (
redStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(160))
greenStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(34))
grayStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(247))
yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(214))
blueStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(75))
)
func fatalf(err error, msg string) {
fmt.Printf("%s: %v\n", msg, err)
os.Exit(1)
}
type kv struct {
k string
v string
}
func renderToBuf(buf *strings.Builder, kv []kv, sep string) {
for _, i := range kv {
buf.WriteString(redStyle.Render(i.k))
buf.WriteString(grayStyle.Render(sep))
buf.WriteString(greenStyle.Render(i.v))
buf.WriteString("\n")
}
}
+14 -10
View File
@@ -3,9 +3,9 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@@ -38,15 +38,14 @@ func verifyUserCmd() *cli.Command {
&cli.FlagLoader{}, &cli.FlagLoader{},
} }
return &cli.Command{ cmd := &cli.Command{
Name: "verify", Name: "verify",
Description: "Verify a user is set up correctly", Description: "Verify a user is set up correctly",
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { }
log := logger.NewLogger().WithSimpleConfig()
log.Init()
cmd.Run = func(_ []string) error {
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
@@ -80,6 +79,11 @@ func verifyUserCmd() *cli.Command {
} }
} }
if tCfg.User == "" || tCfg.Username == "" || tCfg.Password == "" {
cmd.PrintHelp(os.Stdout)
return fmt.Errorf("user, username, and password are required")
}
user, err := utils.ParseUser(tCfg.User) user, err := utils.ParseUser(tCfg.User)
if err != nil { if err != nil {
@@ -98,21 +102,21 @@ func verifyUserCmd() *cli.Command {
if user.TOTPSecret == "" { if user.TOTPSecret == "" {
if tCfg.Totp != "" { if tCfg.Totp != "" {
log.App.Warn().Msg("User does not have TOTP secret") fmt.Println(yellowStyle.Render("⚠") + " TOTP code provided but user does not have TOTP enabled")
} }
log.App.Info().Msg("User verified") fmt.Println(greenStyle.Render("✓") + " User verified")
return nil return nil
} }
ok := totp.Validate(tCfg.Totp, user.TOTPSecret) ok := totp.Validate(tCfg.Totp, user.TOTPSecret)
if !ok { if !ok {
return fmt.Errorf("TOTP code incorrect") return fmt.Errorf("TOTP code incorrect")
} }
log.App.Info().Msg("User verified") fmt.Println(greenStyle.Render("✓") + " User verified")
return nil return nil
},
} }
return cmd
} }
+3 -3
View File
@@ -14,9 +14,9 @@ func versionCmd() *cli.Command {
Configuration: nil, Configuration: nil,
Resources: nil, Resources: nil,
Run: func(_ []string) error { Run: func(_ []string) error {
fmt.Printf("Version: %s\n", model.Version) fmt.Printf("Version: %s\n", blueStyle.Render(model.Version))
fmt.Printf("Commit Hash: %s\n", model.CommitHash) fmt.Printf("Commit Hash: %s\n", blueStyle.Render(model.CommitHash))
fmt.Printf("Build Timestamp: %s\n", model.BuildTimestamp) fmt.Printf("Build Timestamp: %s\n", blueStyle.Render(model.BuildTimestamp))
return nil return nil
}, },
} }
-1
View File
@@ -51,7 +51,6 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"prettier": "3.8.2",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
-10
View File
@@ -120,9 +120,6 @@ importers:
globals: globals:
specifier: ^17.5.0 specifier: ^17.5.0
version: 17.6.0 version: 17.6.0
prettier:
specifier: 3.8.2
version: 3.8.2
rollup-plugin-visualizer: rollup-plugin-visualizer:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1(rolldown@1.0.1) version: 7.0.1(rolldown@1.0.1)
@@ -2148,11 +2145,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier@3.8.2:
resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==}
engines: {node: '>=14'}
hasBin: true
property-information@7.1.0: property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@@ -4658,8 +4650,6 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier@3.8.2: {}
property-information@7.1.0: {} property-information@7.1.0: {}
proxy-from-env@2.1.0: {} proxy-from-env@2.1.0: {}
-1
View File
@@ -1,4 +1,3 @@
dangerouslyAllowAllBuilds: false dangerouslyAllowAllBuilds: false
blockExoticSubdeps: true blockExoticSubdeps: true
minimumReleaseAge: 1440 # 1 day minimumReleaseAge: 1440 # 1 day
trustPolicy: no-downgrade
+1 -1
View File
@@ -20,7 +20,7 @@ type EnvEntry struct {
} }
func generateExampleEnv() { func generateExampleEnv() {
cfg := model.NewDefaultConfiguration() cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown)
entries := make([]EnvEntry, 0) entries := make([]EnvEntry, 0)
root := reflect.TypeOf(cfg).Elem() root := reflect.TypeOf(cfg).Elem()
+1 -1
View File
@@ -21,7 +21,7 @@ type MarkdownEntry struct {
} }
func generateMarkdown() { func generateMarkdown() {
cfg := model.NewDefaultConfiguration() cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown)
entries := make([]MarkdownEntry, 0) entries := make([]MarkdownEntry, 0)
root := reflect.TypeOf(cfg).Elem() root := reflect.TypeOf(cfg).Elem()
-1
View File
@@ -227,7 +227,6 @@ func (app *BootstrapApp) serve(listener net.Listener, server *http.Server, ctx c
err := server.Serve(listener) err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
shutdown()
return fmt.Errorf("failed to start %s listener: %w", name, err) return fmt.Errorf("failed to start %s listener: %w", name, err)
} }
+170 -140
View File
@@ -1,8 +1,27 @@
package model package model
import "os"
type RuntimeEnv int
const (
RuntimeEnvUnknown RuntimeEnv = iota
RuntimeEnvDocker
)
func DetectRuntimeEnv() RuntimeEnv {
env := os.Getenv("RUNTIME_ENV")
switch env {
case "docker":
return RuntimeEnvDocker
default:
return RuntimeEnvUnknown
}
}
// Default configuration // Default configuration
func NewDefaultConfiguration() *Config { func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
return &Config{ cfg := &Config{
Database: DatabaseConfig{ Database: DatabaseConfig{
Driver: "sqlite", Driver: "sqlite",
Path: "./tinyauth.db", Path: "./tinyauth.db",
@@ -67,241 +86,252 @@ func NewDefaultConfiguration() *Config {
}, },
LabelProvider: "auto", LabelProvider: "auto",
} }
// apply path overrides for docker runtime
if runtimeEnv == RuntimeEnvDocker {
cfg.Database.Path = "/data/tinyauth.db"
cfg.Resources.Path = "/data/resources"
cfg.OIDC.PrivateKeyPath = "/data/oidc/key.pem"
cfg.OIDC.PublicKeyPath = "/data/oidc/key.pub"
cfg.Tailscale.Dir = "/data/tailscale"
}
return cfg
} }
type Config struct { type Config struct {
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"` AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"`
Database DatabaseConfig `description:"Database configuration." yaml:"database"` Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"` Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"` Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
Server ServerConfig `description:"Server configuration." yaml:"server"` Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"` Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"` Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"` OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"` OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"`
UI UIConfig `description:"UI customization." yaml:"ui"` UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"` LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"` // Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider"` LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"`
Log LogConfig `description:"Logging configuration." yaml:"log"` Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale"` Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
ConfigFile string `description:"Path to config file." yaml:"-"` ConfigFile string `description:"Path to config file." yaml:"-"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
Driver string `description:"The database driver to use. Valid values: sqlite, postgres, memory." yaml:"driver"` Driver string `description:"The database driver to use. Valid values: sqlite, postgres, memory." yaml:"driver,omitempty"`
Path string `description:"The path to the SQLite database file, or connection URL when driver is postgres." yaml:"path"` Path string `description:"The path to the SQLite database file, or connection URL when driver is postgres." yaml:"path,omitempty"`
} }
type AnalyticsConfig struct { type AnalyticsConfig struct {
Enabled bool `description:"Enable periodic version information collection." yaml:"enabled"` Enabled bool `description:"Enable periodic version information collection." yaml:"enabled,omitempty"`
} }
type ResourcesConfig struct { type ResourcesConfig struct {
Enabled bool `description:"Enable the resources server." yaml:"enabled"` Enabled bool `description:"Enable the resources server." yaml:"enabled,omitempty"`
Path string `description:"The directory where resources are stored." yaml:"path"` Path string `description:"The directory where resources are stored." yaml:"path,omitempty"`
} }
type ServerConfig struct { type ServerConfig struct {
Port int `description:"The port on which the server listens." yaml:"port"` Port int `description:"The port on which the server listens." yaml:"port,omitempty"`
Address string `description:"The address on which the server listens." yaml:"address"` Address string `description:"The address on which the server listens." yaml:"address,omitempty"`
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"` SocketPath string `description:"The path to the Unix socket." yaml:"socketPath,omitempty"`
} }
type AuthConfig struct { type AuthConfig struct {
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"` IP IPConfig `description:"IP whitelisting config options." yaml:"ip,omitempty"`
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users,omitempty"`
SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled"` SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled,omitempty"`
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"` UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes,omitempty"`
UsersFile string `description:"Path to the users file." yaml:"usersFile"` UsersFile string `description:"Path to the users file." yaml:"usersFile,omitempty"`
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"` SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie,omitempty"`
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"` SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry,omitempty"`
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"` SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"`
LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled"` LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled,omitempty"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"` ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"`
} }
type UserAttributes struct { type UserAttributes struct {
Name string `description:"Full name of the user." yaml:"name"` Name string `description:"Full name of the user." yaml:"name,omitempty"`
GivenName string `description:"Given (first) name of the user." yaml:"givenName"` GivenName string `description:"Given (first) name of the user." yaml:"givenName,omitempty"`
FamilyName string `description:"Family (last) name of the user." yaml:"familyName"` FamilyName string `description:"Family (last) name of the user." yaml:"familyName,omitempty"`
MiddleName string `description:"Middle name of the user." yaml:"middleName"` MiddleName string `description:"Middle name of the user." yaml:"middleName,omitempty"`
Nickname string `description:"Nickname of the user." yaml:"nickname"` Nickname string `description:"Nickname of the user." yaml:"nickname,omitempty"`
Profile string `description:"URL of the user's profile page." yaml:"profile"` Profile string `description:"URL of the user's profile page." yaml:"profile,omitempty"`
Picture string `description:"URL of the user's profile picture." yaml:"picture"` Picture string `description:"URL of the user's profile picture." yaml:"picture,omitempty"`
Website string `description:"URL of the user's website." yaml:"website"` Website string `description:"URL of the user's website." yaml:"website,omitempty"`
Email string `description:"Email address of the user." yaml:"email"` Email string `description:"Email address of the user." yaml:"email,omitempty"`
Gender string `description:"Gender of the user." yaml:"gender"` Gender string `description:"Gender of the user." yaml:"gender,omitempty"`
Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate"` Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate,omitempty"`
Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo"` Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo,omitempty"`
Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"` Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale,omitempty"`
PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"` PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber,omitempty"`
Address AddressClaim `description:"Address of the user." yaml:"address"` Address AddressClaim `description:"Address of the user." yaml:"address,omitempty"`
} }
type AddressClaim struct { type AddressClaim struct {
Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"` Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted,omitempty" json:"formatted,omitempty"`
StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"` StreetAddress string `description:"Street address." yaml:"streetAddress,omitempty" json:"street_address,omitempty"`
Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"` Locality string `description:"City or locality." yaml:"locality,omitempty" json:"locality,omitempty"`
Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"` Region string `description:"State, province, or region." yaml:"region,omitempty" json:"region,omitempty"`
PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"` PostalCode string `description:"Zip or postal code." yaml:"postalCode,omitempty" json:"postal_code,omitempty"`
Country string `description:"Country." yaml:"country" json:"country,omitempty"` Country string `description:"Country." yaml:"country,omitempty" json:"country,omitempty"`
} }
type IPConfig struct { type IPConfig struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow,omitempty"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block,omitempty"`
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass"` Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass,omitempty"`
} }
type OAuthConfig struct { type OAuthConfig struct {
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"` Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist,omitempty"`
WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"` WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile,omitempty"`
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"` AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect,omitempty"`
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"` Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers,omitempty"`
} }
type OIDCConfig struct { type OIDCConfig struct {
PrivateKeyPath string `description:"Path to the private key file, including file name." yaml:"privateKeyPath"` PrivateKeyPath string `description:"Path to the private key file, including file name." yaml:"privateKeyPath,omitempty"`
PublicKeyPath string `description:"Path to the public key file, including file name." yaml:"publicKeyPath"` PublicKeyPath string `description:"Path to the public key file, including file name." yaml:"publicKeyPath,omitempty"`
Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients"` Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients,omitempty"`
} }
type UIConfig struct { type UIConfig struct {
Title string `description:"The title of the UI." yaml:"title"` Title string `description:"The title of the UI." yaml:"title,omitempty"`
ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage"` ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage,omitempty"`
BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage"` BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage,omitempty"`
WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled"` WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled,omitempty"`
} }
type LDAPConfig struct { type LDAPConfig struct {
Address string `description:"LDAP server address." yaml:"address"` Address string `description:"LDAP server address." yaml:"address,omitempty"`
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn,omitempty"`
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword,omitempty"`
BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile"` BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile,omitempty"`
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn,omitempty"`
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure,omitempty"`
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` SearchFilter string `description:"LDAP search filter." yaml:"searchFilter,omitempty"`
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"` AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert,omitempty"`
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"` AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey,omitempty"`
GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"` GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL,omitempty"`
} }
type LogConfig struct { type LogConfig struct {
Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level"` Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level,omitempty"`
Json bool `description:"Enable JSON formatted logs." yaml:"json"` Json bool `description:"Enable JSON formatted logs." yaml:"json,omitempty"`
Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams"` Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams,omitempty"`
} }
type LogStreams struct { type LogStreams struct {
HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http"` HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http,omitempty"`
App LogStreamConfig `description:"Application logging." yaml:"app"` App LogStreamConfig `description:"Application logging." yaml:"app,omitempty"`
Audit LogStreamConfig `description:"Audit logging." yaml:"audit"` Audit LogStreamConfig `description:"Audit logging." yaml:"audit,omitempty"`
} }
type LogStreamConfig struct { type LogStreamConfig struct {
Enabled bool `description:"Enable this log stream." yaml:"enabled"` Enabled bool `description:"Enable this log stream." yaml:"enabled,omitempty"`
Level string `description:"Log level for this stream. Use global if empty." yaml:"level"` Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"`
} }
// no experimental features // no experimental features
type ExperimentalConfig struct{} type ExperimentalConfig struct{}
type TailscaleConfig struct { type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled"` Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"`
Dir string `description:"Tailscale state directory." yaml:"dir"` Dir string `description:"Tailscale state directory." yaml:"dir,omitempty"`
Hostname string `description:"Tailscale hostname." yaml:"hostname"` Hostname string `description:"Tailscale hostname." yaml:"hostname,omitempty"`
AuthKey string `description:"Tailscale auth key." yaml:"authKey"` AuthKey string `description:"Tailscale auth key." yaml:"authKey,omitempty"`
Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral"` Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral,omitempty"`
Funnel bool `description:"Enable Tailscale Funnel." yaml:"funnel"` Funnel bool `description:"Enable Tailscale Funnel." yaml:"funnel,omitempty"`
Listen bool `description:"Listen on the Tailscale address instead of standard address." yaml:"listen"` Listen bool `description:"Listen on the Tailscale address instead of standard address." yaml:"listen,omitempty"`
} }
// OAuth/OIDC config // OAuth/OIDC config
type OAuthServiceConfig struct { type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID." yaml:"clientId"` ClientID string `description:"OAuth client ID." yaml:"clientId,omitempty"`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"` ClientSecret string `description:"OAuth client secret." yaml:"clientSecret,omitempty"`
ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile"` 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"` 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"` WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"`
Scopes []string `description:"OAuth scopes." yaml:"scopes"` Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"`
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl"` RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"`
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl"` AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"`
TokenURL string `description:"OAuth token URL." yaml:"tokenUrl"` TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"`
UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl"` UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"`
Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure"` Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"`
Name string `description:"Provider name in UI." yaml:"name"` Name string `description:"Provider name in UI." yaml:"name,omitempty"`
} }
type OIDCClientConfig struct { type OIDCClientConfig struct {
ID string `description:"OIDC client ID." yaml:"-"` ID string `description:"OIDC client ID." yaml:"-"`
ClientID string `description:"OIDC client ID." yaml:"clientId"` ClientID string `description:"OIDC client ID." yaml:"clientId,omitempty"`
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"` ClientSecret string `description:"OIDC client secret." yaml:"clientSecret,omitempty"`
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"` ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile,omitempty"`
TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris"` TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris,omitempty"`
Name string `description:"Client name in UI." yaml:"name"` Name string `description:"Client name in UI." yaml:"name,omitempty"`
} }
type ACLsConfig struct { type ACLsConfig struct {
Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy"` Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy,omitempty"`
} }
// ACLs // ACLs
type Apps struct { type Apps struct {
Apps map[string]App `description:"App ACLs configuration." yaml:"apps"` Apps map[string]App `description:"App ACLs configuration." yaml:"apps,omitempty"`
} }
type App struct { type App struct {
Config AppConfig `description:"App configuration." yaml:"config"` Config AppConfig `description:"App configuration." yaml:"config,omitempty"`
Users AppUsers `description:"User access configuration." yaml:"users"` Users AppUsers `description:"User access configuration." yaml:"users,omitempty"`
OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth"` OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth,omitempty"`
IP AppIP `description:"IP access configuration." yaml:"ip"` IP AppIP `description:"IP access configuration." yaml:"ip,omitempty"`
Response AppResponse `description:"Response customization." yaml:"response"` Response AppResponse `description:"Response customization." yaml:"response,omitempty"`
Path AppPath `description:"Path access configuration." yaml:"path"` Path AppPath `description:"Path access configuration." yaml:"path,omitempty"`
LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap"` LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap,omitempty"`
} }
type AppConfig struct { type AppConfig struct {
Domain string `description:"The domain of the app." yaml:"domain"` Domain string `description:"The domain of the app." yaml:"domain,omitempty"`
} }
type AppUsers struct { type AppUsers struct {
Allow string `description:"Comma-separated list of allowed users." yaml:"allow"` Allow string `description:"Comma-separated list of allowed users." yaml:"allow,omitempty"`
Block string `description:"Comma-separated list of blocked users." yaml:"block"` Block string `description:"Comma-separated list of blocked users." yaml:"block,omitempty"`
} }
type AppOAuth struct { type AppOAuth struct {
Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist"` Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist,omitempty"`
Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"` Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups,omitempty"`
} }
type AppLDAP struct { type AppLDAP struct {
Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups"` Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups,omitempty"`
} }
type AppIP struct { type AppIP struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow,omitempty"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block,omitempty"`
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass"` Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass,omitempty"`
} }
type AppResponse struct { type AppResponse struct {
Headers []string `description:"Custom headers to add to the response." yaml:"headers"` Headers []string `description:"Custom headers to add to the response." yaml:"headers,omitempty"`
BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth"` BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth,omitempty"`
} }
type AppBasicAuth struct { type AppBasicAuth struct {
Username string `description:"Basic auth username." yaml:"username"` Username string `description:"Basic auth username." yaml:"username,omitempty"`
Password string `description:"Basic auth password." yaml:"password"` Password string `description:"Basic auth password." yaml:"password,omitempty"`
PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile"` PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile,omitempty"`
} }
type AppPath struct { type AppPath struct {
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"` Allow string `description:"Comma-separated list of allowed paths." yaml:"allow,omitempty"`
Block string `description:"Comma-separated list of blocked paths." yaml:"block"` Block string `description:"Comma-separated list of blocked paths." yaml:"block,omitempty"`
} }
+11 -4
View File
@@ -18,6 +18,7 @@ import (
type LdapService struct { type LdapService struct {
log *logger.Logger log *logger.Logger
ctx context.Context
config *model.Config config *model.Config
conn *ldapgo.Conn conn *ldapgo.Conn
@@ -32,6 +33,7 @@ type LdapServiceInput struct {
Log *logger.Logger Log *logger.Logger
Config *model.Config Config *model.Config
Ding *ding.Ding Ding *ding.Ding
Ctx context.Context
} }
func NewLdapService(i LdapServiceInput) (*LdapService, error) { func NewLdapService(i LdapServiceInput) (*LdapService, error) {
@@ -42,6 +44,7 @@ func NewLdapService(i LdapServiceInput) (*LdapService, error) {
ldap := &LdapService{ ldap := &LdapService{
log: i.Log, log: i.Log,
config: i.Config, config: i.Config,
ctx: i.Ctx,
} }
ldap.bindPw = utils.GetSecret(i.Config.LDAP.BindPassword, i.Config.LDAP.BindPasswordFile) ldap.bindPw = utils.GetSecret(i.Config.LDAP.BindPassword, i.Config.LDAP.BindPasswordFile)
@@ -73,6 +76,8 @@ func NewLdapService(i LdapServiceInput) (*LdapService, error) {
_, err := ldap.connect() _, err := ldap.connect()
if err != nil { if err != nil {
// 3s + 4.5s (3x1.5) = ~6.75-8.25s total wait time before giving up
err = ldap.reconnect(3 * time.Second)
return nil, fmt.Errorf("failed to connect to ldap server: %w", err) return nil, fmt.Errorf("failed to connect to ldap server: %w", err)
} }
@@ -88,7 +93,7 @@ func NewLdapService(i LdapServiceInput) (*LdapService, error) {
err := ldap.heartbeat() err := ldap.heartbeat()
if err != nil { if err != nil {
ldap.log.App.Warn().Err(err).Msg("LDAP connection heartbeat failed, attempting to reconnect") ldap.log.App.Warn().Err(err).Msg("LDAP connection heartbeat failed, attempting to reconnect")
if reconnectErr := ldap.reconnect(); reconnectErr != nil { if reconnectErr := ldap.reconnect(1 * time.Second); reconnectErr != nil {
ldap.log.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server") ldap.log.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
continue continue
} }
@@ -276,17 +281,19 @@ func (ldap *LdapService) heartbeat() error {
return nil return nil
} }
func (ldap *LdapService) reconnect() error { func (ldap *LdapService) reconnect(interval time.Duration) error {
ldap.log.App.Info().Msg("Attempting to reconnect to LDAP server") ldap.log.App.Info().Msg("Attempting to reconnect to LDAP server")
exp := backoff.NewExponentialBackOff() exp := backoff.NewExponentialBackOff()
exp.InitialInterval = 500 * time.Millisecond exp.InitialInterval = interval
exp.RandomizationFactor = 0.1 exp.RandomizationFactor = 0.1
exp.Multiplier = 1.5 exp.Multiplier = 1.5
exp.Reset() exp.Reset()
operation := func() (*ldapgo.Conn, error) { operation := func() (*ldapgo.Conn, error) {
if ldap.conn != nil {
ldap.conn.Close() ldap.conn.Close()
}
conn, err := ldap.connect() conn, err := ldap.connect()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -294,7 +301,7 @@ func (ldap *LdapService) reconnect() error {
return conn, nil return conn, nil
} }
_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3)) _, err := backoff.Retry(ldap.ctx, operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
if err != nil { if err != nil {
return err return err