Compare commits

..

1 Commits

Author SHA1 Message Date
GitHub
461d51928d docs: regenerate readme sponsors list 2025-12-31 09:33:39 +00:00
78 changed files with 797 additions and 4110 deletions

View File

@@ -2,6 +2,8 @@
# The base URL where Tinyauth is accessible # The base URL where Tinyauth is accessible
TINYAUTH_APPURL="https://auth.example.com" TINYAUTH_APPURL="https://auth.example.com"
# Log level: trace, debug, info, warn, error
TINYAUTH_LOGLEVEL="info"
# Directory for static resources # Directory for static resources
TINYAUTH_RESOURCESDIR="/data/resources" TINYAUTH_RESOURCESDIR="/data/resources"
# Path to SQLite database file # Path to SQLite database file
@@ -12,21 +14,8 @@ TINYAUTH_DISABLEANALYTICS="false"
TINYAUTH_DISABLERESOURCES="false" TINYAUTH_DISABLERESOURCES="false"
# Disable UI warning messages # Disable UI warning messages
TINYAUTH_DISABLEUIWARNINGS="false" TINYAUTH_DISABLEUIWARNINGS="false"
# Logging Configuration
# Log level: trace, debug, info, warn, error
TINYAUTH_LOG_LEVEL="info"
# Enable JSON formatted logs # Enable JSON formatted logs
TINYAUTH_LOG_JSON="false" TINYAUTH_LOGJSON="false"
# Specific Log stream configurations
# APP and HTTP log streams are enabled by default, and use the global log level unless overridden
TINYAUTH_LOG_STREAMS_APP_ENABLED="true"
TINYAUTH_LOG_STREAMS_APP_LEVEL="info"
TINYAUTH_LOG_STREAMS_HTTP_ENABLED="true"
TINYAUTH_LOG_STREAMS_HTTP_LEVEL="info"
TINYAUTH_LOG_STREAMS_AUDIT_ENABLED="false"
TINYAUTH_LOG_STREAMS_AUDIT_LEVEL="info"
# Server Configuration # Server Configuration
@@ -44,17 +33,15 @@ TINYAUTH_SERVER_TRUSTEDPROXIES=""
# Format: username:bcrypt_hash (use bcrypt to generate hash) # Format: username:bcrypt_hash (use bcrypt to generate hash)
TINYAUTH_AUTH_USERS="admin:$2a$10$example_bcrypt_hash_here" TINYAUTH_AUTH_USERS="admin:$2a$10$example_bcrypt_hash_here"
# Path to external users file (optional) # Path to external users file (optional)
TINYAUTH_AUTH_USERSFILE="" TINYAUTH_USERSFILE=""
# Enable secure cookies (requires HTTPS) # Enable secure cookies (requires HTTPS)
TINYAUTH_AUTH_SECURECOOKIE="true" TINYAUTH_SECURECOOKIE="true"
# Session expiry in seconds (7200 = 2 hours) # Session expiry in seconds (7200 = 2 hours)
TINYAUTH_AUTH_SESSIONEXPIRY="7200" TINYAUTH_SESSIONEXPIRY="7200"
# Session maximum lifetime in seconds (0 = unlimited)
TINYAUTH_AUTH_SESSIONMAXLIFETIME="0"
# Login timeout in seconds (300 = 5 minutes) # Login timeout in seconds (300 = 5 minutes)
TINYAUTH_AUTH_LOGINTIMEOUT="300" TINYAUTH_LOGINTIMEOUT="300"
# Maximum login retries before lockout # Maximum login retries before lockout
TINYAUTH_AUTH_LOGINMAXRETRIES="5" TINYAUTH_LOGINMAXRETRIES="5"
# OAuth Configuration # OAuth Configuration

View File

@@ -89,7 +89,7 @@ jobs:
- name: Build - name: Build
run: | run: |
cp -r frontend/dist internal/assets/dist cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/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 ./cmd/tinyauth
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0
@@ -144,7 +144,7 @@ jobs:
- name: Build - name: Build
run: | run: |
cp -r frontend/dist internal/assets/dist cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/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 ./cmd/tinyauth
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0

View File

@@ -67,7 +67,7 @@ jobs:
- name: Build - name: Build
run: | run: |
cp -r frontend/dist internal/assets/dist cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/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 ./cmd/tinyauth
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0
@@ -119,7 +119,7 @@ jobs:
- name: Build - name: Build
run: | run: |
cp -r frontend/dist internal/assets/dist cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/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 ./cmd/tinyauth
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0

10
.gitignore vendored
View File

@@ -3,8 +3,6 @@
# binaries # binaries
/tinyauth /tinyauth
/tinyauth-arm64
/tinyauth-amd64
# test docker compose # test docker compose
/docker-compose.test* /docker-compose.test*
@@ -24,6 +22,9 @@
# tmp directory # tmp directory
/tmp /tmp
# version files
/internal/assets/version
# data directory # data directory
/data /data
@@ -35,7 +36,4 @@
/resources /resources
# debug files # debug files
__debug_* __debug_*
# infisical
/.infisical.json

View File

@@ -1,13 +0,0 @@
[
{
"label": "Attach to remote Delve",
"adapter": "Delve",
"mode": "remote",
"remotePath": "/tinyauth",
"request": "attach",
"tcp_connection": {
"host": "127.0.0.1",
"port": 4000,
},
},
]

View File

@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM oven/bun:1.3.6-alpine AS frontend-builder FROM oven/bun:1.3.5-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -39,10 +39,7 @@ COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -ldflags "-s -w \ 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
-X github.com/steveiliop56/tinyauth/internal/config.Version=${VERSION} \
-X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
-X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
# Runner # Runner
FROM alpine:3.23 AS runner FROM alpine:3.23 AS runner
@@ -57,9 +54,11 @@ EXPOSE 3000
VOLUME ["/data"] VOLUME ["/data"]
ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db ENV DATABASEPATH=/data/tinyauth.db
ENV TINYAUTH_RESOURCESDIR=/data/resources ENV RESOURCESDIR=/data/resources
ENV GIN_MODE=release
ENV PATH=$PATH:/tinyauth ENV PATH=$PATH:/tinyauth

View File

@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM oven/bun:1.3.6-alpine AS frontend-builder FROM oven/bun:1.3.5-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -41,10 +41,7 @@ COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN mkdir -p data RUN mkdir -p data
RUN CGO_ENABLED=0 go build -ldflags "-s -w \ 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
-X github.com/steveiliop56/tinyauth/internal/config.Version=${VERSION} \
-X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
-X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
# Runner # Runner
FROM gcr.io/distroless/static-debian12:latest AS runner FROM gcr.io/distroless/static-debian12:latest AS runner
@@ -64,6 +61,8 @@ ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db
ENV TINYAUTH_RESOURCESDIR=/data/resources ENV TINYAUTH_RESOURCESDIR=/data/resources
ENV GIN_MODE=release
ENV PATH=$PATH:/tinyauth ENV PATH=$PATH:/tinyauth
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["tinyauth", "healthcheck"] HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["tinyauth", "healthcheck"]

View File

@@ -1,77 +0,0 @@
# Go specific stuff
CGO_ENABLED := 0
GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
# Build out
TAG_NAME := $(shell git describe --abbrev=0 --exact-match 2> /dev/null || echo "main")
COMMIT_HASH := $(shell git rev-parse HEAD)
BUILD_TIMESTAMP := $(shell date '+%Y-%m-%dT%H:%M:%S')
BIN_NAME := tinyauth-$(GOARCH)
# Development vars
DEV_COMPOSE := $(shell test -f "docker-compose.test.yml" && echo "docker-compose.test.yml" || echo "docker-compose.yml" )
PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-compose.test.prod.yml" || echo "docker-compose.example.yml" )
# Deps
deps:
bun install --cwd frontend
go mod download
# Clean data
clean-data:
rm -rf data/
# Clean web UI build
clean-webui:
rm -rf internal/assets/dist
rm -rf frontend/dist
# Build the web UI
webui: clean-webui
bun run --cwd frontend build
cp -r frontend/dist internal/assets
# Build the binary
binary: webui
CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "-s -w \
-X github.com/steveiliop56/tinyauth/internal/config.Version=${TAG_NAME} \
-X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
-X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" \
-o ${BIN_NAME} ./cmd/tinyauth
# Build for amd64
binary-linux-amd64:
export BIN_NAME=tinyauth-amd64
export GOARCH=amd64
export GOOS=linux
$(MAKE) binary
# Build for arm64
binary-linux-arm64:
export BIN_NAME=tinyauth-arm64
export GOARCH=arm64
export GOOS=linux
$(MAKE) binary
# Go test
.PHONY: test
test:
go test -v ./...
# Development
develop:
docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans
# Development - Infisical
develop-infisical:
infisical run --env=dev -- docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans
# Production
prod:
docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans
# SQL
.PHONY: sql
sql:
sqlc generate

View File

@@ -55,7 +55,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
A big thank you to the following people for providing me with more coffee: A big thank you to the following people for providing me with more coffee:
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/algorist-ahmad"><img src="https:&#x2F;&#x2F;github.com&#x2F;algorist-ahmad.png" width="64px" alt="User avatar: algorist-ahmad" /></a>&nbsp;&nbsp;<!-- sponsors --> <!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<!-- sponsors -->
## Acknowledgements ## Acknowledgements

View File

@@ -3,10 +3,13 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"time"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli" "github.com/traefik/paerser/cli"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -40,7 +43,7 @@ func createUserCmd() *cli.Command {
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
tlog.NewSimpleLogger().Init() log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
@@ -74,7 +77,7 @@ func createUserCmd() *cli.Command {
return errors.New("username and password cannot be empty") return errors.New("username and password cannot be empty")
} }
tlog.App.Info().Str("username", tCfg.Username).Msg("Creating user") log.Info().Str("username", tCfg.Username).Msg("Creating user")
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost) passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -87,7 +90,7 @@ func createUserCmd() *cli.Command {
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$") passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
} }
tlog.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created") log.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
return nil return nil
}, },

View File

@@ -5,13 +5,15 @@ import (
"fmt" "fmt"
"os" "os"
"strings" "strings"
"time"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/mdp/qrterminal/v3" "github.com/mdp/qrterminal/v3"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli" "github.com/traefik/paerser/cli"
) )
@@ -40,7 +42,7 @@ func generateTotpCmd() *cli.Command {
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
tlog.NewSimpleLogger().Init() log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
@@ -89,9 +91,9 @@ func generateTotpCmd() *cli.Command {
secret := key.Secret() secret := key.Secret()
tlog.App.Info().Str("secret", secret).Msg("Generated TOTP secret") log.Info().Str("secret", secret).Msg("Generated TOTP secret")
tlog.App.Info().Msg("Generated QR code") log.Info().Msg("Generated QR code")
config := qrterminal.Config{ config := qrterminal.Config{
Level: qrterminal.L, Level: qrterminal.L,
@@ -110,7 +112,7 @@ func generateTotpCmd() *cli.Command {
user.Password = strings.ReplaceAll(user.Password, "$", "$$") user.Password = strings.ReplaceAll(user.Password, "$", "$$")
} }
tlog.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.") 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 return nil
}, },

View File

@@ -9,7 +9,8 @@ import (
"os" "os"
"time" "time"
"github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli" "github.com/traefik/paerser/cli"
) )
@@ -26,7 +27,7 @@ func healthcheckCmd() *cli.Command {
Resources: nil, Resources: nil,
AllowArg: true, AllowArg: true,
Run: func(args []string) error { Run: func(args []string) error {
tlog.NewSimpleLogger().Init() log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
appUrl := os.Getenv("TINYAUTH_APPURL") appUrl := os.Getenv("TINYAUTH_APPURL")
@@ -38,7 +39,7 @@ func healthcheckCmd() *cli.Command {
return errors.New("TINYAUTH_APPURL is not set and no argument was provided") return errors.New("TINYAUTH_APPURL is not set and no argument was provided")
} }
tlog.App.Info().Str("app_url", appUrl).Msg("Performing health check") log.Info().Str("app_url", appUrl).Msg("Performing health check")
client := http.Client{ client := http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
@@ -76,7 +77,7 @@ func healthcheckCmd() *cli.Command {
return fmt.Errorf("failed to decode response: %w", err) return fmt.Errorf("failed to decode response: %w", err)
} }
tlog.App.Info().Interface("response", healthResp).Msg("Tinyauth is healthy") log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
return nil return nil
}, },

View File

@@ -2,18 +2,22 @@ package main
import ( import (
"fmt" "fmt"
"os"
"strings"
"time"
"github.com/steveiliop56/tinyauth/internal/bootstrap" "github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/loaders" "github.com/steveiliop56/tinyauth/internal/utils/loaders"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli" "github.com/traefik/paerser/cli"
) )
func NewTinyauthCmdConfiguration() *config.Config { func NewTinyauthCmdConfiguration() *config.Config {
return &config.Config{ return &config.Config{
LogLevel: "info",
ResourcesDir: "./resources", ResourcesDir: "./resources",
DatabasePath: "./tinyauth.db", DatabasePath: "./tinyauth.db",
Server: config.ServerConfig{ Server: config.ServerConfig{
@@ -21,10 +25,9 @@ func NewTinyauthCmdConfiguration() *config.Config {
Address: "0.0.0.0", Address: "0.0.0.0",
}, },
Auth: config.AuthConfig{ Auth: config.AuthConfig{
SessionExpiry: 86400, // 1 day SessionExpiry: 3600,
SessionMaxLifetime: 0, // disabled LoginTimeout: 300,
LoginTimeout: 300, // 5 minutes LoginMaxRetries: 3,
LoginMaxRetries: 3,
}, },
UI: config.UIConfig{ UI: config.UIConfig{
Title: "Tinyauth", Title: "Tinyauth",
@@ -32,31 +35,8 @@ func NewTinyauthCmdConfiguration() *config.Config {
BackgroundImage: "/background.jpg", BackgroundImage: "/background.jpg",
}, },
Ldap: config.LdapConfig{ Ldap: config.LdapConfig{
Insecure: false, Insecure: false,
SearchFilter: "(uid=%s)", SearchFilter: "(uid=%s)",
GroupCacheTTL: 900, // 15 minutes
},
Log: config.LogConfig{
Level: "info",
Json: false,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{
Enabled: true,
Level: "",
},
App: config.LogStreamConfig{
Enabled: true,
Level: "",
},
Audit: config.LogStreamConfig{
Enabled: false,
Level: "",
},
},
},
OIDC: config.OIDCConfig{
PrivateKeyPath: "./tinyauth_oidc_key",
PublicKeyPath: "./tinyauth_oidc_key.pub",
}, },
Experimental: config.ExperimentalConfig{ Experimental: config.ExperimentalConfig{
ConfigFile: "", ConfigFile: "",
@@ -121,14 +101,25 @@ func main() {
} }
func runCmd(cfg config.Config) error { func runCmd(cfg config.Config) error {
logger := tlog.NewLogger(cfg.Log) logLevel, err := zerolog.ParseLevel(strings.ToLower(cfg.LogLevel))
logger.Init()
tlog.App.Info().Str("version", config.Version).Msg("Starting tinyauth") 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) app := bootstrap.NewBootstrapApp(cfg)
err := app.Setup() err = app.Setup()
if err != nil { if err != nil {
return fmt.Errorf("failed to bootstrap app: %w", err) return fmt.Errorf("failed to bootstrap app: %w", err)

View File

@@ -3,12 +3,15 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"time"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/paerser/cli" "github.com/traefik/paerser/cli"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -44,7 +47,7 @@ func verifyUserCmd() *cli.Command {
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
tlog.NewSimpleLogger().Init() log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
@@ -98,9 +101,9 @@ func verifyUserCmd() *cli.Command {
if user.TotpSecret == "" { if user.TotpSecret == "" {
if tCfg.Totp != "" { if tCfg.Totp != "" {
tlog.App.Warn().Msg("User does not have TOTP secret") log.Warn().Msg("User does not have TOTP secret")
} }
tlog.App.Info().Msg("User verified") log.Info().Msg("User verified")
return nil return nil
} }
@@ -110,7 +113,7 @@ func verifyUserCmd() *cli.Command {
return fmt.Errorf("TOTP code incorrect") return fmt.Errorf("TOTP code incorrect")
} }
tlog.App.Info().Msg("User verified") log.Info().Msg("User verified")
return nil return nil
}, },

View File

@@ -2,6 +2,8 @@
# The base URL where Tinyauth is accessible # The base URL where Tinyauth is accessible
appUrl: "https://auth.example.com" appUrl: "https://auth.example.com"
# Log level: trace, debug, info, warn, error
logLevel: "info"
# Directory for static resources # Directory for static resources
resourcesDir: "./resources" resourcesDir: "./resources"
# Path to SQLite database file # Path to SQLite database file
@@ -12,22 +14,8 @@ disableAnalytics: false
disableResources: false disableResources: false
# Disable UI warning messages # Disable UI warning messages
disableUIWarnings: false disableUIWarnings: false
# Enable JSON formatted logs
# Logging Configuration logJSON: false
log:
# Log level: trace, debug, info, warn, error
level: "info"
json: false
streams:
app:
enabled: true
level: "warn"
http:
enabled: true
level: "debug"
audit:
enabled: false
level: "info"
# Server Configuration # Server Configuration
server: server:
@@ -50,8 +38,6 @@ auth:
secureCookie: false secureCookie: false
# Session expiry in seconds (3600 = 1 hour) # Session expiry in seconds (3600 = 1 hour)
sessionExpiry: 3600 sessionExpiry: 3600
# Session maximum lifetime in seconds (0 = unlimited)
sessionMaxLifetime: 0
# Login timeout in seconds (300 = 5 minutes) # Login timeout in seconds (300 = 5 minutes)
loginTimeout: 300 loginTimeout: 300
# Maximum login retries before lockout # Maximum login retries before lockout

View File

@@ -1,7 +1,7 @@
services: services:
traefik: traefik:
container_name: traefik container_name: traefik
image: traefik:v3.6 image: traefik:v3.3
command: --api.insecure=true --providers.docker command: --api.insecure=true --providers.docker
ports: ports:
- 80:80 - 80:80
@@ -50,4 +50,3 @@ services:
labels: labels:
traefik.enable: true traefik.enable: true
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth-backend:3000/api/auth/traefik traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth-backend:3000/api/auth/traefik
traefik.http.middlewares.tinyauth.forwardauth.authResponseHeaders: remote-user, remote-sub, remote-name, remote-email, remote-groups

View File

@@ -1,7 +1,7 @@
services: services:
traefik: traefik:
container_name: traefik container_name: traefik
image: traefik:v3.6 image: traefik:v3.3
command: --api.insecure=true --providers.docker command: --api.insecure=true --providers.docker
ports: ports:
- 80:80 - 80:80

View File

@@ -12,11 +12,11 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.15",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^25.7.4", "i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@@ -24,31 +24,31 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.69.0",
"react-i18next": "^16.5.3", "react-i18next": "^16.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.12.0", "react-router": "^7.11.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"zod": "^4.3.5", "zod": "^4.2.1",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/eslint-plugin-query": "^5.91.2",
"@types/node": "^25.0.9", "@types/node": "^25.0.3",
"@types/react": "^19.2.8", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.0.0", "globals": "^16.5.0",
"prettier": "3.8.0", "prettier": "3.7.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.53.0", "typescript-eslint": "^8.51.0",
"vite": "^7.3.1", "vite": "^7.3.0",
}, },
}, },
}, },
@@ -339,9 +339,9 @@
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="], "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.15", "", {}, "sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="], "@tanstack/react-query": ["@tanstack/react-query@5.90.15", "", { "dependencies": { "@tanstack/query-core": "5.90.15" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uQvnDDcTOgJouNtAyrgRej+Azf0U5WDov3PXmHFUBc+t1INnAYhIlpZtCGNBLwCN41b43yO7dPNZu8xWkUFBwQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -365,33 +365,33 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/type-utils": "8.53.0", "@typescript-eslint/utils": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.53.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.53.0", "@typescript-eslint/types": "^8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg=="], "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.51.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.51.0", "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ=="],
"@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/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.53.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA=="], "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.51.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/utils": "8.53.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw=="], "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.46.1", "", {}, "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.46.1", "", {}, "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.53.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.53.0", "@typescript-eslint/tsconfig-utils": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw=="], "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.51.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.51.0", "@typescript-eslint/tsconfig-utils": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng=="],
"@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/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.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -563,7 +563,7 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.0.0", "", {}, "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
@@ -589,7 +589,7 @@
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"i18next": ["i18next@25.7.4", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw=="], "i18next": ["i18next@25.7.3", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="], "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
@@ -781,7 +781,7 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.0", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA=="], "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
@@ -795,9 +795,9 @@
"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.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], "react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="],
"react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "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-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="], "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-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=="], "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 +807,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-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.12.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="], "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-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=="], "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=="],
@@ -863,7 +863,7 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ts-api-utils": ["ts-api-utils@2.3.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -873,7 +873,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.53.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/utils": "8.53.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw=="], "typescript-eslint": ["typescript-eslint@8.51.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.51.0", "@typescript-eslint/parser": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -903,7 +903,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=="], "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.1", "", { "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-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "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=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
@@ -915,7 +915,7 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
@@ -995,43 +995,33 @@
"@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], "@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0" } }, "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.53.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0" } }, "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g=="], "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="], "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="],
"@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@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/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.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="], "@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.53.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA=="], "@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
"@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="],
"@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@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/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.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="], "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"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=="], "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=="],
@@ -1049,7 +1039,7 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.53.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA=="], "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
"@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=="], "@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=="],
@@ -1067,15 +1057,11 @@
"@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="], "@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.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="], "@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0" } }, "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
@@ -1097,28 +1083,20 @@
"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=="], "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/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0" } }, "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g=="], "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="],
"@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=="], "@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=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"eslint-plugin-react-hooks/@babel/core/@babel/generator/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="], "eslint-plugin-react-hooks/@babel/core/@babel/generator/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="],
"eslint-plugin-react-hooks/@babel/core/@babel/generator/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "eslint-plugin-react-hooks/@babel/core/@babel/generator/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],

View File

@@ -18,11 +18,11 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.15",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^25.7.4", "i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@@ -30,30 +30,30 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.69.0",
"react-i18next": "^16.5.3", "react-i18next": "^16.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.12.0", "react-router": "^7.11.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"zod": "^4.3.5" "zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/eslint-plugin-query": "^5.91.2",
"@types/node": "^25.0.9", "@types/node": "^25.0.3",
"@types/react": "^19.2.8", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.0.0", "globals": "^16.5.0",
"prettier": "3.8.0", "prettier": "3.7.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.53.0", "typescript-eslint": "^8.51.0",
"vite": "^7.3.1" "vite": "^7.3.0"
} }
} }

View File

@@ -159,10 +159,6 @@ code {
@apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all; @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all;
} }
pre {
@apply bg-accent border border-border rounded-md p-2;
}
.lead { .lead {
@apply text-xl text-muted-foreground; @apply text-xl text-muted-foreground;
} }

