Compare commits

..

3 Commits

Author SHA1 Message Date
Stavros
7df60840ce fix: assign configured providers to app context 2025-12-14 19:34:45 +02:00
Stavros
2932aba750 chore: rename setup routes to setup router 2025-12-13 16:02:14 +02:00
Stavros
5cad1f0219 refactor: split bootstrap to smaller files for better readability 2025-12-13 15:58:41 +02:00
92 changed files with 1489 additions and 3673 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.4-alpine AS frontend-builder
WORKDIR /frontend
@@ -33,11 +33,12 @@ 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
@@ -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.4-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. Directory will be created if it doesn't exist."},
{"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

@@ -11,44 +11,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",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.7.3",
"i18next": "^25.7.2",
"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.556.0",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-i18next": "^16.5.0",
"react-i18next": "^16.4.0",
"react-markdown": "^10.1.0",
"react-router": "^7.11.0",
"react-router": "^7.10.1",
"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.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"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.49.0",
"vite": "^7.2.7",
},
},
},
@@ -93,57 +93,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 +155,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=="],
@@ -307,35 +305,35 @@
"@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=="],
@@ -365,7 +363,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.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
@@ -373,25 +371,25 @@
"@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.49.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/type-utils": "8.49.0", "@typescript-eslint/utils": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A=="],
"@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.49.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA=="],
"@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.49.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.49.0", "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g=="],
"@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.49.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA=="],
"@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.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/utils": "8.49.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-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg=="],
"@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.49.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.49.0", "@typescript-eslint/tsconfig-utils": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.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-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA=="],
"@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.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -493,17 +491,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=="],
@@ -589,7 +587,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.2", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-58b4kmLpLv1buWUEwegMDUqZVR5J+rT+WTRFaBGL7lxDuJQQ0NrJFrq+eT2N94aYVR1k1Sr13QITNOL88tZCuw=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
@@ -675,7 +673,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.556.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -791,13 +789,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.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="],
"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.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="],
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
"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.4.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-bxVeBA8Ky2UeItNhF4JRxHCFIrpEJHGFG/mOAa4CR0JkqaDEYSLmlEgmC4Os63SBlZ+E5U0YyrNJOSVl2mtVqQ=="],
"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 +805,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.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw=="],
"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 +849,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 +871,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.49.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.49.0", "@typescript-eslint/parser": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/utils": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -903,7 +901,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.7", "", { "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-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
@@ -915,7 +913,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 +967,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 +993,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.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0" } }, "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="],
"@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.49.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA=="],
"@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.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0" } }, "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="],
"@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.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@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.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@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.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@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.49.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA=="],
"@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.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1021,7 +1019,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.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"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 +1037,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.49.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA=="],
"@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 +1055,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.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0" } }, "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
@@ -1081,9 +1083,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.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0" } }, "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="],
"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.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@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",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.7.3",
"i18next": "^25.7.2",
"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.556.0",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-i18next": "^16.5.0",
"react-i18next": "^16.4.0",
"react-markdown": "^10.1.0",
"react-router": "^7.11.0",
"react-router": "^7.10.1",
"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.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"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.49.0",
"vite": "^7.2.7"
}
}

View File

@@ -2,6 +2,7 @@ import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import resourcesToBackend from "i18next-resources-to-backend";
import { languages } from "./locales";
i18n
.use(LanguageDetector)
@@ -15,6 +16,7 @@ i18n
fallbackLng: "en",
debug: import.meta.env.MODE === "development",
nonExplicitSupportedLngs: true,
supportedLngs: Object.keys(languages),
load: "currentOnly",
detection: {
lookupLocalStorage: "tinyauth-lang",

97
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.2
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/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.30.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/tools v0.39.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/oauth2 v0.34.0
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
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
)

