Compare commits

..

5 Commits

Author SHA1 Message Date
Stavros
90b02d980c New translations en.json (Czech) 2025-12-25 18:52:27 +02:00
Stavros
8ce382b119 New translations en.json (German) 2025-12-18 18:02:11 +02:00
Stavros
4f8cf471b0 New translations en.json (Hungarian) 2025-12-14 21:53:34 +02:00
Stavros
6b2cf3dec2 New translations en.json (Italian) 2025-12-05 00:08:29 +02:00
Stavros
745c3eca36 New translations en.json (Italian) 2025-12-04 22:52:59 +02:00
96 changed files with 1774 additions and 4032 deletions

View File

@@ -1,3 +0,0 @@
issue_enrichment:
auto_enrich:
enabled: false

View File

@@ -1,86 +1,22 @@
# Base Configuration
# The base URL where Tinyauth is accessible
TINYAUTH_APPURL="https://auth.example.com"
# Log level: trace, debug, info, warn, error
TINYAUTH_LOGLEVEL="info"
# Directory for static resources
TINYAUTH_RESOURCESDIR="/data/resources"
# Path to SQLite database file
TINYAUTH_DATABASEPATH="/data/tinyauth.db"
# Disable version heartbeat
TINYAUTH_DISABLEANALYTICS="false"
# Disable static resource serving
TINYAUTH_DISABLERESOURCES="false"
# Disable UI warning messages
TINYAUTH_DISABLEUIWARNINGS="false"
# Enable JSON formatted logs
TINYAUTH_LOGJSON="false"
# Server Configuration
# Port to listen on
TINYAUTH_SERVER_PORT="3000"
# Interface to bind to (0.0.0.0 for all interfaces)
TINYAUTH_SERVER_ADDRESS="0.0.0.0"
# Unix socket path (optional, overrides port/address if set)
TINYAUTH_SERVER_SOCKETPATH=""
# Comma-separated list of trusted proxy IPs/CIDRs
TINYAUTH_SERVER_TRUSTEDPROXIES=""
# Authentication Configuration
# Format: username:bcrypt_hash (use bcrypt to generate hash)
TINYAUTH_AUTH_USERS="admin:$2a$10$example_bcrypt_hash_here"
# Path to external users file (optional)
TINYAUTH_USERSFILE=""
# Enable secure cookies (requires HTTPS)
TINYAUTH_SECURECOOKIE="true"
# Session expiry in seconds (7200 = 2 hours)
TINYAUTH_SESSIONEXPIRY="7200"
# Login timeout in seconds (300 = 5 minutes)
TINYAUTH_LOGINTIMEOUT="300"
# Maximum login retries before lockout
TINYAUTH_LOGINMAXRETRIES="5"
# OAuth Configuration
# Regex pattern for allowed email addresses (e.g., /@example\.com$/)
TINYAUTH_OAUTH_WHITELIST=""
# Provider ID to auto-redirect to (skips login page)
TINYAUTH_OAUTH_AUTOREDIRECT=""
# OAuth Provider Configuration (replace MYPROVIDER with your provider name)
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_CLIENTID="your_client_id_here"
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_CLIENTSECRET="your_client_secret_here"
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_AUTHURL="https://provider.example.com/oauth/authorize"
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_TOKENURL="https://provider.example.com/oauth/token"
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_USERINFOURL="https://provider.example.com/oauth/userinfo"
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_REDIRECTURL="https://auth.example.com/oauth/callback/myprovider"
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_SCOPES="openid email profile"
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_NAME="My OAuth Provider"
# Allow self-signed certificates
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_INSECURE="false"
# UI Customization
# Custom title for login page
TINYAUTH_UI_TITLE="Tinyauth"
# Message shown on forgot password page
TINYAUTH_UI_FORGOTPASSWORDMESSAGE="Contact your administrator to reset your password"
# Background image URL for login page
TINYAUTH_UI_BACKGROUNDIMAGE=""
# LDAP Configuration
# LDAP server address
TINYAUTH_LDAP_ADDRESS="ldap://ldap.example.com:389"
# DN for binding to LDAP server
TINYAUTH_LDAP_BINDDN="cn=readonly,dc=example,dc=com"
# Password for bind DN
TINYAUTH_LDAP_BINDPASSWORD="your_bind_password"
# Base DN for user searches
TINYAUTH_LDAP_BASEDN="dc=example,dc=com"
# Search filter (%s will be replaced with username)
TINYAUTH_LDAP_SEARCHFILTER="(&(uid=%s)(memberOf=cn=users,ou=groups,dc=example,dc=com))"
# Allow insecure LDAP connections
TINYAUTH_LDAP_INSECURE="false"
PORT=3000
ADDRESS=0.0.0.0
APP_URL=http://localhost:3000
USERS=your_user_password_hash
USERS_FILE=users_file
SECURE_COOKIE=false
OAUTH_WHITELIST=
GENERIC_NAME=My OAuth
SESSION_EXPIRY=7200
LOGIN_TIMEOUT=300
LOGIN_MAX_RETRIES=5
LOG_LEVEL=debug
APP_TITLE=Tinyauth SSO
FORGOT_PASSWORD_MESSAGE=Some message about resetting the password
OAUTH_AUTO_REDIRECT=none
BACKGROUND_IMAGE=some_image_url
GENERIC_SKIP_SSL=false
RESOURCES_DIR=/data/resources
DATABASE_PATH=/data/tinyauth.db
DISABLE_ANALYTICS=false
DISABLE_RESOURCES=false
TRUSTED_PROXIES=

View File

@@ -80,7 +80,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64
env:
CGO_ENABLED: 0
@@ -126,7 +126,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64
env:
CGO_ENABLED: 0

View File

@@ -58,7 +58,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64
env:
CGO_ENABLED: 0
@@ -101,7 +101,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64
env:
CGO_ENABLED: 0

28
.gitignore vendored
View File

@@ -1,39 +1,29 @@
# dist
/internal/assets/dist
internal/assets/dist
# binaries
/tinyauth
tinyauth
# test docker compose
/docker-compose.test*
docker-compose.test*
# users file
/users.txt
users.txt
# secret test file
/secret*
secret*
# apple stuff
.DS_Store
# env
/.env
.env
# tmp directory
/tmp
tmp
# version files
/internal/assets/version
internal/assets/version
# data directory
/data
# config file
/config.yml
# binary out
/tinyauth.db
/resources
# debug files
__debug_*
data

View File

@@ -1 +0,0 @@
# Trigger automated review

View File

@@ -1,5 +1,5 @@
# Site builder
FROM oven/bun:1.3.5-alpine AS frontend-builder
FROM oven/bun:1.3.3-alpine AS frontend-builder
WORKDIR /frontend
@@ -33,14 +33,15 @@ COPY go.sum ./
RUN go mod download
COPY ./main.go ./
COPY ./cmd ./cmd
COPY ./internal ./internal
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}"
# Runner
FROM alpine:3.23 AS runner
FROM alpine:3.22 AS runner
WORKDIR /tinyauth
@@ -52,10 +53,6 @@ EXPOSE 3000
VOLUME ["/data"]
ENV DATABASEPATH=/data/tinyauth.db
ENV RESOURCESDIR=/data/resources
ENV GIN_MODE=release
ENV PATH=$PATH:/tinyauth

View File

@@ -12,12 +12,9 @@ RUN go install github.com/go-delve/delve/cmd/dlv@latest
COPY ./cmd ./cmd
COPY ./internal ./internal
COPY ./main.go ./
COPY ./air.toml ./
EXPOSE 3000
ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db
ENV TINYAUTH_RESOURCESDIR=/data/resources
ENTRYPOINT ["air", "-c", "air.toml"]

View File

@@ -1,5 +1,5 @@
# Site builder
FROM oven/bun:1.3.5-alpine AS frontend-builder
FROM oven/bun:1.3.3-alpine AS frontend-builder
WORKDIR /frontend
@@ -33,13 +33,14 @@ COPY go.sum ./
RUN go mod download
COPY ./main.go ./
COPY ./cmd ./cmd
COPY ./internal ./internal
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN mkdir -p data
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}"
# Runner
FROM gcr.io/distroless/static-debian12:latest AS runner
@@ -55,10 +56,6 @@ EXPOSE 3000
VOLUME ["/data"]
ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db
ENV TINYAUTH_RESOURCESDIR=/data/resources
ENV GIN_MODE=release
ENV PATH=$PATH:/tinyauth

View File

@@ -33,8 +33,6 @@ If you are still not sure if Tinyauth suits your needs you can try out the [demo
You can find documentation and guides on all of the available configuration of Tinyauth in the [website](https://tinyauth.app).
If you wish to contribute to the documentation head over to the [repository](https://github.com/steveiliop56/tinyauth-docs).
## Discord
Tinyauth has a [discord](https://discord.gg/eHzVaCzRRd) server. Feel free to hop in to chat about self-hosting, homelabs and of course Tinyauth. See you there!

View File

@@ -3,7 +3,7 @@ tmp_dir = "tmp"
[build]
pre_cmd = ["mkdir -p internal/assets/dist", "mkdir -p /data", "echo 'backend running' > internal/assets/dist/index.html"]
cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ./cmd/tinyauth"
cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ."
bin = "tmp/tinyauth"
full_bin = "dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue --check-go-version=false"
include_ext = ["go"]

99
cmd/create.go Normal file
View File

@@ -0,0 +1,99 @@
package cmd
import (
"errors"
"fmt"
"strings"
"github.com/charmbracelet/huh"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
type createUserCmd struct {
root *cobra.Command
cmd *cobra.Command
interactive bool
docker bool
username string
password string
}
func newCreateUserCmd(root *cobra.Command) *createUserCmd {
return &createUserCmd{
root: root,
}
}
func (c *createUserCmd) Register() {
c.cmd = &cobra.Command{
Use: "create",
Short: "Create a user",
Long: `Create a user either interactively or by passing flags.`,
Run: c.run,
}
c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Create a user interactively")
c.cmd.Flags().BoolVar(&c.docker, "docker", false, "Format output for docker")
c.cmd.Flags().StringVar(&c.username, "username", "", "Username")
c.cmd.Flags().StringVar(&c.password, "password", "", "Password")
if c.root != nil {
c.root.AddCommand(c.cmd)
}
}
func (c *createUserCmd) GetCmd() *cobra.Command {
return c.cmd
}
func (c *createUserCmd) run(cmd *cobra.Command, args []string) {
log.Logger = log.Level(zerolog.InfoLevel)
if c.interactive {
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
return nil
})),
huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&c.docker),
),
)
var baseTheme *huh.Theme = huh.ThemeBase()
err := form.WithTheme(baseTheme).Run()
if err != nil {
log.Fatal().Err(err).Msg("Form failed")
}
}
if c.username == "" || c.password == "" {
log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty")
}
log.Info().Str("username", c.username).Msg("Creating user")
passwd, err := bcrypt.GenerateFromPassword([]byte(c.password), bcrypt.DefaultCost)
if err != nil {
log.Fatal().Err(err).Msg("Failed to hash password")
}
// If docker format is enabled, escape the dollar sign
passwdStr := string(passwd)
if c.docker {
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
}
log.Info().Str("user", fmt.Sprintf("%s:%s", c.username, passwdStr)).Msg("User created")
}

120
cmd/generate.go Normal file
View File

@@ -0,0 +1,120 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"tinyauth/internal/utils"
"github.com/charmbracelet/huh"
"github.com/mdp/qrterminal/v3"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
type generateTotpCmd struct {
root *cobra.Command
cmd *cobra.Command
interactive bool
user string
}
func newGenerateTotpCmd(root *cobra.Command) *generateTotpCmd {
return &generateTotpCmd{
root: root,
}
}
func (c *generateTotpCmd) Register() {
c.cmd = &cobra.Command{
Use: "generate",
Short: "Generate a totp secret",
Long: `Generate a totp secret for a user either interactively or by passing flags.`,
Run: c.run,
}
c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Run in interactive mode")
c.cmd.Flags().StringVar(&c.user, "user", "", "Your current user (username:hash)")
if c.root != nil {
c.root.AddCommand(c.cmd)
}
}
func (c *generateTotpCmd) GetCmd() *cobra.Command {
return c.cmd
}
func (c *generateTotpCmd) run(cmd *cobra.Command, args []string) {
log.Logger = log.Level(zerolog.InfoLevel)
if c.interactive {
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Current user (username:hash)").Value(&c.user).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
),
)
var baseTheme *huh.Theme = huh.ThemeBase()
err := form.WithTheme(baseTheme).Run()
if err != nil {
log.Fatal().Err(err).Msg("Form failed")
}
}
user, err := utils.ParseUser(c.user)
if err != nil {
log.Fatal().Err(err).Msg("Failed to parse user")
}
docker := false
if strings.Contains(c.user, "$$") {
docker = true
}
if user.TotpSecret != "" {
log.Fatal().Msg("User already has a TOTP secret")
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Tinyauth",
AccountName: user.Username,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to generate TOTP secret")
}
secret := key.Secret()
log.Info().Str("secret", secret).Msg("Generated TOTP secret")
log.Info().Msg("Generated QR code")
config := qrterminal.Config{
Level: qrterminal.L,
Writer: os.Stdout,
BlackChar: qrterminal.BLACK,
WhiteChar: qrterminal.WHITE,
QuietZone: 2,
}
qrterminal.GenerateWithConfig(key.URL(), config)
user.TotpSecret = secret
// If using docker escape re-escape it
if docker {
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
}
log.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.")
}

112
cmd/healthcheck.go Normal file
View File

@@ -0,0 +1,112 @@
package cmd
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type healthzResponse struct {
Status string `json:"status"`
Message string `json:"message"`
}
type healthcheckCmd struct {
root *cobra.Command
cmd *cobra.Command
viper *viper.Viper
}
func newHealthcheckCmd(root *cobra.Command) *healthcheckCmd {
return &healthcheckCmd{
root: root,
viper: viper.New(),
}
}
func (c *healthcheckCmd) Register() {
c.cmd = &cobra.Command{
Use: "healthcheck [app-url]",
Short: "Perform a health check",
Long: `Use the health check endpoint to verify that Tinyauth is running and it's healthy.`,
Run: c.run,
}
c.viper.AutomaticEnv()
if c.root != nil {
c.root.AddCommand(c.cmd)
}
}
func (c *healthcheckCmd) GetCmd() *cobra.Command {
return c.cmd
}
func (c *healthcheckCmd) run(cmd *cobra.Command, args []string) {
log.Logger = log.Level(zerolog.InfoLevel)
var appUrl string
port := c.viper.GetString("PORT")
address := c.viper.GetString("ADDRESS")
if port == "" {
port = "3000"
}
if address == "" {
address = "127.0.0.1"
}
appUrl = "http://" + address + ":" + port
if len(args) > 0 {
appUrl = args[0]
}
log.Info().Str("app_url", appUrl).Msg("Performing health check")
client := http.Client{}
req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil)
if err != nil {
log.Fatal().Err(err).Msg("Failed to create request")
}
resp, err := client.Do(req)
if err != nil {
log.Fatal().Err(err).Msg("Failed to perform request")
}
if resp.StatusCode != http.StatusOK {
log.Fatal().Err(errors.New("service is not healthy")).Msgf("Service is not healthy. Status code: %d", resp.StatusCode)
}
defer resp.Body.Close()
var healthResp healthzResponse
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal().Err(err).Msg("Failed to read response")
}
err = json.Unmarshal(body, &healthResp)
if err != nil {
log.Fatal().Err(err).Msg("Failed to decode response")
}
log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
}

162
cmd/root.go Normal file
View File

@@ -0,0 +1,162 @@
package cmd
import (
"strings"
"tinyauth/internal/bootstrap"
"tinyauth/internal/config"
"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"
)
type rootCmd struct {
root *cobra.Command
cmd *cobra.Command
viper *viper.Viper
}
func newRootCmd() *rootCmd {
return &rootCmd{
viper: viper.New(),
}
}
func (c *rootCmd) Register() {
c.cmd = &cobra.Command{
Use: "tinyauth",
Short: "The simplest way to protect your apps with a login screen",
Long: `Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github or any other provider to all of your docker apps.`,
Run: c.run,
}
// Ignore unknown flags to allow --providers-*
c.cmd.FParseErrWhitelist.UnknownFlags = true
c.viper.AutomaticEnv()
configOptions := []struct {
name string
defaultVal any
description string
}{
{"port", 3000, "Port to run the server on."},
{"address", "0.0.0.0", "Address to bind the server to."},
{"app-url", "", "The Tinyauth URL."},
{"users", "", "Comma separated list of users in the format username:hash."},
{"users-file", "", "Path to a file containing users in the format username:hash."},
{"secure-cookie", false, "Send cookie over secure connection only."},
{"oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth."},
{"oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)"},
{"session-expiry", 86400, "Session (cookie) expiration time in seconds."},
{"login-timeout", 300, "Login timeout in seconds after max retries reached (0 to disable)."},
{"login-max-retries", 5, "Maximum login attempts before timeout (0 to disable)."},
{"log-level", "info", "Log level."},
{"app-title", "Tinyauth", "Title of the app."},
{"forgot-password-message", "", "Message to show on the forgot password page."},
{"background-image", "/background.jpg", "Background image URL for the login page."},
{"ldap-address", "", "LDAP server address (e.g. ldap://localhost:389)."},
{"ldap-bind-dn", "", "LDAP bind DN (e.g. uid=user,dc=example,dc=com)."},
{"ldap-bind-password", "", "LDAP bind password."},
{"ldap-base-dn", "", "LDAP base DN (e.g. dc=example,dc=com)."},
{"ldap-insecure", false, "Skip certificate verification for the LDAP server."},
{"ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup."},
{"resources-dir", "/data/resources", "Path to a directory containing custom resources (e.g. background image)."},
{"database-path", "/data/tinyauth.db", "Path to the Sqlite database file."},
{"trusted-proxies", "", "Comma separated list of trusted proxies (IP addresses or CIDRs) for correct client IP detection."},
{"disable-analytics", false, "Disable anonymous version collection."},
{"disable-resources", false, "Disable the resources server."},
{"socket-path", "", "Path to the Unix socket to bind the server to."},
{"disable-ui-warnings", false, "Disable UI warnings about insecure configurations."},
}
for _, opt := range configOptions {
switch v := opt.defaultVal.(type) {
case bool:
c.cmd.Flags().Bool(opt.name, v, opt.description)
case int:
c.cmd.Flags().Int(opt.name, v, opt.description)
case string:
c.cmd.Flags().String(opt.name, v, opt.description)
}
// Create uppercase env var name
envVar := strings.ReplaceAll(strings.ToUpper(opt.name), "-", "_")
c.viper.BindEnv(opt.name, envVar)
}
c.viper.BindPFlags(c.cmd.Flags())
if c.root != nil {
c.root.AddCommand(c.cmd)
}
}
func (c *rootCmd) GetCmd() *cobra.Command {
return c.cmd
}
func (c *rootCmd) run(cmd *cobra.Command, args []string) {
var conf config.Config
err := c.viper.Unmarshal(&conf)
if err != nil {
log.Fatal().Err(err).Msg("Failed to parse config")
}
v := validator.New()
err = v.Struct(conf)
if err != nil {
log.Fatal().Err(err).Msg("Invalid config")
}
log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel)))
log.Info().Str("version", strings.TrimSpace(config.Version)).Msg("Starting Tinyauth")
if log.Logger.GetLevel() == zerolog.TraceLevel {
log.Warn().Msg("Log level set to trace, this will log sensitive information!")
}
app := bootstrap.NewBootstrapApp(conf)
err = app.Setup()
if err != nil {
log.Fatal().Err(err).Msg("Failed to setup app")
}
}
func Run() {
rootCmd := newRootCmd()
rootCmd.Register()
root := rootCmd.GetCmd()
userCmd := &cobra.Command{
Use: "user",
Short: "User utilities",
Long: `Utilities for creating and verifying tinyauth compatible users.`,
}
totpCmd := &cobra.Command{
Use: "totp",
Short: "Totp utilities",
Long: `Utilities for creating and verifying totp codes.`,
}
newCreateUserCmd(userCmd).Register()
newVerifyUserCmd(userCmd).Register()
newGenerateTotpCmd(totpCmd).Register()
newVersionCmd(root).Register()
newHealthcheckCmd(root).Register()
root.AddCommand(userCmd)
root.AddCommand(totpCmd)
err := root.Execute()
if err != nil {
log.Fatal().Err(err).Msg("Failed to execute root command")
}
}