View File

@@ -1,53 +0,0 @@
export type OIDCValues = {
scope: string;
response_type: string;
client_id: string;
redirect_uri: string;
state: string;
};
interface IuseOIDCParams {
values: OIDCValues;
compiled: string;
isOidc: boolean;
missingParams: string[];
}
const optionalParams: string[] = ["state"];
export function useOIDCParams(params: URLSearchParams): IuseOIDCParams {
let compiled: string = "";
let isOidc = false;
const missingParams: string[] = [];
const values: OIDCValues = {
scope: params.get("scope") ?? "",
response_type: params.get("response_type") ?? "",
client_id: params.get("client_id") ?? "",
redirect_uri: params.get("redirect_uri") ?? "",
state: params.get("state") ?? "",
};
for (const key of Object.keys(values)) {
if (!values[key as keyof OIDCValues]) {
if (!optionalParams.includes(key)) {
missingParams.push(key);
}
}
}
if (missingParams.length === 0) {
isOidc = true;
}
if (isOidc) {
compiled = new URLSearchParams(values).toString();
}
return {
values,
compiled,
isOidc,
missingParams,
};
}

View File

@@ -51,29 +51,12 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitleInfo": "The following error occurred while processing your request:", "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required", "fieldRequired": "This field is required",
"invalidInput": "Invalid input", "invalidInput": "Invalid input",
"domainWarningTitle": "Invalid Domain", "domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.", "domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.",
"ignoreTitle": "Ignore", "ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Go to correct domain", "goToCorrectDomainTitle": "Go to correct domain"
"authorizeTitle": "Authorize", }
"authorizeCardTitle": "Continue to {{app}}?",
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
"authorizeLoadingTitle": "Loading...",
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
"authorizeSuccessTitle": "Authorized",
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
"openidScopeName": "OpenID Connect",
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
"emailScopeName": "Email",
"emailScopeDescription": "Allows the app to access your email address.",
"profileScopeName": "Profile",
"profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access the groups in which you are a member."
}

View File

@@ -51,29 +51,12 @@
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitleInfo": "The following error occurred while processing your request:", "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required", "fieldRequired": "This field is required",
"invalidInput": "Invalid input", "invalidInput": "Invalid input",
"domainWarningTitle": "Invalid Domain", "domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.", "domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.",
"ignoreTitle": "Ignore", "ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Go to correct domain", "goToCorrectDomainTitle": "Go to correct domain"
"authorizeTitle": "Authorize", }
"authorizeCardTitle": "Continue to {{app}}?",
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
"authorizeLoadingTitle": "Loading...",
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
"authorizeSuccessTitle": "Authorized",
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
"openidScopeName": "OpenID Connect",
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
"emailScopeName": "Email",
"emailScopeDescription": "Allows the app to access your email address.",
"profileScopeName": "Profile",
"profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access the groups in which you are a member."
}

View File

@@ -17,7 +17,6 @@ import { AppContextProvider } from "./context/app-context.tsx";
import { UserContextProvider } from "./context/user-context.tsx"; import { UserContextProvider } from "./context/user-context.tsx";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "./components/providers/theme-provider.tsx"; import { ThemeProvider } from "./components/providers/theme-provider.tsx";
import { AuthorizePage } from "./pages/authorize-page.tsx";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -32,7 +31,6 @@ createRoot(document.getElementById("root")!).render(
<Route element={<Layout />} errorElement={<ErrorPage />}> <Route element={<Layout />} errorElement={<ErrorPage />}>
<Route path="/" element={<App />} /> <Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/authorize" element={<AuthorizePage />} />
<Route path="/logout" element={<LogoutPage />} /> <Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} /> <Route path="/continue" element={<ContinuePage />} />
<Route path="/totp" element={<TotpPage />} /> <Route path="/totp" element={<TotpPage />} />

View File

@@ -1,199 +0,0 @@
import { useUserContext } from "@/context/user-context";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Navigate, useNavigate } from "react-router";
import { useLocation } from "react-router";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
CardContent,
} from "@/components/ui/card";
import { getOidcClientInfoScehma } from "@/schemas/oidc-schemas";
import { Button } from "@/components/ui/button";
import axios from "axios";
import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc";
import { useTranslation } from "react-i18next";
import { TFunction } from "i18next";
import { Mail, Shield, User, Users } from "lucide-react";
type Scope = {
id: string;
name: string;
description: string;
icon: React.ReactNode;
};
const scopeMapIconProps = {
className: "stroke-card stroke-2.5",
};
const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
return [
{
id: "openid",
name: t("openidScopeName"),
description: t("openidScopeDescription"),
icon: <Shield {...scopeMapIconProps} />,
},
{
id: "email",
name: t("emailScopeName"),
description: t("emailScopeDescription"),
icon: <Mail {...scopeMapIconProps} />,
},
{
id: "profile",
name: t("profileScopeName"),
description: t("profileScopeDescription"),
icon: <User {...scopeMapIconProps} />,
},
{
id: "groups",
name: t("groupsScopeName"),
description: t("groupsScopeDescription"),
icon: <Users {...scopeMapIconProps} />,
},
];
};
export const AuthorizePage = () => {
const { isLoggedIn } = useUserContext();
const { search } = useLocation();
const { t } = useTranslation();
const navigate = useNavigate();
const scopeMap = createScopeMap(t);
const searchParams = new URLSearchParams(search);
const {
values: props,
missingParams,
isOidc,
compiled: compiledOIDCParams,
} = useOIDCParams(searchParams);
const scopes = props.scope.split(" ");
const getClientInfo = useQuery({
queryKey: ["client", props.client_id],
queryFn: async () => {
const res = await fetch(`/api/oidc/clients/${props.client_id}`);
const data = await getOidcClientInfoScehma.parseAsync(await res.json());
return data;
},
enabled: isOidc,
});
const authorizeMutation = useMutation({
mutationFn: () => {
return axios.post("/api/oidc/authorize", {
scope: props.scope,
response_type: props.response_type,
client_id: props.client_id,
redirect_uri: props.redirect_uri,
state: props.state,
});
},
mutationKey: ["authorize", props.client_id],
onSuccess: (data) => {
toast.info(t("authorizeSuccessTitle"), {
description: t("authorizeSuccessSubtitle"),
});
window.location.replace(data.data.redirect_uri);
},
onError: (error) => {
window.location.replace(
`/error?error=${encodeURIComponent(error.message)}`,
);
},
});
if (!isLoggedIn) {
return <Navigate to={`/login?${compiledOIDCParams}`} replace />;
}
if (missingParams.length > 0) {
return (
<Navigate
to={`/error?error=${encodeURIComponent(`Missing parameters: ${missingParams.join(", ")}`)}`}
replace
/>
);
}
if (getClientInfo.isLoading) {
return (
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">
{t("authorizeLoadingTitle")}
</CardTitle>
<CardDescription>{t("authorizeLoadingSubtitle")}</CardDescription>
</CardHeader>
</Card>
);
}
if (getClientInfo.isError) {
return (
<Navigate
to={`/error?error=${encodeURIComponent(`Failed to load client information`)}`}
replace
/>
);
}
return (
<Card className="min-w-xs sm:min-w-sm mx-4">
<CardHeader>
<CardTitle className="text-3xl">
{t("authorizeCardTitle", {
app: getClientInfo.data?.name || "Unknown",
})}
</CardTitle>
<CardDescription>
{scopes.includes("openid")
? t("authorizeSubtitle")
: t("authorizeSubtitleOAuth")}
</CardDescription>
</CardHeader>
{scopes.includes("openid") && (
<CardContent className="flex flex-col gap-4">
{scopes.map((id) => {
const scope = scopeMap.find((s) => s.id === id);
if (!scope) return null;
return (
<div key={scope.id} className="flex flex-row items-center gap-3">
<div className="p-2 flex flex-col items-center justify-center bg-card-foreground rounded-md">
{scope.icon}
</div>
<div className="flex flex-col gap-0.5">
<div className="text-md">{scope.name}</div>
<div className="text-sm text-muted-foreground">
{scope.description}
</div>
</div>
</div>
);
})}
</CardContent>
)}
<CardFooter className="flex flex-col items-stretch gap-2">
<Button
onClick={() => authorizeMutation.mutate()}
loading={authorizeMutation.isPending}
>
{t("authorizeTitle")}
</Button>
<Button
onClick={() => navigate("/")}
disabled={authorizeMutation.isPending}
variant="outline"
>
{t("cancelTitle")}
</Button>
</CardFooter>
</Card>
);
};

View File

@@ -80,7 +80,7 @@ export const ContinuePage = () => {
clearTimeout(auto); clearTimeout(auto);
clearTimeout(reveal); clearTimeout(reveal);
}; };
}); }, []);
if (!isLoggedIn) { if (!isLoggedIn) {
return ( return (

View File

@@ -5,30 +5,15 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
export const ErrorPage = () => { export const ErrorPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const error = searchParams.get("error") ?? "";
return ( return (
<Card className="min-w-xs sm:min-w-sm"> <Card className="min-w-xs sm:min-w-sm">
<CardHeader> <CardHeader>
<CardTitle className="text-3xl">{t("errorTitle")}</CardTitle> <CardTitle className="text-3xl">{t("errorTitle")}</CardTitle>
<CardDescription className="flex flex-col gap-1.5"> <CardDescription>{t("errorSubtitle")}</CardDescription>
{error ? (
<>
<p>{t("errorSubtitleInfo")}</p>
<pre>{error}</pre>
</>
) : (
<>
<p>{t("errorSubtitle")}</p>
</>
)}
</CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
); );

View File

@@ -18,7 +18,6 @@ import { OAuthButton } from "@/components/ui/oauth-button";
import { SeperatorWithChildren } from "@/components/ui/separator"; import { SeperatorWithChildren } from "@/components/ui/separator";
import { useAppContext } from "@/context/app-context"; import { useAppContext } from "@/context/app-context";
import { useUserContext } from "@/context/user-context"; import { useUserContext } from "@/context/user-context";
import { useOIDCParams } from "@/lib/hooks/oidc";
import { LoginSchema } from "@/schemas/login-schema"; import { LoginSchema } from "@/schemas/login-schema";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
@@ -48,24 +47,18 @@ export const LoginPage = () => {
const redirectButtonTimer = useRef<number | null>(null); const redirectButtonTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const { const redirectUri = searchParams.get("redirect_uri");
values: props,
isOidc,
compiled: compiledOIDCParams,
} = useOIDCParams(searchParams);
const oauthProviders = providers.filter( const oauthProviders = providers.filter(
(provider) => provider.id !== "local" && provider.id !== "ldap", (provider) => provider.id !== "username",
); );
const userAuthConfigured = const userAuthConfigured =
providers.find( providers.find((provider) => provider.id === "username") !== undefined;
(provider) => provider.id === "local" || provider.id === "ldap",
) !== undefined;
const oauthMutation = useMutation({ const oauthMutation = useMutation({
mutationFn: (provider: string) => mutationFn: (provider: string) =>
axios.get( axios.get(
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(props.redirect_uri)}`, `/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
), ),
mutationKey: ["oauth"], mutationKey: ["oauth"],
onSuccess: (data) => { onSuccess: (data) => {
@@ -90,7 +83,9 @@ export const LoginPage = () => {
mutationKey: ["login"], mutationKey: ["login"],
onSuccess: (data) => { onSuccess: (data) => {
if (data.data.totpPending) { if (data.data.totpPending) {
window.location.replace(`/totp?${compiledOIDCParams}`); window.location.replace(
`/totp?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
);
return; return;
} }
@@ -99,12 +94,8 @@ export const LoginPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
if (isOidc) {
window.location.replace(`/authorize?${compiledOIDCParams}`);
return;
}
window.location.replace( window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`, `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
); );
}, 500); }, 500);
}, },
@@ -122,7 +113,7 @@ export const LoginPage = () => {
if ( if (
providers.find((provider) => provider.id === oauthAutoRedirect) && providers.find((provider) => provider.id === oauthAutoRedirect) &&
!isLoggedIn && !isLoggedIn &&
props.redirect_uri !== "" redirectUri
) { ) {
// Not sure of a better way to do this // Not sure of a better way to do this
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
@@ -132,13 +123,7 @@ export const LoginPage = () => {
setShowRedirectButton(true); setShowRedirectButton(true);
}, 5000); }, 5000);
} }
}, [ }, []);
providers,
isLoggedIn,
props.redirect_uri,
oauthAutoRedirect,
oauthMutation,
]);
useEffect( useEffect(
() => () => { () => () => {
@@ -149,10 +134,10 @@ export const LoginPage = () => {
[], [],
); );
if (isLoggedIn && props.redirect_uri !== "") { if (isLoggedIn && redirectUri) {
return ( return (
<Navigate <Navigate
to={`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`} to={`/continue?redirect_uri=${encodeURIComponent(redirectUri)}`}
replace replace
/> />
); );

View File

@@ -55,7 +55,7 @@ export const LogoutPage = () => {
<CardHeader> <CardHeader>
<CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle> <CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle>
<CardDescription> <CardDescription>
{provider !== "local" && provider !== "ldap" ? ( {provider !== "username" ? (
<Trans <Trans
i18nKey="logoutOauthSubtitle" i18nKey="logoutOauthSubtitle"
t={t} t={t}

View File

@@ -16,7 +16,6 @@ import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc";
export const TotpPage = () => { export const TotpPage = () => {
const { totpPending } = useUserContext(); const { totpPending } = useUserContext();
@@ -27,11 +26,7 @@ export const TotpPage = () => {
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const { const redirectUri = searchParams.get("redirect_uri");
values: props,
isOidc,
compiled: compiledOIDCParams,
} = useOIDCParams(searchParams);
const totpMutation = useMutation({ const totpMutation = useMutation({
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values), mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
@@ -42,14 +37,9 @@ export const TotpPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
if (isOidc) { window.location.replace(
window.location.replace(`/authorize?${compiledOIDCParams}`); `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
return; );
} else {
window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
);
}
}, 500); }, 500);
}, },
onError: () => { onError: () => {

View File

@@ -1,5 +0,0 @@
import { z } from "zod";
export const getOidcClientInfoScehma = z.object({
name: z.string(),
});

25
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/huh v0.8.0
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.11.0 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/go-ldap/ldap/v3 v3.4.12
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/go-querystring v1.2.0 github.com/google/go-querystring v1.2.0
@@ -19,12 +20,12 @@ require (
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/traefik/paerser v0.2.2 github.com/traefik/paerser v0.2.2
github.com/weppos/publicsuffix-go v0.50.2 github.com/weppos/publicsuffix-go v0.50.1
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.46.0
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
golang.org/x/oauth2 v0.34.0 golang.org/x/oauth2 v0.34.0
gorm.io/gorm v1.31.1
gotest.tools/v3 v3.5.2 gotest.tools/v3 v3.5.2
modernc.org/sqlite v1.44.1
) )
require ( require (
@@ -60,6 +61,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // 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-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
@@ -71,6 +73,8 @@ require (
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.11 // 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/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
@@ -91,7 +95,7 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
@@ -112,15 +116,16 @@ require (
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect
rsc.io/qr v0.2.0 // indirect rsc.io/qr v0.2.0 // indirect
) )

74
go.sum
View File

@@ -101,6 +101,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
@@ -140,8 +144,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3Ar
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= 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 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 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 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
@@ -159,6 +161,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -217,8 +223,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -269,8 +275,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/weppos/publicsuffix-go v0.50.2 h1:KsJFc8IEKTJovM46SRCnGNsM+rFShxcs6VEHjOJcXzE= github.com/weppos/publicsuffix-go v0.50.1 h1:elrBHeSkS/eIb169+DnLrknqmdP4AjT0Q0tEdytz1Og=
github.com/weppos/publicsuffix-go v0.50.2/go.mod h1:CbQCKDtXF8UcT7hrxeMa0MDjwhpOI9iYOU7cfq+yo8k= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -301,19 +307,19 @@ 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-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.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.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 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-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-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.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.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 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 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -330,26 +336,26 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.2.0/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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-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.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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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.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.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.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.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
@@ -369,22 +375,22 @@ 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -393,8 +399,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.1 h1:qybx/rNpfQipX/t47OxbHmkkJuv2JWifCMH8SVUiDas= modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.44.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

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

View File

@@ -1 +0,0 @@
ALTER TABLE "sessions" ADD COLUMN "created_at" INTEGER NOT NULL DEFAULT 0;

View File

@@ -1,3 +0,0 @@
DROP TABLE IF EXISTS "oidc_tokens";
DROP TABLE IF EXISTS "oidc_userinfo";
DROP TABLE IF EXISTS "oidc_codes";

View File

@@ -1,27 +0,0 @@
CREATE TABLE IF NOT EXISTS "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
"scope" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"expires_at" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "oidc_tokens" (
"sub" TEXT NOT NULL UNIQUE,
"access_token_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
"refresh_token_hash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" INTEGER NOT NULL,
"refresh_token_expires_at" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "oidc_userinfo" (
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
"name" TEXT NOT NULL,
"preferred_username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"groups" TEXT NOT NULL,
"updated_at" INTEGER NOT NULL
);

View File

@@ -14,9 +14,11 @@ import (
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/model"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
) )
type BootstrapApp struct { type BootstrapApp struct {
@@ -30,7 +32,6 @@ type BootstrapApp struct {
users []config.User users []config.User
oauthProviders map[string]config.OAuthServiceConfig oauthProviders map[string]config.OAuthServiceConfig
configuredProviders []controller.Provider configuredProviders []controller.Provider
oidcClients []config.OIDCClientConfig
} }
services Services services Services
} }
@@ -42,10 +43,6 @@ func NewBootstrapApp(config config.Config) *BootstrapApp {
} }
func (app *BootstrapApp) Setup() error { func (app *BootstrapApp) Setup() error {
// validate session config
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
return fmt.Errorf("session max lifetime cannot be less than session expiry")
}
// Parse users // Parse users
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile) users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)
@@ -85,12 +82,6 @@ func (app *BootstrapApp) Setup() error {
app.context.oauthProviders[id] = provider app.context.oauthProviders[id] = provider
} }
// Setup OIDC clients
for id, client := range app.config.OIDC.Clients {
client.ID = id
app.context.oidcClients = append(app.context.oidcClients, client)
}
// Get cookie domain // Get cookie domain
cookieDomain, err := utils.GetCookieDomain(app.config.AppURL) cookieDomain, err := utils.GetCookieDomain(app.config.AppURL)
@@ -109,26 +100,16 @@ func (app *BootstrapApp) Setup() error {
app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId) app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
// Dumps // Dumps
tlog.App.Trace().Interface("config", app.config).Msg("Config dump") log.Trace().Interface("config", app.config).Msg("Config dump")
tlog.App.Trace().Interface("users", app.context.users).Msg("Users dump") log.Trace().Interface("users", app.context.users).Msg("Users dump")
tlog.App.Trace().Interface("oauthProviders", app.context.oauthProviders).Msg("OAuth providers dump") log.Trace().Interface("oauthProviders", app.context.oauthProviders).Msg("OAuth providers dump")
tlog.App.Trace().Str("cookieDomain", app.context.cookieDomain).Msg("Cookie domain") log.Trace().Str("cookieDomain", app.context.cookieDomain).Msg("Cookie domain")
tlog.App.Trace().Str("sessionCookieName", app.context.sessionCookieName).Msg("Session cookie name") log.Trace().Str("sessionCookieName", app.context.sessionCookieName).Msg("Session cookie name")
tlog.App.Trace().Str("csrfCookieName", app.context.csrfCookieName).Msg("CSRF cookie name") log.Trace().Str("csrfCookieName", app.context.csrfCookieName).Msg("CSRF cookie name")
tlog.App.Trace().Str("redirectCookieName", app.context.redirectCookieName).Msg("Redirect cookie name") log.Trace().Str("redirectCookieName", app.context.redirectCookieName).Msg("Redirect cookie name")
// Database
db, err := app.SetupDatabase(app.config.DatabasePath)
if err != nil {
return fmt.Errorf("failed to setup database: %w", err)
}
// Queries
queries := repository.New(db)
// Services // Services
services, err := app.initServices(queries) services, err := app.initServices()
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize services: %w", err) return fmt.Errorf("failed to initialize services: %w", err)
@@ -151,23 +132,15 @@ func (app *BootstrapApp) Setup() error {
return configuredProviders[i].Name < configuredProviders[j].Name return configuredProviders[i].Name < configuredProviders[j].Name
}) })
if services.authService.LocalAuthConfigured() { if services.authService.UserAuthConfigured() {
configuredProviders = append(configuredProviders, controller.Provider{ configuredProviders = append(configuredProviders, controller.Provider{
Name: "Local", Name: "Username",
ID: "local", ID: "username",
OAuth: false, OAuth: false,
}) })
} }
if services.authService.LdapAuthConfigured() { log.Debug().Interface("providers", configuredProviders).Msg("Authentication providers")
configuredProviders = append(configuredProviders, controller.Provider{
Name: "LDAP",
ID: "ldap",
OAuth: false,
})
}
tlog.App.Debug().Interface("providers", configuredProviders).Msg("Authentication providers")
if len(configuredProviders) == 0 { if len(configuredProviders) == 0 {
return fmt.Errorf("no authentication providers configured") return fmt.Errorf("no authentication providers configured")
@@ -182,29 +155,29 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to setup routes: %w", err) return fmt.Errorf("failed to setup routes: %w", err)
} }
// Start db cleanup routine // Start DB cleanup routine
tlog.App.Debug().Msg("Starting database cleanup routine") log.Debug().Msg("Starting database cleanup routine")
go app.dbCleanup(queries) go app.dbCleanup(services.databaseService.GetDatabase())
// If analytics are not disabled, start heartbeat // If analytics are not disabled, start heartbeat
if !app.config.DisableAnalytics { if !app.config.DisableAnalytics {
tlog.App.Debug().Msg("Starting heartbeat routine") log.Debug().Msg("Starting heartbeat routine")
go app.heartbeat() go app.heartbeat()
} }
// If we have an socket path, bind to it // If we have an socket path, bind to it
if app.config.Server.SocketPath != "" { if app.config.Server.SocketPath != "" {
if _, err := os.Stat(app.config.Server.SocketPath); err == nil { if _, err := os.Stat(app.config.Server.SocketPath); err == nil {
tlog.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath) log.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath) err := os.Remove(app.config.Server.SocketPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err) return fmt.Errorf("failed to remove existing socket file: %w", err)
} }
} }
tlog.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath) log.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
if err := router.RunUnix(app.config.Server.SocketPath); err != nil { if err := router.RunUnix(app.config.Server.SocketPath); err != nil {
tlog.App.Fatal().Err(err).Msg("Failed to start server") log.Fatal().Err(err).Msg("Failed to start server")
} }
return nil return nil
@@ -212,9 +185,9 @@ func (app *BootstrapApp) Setup() error {
// Start server // Start server
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port) address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
tlog.App.Info().Msgf("Starting server on %s", address) log.Info().Msgf("Starting server on %s", address)
if err := router.Run(address); err != nil { if err := router.Run(address); err != nil {
tlog.App.Fatal().Err(err).Msg("Failed to start server") log.Fatal().Err(err).Msg("Failed to start server")
} }
return nil return nil
@@ -237,7 +210,7 @@ func (app *BootstrapApp) heartbeat() {
bodyJson, err := json.Marshal(body) bodyJson, err := json.Marshal(body)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to marshal heartbeat body") log.Error().Err(err).Msg("Failed to marshal heartbeat body")
return return
} }
@@ -247,13 +220,13 @@ func (app *BootstrapApp) heartbeat() {
heartbeatURL := config.ApiServer + "/v1/instances/heartbeat" heartbeatURL := config.ApiServer + "/v1/instances/heartbeat"
for range ticker.C { for ; true; <-ticker.C {
tlog.App.Debug().Msg("Sending heartbeat") log.Debug().Msg("Sending heartbeat")
req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson)) req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson))
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create heartbeat request") log.Error().Err(err).Msg("Failed to create heartbeat request")
continue continue
} }
@@ -262,28 +235,28 @@ func (app *BootstrapApp) heartbeat() {
res, err := client.Do(req) res, err := client.Do(req)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to send heartbeat") log.Error().Err(err).Msg("Failed to send heartbeat")
continue continue
} }
res.Body.Close() res.Body.Close()
if res.StatusCode != 200 && res.StatusCode != 201 { if res.StatusCode != 200 && res.StatusCode != 201 {
tlog.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status") log.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
} }
} }
} }
func (app *BootstrapApp) dbCleanup(queries *repository.Queries) { func (app *BootstrapApp) dbCleanup(db *gorm.DB) {
ticker := time.NewTicker(time.Duration(30) * time.Minute) ticker := time.NewTicker(time.Duration(30) * time.Minute)
defer ticker.Stop() defer ticker.Stop()
ctx := context.Background() ctx := context.Background()
for range ticker.C { for ; true; <-ticker.C {
tlog.App.Debug().Msg("Cleaning up old database sessions") log.Debug().Msg("Cleaning up old database sessions")
err := queries.DeleteExpiredSessions(ctx, time.Now().Unix()) _, err := gorm.G[model.Session](db).Where("expiry < ?", time.Now().Unix()).Delete(ctx)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to clean up old database sessions") log.Error().Err(err).Msg("Failed to cleanup old sessions")
} }
} }
} }