99
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.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
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/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/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/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/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/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,11 +11,10 @@ import (
"sort"
"strings"
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/model"
"github.com/steveiliop56/tinyauth/internal/utils"
"tinyauth/internal/config"
"tinyauth/internal/controller"
"tinyauth/internal/model"
"tinyauth/internal/utils"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
@@ -44,7 +43,7 @@ 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
@@ -52,35 +51,14 @@ func (app *BootstrapApp) Setup() error {
app.context.users = users
// Setup OAuth providers
app.context.oauthProviders = app.config.OAuth.Providers
// Get OAuth configs
oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.config.AppURL)
for name, provider := range app.context.oauthProviders {
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
provider.ClientSecret = secret
provider.ClientSecretFile = ""
app.context.oauthProviders[name] = provider
if err != nil {
return err
}
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
}
app.context.oauthProviders = oauthProviders
// Get cookie domain
cookieDomain, err := utils.GetCookieDomain(app.config.AppURL)
@@ -120,7 +98,7 @@ func (app *BootstrapApp) Setup() error {
// 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,
@@ -166,17 +144,17 @@ func (app *BootstrapApp) Setup() error {
}
// 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 != "" {
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 {
log.Info().Msgf("Starting server on unix socket %s", app.config.SocketPath)
if err := router.RunUnix(app.config.SocketPath); err != nil {
log.Fatal().Err(err).Msg("Failed to start server")
}
@@ -184,7 +162,7 @@ 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 {
log.Fatal().Err(err).Msg("Failed to start server")
@@ -215,7 +193,7 @@ func (app *BootstrapApp) heartbeat() {
}
client := &http.Client{
Timeout: 30 * time.Second, // The server should never take more than 30 seconds to respond
Timeout: time.Duration(10) * time.Second, // The server should never take more than 10 seconds to respond
}
heartbeatURL := config.ApiServer + "/v1/instances/heartbeat"

View File

@@ -3,9 +3,8 @@ package bootstrap
import (
"fmt"
"strings"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/middleware"
"tinyauth/internal/controller"
"tinyauth/internal/middleware"
"github.com/gin-gonic/gin"
)
@@ -14,8 +13,8 @@ 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 len(app.config.TrustedProxies) > 0 {
err := engine.SetTrustedProxies(strings.Split(app.config.TrustedProxies, ","))
if err != nil {
return nil, fmt.Errorf("failed to set trusted proxies: %w", err)
@@ -58,12 +57,12 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
contextController := controller.NewContextController(controller.ContextControllerConfig{
Providers: app.context.configuredProviders,
Title: app.config.UI.Title,
Title: app.config.Title,
AppURL: app.config.AppURL,
CookieDomain: app.context.cookieDomain,
ForgotPasswordMessage: app.config.UI.ForgotPasswordMessage,
BackgroundImage: app.config.UI.BackgroundImage,
OAuthAutoRedirect: app.config.OAuth.AutoRedirect,
ForgotPasswordMessage: app.config.ForgotPasswordMessage,
BackgroundImage: app.config.BackgroundImage,
OAuthAutoRedirect: app.config.OAuthAutoRedirect,
DisableUIWarnings: app.config.DisableUIWarnings,
}, apiRouter)
@@ -71,7 +70,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
AppURL: app.config.AppURL,
SecureCookie: app.config.Auth.SecureCookie,
SecureCookie: app.config.SecureCookie,
CSRFCookieName: app.context.csrfCookieName,
RedirectCookieName: app.context.redirectCookieName,
CookieDomain: app.context.cookieDomain,
@@ -102,15 +101,5 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
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,7 +1,7 @@
package bootstrap
import (
"github.com/steveiliop56/tinyauth/internal/service"
"tinyauth/internal/service"
"github.com/rs/zerolog/log"
)
@@ -13,7 +13,6 @@ type Services struct {
dockerService *service.DockerService
ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService
}
func (app *BootstrapApp) initServices() (Services, error) {
@@ -32,12 +31,12 @@ func (app *BootstrapApp) initServices() (Services, error) {
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,
Address: app.config.LdapAddress,
BindDN: app.config.LdapBindDN,
BindPassword: app.config.LdapBindPassword,
BaseDN: app.config.LdapBaseDN,
Insecure: app.config.LdapInsecure,
SearchFilter: app.config.LdapSearchFilter,
})
err = ldapService.Init()
@@ -70,12 +69,12 @@ func (app *BootstrapApp) initServices() (Services, error) {
authService := service.NewAuthService(service.AuthServiceConfig{
Users: app.context.users,
OauthWhitelist: app.config.OAuth.Whitelist,
SessionExpiry: app.config.Auth.SessionExpiry,
SecureCookie: app.config.Auth.SecureCookie,
OauthWhitelist: app.config.OAuthWhitelist,
SessionExpiry: app.config.SessionExpiry,
SecureCookie: app.config.SecureCookie,
CookieDomain: app.context.cookieDomain,
LoginTimeout: app.config.Auth.LoginTimeout,
LoginMaxRetries: app.config.Auth.LoginMaxRetries,
LoginTimeout: app.config.LoginTimeout,
LoginMaxRetries: app.config.LoginMaxRetries,
SessionCookieName: app.context.sessionCookieName,
}, dockerService, ldapService, databaseService.GetDatabase())
@@ -97,39 +96,5 @@ func (app *BootstrapApp) initServices() (Services, error) {
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"`
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

@@ -5,8 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"github.com/steveiliop56/tinyauth/internal/assets"
"tinyauth/internal/assets"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"

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()