View File

@@ -1,98 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli"
"golang.org/x/crypto/bcrypt"
)
type CreateUserConfig struct {
Interactive bool `description:"Create a user interactively."`
Docker bool `description:"Format output for docker."`
Username string `description:"Username."`
Password string `description:"Password."`
}
func NewCreateUserConfig() *CreateUserConfig {
return &CreateUserConfig{
Interactive: false,
Docker: false,
Username: "",
Password: "",
}
}
func createUserCmd() *cli.Command {
tCfg := NewCreateUserConfig()
loaders := []cli.ResourceLoader{
&cli.FlagLoader{},
}
return &cli.Command{
Name: "create",
Description: "Create a user",
Configuration: tCfg,
Resources: loaders,
Run: func(_ []string) error {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
if tCfg.Interactive {
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
return nil
})),
huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker),
),
)
var baseTheme *huh.Theme = huh.ThemeBase()
err := form.WithTheme(baseTheme).Run()
if err != nil {
return fmt.Errorf("failed to run interactive prompt: %w", err)
}
}
if tCfg.Username == "" || tCfg.Password == "" {
return errors.New("username and password cannot be empty")
}
log.Info().Str("username", tCfg.Username).Msg("Creating user")
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// If docker format is enabled, escape the dollar sign
passwdStr := string(passwd)
if tCfg.Docker {
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
}
log.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
return nil
},
}
}

View File

@@ -1,120 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/charmbracelet/huh"
"github.com/mdp/qrterminal/v3"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli"
)
type GenerateTotpConfig struct {
Interactive bool `description:"Generate a TOTP secret interactively."`
User string `description:"Your current user (username:hash)."`
}
func NewGenerateTotpConfig() *GenerateTotpConfig {
return &GenerateTotpConfig{
Interactive: false,
User: "",
}
}
func generateTotpCmd() *cli.Command {
tCfg := NewGenerateTotpConfig()
loaders := []cli.ResourceLoader{
&cli.FlagLoader{},
}
return &cli.Command{
Name: "generate",
Description: "Generate a TOTP secret",
Configuration: tCfg,
Resources: loaders,
Run: func(_ []string) error {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
if tCfg.Interactive {
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Current user (username:hash)").Value(&tCfg.User).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
),
)
var baseTheme *huh.Theme = huh.ThemeBase()
err := form.WithTheme(baseTheme).Run()
if err != nil {
return fmt.Errorf("failed to run interactive prompt: %w", err)
}
}
user, err := utils.ParseUser(tCfg.User)
if err != nil {
return fmt.Errorf("failed to parse user: %w", err)
}
docker := false
if strings.Contains(tCfg.User, "$$") {
docker = true
}
if user.TotpSecret != "" {
return fmt.Errorf("user already has a TOTP secret")
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Tinyauth",
AccountName: user.Username,
})
if err != nil {
return fmt.Errorf("failed to generate TOTP secret: %w", err)
}
secret := key.Secret()
log.Info().Str("secret", secret).Msg("Generated TOTP secret")
log.Info().Msg("Generated QR code")
config := qrterminal.Config{
Level: qrterminal.L,
Writer: os.Stdout,
BlackChar: qrterminal.BLACK,
WhiteChar: qrterminal.WHITE,
QuietZone: 2,
}
qrterminal.GenerateWithConfig(key.URL(), config)
user.TotpSecret = secret
// If using docker escape re-escape it
if docker {
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
}
log.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.")
return nil
},
}
}

View File

@@ -1,85 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli"
)
type healthzResponse struct {
Status string `json:"status"`
Message string `json:"message"`
}
func healthcheckCmd() *cli.Command {
return &cli.Command{
Name: "healthcheck",
Description: "Perform a health check",
Configuration: nil,
Resources: nil,
AllowArg: true,
Run: func(args []string) error {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
appUrl := os.Getenv("TINYAUTH_APPURL")
if len(args) > 0 {
appUrl = args[0]
}
if appUrl == "" {
return errors.New("TINYAUTH_APPURL is not set and no argument was provided")
}
log.Info().Str("app_url", appUrl).Msg("Performing health check")
client := http.Client{
Timeout: 30 * time.Second,
}
req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("service is not healthy, got: %s", resp.Status)
}
defer resp.Body.Close()
var healthResp healthzResponse
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
err = json.Unmarshal(body, &healthResp)
if err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
return nil
},
}
}

View File

@@ -1,129 +0,0 @@
package main
import (
"fmt"
"os"
"strings"
"time"
"github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/loaders"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli"
)
func NewTinyauthCmdConfiguration() *config.Config {
return &config.Config{
LogLevel: "info",
ResourcesDir: "./resources",
DatabasePath: "./tinyauth.db",
Server: config.ServerConfig{
Port: 3000,
Address: "0.0.0.0",
},
Auth: config.AuthConfig{
SessionExpiry: 3600,
LoginTimeout: 300,
LoginMaxRetries: 3,
},
UI: config.UIConfig{
Title: "Tinyauth",
ForgotPasswordMessage: "You can change your password by changing the configuration.",
BackgroundImage: "/background.jpg",
},
Ldap: config.LdapConfig{
Insecure: false,
SearchFilter: "(uid=%s)",
},
Experimental: config.ExperimentalConfig{
ConfigFile: "",
},
}
}
func main() {
tConfig := NewTinyauthCmdConfiguration()
loaders := []cli.ResourceLoader{
&loaders.FileLoader{},
&loaders.FlagLoader{},
&loaders.EnvLoader{},
}
cmdTinyauth := &cli.Command{
Name: "tinyauth",
Description: "The simplest way to protect your apps with a login screen.",
Configuration: tConfig,
Resources: loaders,
Run: func(_ []string) error {
return runCmd(*tConfig)
},
}
err := cmdTinyauth.AddCommand(versionCmd())
if err != nil {
log.Fatal().Err(err).Msg("Failed to add version command")
}
err = cmdTinyauth.AddCommand(verifyUserCmd())
if err != nil {
log.Fatal().Err(err).Msg("Failed to add verify command")
}
err = cmdTinyauth.AddCommand(healthcheckCmd())
if err != nil {
log.Fatal().Err(err).Msg("Failed to add healthcheck command")
}
err = cmdTinyauth.AddCommand(generateTotpCmd())
if err != nil {
log.Fatal().Err(err).Msg("Failed to add generate command")
}
err = cmdTinyauth.AddCommand(createUserCmd())
if err != nil {
log.Fatal().Err(err).Msg("Failed to add create command")
}
err = cli.Execute(cmdTinyauth)
if err != nil {
log.Fatal().Err(err).Msg("Failed to execute command")
}
}
func runCmd(cfg config.Config) error {
logLevel, err := zerolog.ParseLevel(strings.ToLower(cfg.LogLevel))
if err != nil {
log.Error().Err(err).Msg("Invalid or missing log level, defaulting to info")
} else {
zerolog.SetGlobalLevel(logLevel)
}
log.Logger = log.With().Caller().Logger()
if !cfg.LogJSON {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
}
log.Info().Str("version", config.Version).Msg("Starting tinyauth")
app := bootstrap.NewBootstrapApp(cfg)
err = app.Setup()
if err != nil {
return fmt.Errorf("failed to bootstrap app: %w", err)
}
return nil
}

View File

@@ -1,121 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"time"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/charmbracelet/huh"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli"
"golang.org/x/crypto/bcrypt"
)
type VerifyUserConfig struct {
Interactive bool `description:"Validate a user interactively."`
Username string `description:"Username."`
Password string `description:"Password."`
Totp string `description:"TOTP code."`
User string `description:"Hash (username:hash:totp)."`
}
func NewVerifyUserConfig() *VerifyUserConfig {
return &VerifyUserConfig{
Interactive: false,
Username: "",
Password: "",
Totp: "",
User: "",
}
}
func verifyUserCmd() *cli.Command {
tCfg := NewVerifyUserConfig()
loaders := []cli.ResourceLoader{
&cli.FlagLoader{},
}
return &cli.Command{
Name: "verify",
Description: "Verify a user is set up correctly.",
Configuration: tCfg,
Resources: loaders,
Run: func(_ []string) error {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
if tCfg.Interactive {
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("User (username:hash:totp)").Value(&tCfg.User).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
return nil
})),
huh.NewInput().Title("TOTP Code (optional)").Value(&tCfg.Totp),
),
)
var baseTheme *huh.Theme = huh.ThemeBase()
err := form.WithTheme(baseTheme).Run()
if err != nil {
return fmt.Errorf("failed to run interactive prompt: %w", err)
}
}
user, err := utils.ParseUser(tCfg.User)
if err != nil {
return fmt.Errorf("failed to parse user: %w", err)
}
if user.Username != tCfg.Username {
return fmt.Errorf("username is incorrect")
}
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password))
if err != nil {
return fmt.Errorf("password is incorrect: %w", err)
}
if user.TotpSecret == "" {
if tCfg.Totp != "" {
log.Warn().Msg("User does not have TOTP secret")
}
log.Info().Msg("User verified")
return nil
}
ok := totp.Validate(tCfg.Totp, user.TotpSecret)
if !ok {
return fmt.Errorf("TOTP code incorrect")
}
log.Info().Msg("User verified")
return nil
},
}
}

View File

@@ -1,24 +0,0 @@
package main
import (
"fmt"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/traefik/paerser/cli"
)
func versionCmd() *cli.Command {
return &cli.Command{
Name: "version",
Description: "Print the version number of Tinyauth.",
Configuration: nil,
Resources: nil,
Run: func(_ []string) error {
fmt.Printf("Version: %s\n", config.Version)
fmt.Printf("Commit Hash: %s\n", config.CommitHash)
fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
return nil
},
}
}

118
cmd/verify.go Normal file
View File

@@ -0,0 +1,118 @@
package cmd
import (
"errors"
"tinyauth/internal/utils"
"github.com/charmbracelet/huh"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
type verifyUserCmd struct {
root *cobra.Command
cmd *cobra.Command
interactive bool
username string
password string
totp string
user string
}
func newVerifyUserCmd(root *cobra.Command) *verifyUserCmd {
return &verifyUserCmd{
root: root,
}
}
func (c *verifyUserCmd) Register() {
c.cmd = &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 username, password and TOTP code.`,
Run: c.run,
}
c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Validate a user interactively")
c.cmd.Flags().StringVar(&c.username, "username", "", "Username")
c.cmd.Flags().StringVar(&c.password, "password", "", "Password")
c.cmd.Flags().StringVar(&c.totp, "totp", "", "TOTP code")
c.cmd.Flags().StringVar(&c.user, "user", "", "Hash (username:hash:totp)")
if c.root != nil {
c.root.AddCommand(c.cmd)
}
}
func (c *verifyUserCmd) GetCmd() *cobra.Command {
return c.cmd
}
func (c *verifyUserCmd) run(cmd *cobra.Command, args []string) {
log.Logger = log.Level(zerolog.InfoLevel)
if c.interactive {
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("User (username:hash:totp)").Value(&c.user).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
return nil
})),
huh.NewInput().Title("TOTP Code (optional)").Value(&c.totp),
),
)
var baseTheme *huh.Theme = huh.ThemeBase()
err := form.WithTheme(baseTheme).Run()
if err != nil {
log.Fatal().Err(err).Msg("Form failed")
}
}
user, err := utils.ParseUser(c.user)
if err != nil {
log.Fatal().Err(err).Msg("Failed to parse user")
}
if user.Username != c.username {
log.Fatal().Msg("Username is incorrect")
}
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(c.password))
if err != nil {
log.Fatal().Msg("Password is incorrect")
}
if user.TotpSecret == "" {
if c.totp != "" {
log.Warn().Msg("User does not have TOTP secret")
}
log.Info().Msg("User verified")
return
}
ok := totp.Validate(c.totp, user.TotpSecret)
if !ok {
log.Fatal().Msg("TOTP code incorrect")
}
log.Info().Msg("User verified")
}

42
cmd/version.go Normal file
View File

@@ -0,0 +1,42 @@
package cmd
import (
"fmt"
"tinyauth/internal/config"
"github.com/spf13/cobra"
)
type versionCmd struct {
root *cobra.Command
cmd *cobra.Command
}
func newVersionCmd(root *cobra.Command) *versionCmd {
return &versionCmd{
root: root,
}
}
func (c *versionCmd) Register() {
c.cmd = &cobra.Command{
Use: "version",
Short: "Print the version number of Tinyauth",
Long: `All software has versions. This is Tinyauth's.`,
Run: c.run,
}
if c.root != nil {
c.root.AddCommand(c.cmd)
}
}
func (c *versionCmd) GetCmd() *cobra.Command {
return c.cmd
}
func (c *versionCmd) run(cmd *cobra.Command, args []string) {
fmt.Printf("Version: %s\n", config.Version)
fmt.Printf("Commit Hash: %s\n", config.CommitHash)
fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
}

View File

@@ -1,124 +0,0 @@
# Tinyauth Example Configuration
# The base URL where Tinyauth is accessible
appUrl: "https://auth.example.com"
# Log level: trace, debug, info, warn, error
logLevel: "info"
# Directory for static resources
resourcesDir: "./resources"
# Path to SQLite database file
databasePath: "./tinyauth.db"
# Disable usage analytics
disableAnalytics: false
# Disable static resource serving
disableResources: false
# Disable UI warning messages
disableUIWarnings: false
# Enable JSON formatted logs
logJSON: false
# Server Configuration
server:
# Port to listen on
port: 3000
# Interface to bind to (0.0.0.0 for all interfaces)
address: "0.0.0.0"
# Unix socket path (optional, overrides port/address if set)
socketPath: ""
# Comma-separated list of trusted proxy IPs/CIDRs
trustedProxies: ""
# Authentication Configuration
auth:
# Format: username:bcrypt_hash (use bcrypt to generate hash)
users: "admin:$2a$10$example_bcrypt_hash_here"
# Path to external users file (optional)
usersFile: ""
# Enable secure cookies (requires HTTPS)
secureCookie: false
# Session expiry in seconds (3600 = 1 hour)
sessionExpiry: 3600
# Login timeout in seconds (300 = 5 minutes)
loginTimeout: 300
# Maximum login retries before lockout
loginMaxRetries: 3
# OAuth Configuration
oauth:
# Regex pattern for allowed email addresses (e.g., /@example\.com$/)
whitelist: ""
# Provider ID to auto-redirect to (skips login page)
autoRedirect: ""
# OAuth Provider Configuration (replace myprovider with your provider name)
providers:
myprovider:
clientId: "your_client_id_here"
clientSecret: "your_client_secret_here"
authUrl: "https://provider.example.com/oauth/authorize"
tokenUrl: "https://provider.example.com/oauth/token"
userInfoUrl: "https://provider.example.com/oauth/userinfo"
redirectUrl: "https://auth.example.com/api/oauth/callback/myprovider"
scopes: "openid email profile"
name: "My OAuth Provider"
# Allow insecure connections (self-signed certificates)
insecure: false
# OIDC Provider Configuration
oidc:
# Enable OIDC provider functionality
enabled: false
# OIDC issuer URL (defaults to appUrl if not set)
issuer: ""
# Access token expiry in seconds (3600 = 1 hour)
accessTokenExpiry: 3600
# ID token expiry in seconds (3600 = 1 hour)
idTokenExpiry: 3600
# OIDC Client Configuration
clients:
# Client ID (used as the key)
myapp:
# Client secret (or use clientSecretFile)
clientSecret: "your_client_secret_here"
# Path to file containing client secret (optional, alternative to clientSecret)
clientSecretFile: ""
# Client name for display purposes
clientName: "My Application"
# Allowed redirect URIs
redirectUris:
- "https://myapp.example.com/callback"
- "http://localhost:3000/callback"
# Allowed grant types (defaults to ["authorization_code"] if not specified)
grantTypes:
- "authorization_code"
# Allowed response types (defaults to ["code"] if not specified)
responseTypes:
- "code"
# Allowed scopes (defaults to ["openid", "profile", "email"] if not specified)
scopes:
- "openid"
- "profile"
- "email"
# UI Customization
ui:
# Custom title for login page
title: "Tinyauth"
# Message shown on forgot password page
forgotPasswordMessage: "Contact your administrator to reset your password"
# Background image URL for login page
backgroundImage: ""
# LDAP Configuration (optional)
ldap:
# LDAP server address
address: "ldap://ldap.example.com:389"
# DN for binding to LDAP server
bindDn: "cn=readonly,dc=example,dc=com"
# Password for bind DN
bindPassword: "your_bind_password"
# Base DN for user searches
baseDn: "dc=example,dc=com"
# Search filter (%s will be replaced with username)
searchFilter: "(&(uid=%s)(memberOf=cn=users,ou=groups,dc=example,dc=com))"
# Allow insecure LDAP connections
insecure: false

View File

@@ -20,8 +20,8 @@ services:
container_name: tinyauth
image: ghcr.io/steveiliop56/tinyauth:v3
environment:
- TINYAUTH_APPURL=https://tinyauth.example.com
- TINYAUTH_AUTH_USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
- APP_URL=https://tinyauth.example.com
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
volumes:
- ./data:/data
labels:

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "tinyauth-shadcn",
@@ -11,44 +10,44 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.12",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.11",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.7.3",
"i18next": "^25.7.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"lucide-react": "^0.555.0",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.68.0",
"react-i18next": "^16.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.67.0",
"react-i18next": "^16.3.5",
"react-markdown": "^10.1.0",
"react-router": "^7.11.0",
"react-router": "^7.10.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"zod": "^4.2.1",
"tailwindcss": "^4.1.17",
"zod": "^4.1.13",
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@eslint/js": "^9.39.1",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@types/node": "^25.0.3",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "3.7.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.50.0",
"vite": "^7.3.0",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
},
},
},
@@ -93,57 +92,55 @@
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
@@ -157,7 +154,7 @@
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
"@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
@@ -263,7 +260,7 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.46.2", "", { "os": "android", "cpu": "arm" }, "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA=="],
@@ -307,41 +304,41 @@
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.17", "", { "dependencies": { "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "tailwindcss": "4.1.17" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA=="],
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.11", "", {}, "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.11", "", { "dependencies": { "@tanstack/query-core": "5.90.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -365,7 +362,7 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
@@ -373,29 +370,29 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/type-utils": "8.50.0", "@typescript-eslint/utils": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/type-utils": "8.48.1", "@typescript-eslint/utils": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.50.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.50.0", "@typescript-eslint/types": "^8.50.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.48.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.1", "@typescript-eslint/types": "^8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1" } }, "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.50.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.48.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/utils": "8.50.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.46.1", "", {}, "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.50.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.50.0", "@typescript-eslint/tsconfig-utils": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.48.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.48.1", "@typescript-eslint/tsconfig-utils": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -493,17 +490,17 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
"eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
@@ -569,6 +566,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
@@ -589,7 +588,7 @@
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"i18next": ["i18next@25.7.3", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA=="],
"i18next": ["i18next@25.7.1", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-XbTnkh1yCZWSAZGnA9xcQfHcYNgZs2cNxm+c6v1Ma9UAUGCeJPplRe1ILia6xnDvXBjk0uXU+Z8FYWhA19SKFw=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
@@ -675,7 +674,7 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -791,13 +790,13 @@
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
"react-hook-form": ["react-hook-form@7.67.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ=="],
"react-i18next": ["react-i18next@16.5.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw=="],
"react-i18next": ["react-i18next@16.3.5", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
@@ -807,7 +806,7 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-router": ["react-router@7.11.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ=="],
"react-router": ["react-router@7.10.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
@@ -851,7 +850,7 @@
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
@@ -873,7 +872,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.50.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.50.0", "@typescript-eslint/parser": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/utils": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A=="],
"typescript-eslint": ["typescript-eslint@8.48.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -903,7 +902,7 @@
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
@@ -915,7 +914,7 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
@@ -969,13 +968,13 @@
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -995,25 +994,25 @@
"@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" } }, "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.50.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="],
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" } }, "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="],
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="],
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.50.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1021,7 +1020,7 @@
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.1", "@typescript-eslint/tsconfig-utils": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="],
"eslint-plugin-react-hooks/@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
@@ -1039,7 +1038,7 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.50.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="],
@@ -1057,11 +1056,15 @@
"@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" } }, "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
@@ -1081,9 +1084,9 @@
"eslint-plugin-react-hooks/@babel/core/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" } }, "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],