View File

@@ -1,57 +0,0 @@
package bootstrap
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"github.com/steveiliop56/tinyauth/internal/assets"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite"
)
func (app *BootstrapApp) SetupDatabase(databasePath string) (*sql.DB, error) {
dir := filepath.Dir(databasePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
}
db, err := sql.Open("sqlite", databasePath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Limit to 1 connection to sequence writes, this may need to be revisited in the future
// if the sqlite connection starts being a bottleneck
db.SetMaxOpenConns(1)
migrations, err := iofs.New(assets.Migrations, "migrations")
if err != nil {
return nil, fmt.Errorf("failed to create migrations: %w", err)
}
target, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
return nil, fmt.Errorf("failed to create sqlite3 instance: %w", err)
}
migrator, err := migrate.NewWithInstance("iofs", migrations, "sqlite3", target)
if err != nil {
return nil, fmt.Errorf("failed to create migrator: %w", err)
}
if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return db, nil
}

View File

@@ -2,27 +2,20 @@ package bootstrap
import ( import (
"fmt" "fmt"
"slices" "strings"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/middleware" "github.com/steveiliop56/tinyauth/internal/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
var DEV_MODES = []string{"main", "test", "development"}
func (app *BootstrapApp) setupRouter() (*gin.Engine, error) { func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
if !slices.Contains(DEV_MODES, config.Version) {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.New() engine := gin.New()
engine.Use(gin.Recovery()) engine.Use(gin.Recovery())
if len(app.config.Server.TrustedProxies) > 0 { if len(app.config.Server.TrustedProxies) > 0 {
err := engine.SetTrustedProxies(app.config.Server.TrustedProxies) err := engine.SetTrustedProxies(strings.Split(app.config.Server.TrustedProxies, ","))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to set trusted proxies: %w", err) return nil, fmt.Errorf("failed to set trusted proxies: %w", err)
@@ -86,10 +79,6 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
oauthController.SetupRoutes() oauthController.SetupRoutes()
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter)
oidcController.SetupRoutes()
proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ proxyController := controller.NewProxyController(controller.ProxyControllerConfig{
AppURL: app.config.AppURL, AppURL: app.config.AppURL,
}, apiRouter, app.services.accessControlService, app.services.authService) }, apiRouter, app.services.accessControlService, app.services.authService)

View File

@@ -1,23 +1,35 @@
package bootstrap package bootstrap
import ( import (
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/rs/zerolog/log"
) )
type Services struct { type Services struct {
accessControlService *service.AccessControlsService accessControlService *service.AccessControlsService
authService *service.AuthService authService *service.AuthService
databaseService *service.DatabaseService
dockerService *service.DockerService dockerService *service.DockerService
ldapService *service.LdapService ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService
} }
func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, error) { func (app *BootstrapApp) initServices() (Services, error) {
services := Services{} services := Services{}
databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{
DatabasePath: app.config.DatabasePath,
})
err := databaseService.Init()
if err != nil {
return Services{}, err
}
services.databaseService = databaseService
ldapService := service.NewLdapService(service.LdapServiceConfig{ ldapService := service.NewLdapService(service.LdapServiceConfig{
Address: app.config.Ldap.Address, Address: app.config.Ldap.Address,
BindDN: app.config.Ldap.BindDN, BindDN: app.config.Ldap.BindDN,
@@ -25,16 +37,14 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
BaseDN: app.config.Ldap.BaseDN, BaseDN: app.config.Ldap.BaseDN,
Insecure: app.config.Ldap.Insecure, Insecure: app.config.Ldap.Insecure,
SearchFilter: app.config.Ldap.SearchFilter, SearchFilter: app.config.Ldap.SearchFilter,
AuthCert: app.config.Ldap.AuthCert,
AuthKey: app.config.Ldap.AuthKey,
}) })
err := ldapService.Init() err = ldapService.Init()
if err == nil { if err == nil {
services.ldapService = ldapService services.ldapService = ldapService
} else { } else {
tlog.App.Warn().Err(err).Msg("Failed to initialize LDAP service, continuing without it") log.Warn().Err(err).Msg("Failed to initialize LDAP service, continuing without it")
} }
dockerService := service.NewDockerService() dockerService := service.NewDockerService()
@@ -58,18 +68,15 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
services.accessControlService = accessControlsService services.accessControlService = accessControlsService
authService := service.NewAuthService(service.AuthServiceConfig{ authService := service.NewAuthService(service.AuthServiceConfig{
Users: app.context.users, Users: app.context.users,
OauthWhitelist: app.config.OAuth.Whitelist, OauthWhitelist: app.config.OAuth.Whitelist,
SessionExpiry: app.config.Auth.SessionExpiry, SessionExpiry: app.config.Auth.SessionExpiry,
SessionMaxLifetime: app.config.Auth.SessionMaxLifetime, SecureCookie: app.config.Auth.SecureCookie,
SecureCookie: app.config.Auth.SecureCookie, CookieDomain: app.context.cookieDomain,
CookieDomain: app.context.cookieDomain, LoginTimeout: app.config.Auth.LoginTimeout,
LoginTimeout: app.config.Auth.LoginTimeout, LoginMaxRetries: app.config.Auth.LoginMaxRetries,
LoginMaxRetries: app.config.Auth.LoginMaxRetries, SessionCookieName: app.context.sessionCookieName,
SessionCookieName: app.context.sessionCookieName, }, dockerService, ldapService, databaseService.GetDatabase())
IP: app.config.Auth.IP,
LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL,
}, dockerService, services.ldapService, queries)
err = authService.Init() err = authService.Init()
@@ -89,21 +96,5 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
services.oauthBrokerService = oauthBrokerService services.oauthBrokerService = oauthBrokerService
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
Clients: app.config.OIDC.Clients,
PrivateKeyPath: app.config.OIDC.PrivateKeyPath,
PublicKeyPath: app.config.OIDC.PublicKeyPath,
Issuer: app.config.AppURL,
SessionExpiry: app.config.Auth.SessionExpiry,
}, queries)
err = oidcService.Init()
if err != nil {
return Services{}, err
}
services.oidcService = oidcService
return services, nil return services, nil
} }

View File