View File

@@ -17,43 +17,43 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.12",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.11",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.7.3",
"i18next": "^25.7.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"lucide-react": "^0.555.0",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.68.0",
"react-i18next": "^16.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.67.0",
"react-i18next": "^16.3.5",
"react-markdown": "^10.1.0",
"react-router": "^7.11.0",
"react-router": "^7.10.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"zod": "^4.2.1"
"tailwindcss": "^4.1.17",
"zod": "^4.1.13"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@eslint/js": "^9.39.1",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@types/node": "^25.0.3",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "3.7.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.50.0",
"vite": "^7.3.0"
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6"
}
}

View File

@@ -14,11 +14,12 @@ i18n
.init({
fallbackLng: "en",
debug: import.meta.env.MODE === "development",
nonExplicitSupportedLngs: true,
load: "currentOnly",
detection: {
lookupLocalStorage: "tinyauth-lang",
interpolation: {
escapeValue: false,
},
load: "currentOnly",
});
export default i18n;

View File

@@ -14,7 +14,7 @@
"loginOauthFailSubtitle": "Nepodařilo se získat OAuth URL",
"loginOauthSuccessTitle": "Přesměrování",
"loginOauthSuccessSubtitle": "Přesměrování k poskytovateli OAuth",
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
"loginOauthAutoRedirectTitle": "Automatické přesměrování OAuth",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now",
"continueTitle": "Pokračovat",

View File

@@ -1,12 +1,12 @@
{
"loginTitle": "Willkommen zurück, logge dich ein mit",
"loginTitle": "Willkommen zurück, melden Sie sich an mit",
"loginTitleSimple": "Willkommen zurück, bitte anmelden",
"loginDivider": "Oder",
"loginUsername": "Benutzername",
"loginPassword": "Passwort",
"loginSubmit": "Anmelden",
"loginFailTitle": "Login fehlgeschlagen",
"loginFailSubtitle": "Bitte überprüfe deinen Benutzernamen und Passwort",
"loginFailSubtitle": "Bitte überprüfe Sie Ihren Benutzernamen und Passwort",
"loginFailRateLimit": "Zu viele fehlgeschlagene Loginversuche. Versuche es später erneut",
"loginSuccessTitle": "Angemeldet",
"loginSuccessSubtitle": "Willkommen zurück!",
@@ -14,17 +14,17 @@
"loginOauthFailSubtitle": "Fehler beim Abrufen der OAuth-URL",
"loginOauthSuccessTitle": "Leite weiter",
"loginOauthSuccessSubtitle": "Weiterleitung zu Ihrem OAuth-Provider",
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now",
"loginOauthAutoRedirectTitle": "Automatische OAuth-Weiterleitung",
"loginOauthAutoRedirectSubtitle": "Sie werden automatisch zu Ihrem OAuth-Anbieter weitergeleitet, um sich zu authentifizieren.",
"loginOauthAutoRedirectButton": "Jetzt weiterleiten",
"continueTitle": "Weiter",
"continueRedirectingTitle": "Leite weiter...",
"continueRedirectingSubtitle": "Sie sollten in Kürze zur App weitergeleitet werden",
"continueRedirectManually": "Redirect me manually",
"continueRedirectManually": "Manuell weiterleiten",
"continueInsecureRedirectTitle": "Unsichere Weiterleitung",
"continueInsecureRedirectSubtitle": "Sie versuchen von <code>https</code> auf <code>http</code> weiterzuleiten, was unsicher ist. Sind Sie sicher, dass Sie fortfahren möchten?",
"continueUntrustedRedirectTitle": "Untrusted redirect",
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
"continueUntrustedRedirectTitle": "Nicht vertrauenswürdige Weiterleitung",
"continueUntrustedRedirectSubtitle": "Sie versuchen auf eine Domain umzuleiten, die nicht mit Ihrer konfigurierten Domain übereinstimmt (<code>{{cookieDomain}}</code>). Sind Sie sicher, dass Sie fortfahren möchten?",
"logoutFailTitle": "Abmelden fehlgeschlagen",
"logoutFailSubtitle": "Bitte versuchen Sie es erneut",
"logoutSuccessTitle": "Abgemeldet",
@@ -53,10 +53,10 @@
"errorTitle": "Ein Fehler ist aufgetreten",
"errorSubtitle": "Beim Versuch, diese Aktion auszuführen, ist ein Fehler aufgetreten. Bitte überprüfen Sie die Konsole für weitere Informationen.",
"forgotPasswordMessage": "Das Passwort kann durch Änderung der 'USERS' Variable zurückgesetzt werden.",
"fieldRequired": "Dieses Feld ist notwendig",
"fieldRequired": "Dies ist ein Pflichtfeld",
"invalidInput": "Ungültige Eingabe",
"domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.",
"ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Go to correct domain"
"domainWarningTitle": "Ungültige Domain",
"domainWarningSubtitle": "Diese Instanz ist so konfiguriert, dass sie von <code>{{appUrl}}</code> aufgerufen werden kann, aber <code>{{currentUrl}}</code> wird verwendet. Wenn Sie fortfahren, können Probleme bei der Authentifizierung auftreten.",
"ignoreTitle": "Ignorieren",
"goToCorrectDomainTitle": "Zur korrekten Domain gehen"
}

View File

@@ -1,42 +1,42 @@
{
"loginTitle": "Welcome back, login with",
"loginTitleSimple": "Welcome back, please login",
"loginDivider": "Or",
"loginUsername": "Username",
"loginPassword": "Password",
"loginSubmit": "Login",
"loginFailTitle": "Failed to log in",
"loginFailSubtitle": "Please check your username and password",
"loginFailRateLimit": "You failed to login too many times. Please try again later",
"loginSuccessTitle": "Logged in",
"loginSuccessSubtitle": "Welcome back!",
"loginTitleSimple": "Üdvözöljük, kérem jelentkezzen be",
"loginDivider": "Vagy",
"loginUsername": "Felhasználónév",
"loginPassword": "Jelszó",
"loginSubmit": "Bejelentkezés",
"loginFailTitle": "Sikertelen bejelentkezés",
"loginFailSubtitle": "Kérjük, ellenőrizze a felhasználónevét és jelszavát",
"loginFailRateLimit": "Túl sokszor próbálkoztál bejelentkezni. Próbáld újra később",
"loginSuccessTitle": "Bejelentkezve",
"loginSuccessSubtitle": "Üdvözöljük!",
"loginOauthFailTitle": "An error occurred",
"loginOauthFailSubtitle": "Failed to get OAuth URL",
"loginOauthSuccessTitle": "Redirecting",
"loginOauthSuccessTitle": "Átirányítás",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now",
"continueTitle": "Continue",
"continueRedirectingTitle": "Redirecting...",
"continueRedirectingTitle": "Átirányítás...",
"continueRedirectingSubtitle": "You should be redirected to the app soon",
"continueRedirectManually": "Redirect me manually",
"continueInsecureRedirectTitle": "Insecure redirect",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
"continueUntrustedRedirectTitle": "Untrusted redirect",
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
"logoutFailTitle": "Failed to log out",
"logoutFailSubtitle": "Please try again",
"logoutSuccessTitle": "Logged out",
"logoutSuccessSubtitle": "You have been logged out",
"logoutTitle": "Logout",
"logoutFailTitle": "Sikertelen kijelentkezés",
"logoutFailSubtitle": "Próbálja újra",
"logoutSuccessTitle": "Kijelentkezve",
"logoutSuccessSubtitle": "Kijelentkeztél",
"logoutTitle": "Kijelentkezés",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
"notFoundTitle": "Page not found",
"notFoundSubtitle": "The page you are looking for does not exist.",
"notFoundButton": "Go home",
"totpFailTitle": "Failed to verify code",
"totpFailSubtitle": "Please check your code and try again",
"notFoundButton": "Ugrás a kezdőlapra",
"totpFailTitle": "Érvénytelen kód",
"totpFailSubtitle": "Kérjük ellenőrizze a kódot és próbálja újra",
"totpSuccessTitle": "Verified",
"totpSuccessSubtitle": "Redirecting to your app",
"totpTitle": "Enter your TOTP code",
@@ -46,14 +46,14 @@
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again",
"cancelTitle": "Cancel",
"forgotPasswordTitle": "Forgot your password?",
"unauthorizedButton": "Próbálja újra",
"cancelTitle": "Mégse",
"forgotPasswordTitle": "Elfelejtette jelszavát?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred",
"errorTitle": "Hiba történt",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required",
"fieldRequired": "Ez egy kötelező mező",
"invalidInput": "Invalid input",
"domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.",

View File

@@ -1,23 +1,23 @@
{
"loginTitle": "Welcome back, login with",
"loginTitleSimple": "Welcome back, please login",
"loginDivider": "Or",
"loginUsername": "Username",
"loginTitle": "Bentornato, accedi con",
"loginTitleSimple": "Bentornato, accedi al tuo account",
"loginDivider": "Oppure",
"loginUsername": "Nome utente",
"loginPassword": "Password",
"loginSubmit": "Login",
"loginFailTitle": "Failed to log in",
"loginFailSubtitle": "Please check your username and password",
"loginFailRateLimit": "You failed to login too many times. Please try again later",
"loginSuccessTitle": "Logged in",
"loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "An error occurred",
"loginOauthFailSubtitle": "Failed to get OAuth URL",
"loginSubmit": "Accesso",
"loginFailTitle": "Accesso non riuscito",
"loginFailSubtitle": "Verifica che il nome utente e la password siano corretti",
"loginFailRateLimit": "Hai effettuato troppi tentativi errati. Riprova più tardi",
"loginSuccessTitle": "Accesso effettuato",
"loginSuccessSubtitle": "Bentornato!",
"loginOauthFailTitle": "Si è verificato un errore",
"loginOauthFailSubtitle": "Impossibile ottenere l'URL di OAuth",
"loginOauthSuccessTitle": "Redirecting",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now",
"continueTitle": "Continue",
"continueTitle": "Prosegui",
"continueRedirectingTitle": "Redirecting...",
"continueRedirectingSubtitle": "You should be redirected to the app soon",
"continueRedirectManually": "Redirect me manually",
@@ -34,29 +34,29 @@
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
"notFoundTitle": "Page not found",
"notFoundSubtitle": "The page you are looking for does not exist.",
"notFoundButton": "Go home",
"totpFailTitle": "Failed to verify code",
"totpFailSubtitle": "Please check your code and try again",
"totpSuccessTitle": "Verified",
"totpSuccessSubtitle": "Redirecting to your app",
"totpTitle": "Enter your TOTP code",
"totpSubtitle": "Please enter the code from your authenticator app.",
"unauthorizedTitle": "Unauthorized",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again",
"cancelTitle": "Cancel",
"forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required",
"invalidInput": "Invalid input",
"domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.",
"ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Go to correct domain"
"notFoundButton": "Vai alla home",
"totpFailTitle": "Errore nella verifica del codice",
"totpFailSubtitle": "Si prega di controllare il codice e riprovare",
"totpSuccessTitle": "Verificato",
"totpSuccessSubtitle": "Reindirizzamento alla tua app",
"totpTitle": "Inserisci il tuo codice TOTP",
"totpSubtitle": "Inserisci il codice dalla tua app di autenticazione.",
"unauthorizedTitle": "Non Autorizzato",
"unauthorizedResourceSubtitle": "L'utente con username <code>{{username}}</code> non è autorizzato ad accedere alla risorsa <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "L'utente con username <code>{{username}}</code> non è autorizzato a effettuare l'accesso.",
"unauthorizedGroupsSubtitle": "L'utente con nome utente <code>{{username}}</code> non fa parte dei gruppi richiesti dalla risorsa <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Il tuo indirizzo IP <code>{{ip}}</code> non è autorizzato ad accedere alla risorsa <code>{{resource}}</code>.",
"unauthorizedButton": "Riprova",
"cancelTitle": "Annulla",
"forgotPasswordTitle": "Password dimenticata?",
"failedToFetchProvidersTitle": "Impossibile caricare i provider di autenticazione. Si prega di controllare la configurazione.",
"errorTitle": "Si è verificato un errore",
"errorSubtitle": "Si è verificato un errore durante il tentativo di eseguire questa azione. Si prega di controllare la console per ulteriori informazioni.",
"forgotPasswordMessage": "Puoi reimpostare la tua password modificando la variabile d'ambiente `USERS`.",
"fieldRequired": "Questo campo è obbligatorio",
"invalidInput": "Input non valido",
"domainWarningTitle": "Dominio non valido",
"domainWarningSubtitle": "Questa istanza è configurata per essere accessibile da <code>{{appUrl}}</code>, ma <code>{{currentUrl}}</code> è in uso. Se procedi, potresti incorrere in problemi di autenticazione.",
"ignoreTitle": "Ignora",
"goToCorrectDomainTitle": "Vai al dominio corretto"
}

105
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/steveiliop56/tinyauth
module tinyauth
go 1.24.0
@@ -6,33 +6,62 @@ toolchain go1.24.3
require (
github.com/cenkalti/backoff/v5 v5.0.3
github.com/charmbracelet/huh v0.8.0
github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/go-playground/validator/v10 v10.28.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/go-querystring v1.1.0
github.com/google/uuid v1.6.0
github.com/mdp/qrterminal/v3 v3.2.1
github.com/pquerna/otp v1.5.0
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
github.com/stoewer/go-strcase v1.3.1
github.com/traefik/paerser v0.2.2
github.com/weppos/publicsuffix-go v0.50.1
golang.org/x/crypto v0.46.0
golang.org/x/crypto v0.45.0
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
golang.org/x/oauth2 v0.34.0
gorm.io/gorm v1.31.1
gotest.tools/v3 v3.5.2
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/tools v0.38.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect
rsc.io/qr v0.2.0 // indirect
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -42,38 +71,29 @@ require (
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.2+incompatible
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.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-logr/logr v1.4.3 // 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/go-playground/validator/v10 v10.28.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -82,49 +102,36 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // 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.16.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // 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.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/pquerna/otp v1.5.0
github.com/rivo/uniseg v0.4.7 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/oauth2 v0.33.0
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect
rsc.io/qr v0.2.0 // indirect
)

131
go.sum
View File

@@ -2,17 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
@@ -73,6 +64,7 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -95,6 +87,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -122,13 +116,13 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -139,18 +133,14 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -196,14 +186,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
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/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -240,10 +224,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -254,25 +238,37 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/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/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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ=
github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -283,7 +279,6 @@ github.com/weppos/publicsuffix-go v0.50.1 h1:elrBHeSkS/eIb169+DnLrknqmdP4AjT0Q0t
github.com/weppos/publicsuffix-go v0.50.1/go.mod h1:znn0JVXjcR5hpUl9pbEogwH6I710rA1AX0QQPT0bf+k=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
@@ -304,63 +299,38 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
@@ -374,9 +344,6 @@ google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1 +0,0 @@
ALTER TABLE "sessions" DROP COLUMN "oauth_sub";

View File

@@ -1 +0,0 @@
ALTER TABLE "sessions" ADD COLUMN "oauth_sub" TEXT;

View File

@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS "oidc_clients";

View File

@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS "oidc_clients" (
"client_id" TEXT NOT NULL PRIMARY KEY UNIQUE,
"client_secret" TEXT NOT NULL,
"client_name" TEXT NOT NULL,
"redirect_uris" TEXT NOT NULL,
"grant_types" TEXT NOT NULL,
"response_types" TEXT NOT NULL,
"scopes" TEXT NOT NULL,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL
);

View File

@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS "oidc_keys";

View File

@@ -1,7 +0,0 @@
CREATE TABLE IF NOT EXISTS "oidc_keys" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"private_key" TEXT NOT NULL,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL
);

View File

@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS "idx_oidc_auth_codes_expires_at";
DROP TABLE IF EXISTS "oidc_authorization_codes";

View File

@@ -1,11 +0,0 @@
CREATE TABLE IF NOT EXISTS "oidc_authorization_codes" (
"code" TEXT NOT NULL PRIMARY KEY,
"client_id" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT 0,
"expires_at" INTEGER NOT NULL,
"created_at" INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_oidc_auth_codes_expires_at" ON "oidc_authorization_codes"("expires_at");

View File

@@ -11,29 +11,34 @@ import (
"sort"
"strings"
"time"
"tinyauth/internal/config"
"tinyauth/internal/controller"
"tinyauth/internal/middleware"
"tinyauth/internal/model"
"tinyauth/internal/service"
"tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/model"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type Controller interface {
SetupRoutes()
}
type Middleware interface {
Middleware() gin.HandlerFunc
Init() error
}
type Service interface {
Init() error
}
type BootstrapApp struct {
config config.Config
context struct {
uuid string
cookieDomain string
sessionCookieName string
csrfCookieName string
redirectCookieName string
users []config.User
oauthProviders map[string]config.OAuthServiceConfig
configuredProviders []controller.Provider
}
services Services
}
func NewBootstrapApp(config config.Config) *BootstrapApp {
@@ -44,42 +49,17 @@ func NewBootstrapApp(config config.Config) *BootstrapApp {
func (app *BootstrapApp) Setup() error {
// Parse users
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)
users, err := utils.GetUsers(app.config.Users, app.config.UsersFile)
if err != nil {
return err
}
app.context.users = users
// Get OAuth configs
oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.config.AppURL)
// Setup OAuth providers
app.context.oauthProviders = app.config.OAuth.Providers
for name, provider := range app.context.oauthProviders {
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
provider.ClientSecret = secret
provider.ClientSecretFile = ""
app.context.oauthProviders[name] = provider
}
for id := range config.OverrideProviders {
if provider, exists := app.context.oauthProviders[id]; exists {
if provider.RedirectURL == "" {
provider.RedirectURL = app.config.AppURL + "/api/oauth/callback/" + id
app.context.oauthProviders[id] = provider
}
}
}
for id, provider := range app.context.oauthProviders {
if provider.Name == "" {
if name, ok := config.OverrideProviders[id]; ok {
provider.Name = name
} else {
provider.Name = utils.Capitalize(id)
}
}
app.context.oauthProviders[id] = provider
if err != nil {
return err
}
// Get cookie domain
@@ -89,38 +69,102 @@ func (app *BootstrapApp) Setup() error {
return err
}
app.context.cookieDomain = cookieDomain
// Cookie names
appUrl, _ := url.Parse(app.config.AppURL) // Already validated
app.context.uuid = utils.GenerateUUID(appUrl.Hostname())
cookieId := strings.Split(app.context.uuid, "-")[0]
app.context.sessionCookieName = fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId)
app.context.csrfCookieName = fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
uuid := utils.GenerateUUID(appUrl.Hostname())
app.uuid = uuid
cookieId := strings.Split(uuid, "-")[0]
sessionCookieName := fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId)
csrfCookieName := fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
redirectCookieName := fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
// Dumps
log.Trace().Interface("config", app.config).Msg("Config dump")
log.Trace().Interface("users", app.context.users).Msg("Users dump")
log.Trace().Interface("oauthProviders", app.context.oauthProviders).Msg("OAuth providers dump")
log.Trace().Str("cookieDomain", app.context.cookieDomain).Msg("Cookie domain")
log.Trace().Str("sessionCookieName", app.context.sessionCookieName).Msg("Session cookie name")
log.Trace().Str("csrfCookieName", app.context.csrfCookieName).Msg("CSRF cookie name")
log.Trace().Str("redirectCookieName", app.context.redirectCookieName).Msg("Redirect cookie name")
log.Trace().Interface("users", users).Msg("Users dump")
log.Trace().Interface("oauthProviders", oauthProviders).Msg("OAuth providers dump")
log.Trace().Str("cookieDomain", cookieDomain).Msg("Cookie domain")
log.Trace().Str("sessionCookieName", sessionCookieName).Msg("Session cookie name")
log.Trace().Str("csrfCookieName", csrfCookieName).Msg("CSRF cookie name")
log.Trace().Str("redirectCookieName", redirectCookieName).Msg("Redirect cookie name")
// Services
services, err := app.initServices()
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
// Create configs
authConfig := service.AuthServiceConfig{
Users: users,
OauthWhitelist: app.config.OAuthWhitelist,
SessionExpiry: app.config.SessionExpiry,
SecureCookie: app.config.SecureCookie,
CookieDomain: cookieDomain,
LoginTimeout: app.config.LoginTimeout,
LoginMaxRetries: app.config.LoginMaxRetries,
SessionCookieName: sessionCookieName,
}
app.services = services
// Setup services
var ldapService *service.LdapService
if app.config.LdapAddress != "" {
ldapConfig := service.LdapServiceConfig{
Address: app.config.LdapAddress,
BindDN: app.config.LdapBindDN,
BindPassword: app.config.LdapBindPassword,
BaseDN: app.config.LdapBaseDN,
Insecure: app.config.LdapInsecure,
SearchFilter: app.config.LdapSearchFilter,
}
ldapService = service.NewLdapService(ldapConfig)
err := ldapService.Init()
if err != nil {
log.Warn().Err(err).Msg("Failed to initialize LDAP service, continuing without LDAP")
ldapService = nil
}
}
// Bootstrap database
databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{
DatabasePath: app.config.DatabasePath,
})
log.Debug().Str("service", fmt.Sprintf("%T", databaseService)).Msg("Initializing service")
err = databaseService.Init()
if err != nil {
return fmt.Errorf("failed to initialize database service: %w", err)
}
database := databaseService.GetDatabase()
// Create services
dockerService := service.NewDockerService()
aclsService := service.NewAccessControlsService(dockerService)
authService := service.NewAuthService(authConfig, dockerService, ldapService, database)
oauthBrokerService := service.NewOAuthBrokerService(oauthProviders)
// Initialize services (order matters)
services := []Service{
dockerService,
aclsService,
authService,
oauthBrokerService,
}
for _, svc := range services {
if svc != nil {
log.Debug().Str("service", fmt.Sprintf("%T", svc)).Msg("Initializing service")
err := svc.Init()
if err != nil {
return err
}
}
}
// Configured providers
configuredProviders := make([]controller.Provider, 0)
for id, provider := range app.context.oauthProviders {
for id, provider := range oauthProviders {
configuredProviders = append(configuredProviders, controller.Provider{
Name: provider.Name,
ID: id,
@@ -132,7 +176,7 @@ func (app *BootstrapApp) Setup() error {
return configuredProviders[i].Name < configuredProviders[j].Name
})
if services.authService.UserAuthConfigured() {
if authService.UserAuthConfigured() || ldapService != nil {
configuredProviders = append(configuredProviders, controller.Provider{
Name: "Username",
ID: "username",
@@ -146,18 +190,92 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("no authentication providers configured")
}
app.context.configuredProviders = configuredProviders
// Create engine
engine := gin.New()
engine.Use(gin.Recovery())
// Setup router
router, err := app.setupRouter()
if len(app.config.TrustedProxies) > 0 {
err := engine.SetTrustedProxies(strings.Split(app.config.TrustedProxies, ","))
if err != nil {
return fmt.Errorf("failed to setup routes: %w", err)
return fmt.Errorf("failed to set trusted proxies: %w", err)
}
}
// Start DB cleanup routine
log.Debug().Msg("Starting database cleanup routine")
go app.dbCleanup(services.databaseService.GetDatabase())
// Create middlewares
var middlewares []Middleware
contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{
CookieDomain: cookieDomain,
}, authService, oauthBrokerService)
uiMiddleware := middleware.NewUIMiddleware()
zerologMiddleware := middleware.NewZerologMiddleware()
middlewares = append(middlewares, contextMiddleware, uiMiddleware, zerologMiddleware)
for _, middleware := range middlewares {
log.Debug().Str("middleware", fmt.Sprintf("%T", middleware)).Msg("Initializing middleware")
err := middleware.Init()
if err != nil {
return fmt.Errorf("failed to initialize middleware %T: %w", middleware, err)
}
engine.Use(middleware.Middleware())
}
// Create routers
mainRouter := engine.Group("")
apiRouter := engine.Group("/api")
// Create controllers
contextController := controller.NewContextController(controller.ContextControllerConfig{
Providers: configuredProviders,
Title: app.config.Title,
AppURL: app.config.AppURL,
CookieDomain: cookieDomain,
ForgotPasswordMessage: app.config.ForgotPasswordMessage,
BackgroundImage: app.config.BackgroundImage,
OAuthAutoRedirect: app.config.OAuthAutoRedirect,
DisableUIWarnings: app.config.DisableUIWarnings,
}, apiRouter)
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
AppURL: app.config.AppURL,
SecureCookie: app.config.SecureCookie,
CSRFCookieName: csrfCookieName,
RedirectCookieName: redirectCookieName,
CookieDomain: cookieDomain,
}, apiRouter, authService, oauthBrokerService)
proxyController := controller.NewProxyController(controller.ProxyControllerConfig{
AppURL: app.config.AppURL,
}, apiRouter, aclsService, authService)
userController := controller.NewUserController(controller.UserControllerConfig{
CookieDomain: cookieDomain,
}, apiRouter, authService)
resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{
ResourcesDir: app.config.ResourcesDir,
ResourcesDisabled: app.config.DisableResources,
}, mainRouter)
healthController := controller.NewHealthController(apiRouter)
// Setup routes
controller := []Controller{
contextController,
oauthController,
proxyController,
userController,
healthController,
resourcesController,
}
for _, ctrl := range controller {
log.Debug().Msgf("Setting up %T controller", ctrl)
ctrl.SetupRoutes()
}
// If analytics are not disabled, start heartbeat
if !app.config.DisableAnalytics {
@@ -165,18 +283,24 @@ func (app *BootstrapApp) Setup() error {
go app.heartbeat()
}
// Start DB cleanup routine
log.Debug().Msg("Starting database cleanup routine")
go app.dbCleanup(database)
// If we have an socket path, bind to it
if app.config.Server.SocketPath != "" {
if _, err := os.Stat(app.config.Server.SocketPath); err == nil {
log.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath)
if app.config.SocketPath != "" {
// Remove existing socket file
if _, err := os.Stat(app.config.SocketPath); err == nil {
log.Info().Msgf("Removing existing socket file %s", app.config.SocketPath)
err := os.Remove(app.config.SocketPath)
if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err)
}
}
log.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
if err := router.RunUnix(app.config.Server.SocketPath); err != nil {
// Start server with unix socket
log.Info().Msgf("Starting server on unix socket %s", app.config.SocketPath)
if err := engine.RunUnix(app.config.SocketPath); err != nil {
log.Fatal().Err(err).Msg("Failed to start server")
}
@@ -184,9 +308,9 @@ func (app *BootstrapApp) Setup() error {
}
// Start server
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
address := fmt.Sprintf("%s:%d", app.config.Address, app.config.Port)
log.Info().Msgf("Starting server on %s", address)
if err := router.Run(address); err != nil {
if err := engine.Run(address); err != nil {
log.Fatal().Err(err).Msg("Failed to start server")
}
@@ -204,7 +328,7 @@ func (app *BootstrapApp) heartbeat() {
var body heartbeat
body.UUID = app.context.uuid
body.UUID = app.uuid
body.Version = config.Version
bodyJson, err := json.Marshal(body)
@@ -214,9 +338,7 @@ func (app *BootstrapApp) heartbeat() {
return
}
client := &http.Client{
Timeout: 30 * time.Second, // The server should never take more than 30 seconds to respond
}
client := &http.Client{}
heartbeatURL := config.ApiServer + "/v1/instances/heartbeat"

View File

@@ -1,116 +0,0 @@
package bootstrap
import (
"fmt"
"strings"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/middleware"
"github.com/gin-gonic/gin"
)
func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
engine := gin.New()
engine.Use(gin.Recovery())
if len(app.config.Server.TrustedProxies) > 0 {
err := engine.SetTrustedProxies(strings.Split(app.config.Server.TrustedProxies, ","))
if err != nil {
return nil, fmt.Errorf("failed to set trusted proxies: %w", err)
}
}
contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{
CookieDomain: app.context.cookieDomain,
}, app.services.authService, app.services.oauthBrokerService)
err := contextMiddleware.Init()
if err != nil {
return nil, fmt.Errorf("failed to initialize context middleware: %w", err)
}
engine.Use(contextMiddleware.Middleware())
uiMiddleware := middleware.NewUIMiddleware()
err = uiMiddleware.Init()
if err != nil {
return nil, fmt.Errorf("failed to initialize UI middleware: %w", err)
}
engine.Use(uiMiddleware.Middleware())
zerologMiddleware := middleware.NewZerologMiddleware()
err = zerologMiddleware.Init()
if err != nil {
return nil, fmt.Errorf("failed to initialize zerolog middleware: %w", err)
}
engine.Use(zerologMiddleware.Middleware())
apiRouter := engine.Group("/api")
contextController := controller.NewContextController(controller.ContextControllerConfig{
Providers: app.context.configuredProviders,
Title: app.config.UI.Title,
AppURL: app.config.AppURL,
CookieDomain: app.context.cookieDomain,
ForgotPasswordMessage: app.config.UI.ForgotPasswordMessage,
BackgroundImage: app.config.UI.BackgroundImage,
OAuthAutoRedirect: app.config.OAuth.AutoRedirect,
DisableUIWarnings: app.config.DisableUIWarnings,
}, apiRouter)
contextController.SetupRoutes()
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
AppURL: app.config.AppURL,
SecureCookie: app.config.Auth.SecureCookie,
CSRFCookieName: app.context.csrfCookieName,
RedirectCookieName: app.context.redirectCookieName,
CookieDomain: app.context.cookieDomain,
}, apiRouter, app.services.authService, app.services.oauthBrokerService)
oauthController.SetupRoutes()
proxyController := controller.NewProxyController(controller.ProxyControllerConfig{
AppURL: app.config.AppURL,
}, apiRouter, app.services.accessControlService, app.services.authService)
proxyController.SetupRoutes()
userController := controller.NewUserController(controller.UserControllerConfig{
CookieDomain: app.context.cookieDomain,
}, apiRouter, app.services.authService)
userController.SetupRoutes()
resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{
ResourcesDir: app.config.ResourcesDir,
ResourcesDisabled: app.config.DisableResources,
}, &engine.RouterGroup)
resourcesController.SetupRoutes()
healthController := controller.NewHealthController(apiRouter)
healthController.SetupRoutes()
// Setup OIDC controller if OIDC is enabled
if app.config.OIDC.Enabled && app.services.oidcService != nil {
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{
AppURL: app.config.AppURL,
CookieDomain: app.context.cookieDomain,
}, apiRouter, app.services.oidcService, app.services.authService)
oidcController.SetupRoutes()
}
return engine, nil
}

View File

@@ -1,135 +0,0 @@
package bootstrap
import (
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/rs/zerolog/log"
)
type Services struct {
accessControlService *service.AccessControlsService
authService *service.AuthService
databaseService *service.DatabaseService
dockerService *service.DockerService
ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService
}
func (app *BootstrapApp) initServices() (Services, error) {
services := Services{}
databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{
DatabasePath: app.config.DatabasePath,
})
err := databaseService.Init()
if err != nil {
return Services{}, err
}
services.databaseService = databaseService
ldapService := service.NewLdapService(service.LdapServiceConfig{
Address: app.config.Ldap.Address,
BindDN: app.config.Ldap.BindDN,
BindPassword: app.config.Ldap.BindPassword,
BaseDN: app.config.Ldap.BaseDN,
Insecure: app.config.Ldap.Insecure,
SearchFilter: app.config.Ldap.SearchFilter,
})
err = ldapService.Init()
if err == nil {
services.ldapService = ldapService
} else {
log.Warn().Err(err).Msg("Failed to initialize LDAP service, continuing without it")
}
dockerService := service.NewDockerService()
err = dockerService.Init()
if err != nil {
return Services{}, err
}
services.dockerService = dockerService
accessControlsService := service.NewAccessControlsService(dockerService)
err = accessControlsService.Init()
if err != nil {
return Services{}, err
}
services.accessControlService = accessControlsService
authService := service.NewAuthService(service.AuthServiceConfig{
Users: app.context.users,
OauthWhitelist: app.config.OAuth.Whitelist,
SessionExpiry: app.config.Auth.SessionExpiry,
SecureCookie: app.config.Auth.SecureCookie,
CookieDomain: app.context.cookieDomain,
LoginTimeout: app.config.Auth.LoginTimeout,
LoginMaxRetries: app.config.Auth.LoginMaxRetries,
SessionCookieName: app.context.sessionCookieName,
}, dockerService, ldapService, databaseService.GetDatabase())
err = authService.Init()
if err != nil {
return Services{}, err
}
services.authService = authService
oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders)
err = oauthBrokerService.Init()
if err != nil {
return Services{}, err
}
services.oauthBrokerService = oauthBrokerService
// Initialize OIDC service if enabled
if app.config.OIDC.Enabled {
issuer := app.config.OIDC.Issuer
if issuer == "" {
issuer = app.config.AppURL
}
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
AppURL: app.config.AppURL,
Issuer: issuer,
AccessTokenExpiry: app.config.OIDC.AccessTokenExpiry,
IDTokenExpiry: app.config.OIDC.IDTokenExpiry,
Database: databaseService.GetDatabase(),
})
err = oidcService.Init()
if err != nil {
log.Warn().Err(err).Msg("Failed to initialize OIDC service, continuing without it")
} else {
services.oidcService = oidcService
log.Info().Msg("OIDC service initialized")
// Sync clients from config
if len(app.config.OIDC.Clients) > 0 {
err = oidcService.SyncClientsFromConfig(app.config.OIDC.Clients)
if err != nil {
log.Warn().Err(err).Msg("Failed to sync OIDC clients from config")
} else {
log.Info().Int("count", len(app.config.OIDC.Clients)).Msg("Synced OIDC clients from config")
}
}
}
}
return services, nil
}