@@ -16,57 +16,44 @@ var RedirectCookieName = "tinyauth-redirect"
type Config struct { type Config struct {
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"` AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
LogLevel string `description:"Log level (trace, debug, info, warn, error)." yaml:"logLevel"`
ResourcesDir string `description:"The directory where resources are stored." yaml:"resourcesDir"` ResourcesDir string `description:"The directory where resources are stored." yaml:"resourcesDir"`
DatabasePath string `description:"The path to the database file." yaml:"databasePath"` DatabasePath string `description:"The path to the database file." yaml:"databasePath"`
DisableAnalytics bool `description:"Disable analytics." yaml:"disableAnalytics"` DisableAnalytics bool `description:"Disable analytics." yaml:"disableAnalytics"`
DisableResources bool `description:"Disable resources server." yaml:"disableResources"` DisableResources bool `description:"Disable resources server." yaml:"disableResources"`
DisableUIWarnings bool `description:"Disable UI warnings." yaml:"disableUIWarnings"` DisableUIWarnings bool `description:"Disable UI warnings." yaml:"disableUIWarnings"`
LogJSON bool `description:"Enable JSON formatted logs." yaml:"logJSON"`
Server ServerConfig `description:"Server configuration." yaml:"server"` Server ServerConfig `description:"Server configuration." yaml:"server"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"` Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"` Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"` OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
UI UIConfig `description:"UI customization." yaml:"ui"` UI UIConfig `description:"UI customization." yaml:"ui"`
Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"` Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"` Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
Log LogConfig `description:"Logging configuration." yaml:"log"`
} }
type ServerConfig struct { type ServerConfig struct {
Port int `description:"The port on which the server listens." yaml:"port"` Port int `description:"The port on which the server listens." yaml:"port"`
Address string `description:"The address on which the server listens." yaml:"address"` Address string `description:"The address on which the server listens." yaml:"address"`
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"` SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` TrustedProxies string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
} }
type AuthConfig struct { type AuthConfig struct {
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"` Users string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` UsersFile string `description:"Path to the users file." yaml:"usersFile"`
UsersFile string `description:"Path to the users file." yaml:"usersFile"` SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"` SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
}
type IPConfig struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
} }
type OAuthConfig struct { type OAuthConfig struct {
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"` Whitelist string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"` AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"` Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
} }
type OIDCConfig struct {
PrivateKeyPath string `description:"Path to the private key file." yaml:"privateKeyPath"`
PublicKeyPath string `description:"Path to the public key file." yaml:"publicKeyPath"`
Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients"`
}
type UIConfig struct { type UIConfig struct {
Title string `description:"The title of the UI." yaml:"title"` Title string `description:"The title of the UI." yaml:"title"`
ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage"` ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage"`
@@ -74,32 +61,12 @@ type UIConfig struct {
} }
type LdapConfig struct { type LdapConfig struct {
Address string `description:"LDAP server address." yaml:"address"` Address string `description:"LDAP server address." yaml:"address"`
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"`
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"`
GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"`
}
type LogConfig struct {
Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level"`
Json bool `description:"Enable JSON formatted logs." yaml:"json"`
Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams"`
}
type LogStreams struct {
HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http"`
App LogStreamConfig `description:"Application logging." yaml:"app"`
Audit LogStreamConfig `description:"Audit logging." yaml:"audit"`
}
type LogStreamConfig struct {
Enabled bool `description:"Enable this log stream." yaml:"enabled"`
Level string `description:"Log level for this stream. Use global if empty." yaml:"level"`
} }
type ExperimentalConfig struct { type ExperimentalConfig struct {
@@ -121,25 +88,16 @@ type Claims struct {
} }
type OAuthServiceConfig struct { type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID." yaml:"clientId"` ClientID string `description:"OAuth client ID."`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"` ClientSecret string `description:"OAuth client secret."`
ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile"` ClientSecretFile string `description:"Path to the file containing the OAuth client secret."`
Scopes []string `description:"OAuth scopes." yaml:"scopes"` Scopes []string `description:"OAuth scopes."`
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl"` RedirectURL string `description:"OAuth redirect URL."`
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl"` AuthURL string `description:"OAuth authorization URL."`
TokenURL string `description:"OAuth token URL." yaml:"tokenUrl"` TokenURL string `description:"OAuth token URL."`
UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl"` UserinfoURL string `description:"OAuth userinfo URL."`
Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure"` Insecure bool `description:"Allow insecure OAuth connections."`
Name string `description:"Provider name in UI." yaml:"name"` Name string `description:"Provider name in UI."`
}
type OIDCClientConfig struct {
ID string `description:"OIDC client ID." yaml:"-"`
ClientID string `description:"OIDC client ID." yaml:"clientId"`
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"`
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"`
TrustedRedirectURIs []string `description:"List of trusted redirect URLs." yaml:"trustedRedirectUrls"`
Name string `description:"Client name in UI." yaml:"name"`
} }
var OverrideProviders = map[string]string{ var OverrideProviders = map[string]string{
@@ -155,22 +113,28 @@ type User struct {
TotpSecret string TotpSecret string
} }
type LdapUser struct {
DN string
Groups []string
}
type UserSearch struct { type UserSearch struct {
Username string Username string
Type string // local, ldap or unknown Type string // local, ldap or unknown
} }
type SessionCookie struct {
UUID string
Username string
Name string
Email string
Provider string
TotpPending bool
OAuthGroups string
OAuthName string
OAuthSub string
}
type UserContext struct { type UserContext struct {
Username string Username string
Name string Name string
Email string Email string
IsLoggedIn bool IsLoggedIn bool
IsBasicAuth bool
OAuth bool OAuth bool
Provider string Provider string
TotpPending bool TotpPending bool
@@ -178,7 +142,6 @@ type UserContext struct {
TotpEnabled bool TotpEnabled bool
OAuthName string OAuthName string
OAuthSub string OAuthSub string
LdapGroups string
} }
// API responses and queries // API responses and queries
@@ -207,7 +170,6 @@ type App struct {
IP AppIP `description:"IP access configuration." yaml:"ip"` IP AppIP `description:"IP access configuration." yaml:"ip"`
Response AppResponse `description:"Response customization." yaml:"response"` Response AppResponse `description:"Response customization." yaml:"response"`
Path AppPath `description:"Path access configuration." yaml:"path"` Path AppPath `description:"Path access configuration." yaml:"path"`
LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap"`
} }
type AppConfig struct { type AppConfig struct {
@@ -224,10 +186,6 @@ type AppOAuth struct {
Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"` Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"`
} }
type AppLDAP struct {
Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups"`
}
type AppIP struct { type AppIP struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`

View File

@@ -5,9 +5,9 @@ import (
"net/url" "net/url"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
) )
type UserContextResponse struct { type UserContextResponse struct {
@@ -21,6 +21,7 @@ type UserContextResponse struct {
OAuth bool `json:"oauth"` OAuth bool `json:"oauth"`
TotpPending bool `json:"totpPending"` TotpPending bool `json:"totpPending"`
OAuthName string `json:"oauthName"` OAuthName string `json:"oauthName"`
OAuthSub string `json:"oauthSub"`
} }
type AppContextResponse struct { type AppContextResponse struct {
@@ -60,7 +61,7 @@ type ContextController struct {
func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController { func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController {
if config.DisableUIWarnings { if config.DisableUIWarnings {
tlog.App.Warn().Msg("UI warnings are disabled. This may expose users to security risks. Proceed with caution.") log.Warn().Msg("UI warnings are disabled. This may expose users to security risks. Proceed with caution.")
} }
return &ContextController{ return &ContextController{
@@ -89,10 +90,11 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
OAuth: context.OAuth, OAuth: context.OAuth,
TotpPending: context.TotpPending, TotpPending: context.TotpPending,
OAuthName: context.OAuthName, OAuthName: context.OAuthName,
OAuthSub: context.OAuthSub,
} }
if err != nil { if err != nil {
tlog.App.Debug().Err(err).Msg("No user context found in request") log.Debug().Err(err).Msg("No user context found in request")
userContext.Status = 401 userContext.Status = 401
userContext.Message = "Unauthorized" userContext.Message = "Unauthorized"
userContext.IsLoggedIn = false userContext.IsLoggedIn = false
@@ -106,7 +108,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
func (controller *ContextController) appContextHandler(c *gin.Context) { func (controller *ContextController) appContextHandler(c *gin.Context) {
appUrl, err := url.Parse(controller.config.AppURL) appUrl, err := url.Parse(controller.config.AppURL)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to parse app URL") log.Error().Err(err).Msg("Failed to parse app URL")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",

View File

@@ -7,17 +7,16 @@ import (
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
) )
var contextControllerCfg = controller.ContextControllerConfig{ var controllerCfg = controller.ContextControllerConfig{
Providers: []controller.Provider{ Providers: []controller.Provider{
{ {
Name: "Local", Name: "Username",
ID: "local", ID: "username",
OAuth: false, OAuth: false,
}, },
{ {
@@ -35,14 +34,13 @@ var contextControllerCfg = controller.ContextControllerConfig{
DisableUIWarnings: false, DisableUIWarnings: false,
} }
var contextCtrlTestContext = config.UserContext{ var userContext = config.UserContext{
Username: "testuser", Username: "testuser",
Name: "testuser", Name: "testuser",
Email: "test@example.com", Email: "test@example.com",
IsLoggedIn: true, IsLoggedIn: true,
IsBasicAuth: false,
OAuth: false, OAuth: false,
Provider: "local", Provider: "username",
TotpPending: false, TotpPending: false,
OAuthGroups: "", OAuthGroups: "",
TotpEnabled: false, TotpEnabled: false,
@@ -50,8 +48,6 @@ var contextCtrlTestContext = config.UserContext{
} }
func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {
tlog.NewSimpleLogger().Init()
// Setup // Setup
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.Default() router := gin.Default()
@@ -65,7 +61,7 @@ func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httpt
group := router.Group("/api") group := router.Group("/api")
ctrl := controller.NewContextController(contextControllerCfg, group) ctrl := controller.NewContextController(controllerCfg, group)
ctrl.SetupRoutes() ctrl.SetupRoutes()
return router, recorder return router, recorder
@@ -75,14 +71,14 @@ func TestAppContextHandler(t *testing.T) {
expectedRes := controller.AppContextResponse{ expectedRes := controller.AppContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Providers: contextControllerCfg.Providers, Providers: controllerCfg.Providers,
Title: contextControllerCfg.Title, Title: controllerCfg.Title,
AppURL: contextControllerCfg.AppURL, AppURL: controllerCfg.AppURL,
CookieDomain: contextControllerCfg.CookieDomain, CookieDomain: controllerCfg.CookieDomain,
ForgotPasswordMessage: contextControllerCfg.ForgotPasswordMessage, ForgotPasswordMessage: controllerCfg.ForgotPasswordMessage,
BackgroundImage: contextControllerCfg.BackgroundImage, BackgroundImage: controllerCfg.BackgroundImage,
OAuthAutoRedirect: contextControllerCfg.OAuthAutoRedirect, OAuthAutoRedirect: controllerCfg.OAuthAutoRedirect,
DisableUIWarnings: contextControllerCfg.DisableUIWarnings, DisableUIWarnings: controllerCfg.DisableUIWarnings,
} }
router, recorder := setupContextController(nil) router, recorder := setupContextController(nil)
@@ -103,20 +99,20 @@ func TestUserContextHandler(t *testing.T) {
expectedRes := controller.UserContextResponse{ expectedRes := controller.UserContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
IsLoggedIn: contextCtrlTestContext.IsLoggedIn, IsLoggedIn: userContext.IsLoggedIn,
Username: contextCtrlTestContext.Username, Username: userContext.Username,
Name: contextCtrlTestContext.Name, Name: userContext.Name,
Email: contextCtrlTestContext.Email, Email: userContext.Email,
Provider: contextCtrlTestContext.Provider, Provider: userContext.Provider,
OAuth: contextCtrlTestContext.OAuth, OAuth: userContext.OAuth,
TotpPending: contextCtrlTestContext.TotpPending, TotpPending: userContext.TotpPending,
OAuthName: contextCtrlTestContext.OAuthName, OAuthName: userContext.OAuthName,
} }
// Test with context // Test with context
router, recorder := setupContextController(&[]gin.HandlerFunc{ router, recorder := setupContextController(&[]gin.HandlerFunc{
func(c *gin.Context) { func(c *gin.Context) {
c.Set("context", &contextCtrlTestContext) c.Set("context", &userContext)
c.Next() c.Next()
}, },
}) })

View File

@@ -7,13 +7,12 @@ import (
"time" "time"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
"github.com/rs/zerolog/log"
) )
type OAuthRequest struct { type OAuthRequest struct {
@@ -55,7 +54,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
err := c.BindUri(&req) err := c.BindUri(&req)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to bind URI") log.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -66,7 +65,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
service, exists := controller.broker.GetService(req.Provider) service, exists := controller.broker.GetService(req.Provider)
if !exists { if !exists {
tlog.App.Warn().Msgf("OAuth provider not found: %s", req.Provider) log.Warn().Msgf("OAuth provider not found: %s", req.Provider)
c.JSON(404, gin.H{ c.JSON(404, gin.H{
"status": 404, "status": 404,
"message": "Not Found", "message": "Not Found",
@@ -83,12 +82,12 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
isRedirectSafe := utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) isRedirectSafe := utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain)
if !isRedirectSafe { if !isRedirectSafe {
tlog.App.Warn().Str("redirect_uri", redirectURI).Msg("Unsafe redirect URI detected, ignoring") log.Warn().Str("redirect_uri", redirectURI).Msg("Unsafe redirect URI detected, ignoring")
redirectURI = "" redirectURI = ""
} }
if redirectURI != "" && isRedirectSafe { if redirectURI != "" && isRedirectSafe {
tlog.App.Debug().Msg("Setting redirect URI cookie") log.Debug().Msg("Setting redirect URI cookie")
c.SetCookie(controller.config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) c.SetCookie(controller.config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
} }
@@ -104,7 +103,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
err := c.BindUri(&req) err := c.BindUri(&req)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to bind URI") log.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -116,7 +115,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
csrfCookie, err := c.Cookie(controller.config.CSRFCookieName) csrfCookie, err := c.Cookie(controller.config.CSRFCookieName)
if err != nil || state != csrfCookie { if err != nil || state != csrfCookie {
tlog.App.Warn().Err(err).Msg("CSRF token mismatch or cookie missing") log.Warn().Err(err).Msg("CSRF token mismatch or cookie missing")
c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
@@ -128,14 +127,14 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
service, exists := controller.broker.GetService(req.Provider) service, exists := controller.broker.GetService(req.Provider)
if !exists { if !exists {
tlog.App.Warn().Msgf("OAuth provider not found: %s", req.Provider) log.Warn().Msgf("OAuth provider not found: %s", req.Provider)
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
err = service.VerifyCode(code) err = service.VerifyCode(code)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to verify OAuth code") log.Error().Err(err).Msg("Failed to verify OAuth code")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -143,27 +142,26 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
user, err := controller.broker.GetUser(req.Provider) user, err := controller.broker.GetUser(req.Provider)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to get user from OAuth provider") log.Error().Err(err).Msg("Failed to get user from OAuth provider")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
if user.Email == "" { if user.Email == "" {
tlog.App.Error().Msg("OAuth provider did not return an email") log.Error().Msg("OAuth provider did not return an email")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
if !controller.auth.IsEmailWhitelisted(user.Email) { if !controller.auth.IsEmailWhitelisted(user.Email) {
tlog.App.Warn().Str("email", user.Email).Msg("Email not whitelisted") log.Warn().Str("email", user.Email).Msg("Email not whitelisted")
tlog.AuditLoginFailure(c, user.Email, req.Provider, "email not whitelisted")
queries, err := query.Values(config.UnauthorizedQuery{ queries, err := query.Values(config.UnauthorizedQuery{
Username: user.Email, Username: user.Email,
}) })
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query") log.Error().Err(err).Msg("Failed to encode unauthorized query")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -175,24 +173,24 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
var name string var name string
if strings.TrimSpace(user.Name) != "" { if strings.TrimSpace(user.Name) != "" {
tlog.App.Debug().Msg("Using name from OAuth provider") log.Debug().Msg("Using name from OAuth provider")
name = user.Name name = user.Name
} else { } else {
tlog.App.Debug().Msg("No name from OAuth provider, using pseudo name") log.Debug().Msg("No name from OAuth provider, using pseudo name")
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1]) name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
} }
var username string var username string
if strings.TrimSpace(user.PreferredUsername) != "" { if strings.TrimSpace(user.PreferredUsername) != "" {
tlog.App.Debug().Msg("Using preferred username from OAuth provider") log.Debug().Msg("Using preferred username from OAuth provider")
username = user.PreferredUsername username = user.PreferredUsername
} else { } else {
tlog.App.Debug().Msg("No preferred username from OAuth provider, using pseudo username") log.Debug().Msg("No preferred username from OAuth provider, using pseudo username")
username = strings.Replace(user.Email, "@", "_", 1) username = strings.Replace(user.Email, "@", "_", -1)
} }
sessionCookie := repository.Session{ sessionCookie := config.SessionCookie{
Username: username, Username: username,
Name: name, Name: name,
Email: user.Email, Email: user.Email,
@@ -202,22 +200,20 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
OAuthSub: user.Sub, OAuthSub: user.Sub,
} }
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
err = controller.auth.CreateSessionCookie(c, &sessionCookie) err = controller.auth.CreateSessionCookie(c, &sessionCookie)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create session cookie") log.Error().Err(err).Msg("Failed to create session cookie")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
tlog.AuditLoginSuccess(c, sessionCookie.Username, sessionCookie.Provider)
redirectURI, err := c.Cookie(controller.config.RedirectCookieName) redirectURI, err := c.Cookie(controller.config.RedirectCookieName)
if err != nil || !utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) { if err != nil || !utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) {
tlog.App.Debug().Msg("No redirect URI cookie found, redirecting to app root") log.Debug().Msg("No redirect URI cookie found, redirecting to app root")
c.Redirect(http.StatusTemporaryRedirect, controller.config.AppURL) c.Redirect(http.StatusTemporaryRedirect, controller.config.AppURL)
return return
} }
@@ -227,7 +223,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
}) })
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query") log.Error().Err(err).Msg("Failed to encode redirect URI query")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }

View File

@@ -1,406 +0,0 @@
package controller
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
)
type OIDCControllerConfig struct{}
type OIDCController struct {
config OIDCControllerConfig
router *gin.RouterGroup
oidc *service.OIDCService
}
type AuthorizeCallback struct {
Code string `url:"code"`
State string `url:"state"`
}
type TokenRequest struct {
GrantType string `form:"grant_type" binding:"required" url:"grant_type"`
Code string `form:"code" url:"code"`
RedirectURI string `form:"redirect_uri" url:"redirect_uri"`
RefreshToken string `form:"refresh_token" url:"refresh_token"`
}
type CallbackError struct {
Error string `url:"error"`
ErrorDescription string `url:"error_description"`
State string `url:"state"`
}
type ErrorScreen struct {
Error string `url:"error"`
}
type ClientRequest struct {
ClientID string `uri:"id" binding:"required"`
}
func NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup) *OIDCController {
return &OIDCController{
config: config,
oidc: oidcService,
router: router,
}
}
func (controller *OIDCController) SetupRoutes() {
oidcGroup := controller.router.Group("/oidc")
oidcGroup.GET("/clients/:id", controller.GetClientInfo)
oidcGroup.POST("/authorize", controller.Authorize)
oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo)
}
func (controller *OIDCController) GetClientInfo(c *gin.Context) {
var req ClientRequest
err := c.BindUri(&req)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
client, ok := controller.oidc.GetClient(req.ClientID)
if !ok {
tlog.App.Warn().Str("client_id", req.ClientID).Msg("Client not found")
c.JSON(404, gin.H{
"status": 404,
"message": "Client not found",
})
return
}
c.JSON(200, gin.H{
"status": 200,
"client": client.ClientID,
"name": client.Name,
})
}
func (controller *OIDCController) Authorize(c *gin.Context) {
userContext, err := utils.GetContext(c)
if err != nil {
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
return
}
var req service.AuthorizeRequest
err = c.BindJSON(&req)
if err != nil {
controller.authorizeError(c, err, "Failed to bind JSON", "The client provided an invalid authorization request", "", "", "")
return
}
client, ok := controller.oidc.GetClient(req.ClientID)
if !ok {
controller.authorizeError(c, err, "Client not found", "The client ID is invalid", "", "", "")
return
}
err = controller.oidc.ValidateAuthorizeParams(req)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to validate authorize params")
if err.Error() != "invalid_request_uri" {
controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State)
return
}
controller.authorizeError(c, err, "Redirect URI not trusted", "The provided redirect URI is not trusted", "", "", "")
return
}
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.Username, client.ID))
code := rand.Text()
// Before storing the code, delete old session
err = controller.oidc.DeleteOldSession(c, sub)
if err != nil {
controller.authorizeError(c, err, "Failed to delete old sessions", "Failed to delete old sessions", req.RedirectURI, "server_error", req.State)
return
}
err = controller.oidc.StoreCode(c, sub, code, req)
if err != nil {
controller.authorizeError(c, err, "Failed to store code", "Failed to store code", req.RedirectURI, "server_error", req.State)
return
}
// We also need a snapshot of the user that authorized this (skip if no openid scope)
if slices.Contains(strings.Split(req.Scope, " "), "openid") {
err = controller.oidc.StoreUserinfo(c, sub, userContext, req)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to insert user info into database")
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
return
}
}
queries, err := query.Values(AuthorizeCallback{
Code: code,
State: req.State,
})
if err != nil {
controller.authorizeError(c, err, "Failed to build query", "Failed to build query", req.RedirectURI, "server_error", req.State)
return
}
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": fmt.Sprintf("%s?%s", req.RedirectURI, queries.Encode()),
})
}
func (controller *OIDCController) Token(c *gin.Context) {
var req TokenRequest
err := c.Bind(&req)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to bind token request")
c.JSON(400, gin.H{
"error": "invalid_request",
})
return
}
err = controller.oidc.ValidateGrantType(req.GrantType)
if err != nil {
tlog.App.Warn().Str("grant_type", req.GrantType).Msg("Unsupported grant type")
c.JSON(400, gin.H{
"error": err.Error(),
})
return
}
rclientId, rclientSecret, ok := c.Request.BasicAuth()
if !ok {
tlog.App.Error().Msg("Missing authorization header")
c.Header("www-authenticate", "basic")
c.JSON(401, gin.H{
"error": "invalid_client",
})
return
}
client, ok := controller.oidc.GetClient(rclientId)
if !ok {
tlog.App.Warn().Str("client_id", rclientId).Msg("Client not found")
c.JSON(400, gin.H{
"error": "invalid_client",
})
return
}
if client.ClientSecret != rclientSecret {
tlog.App.Warn().Str("client_id", rclientId).Msg("Invalid client secret")
c.JSON(400, gin.H{
"error": "invalid_client",
})
return
}
var tokenResponse service.TokenResponse
switch req.GrantType {
case "authorization_code":
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code))
if err != nil {
if errors.Is(err, service.ErrCodeNotFound) {
tlog.App.Warn().Str("code", req.Code).Msg("Code not found")
c.JSON(400, gin.H{
"error": "invalid_grant",
})
return
}
if errors.Is(err, service.ErrCodeExpired) {
tlog.App.Warn().Str("code", req.Code).Msg("Code expired")
c.JSON(400, gin.H{
"error": "invalid_grant",
})
return
}
tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry")
c.JSON(400, gin.H{
"error": "server_error",
})
return
}
if entry.RedirectURI != req.RedirectURI {
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI mismatch")
c.JSON(400, gin.H{
"error": "invalid_grant",
})
return
}
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry.Sub, entry.Scope)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to generate access token")
c.JSON(400, gin.H{
"error": "server_error",
})
return
}
tokenResponse = tokenRes
case "refresh_token":
tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken)
if err != nil {
if errors.Is(err, service.ErrTokenExpired) {
tlog.App.Error().Err(err).Msg("Refresh token expired")
c.JSON(401, gin.H{
"error": "invalid_grant",
})
return
}
tlog.App.Error().Err(err).Msg("Failed to refresh access token")
c.JSON(400, gin.H{
"error": "server_error",
})
return
}
tokenResponse = tokenRes
}
c.JSON(200, tokenResponse)
}
func (controller *OIDCController) Userinfo(c *gin.Context) {
authorization := c.GetHeader("Authorization")
tokenType, token, ok := strings.Cut(authorization, " ")
if !ok {
tlog.App.Warn().Msg("OIDC userinfo accessed without authorization header")
c.JSON(401, gin.H{
"error": "invalid_grant",
})
return
}
if strings.ToLower(tokenType) != "bearer" {
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token type")
c.JSON(401, gin.H{
"error": "invalid_grant",
})
return
}
entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
if err != nil {
if err == service.ErrTokenNotFound {
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token")
c.JSON(401, gin.H{
"error": "invalid_grant",
})
return
}
tlog.App.Err(err).Msg("Failed to get token entry")
c.JSON(401, gin.H{
"error": "server_error",
})
return
}
// If we don't have the openid scope, return an error
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
tlog.App.Warn().Msg("OIDC userinfo accessed without openid scope")
c.JSON(401, gin.H{
"error": "invalid_scope",
})
return
}
user, err := controller.oidc.GetUserinfo(c, entry.Sub)
if err != nil {
tlog.App.Err(err).Msg("Failed to get user entry")
c.JSON(401, gin.H{
"error": "server_error",
})
return
}
c.JSON(200, controller.oidc.CompileUserinfo(user, entry.Scope))
}
func (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {
tlog.App.Error().Err(err).Msg(reason)
if callback != "" {
errorQueries := CallbackError{
Error: callbackError,
}
if reasonUser != "" {
errorQueries.ErrorDescription = reasonUser
}
if state != "" {
errorQueries.State = state
}
queries, err := query.Values(errorQueries)
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": fmt.Sprintf("%s?%s", callback, queries.Encode()),
})
return
}
errorQueries := ErrorScreen{
Error: reasonUser,
}
queries, err := query.Values(errorQueries)
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": fmt.Sprintf("%s/error?%s", controller.oidc.GetIssuer(), queries.Encode()),
})
}

View File

@@ -1,279 +0,0 @@
package controller_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"gotest.tools/v3/assert"
)
var oidcServiceConfig = service.OIDCServiceConfig{
Clients: map[string]config.OIDCClientConfig{
"client1": {
ClientID: "some-client-id",
ClientSecret: "some-client-secret",
ClientSecretFile: "",
TrustedRedirectURIs: []string{
"https://example.com/oauth/callback",
},
Name: "Client 1",
},
},
PrivateKeyPath: "/tmp/tinyauth_oidc_key",
PublicKeyPath: "/tmp/tinyauth_oidc_key.pub",
Issuer: "https://example.com",
SessionExpiry: 3600,
}
var oidcCtrlTestContext = config.UserContext{
Username: "test",
Name: "Test",
Email: "test@example.com",
IsLoggedIn: true,
IsBasicAuth: false,
OAuth: false,
Provider: "ldap", // ldap in order to test the groups
TotpPending: false,
OAuthGroups: "",
TotpEnabled: false,
OAuthName: "",
OAuthSub: "",
LdapGroups: "test1,test2",
}
// Test is not amazing, but it will confirm the OIDC server works
func TestOIDCController(t *testing.T) {
tlog.NewSimpleLogger().Init()
// Create an app instance
app := bootstrap.NewBootstrapApp(config.Config{})
// Get db
db, err := app.SetupDatabase("/tmp/tinyauth.db")
assert.NilError(t, err)
// Create queries
queries := repository.New(db)
// Create a new OIDC Servicee
oidcService := service.NewOIDCService(oidcServiceConfig, queries)
err = oidcService.Init()
assert.NilError(t, err)
// Create test router
gin.SetMode(gin.TestMode)
router := gin.Default()
router.Use(func(c *gin.Context) {
c.Set("context", &oidcCtrlTestContext)
c.Next()
})
group := router.Group("/api")
// Register oidc controller
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, oidcService, group)
oidcController.SetupRoutes()
// Get redirect URL test
recorder := httptest.NewRecorder()
marshalled, err := json.Marshal(service.AuthorizeRequest{
Scope: "openid profile email groups",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://example.com/oauth/callback",
State: "some-state",
})
assert.NilError(t, err)
req, err := http.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(marshalled)))
assert.NilError(t, err)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
resJson := map[string]any{}
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
assert.NilError(t, err)
redirect_uri, ok := resJson["redirect_uri"].(string)
assert.Assert(t, ok)
u, err := url.Parse(redirect_uri)
assert.NilError(t, err)
m, err := url.ParseQuery(u.RawQuery)
assert.NilError(t, err)
assert.Equal(t, m["state"][0], "some-state")
code := m["code"][0]
// Exchange code for token
recorder = httptest.NewRecorder()
params, err := query.Values(controller.TokenRequest{
GrantType: "authorization_code",
Code: code,
RedirectURI: "https://example.com/oauth/callback",
})
assert.NilError(t, err)
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
assert.NilError(t, err)
req.Header.Set("content-type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
resJson = map[string]any{}
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
assert.NilError(t, err)
accessToken, ok := resJson["access_token"].(string)
assert.Assert(t, ok)
_, ok = resJson["id_token"].(string)
assert.Assert(t, ok)
refreshToken, ok := resJson["refresh_token"].(string)
assert.Assert(t, ok)
expires_in, ok := resJson["expires_in"].(float64)
assert.Assert(t, ok)
assert.Equal(t, expires_in, float64(oidcServiceConfig.SessionExpiry))
// Ensure code is expired
recorder = httptest.NewRecorder()
params, err = query.Values(controller.TokenRequest{
GrantType: "authorization_code",
Code: code,
RedirectURI: "https://example.com/oauth/callback",
})
assert.NilError(t, err)
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
req.Header.Set("content-type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
// Test userinfo
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
assert.NilError(t, err)
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", accessToken))
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
resJson = map[string]any{}
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
assert.NilError(t, err)
_, ok = resJson["sub"].(string)
assert.Assert(t, ok)
name, ok := resJson["name"].(string)
assert.Assert(t, ok)
assert.Equal(t, name, oidcCtrlTestContext.Name)
email, ok := resJson["email"].(string)
assert.Assert(t, ok)
assert.Equal(t, email, oidcCtrlTestContext.Email)
preferred_username, ok := resJson["preferred_username"].(string)
assert.Assert(t, ok)
assert.Equal(t, preferred_username, oidcCtrlTestContext.Username)
// Not sure why this is failing, will look into it later
igroups, ok := resJson["groups"].([]any)
assert.Assert(t, ok)
groups := make([]string, len(igroups))
for i, group := range igroups {
groups[i], ok = group.(string)
assert.Assert(t, ok)
}
assert.DeepEqual(t, strings.Split(oidcCtrlTestContext.LdapGroups, ","), groups)
// Test refresh token
recorder = httptest.NewRecorder()
params, err = query.Values(controller.TokenRequest{
GrantType: "refresh_token",
RefreshToken: refreshToken,
})
assert.NilError(t, err)
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
assert.NilError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("some-client-id", "some-client-secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
resJson = map[string]any{}
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
assert.NilError(t, err)
newToken, ok := resJson["access_token"].(string)
assert.Assert(t, ok)
assert.Assert(t, newToken != accessToken)
// Ensure old token is invalid
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
assert.NilError(t, err)
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", accessToken))
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
// Test new token
recorder = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
assert.NilError(t, err)
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", newToken))
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
}

View File

@@ -9,10 +9,10 @@ import (
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
"github.com/rs/zerolog/log"
) )
var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"} var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"}
@@ -43,8 +43,8 @@ func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, a
func (controller *ProxyController) SetupRoutes() { func (controller *ProxyController) SetupRoutes() {
proxyGroup := controller.router.Group("/auth") proxyGroup := controller.router.Group("/auth")
// There is a later check to control allowed methods per proxy proxyGroup.GET("/:proxy", controller.proxyHandler)
proxyGroup.Any("/:proxy", controller.proxyHandler) proxyGroup.POST("/:proxy", controller.proxyHandler)
} }
func (controller *ProxyController) proxyHandler(c *gin.Context) { func (controller *ProxyController) proxyHandler(c *gin.Context) {
@@ -52,7 +52,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
err := c.BindUri(&req) err := c.BindUri(&req)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to bind URI") log.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -61,7 +61,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
if !slices.Contains(SupportedProxies, req.Proxy) { if !slices.Contains(SupportedProxies, req.Proxy) {
tlog.App.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy") log.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -69,25 +69,12 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
// Only allow GET for non-envoy proxies.
// Envoy uses the original client method for the external auth request
// so we allow Any standard HTTP method for /api/auth/envoy
if req.Proxy != "envoy" && c.Request.Method != http.MethodGet {
tlog.App.Warn().Str("method", c.Request.Method).Msg("Invalid method for proxy")
c.Header("Allow", "GET")
c.JSON(405, gin.H{
"status": 405,
"message": "Method Not Allowed",
})
return
}
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html") isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
if isBrowser { if isBrowser {
tlog.App.Debug().Msg("Request identified as (most likely) coming from a browser") log.Debug().Msg("Request identified as (most likely) coming from a browser")
} else { } else {
tlog.App.Debug().Msg("Request identified as (most likely) coming from a non-browser client") log.Debug().Msg("Request identified as (most likely) coming from a non-browser client")
} }
uri := c.Request.Header.Get("X-Forwarded-Uri") uri := c.Request.Header.Get("X-Forwarded-Uri")
@@ -98,12 +85,12 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
acls, err := controller.acls.GetAccessControls(host) acls, err := controller.acls.GetAccessControls(host)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to get access controls for resource") log.Error().Err(err).Msg("Failed to get access controls for resource")
controller.handleError(c, req, isBrowser) controller.handleError(c, req, isBrowser)
return return
} }
tlog.App.Trace().Interface("acls", acls).Msg("ACLs for resource") log.Trace().Interface("acls", acls).Msg("ACLs for resource")
clientIP := c.ClientIP() clientIP := c.ClientIP()
@@ -119,13 +106,13 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
authEnabled, err := controller.auth.IsAuthEnabled(uri, acls.Path) authEnabled, err := controller.auth.IsAuthEnabled(uri, acls.Path)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource") log.Error().Err(err).Msg("Failed to check if auth is enabled for resource")
controller.handleError(c, req, isBrowser) controller.handleError(c, req, isBrowser)
return return
} }
if !authEnabled { if !authEnabled {
tlog.App.Debug().Msg("Authentication disabled for resource, allowing access") log.Debug().Msg("Authentication disabled for resource, allowing access")
controller.setHeaders(c, acls) controller.setHeaders(c, acls)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -149,7 +136,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
}) })
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query") log.Error().Err(err).Msg("Failed to encode unauthorized query")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -163,7 +150,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
context, err := utils.GetContext(c) context, err := utils.GetContext(c)
if err != nil { if err != nil {
tlog.App.Debug().Msg("No user context found in request, treating as not logged in") log.Debug().Msg("No user context found in request, treating as not logged in")
userContext = config.UserContext{ userContext = config.UserContext{
IsLoggedIn: false, IsLoggedIn: false,
} }
@@ -171,18 +158,18 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
userContext = context userContext = context
} }
tlog.App.Trace().Interface("context", userContext).Msg("User context from request") log.Trace().Interface("context", userContext).Msg("User context from request")
if userContext.IsBasicAuth && userContext.TotpEnabled { if userContext.Provider == "basic" && userContext.TotpEnabled {
tlog.App.Debug().Msg("User has TOTP enabled, denying basic auth access") log.Debug().Msg("User has TOTP enabled, denying basic auth access")
userContext.IsLoggedIn = false userContext.IsLoggedIn = false
} }
if userContext.IsLoggedIn { if userContext.IsLoggedIn {
userAllowed := controller.auth.IsUserAllowed(c, userContext, acls) appAllowed := controller.auth.IsResourceAllowed(c, userContext, acls)
if !userAllowed { if !appAllowed {
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource")
if req.Proxy == "nginx" || !isBrowser { if req.Proxy == "nginx" || !isBrowser {
c.JSON(403, gin.H{ c.JSON(403, gin.H{
@@ -197,7 +184,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
}) })
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query") log.Error().Err(err).Msg("Failed to encode unauthorized query")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -212,17 +199,11 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
if userContext.OAuth || userContext.Provider == "ldap" { if userContext.OAuth {
var groupOK bool groupOK := controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups)
if userContext.OAuth {
groupOK = controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups)
} else {
groupOK = controller.auth.IsInLdapGroup(c, userContext, acls.LDAP.Groups)
}
if !groupOK { if !groupOK {
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User groups do not match resource requirements") log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements")
if req.Proxy == "nginx" || !isBrowser { if req.Proxy == "nginx" || !isBrowser {
c.JSON(403, gin.H{ c.JSON(403, gin.H{
@@ -238,7 +219,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
}) })
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query") log.Error().Err(err).Msg("Failed to encode unauthorized query")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -257,13 +238,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
c.Header("Remote-User", utils.SanitizeHeader(userContext.Username)) c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name)) c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
if userContext.Provider == "ldap" {
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.LdapGroups))
} else if userContext.Provider != "local" {
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
}
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub)) c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub))
controller.setHeaders(c, acls) controller.setHeaders(c, acls)
@@ -288,7 +263,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
}) })
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query") log.Error().Err(err).Msg("Failed to encode redirect URI query")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -302,14 +277,14 @@ func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) {
headers := utils.ParseHeaders(acls.Response.Headers) headers := utils.ParseHeaders(acls.Response.Headers)
for key, value := range headers { for key, value := range headers {
tlog.App.Debug().Str("header", key).Msg("Setting header") log.Debug().Str("header", key).Msg("Setting header")
c.Header(key, value) c.Header(key, value)
} }
basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile) basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile)
if acls.Response.BasicAuth.Username != "" && basicPassword != "" { if acls.Response.BasicAuth.Username != "" && basicPassword != "" {
tlog.App.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header") log.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header")
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword))) c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))
} }
} }

View File

@@ -4,20 +4,15 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
) )
func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder, *service.AuthService) { func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder, *service.AuthService) {
tlog.NewSimpleLogger().Init()
// Setup // Setup
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.Default() router := gin.Default()
@@ -31,16 +26,14 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En
group := router.Group("/api") group := router.Group("/api")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
// Mock app
app := bootstrap.NewBootstrapApp(config.Config{})
// Database // Database
db, err := app.SetupDatabase(":memory:") databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{
DatabasePath: "/tmp/tinyauth_test.db",
})
assert.NilError(t, err) assert.NilError(t, databaseService.Init())
// Queries database := databaseService.GetDatabase()
queries := repository.New(db)
// Docker // Docker
dockerService := service.NewDockerService() dockerService := service.NewDockerService()
@@ -60,15 +53,14 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En
Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test
}, },
}, },
OauthWhitelist: []string{}, OauthWhitelist: "",
SessionExpiry: 3600, SessionExpiry: 3600,
SessionMaxLifetime: 0, SecureCookie: false,
SecureCookie: false, CookieDomain: "localhost",
CookieDomain: "localhost", LoginTimeout: 300,
LoginTimeout: 300, LoginMaxRetries: 3,
LoginMaxRetries: 3, SessionCookieName: "tinyauth-session",
SessionCookieName: "tinyauth-session", }, dockerService, nil, database)
}, dockerService, nil, queries)
// Controller // Controller
ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ ctrl := controller.NewProxyController(controller.ProxyControllerConfig{
@@ -89,14 +81,6 @@ func TestProxyHandler(t *testing.T) {
assert.Equal(t, 400, recorder.Code) assert.Equal(t, 400, recorder.Code)
// Test invalid method for non-envoy proxy
recorder = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/auth/traefik", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 405, recorder.Code)
assert.Equal(t, "GET", recorder.Header().Get("Allow"))
// Test logged out user (traefik/caddy) // Test logged out user (traefik/caddy)
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/api/auth/traefik", nil) req = httptest.NewRequest("GET", "/api/auth/traefik", nil)
@@ -109,7 +93,7 @@ func TestProxyHandler(t *testing.T) {
assert.Equal(t, 307, recorder.Code) 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")) assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
// Test logged out user (envoy - POST method) // Test logged out user (envoy)
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/auth/envoy", nil) req = httptest.NewRequest("POST", "/api/auth/envoy", nil)
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
@@ -121,18 +105,6 @@ func TestProxyHandler(t *testing.T) {
assert.Equal(t, 307, recorder.Code) 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")) assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
// Test logged out user (envoy - DELETE method)
recorder = httptest.NewRecorder()
req = httptest.NewRequest("DELETE", "/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) // Test logged out user (nginx)
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/api/auth/nginx", nil) req = httptest.NewRequest("GET", "/api/auth/nginx", nil)
@@ -143,11 +115,11 @@ func TestProxyHandler(t *testing.T) {
// Test logged in user // Test logged in user
c := gin.CreateTestContextOnly(recorder, router) c := gin.CreateTestContextOnly(recorder, router)
err := authService.CreateSessionCookie(c, &repository.Session{ err := authService.CreateSessionCookie(c, &config.SessionCookie{
Username: "testuser", Username: "testuser",
Name: "testuser", Name: "testuser",
Email: "testuser@example.com", Email: "testuser@example.com",
Provider: "local", Provider: "username",
TotpPending: false, TotpPending: false,
OAuthGroups: "", OAuthGroups: "",
}) })
@@ -164,7 +136,7 @@ func TestProxyHandler(t *testing.T) {
Email: "testuser@example.com", Email: "testuser@example.com",
IsLoggedIn: true, IsLoggedIn: true,
OAuth: false, OAuth: false,
Provider: "local", Provider: "username",
TotpPending: false, TotpPending: false,
OAuthGroups: "", OAuthGroups: "",
TotpEnabled: false, TotpEnabled: false,
@@ -192,9 +164,8 @@ func TestProxyHandler(t *testing.T) {
Name: "testuser", Name: "testuser",
Email: "testuser@example.com", Email: "testuser@example.com",
IsLoggedIn: true, IsLoggedIn: true,
IsBasicAuth: true,
OAuth: false, OAuth: false,
Provider: "local", Provider: "basic",
TotpPending: false, TotpPending: false,
OAuthGroups: "", OAuthGroups: "",
TotpEnabled: true, TotpEnabled: true,

View File

@@ -3,15 +3,14 @@ package controller
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log"
) )
type LoginRequest struct { type LoginRequest struct {
@@ -53,7 +52,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
err := c.ShouldBindJSON(&req) err := c.ShouldBindJSON(&req)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to bind JSON") log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -61,18 +60,23 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return return
} }
tlog.App.Debug().Str("username", req.Username).Msg("Login attempt") clientIP := c.ClientIP()
isLocked, remaining := controller.auth.IsAccountLocked(req.Username) rateIdentifier := req.Username
if rateIdentifier == "" {
rateIdentifier = clientIP
}
log.Debug().Str("username", req.Username).Str("ip", clientIP).Msg("Login attempt")
isLocked, remainingTime := controller.auth.IsAccountLocked(rateIdentifier)
if isLocked { if isLocked {
tlog.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts") log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed login attempts")
tlog.AuditLoginFailure(c, req.Username, "username", "account locked")
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{ c.JSON(429, gin.H{
"status": 429, "status": 429,
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remaining), "message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime),
}) })
return return
} }
@@ -80,9 +84,8 @@ func (controller *UserController) loginHandler(c *gin.Context) {
userSearch := controller.auth.SearchUser(req.Username) userSearch := controller.auth.SearchUser(req.Username)
if userSearch.Type == "unknown" { if userSearch.Type == "unknown" {
tlog.App.Warn().Str("username", req.Username).Msg("User not found") log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("User not found")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(rateIdentifier, false)
tlog.AuditLoginFailure(c, req.Username, "username", "user not found")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -91,9 +94,8 @@ func (controller *UserController) loginHandler(c *gin.Context) {
} }
if !controller.auth.VerifyUser(userSearch, req.Password) { if !controller.auth.VerifyUser(userSearch, req.Password) {
tlog.App.Warn().Str("username", req.Username).Msg("Invalid password") log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Invalid password")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(rateIdentifier, false)
tlog.AuditLoginFailure(c, req.Username, "username", "invalid password")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -101,27 +103,26 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return return
} }
tlog.App.Info().Str("username", req.Username).Msg("Login successful") log.Info().Str("username", req.Username).Str("ip", clientIP).Msg("Login successful")
tlog.AuditLoginSuccess(c, req.Username, "username")
controller.auth.RecordLoginAttempt(req.Username, true) controller.auth.RecordLoginAttempt(rateIdentifier, true)
if userSearch.Type == "local" { if userSearch.Type == "local" {
user := controller.auth.GetLocalUser(userSearch.Username) user := controller.auth.GetLocalUser(userSearch.Username)
if user.TotpSecret != "" { if user.TotpSecret != "" {
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification") log.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
err := controller.auth.CreateSessionCookie(c, &repository.Session{ err := controller.auth.CreateSessionCookie(c, &config.SessionCookie{
Username: user.Username, Username: user.Username,
Name: utils.Capitalize(req.Username), Name: utils.Capitalize(req.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain), Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain),
Provider: "local", Provider: "username",
TotpPending: true, TotpPending: true,
}) })
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create session cookie") log.Error().Err(err).Msg("Failed to create session cookie")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -138,23 +139,19 @@ func (controller *UserController) loginHandler(c *gin.Context) {
} }
} }
sessionCookie := repository.Session{ sessionCookie := config.SessionCookie{
Username: req.Username, Username: req.Username,
Name: utils.Capitalize(req.Username), Name: utils.Capitalize(req.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain), Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain),
Provider: "local", Provider: "username",
} }
if userSearch.Type == "ldap" { log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
sessionCookie.Provider = "ldap"
}
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
err = controller.auth.CreateSessionCookie(c, &sessionCookie) err = controller.auth.CreateSessionCookie(c, &sessionCookie)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create session cookie") log.Error().Err(err).Msg("Failed to create session cookie")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -169,15 +166,10 @@ func (controller *UserController) loginHandler(c *gin.Context) {
} }
func (controller *UserController) logoutHandler(c *gin.Context) { func (controller *UserController) logoutHandler(c *gin.Context) {
tlog.App.Debug().Msg("Logout request received") log.Debug().Msg("Logout request received")
controller.auth.DeleteSessionCookie(c) controller.auth.DeleteSessionCookie(c)
context, err := utils.GetContext(c)
if err == nil && context.IsLoggedIn {
tlog.AuditLogout(c, context.Username, context.Provider)
}
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"message": "Logout successful", "message": "Logout successful",
@@ -189,7 +181,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
err := c.ShouldBindJSON(&req) err := c.ShouldBindJSON(&req)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to bind JSON") log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -200,7 +192,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
context, err := utils.GetContext(c) context, err := utils.GetContext(c)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to get user context") log.Error().Err(err).Msg("Failed to get user context")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -209,7 +201,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
} }
if !context.TotpPending { if !context.TotpPending {
tlog.App.Warn().Msg("TOTP attempt without a pending TOTP session") log.Warn().Msg("TOTP attempt without a pending TOTP session")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -217,17 +209,23 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return return
} }
tlog.App.Debug().Str("username", context.Username).Msg("TOTP verification attempt") clientIP := c.ClientIP()
isLocked, remaining := controller.auth.IsAccountLocked(context.Username) rateIdentifier := context.Username
if rateIdentifier == "" {
rateIdentifier = clientIP
}
log.Debug().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification attempt")
isLocked, remainingTime := controller.auth.IsAccountLocked(rateIdentifier)
if isLocked { if isLocked {
tlog.App.Warn().Str("username", context.Username).Msg("Account is locked due to too many failed TOTP attempts") log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed TOTP attempts")
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{ c.JSON(429, gin.H{
"status": 429, "status": 429,
"message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remaining), "message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remainingTime),
}) })
return return
} }
@@ -237,9 +235,8 @@ func (controller *UserController) totpHandler(c *gin.Context) {
ok := totp.Validate(req.Code, user.TotpSecret) ok := totp.Validate(req.Code, user.TotpSecret)
if !ok { if !ok {
tlog.App.Warn().Str("username", context.Username).Msg("Invalid TOTP code") log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Invalid TOTP code")
controller.auth.RecordLoginAttempt(context.Username, false) controller.auth.RecordLoginAttempt(rateIdentifier, false)
tlog.AuditLoginFailure(c, context.Username, "totp", "invalid totp code")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -247,24 +244,23 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return return
} }
tlog.App.Info().Str("username", context.Username).Msg("TOTP verification successful") log.Info().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification successful")
tlog.AuditLoginSuccess(c, context.Username, "totp")
controller.auth.RecordLoginAttempt(context.Username, true) controller.auth.RecordLoginAttempt(rateIdentifier, true)
sessionCookie := repository.Session{ sessionCookie := config.SessionCookie{
Username: user.Username, Username: user.Username,
Name: utils.Capitalize(user.Username), Name: utils.Capitalize(user.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.config.CookieDomain), Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.config.CookieDomain),
Provider: "local", Provider: "username",
} }
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
err = controller.auth.CreateSessionCookie(c, &sessionCookie) err = controller.auth.CreateSessionCookie(c, &sessionCookie)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create session cookie") log.Error().Err(err).Msg("Failed to create session cookie")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",

View File

@@ -8,12 +8,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller" "github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@@ -24,8 +21,6 @@ var cookieValue string
var totpSecret = "6WFZXPEZRK5MZHHYAFW4DAOUYQMCASBJ" var totpSecret = "6WFZXPEZRK5MZHHYAFW4DAOUYQMCASBJ"
func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {
tlog.NewSimpleLogger().Init()
// Setup // Setup
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.Default() router := gin.Default()
@@ -39,16 +34,14 @@ func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Eng
group := router.Group("/api") group := router.Group("/api")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
// Mock app
app := bootstrap.NewBootstrapApp(config.Config{})
// Database // Database
db, err := app.SetupDatabase(":memory:") databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{
DatabasePath: "/tmp/tinyauth_test.db",
})
assert.NilError(t, err) assert.NilError(t, databaseService.Init())
// Queries database := databaseService.GetDatabase()
queries := repository.New(db)
// Auth service // Auth service
authService := service.NewAuthService(service.AuthServiceConfig{ authService := service.NewAuthService(service.AuthServiceConfig{
@@ -63,15 +56,14 @@ func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Eng
TotpSecret: totpSecret, TotpSecret: totpSecret,
}, },
}, },
OauthWhitelist: []string{}, OauthWhitelist: "",
SessionExpiry: 3600, SessionExpiry: 3600,
SessionMaxLifetime: 0, SecureCookie: false,
SecureCookie: false, CookieDomain: "localhost",
CookieDomain: "localhost", LoginTimeout: 300,
LoginTimeout: 300, LoginMaxRetries: 3,
LoginMaxRetries: 3, SessionCookieName: "tinyauth-session",
SessionCookieName: "tinyauth-session", }, nil, nil, database)
}, nil, nil, queries)
// Controller // Controller
ctrl := controller.NewUserController(controller.UserControllerConfig{ ctrl := controller.NewUserController(controller.UserControllerConfig{
@@ -204,7 +196,7 @@ func TestTotpHandler(t *testing.T) {
Email: "totpuser@example.com", Email: "totpuser@example.com",
IsLoggedIn: false, IsLoggedIn: false,
OAuth: false, OAuth: false,
Provider: "local", Provider: "username",
TotpPending: true, TotpPending: true,
OAuthGroups: "", OAuthGroups: "",
TotpEnabled: true, TotpEnabled: true,
@@ -267,7 +259,7 @@ func TestTotpHandler(t *testing.T) {
Email: "totpuser@example.com", Email: "totpuser@example.com",
IsLoggedIn: false, IsLoggedIn: false,
OAuth: false, OAuth: false,
Provider: "local", Provider: "username",
TotpPending: true, TotpPending: true,
OAuthGroups: "", OAuthGroups: "",
TotpEnabled: true, TotpEnabled: true,
@@ -290,7 +282,7 @@ func TestTotpHandler(t *testing.T) {
Email: "totpuser@example.com", Email: "totpuser@example.com",
IsLoggedIn: false, IsLoggedIn: false,
OAuth: false, OAuth: false,
Provider: "local", Provider: "username",
TotpPending: false, TotpPending: false,
OAuthGroups: "", OAuthGroups: "",
TotpEnabled: false, TotpEnabled: false,

View File

@@ -2,20 +2,16 @@ package middleware
import ( import (
"fmt" "fmt"
"slices"
"strings" "strings"
"time"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
) )
var OIDCIgnorePaths = []string{"/api/oidc/token", "/api/oidc/userinfo"}
type ContextMiddlewareConfig struct { type ContextMiddlewareConfig struct {
CookieDomain string CookieDomain string
} }
@@ -40,17 +36,10 @@ func (m *ContextMiddleware) Init() error {
func (m *ContextMiddleware) Middleware() gin.HandlerFunc { func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// There is no point in trying to get credentials if it's an OIDC endpoint
path := c.Request.URL.Path
if slices.Contains(OIDCIgnorePaths, strings.TrimSuffix(path, "/")) {
c.Next()
return
}
cookie, err := m.auth.GetSessionCookie(c) cookie, err := m.auth.GetSessionCookie(c)
if err != nil { if err != nil {
tlog.App.Debug().Err(err).Msg("No valid session cookie found") log.Debug().Err(err).Msg("No valid session cookie found")
goto basic goto basic
} }
@@ -59,7 +48,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
Username: cookie.Username, Username: cookie.Username,
Name: cookie.Name, Name: cookie.Name,
Email: cookie.Email, Email: cookie.Email,
Provider: "local", Provider: "username",
TotpPending: true, TotpPending: true,
TotpEnabled: true, TotpEnabled: true,
}) })
@@ -68,44 +57,22 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
} }
switch cookie.Provider { switch cookie.Provider {
case "local", "ldap": case "username":
userSearch := m.auth.SearchUser(cookie.Username) userSearch := m.auth.SearchUser(cookie.Username)
if userSearch.Type == "unknown" { if userSearch.Type == "unknown" || userSearch.Type == "error" {
tlog.App.Debug().Msg("User from session cookie not found") log.Debug().Msg("User from session cookie not found")
m.auth.DeleteSessionCookie(c) m.auth.DeleteSessionCookie(c)
goto basic goto basic
} }
if userSearch.Type != cookie.Provider {
tlog.App.Warn().Msg("User type from session cookie does not match user search type")
m.auth.DeleteSessionCookie(c)
c.Next()
return
}
var ldapGroups []string
if cookie.Provider == "ldap" {
ldapUser, err := m.auth.GetLdapUser(userSearch.Username)
if err != nil {
tlog.App.Error().Err(err).Msg("Error retrieving LDAP user details")
c.Next()
return
}
ldapGroups = ldapUser.Groups
}
m.auth.RefreshSessionCookie(c) m.auth.RefreshSessionCookie(c)
c.Set("context", &config.UserContext{ c.Set("context", &config.UserContext{
Username: cookie.Username, Username: cookie.Username,
Name: cookie.Name, Name: cookie.Name,
Email: cookie.Email, Email: cookie.Email,
Provider: cookie.Provider, Provider: "username",
IsLoggedIn: true, IsLoggedIn: true,
LdapGroups: strings.Join(ldapGroups, ","),
}) })
c.Next() c.Next()
return return
@@ -113,13 +80,13 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
_, exists := m.broker.GetService(cookie.Provider) _, exists := m.broker.GetService(cookie.Provider)
if !exists { if !exists {
tlog.App.Debug().Msg("OAuth provider from session cookie not found") log.Debug().Msg("OAuth provider from session cookie not found")
m.auth.DeleteSessionCookie(c) m.auth.DeleteSessionCookie(c)
goto basic goto basic
} }
if !m.auth.IsEmailWhitelisted(cookie.Email) { if !m.auth.IsEmailWhitelisted(cookie.Email) {
tlog.App.Debug().Msg("Email from session cookie not whitelisted") log.Debug().Msg("Email from session cookie not whitelisted")
m.auth.DeleteSessionCookie(c) m.auth.DeleteSessionCookie(c)
goto basic goto basic
} }
@@ -144,17 +111,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
basic := m.auth.GetBasicAuth(c) basic := m.auth.GetBasicAuth(c)
if basic == nil { if basic == nil {
tlog.App.Debug().Msg("No basic auth provided") log.Debug().Msg("No basic auth provided")
c.Next()
return
}
locked, remaining := m.auth.IsAccountLocked(basic.Username)
if locked {
tlog.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", basic.Username, remaining)
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.Next() c.Next()
return return
} }
@@ -162,24 +119,20 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
userSearch := m.auth.SearchUser(basic.Username) userSearch := m.auth.SearchUser(basic.Username)
if userSearch.Type == "unknown" || userSearch.Type == "error" { if userSearch.Type == "unknown" || userSearch.Type == "error" {
m.auth.RecordLoginAttempt(basic.Username, false) log.Debug().Msg("User from basic auth not found")
tlog.App.Debug().Msg("User from basic auth not found")
c.Next() c.Next()
return return
} }
if !m.auth.VerifyUser(userSearch, basic.Password) { if !m.auth.VerifyUser(userSearch, basic.Password) {
m.auth.RecordLoginAttempt(basic.Username, false) log.Debug().Msg("Invalid password for basic auth user")
tlog.App.Debug().Msg("Invalid password for basic auth user")
c.Next() c.Next()
return return
} }
m.auth.RecordLoginAttempt(basic.Username, true)
switch userSearch.Type { switch userSearch.Type {
case "local": case "local":
tlog.App.Debug().Msg("Basic auth user is local") log.Debug().Msg("Basic auth user is local")
user := m.auth.GetLocalUser(basic.Username) user := m.auth.GetLocalUser(basic.Username)
@@ -187,32 +140,20 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
Username: user.Username, Username: user.Username,
Name: utils.Capitalize(user.Username), Name: utils.Capitalize(user.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.config.CookieDomain), Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.config.CookieDomain),
Provider: "local", Provider: "basic",
IsLoggedIn: true, IsLoggedIn: true,
TotpEnabled: user.TotpSecret != "", TotpEnabled: user.TotpSecret != "",
IsBasicAuth: true,
}) })
c.Next() c.Next()
return return
case "ldap": case "ldap":
tlog.App.Debug().Msg("Basic auth user is LDAP") log.Debug().Msg("Basic auth user is LDAP")
ldapUser, err := m.auth.GetLdapUser(basic.Username)
if err != nil {
tlog.App.Debug().Err(err).Msg("Error retrieving LDAP user details")
c.Next()
return
}
c.Set("context", &config.UserContext{ c.Set("context", &config.UserContext{
Username: basic.Username, Username: basic.Username,
Name: utils.Capitalize(basic.Username), Name: utils.Capitalize(basic.Username),
Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.config.CookieDomain), Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.config.CookieDomain),
Provider: "ldap", Provider: "basic",
IsLoggedIn: true, IsLoggedIn: true,
LdapGroups: strings.Join(ldapUser.Groups, ","),
IsBasicAuth: true,
}) })
c.Next() c.Next()
return return

View File

@@ -5,7 +5,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/rs/zerolog/log"
) )
var ( var (
@@ -49,7 +49,7 @@ func (m *ZerologMiddleware) Middleware() gin.HandlerFunc {
latency := time.Since(tStart).String() latency := time.Since(tStart).String()
subLogger := tlog.HTTP.With().Str("method", method). subLogger := log.With().Str("method", method).
Str("path", path). Str("path", path).
Str("address", address). Str("address", address).
Str("client_ip", clientIP). Str("client_ip", clientIP).

View File

@@ -0,0 +1,14 @@
package model
type Session struct {
UUID string `gorm:"column:uuid;primaryKey"`
Username string `gorm:"column:username"`
Email string `gorm:"column:email"`
Name string `gorm:"column:name"`
Provider string `gorm:"column:provider"`
TOTPPending bool `gorm:"column:totp_pending"`
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,31 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package repository
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -1,47 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package repository
type OidcCode struct {
Sub string
CodeHash string
Scope string
RedirectURI string
ClientID string
ExpiresAt int64
}
type OidcToken struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
type OidcUserinfo struct {
Sub string
Name string
PreferredUsername string
Email string
Groups string
UpdatedAt int64
}
type Session struct {
UUID string
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
CreatedAt int64
OAuthName string
OAuthSub string
}

View File

@@ -1,470 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: oidc_queries.sql
package repository
import (
"context"
)
const createOidcCode = `-- name: CreateOidcCode :one
INSERT INTO "oidc_codes" (
"sub",
"code_hash",
"scope",
"redirect_uri",
"client_id",
"expires_at"
) VALUES (
?, ?, ?, ?, ?, ?
)
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at
`
type CreateOidcCodeParams struct {
Sub string
CodeHash string
Scope string
RedirectURI string
ClientID string
ExpiresAt int64
}
func (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, createOidcCode,
arg.Sub,
arg.CodeHash,
arg.Scope,
arg.RedirectURI,
arg.ClientID,
arg.ExpiresAt,
)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
)
return i, err
}
const createOidcToken = `-- name: CreateOidcToken :one
INSERT INTO "oidc_tokens" (
"sub",
"access_token_hash",
"refresh_token_hash",
"scope",
"client_id",
"token_expires_at",
"refresh_token_expires_at"
) VALUES (
?, ?, ?, ?, ?, ?, ?
)
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at
`
type CreateOidcTokenParams struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
func (q *Queries) CreateOidcToken(ctx context.Context, arg CreateOidcTokenParams) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, createOidcToken,
arg.Sub,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.Scope,
arg.ClientID,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
)
return i, err
}
const createOidcUserInfo = `-- name: CreateOidcUserInfo :one
INSERT INTO "oidc_userinfo" (
"sub",
"name",
"preferred_username",
"email",
"groups",
"updated_at"
) VALUES (
?, ?, ?, ?, ?, ?
)
RETURNING sub, name, preferred_username, email, "groups", updated_at
`
type CreateOidcUserInfoParams struct {
Sub string
Name string
PreferredUsername string
Email string
Groups string
UpdatedAt int64
}
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
row := q.db.QueryRowContext(ctx, createOidcUserInfo,
arg.Sub,
arg.Name,
arg.PreferredUsername,
arg.Email,
arg.Groups,
arg.UpdatedAt,
)
var i OidcUserinfo
err := row.Scan(
&i.Sub,
&i.Name,
&i.PreferredUsername,
&i.Email,
&i.Groups,
&i.UpdatedAt,
)
return i, err
}
const deleteExpiredOidcCodes = `-- name: DeleteExpiredOidcCodes :many
DELETE FROM "oidc_codes"
WHERE "expires_at" < ?
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at
`
func (q *Queries) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]OidcCode, error) {
rows, err := q.db.QueryContext(ctx, deleteExpiredOidcCodes, expiresAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcCode
for rows.Next() {
var i OidcCode
if err := rows.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteExpiredOidcTokens = `-- name: DeleteExpiredOidcTokens :many
DELETE FROM "oidc_tokens"
WHERE "token_expires_at" < ? AND "refresh_token_expires_at" < ?
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at
`
type DeleteExpiredOidcTokensParams struct {
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
func (q *Queries) DeleteExpiredOidcTokens(ctx context.Context, arg DeleteExpiredOidcTokensParams) ([]OidcToken, error) {
rows, err := q.db.QueryContext(ctx, deleteExpiredOidcTokens, arg.TokenExpiresAt, arg.RefreshTokenExpiresAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcToken
for rows.Next() {
var i OidcToken
if err := rows.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteOidcCode = `-- name: DeleteOidcCode :exec
DELETE FROM "oidc_codes"
WHERE "code_hash" = ?
`
func (q *Queries) DeleteOidcCode(ctx context.Context, codeHash string) error {
_, err := q.db.ExecContext(ctx, deleteOidcCode, codeHash)
return err
}
const deleteOidcCodeBySub = `-- name: DeleteOidcCodeBySub :exec
DELETE FROM "oidc_codes"
WHERE "sub" = ?
`
func (q *Queries) DeleteOidcCodeBySub(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcCodeBySub, sub)
return err
}
const deleteOidcToken = `-- name: DeleteOidcToken :exec
DELETE FROM "oidc_tokens"
WHERE "access_token_hash" = ?
`
func (q *Queries) DeleteOidcToken(ctx context.Context, accessTokenHash string) error {
_, err := q.db.ExecContext(ctx, deleteOidcToken, accessTokenHash)
return err
}
const deleteOidcTokenBySub = `-- name: DeleteOidcTokenBySub :exec
DELETE FROM "oidc_tokens"
WHERE "sub" = ?
`
func (q *Queries) DeleteOidcTokenBySub(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcTokenBySub, sub)
return err
}
const deleteOidcUserInfo = `-- name: DeleteOidcUserInfo :exec
DELETE FROM "oidc_userinfo"
WHERE "sub" = ?
`
func (q *Queries) DeleteOidcUserInfo(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcUserInfo, sub)
return err
}
const getOidcCode = `-- name: GetOidcCode :one
DELETE FROM "oidc_codes"
WHERE "code_hash" = ?
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at
`
func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCode, codeHash)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
)
return i, err
}
const getOidcCodeBySub = `-- name: GetOidcCodeBySub :one
DELETE FROM "oidc_codes"
WHERE "sub" = ?
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at
`
func (q *Queries) GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeBySub, sub)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
)
return i, err
}
const getOidcCodeBySubUnsafe = `-- name: GetOidcCodeBySubUnsafe :one
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at FROM "oidc_codes"
WHERE "sub" = ?
`
func (q *Queries) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeBySubUnsafe, sub)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
)
return i, err
}
const getOidcCodeUnsafe = `-- name: GetOidcCodeUnsafe :one
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at FROM "oidc_codes"
WHERE "code_hash" = ?
`
func (q *Queries) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeUnsafe, codeHash)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
)
return i, err
}
const getOidcToken = `-- name: GetOidcToken :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at FROM "oidc_tokens"
WHERE "access_token_hash" = ?
`
func (q *Queries) GetOidcToken(ctx context.Context, accessTokenHash string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcToken, accessTokenHash)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
)
return i, err
}
const getOidcTokenByRefreshToken = `-- name: GetOidcTokenByRefreshToken :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at FROM "oidc_tokens"
WHERE "refresh_token_hash" = ?
`
func (q *Queries) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcTokenByRefreshToken, refreshTokenHash)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
)
return i, err
}
const getOidcTokenBySub = `-- name: GetOidcTokenBySub :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at FROM "oidc_tokens"
WHERE "sub" = ?
`
func (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcTokenBySub, sub)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
)
return i, err
}
const getOidcUserInfo = `-- name: GetOidcUserInfo :one
SELECT sub, name, preferred_username, email, "groups", updated_at FROM "oidc_userinfo"
WHERE "sub" = ?
`
func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo, error) {
row := q.db.QueryRowContext(ctx, getOidcUserInfo, sub)
var i OidcUserinfo
err := row.Scan(
&i.Sub,
&i.Name,
&i.PreferredUsername,
&i.Email,
&i.Groups,
&i.UpdatedAt,
)
return i, err
}
const updateOidcTokenByRefreshToken = `-- name: UpdateOidcTokenByRefreshToken :one
UPDATE "oidc_tokens" SET
"access_token_hash" = ?,
"refresh_token_hash" = ?,
"token_expires_at" = ?,
"refresh_token_expires_at" = ?
WHERE "refresh_token_hash" = ?
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at
`
type UpdateOidcTokenByRefreshTokenParams struct {
AccessTokenHash string
RefreshTokenHash string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
RefreshTokenHash_2 string
}
func (q *Queries) UpdateOidcTokenByRefreshToken(ctx context.Context, arg UpdateOidcTokenByRefreshTokenParams) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, updateOidcTokenByRefreshToken,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.RefreshTokenHash_2,
)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
)
return i, err
}

View File

@@ -1,176 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: session_queries.sql
package repository
import (
"context"
)
const createSession = `-- name: CreateSession :one
INSERT INTO "sessions" (
"uuid",
"username",
"email",
"name",
"provider",
"totp_pending",
"oauth_groups",
"expiry",
"created_at",
"oauth_name",
"oauth_sub"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub
`
type CreateSessionParams struct {
UUID string
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
CreatedAt int64
OAuthName string
OAuthSub string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, createSession,
arg.UUID,
arg.Username,
arg.Email,
arg.Name,
arg.Provider,
arg.TotpPending,
arg.OAuthGroups,
arg.Expiry,
arg.CreatedAt,
arg.OAuthName,
arg.OAuthSub,
)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
DELETE FROM "sessions"
WHERE "expiry" < ?
`
func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
_, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiry)
return err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM "sessions"
WHERE "uuid" = ?
`
func (q *Queries) DeleteSession(ctx context.Context, uuid string) error {
_, err := q.db.ExecContext(ctx, deleteSession, uuid)
return err
}
const getSession = `-- name: GetSession :one
SELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub FROM "sessions"
WHERE "uuid" = ?
`
func (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error) {
row := q.db.QueryRowContext(ctx, getSession, uuid)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
const updateSession = `-- name: UpdateSession :one
UPDATE "sessions" SET
"username" = ?,
"email" = ?,
"name" = ?,
"provider" = ?,
"totp_pending" = ?,
"oauth_groups" = ?,
"expiry" = ?,
"oauth_name" = ?,
"oauth_sub" = ?
WHERE "uuid" = ?
RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub
`
type UpdateSessionParams struct {
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
OAuthName string
OAuthSub string
UUID string
}
func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, updateSession,
arg.Username,
arg.Email,
arg.Name,
arg.Provider,
arg.TotpPending,
arg.OAuthGroups,
arg.Expiry,
arg.OAuthName,
arg.OAuthSub,
arg.UUID,
)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}

View File

@@ -4,8 +4,8 @@ import (
"errors" "errors"
"strings" "strings"
"github.com/rs/zerolog/log"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
) )
type AccessControlsService struct { type AccessControlsService struct {
@@ -27,12 +27,12 @@ func (acls *AccessControlsService) Init() error {
func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) { func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {
for app, config := range acls.static { for app, config := range acls.static {
if config.Config.Domain == domain { if config.Config.Domain == domain {
tlog.App.Debug().Str("name", app).Msg("Found matching container by domain") log.Debug().Str("name", app).Msg("Found matching container by domain")
return config, nil return config, nil
} }
if strings.SplitN(domain, ".", 2)[0] == app { if strings.SplitN(domain, ".", 2)[0] == app {
tlog.App.Debug().Str("name", app).Msg("Found matching container by app name") log.Debug().Str("name", app).Msg("Found matching container by app name")
return config, nil return config, nil
} }
} }
@@ -44,11 +44,11 @@ func (acls *AccessControlsService) GetAccessControls(domain string) (config.App,
app, err := acls.lookupStaticACLs(domain) app, err := acls.lookupStaticACLs(domain)
if err == nil { if err == nil {
tlog.App.Debug().Msg("Using ACls from static configuration") log.Debug().Msg("Using ACls from static configuration")
return app, nil return app, nil
} }
// Fallback to Docker labels // Fallback to Docker labels
tlog.App.Debug().Msg("Falling back to Docker labels for ACLs") log.Debug().Msg("Falling back to Docker labels for ACLs")
return acls.docker.GetLabels(domain) return acls.docker.GetLabels(domain)
} }

View File

@@ -1,7 +1,6 @@
package service package service
import ( import (
"database/sql"
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
@@ -10,20 +9,16 @@ import (
"time" "time"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/model"
"github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
type LdapGroupsCache struct {
Groups []string
Expires time.Time
}
type LoginAttempt struct { type LoginAttempt struct {
FailedAttempts int FailedAttempts int
LastAttempt time.Time LastAttempt time.Time
@@ -31,38 +26,32 @@ type LoginAttempt struct {
} }
type AuthServiceConfig struct { type AuthServiceConfig struct {
Users []config.User Users []config.User
OauthWhitelist []string OauthWhitelist string
SessionExpiry int SessionExpiry int
SessionMaxLifetime int SecureCookie bool
SecureCookie bool CookieDomain string
CookieDomain string LoginTimeout int
LoginTimeout int LoginMaxRetries int
LoginMaxRetries int SessionCookieName string
SessionCookieName string
IP config.IPConfig
LDAPGroupsCacheTTL int
} }
type AuthService struct { type AuthService struct {
config AuthServiceConfig config AuthServiceConfig
docker *DockerService docker *DockerService
loginAttempts map[string]*LoginAttempt loginAttempts map[string]*LoginAttempt
ldapGroupsCache map[string]*LdapGroupsCache loginMutex sync.RWMutex
loginMutex sync.RWMutex ldap *LdapService
ldapGroupsMutex sync.RWMutex database *gorm.DB
ldap *LdapService
queries *repository.Queries
} }
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries) *AuthService { func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, database *gorm.DB) *AuthService {
return &AuthService{ return &AuthService{
config: config, config: config,
docker: docker, docker: docker,
loginAttempts: make(map[string]*LoginAttempt), loginAttempts: make(map[string]*LoginAttempt),
ldapGroupsCache: make(map[string]*LdapGroupsCache), ldap: ldap,
ldap: ldap, database: database,
queries: queries,
} }
} }
@@ -79,12 +68,12 @@ func (auth *AuthService) SearchUser(username string) config.UserSearch {
} }
if auth.ldap != nil { if auth.ldap != nil {
userDN, err := auth.ldap.GetUserDN(username) userDN, err := auth.ldap.Search(username)
if err != nil { if err != nil {
tlog.App.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP") log.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP")
return config.UserSearch{ return config.UserSearch{
Type: "unknown", Type: "error",
} }
} }
@@ -108,24 +97,24 @@ func (auth *AuthService) VerifyUser(search config.UserSearch, password string) b
if auth.ldap != nil { if auth.ldap != nil {
err := auth.ldap.Bind(search.Username, password) err := auth.ldap.Bind(search.Username, password)
if err != nil { if err != nil {
tlog.App.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP") log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
return false return false
} }
err = auth.ldap.BindService(true) err = auth.ldap.Bind(auth.ldap.Config.BindDN, auth.ldap.Config.BindPassword)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to rebind with service account after user authentication") log.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
return false return false
} }
return true return true
} }
default: default:
tlog.App.Debug().Str("type", search.Type).Msg("Unknown user type for authentication") log.Debug().Str("type", search.Type).Msg("Unknown user type for authentication")
return false return false
} }
tlog.App.Warn().Str("username", search.Username).Msg("User authentication failed") log.Warn().Str("username", search.Username).Msg("User authentication failed")
return false return false
} }
@@ -136,45 +125,10 @@ func (auth *AuthService) GetLocalUser(username string) config.User {
} }
} }
tlog.App.Warn().Str("username", username).Msg("Local user not found") log.Warn().Str("username", username).Msg("Local user not found")
return config.User{} return config.User{}
} }
func (auth *AuthService) GetLdapUser(userDN string) (config.LdapUser, error) {
if auth.ldap == nil {
return config.LdapUser{}, errors.New("LDAP service not initialized")
}
auth.ldapGroupsMutex.RLock()
entry, exists := auth.ldapGroupsCache[userDN]
auth.ldapGroupsMutex.RUnlock()
if exists && time.Now().Before(entry.Expires) {
return config.LdapUser{
DN: userDN,
Groups: entry.Groups,
}, nil
}
groups, err := auth.ldap.GetUserGroups(userDN)
if err != nil {
return config.LdapUser{}, err
}
auth.ldapGroupsMutex.Lock()
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
Groups: groups,
Expires: time.Now().Add(time.Duration(auth.config.LDAPGroupsCacheTTL) * time.Second),
}
auth.ldapGroupsMutex.Unlock()
return config.LdapUser{
DN: userDN,
Groups: groups,
}, nil
}
func (auth *AuthService) CheckPassword(user config.User, password string) bool { func (auth *AuthService) CheckPassword(user config.User, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
} }
@@ -226,15 +180,15 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
if attempt.FailedAttempts >= auth.config.LoginMaxRetries { if attempt.FailedAttempts >= auth.config.LoginMaxRetries {
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second) attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second)
tlog.App.Warn().Str("identifier", identifier).Int("timeout", auth.config.LoginTimeout).Msg("Account locked due to too many failed login attempts") log.Warn().Str("identifier", identifier).Int("timeout", auth.config.LoginTimeout).Msg("Account locked due to too many failed login attempts")
} }
} }
func (auth *AuthService) IsEmailWhitelisted(email string) bool { func (auth *AuthService) IsEmailWhitelisted(email string) bool {
return utils.CheckFilter(strings.Join(auth.config.OauthWhitelist, ","), email) return utils.CheckFilter(auth.config.OauthWhitelist, email)
} }
func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *repository.Session) error { func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.SessionCookie) error {
uuid, err := uuid.NewRandom() uuid, err := uuid.NewRandom()
if err != nil { if err != nil {
@@ -249,21 +203,20 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *repository.Se
expiry = auth.config.SessionExpiry expiry = auth.config.SessionExpiry
} }
session := repository.CreateSessionParams{ session := model.Session{
UUID: uuid.String(), UUID: uuid.String(),
Username: data.Username, Username: data.Username,
Email: data.Email, Email: data.Email,
Name: data.Name, Name: data.Name,
Provider: data.Provider, Provider: data.Provider,
TotpPending: data.TotpPending, TOTPPending: data.TotpPending,
OAuthGroups: data.OAuthGroups, OAuthGroups: data.OAuthGroups,
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(), Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
CreatedAt: time.Now().Unix(),
OAuthName: data.OAuthName, OAuthName: data.OAuthName,
OAuthSub: data.OAuthSub, OAuthSub: data.OAuthSub,
} }
_, err = auth.queries.CreateSession(c, session) err = gorm.G[model.Session](auth.database).Create(c, &session)
if err != nil { if err != nil {
return err return err
@@ -281,7 +234,7 @@ func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
return err return err
} }
session, err := auth.queries.GetSession(c, cookie) session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(c)
if err != nil { if err != nil {
return err return err
@@ -289,39 +242,21 @@ func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
currentTime := time.Now().Unix() currentTime := time.Now().Unix()
var refreshThreshold int64 if session.Expiry-currentTime > int64(time.Hour.Seconds()) {
if auth.config.SessionExpiry <= int(time.Hour.Seconds()) {
refreshThreshold = int64(auth.config.SessionExpiry / 2)
} else {
refreshThreshold = int64(time.Hour.Seconds())
}
if session.Expiry-currentTime > refreshThreshold {
return nil return nil
} }
newExpiry := session.Expiry + refreshThreshold newExpiry := currentTime + int64(time.Hour.Seconds())
_, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{ _, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Updates(c, model.Session{
Username: session.Username, Expiry: newExpiry,
Email: session.Email,
Name: session.Name,
Provider: session.Provider,
TotpPending: session.TotpPending,
OAuthGroups: session.OAuthGroups,
Expiry: newExpiry,
OAuthName: session.OAuthName,
OAuthSub: session.OAuthSub,
UUID: session.UUID,
}) })
if err != nil { if err != nil {
return err return err
} }
c.SetCookie(auth.config.SessionCookieName, cookie, int(newExpiry-currentTime), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true) c.SetCookie(auth.config.SessionCookieName, cookie, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
tlog.App.Trace().Str("username", session.Username).Msg("Session cookie refreshed")
return nil return nil
} }
@@ -333,7 +268,7 @@ func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
return err return err
} }
err = auth.queries.DeleteSession(c, cookie) _, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(c)
if err != nil { if err != nil {
return err return err
@@ -344,77 +279,64 @@ func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
return nil return nil
} }
func (auth *AuthService) GetSessionCookie(c *gin.Context) (repository.Session, error) { func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, error) {
cookie, err := c.Cookie(auth.config.SessionCookieName) cookie, err := c.Cookie(auth.config.SessionCookieName)
if err != nil { if err != nil {
return repository.Session{}, err return config.SessionCookie{}, err
} }
session, err := auth.queries.GetSession(c, cookie) session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(c)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { return config.SessionCookie{}, err
return repository.Session{}, fmt.Errorf("session not found") }
}
return repository.Session{}, err if errors.Is(err, gorm.ErrRecordNotFound) {
return config.SessionCookie{}, fmt.Errorf("session not found")
} }
currentTime := time.Now().Unix() currentTime := time.Now().Unix()
if auth.config.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
if currentTime-session.CreatedAt > int64(auth.config.SessionMaxLifetime) {
err = auth.queries.DeleteSession(c, cookie)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to delete session exceeding max lifetime")
}
return repository.Session{}, fmt.Errorf("session expired due to max lifetime exceeded")
}
}
if currentTime > session.Expiry { if currentTime > session.Expiry {
err = auth.queries.DeleteSession(c, cookie) _, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(c)
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("Failed to delete expired session") log.Error().Err(err).Msg("Failed to delete expired session")
} }
return repository.Session{}, fmt.Errorf("session expired") return config.SessionCookie{}, fmt.Errorf("session expired")
} }
return repository.Session{ return config.SessionCookie{
UUID: session.UUID, UUID: session.UUID,
Username: session.Username, Username: session.Username,
Email: session.Email, Email: session.Email,
Name: session.Name, Name: session.Name,
Provider: session.Provider, Provider: session.Provider,
TotpPending: session.TotpPending, TotpPending: session.TOTPPending,
OAuthGroups: session.OAuthGroups, OAuthGroups: session.OAuthGroups,
OAuthName: session.OAuthName, OAuthName: session.OAuthName,
OAuthSub: session.OAuthSub, OAuthSub: session.OAuthSub,
}, nil }, nil
} }
func (auth *AuthService) LocalAuthConfigured() bool { func (auth *AuthService) UserAuthConfigured() bool {
return len(auth.config.Users) > 0 return len(auth.config.Users) > 0 || auth.ldap != nil
} }
func (auth *AuthService) LdapAuthConfigured() bool { func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {
return auth.ldap != nil
}
func (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {
if context.OAuth { if context.OAuth {
tlog.App.Debug().Msg("Checking OAuth whitelist") log.Debug().Msg("Checking OAuth whitelist")
return utils.CheckFilter(acls.OAuth.Whitelist, context.Email) return utils.CheckFilter(acls.OAuth.Whitelist, context.Email)
} }
if acls.Users.Block != "" { if acls.Users.Block != "" {
tlog.App.Debug().Msg("Checking blocked users") log.Debug().Msg("Checking blocked users")
if utils.CheckFilter(acls.Users.Block, context.Username) { if utils.CheckFilter(acls.Users.Block, context.Username) {
return false return false
} }
} }
tlog.App.Debug().Msg("Checking users") log.Debug().Msg("Checking users")
return utils.CheckFilter(acls.Users.Allow, context.Username) return utils.CheckFilter(acls.Users.Allow, context.Username)
} }
@@ -425,35 +347,19 @@ func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserConte
for id := range config.OverrideProviders { for id := range config.OverrideProviders {
if context.Provider == id { if context.Provider == id {
tlog.App.Info().Str("provider", id).Msg("OAuth groups not supported for this provider") log.Info().Str("provider", id).Msg("OAuth groups not supported for this provider")
return true return true
} }
} }
for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") { for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") {
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) { if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched") log.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
return true return true
} }
} }
tlog.App.Debug().Msg("No groups matched") log.Debug().Msg("No groups matched")
return false
}
func (auth *AuthService) IsInLdapGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {
if requiredGroups == "" {
return true
}
for userGroup := range strings.SplitSeq(context.LdapGroups, ",") {
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
return true
}
}
tlog.App.Debug().Msg("No groups matched")
return false return false
} }
@@ -490,7 +396,7 @@ func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, e
func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User { func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User {
username, password, ok := c.Request.BasicAuth() username, password, ok := c.Request.BasicAuth()
if !ok { if !ok {
tlog.App.Debug().Msg("No basic auth provided") log.Debug().Msg("No basic auth provided")
return nil return nil
} }
return &config.User{ return &config.User{
@@ -500,40 +406,36 @@ func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User {
} }
func (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool { func (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool {
// Merge the global and app IP filter for _, blocked := range acls.Block {
blockedIps := append(auth.config.IP.Block, acls.Block...)
allowedIPs := append(auth.config.IP.Allow, acls.Allow...)
for _, blocked := range blockedIps {
res, err := utils.FilterIP(blocked, ip) res, err := utils.FilterIP(blocked, ip)
if err != nil { if err != nil {
tlog.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") log.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue continue
} }
if res { if res {
tlog.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access") log.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access")
return false return false
} }
} }
for _, allowed := range allowedIPs { for _, allowed := range acls.Allow {
res, err := utils.FilterIP(allowed, ip) res, err := utils.FilterIP(allowed, ip)
if err != nil { if err != nil {
tlog.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") log.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue continue
} }
if res { if res {
tlog.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access") log.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access")
return true return true
} }
} }
if len(allowedIPs) > 0 { if len(acls.Allow) > 0 {
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access") log.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
return false return false
} }
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default") log.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default")
return true return true
} }
@@ -541,15 +443,15 @@ func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool {
for _, bypassed := range acls.Bypass { for _, bypassed := range acls.Bypass {
res, err := utils.FilterIP(bypassed, ip) res, err := utils.FilterIP(bypassed, ip)
if err != nil { if err != nil {
tlog.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") log.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue continue
} }
if res { if res {
tlog.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access") log.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access")
return true return true
} }
} }
tlog.App.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication") log.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
return false return false
} }

View File

@@ -0,0 +1,92 @@
package service
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"github.com/steveiliop56/tinyauth/internal/assets"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"gorm.io/gorm"
)
type DatabaseServiceConfig struct {
DatabasePath string
}
type DatabaseService struct {
config DatabaseServiceConfig
database *gorm.DB
}
func NewDatabaseService(config DatabaseServiceConfig) *DatabaseService {
return &DatabaseService{
config: config,
}
}
func (ds *DatabaseService) Init() error {
dbPath := ds.config.DatabasePath
if dbPath == "" {
dbPath = "/data/tinyauth.db"
}
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create database directory %s: %w", dir, err)
}
gormDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := gormDB.DB()
if err != nil {
return err
}
sqlDB.SetMaxOpenConns(1)
err = ds.migrateDatabase(sqlDB)
if err != nil && err != migrate.ErrNoChange {
return err
}
ds.database = gormDB
return nil
}
func (ds *DatabaseService) migrateDatabase(sqlDB *sql.DB) error {
data, err := iofs.New(assets.Migrations, "migrations")
if err != nil {
return err
}
target, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{})
if err != nil {
return err
}
migrator, err := migrate.NewWithInstance("iofs", data, "tinyauth", target)
if err != nil {
return err
}
return migrator.Up()
}
func (ds *DatabaseService) GetDatabase() *gorm.DB {
return ds.database
}

View File

@@ -6,10 +6,10 @@ import (
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/decoders" "github.com/steveiliop56/tinyauth/internal/utils/decoders"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
container "github.com/docker/docker/api/types/container" container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/rs/zerolog/log"
) )
type DockerService struct { type DockerService struct {
@@ -37,7 +37,7 @@ func (docker *DockerService) Init() error {
_, err = docker.client.Ping(docker.context) _, err = docker.client.Ping(docker.context)
if err != nil { if err != nil {
tlog.App.Debug().Err(err).Msg("Docker not connected") log.Debug().Err(err).Msg("Docker not connected")
docker.isConnected = false docker.isConnected = false
docker.client = nil docker.client = nil
docker.context = nil docker.context = nil
@@ -45,7 +45,7 @@ func (docker *DockerService) Init() error {
} }
docker.isConnected = true docker.isConnected = true
tlog.App.Debug().Msg("Docker connected") log.Debug().Msg("Docker connected")
return nil return nil
} }
@@ -68,7 +68,7 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
func (docker *DockerService) GetLabels(appDomain string) (config.App, error) { func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
if !docker.isConnected { if !docker.isConnected {
tlog.App.Debug().Msg("Docker not connected, returning empty labels") log.Debug().Msg("Docker not connected, returning empty labels")
return config.App{}, nil return config.App{}, nil
} }
@@ -90,17 +90,17 @@ func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
for appName, appLabels := range labels.Apps { for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == appDomain { if appLabels.Config.Domain == appDomain {
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain") log.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
return appLabels, nil return appLabels, nil
} }
if strings.SplitN(appDomain, ".", 2)[0] == appName { if strings.SplitN(appDomain, ".", 2)[0] == appName {
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name") log.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
return appLabels, nil return appLabels, nil
} }
} }
} }
tlog.App.Debug().Msg("No matching container found, returning empty labels") log.Debug().Msg("No matching container found, returning empty labels")
return config.App{}, nil return config.App{}, nil
} }

View File

@@ -12,8 +12,8 @@ import (
"time" "time"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@@ -117,7 +117,7 @@ func (generic *GenericOAuthService) Userinfo() (config.Claims, error) {
return user, err return user, err
} }
tlog.App.Trace().Str("body", string(body)).Msg("Userinfo response body") log.Trace().Str("body", string(body)).Msg("Userinfo response body")
err = json.Unmarshal(body, &user) err = json.Unmarshal(body, &user)
if err != nil { if err != nil {

View File

@@ -9,7 +9,7 @@ import (
"github.com/cenkalti/backoff/v5" "github.com/cenkalti/backoff/v5"
ldapgo "github.com/go-ldap/ldap/v3" ldapgo "github.com/go-ldap/ldap/v3"
"github.com/steveiliop56/tinyauth/internal/utils/tlog" "github.com/rs/zerolog/log"
) )
type LdapServiceConfig struct { type LdapServiceConfig struct {
@@ -19,44 +19,21 @@ type LdapServiceConfig struct {
BaseDN string BaseDN string
Insecure bool Insecure bool
SearchFilter string SearchFilter string
AuthCert string
AuthKey string
} }
type LdapService struct { type LdapService struct {
config LdapServiceConfig Config LdapServiceConfig // exported so as the auth service can use it
conn *ldapgo.Conn conn *ldapgo.Conn
mutex sync.RWMutex mutex sync.RWMutex
cert *tls.Certificate
} }
func NewLdapService(config LdapServiceConfig) *LdapService { func NewLdapService(config LdapServiceConfig) *LdapService {
return &LdapService{ return &LdapService{
config: config, Config: config,
} }
} }
func (ldap *LdapService) Init() error { func (ldap *LdapService) Init() error {
// Check whether authentication with client certificate is possible
if ldap.config.AuthCert != "" && ldap.config.AuthKey != "" {
cert, err := tls.LoadX509KeyPair(ldap.config.AuthCert, ldap.config.AuthKey)
if err != nil {
return fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err)
}
ldap.cert = &cert
tlog.App.Info().Msg("Using LDAP with mTLS authentication")
// TODO: Add optional extra CA certificates, instead of `InsecureSkipVerify`
/*
caCert, _ := ioutil.ReadFile(*caFile)
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
...
RootCAs: caCertPool,
}
*/
}
_, err := ldap.connect() _, err := ldap.connect()
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to LDAP server: %w", err) return fmt.Errorf("failed to connect to LDAP server: %w", err)
@@ -66,12 +43,12 @@ func (ldap *LdapService) Init() error {
for range time.Tick(time.Duration(5) * time.Minute) { for range time.Tick(time.Duration(5) * time.Minute) {
err := ldap.heartbeat() err := ldap.heartbeat()
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msg("LDAP connection heartbeat failed") log.Error().Err(err).Msg("LDAP connection heartbeat failed")
if reconnectErr := ldap.reconnect(); reconnectErr != nil { if reconnectErr := ldap.reconnect(); reconnectErr != nil {
tlog.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server") log.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
continue continue
} }
tlog.App.Info().Msg("Successfully reconnected to LDAP server") log.Info().Msg("Successfully reconnected to LDAP server")
} }
} }
}() }()
@@ -83,46 +60,31 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
ldap.mutex.Lock() ldap.mutex.Lock()
defer ldap.mutex.Unlock() defer ldap.mutex.Unlock()
var conn *ldapgo.Conn conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
var err error InsecureSkipVerify: ldap.Config.Insecure,
MinVersion: tls.VersionTLS12,
// TODO: There's also STARTTLS (or SASL)-based mTLS authentication }))
// scenario, where we first connect to plain text port (389) and
// continue with a STARTTLS negotiation:
// 1. conn = ldap.DialURL("ldap://ldap.example.com:389")
// 2. conn.StartTLS(tlsConfig)
// 3. conn.externalBind()
if ldap.cert != nil {
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{*ldap.cert},
}))
} else {
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
InsecureSkipVerify: ldap.config.Insecure,
MinVersion: tls.VersionTLS12,
}))
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = conn.Bind(ldap.Config.BindDN, ldap.Config.BindPassword)
if err != nil {
return nil, err
}
// Set and return the connection
ldap.conn = conn ldap.conn = conn
return conn, nil
err = ldap.BindService(false)
if err != nil {
return nil, err
}
return ldap.conn, nil
} }
func (ldap *LdapService) GetUserDN(username string) (string, error) { func (ldap *LdapService) Search(username string) (string, error) {
// Escape the username to prevent LDAP injection // Escape the username to prevent LDAP injection
escapedUsername := ldapgo.EscapeFilter(username) escapedUsername := ldapgo.EscapeFilter(username)
filter := fmt.Sprintf(ldap.config.SearchFilter, escapedUsername) filter := fmt.Sprintf(ldap.Config.SearchFilter, escapedUsername)
searchRequest := ldapgo.NewSearchRequest( searchRequest := ldapgo.NewSearchRequest(
ldap.config.BaseDN, ldap.Config.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false, ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
filter, filter,
[]string{"dn"}, []string{"dn"},
@@ -145,61 +107,6 @@ func (ldap *LdapService) GetUserDN(username string) (string, error) {
return userDN, nil return userDN, nil
} }
func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {
escapedUserDN := ldapgo.EscapeFilter(userDN)
searchRequest := ldapgo.NewSearchRequest(
ldap.config.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectclass=groupOfUniqueNames)(uniquemember=%s))", escapedUserDN),
[]string{"dn"},
nil,
)
ldap.mutex.Lock()
defer ldap.mutex.Unlock()
searchResult, err := ldap.conn.Search(searchRequest)
if err != nil {
return []string{}, err
}
groupDNs := []string{}
for _, entry := range searchResult.Entries {
groupDNs = append(groupDNs, entry.DN)
}
groups := []string{}
// I guess it should work for most ldap providers
for _, dn := range groupDNs {
rdnParts, err := ldapgo.ParseDN(dn)
if err != nil {
return []string{}, err
}
if len(rdnParts.RDNs) == 0 || len(rdnParts.RDNs[0].Attributes) == 0 {
return []string{}, fmt.Errorf("invalid DN format: %s", dn)
}
groups = append(groups, rdnParts.RDNs[0].Attributes[0].Value)
}
return groups, nil
}
func (ldap *LdapService) BindService(rebind bool) error {
// Locks must not be used for initial binding attempt
if rebind {
ldap.mutex.Lock()
defer ldap.mutex.Unlock()
}
if ldap.cert != nil {
return ldap.conn.ExternalBind()
}
return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)
}
func (ldap *LdapService) Bind(userDN string, password string) error { func (ldap *LdapService) Bind(userDN string, password string) error {
ldap.mutex.Lock() ldap.mutex.Lock()
defer ldap.mutex.Unlock() defer ldap.mutex.Unlock()
@@ -211,7 +118,7 @@ func (ldap *LdapService) Bind(userDN string, password string) error {
} }
func (ldap *LdapService) heartbeat() error { func (ldap *LdapService) heartbeat() error {
tlog.App.Debug().Msg("Performing LDAP connection heartbeat") log.Debug().Msg("Performing LDAP connection heartbeat")
searchRequest := ldapgo.NewSearchRequest( searchRequest := ldapgo.NewSearchRequest(
"", "",
@@ -233,7 +140,7 @@ func (ldap *LdapService) heartbeat() error {
} }
func (ldap *LdapService) reconnect() error { func (ldap *LdapService) reconnect() error {
tlog.App.Info().Msg("Reconnecting to LDAP server") log.Info().Msg("Reconnecting to LDAP server")
exp := backoff.NewExponentialBackOff() exp := backoff.NewExponentialBackOff()
exp.InitialInterval = 500 * time.Millisecond exp.InitialInterval = 500 * time.Millisecond

View File

@@ -4,8 +4,8 @@ import (
"errors" "errors"
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/rs/zerolog/log"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
@@ -49,10 +49,10 @@ func (broker *OAuthBrokerService) Init() error {
for name, service := range broker.services { for name, service := range broker.services {
err := service.Init() err := service.Init()
if err != nil { if err != nil {
tlog.App.Error().Err(err).Msgf("Failed to initialize OAuth service: %s", name) log.Error().Err(err).Msgf("Failed to initialize OAuth service: %T", name)
return err return err
} }
tlog.App.Info().Str("service", name).Msg("Initialized OAuth service") log.Info().Str("service", name).Msg("Initialized OAuth service")
} }
return nil return nil

View File

@@ -1,597 +0,0 @@
package service
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/pem"
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"golang.org/x/exp/slices"
// Should probably switch to another package but for now this works
"golang.org/x/oauth2/jws"
)
var (
SupportedScopes = []string{"openid", "profile", "email", "groups"}
SupportedResponseTypes = []string{"code"}
SupportedGrantTypes = []string{"authorization_code", "refresh_token"}
)
var (
ErrCodeExpired = errors.New("code_expired")
ErrCodeNotFound = errors.New("code_not_found")
ErrTokenNotFound = errors.New("token_not_found")
ErrTokenExpired = errors.New("token_expired")
)
type UserinfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"`
UpdatedAt int64 `json:"updated_at"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
IDToken string `json:"id_token"`
Scope string `json:"scope"`
}
type AuthorizeRequest struct {
Scope string `json:"scope" binding:"required"`
ResponseType string `json:"response_type" binding:"required"`
ClientID string `json:"client_id" binding:"required"`
RedirectURI string `json:"redirect_uri" binding:"required"`
State string `json:"state" binding:"required"`
}
type OIDCServiceConfig struct {
Clients map[string]config.OIDCClientConfig
PrivateKeyPath string
PublicKeyPath string
Issuer string
SessionExpiry int
}
type OIDCService struct {
config OIDCServiceConfig
queries *repository.Queries
clients map[string]config.OIDCClientConfig
privateKey *rsa.PrivateKey
publicKey crypto.PublicKey
issuer string
}
func NewOIDCService(config OIDCServiceConfig, queries *repository.Queries) *OIDCService {
return &OIDCService{
config: config,
queries: queries,
}
}
// TODO: A cleanup routine is needed to clean up expired tokens/code/userinfo
func (service *OIDCService) Init() error {
// Ensure issuer is https
uissuer, err := url.Parse(service.config.Issuer)
if err != nil {
return err
}
if uissuer.Scheme != "https" {
return errors.New("issuer must be https")
}
service.issuer = fmt.Sprintf("%s://%s", uissuer.Scheme, uissuer.Host)
// Create/load private and public keys
if strings.TrimSpace(service.config.PrivateKeyPath) == "" ||
strings.TrimSpace(service.config.PublicKeyPath) == "" {
return errors.New("private key path and public key path are required")
}
var privateKey *rsa.PrivateKey
fprivateKey, err := os.ReadFile(service.config.PrivateKeyPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if errors.Is(err, os.ErrNotExist) {
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
der := x509.MarshalPKCS1PrivateKey(privateKey)
if der == nil {
return errors.New("failed to marshal private key")
}
encoded := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: der,
})
err = os.WriteFile(service.config.PrivateKeyPath, encoded, 0600)
if err != nil {
return err
}
service.privateKey = privateKey
} else {
block, _ := pem.Decode(fprivateKey)
if block == nil {
return errors.New("failed to decode private key")
}
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return err
}
service.privateKey = privateKey
}
fpublicKey, err := os.ReadFile(service.config.PublicKeyPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if errors.Is(err, os.ErrNotExist) {
publicKey := service.privateKey.Public()
der := x509.MarshalPKCS1PublicKey(publicKey.(*rsa.PublicKey))
if der == nil {
return errors.New("failed to marshal public key")
}
encoded := pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: der,
})
err = os.WriteFile(service.config.PublicKeyPath, encoded, 0644)
if err != nil {
return err
}
service.publicKey = publicKey
} else {
block, _ := pem.Decode(fpublicKey)
if block == nil {
return errors.New("failed to decode public key")
}
publicKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
if err != nil {
return err
}
service.publicKey = publicKey
}
// We will reorganize the client into a map with the client ID as the key
service.clients = make(map[string]config.OIDCClientConfig)
for id, client := range service.config.Clients {
client.ID = id
service.clients[client.ClientID] = client
}
// Load the client secrets from files if they exist
for id, client := range service.clients {
secret := utils.GetSecret(client.ClientSecret, client.ClientSecretFile)
if secret != "" {
client.ClientSecret = secret
}
client.ClientSecretFile = ""
service.clients[id] = client
}
return nil
}
func (service *OIDCService) GetIssuer() string {
return service.config.Issuer
}
func (service *OIDCService) GetClient(id string) (config.OIDCClientConfig, bool) {
client, ok := service.clients[id]
return client, ok
}
func (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error {
// Validate client ID
client, ok := service.GetClient(req.ClientID)
if !ok {
return errors.New("access_denied")
}
// Scopes
scopes := strings.Split(req.Scope, " ")
if len(scopes) == 0 || strings.TrimSpace(req.Scope) == "" {
return errors.New("invalid_scope")
}
for _, scope := range scopes {
if strings.TrimSpace(scope) == "" {
return errors.New("invalid_scope")
}
if !slices.Contains(SupportedScopes, scope) {
tlog.App.Warn().Str("scope", scope).Msg("Unsupported OIDC scope, will be ignored")
}
}
// Response type
if !slices.Contains(SupportedResponseTypes, req.ResponseType) {
return errors.New("unsupported_response_type")
}
// Redirect URI
if !slices.Contains(client.TrustedRedirectURIs, req.RedirectURI) {
return errors.New("invalid_request_uri")
}
return nil
}
func (service *OIDCService) filterScopes(scopes []string) []string {
return utils.Filter(scopes, func(scope string) bool {
return slices.Contains(SupportedScopes, scope)
})
}
func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, req AuthorizeRequest) error {
// Fixed 10 minutes
expiresAt := time.Now().Add(time.Minute * time.Duration(10)).Unix()
// Insert the code into the database
_, err := service.queries.CreateOidcCode(c, repository.CreateOidcCodeParams{
Sub: sub,
CodeHash: service.Hash(code),
// Here it's safe to split and trust the output since, we validated the scopes before
Scope: strings.Join(service.filterScopes(strings.Split(req.Scope, " ")), ","),
RedirectURI: req.RedirectURI,
ClientID: req.ClientID,
ExpiresAt: expiresAt,
})
return err
}
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {
userInfoParams := repository.CreateOidcUserInfoParams{
Sub: sub,
Name: userContext.Name,
Email: userContext.Email,
PreferredUsername: userContext.Username,
UpdatedAt: time.Now().Unix(),
}
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
if userContext.Provider == "ldap" {
userInfoParams.Groups = userContext.LdapGroups
}
if userContext.OAuth && len(userContext.OAuthGroups) > 0 {
userInfoParams.Groups = userContext.OAuthGroups
}
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
return err
}
func (service *OIDCService) ValidateGrantType(grantType string) error {
if !slices.Contains(SupportedGrantTypes, grantType) {
return errors.New("unsupported_grant_type")
}
return nil
}
func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string) (repository.OidcCode, error) {
oidcCode, err := service.queries.GetOidcCode(c, codeHash)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return repository.OidcCode{}, ErrCodeNotFound
}
return repository.OidcCode{}, err
}
if time.Now().Unix() > oidcCode.ExpiresAt {
err = service.queries.DeleteOidcCode(c, codeHash)
if err != nil {
return repository.OidcCode{}, err
}
err = service.DeleteUserinfo(c, oidcCode.Sub)
if err != nil {
return repository.OidcCode{}, err
}
return repository.OidcCode{}, ErrCodeExpired
}
return oidcCode, nil
}
func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, sub string) (string, error) {
createdAt := time.Now().Unix()
expiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
claims := jws.ClaimSet{
Iss: service.issuer,
Aud: client.ClientID,
Sub: sub,
Iat: createdAt,
Exp: expiresAt,
}
header := jws.Header{
Algorithm: "RS256",
Typ: "JWT",
}
token, err := jws.Encode(&header, &claims, service.privateKey)
if err != nil {
return "", err
}
return token, nil
}
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OIDCClientConfig, sub string, scope string) (TokenResponse, error) {
idToken, err := service.generateIDToken(client, sub)
if err != nil {
return TokenResponse{}, err
}
accessToken := rand.Text()
refreshToken := rand.Text()
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
// Refresh token lives double the time of an access token but can't be used to access userinfo
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
tokenResponse := TokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: int64(service.config.SessionExpiry),
IDToken: idToken,
Scope: strings.ReplaceAll(scope, ",", " "),
}
_, err = service.queries.CreateOidcToken(c, repository.CreateOidcTokenParams{
Sub: sub,
AccessTokenHash: service.Hash(accessToken),
RefreshTokenHash: service.Hash(refreshToken),
ClientID: client.ClientID,
Scope: scope,
TokenExpiresAt: tokenExpiresAt,
RefreshTokenExpiresAt: refrshTokenExpiresAt,
})
if err != nil {
return TokenResponse{}, err
}
return tokenResponse, nil
}
func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken string) (TokenResponse, error) {
entry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))
if err != nil {
if err == sql.ErrNoRows {
return TokenResponse{}, ErrTokenNotFound
}
return TokenResponse{}, err
}
if entry.RefreshTokenExpiresAt < time.Now().Unix() {
return TokenResponse{}, ErrTokenExpired
}
idToken, err := service.generateIDToken(config.OIDCClientConfig{
ClientID: entry.ClientID,
}, entry.Sub)
if err != nil {
return TokenResponse{}, err
}
accessToken := rand.Text()
newRefreshToken := rand.Text()
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
tokenResponse := TokenResponse{
AccessToken: accessToken,
RefreshToken: newRefreshToken,
TokenType: "Bearer",
ExpiresIn: int64(service.config.SessionExpiry),
IDToken: idToken,
Scope: strings.ReplaceAll(entry.Scope, ",", " "),
}
_, err = service.queries.UpdateOidcTokenByRefreshToken(c, repository.UpdateOidcTokenByRefreshTokenParams{
AccessTokenHash: service.Hash(accessToken),
RefreshTokenHash: service.Hash(newRefreshToken),
TokenExpiresAt: tokenExpiresAt,
RefreshTokenExpiresAt: refrshTokenExpiresAt,
RefreshTokenHash_2: service.Hash(refreshToken), // that's the selector, it's not stored in the db
})
if err != nil {
return TokenResponse{}, err
}
return tokenResponse, nil
}
func (service *OIDCService) DeleteCodeEntry(c *gin.Context, codeHash string) error {
return service.queries.DeleteOidcCode(c, codeHash)
}
func (service *OIDCService) DeleteUserinfo(c *gin.Context, sub string) error {
return service.queries.DeleteOidcUserInfo(c, sub)
}
func (service *OIDCService) DeleteToken(c *gin.Context, tokenHash string) error {
return service.queries.DeleteOidcToken(c, tokenHash)
}
func (service *OIDCService) GetAccessToken(c *gin.Context, tokenHash string) (repository.OidcToken, error) {
entry, err := service.queries.GetOidcToken(c, tokenHash)
if err != nil {
if err == sql.ErrNoRows {
return repository.OidcToken{}, ErrTokenNotFound
}
return repository.OidcToken{}, err
}
if entry.TokenExpiresAt < time.Now().Unix() {
// If refresh token is expired, delete the token and userinfo since there is no way for the client to access anything anymore
if entry.RefreshTokenExpiresAt < time.Now().Unix() {
err := service.DeleteToken(c, tokenHash)
if err != nil {
return repository.OidcToken{}, err
}
err = service.DeleteUserinfo(c, entry.Sub)
if err != nil {
return repository.OidcToken{}, err
}
}
return repository.OidcToken{}, ErrTokenExpired
}
return entry, nil
}
func (service *OIDCService) GetUserinfo(c *gin.Context, sub string) (repository.OidcUserinfo, error) {
return service.queries.GetOidcUserInfo(c, sub)
}
func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope string) UserinfoResponse {
scopes := strings.Split(scope, ",") // split by comma since it's a db entry
userInfo := UserinfoResponse{
Sub: user.Sub,
UpdatedAt: user.UpdatedAt,
}
if slices.Contains(scopes, "profile") {
userInfo.Name = user.Name
userInfo.PreferredUsername = user.PreferredUsername
}
if slices.Contains(scopes, "email") {
userInfo.Email = user.Email
}
if slices.Contains(scopes, "groups") {
if user.Groups != "" {
userInfo.Groups = strings.Split(user.Groups, ",")
} else {
userInfo.Groups = []string{}
}
}
return userInfo
}
func (service *OIDCService) Hash(token string) string {
hasher := sha256.New()
hasher.Write([]byte(token))
return fmt.Sprintf("%x", hasher.Sum(nil))
}
func (service *OIDCService) DeleteOldSession(ctx context.Context, sub string) error {
err := service.queries.DeleteOidcCodeBySub(ctx, sub)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
err = service.queries.DeleteOidcTokenBySub(ctx, sub)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
err = service.queries.DeleteOidcUserInfo(ctx, sub)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return nil
}
// Cleanup routine - Resource heavy due to the linked tables
func (service *OIDCService) Cleanup() {
// We need a context for the routine
ctx := context.Background()
ticker := time.NewTicker(time.Duration(30) * time.Minute)
defer ticker.Stop()
for range ticker.C {
currentTime := time.Now().Unix()
// For the OIDC tokens, if they are expired we delete the userinfo and codes
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{
TokenExpiresAt: currentTime,
RefreshTokenExpiresAt: currentTime,
})
if err != nil {
tlog.App.Warn().Err(err).Msg("Failed to delete expired tokens")
}
for _, expiredToken := range expiredTokens {
err := service.DeleteOldSession(ctx, expiredToken.Sub)
if err != nil {
tlog.App.Warn().Err(err).Msg("Failed to delete old session")
}
}
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(ctx, currentTime)
if err != nil {
tlog.App.Warn().Err(err).Msg("Failed to delete expired codes")
}
for _, expiredCode := range expiredCodes {
token, err := service.queries.GetOidcTokenBySub(ctx, expiredCode.Sub)
if err != nil {
if err == sql.ErrNoRows {
continue
}
tlog.App.Warn().Err(err).Msg("Failed to get OIDC token by sub")
}
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
err := service.DeleteOldSession(ctx, expiredCode.Sub)
if err != nil {
tlog.App.Warn().Err(err).Msg("Failed to delete session")
}
}
}
}
}

View File

@@ -2,7 +2,6 @@ package utils
import ( import (
"errors" "errors"
"fmt"
"net" "net"
"net/url" "net/url"
"strings" "strings"
@@ -96,7 +95,7 @@ func IsRedirectSafe(redirectURL string, domain string) bool {
hostname := parsed.Hostname() hostname := parsed.Hostname()
if strings.HasSuffix(hostname, fmt.Sprintf(".%s", domain)) { if strings.HasSuffix(hostname, domain) {
return true return true
} }

View File

@@ -205,9 +205,4 @@ func TestIsRedirectSafe(t *testing.T) {
redirectURL = "http://example.org/page" redirectURL = "http://example.org/page"
result = utils.IsRedirectSafe(redirectURL, domain) result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, false, result) assert.Equal(t, false, result)
// Case with malicious domain
redirectURL = "https://malicious-example.com/yoyo"
result = utils.IsRedirectSafe(redirectURL, domain)
assert.Equal(t, false, result)
} }

View File

@@ -1,39 +0,0 @@
package tlog
import "github.com/gin-gonic/gin"
// functions here use CallerSkipFrame to ensure correct caller info is logged
func AuditLoginSuccess(c *gin.Context, username, provider string) {
Audit.Info().
CallerSkipFrame(1).
Str("event", "login").
Str("result", "success").
Str("username", username).
Str("provider", provider).
Str("ip", c.ClientIP()).
Send()
}
func AuditLoginFailure(c *gin.Context, username, provider string, reason string) {
Audit.Warn().
CallerSkipFrame(1).
Str("event", "login").
Str("result", "failure").
Str("username", username).
Str("provider", provider).
Str("ip", c.ClientIP()).
Str("reason", reason).
Send()
}
func AuditLogout(c *gin.Context, username, provider string) {
Audit.Info().
CallerSkipFrame(1).
Str("event", "logout").
Str("result", "success").
Str("username", username).
Str("provider", provider).
Str("ip", c.ClientIP()).
Send()
}

View File

@@ -1,86 +0,0 @@
package tlog
import (
"os"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/steveiliop56/tinyauth/internal/config"
)
type Logger struct {
Audit zerolog.Logger
HTTP zerolog.Logger
App zerolog.Logger
}
var (
Audit zerolog.Logger
HTTP zerolog.Logger
App zerolog.Logger
)
func NewLogger(cfg config.LogConfig) *Logger {
baseLogger := log.With().
Timestamp().
Caller().
Logger().
Level(parseLogLevel(cfg.Level))
if !cfg.Json {
baseLogger = baseLogger.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: time.RFC3339,
})
}
return &Logger{
Audit: createLogger("audit", cfg.Streams.Audit, baseLogger),
HTTP: createLogger("http", cfg.Streams.HTTP, baseLogger),
App: createLogger("app", cfg.Streams.App, baseLogger),
}
}
func NewSimpleLogger() *Logger {
return NewLogger(config.LogConfig{
Level: "info",
Json: false,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true},
App: config.LogStreamConfig{Enabled: true},
Audit: config.LogStreamConfig{Enabled: false},
},
})
}
func (l *Logger) Init() {
Audit = l.Audit
HTTP = l.HTTP
App = l.App
}
func createLogger(component string, streamCfg config.LogStreamConfig, baseLogger zerolog.Logger) zerolog.Logger {
if !streamCfg.Enabled {
return zerolog.Nop()
}
subLogger := baseLogger.With().Str("log_stream", component).Logger()
// override level if specified, otherwise use base level
if streamCfg.Level != "" {
subLogger = subLogger.Level(parseLogLevel(streamCfg.Level))
}
return subLogger
}
func parseLogLevel(level string) zerolog.Level {
if level == "" {
return zerolog.InfoLevel
}
parsedLevel, err := zerolog.ParseLevel(strings.ToLower(level))
if err != nil {
log.Warn().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
parsedLevel = zerolog.InfoLevel
}
return parsedLevel
}

View File

@@ -1,93 +0,0 @@
package tlog_test
import (
"bytes"
"encoding/json"
"testing"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/rs/zerolog"
"gotest.tools/v3/assert"
)
func TestNewLogger(t *testing.T) {
cfg := config.LogConfig{
Level: "debug",
Json: true,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true, Level: "info"},
App: config.LogStreamConfig{Enabled: true, Level: ""},
Audit: config.LogStreamConfig{Enabled: false, Level: ""},
},
}
logger := tlog.NewLogger(cfg)
assert.Assert(t, logger != nil)
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.InfoLevel)
assert.Assert(t, logger.App.GetLevel() == zerolog.DebugLevel)
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
}
func TestNewSimpleLogger(t *testing.T) {
logger := tlog.NewSimpleLogger()
assert.Assert(t, logger != nil)
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.InfoLevel)
assert.Assert(t, logger.App.GetLevel() == zerolog.InfoLevel)
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
}
func TestLoggerInit(t *testing.T) {
logger := tlog.NewSimpleLogger()
logger.Init()
assert.Assert(t, tlog.App.GetLevel() != zerolog.Disabled)
}
func TestLoggerWithDisabledStreams(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Json: false,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: false},
App: config.LogStreamConfig{Enabled: false},
Audit: config.LogStreamConfig{Enabled: false},
},
}
logger := tlog.NewLogger(cfg)
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.Disabled)
assert.Assert(t, logger.App.GetLevel() == zerolog.Disabled)
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
}
func TestLogStreamField(t *testing.T) {
var buf bytes.Buffer
cfg := config.LogConfig{
Level: "info",
Json: true,
Streams: config.LogStreams{
HTTP: config.LogStreamConfig{Enabled: true},
App: config.LogStreamConfig{Enabled: true},
Audit: config.LogStreamConfig{Enabled: true},
},
}
logger := tlog.NewLogger(cfg)
// Override output for HTTP logger to capture output
logger.HTTP = logger.HTTP.Output(&buf)
logger.HTTP.Info().Msg("test message")
var logEntry map[string]interface{}
err := json.Unmarshal(buf.Bytes(), &logEntry)
assert.NilError(t, err)
assert.Equal(t, "http", logEntry["log_stream"])
assert.Equal(t, "test message", logEntry["message"])
}

View File

@@ -7,14 +7,22 @@ import (
"github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/config"
) )
func ParseUsers(usersStr []string) ([]config.User, error) { func ParseUsers(users string) ([]config.User, error) {
var users []config.User var usersParsed []config.User
if len(usersStr) == 0 { users = strings.TrimSpace(users)
if users == "" {
return []config.User{}, nil return []config.User{}, nil
} }
for _, user := range usersStr { userList := strings.Split(users, ",")
if len(userList) == 0 {
return []config.User{}, errors.New("invalid user format")
}
for _, user := range userList {
if strings.TrimSpace(user) == "" { if strings.TrimSpace(user) == "" {
continue continue
} }
@@ -22,71 +30,64 @@ func ParseUsers(usersStr []string) ([]config.User, error) {
if err != nil { if err != nil {
return []config.User{}, err return []config.User{}, err
} }
users = append(users, parsed) usersParsed = append(usersParsed, parsed)
} }
return users, nil return usersParsed, nil
} }
func GetUsers(usersCfg []string, usersPath string) ([]config.User, error) { func GetUsers(conf string, file string) ([]config.User, error) {
var usersStr []string var users string
if len(usersCfg) == 0 && usersPath == "" { if conf == "" && file == "" {
return []config.User{}, nil return []config.User{}, nil
} }
if len(usersCfg) > 0 { if conf != "" {
usersStr = append(usersStr, usersCfg...) users += conf
} }
if usersPath != "" { if file != "" {
contents, err := ReadFile(usersPath) contents, err := ReadFile(file)
if err != nil { if err != nil {
return []config.User{}, err return []config.User{}, err
} }
if users != "" {
lines := strings.SplitSeq(contents, "\n") users += ","
for line := range lines {
lineTrimmed := strings.TrimSpace(line)
if lineTrimmed == "" {
continue
}
usersStr = append(usersStr, lineTrimmed)
} }
users += ParseFileToLine(contents)
} }
return ParseUsers(usersStr) return ParseUsers(users)
} }
func ParseUser(userStr string) (config.User, error) { func ParseUser(user string) (config.User, error) {
if strings.Contains(userStr, "$$") { if strings.Contains(user, "$$") {
userStr = strings.ReplaceAll(userStr, "$$", "$") user = strings.ReplaceAll(user, "$$", "$")
} }
parts := strings.SplitN(userStr, ":", 4) userSplit := strings.Split(user, ":")
if len(parts) < 2 || len(parts) > 3 { if len(userSplit) < 2 || len(userSplit) > 3 {
return config.User{}, errors.New("invalid user format") return config.User{}, errors.New("invalid user format")
} }
for i, part := range parts { for _, userPart := range userSplit {
trimmed := strings.TrimSpace(part) if strings.TrimSpace(userPart) == "" {
if trimmed == "" {
return config.User{}, errors.New("invalid user format") return config.User{}, errors.New("invalid user format")
} }
parts[i] = trimmed
} }
user := config.User{ if len(userSplit) == 2 {
Username: parts[0], return config.User{
Password: parts[1], Username: strings.TrimSpace(userSplit[0]),
Password: strings.TrimSpace(userSplit[1]),
}, nil
} }
if len(parts) == 3 { return config.User{
user.TotpSecret = parts[2] Username: strings.TrimSpace(userSplit[0]),
} Password: strings.TrimSpace(userSplit[1]),
TotpSecret: strings.TrimSpace(userSplit[2]),
return user, nil }, nil
} }

View File

@@ -22,7 +22,7 @@ func TestGetUsers(t *testing.T) {
defer os.Remove("/tmp/tinyauth_users_test.txt") defer os.Remove("/tmp/tinyauth_users_test.txt")
// Test file // Test file
users, err := utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt") users, err := utils.GetUsers("", "/tmp/tinyauth_users_test.txt")
assert.NilError(t, err) assert.NilError(t, err)
@@ -34,7 +34,7 @@ func TestGetUsers(t *testing.T) {
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
// Test config // Test config
users, err = utils.GetUsers([]string{"user3:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "user4:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"}, "") users, err = utils.GetUsers("user3:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G,user4:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "")
assert.NilError(t, err) assert.NilError(t, err)
@@ -46,7 +46,7 @@ func TestGetUsers(t *testing.T) {
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
// Test both // Test both
users, err = utils.GetUsers([]string{"user5:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"}, "/tmp/tinyauth_users_test.txt") users, err = utils.GetUsers("user5:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "/tmp/tinyauth_users_test.txt")
assert.NilError(t, err) assert.NilError(t, err)
@@ -60,14 +60,14 @@ func TestGetUsers(t *testing.T) {
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[2].Password) assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[2].Password)
// Test empty // Test empty
users, err = utils.GetUsers([]string{}, "") users, err = utils.GetUsers("", "")
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, 0, len(users)) assert.Equal(t, 0, len(users))
// Test non-existent file // Test non-existent file
users, err = utils.GetUsers([]string{}, "/tmp/non_existent_file.txt") users, err = utils.GetUsers("", "/tmp/non_existent_file.txt")
assert.ErrorContains(t, err, "no such file or directory") assert.ErrorContains(t, err, "no such file or directory")
@@ -76,7 +76,7 @@ func TestGetUsers(t *testing.T) {
func TestParseUsers(t *testing.T) { func TestParseUsers(t *testing.T) {
// Valid users // Valid users
users, err := utils.ParseUsers([]string{"user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF"}) // user2 has TOTP users, err := utils.ParseUsers("user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G,user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF") // user2 has TOTP
assert.NilError(t, err) assert.NilError(t, err)
@@ -90,7 +90,7 @@ func TestParseUsers(t *testing.T) {
assert.Equal(t, "ABCDEF", users[1].TotpSecret) assert.Equal(t, "ABCDEF", users[1].TotpSecret)
// Valid weirdly spaced users // Valid weirdly spaced users
users, err = utils.ParseUsers([]string{" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G ", " user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF "}) // Spacing is on purpose users, err = utils.ParseUsers(" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G , user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF ") // Spacing is on purpose
assert.NilError(t, err) assert.NilError(t, err)
assert.Equal(t, 2, len(users)) assert.Equal(t, 2, len(users))

View File

@@ -1,113 +0,0 @@
-- name: CreateOidcCode :one
INSERT INTO "oidc_codes" (
"sub",
"code_hash",
"scope",
"redirect_uri",
"client_id",
"expires_at"
) VALUES (
?, ?, ?, ?, ?, ?
)
RETURNING *;
-- name: GetOidcCodeUnsafe :one
SELECT * FROM "oidc_codes"
WHERE "code_hash" = ?;
-- name: GetOidcCode :one
DELETE FROM "oidc_codes"
WHERE "code_hash" = ?
RETURNING *;
-- name: GetOidcCodeBySubUnsafe :one
SELECT * FROM "oidc_codes"
WHERE "sub" = ?;
-- name: GetOidcCodeBySub :one
DELETE FROM "oidc_codes"
WHERE "sub" = ?
RETURNING *;
-- name: DeleteOidcCode :exec
DELETE FROM "oidc_codes"
WHERE "code_hash" = ?;
-- name: DeleteOidcCodeBySub :exec
DELETE FROM "oidc_codes"
WHERE "sub" = ?;
-- name: CreateOidcToken :one
INSERT INTO "oidc_tokens" (
"sub",
"access_token_hash",
"refresh_token_hash",
"scope",
"client_id",
"token_expires_at",
"refresh_token_expires_at"
) VALUES (
?, ?, ?, ?, ?, ?, ?
)
RETURNING *;
-- name: UpdateOidcTokenByRefreshToken :one
UPDATE "oidc_tokens" SET
"access_token_hash" = ?,
"refresh_token_hash" = ?,
"token_expires_at" = ?,
"refresh_token_expires_at" = ?
WHERE "refresh_token_hash" = ?
RETURNING *;
-- name: GetOidcToken :one
SELECT * FROM "oidc_tokens"
WHERE "access_token_hash" = ?;
-- name: GetOidcTokenByRefreshToken :one
SELECT * FROM "oidc_tokens"
WHERE "refresh_token_hash" = ?;
-- name: GetOidcTokenBySub :one
SELECT * FROM "oidc_tokens"
WHERE "sub" = ?;
-- name: DeleteOidcToken :exec
DELETE FROM "oidc_tokens"
WHERE "access_token_hash" = ?;
-- name: DeleteOidcTokenBySub :exec
DELETE FROM "oidc_tokens"
WHERE "sub" = ?;
-- name: CreateOidcUserInfo :one
INSERT INTO "oidc_userinfo" (
"sub",
"name",
"preferred_username",
"email",
"groups",
"updated_at"
) VALUES (
?, ?, ?, ?, ?, ?
)
RETURNING *;
-- name: GetOidcUserInfo :one
SELECT * FROM "oidc_userinfo"
WHERE "sub" = ?;
-- name: DeleteOidcUserInfo :exec
DELETE FROM "oidc_userinfo"
WHERE "sub" = ?;
-- name: DeleteExpiredOidcCodes :many
DELETE FROM "oidc_codes"
WHERE "expires_at" < ?
RETURNING *;
-- name: DeleteExpiredOidcTokens :many
DELETE FROM "oidc_tokens"
WHERE "token_expires_at" < ? AND "refresh_token_expires_at" < ?
RETURNING *;

View File

@@ -1,27 +0,0 @@
CREATE TABLE IF NOT EXISTS "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
"scope" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"expires_at" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "oidc_tokens" (
"sub" TEXT NOT NULL UNIQUE,
"access_token_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
"refresh_token_hash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" INTEGER NOT NULL,
"refresh_token_expires_at" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "oidc_userinfo" (
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
"name" TEXT NOT NULL,
"preferred_username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"groups" TEXT NOT NULL,
"updated_at" INTEGER NOT NULL
);

View File

@@ -1,43 +0,0 @@
-- name: CreateSession :one
INSERT INTO "sessions" (
"uuid",
"username",
"email",
"name",
"provider",
"totp_pending",
"oauth_groups",
"expiry",
"created_at",
"oauth_name",
"oauth_sub"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING *;
-- name: GetSession :one
SELECT * FROM "sessions"
WHERE "uuid" = ?;
-- name: DeleteSession :exec
DELETE FROM "sessions"
WHERE "uuid" = ?;
-- name: UpdateSession :one
UPDATE "sessions" SET
"username" = ?,
"email" = ?,
"name" = ?,
"provider" = ?,
"totp_pending" = ?,
"oauth_groups" = ?,
"expiry" = ?,
"oauth_name" = ?,
"oauth_sub" = ?
WHERE "uuid" = ?
RETURNING *;
-- name: DeleteExpiredSessions :exec
DELETE FROM "sessions"
WHERE "expiry" < ?;

View File

@@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS "sessions" (
"uuid" TEXT NOT NULL PRIMARY KEY UNIQUE,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"totp_pending" BOOLEAN NOT NULL,
"oauth_groups" TEXT NULL,
"expiry" INTEGER NOT NULL,
"created_at" INTEGER NOT NULL,
"oauth_name" TEXT NULL,
"oauth_sub" TEXT NULL
);

View File

@@ -1,24 +0,0 @@
version: "2"
sql:
- engine: "sqlite"
queries: "sql/*_queries.sql"
schema: "sql/*_schemas.sql"
gen:
go:
package: "repository"
out: "internal/repository"
rename:
uuid: "UUID"
oauth_groups: "OAuthGroups"
oauth_name: "OAuthName"
oauth_sub: "OAuthSub"
redirect_uri: "RedirectURI"
overrides:
- column: "sessions.oauth_groups"
go_type: "string"
- column: "sessions.oauth_name"
go_type: "string"
- column: "sessions.oauth_sub"
go_type: "string"
- column: "sessions.ldap_groups"
go_type: "string"