View File

@@ -15,90 +15,39 @@ var RedirectCookieName = "tinyauth-redirect"
// Main app config
type Config struct {
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
LogLevel string `description:"Log level (trace, debug, info, warn, error)." yaml:"logLevel"`
ResourcesDir string `description:"The directory where resources are stored." yaml:"resourcesDir"`
DatabasePath string `description:"The path to the database file." yaml:"databasePath"`
DisableAnalytics bool `description:"Disable analytics." yaml:"disableAnalytics"`
DisableResources bool `description:"Disable resources server." yaml:"disableResources"`
DisableUIWarnings bool `description:"Disable UI warnings." yaml:"disableUIWarnings"`
LogJSON bool `description:"Enable JSON formatted logs." yaml:"logJSON"`
Server ServerConfig `description:"Server configuration." yaml:"server"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
OIDC OIDCConfig `description:"OIDC provider configuration." yaml:"oidc"`
UI UIConfig `description:"UI customization." yaml:"ui"`
Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
Port int `mapstructure:"port" validate:"required"`
Address string `validate:"required,ip4_addr" mapstructure:"address"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `mapstructure:"users"`
UsersFile string `mapstructure:"users-file"`
SecureCookie bool `mapstructure:"secure-cookie"`
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect"`
SessionExpiry int `mapstructure:"session-expiry"`
LogLevel string `mapstructure:"log-level" validate:"oneof=trace debug info warn error fatal panic"`
Title string `mapstructure:"app-title"`
LoginTimeout int `mapstructure:"login-timeout"`
LoginMaxRetries int `mapstructure:"login-max-retries"`
ForgotPasswordMessage string `mapstructure:"forgot-password-message"`
BackgroundImage string `mapstructure:"background-image" validate:"required"`
LdapAddress string `mapstructure:"ldap-address"`
LdapBindDN string `mapstructure:"ldap-bind-dn"`
LdapBindPassword string `mapstructure:"ldap-bind-password"`
LdapBaseDN string `mapstructure:"ldap-base-dn"`
LdapInsecure bool `mapstructure:"ldap-insecure"`
LdapSearchFilter string `mapstructure:"ldap-search-filter"`
ResourcesDir string `mapstructure:"resources-dir"`
DatabasePath string `mapstructure:"database-path" validate:"required"`
TrustedProxies string `mapstructure:"trusted-proxies"`
DisableAnalytics bool `mapstructure:"disable-analytics"`
DisableResources bool `mapstructure:"disable-resources"`
DisableUIWarnings bool `mapstructure:"disable-ui-warnings"`
SocketPath string `mapstructure:"socket-path"`
}
type ServerConfig struct {
Port int `description:"The port on which the server listens." yaml:"port"`
Address string `description:"The address on which the server listens." yaml:"address"`
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
TrustedProxies string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
}
type AuthConfig struct {
Users string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
}
type OAuthConfig struct {
Whitelist string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
}
type UIConfig struct {
Title string `description:"The title of the UI." yaml:"title"`
ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage"`
BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage"`
}
type LdapConfig struct {
Address string `description:"LDAP server address." yaml:"address"`
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
}
type OIDCConfig struct {
Enabled bool `description:"Enable OIDC provider functionality." yaml:"enabled"`
Issuer string `description:"OIDC issuer URL (defaults to appUrl)." yaml:"issuer"`
AccessTokenExpiry int `description:"Access token expiry time in seconds." yaml:"accessTokenExpiry"`
IDTokenExpiry int `description:"ID token expiry time in seconds." yaml:"idTokenExpiry"`
Clients map[string]OIDCClientConfig `description:"OIDC client configurations." yaml:"clients"`
}
type OIDCClientConfig struct {
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"`
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"`
ClientName string `description:"Client name for display purposes." yaml:"clientName"`
RedirectURIs []string `description:"Allowed redirect URIs." yaml:"redirectUris"`
GrantTypes []string `description:"Allowed grant types (defaults to ['authorization_code'])." yaml:"grantTypes"`
ResponseTypes []string `description:"Allowed response types (defaults to ['code'])." yaml:"responseTypes"`
Scopes []string `description:"Allowed scopes (defaults to ['openid', 'profile', 'email'])." yaml:"scopes"`
}
type ExperimentalConfig struct {
ConfigFile string `description:"Path to config file." yaml:"-"`
}
// Config loader options
const DefaultNamePrefix = "TINYAUTH_"
// OAuth/OIDC config
type Claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
@@ -106,16 +55,16 @@ type Claims struct {
}
type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID."`
ClientSecret string `description:"OAuth client secret."`
ClientSecretFile string `description:"Path to the file containing the OAuth client secret."`
Scopes []string `description:"OAuth scopes."`
RedirectURL string `description:"OAuth redirect URL."`
AuthURL string `description:"OAuth authorization URL."`
TokenURL string `description:"OAuth token URL."`
UserinfoURL string `description:"OAuth userinfo URL."`
Insecure bool `description:"Allow insecure OAuth connections."`
Name string `description:"Provider name in UI."`
ClientID string `field:"client-id"`
ClientSecret string
ClientSecretFile string
Scopes []string
RedirectURL string `field:"redirect-url"`
AuthURL string `field:"auth-url"`
TokenURL string `field:"token-url"`
UserinfoURL string `field:"user-info-url"`
InsecureSkipVerify bool
Name string
}
var OverrideProviders = map[string]string{
@@ -145,7 +94,6 @@ type SessionCookie struct {
TotpPending bool
OAuthGroups string
OAuthName string
OAuthSub string
}
type UserContext struct {
@@ -159,7 +107,6 @@ type UserContext struct {
OAuthGroups string
TotpEnabled bool
OAuthName string
OAuthSub string
}
// API responses and queries

View File

@@ -3,8 +3,7 @@ package controller
import (
"fmt"
"net/url"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
@@ -21,7 +20,6 @@ type UserContextResponse struct {
OAuth bool `json:"oauth"`
TotpPending bool `json:"totpPending"`
OAuthName string `json:"oauthName"`
OAuthSub string `json:"oauthSub"`
}
type AppContextResponse struct {
@@ -90,7 +88,6 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
OAuth: context.OAuth,
TotpPending: context.TotpPending,
OAuthName: context.OAuthName,
OAuthSub: context.OAuthSub,
}
if err != nil {

View File

@@ -4,9 +4,8 @@ import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"tinyauth/internal/config"
"tinyauth/internal/controller"
"github.com/gin-gonic/gin"
"gotest.tools/v3/assert"
@@ -44,7 +43,6 @@ var userContext = config.UserContext{
TotpPending: false,
OAuthGroups: "",
TotpEnabled: false,
OAuthSub: "",
}
func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {

View File

@@ -5,10 +5,9 @@ import (
"net/http"
"strings"
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/config"
"tinyauth/internal/service"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
@@ -197,7 +196,6 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
Provider: req.Provider,
OAuthGroups: utils.CoalesceToString(user.Groups),
OAuthName: service.GetName(),
OAuthSub: user.Sub,
}
log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")

View File

@@ -1,489 +0,0 @@
package controller
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
// OIDCControllerConfig holds configuration for the OIDC controller.
type OIDCControllerConfig struct {
AppURL string // Base URL of the application
CookieDomain string // Domain for setting cookies
}
// OIDCController handles OpenID Connect (OIDC) protocol endpoints.
// It implements the OIDC provider functionality including discovery, authorization,
// token exchange, userinfo, and JWKS endpoints.
type OIDCController struct {
config OIDCControllerConfig
router *gin.RouterGroup
oidc *service.OIDCService
auth *service.AuthService
}
// NewOIDCController creates a new OIDC controller with the given configuration and services.
func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oidc *service.OIDCService, auth *service.AuthService) *OIDCController {
return &OIDCController{
config: config,
router: router,
oidc: oidc,
auth: auth,
}
}
// SetupRoutes registers all OIDC endpoints with the router.
// This includes:
// - /.well-known/openid-configuration - OIDC discovery endpoint
// - /oidc/authorize - Authorization endpoint
// - /oidc/token - Token endpoint
// - /oidc/userinfo - UserInfo endpoint
// - /oidc/jwks - JSON Web Key Set endpoint
func (controller *OIDCController) SetupRoutes() {
// Well-known discovery endpoint
controller.router.GET("/.well-known/openid-configuration", controller.discoveryHandler)
// OIDC endpoints
oidcGroup := controller.router.Group("/oidc")
oidcGroup.GET("/authorize", controller.authorizeHandler)
oidcGroup.POST("/token", controller.tokenHandler)
oidcGroup.GET("/userinfo", controller.userinfoHandler)
oidcGroup.GET("/jwks", controller.jwksHandler)
}
// discoveryHandler handles the OIDC discovery endpoint.
// Returns the OpenID Connect discovery document as specified in RFC 8414.
// The document contains metadata about the OIDC provider including endpoints,
// supported features, and cryptographic capabilities.
func (controller *OIDCController) discoveryHandler(c *gin.Context) {
issuer := controller.oidc.GetIssuer()
baseURL := strings.TrimSuffix(controller.config.AppURL, "/")
discovery := map[string]interface{}{
"issuer": issuer,
"authorization_endpoint": fmt.Sprintf("%s/api/oidc/authorize", baseURL),
"token_endpoint": fmt.Sprintf("%s/api/oidc/token", baseURL),
"userinfo_endpoint": fmt.Sprintf("%s/api/oidc/userinfo", baseURL),
"jwks_uri": fmt.Sprintf("%s/api/oidc/jwks", baseURL),
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
"scopes_supported": []string{"openid", "profile", "email"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"grant_types_supported": []string{"authorization_code"},
"code_challenge_methods_supported": []string{"S256", "plain"},
}
c.JSON(http.StatusOK, discovery)
}
// authorizeHandler handles the OIDC authorization endpoint.
// Implements the authorization code flow as specified in OAuth 2.0 RFC 6749.
// Validates client credentials, redirect URI, scopes, and response type.
// Supports PKCE (RFC 7636) for enhanced security.
// If the user is not authenticated, redirects to the login page with the
// authorization request parameters preserved for redirect after login.
// On success, generates an authorization code and redirects to the client's
// redirect URI with the code and state parameter.
func (controller *OIDCController) authorizeHandler(c *gin.Context) {
// Get query parameters
clientID := c.Query("client_id")
redirectURI := c.Query("redirect_uri")
responseType := c.Query("response_type")
scope := c.Query("scope")
state := c.Query("state")
nonce := c.Query("nonce")
codeChallenge := c.Query("code_challenge")
codeChallengeMethod := c.Query("code_challenge_method")
// Validate required parameters
// Return JSON error instead of redirecting since redirect_uri is not yet validated
if clientID == "" || redirectURI == "" || responseType == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing required parameters",
})
return
}
// Get client
// Return JSON error instead of redirecting since redirect_uri is not yet validated
client, err := controller.oidc.GetClient(clientID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_client",
"error_description": "Client not found",
})
return
}
// Validate redirect URI
// After this point, redirect_uri is validated and we can safely redirect
if !controller.oidc.ValidateRedirectURI(client, redirectURI) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Invalid redirect_uri",
})
return
}
// Validate response type
if !controller.oidc.ValidateResponseType(client, responseType) {
controller.redirectError(c, redirectURI, state, "unsupported_response_type", "Unsupported response_type")
return
}
// Validate scopes
scopes, err := controller.oidc.ValidateScope(client, scope)
if err != nil {
controller.redirectError(c, redirectURI, state, "invalid_scope", "Invalid scope")
return
}
// Check if user is authenticated
userContext, err := utils.GetContext(c)
if err != nil || !userContext.IsLoggedIn {
// User not authenticated, redirect to login
// Build the full authorize URL to redirect back to after login
authorizeURL := fmt.Sprintf("%s%s", controller.config.AppURL, c.Request.URL.Path)
if c.Request.URL.RawQuery != "" {
authorizeURL = fmt.Sprintf("%s?%s", authorizeURL, c.Request.URL.RawQuery)
}
loginURL := fmt.Sprintf("%s/login?redirect_uri=%s&client_id=%s&response_type=%s&scope=%s&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=%s",
controller.config.AppURL,
url.QueryEscape(authorizeURL),
url.QueryEscape(clientID),
url.QueryEscape(responseType),
url.QueryEscape(scope),
url.QueryEscape(state),
url.QueryEscape(nonce),
url.QueryEscape(codeChallenge),
url.QueryEscape(codeChallengeMethod))
c.Redirect(http.StatusFound, loginURL)
return
}
// Check for TOTP pending
if userContext.TotpPending {
controller.redirectError(c, redirectURI, state, "access_denied", "TOTP verification required")
return
}
// Generate authorization code (including PKCE challenge if provided)
authCode, err := controller.oidc.GenerateAuthorizationCode(&userContext, clientID, redirectURI, scopes, nonce, codeChallenge, codeChallengeMethod)
if err != nil {
log.Error().Err(err).Msg("Failed to generate authorization code")
controller.redirectError(c, redirectURI, state, "server_error", "Internal server error")
return
}
// Build redirect URL with authorization code
redirectURL, err := url.Parse(redirectURI)
if err != nil {
controller.redirectError(c, redirectURI, state, "invalid_request", "Invalid redirect_uri")
return
}
query := redirectURL.Query()
query.Set("code", authCode)
if state != "" {
query.Set("state", state)
}
redirectURL.RawQuery = query.Encode()
c.Redirect(http.StatusFound, redirectURL.String())
}
// tokenHandler handles the OIDC token endpoint.
// Exchanges an authorization code for access and ID tokens.
// Validates the authorization code, client credentials, redirect URI, and PKCE verifier.
// Returns an access token and optionally an ID token (if openid scope is present).
// Implements the authorization code grant type as specified in OAuth 2.0 RFC 6749.
func (controller *OIDCController) tokenHandler(c *gin.Context) {
// Get grant type
grantType := c.PostForm("grant_type")
if grantType == "" {
grantType = c.Query("grant_type")
}
if grantType != "authorization_code" {
controller.tokenError(c, "unsupported_grant_type", "Only authorization_code grant type is supported")
return
}
// Get authorization code
code := c.PostForm("code")
if code == "" {
code = c.Query("code")
}
if code == "" {
controller.tokenError(c, "invalid_request", "Missing authorization code")
return
}
// Get client credentials
clientID, clientSecret, err := controller.getClientCredentials(c)
if err != nil {
controller.tokenError(c, "invalid_client", "Invalid client credentials")
return
}
// Get client
client, err := controller.oidc.GetClient(clientID)
if err != nil {
controller.tokenError(c, "invalid_client", "Client not found")
return
}
// Verify client secret
if !controller.oidc.VerifyClientSecret(client, clientSecret) {
controller.tokenError(c, "invalid_client", "Invalid client secret")
return
}
// Get redirect URI
redirectURI := c.PostForm("redirect_uri")
if redirectURI == "" {
redirectURI = c.Query("redirect_uri")
}
// Validate redirect URI
if !controller.oidc.ValidateRedirectURI(client, redirectURI) {
controller.tokenError(c, "invalid_request", "Invalid redirect_uri")
return
}
// Get code_verifier for PKCE validation
codeVerifier := c.PostForm("code_verifier")
if codeVerifier == "" {
codeVerifier = c.Query("code_verifier")
}
// Validate authorization code
userContext, scopes, nonce, codeChallenge, codeChallengeMethod, err := controller.oidc.ValidateAuthorizationCode(code, clientID, redirectURI)
if err != nil {
log.Error().Err(err).Msg("Failed to validate authorization code")
controller.tokenError(c, "invalid_grant", "Invalid or expired authorization code")
return
}
// Validate PKCE if code challenge was provided
if codeChallenge != "" {
if err := controller.oidc.ValidatePKCE(codeChallenge, codeChallengeMethod, codeVerifier); err != nil {
log.Error().Err(err).Msg("PKCE validation failed")
controller.tokenError(c, "invalid_grant", "Invalid code_verifier")
return
}
}
// Generate tokens
accessToken, err := controller.oidc.GenerateAccessToken(userContext, clientID, scopes)
if err != nil {
log.Error().Err(err).Msg("Failed to generate access token")
controller.tokenError(c, "server_error", "Internal server error")
return
}
// Generate ID token if openid scope is present
var idToken string
hasOpenID := false
for _, scope := range scopes {
if scope == "openid" {
hasOpenID = true
break
}
}
if hasOpenID {
idToken, err = controller.oidc.GenerateIDToken(userContext, clientID, nonce)
if err != nil {
log.Error().Err(err).Msg("Failed to generate ID token")
controller.tokenError(c, "server_error", "Internal server error")
return
}
}
// Return token response
response := map[string]interface{}{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": controller.oidc.GetAccessTokenExpiry(),
"scope": strings.Join(scopes, " "),
}
if idToken != "" {
response["id_token"] = idToken
}
c.JSON(http.StatusOK, response)
}
// userinfoHandler handles the OIDC UserInfo endpoint.
// Returns user information claims for the authenticated user based on the
// provided access token. Validates the access token signature, issuer, and expiration.
// Returns standard OIDC claims: sub, email, name, and preferred_username.
func (controller *OIDCController) userinfoHandler(c *gin.Context) {
// Get access token from Authorization header or query parameter
accessToken := controller.getAccessToken(c)
if accessToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "invalid_token",
"error_description": "Missing access token",
})
return
}
// Get optional client_id from request for audience validation
clientID := c.Query("client_id")
if clientID == "" {
clientID = c.PostForm("client_id")
}
// Validate and parse access token with audience validation
userContext, err := controller.oidc.ValidateAccessTokenForClient(accessToken, clientID)
if err != nil {
log.Error().Err(err).Msg("Failed to validate access token")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "invalid_token",
"error_description": "Invalid or expired access token",
})
return
}
// Return user info
userInfo := map[string]interface{}{
"sub": userContext.Username,
"email": userContext.Email,
"name": userContext.Name,
"preferred_username": userContext.Username,
}
c.JSON(http.StatusOK, userInfo)
}
// jwksHandler handles the JSON Web Key Set (JWKS) endpoint.
// Returns the public keys used to verify ID tokens and access tokens.
// The keys are in JWK format as specified in RFC 7517.
func (controller *OIDCController) jwksHandler(c *gin.Context) {
jwks, err := controller.oidc.GetJWKS()
if err != nil {
log.Error().Err(err).Msg("Failed to get JWKS")
c.JSON(http.StatusInternalServerError, gin.H{
"error": "server_error",
})
return
}
c.JSON(http.StatusOK, jwks)
}
// Helper functions
// redirectError redirects the user to the redirect URI with an error response.
// Includes the error code, error description, and state parameter (if provided).
// If the redirect URI is invalid or empty, returns a JSON error response instead.
func (controller *OIDCController) redirectError(c *gin.Context, redirectURI string, state string, errorCode string, errorDescription string) {
if redirectURI == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": errorCode,
"error_description": errorDescription,
})
return
}
redirectURL, err := url.Parse(redirectURI)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": errorCode,
"error_description": errorDescription,
})
return
}
query := redirectURL.Query()
query.Set("error", errorCode)
query.Set("error_description", errorDescription)
if state != "" {
query.Set("state", state)
}
redirectURL.RawQuery = query.Encode()
c.Redirect(http.StatusFound, redirectURL.String())
}
// tokenError returns a JSON error response for token endpoint errors.
// Uses the standard OAuth 2.0 error format with error and error_description fields.
func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, errorDescription string) {
c.JSON(http.StatusBadRequest, gin.H{
"error": errorCode,
"error_description": errorDescription,
})
}
// getClientCredentials extracts client credentials from the request.
// Supports client_secret_basic (HTTP Basic Authentication) and
// client_secret_post (POST form parameters) as specified in the discovery document.
// Does not accept credentials via query parameters for security reasons
// (they may be logged in access logs, browser history, or referrer headers).
// Returns the client ID, client secret, and an error if credentials are not found.
func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, string, error) {
// Try Basic Auth first (client_secret_basic)
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Basic ") {
encoded := strings.TrimPrefix(authHeader, "Basic ")
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err == nil {
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) == 2 {
return parts[0], parts[1], nil
}
}
}
// Try POST form parameters (client_secret_post)
clientID := c.PostForm("client_id")
clientSecret := c.PostForm("client_secret")
if clientID != "" && clientSecret != "" {
return clientID, clientSecret, nil
}
// Do not accept credentials via query parameters as they are logged
// in access logs, browser history, and referrer headers
return "", "", fmt.Errorf("client credentials not found")
}
// getAccessToken extracts the access token from the request.
// Checks the Authorization header (Bearer token) first, then falls back to
// the access_token query parameter.
// Returns an empty string if no access token is found.
func (controller *OIDCController) getAccessToken(c *gin.Context) string {
// Try Authorization header
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
// Try query parameter
return c.Query("access_token")
}
// validateAccessToken validates an access token and extracts user context.
// Verifies the JWT signature using the OIDC service's public key, checks the
// issuer, and validates expiration. Returns the user context if valid, or an
// error if validation fails.
func (controller *OIDCController) validateAccessToken(accessToken string) (*config.UserContext, error) {
// Validate the JWT token using the OIDC service's public key
// This properly verifies the signature, issuer, and expiration
// Note: This method does not validate audience - use ValidateAccessTokenForClient for that
return controller.oidc.ValidateAccessToken(accessToken)
}

View File

@@ -3,20 +3,16 @@ package controller
import (
"fmt"
"net/http"
"slices"
"strings"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/config"
"tinyauth/internal/service"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/rs/zerolog/log"
)
var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"}
type Proxy struct {
Proxy string `uri:"proxy" binding:"required"`
}
@@ -44,7 +40,6 @@ func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, a
func (controller *ProxyController) SetupRoutes() {
proxyGroup := controller.router.Group("/auth")
proxyGroup.GET("/:proxy", controller.proxyHandler)
proxyGroup.POST("/:proxy", controller.proxyHandler)
}
func (controller *ProxyController) proxyHandler(c *gin.Context) {
@@ -60,7 +55,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if !slices.Contains(SupportedProxies, req.Proxy) {
if req.Proxy != "nginx" && req.Proxy != "traefik" && req.Proxy != "caddy" {
log.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy")
c.JSON(400, gin.H{
"status": 400,
@@ -239,7 +234,6 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub))
controller.setHeaders(c, acls)

View File

@@ -3,10 +3,9 @@ package controller_test
import (
"net/http/httptest"
"testing"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/service"
"tinyauth/internal/config"
"tinyauth/internal/controller"
"tinyauth/internal/service"
"github.com/gin-gonic/gin"
"gotest.tools/v3/assert"
@@ -93,18 +92,6 @@ func TestProxyHandler(t *testing.T) {
assert.Equal(t, 307, recorder.Code)
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
// Test logged out user (envoy)
recorder = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/auth/envoy", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com")
req.Header.Set("X-Forwarded-Uri", "/somepath")
req.Header.Set("Accept", "text/html")
router.ServeHTTP(recorder, req)
assert.Equal(t, 307, recorder.Code)
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
// Test logged out user (nginx)
recorder = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/api/auth/nginx", nil)

View File

@@ -4,8 +4,7 @@ import (
"net/http/httptest"
"os"
"testing"
"github.com/steveiliop56/tinyauth/internal/controller"
"tinyauth/internal/controller"
"github.com/gin-gonic/gin"
"gotest.tools/v3/assert"

View File

@@ -3,10 +3,9 @@ package controller
import (
"fmt"
"strings"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/config"
"tinyauth/internal/service"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"

View File

@@ -7,10 +7,9 @@ import (
"strings"
"testing"
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/service"
"tinyauth/internal/config"
"tinyauth/internal/controller"
"tinyauth/internal/service"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"

View File

@@ -3,10 +3,9 @@ package middleware
import (
"fmt"
"strings"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/config"
"tinyauth/internal/service"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
@@ -66,7 +65,6 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
goto basic
}
m.auth.RefreshSessionCookie(c)
c.Set("context", &config.UserContext{
Username: cookie.Username,
Name: cookie.Name,
@@ -91,7 +89,6 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
goto basic
}
m.auth.RefreshSessionCookie(c)
c.Set("context", &config.UserContext{
Username: cookie.Username,
Name: cookie.Name,
@@ -99,7 +96,6 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
Provider: cookie.Provider,
OAuthGroups: cookie.OAuthGroups,
OAuthName: cookie.OAuthName,
OAuthSub: cookie.OAuthSub,
IsLoggedIn: true,
OAuth: true,
})

View File

@@ -7,8 +7,7 @@ import (
"os"
"strings"
"time"
"github.com/steveiliop56/tinyauth/internal/assets"
"tinyauth/internal/assets"
"github.com/gin-gonic/gin"
)

View File

@@ -1,15 +0,0 @@
package model
type OIDCAuthorizationCode struct {
Code string `gorm:"column:code;primaryKey"`
ClientID string `gorm:"column:client_id;not null"`
RedirectURI string `gorm:"column:redirect_uri;not null"`
Used bool `gorm:"column:used;default:false"`
ExpiresAt int64 `gorm:"column:expires_at;not null"`
CreatedAt int64 `gorm:"column:created_at;not null"`
}
func (OIDCAuthorizationCode) TableName() string {
return "oidc_authorization_codes"
}

View File

@@ -1,18 +0,0 @@
package model
type OIDCClient struct {
ClientID string `gorm:"column:client_id;primaryKey"`
ClientSecret string `gorm:"column:client_secret"`
ClientName string `gorm:"column:client_name"`
RedirectURIs string `gorm:"column:redirect_uris"` // JSON array
GrantTypes string `gorm:"column:grant_types"` // JSON array
ResponseTypes string `gorm:"column:response_types"` // JSON array
Scopes string `gorm:"column:scopes"` // JSON array
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
}
func (OIDCClient) TableName() string {
return "oidc_clients"
}

View File

@@ -1,13 +0,0 @@
package model
type OIDCKey struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"`
PrivateKey string `gorm:"column:private_key;not null"`
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
}
func (OIDCKey) TableName() string {
return "oidc_keys"
}

View File

@@ -10,5 +10,4 @@ type Session struct {
OAuthGroups string `gorm:"column:oauth_groups"`
Expiry int64 `gorm:"column:expiry"`
OAuthName string `gorm:"column:oauth_name"`
OAuthSub string `gorm:"column:oauth_sub"`
}

View File

@@ -1,7 +1,7 @@
package service
import (
"github.com/steveiliop56/tinyauth/internal/config"
"tinyauth/internal/config"
)
/*

View File

@@ -1,16 +1,16 @@
package service
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/model"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/config"
"tinyauth/internal/model"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -43,6 +43,7 @@ type AuthService struct {
loginMutex sync.RWMutex
ldap *LdapService
database *gorm.DB
ctx context.Context
}
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, database *gorm.DB) *AuthService {
@@ -56,6 +57,7 @@ func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapS
}
func (auth *AuthService) Init() error {
auth.ctx = context.Background()
return nil
}
@@ -213,10 +215,9 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio
OAuthGroups: data.OAuthGroups,
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
OAuthName: data.OAuthName,
OAuthSub: data.OAuthSub,
}
err = gorm.G[model.Session](auth.database).Create(c, &session)
err = gorm.G[model.Session](auth.database).Create(auth.ctx, &session)
if err != nil {
return err
@@ -227,40 +228,6 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio
return nil
}
func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
cookie, err := c.Cookie(auth.config.SessionCookieName)
if err != nil {
return err
}
session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(c)
if err != nil {
return err
}
currentTime := time.Now().Unix()
if session.Expiry-currentTime > int64(time.Hour.Seconds()) {
return nil
}
newExpiry := currentTime + int64(time.Hour.Seconds())
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Updates(c, model.Session{
Expiry: newExpiry,
})
if err != nil {
return err
}
c.SetCookie(auth.config.SessionCookieName, cookie, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
return nil
}
func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
cookie, err := c.Cookie(auth.config.SessionCookieName)
@@ -268,7 +235,7 @@ func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
return err
}
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(c)
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(auth.ctx)
if err != nil {
return err
@@ -286,7 +253,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
return config.SessionCookie{}, err
}
session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(c)
session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(auth.ctx)
if err != nil {
return config.SessionCookie{}, err
@@ -299,7 +266,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
currentTime := time.Now().Unix()
if currentTime > session.Expiry {
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(c)
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(auth.ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to delete expired session")
}
@@ -315,7 +282,6 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
TotpPending: session.TOTPPending,
OAuthGroups: session.OAuthGroups,
OAuthName: session.OAuthName,
OAuthSub: session.OAuthSub,
}, nil
}

View File

@@ -2,11 +2,7 @@ package service
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"github.com/steveiliop56/tinyauth/internal/assets"
"tinyauth/internal/assets"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
@@ -31,17 +27,7 @@ func NewDatabaseService(config DatabaseServiceConfig) *DatabaseService {
}
func (ds *DatabaseService) Init() error {
dbPath := ds.config.DatabasePath
if dbPath == "" {
dbPath = "/data/tinyauth.db"
}
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create database directory %s: %w", dir, err)
}
gormDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
gormDB, err := gorm.Open(sqlite.Open(ds.config.DatabasePath), &gorm.Config{})
if err != nil {
return err

View File

@@ -3,9 +3,8 @@ package service
import (
"context"
"strings"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/decoders"
"tinyauth/internal/config"
"tinyauth/internal/utils/decoders"
container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
@@ -83,7 +82,7 @@ func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
return config.App{}, err
}
labels, err := decoders.DecodeLabels[config.Apps](inspect.Config.Labels, "apps")
labels, err := decoders.DecodeLabels(inspect.Config.Labels)
if err != nil {
return config.App{}, err
}

View File

@@ -10,8 +10,7 @@ import (
"io"
"net/http"
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"tinyauth/internal/config"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
@@ -39,7 +38,7 @@ func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthServi
TokenURL: config.TokenURL,
},
},
insecureSkipVerify: config.Insecure,
insecureSkipVerify: config.InsecureSkipVerify,
userinfoUrl: config.UserinfoURL,
name: config.Name,
}
@@ -55,7 +54,6 @@ func (generic *GenericOAuthService) Init() error {
httpClient := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
ctx := context.Background()

View File

@@ -9,10 +9,8 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"tinyauth/internal/config"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
@@ -28,7 +26,6 @@ type GithubEmailResponse []struct {
type GithubUserInfoResponse struct {
Login string `json:"login"`
Name string `json:"name"`
ID int `json:"id"`
}
type GithubOAuthService struct {
@@ -53,9 +50,7 @@ func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService
}
func (github *GithubOAuthService) Init() error {
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
httpClient := &http.Client{}
ctx := context.Background()
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
github.context = ctx
@@ -174,7 +169,6 @@ func (github *GithubOAuthService) Userinfo() (config.Claims, error) {
user.PreferredUsername = userInfo.Login
user.Name = userInfo.Name
user.Sub = strconv.Itoa(userInfo.ID)
return user, nil
}

View File

@@ -10,14 +10,18 @@ import (
"net/http"
"strings"
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"tinyauth/internal/config"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
)
var GoogleOAuthScopes = []string{"openid", "email", "profile"}
var GoogleOAuthScopes = []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}
type GoogleUserInfoResponse struct {
Email string `json:"email"`
Name string `json:"name"`
}
type GoogleOAuthService struct {
config oauth2.Config
@@ -41,9 +45,7 @@ func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService
}
func (google *GoogleOAuthService) Init() error {
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
httpClient := &http.Client{}
ctx := context.Background()
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
google.context = ctx
@@ -86,7 +88,7 @@ func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
client := google.config.Client(google.context, google.token)
res, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo")
res, err := client.Get("https://www.googleapis.com/userinfo/v2/me")
if err != nil {
return config.Claims{}, err
}
@@ -101,12 +103,16 @@ func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
return config.Claims{}, err
}
err = json.Unmarshal(body, &user)
var userInfo GoogleUserInfoResponse
err = json.Unmarshal(body, &userInfo)
if err != nil {
return config.Claims{}, err
}
user.PreferredUsername = strings.SplitN(user.Email, "@", 2)[0]
user.PreferredUsername = strings.Split(userInfo.Email, "@")[0]
user.Name = userInfo.Name
user.Email = userInfo.Email
return user, nil
}

View File

@@ -2,8 +2,7 @@ package service
import (
"errors"
"github.com/steveiliop56/tinyauth/internal/config"
"tinyauth/internal/config"
"github.com/rs/zerolog/log"
"golang.org/x/exp/slices"

View File

@@ -1,822 +0,0 @@
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
"os"
"strings"
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/model"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/hkdf"
"gorm.io/gorm"
)
type OIDCServiceConfig struct {
AppURL string
Issuer string
AccessTokenExpiry int
IDTokenExpiry int
Database *gorm.DB
}
type OIDCService struct {
config OIDCServiceConfig
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
masterKey []byte // Master key for encrypting private keys (optional)
}
func NewOIDCService(config OIDCServiceConfig) *OIDCService {
return &OIDCService{
config: config,
}
}
// encryptPrivateKey encrypts a private key PEM string using AES-GCM
func (oidc *OIDCService) encryptPrivateKey(plaintext string) (string, error) {
if len(oidc.masterKey) == 0 {
// No encryption key set, return plaintext
return plaintext, nil
}
// Derive AES-256 key from master key using HKDF
hkdfReader := hkdf.New(sha256.New, oidc.masterKey, nil, []byte("oidc-aes-256-key-v1"))
key := make([]byte, 32) // AES-256 requires 32 bytes
if _, err := io.ReadFull(hkdfReader, key); err != nil {
return "", fmt.Errorf("failed to derive encryption key: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
// Encode as base64 for storage
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// decryptPrivateKey decrypts an encrypted private key PEM string
func (oidc *OIDCService) decryptPrivateKey(encrypted string) (string, error) {
if len(oidc.masterKey) == 0 {
// No encryption key set, assume plaintext
return encrypted, nil
}
// Try to decode as base64 (encrypted) first
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
// Not base64, assume it's plaintext (backward compatibility)
return encrypted, nil
}
// Derive AES-256 key from master key using HKDF
hkdfReader := hkdf.New(sha256.New, oidc.masterKey, nil, []byte("oidc-aes-256-key-v1"))
key := make([]byte, 32) // AES-256 requires 32 bytes
if _, err := io.ReadFull(hkdfReader, key); err != nil {
return "", fmt.Errorf("failed to derive decryption key: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
// Too short to be encrypted, assume plaintext
return encrypted, nil
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt private key: %w", err)
}
return string(plaintext), nil
}
func (oidc *OIDCService) Init() error {
// Load master key from environment (optional)
masterKeyEnv := os.Getenv("OIDC_RSA_MASTER_KEY")
if masterKeyEnv != "" {
oidc.masterKey = []byte(masterKeyEnv)
if len(oidc.masterKey) < 32 {
log.Warn().Msg("OIDC_RSA_MASTER_KEY is shorter than 32 bytes, consider using a longer key for better security")
}
log.Info().Msg("RSA private key encryption enabled (using OIDC_RSA_MASTER_KEY)")
} else {
log.Info().Msg("RSA private key encryption disabled (OIDC_RSA_MASTER_KEY not set)")
}
// Check if multiple keys exist (for warning)
var keyCount int64
if err := oidc.config.Database.Model(&model.OIDCKey{}).Count(&keyCount).Error; err != nil {
return fmt.Errorf("failed to count RSA keys: %w", err)
}
if keyCount > 1 {
log.Warn().Int64("count", keyCount).Msg("Multiple RSA keys detected in database, loading most recently created key. Consider cleaning up older keys.")
}
// Try to load existing key from database (most recently created)
var keyRecord model.OIDCKey
err := oidc.config.Database.Order("created_at DESC").First(&keyRecord).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("failed to query for existing RSA key: %w", err)
}
var privateKey *rsa.PrivateKey
if err == nil && keyRecord.PrivateKey != "" {
// Decrypt private key if encrypted
privateKeyPEM, err := oidc.decryptPrivateKey(keyRecord.PrivateKey)
if err != nil {
return fmt.Errorf("failed to decrypt private key: %w", err)
}
// Load existing key
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return fmt.Errorf("failed to decode PEM block from stored key")
}
parsedKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
// Try PKCS8 format as fallback
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse stored private key: %w", err)
}
var ok bool
privateKey, ok = key.(*rsa.PrivateKey)
if !ok {
return fmt.Errorf("stored key is not an RSA private key")
}
} else {
privateKey = parsedKey
}
oidc.privateKey = privateKey
oidc.publicKey = &privateKey.PublicKey
log.Info().Msg("OIDC service initialized with existing RSA key pair from database")
return nil
}
// No existing key found, generate new one
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate RSA key: %w", err)
}
// Encode private key to PEM format
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateKeyBytes,
})
// Encrypt private key before storing
encryptedPrivateKey, err := oidc.encryptPrivateKey(string(privateKeyPEM))
if err != nil {
return fmt.Errorf("failed to encrypt private key: %w", err)
}
// Save to database
now := time.Now().Unix()
keyRecord = model.OIDCKey{
PrivateKey: encryptedPrivateKey,
CreatedAt: now,
UpdatedAt: now,
}
if err := oidc.config.Database.Create(&keyRecord).Error; err != nil {
return fmt.Errorf("failed to save RSA key to database: %w", err)
}
oidc.privateKey = privateKey
oidc.publicKey = &privateKey.PublicKey
log.Info().Msg("OIDC service initialized with new RSA key pair (saved to database)")
return nil
}
func (oidc *OIDCService) GetClient(clientID string) (*model.OIDCClient, error) {
var client model.OIDCClient
err := oidc.config.Database.Where("client_id = ?", clientID).First(&client).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("client not found")
}
return nil, err
}
return &client, nil
}
func (oidc *OIDCService) VerifyClientSecret(client *model.OIDCClient, secret string) bool {
// Use bcrypt for constant-time comparison to prevent timing attacks
err := bcrypt.CompareHashAndPassword([]byte(client.ClientSecret), []byte(secret))
if err != nil {
log.Debug().Err(err).Str("client_id", client.ClientID).Msg("Client secret verification failed")
return false
}
return true
}
func (oidc *OIDCService) ValidateRedirectURI(client *model.OIDCClient, redirectURI string) bool {
var redirectURIs []string
if err := json.Unmarshal([]byte(client.RedirectURIs), &redirectURIs); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal redirect URIs")
return false
}
for _, uri := range redirectURIs {
if uri == redirectURI {
return true
}
}
return false
}
func (oidc *OIDCService) ValidateGrantType(client *model.OIDCClient, grantType string) bool {
var grantTypes []string
if err := json.Unmarshal([]byte(client.GrantTypes), &grantTypes); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal grant types")
return false
}
for _, gt := range grantTypes {
if gt == grantType {
return true
}
}
return false
}
func (oidc *OIDCService) ValidateResponseType(client *model.OIDCClient, responseType string) bool {
var responseTypes []string
if err := json.Unmarshal([]byte(client.ResponseTypes), &responseTypes); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal response types")
return false
}
for _, rt := range responseTypes {
if rt == responseType {
return true
}
}
return false
}
func (oidc *OIDCService) ValidateScope(client *model.OIDCClient, requestedScopes string) ([]string, error) {
var allowedScopes []string
if err := json.Unmarshal([]byte(client.Scopes), &allowedScopes); err != nil {
return nil, fmt.Errorf("failed to unmarshal scopes: %w", err)
}
requestedScopesList := []string{}
if requestedScopes != "" {
requestedScopesList = splitScopes(requestedScopes)
}
validScopes := []string{}
for _, scope := range requestedScopesList {
for _, allowed := range allowedScopes {
if scope == allowed {
validScopes = append(validScopes, scope)
break
}
}
}
return validScopes, nil
}
func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserContext, clientID string, redirectURI string, scopes []string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
code := uuid.New().String()
now := time.Now()
expiresAt := now.Add(10 * time.Minute).Unix()
// Store authorization code in database for replay protection
authCodeRecord := model.OIDCAuthorizationCode{
Code: code,
ClientID: clientID,
RedirectURI: redirectURI,
Used: false,
ExpiresAt: expiresAt,
CreatedAt: now.Unix(),
}
if err := oidc.config.Database.Create(&authCodeRecord).Error; err != nil {
return "", fmt.Errorf("failed to store authorization code: %w", err)
}
// Encode as JWT for stateless operation (but code is tracked in DB)
claims := jwt.MapClaims{
"code": code,
"username": userContext.Username,
"email": userContext.Email,
"name": userContext.Name,
"provider": userContext.Provider,
"client_id": clientID,
"redirect_uri": redirectURI,
"scopes": scopes,
"exp": expiresAt,
"iat": now.Unix(),
}
if nonce != "" {
claims["nonce"] = nonce
}
// Store PKCE challenge if provided
if codeChallenge != "" {
claims["code_challenge"] = codeChallenge
if codeChallengeMethod != "" {
claims["code_challenge_method"] = codeChallengeMethod
} else {
// Default to plain if method not specified
claims["code_challenge_method"] = "plain"
}
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
codeToken, err := token.SignedString(oidc.privateKey)
if err != nil {
// Clean up the database record if JWT signing fails
oidc.config.Database.Delete(&authCodeRecord)
return "", fmt.Errorf("failed to sign authorization code: %w", err)
}
return codeToken, nil
}
func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID string, redirectURI string) (*config.UserContext, []string, string, string, string, error) {
token, err := jwt.Parse(codeToken, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return oidc.publicKey, nil
})
if err != nil {
return nil, nil, "", "", "", fmt.Errorf("failed to parse authorization code: %w", err)
}
if !token.Valid {
return nil, nil, "", "", "", errors.New("invalid authorization code")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, nil, "", "", "", errors.New("invalid token claims")
}
// Extract code from JWT for database lookup
code, ok := claims["code"].(string)
if !ok || code == "" {
return nil, nil, "", "", "", errors.New("missing code in authorization code token")
}
// Check database for replay protection - verify code exists and hasn't been used
var authCodeRecord model.OIDCAuthorizationCode
err = oidc.config.Database.Where("code = ?", code).First(&authCodeRecord).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, "", "", "", errors.New("authorization code not found")
}
return nil, nil, "", "", "", fmt.Errorf("failed to query authorization code: %w", err)
}
// Check if code has already been used (replay attack protection)
if authCodeRecord.Used {
return nil, nil, "", "", "", errors.New("authorization code has already been used")
}
// Check expiration
if time.Now().Unix() > authCodeRecord.ExpiresAt {
return nil, nil, "", "", "", errors.New("authorization code expired")
}
// Verify client_id and redirect_uri match
if claims["client_id"] != clientID {
return nil, nil, "", "", "", errors.New("client_id mismatch")
}
if claims["redirect_uri"] != redirectURI {
return nil, nil, "", "", "", errors.New("redirect_uri mismatch")
}
// Verify database record matches request parameters
if authCodeRecord.ClientID != clientID {
return nil, nil, "", "", "", errors.New("client_id mismatch")
}
if authCodeRecord.RedirectURI != redirectURI {
return nil, nil, "", "", "", errors.New("redirect_uri mismatch")
}
// Mark code as used to prevent replay attacks
authCodeRecord.Used = true
if err := oidc.config.Database.Save(&authCodeRecord).Error; err != nil {
return nil, nil, "", "", "", fmt.Errorf("failed to mark authorization code as used: %w", err)
}
userContext := &config.UserContext{
Username: getStringClaim(claims, "username"),
Email: getStringClaim(claims, "email"),
Name: getStringClaim(claims, "name"),
Provider: getStringClaim(claims, "provider"),
IsLoggedIn: true,
}
scopes := []string{}
if scopesInterface, ok := claims["scopes"].([]interface{}); ok {
for _, s := range scopesInterface {
if scope, ok := s.(string); ok {
scopes = append(scopes, scope)
}
}
}
nonce := getStringClaim(claims, "nonce")
codeChallenge := getStringClaim(claims, "code_challenge")
codeChallengeMethod := getStringClaim(claims, "code_challenge_method")
return userContext, scopes, nonce, codeChallenge, codeChallengeMethod, nil
}
func (oidc *OIDCService) ValidatePKCE(codeChallenge string, codeChallengeMethod string, codeVerifier string) error {
if codeChallenge == "" {
// PKCE not used, validation passes
return nil
}
if codeVerifier == "" {
return errors.New("code_verifier required when code_challenge is present")
}
switch codeChallengeMethod {
case "S256":
// Compute SHA256 hash of code_verifier
hash := sha256.Sum256([]byte(codeVerifier))
// Base64URL encode (without padding)
computedChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
if computedChallenge != codeChallenge {
return errors.New("code_verifier does not match code_challenge")
}
case "plain":
// Direct comparison
if codeVerifier != codeChallenge {
return errors.New("code_verifier does not match code_challenge")
}
default:
return fmt.Errorf("unsupported code_challenge_method: %s", codeChallengeMethod)
}
return nil
}
func (oidc *OIDCService) GenerateAccessToken(userContext *config.UserContext, clientID string, scopes []string) (string, error) {
expiry := oidc.config.AccessTokenExpiry
if expiry <= 0 {
expiry = 3600 // Default 1 hour
}
now := time.Now()
claims := jwt.MapClaims{
"sub": userContext.Username,
"iss": oidc.config.Issuer,
"aud": clientID,
"exp": now.Add(time.Duration(expiry) * time.Second).Unix(),
"iat": now.Unix(),
"scope": joinScopes(scopes),
"client_id": clientID,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
accessToken, err := token.SignedString(oidc.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign access token: %w", err)
}
return accessToken, nil
}
func (oidc *OIDCService) ValidateAccessToken(accessToken string) (*config.UserContext, error) {
return oidc.ValidateAccessTokenForClient(accessToken, "")
}
// ValidateAccessTokenForClient validates an access token and optionally checks the audience claim.
// If expectedClientID is provided, validates that the token's audience matches the expected client ID.
// This prevents tokens issued for one client from being used by another client.
func (oidc *OIDCService) ValidateAccessTokenForClient(accessToken string, expectedClientID string) (*config.UserContext, error) {
token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return oidc.publicKey, nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse access token: %w", err)
}
if !token.Valid {
return nil, errors.New("invalid access token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid token claims")
}
// Verify issuer
iss, ok := claims["iss"].(string)
if !ok || iss != oidc.config.Issuer {
return nil, errors.New("invalid issuer")
}
// Verify audience if expected client ID is provided
if expectedClientID != "" {
aud, ok := claims["aud"].(string)
if !ok || aud != expectedClientID {
return nil, errors.New("invalid audience")
}
}
// Check expiration
exp, ok := claims["exp"].(float64)
if !ok || time.Now().Unix() > int64(exp) {
return nil, errors.New("access token expired")
}
// Extract user info from claims
username, ok := claims["sub"].(string)
if !ok || username == "" {
return nil, errors.New("missing sub claim")
}
// Extract email and name if available
email, _ := claims["email"].(string)
name, _ := claims["name"].(string)
// Create user context
userContext := &config.UserContext{
Username: username,
Email: email,
Name: name,
IsLoggedIn: true,
}
return userContext, nil
}
func (oidc *OIDCService) GenerateIDToken(userContext *config.UserContext, clientID string, nonce string) (string, error) {
expiry := oidc.config.IDTokenExpiry
if expiry <= 0 {
expiry = 3600 // Default 1 hour
}
now := time.Now()
claims := jwt.MapClaims{
"sub": userContext.Username,
"iss": oidc.config.Issuer,
"aud": clientID,
"exp": now.Add(time.Duration(expiry) * time.Second).Unix(),
"iat": now.Unix(),
"auth_time": now.Unix(),
"email": userContext.Email,
"name": userContext.Name,
"preferred_username": userContext.Username,
}
if nonce != "" {
claims["nonce"] = nonce
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
idToken, err := token.SignedString(oidc.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign ID token: %w", err)
}
return idToken, nil
}
func (oidc *OIDCService) GetJWKS() (map[string]interface{}, error) {
// Extract modulus and exponent from public key
n := oidc.publicKey.N
e := oidc.publicKey.E
nBytes := n.Bytes()
// Use minimal-octet encoding for exponent per RFC 7517
eBytes := big.NewInt(int64(e)).Bytes()
jwk := map[string]interface{}{
"kty": "RSA",
"use": "sig",
"kid": "default",
"n": base64.RawURLEncoding.EncodeToString(nBytes),
"e": base64.RawURLEncoding.EncodeToString(eBytes),
"alg": "RS256",
}
return map[string]interface{}{
"keys": []interface{}{jwk},
}, nil
}
func (oidc *OIDCService) GetIssuer() string {
return oidc.config.Issuer
}
func (oidc *OIDCService) GetAccessTokenExpiry() int {
if oidc.config.AccessTokenExpiry <= 0 {
return 3600 // Default 1 hour
}
return oidc.config.AccessTokenExpiry
}
func (oidc *OIDCService) SyncClientsFromConfig(clients map[string]config.OIDCClientConfig) error {
for clientID, clientConfig := range clients {
// Get client secret from config or file (similar to OAuth providers)
clientSecret := utils.GetSecret(clientConfig.ClientSecret, clientConfig.ClientSecretFile)
if clientSecret == "" {
log.Warn().Str("client_id", clientID).Msg("Client secret is empty, skipping client")
continue
}
// Set defaults
clientName := clientConfig.ClientName
if clientName == "" {
clientName = clientID
}
redirectURIs := clientConfig.RedirectURIs
if len(redirectURIs) == 0 {
log.Warn().Str("client_id", clientID).Msg("No redirect URIs configured for client")
continue
}
grantTypes := clientConfig.GrantTypes
if len(grantTypes) == 0 {
grantTypes = []string{"authorization_code"}
}
responseTypes := clientConfig.ResponseTypes
if len(responseTypes) == 0 {
responseTypes = []string{"code"}
}
scopes := clientConfig.Scopes
if len(scopes) == 0 {
scopes = []string{"openid", "profile", "email"}
}
// Serialize arrays to JSON
redirectURIsJSON, err := json.Marshal(redirectURIs)
if err != nil {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal redirect URIs")
continue
}
grantTypesJSON, err := json.Marshal(grantTypes)
if err != nil {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal grant types")
continue
}
responseTypesJSON, err := json.Marshal(responseTypes)
if err != nil {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal response types")
continue
}
scopesJSON, err := json.Marshal(scopes)
if err != nil {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal scopes")
continue
}
// Hash client secret with bcrypt before storing
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
if err != nil {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to hash client secret")
continue
}
now := time.Now().Unix()
// Check if client exists
var existingClient model.OIDCClient
err = oidc.config.Database.Where("client_id = ?", clientID).First(&existingClient).Error
client := model.OIDCClient{
ClientID: clientID,
ClientSecret: string(hashedSecret),
ClientName: clientName,
RedirectURIs: string(redirectURIsJSON),
GrantTypes: string(grantTypesJSON),
ResponseTypes: string(responseTypesJSON),
Scopes: string(scopesJSON),
UpdatedAt: now,
}
if errors.Is(err, gorm.ErrRecordNotFound) {
// Create new client
client.CreatedAt = now
if err := oidc.config.Database.Create(&client).Error; err != nil {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to create OIDC client")
continue
}
log.Info().Str("client_id", clientID).Str("client_name", clientName).Msg("Created OIDC client from config")
} else if err == nil {
// Update existing client
client.CreatedAt = existingClient.CreatedAt // Preserve original creation time
if err := oidc.config.Database.Where("client_id = ?", clientID).Updates(&client).Error; err != nil {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to update OIDC client")
continue
}
log.Info().Str("client_id", clientID).Str("client_name", clientName).Msg("Updated OIDC client from config")
} else {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to check existing OIDC client")
continue
}
}
return nil
}
// Helper functions
func splitScopes(scopes string) []string {
if scopes == "" {
return []string{}
}
parts := strings.Split(scopes, " ")
result := []string{}
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func joinScopes(scopes []string) string {
return strings.Join(scopes, " ")
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
func getStringClaim(claims jwt.MapClaims, key string) string {
if val, ok := claims[key].(string); ok {
return val
}
return ""
}

View File

@@ -2,14 +2,16 @@ package utils
import (
"errors"
"fmt"
"net"
"net/url"
"strings"
"tinyauth/internal/config"
"tinyauth/internal/utils/decoders"
"github.com/steveiliop56/tinyauth/internal/config"
"maps"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"github.com/weppos/publicsuffix-go/publicsuffix"
)
@@ -23,13 +25,13 @@ func GetCookieDomain(u string) (string, error) {
host := parsed.Hostname()
if netIP := net.ParseIP(host); netIP != nil {
return "", fmt.Errorf("IP addresses not allowed for app url '%s' (got IP: %s)", u, host)
return "", errors.New("IP addresses not allowed")
}
parts := strings.Split(host, ".")
if len(parts) < 3 {
return "", fmt.Errorf("invalid app url '%s', must be at least second level domain (got %d parts, need 3+)", u, len(parts))
return "", errors.New("invalid app url, must be at least second level domain")
}
domain := strings.Join(parts[1:], ".")
@@ -37,7 +39,7 @@ func GetCookieDomain(u string) (string, error) {
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil)
if err != nil {
return "", fmt.Errorf("domain '%s' (from app url '%s') is in public suffix list, cannot set cookies", domain, u)
return "", errors.New("domain in public suffix list, cannot set cookies")
}
return domain, nil
@@ -102,3 +104,119 @@ func IsRedirectSafe(redirectURL string, domain string) bool {
return hostname == domain
}
func GetLogLevel(level string) zerolog.Level {
switch strings.ToLower(level) {
case "trace":
return zerolog.TraceLevel
case "debug":
return zerolog.DebugLevel
case "info":
return zerolog.InfoLevel
case "warn":
return zerolog.WarnLevel
case "error":
return zerolog.ErrorLevel
case "fatal":
return zerolog.FatalLevel
case "panic":
return zerolog.PanicLevel
default:
return zerolog.InfoLevel
}
}
func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[string]config.OAuthServiceConfig, error) {
providers := make(map[string]config.OAuthServiceConfig)
// Get from environment variables
envMap := make(map[string]string)
for _, e := range env {
pair := strings.SplitN(e, "=", 2)
if len(pair) == 2 {
envMap[pair[0]] = pair[1]
}
}
envProviders, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](envMap, "providers")
if err != nil {
return nil, err
}
maps.Copy(providers, envProviders.Providers)
// Get from flags
flagsMap := make(map[string]string)
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "--") {
pair := strings.SplitN(arg[2:], "=", 2)
if len(pair) == 2 {
flagsMap[pair[0]] = pair[1]
}
}
}
flagProviders, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flagsMap, "providers")
if err != nil {
return nil, err
}
maps.Copy(providers, flagProviders.Providers)
// For every provider get correct secret from file if set
for name, provider := range providers {
secret := GetSecret(provider.ClientSecret, provider.ClientSecretFile)
provider.ClientSecret = secret
provider.ClientSecretFile = ""
providers[name] = provider
}
// If we have google/github providers and no redirect URL then set a default
for id := range config.OverrideProviders {
if provider, exists := providers[id]; exists {
if provider.RedirectURL == "" {
provider.RedirectURL = appUrl + "/api/oauth/callback/" + id
providers[id] = provider
}
}
}
// Set names
for id, provider := range providers {
if provider.Name == "" {
if name, ok := config.OverrideProviders[id]; ok {
provider.Name = name
} else {
provider.Name = Capitalize(id)
}
}
providers[id] = provider
}
// Return combined providers
return providers, nil
}
func ShoudLogJSON(environ []string, args []string) bool {
for _, e := range environ {
pair := strings.SplitN(e, "=", 2)
if len(pair) == 2 && pair[0] == "LOG_JSON" && strings.ToLower(pair[1]) == "true" {
return true
}
}
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "--log-json=") {
value := strings.SplitN(arg, "=", 2)[1]
if strings.ToLower(value) == "true" {
return true
}
}
}
return false
}

View File

@@ -1,10 +1,10 @@
package utils_test
import (
"os"
"testing"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/config"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"gotest.tools/v3/assert"
@@ -206,3 +206,93 @@ func TestIsRedirectSafe(t *testing.T) {
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, false, result)
}
func TestGetOAuthProvidersConfig(t *testing.T) {
env := []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET=client1-secret"}
args := []string{"/tinyauth/tinyauth", "--providers-client2-client-id=client2-id", "--providers-client2-client-secret=client2-secret"}
expected := map[string]config.OAuthServiceConfig{
"client1": {
ClientID: "client1-id",
ClientSecret: "client1-secret",
Name: "Client1",
},
"client2": {
ClientID: "client2-id",
ClientSecret: "client2-secret",
Name: "Client2",
},
}
result, err := utils.GetOAuthProvidersConfig(env, args, "")
assert.NilError(t, err)
assert.DeepEqual(t, expected, result)
// Case with no providers
env = []string{}
args = []string{"/tinyauth/tinyauth"}
expected = map[string]config.OAuthServiceConfig{}
result, err = utils.GetOAuthProvidersConfig(env, args, "")
assert.NilError(t, err)
assert.DeepEqual(t, expected, result)
// Case with secret from file
file, err := os.Create("/tmp/tinyauth_test_file")
assert.NilError(t, err)
_, err = file.WriteString("file content\n")
assert.NilError(t, err)
err = file.Close()
assert.NilError(t, err)
defer os.Remove("/tmp/tinyauth_test_file")
env = []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET_FILE=/tmp/tinyauth_test_file"}
args = []string{"/tinyauth/tinyauth"}
expected = map[string]config.OAuthServiceConfig{
"client1": {
ClientID: "client1-id",
ClientSecret: "file content",
Name: "Client1",
},
}
result, err = utils.GetOAuthProvidersConfig(env, args, "")
assert.NilError(t, err)
assert.DeepEqual(t, expected, result)
// Case with google provider and no redirect URL
env = []string{"PROVIDERS_GOOGLE_CLIENT_ID=google-id", "PROVIDERS_GOOGLE_CLIENT_SECRET=google-secret"}
args = []string{"/tinyauth/tinyauth"}
expected = map[string]config.OAuthServiceConfig{
"google": {
ClientID: "google-id",
ClientSecret: "google-secret",
RedirectURL: "http://app.url/api/oauth/callback/google",
Name: "Google",
},
}
result, err = utils.GetOAuthProvidersConfig(env, args, "http://app.url")
assert.NilError(t, err)
assert.DeepEqual(t, expected, result)
}
func TestShoudLogJSON(t *testing.T) {
// Test with no env or args
result := utils.ShoudLogJSON([]string{"FOO=bar"}, []string{"tinyauth", "--foo-bar=baz"})
assert.Equal(t, false, result)
// Test with env variable set
result = utils.ShoudLogJSON([]string{"LOG_JSON=true"}, []string{"tinyauth", "--foo-bar=baz"})
assert.Equal(t, true, result)
// Test with flag set
result = utils.ShoudLogJSON([]string{"FOO=bar"}, []string{"tinyauth", "--log-json=true"})
assert.Equal(t, true, result)
// Test with both env and flag set to false
result = utils.ShoudLogJSON([]string{"LOG_JSON=false"}, []string{"tinyauth", "--log-json=false"})
assert.Equal(t, false, result)
}

View File

@@ -0,0 +1,80 @@
package decoders
import (
"reflect"
"strings"
"github.com/stoewer/go-strcase"
)
func normalizeKeys[T any](input map[string]string, root string, sep string) map[string]string {
knownKeys := getKnownKeys[T]()
normalized := make(map[string]string)
for k, v := range input {
parts := []string{"tinyauth"}
key := strings.ToLower(k)
key = strings.ReplaceAll(key, sep, "-")
if !strings.HasPrefix(key, root+"-") {
continue
}
suffix := ""
for _, known := range knownKeys {
if strings.HasSuffix(key, known) {
suffix = known
break
}
}
if suffix == "" {
continue
}
parts = append(parts, root)
id := strings.TrimPrefix(key, root+"-")
id = strings.TrimSuffix(id, "-"+suffix)
if id == "" {
continue
}
parts = append(parts, id)
parts = append(parts, suffix)
final := ""
for i, part := range parts {
if i > 0 {
final += "."
}
final += strcase.LowerCamelCase(part)
}
normalized[final] = v
}
return normalized
}
func getKnownKeys[T any]() []string {
var keys []string
var t T
v := reflect.ValueOf(t)
typeOfT := v.Type()
for field := range typeOfT.NumField() {
if typeOfT.Field(field).Tag.Get("field") != "" {
keys = append(keys, typeOfT.Field(field).Tag.Get("field"))
continue
}
keys = append(keys, strcase.KebabCase(typeOfT.Field(field).Name))
}
return keys
}

View File

@@ -0,0 +1,19 @@
package decoders
import (
"github.com/traefik/paerser/parser"
)
func DecodeEnv[T any, C any](env map[string]string, subName string) (T, error) {
var result T
normalized := normalizeKeys[C](env, subName, "_")
err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName)
if err != nil {
return result, err
}
return result, nil
}

View File

@@ -0,0 +1,37 @@
package decoders_test
import (
"testing"
"tinyauth/internal/config"
"tinyauth/internal/utils/decoders"
"gotest.tools/v3/assert"
)
func TestDecodeEnv(t *testing.T) {
// Setup
env := map[string]string{
"PROVIDERS_GOOGLE_CLIENT_ID": "google-client-id",
"PROVIDERS_GOOGLE_CLIENT_SECRET": "google-client-secret",
"PROVIDERS_MY_GITHUB_CLIENT_ID": "github-client-id",
"PROVIDERS_MY_GITHUB_CLIENT_SECRET": "github-client-secret",
}
expected := config.Providers{
Providers: map[string]config.OAuthServiceConfig{
"google": {
ClientID: "google-client-id",
ClientSecret: "google-client-secret",
},
"myGithub": {
ClientID: "github-client-id",
ClientSecret: "github-client-secret",
},
},
}
// Execute
result, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](env, "providers")
assert.NilError(t, err)
assert.DeepEqual(t, result, expected)
}

View File

@@ -0,0 +1,30 @@
package decoders
import (
"strings"
"github.com/traefik/paerser/parser"
)
func DecodeFlags[T any, C any](flags map[string]string, subName string) (T, error) {
var result T
filtered := filterFlags(flags)
normalized := normalizeKeys[C](filtered, subName, "_")
err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName)
if err != nil {
return result, err
}
return result, nil
}
func filterFlags(flags map[string]string) map[string]string {
filtered := make(map[string]string)
for k, v := range flags {
filtered[strings.TrimPrefix(k, "--")] = v
}
return filtered
}

View File

@@ -0,0 +1,37 @@
package decoders_test
import (
"testing"
"tinyauth/internal/config"
"tinyauth/internal/utils/decoders"
"gotest.tools/v3/assert"
)
func TestDecodeFlags(t *testing.T) {
// Setup
flags := map[string]string{
"--providers-google-client-id": "google-client-id",
"--providers-google-client-secret": "google-client-secret",
"--providers-my-github-client-id": "github-client-id",
"--providers-my-github-client-secret": "github-client-secret",
}
expected := config.Providers{
Providers: map[string]config.OAuthServiceConfig{
"google": {
ClientID: "google-client-id",
ClientSecret: "google-client-secret",
},
"myGithub": {
ClientID: "github-client-id",
ClientSecret: "github-client-secret",
},
},
}
// Execute
result, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flags, "providers")
assert.NilError(t, err)
assert.DeepEqual(t, result, expected)
}

View File

@@ -1,17 +1,19 @@
package decoders
import (
"tinyauth/internal/config"
"github.com/traefik/paerser/parser"
)
func DecodeLabels[T any](labels map[string]string, root string) (T, error) {
var labelsDecoded T
func DecodeLabels(labels map[string]string) (config.Apps, error) {
var appLabels config.Apps
err := parser.Decode(labels, &labelsDecoded, "tinyauth", "tinyauth."+root)
err := parser.Decode(labels, &appLabels, "tinyauth", "tinyauth.apps")
if err != nil {
return labelsDecoded, err
return config.Apps{}, err
}
return labelsDecoded, nil
return appLabels, nil
}

View File

@@ -2,9 +2,8 @@ package decoders_test
import (
"testing"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/decoders"
"tinyauth/internal/config"
"tinyauth/internal/utils/decoders"
"gotest.tools/v3/assert"
)
@@ -63,7 +62,7 @@ func TestDecodeLabels(t *testing.T) {
}
// Test
result, err := decoders.DecodeLabels[config.Apps](test, "apps")
result, err := decoders.DecodeLabels(test)
assert.NilError(t, err)
assert.DeepEqual(t, expected, result)
}

View File

@@ -2,8 +2,7 @@ package utils_test
import (
"testing"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/utils"
"gotest.tools/v3/assert"
)

View File

@@ -1,26 +0,0 @@
package loaders
import (
"fmt"
"os"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/traefik/paerser/cli"
"github.com/traefik/paerser/env"
)
type EnvLoader struct{}
func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
vars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration)
if len(vars) == 0 {
return false, nil
}
if err := env.Decode(vars, config.DefaultNamePrefix, cmd.Configuration); err != nil {
return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err)
}
return true, nil
}

View File

@@ -1,42 +0,0 @@
package loaders
import (
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli"
"github.com/traefik/paerser/file"
"github.com/traefik/paerser/flag"
)
type FileLoader struct{}
func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
flags, err := flag.Parse(args, cmd.Configuration)
if err != nil {
return false, err
}
// Check for experimental config file flag (supports both traefik.* and direct format)
// Note: paerser converts flags to lowercase, so we check lowercase versions
configFilePath := ""
if val, ok := flags["traefik.experimental.configfile"]; ok {
configFilePath = val
} else if val, ok := flags["experimental.configfile"]; ok {
configFilePath = val
}
if configFilePath == "" {
return false, nil
}
log.Warn().Str("configFile", configFilePath).Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases")
err = file.Decode(configFilePath, cmd.Configuration)
if err != nil {
log.Error().Err(err).Str("configFile", configFilePath).Msg("Failed to decode config file")
return false, err
}
return true, nil
}

View File

@@ -1,22 +0,0 @@
package loaders
import (
"fmt"
"github.com/traefik/paerser/cli"
"github.com/traefik/paerser/flag"
)
type FlagLoader struct{}
func (*FlagLoader) Load(args []string, cmd *cli.Command) (bool, error) {
if len(args) == 0 {
return false, nil
}
if err := flag.Decode(args, cmd.Configuration); err != nil {
return false, fmt.Errorf("failed to decode configuration from flags: %w", err)
}
return true, nil
}

View File

@@ -3,8 +3,7 @@ package utils_test
import (
"os"
"testing"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/utils"
"gotest.tools/v3/assert"
)

View File

@@ -2,8 +2,7 @@ package utils_test
import (
"testing"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/utils"
"gotest.tools/v3/assert"
)

View File

@@ -3,8 +3,7 @@ package utils
import (
"errors"
"strings"
"github.com/steveiliop56/tinyauth/internal/config"
"tinyauth/internal/config"
)
func ParseUsers(users string) ([]config.User, error) {

View File

@@ -3,8 +3,7 @@ package utils_test
import (
"os"
"testing"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/utils"
"gotest.tools/v3/assert"
)

19
main.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"os"
"time"
"tinyauth/cmd"
"tinyauth/internal/utils"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func main() {
log.Logger = log.Logger.With().Caller().Logger()
if !utils.ShoudLogJSON(os.Environ(), os.Args) {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
}
cmd.Run()
}

View File

@@ -1,14 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir requests authlib
COPY oidc_whoami.py /app/oidc_whoami.py
RUN chmod +x /app/oidc_whoami.py
EXPOSE 8765
CMD ["python3", "/app/oidc_whoami.py"]

View File

@@ -1,181 +0,0 @@
# OIDC Validation Setup
This directory contains a docker-compose setup for testing tinyauth's OIDC provider functionality with a minimal test client.
## Setup
1. **Build the OIDC test client image:**
```bash
docker build -t oidc-whoami-test:latest .
```
2. **Start the services:**
```bash
docker compose up --build
```
## Services
### nginx
- **Purpose:** Reverse proxy for `auth.example.com` → tinyauth
- **Ports:** 80 (exposed to host)
- **Access:** http://auth.example.com/ (via nginx on port 80)
### dns
- **Purpose:** DNS server (dnsmasq) that resolves `auth.example.com` to the tinyauth container
- **Configuration:** Resolves `auth.example.com` to the `tinyauth` container IP (172.28.0.20) within the Docker network
- **Ports:** 53 (UDP/TCP) - not exposed to host (only for container-to-container communication)
### tinyauth
- **URL:** http://auth.example.com/ (via nginx)
- **Credentials:** `user` / `pass`
- **OIDC Discovery:** http://auth.example.com/api/.well-known/openid-configuration
- **OIDC Client ID:** `testclient`
- **OIDC Client Secret:** `test-secret-123`
- **Ports:** Not exposed to host (accessed via nginx on port 80)
### oidc-whoami
- **Callback URL:** http://localhost:8765/callback
- **Purpose:** Minimal OIDC test client that validates the OIDC flow
- **Ports:** 8765 (exposed to host)
## Quick Start
1. **Start all services:**
```bash
docker compose up --build -d
```
2. **Launch Chrome with host-resolver-rules:**
```bash
./launch-chrome-host.sh
```
Or manually:
```bash
google-chrome \
--host-resolver-rules="MAP auth.example.com 127.0.0.1" \
--disable-features=HttpsOnlyMode \
--unsafely-treat-insecure-origin-as-secure=http://auth.example.com \
--user-data-dir=/tmp/chrome-test-profile \
http://auth.example.com/
```
**Note:** The `--user-data-dir` flag uses a temporary profile to avoid HSTS (HTTP Strict Transport Security) issues that might force HTTPS redirects.
3. **Access tinyauth:** http://auth.example.com/
- Login with: `user` / `pass`
4. **Test OIDC flow:**
```bash
# Get authorization URL from oidc-whoami logs
docker compose logs oidc-whoami | grep "Authorization URL"
# Open that URL in Chrome (already configured with host-resolver-rules)
```
## Connecting from Chrome/Browser
Since the DNS server is only accessible within the Docker network, you have several options to access `auth.example.com` from your browser:
### Option 1: Use /etc/hosts (Simplest)
Add this line to your `/etc/hosts` file (or `C:\Windows\System32\drivers\etc\hosts` on Windows):
```
127.0.0.1 auth.example.com
```
Then access: http://auth.example.com/
**To edit /etc/hosts on Linux/Mac:**
```bash
sudo nano /etc/hosts
# Add: 127.0.0.1 auth.example.com
```
**To edit hosts on Windows:**
1. Open Notepad as Administrator
2. Open `C:\Windows\System32\drivers\etc\hosts`
3. Add: `127.0.0.1 auth.example.com`
### Option 2: Use Chrome's `--host-resolver-rules` (Chrome-specific, No System Changes)
Chrome has a command-line flag that lets you map hostnames directly, bypassing DNS entirely. This is perfect for testing without modifying system settings.
**To use it:**
1. **Make sure services are running:**
```bash
docker compose up -d
```
2. **Launch Chrome with the host resolver rule:**
**Linux:**
```bash
google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1"
```
**Mac:**
```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--host-resolver-rules="MAP auth.example.com 127.0.0.1"
```
**Windows:**
```cmd
"C:\Program Files\Google\ Chrome\Application\chrome.exe" --host-resolver-rules="MAP auth.example.com 127.0.0.1"
```
3. **Or modify Chrome's shortcut:**
- Right-click Chrome shortcut → Properties
- In "Target" field, append: ` --host-resolver-rules="MAP auth.example.com 127.0.0.1"`
- Click OK
4. **Access:** http://auth.example.com/
**Note:** This only affects Chrome, not other applications. The DNS server on port 5353 isn't needed for this approach.
### Option 3: Use System DNS (All Applications)
If you want to use the DNS server on port 5353 for all applications (not just Chrome), configure your system DNS:
**Linux (with systemd-resolved):**
```bash
# Configure systemd-resolved to use our DNS
sudo resolvectl dns lo 127.0.0.1:5353
```
**Linux (without systemd-resolved):**
```bash
# Edit /etc/resolv.conf
sudo nano /etc/resolv.conf
# Add: nameserver 127.0.0.1
# Note: This won't work with port 5353, you'd need port 53
```
**Note:** Most systems expect DNS on port 53. To use port 5353, you'd need a DNS proxy or configure Chrome specifically (see Option 2 above).
## Testing
1. Start the services with `docker compose up --build -d`
2. Launch Chrome: `./launch-chrome-host.sh` (or use `--host-resolver-rules` manually)
3. Navigate to: http://auth.example.com/
4. Login with `user` / `pass`
5. Test the OIDC flow by accessing the discovery endpoint: http://auth.example.com/api/.well-known/openid-configuration
## Configuration
The tinyauth configuration is in `config.yaml`:
- OIDC is enabled
- Single user: `user` with password `pass`
- OIDC client `testclient` is configured with redirect URI `http://localhost:8765/callback`
- App URL and OIDC issuer: `http://auth.example.com` (via nginx on port 80)
## Notes
- All containers are on a custom Docker network (`tinyauth-network`) with a DNS server for domain resolution
- The DNS server resolves `auth.example.com` to the tinyauth container within the network
- The redirect URI must match exactly what's configured in tinyauth
- Data is persisted in the `./data` directory
- The domain `auth.example.com` is used to satisfy cookie domain validation requirements (needs at least 3 domain parts and not in public suffix list)

View File

@@ -1,36 +0,0 @@
appUrl: "http://auth.example.com"
logLevel: "info"
databasePath: "/data/tinyauth.db"
auth:
users: "user:$2b$12$mWEdxub8KTTBLK/f7dloKOS4t3kIeLOpme5pMXci5.lXNPANjCT5u" # user:pass
secureCookie: false
sessionExpiry: 3600
loginTimeout: 300
loginMaxRetries: 3
oidc:
enabled: true
issuer: "http://auth.example.com"
accessTokenExpiry: 3600
idTokenExpiry: 3600
clients:
testclient:
clientSecret: "test-secret-123"
clientName: "OIDC Test Client"
redirectUris:
- "http://client.example.com/callback"
- "http://localhost:8765/callback"
- "http://127.0.0.1:8765/callback"
grantTypes:
- "authorization_code"
responseTypes:
- "code"
scopes:
- "openid"
- "profile"
- "email"
ui:
title: "Tinyauth OIDC Test"

View File

@@ -1,91 +0,0 @@
version: '3.8'
services:
dns:
container_name: dns-server
image: strm/dnsmasq:latest
cap_add:
- NET_ADMIN
command:
- "--no-daemon"
- "--log-queries"
- "--no-resolv"
- "--server=8.8.8.8"
- "--server=8.8.4.4"
- "--address=/auth.example.com/172.28.0.2"
- "--address=/client.example.com/172.28.0.2"
# DNS port not exposed to host - only needed for container-to-container communication
# Chrome uses --host-resolver-rules instead
networks:
tinyauth-network:
ipv4_address: 172.28.0.10
nginx:
container_name: nginx-proxy
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- tinyauth-network
# Use Docker's built-in DNS (127.0.0.11) for service name resolution
# Our custom DNS (172.28.0.10) is only used via resolver directive in nginx.conf
depends_on:
- tinyauth
- dns
- oidc-whoami
tinyauth:
container_name: tinyauth-oidc-test
build:
context: ..
dockerfile: Dockerfile
command: ["--experimental.configfile=/config/config.yaml"]
# Port not exposed to host - accessed via nginx
volumes:
- ./data:/data
- ./config.yaml:/config/config.yaml:ro
networks:
tinyauth-network:
ipv4_address: 172.28.0.20
depends_on:
- dns
healthcheck:
test: ["CMD", "tinyauth", "healthcheck"]
interval: 10s
timeout: 5s
retries: 3
oidc-whoami:
container_name: oidc-whoami-test
build:
context: .
dockerfile: Dockerfile
environment:
- OIDC_ISSUER=http://auth.example.com
- CLIENT_ID=testclient
- CLIENT_SECRET=test-secret-123
# Port not exposed to host - accessed via nginx
depends_on:
- tinyauth
- dns
# Use Docker's built-in DNS first, then our custom DNS for custom domains
dns:
- 127.0.0.11
- 172.28.0.10
networks:
tinyauth-network:
ipv4_address: 172.28.0.30
# Note: Using custom network with DNS server to resolve auth.example.test
# The redirect URI must match what's configured in tinyauth (http://localhost:8765/callback)
# Using auth.example.test domain to satisfy cookie domain validation requirements (needs 3+ parts, not in public suffix list)
networks:
tinyauth-network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16

View File

@@ -1,39 +0,0 @@
#!/bin/bash
# Launch Chrome from host (not in container)
# This script should be run on your host machine
set -e
echo "Launching Chrome for OIDC test setup..."
# Detect Chrome
if command -v google-chrome &> /dev/null; then
CHROME_CMD="google-chrome"
elif command -v chromium-browser &> /dev/null; then
CHROME_CMD="chromium-browser"
elif command -v chromium &> /dev/null; then
CHROME_CMD="chromium"
elif [ -f "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then
CHROME_CMD="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
else
echo "Error: Chrome not found. Please install Google Chrome or Chromium."
exit 1
fi
echo "Using: $CHROME_CMD"
echo "Opening: http://client.example.com/ (OIDC test client)"
echo ""
$CHROME_CMD \
--host-resolver-rules="MAP auth.example.com 127.0.0.1, MAP client.example.com 127.0.0.1" \
--disable-features=HttpsOnlyMode \
--unsafely-treat-insecure-origin-as-secure=http://auth.example.com,http://client.example.com \
--user-data-dir=/tmp/chrome-test-profile-$(date +%s) \
--new-window \
http://client.example.com/ \
> /dev/null 2>&1 &
echo "Chrome launched!"
echo "OIDC test client: http://client.example.com/"
echo "Tinyauth: http://auth.example.com/"

View File

@@ -1,68 +0,0 @@
#!/bin/bash
set -e
echo "=========================================="
echo "Chrome Launcher for OIDC Test Setup"
echo "=========================================="
# Wait for nginx to be ready
echo "Waiting for nginx to be ready..."
for i in {1..30}; do
if curl -s http://127.0.0.1/ > /dev/null 2>&1; then
echo "✓ Nginx is ready"
break
fi
if [ $i -eq 30 ]; then
echo "✗ Nginx not ready after 30 seconds"
exit 1
fi
sleep 1
done
# Try to find Chrome on the host system
# Since we're in a container, we need to check common locations
CHROME_PATHS=(
"/usr/bin/google-chrome"
"/usr/bin/google-chrome-stable"
"/usr/bin/chromium-browser"
"/usr/bin/chromium"
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
)
CHROME_CMD=""
for path in "${CHROME_PATHS[@]}"; do
if [ -f "$path" ] || command -v "$(basename "$path")" &> /dev/null; then
CHROME_CMD="$(basename "$path")"
break
fi
done
if [ -z "$CHROME_CMD" ]; then
echo ""
echo "Chrome not found in container. This is expected."
echo "Please launch Chrome manually on your host with:"
echo ""
echo ' google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1" http://auth.example.com/'
echo ""
echo "Or use the launch script on your host:"
echo " ./launch-chrome.sh"
echo ""
exit 0
fi
echo "Found Chrome: $CHROME_CMD"
echo "Launching Chrome with host-resolver-rules..."
echo ""
$CHROME_CMD \
--host-resolver-rules="MAP auth.example.com 127.0.0.1" \
--new-window \
http://auth.example.com/ \
> /dev/null 2>&1 &
echo "✓ Chrome launched!"
echo ""
echo "Access tinyauth at: http://auth.example.com/"
echo "OIDC test client callback: http://127.0.0.1:8765/callback"
echo ""

View File

@@ -1,43 +0,0 @@
events {
worker_connections 1024;
}
http {
# Use Docker's built-in DNS (127.0.0.11) for service name resolution
# This allows nginx to resolve Docker service names like "tinyauth" and "oidc-whoami"
resolver 127.0.0.11 valid=10s;
resolver_timeout 5s;
server {
listen 80;
server_name auth.example.com;
location / {
# Use variable to enable dynamic resolution at request time
set $backend "tinyauth:3000";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
server {
listen 80;
server_name client.example.com;
location / {
# Use variable to enable dynamic resolution at request time
set $backend "oidc-whoami:8765";
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
}

View File

@@ -1,298 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import json
import html
import webbrowser
import secrets
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from http.cookies import SimpleCookie
import requests
from authlib.integrations.requests_client import OAuth2Session
from authlib.oidc.core import CodeIDToken
from authlib.jose import jwt
# ---- config via env ----
ISSUER = os.environ["OIDC_ISSUER"]
CLIENT_ID = os.environ["CLIENT_ID"]
CLIENT_SECRET= os.environ.get("CLIENT_SECRET") # optional (public clients ok)
REDIRECT_URI = "http://client.example.com/callback"
SCOPE = "openid profile email"
# ---- discovery ----
# Retry discovery in case nginx isn't ready yet
discovery = None
for attempt in range(10):
try:
discovery = requests.get(
f"{ISSUER.rstrip('/')}/api/.well-known/openid-configuration",
timeout=5
).json()
break
except Exception as e:
if attempt < 9:
print(f"Discovery attempt {attempt + 1} failed: {e}, retrying...")
time.sleep(2)
else:
raise
if discovery is None:
raise RuntimeError("Failed to fetch OIDC discovery document after 10 attempts")
state = secrets.token_urlsafe(16)
nonce = secrets.token_urlsafe(16)
client = OAuth2Session(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
scope=SCOPE,
redirect_uri=REDIRECT_URI,
)
auth_result = client.create_authorization_url(
discovery["authorization_endpoint"],
state=state,
nonce=nonce,
code_challenge_method="S256",
)
auth_url = auth_result[0]
code_verifier = auth_result[1] if len(auth_result) > 1 else None
# Cache JWKS for token validation
jwk_set_cache = None
jwk_set_cache_time = None
def get_jwk_set():
"""Get JWKS with caching"""
global jwk_set_cache, jwk_set_cache_time
# Cache for 1 hour
if jwk_set_cache is None or (jwk_set_cache_time and time.time() - jwk_set_cache_time > 3600):
jwk_set_cache = requests.get(discovery["jwks_uri"]).json()
jwk_set_cache_time = time.time()
return jwk_set_cache
def parse_cookies(cookie_header):
"""Parse cookies from Cookie header"""
if not cookie_header:
return {}
cookie = SimpleCookie()
cookie.load(cookie_header)
return {k: v.value for k, v in cookie.items()}
def validate_id_token(id_token):
"""Validate and decode ID token"""
try:
jwk_set = get_jwk_set()
claims_options = {
"iss": {"essential": True, "value": discovery["issuer"]},
"aud": {"essential": True, "value": CLIENT_ID},
}
decoded = jwt.decode(
id_token,
key=jwk_set,
claims_options=claims_options
)
decoded.validate()
return dict(decoded)
except Exception as e:
print(f"Token validation failed: {e}")
return None
# ---- tiny callback server ----
class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
# Handle root path - check if already logged in
if self.path == "/" or self.path == "":
cookies = parse_cookies(self.headers.get("Cookie"))
id_token = cookies.get("id_token")
# Check if we have a valid token
if id_token:
claims = validate_id_token(id_token)
if claims and claims.get("exp", 0) > time.time():
# Already logged in - show main page
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>OIDC Test Client - Welcome</title>
<style>
body {{
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}}
.main-box {{
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
h1 {{
color: #4285f4;
margin-top: 0;
}}
.user-info {{
background: #f9f9f9;
padding: 20px;
border-radius: 4px;
margin: 20px 0;
border-left: 4px solid #4285f4;
}}
pre {{
background: #f9f9f9;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
border: 1px solid #ddd;
}}
.logout-btn {{
display: inline-block;
padding: 10px 20px;
background: #dc3545;
color: white;
text-decoration: none;
border-radius: 4px;
margin-top: 20px;
}}
</style>
</head>
<body>
<div class="main-box">
<h1>✅ Welcome back!</h1>
<div class="user-info">
<h2>User Information</h2>
<p><strong>Username:</strong> {html.escape(str(claims.get('preferred_username', claims.get('sub', 'N/A'))))}</p>
<p><strong>Name:</strong> {html.escape(str(claims.get('name', 'N/A')))}</p>
<p><strong>Email:</strong> {html.escape(str(claims.get('email', 'N/A')))}</p>
</div>
<hr>
<h2>ID Token Claims:</h2>
<pre>{html.escape(json.dumps(claims, indent=2))}</pre>
<a href="/logout" class="logout-btn">Logout</a>
</div>
</body>
</html>
"""
self.wfile.write(html_content.encode())
return
# Not logged in - show login page
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
html_content = f"""
<!DOCTYPE html>
<html>
<head><title>OIDC Test Client</title></head>
<body>
<h1>OIDC Test Client</h1>
<p>Click the button below to start the OIDC flow:</p>
<a href="{auth_url}" style="display: inline-block; padding: 10px 20px; background: #4285f4; color: white; text-decoration: none; border-radius: 4px;">Login with OIDC</a>
<hr>
<p><small>Authorization URL: <code>{auth_url}</code></small></p>
</body>
</html>
"""
self.wfile.write(html_content.encode())
return
# Handle logout
if self.path == "/logout":
self.send_response(302)
self.send_header("Location", "/")
self.send_header("Set-Cookie", "id_token=; Path=/; Max-Age=0")
self.end_headers()
return
# Handle callback
if not self.path.startswith("/callback"):
self.send_error(404, "Not Found")
return
qs = parse_qs(urlparse(self.path).query)
if qs.get("state", [None])[0] != state:
self.send_error(400, "Invalid state")
return
code = qs.get("code", [None])[0]
if not code:
self.send_error(400, "Missing code")
return
token = client.fetch_token(
discovery["token_endpoint"],
code=code,
code_verifier=code_verifier,
)
# ---- ID token validation ----
# Decode and validate the ID token using cached JWKS
jwk_set = get_jwk_set()
# Decode the JWT - make nonce optional if not provided
claims_options = {
"iss": {"essential": True, "value": discovery["issuer"]},
"aud": {"essential": True, "value": CLIENT_ID},
}
if nonce:
claims_options["nonce"] = {"essential": True, "value": nonce}
decoded = jwt.decode(
token["id_token"],
key=jwk_set,
claims_options=claims_options
)
decoded.validate()
# Convert JWTClaims to dict for display
id_token_claims = dict(decoded)
# Store ID token in cookie (expires when token expires)
token_expiry = id_token_claims.get("exp", 0) - time.time()
max_age = max(0, int(token_expiry))
# Redirect to main page with cookie set
self.send_response(302)
self.send_header("Location", "/")
self.send_header("Set-Cookie", f"id_token={token['id_token']}; Path=/; Max-Age={max_age}; HttpOnly")
self.end_headers()
print("\n" + "=" * 60)
print("✅ OIDC Authentication Successful!")
print("=" * 60)
print("\nID Token Claims:")
print(json.dumps(id_token_claims, indent=2))
print("\n" + "=" * 60)
# Don't exit - keep server running for multiple test flows
# ---- run ----
print("=" * 60)
print("OIDC Test Client")
print("=" * 60)
print(f"\nAuthorization URL: {auth_url}")
print("\nTo test the OIDC flow:")
print("1. Open the authorization URL above in your browser")
print("2. Login with credentials: user / pass")
print("3. You will be redirected back to the callback")
print("4. The ID token claims will be displayed below")
print(f"\nWaiting for callback on {REDIRECT_URI}...")
print("=" * 60)
# Try to open browser (may fail in Docker, that's OK)
try:
webbrowser.open(auth_url)
except Exception as e:
print(f"Could not open browser automatically: {e}")
print("Please open the authorization URL manually")
HTTPServer(("0.0.0.0", 8765), CallbackHandler).serve_forever()