Compare commits

..

7 Commits

32 changed files with 1170 additions and 412 deletions
+66 -1
View File
@@ -7,7 +7,9 @@ TINYAUTH_APPURL=
# database config
# The path to the database, including file name.
# The database driver to use. Valid values: sqlite, memory.
TINYAUTH_DATABASE_DRIVER="sqlite"
# The path to the SQLite database, including file name. Only used when driver is sqlite.
TINYAUTH_DATABASE_PATH="./tinyauth.db"
# analytics config
@@ -30,6 +32,8 @@ TINYAUTH_SERVER_PORT=3000
TINYAUTH_SERVER_ADDRESS="0.0.0.0"
# The path to the Unix socket.
TINYAUTH_SERVER_SOCKETPATH=
# Enable listening on both TCP and Unix socket at the same time.
TINYAUTH_SERVER_CONCURRENTLISTENERSENABLED=false
# auth config
@@ -37,8 +41,52 @@ TINYAUTH_SERVER_SOCKETPATH=
TINYAUTH_AUTH_IP_ALLOW=
# List of blocked IPs or CIDR ranges.
TINYAUTH_AUTH_IP_BLOCK=
# List of IPs or CIDR ranges that bypass authentication entirely.
TINYAUTH_AUTH_IP_BYPASS=
# Comma-separated list of users (username:hashed_password).
TINYAUTH_AUTH_USERS=
# Enable subdomains support.
TINYAUTH_AUTH_SUBDOMAINSENABLED=true
# Full name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_NAME=
# Given (first) name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_GIVENNAME=
# Family (last) name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_FAMILYNAME=
# Middle name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_MIDDLENAME=
# Nickname of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_NICKNAME=
# URL of the user's profile page.
TINYAUTH_AUTH_USERATTRIBUTES_name_PROFILE=
# URL of the user's profile picture.
TINYAUTH_AUTH_USERATTRIBUTES_name_PICTURE=
# URL of the user's website.
TINYAUTH_AUTH_USERATTRIBUTES_name_WEBSITE=
# Email address of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_EMAIL=
# Gender of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_GENDER=
# Birthdate of the user (YYYY-MM-DD).
TINYAUTH_AUTH_USERATTRIBUTES_name_BIRTHDATE=
# Time zone of the user (e.g. Europe/Athens).
TINYAUTH_AUTH_USERATTRIBUTES_name_ZONEINFO=
# Locale of the user (e.g. en-US).
TINYAUTH_AUTH_USERATTRIBUTES_name_LOCALE=
# Phone number of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_PHONENUMBER=
# Full mailing address, formatted for display.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_FORMATTED=
# Street address.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_STREETADDRESS=
# City or locality.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_LOCALITY=
# State, province, or region.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_REGION=
# Zip or postal code.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_POSTALCODE=
# Country.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_COUNTRY=
# Path to the users file.
TINYAUTH_AUTH_USERSFILE=
# Enable secure cookies.
@@ -53,6 +101,8 @@ TINYAUTH_AUTH_LOGINTIMEOUT=300
TINYAUTH_AUTH_LOGINMAXRETRIES=3
# Comma-separated list of trusted proxy addresses.
TINYAUTH_AUTH_TRUSTEDPROXIES=
# ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow.
TINYAUTH_AUTH_ACLS_POLICY="allow"
# apps config
@@ -168,6 +218,8 @@ TINYAUTH_LDAP_AUTHCERT=
TINYAUTH_LDAP_AUTHKEY=
# Cache duration for LDAP group membership in seconds.
TINYAUTH_LDAP_GROUPCACHETTL=900
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
TINYAUTH_LABELPROVIDER="auto"
# log config
@@ -187,3 +239,16 @@ TINYAUTH_LOG_STREAMS_APP_LEVEL=
TINYAUTH_LOG_STREAMS_AUDIT_ENABLED=false
# Log level for this stream. Use global if empty.
TINYAUTH_LOG_STREAMS_AUDIT_LEVEL=
# tailscale config
# Enable Tailscale integration.
TINYAUTH_TAILSCALE_ENABLED=false
# Tailscale state directory.
TINYAUTH_TAILSCALE_DIR="./tailscale_state"
# Tailscale hostname.
TINYAUTH_TAILSCALE_HOSTNAME=
# Tailscale auth key.
TINYAUTH_TAILSCALE_AUTHKEY=
# Use ephemeral Tailscale node.
TINYAUTH_TAILSCALE_EPHEMERAL=false
+2 -2
View File
@@ -83,7 +83,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
go build -ldflags "-X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
env:
CGO_ENABLED: 0
@@ -128,7 +128,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
go build -ldflags "-X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
env:
CGO_ENABLED: 0
+4
View File
@@ -150,6 +150,7 @@ jobs:
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS="-s -w"
- name: Export digest
run: |
@@ -206,6 +207,7 @@ jobs:
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS="-s -w"
- name: Export digest
run: |
@@ -260,6 +262,7 @@ jobs:
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS="-s -w"
- name: Export digest
run: |
@@ -316,6 +319,7 @@ jobs:
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS="-s -w"
- name: Export digest
run: |
+2 -1
View File
@@ -27,6 +27,7 @@ FROM golang:1.26-alpine3.23 AS builder
ARG VERSION
ARG COMMIT_HASH
ARG BUILD_TIMESTAMP
ARG LDFLAGS
WORKDIR /tinyauth
@@ -39,7 +40,7 @@ COPY ./cmd ./cmd
COPY ./internal ./internal
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 "${LDFLAGS} \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
+2 -1
View File
@@ -27,6 +27,7 @@ FROM golang:1.26-alpine3.23 AS builder
ARG VERSION
ARG COMMIT_HASH
ARG BUILD_TIMESTAMP
ARG LDFLAGS
WORKDIR /tinyauth
@@ -41,7 +42,7 @@ COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN mkdir -p data
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
RUN CGO_ENABLED=0 go build -ldflags "${LDFLAGS} \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
+11 -1
View File
@@ -8,6 +8,7 @@ TAG_NAME := $(shell git describe --abbrev=0 --exact-match 2> /dev/null || echo "
COMMIT_HASH := $(shell git rev-parse HEAD)
BUILD_TIMESTAMP := $(shell date '+%Y-%m-%dT%H:%M:%S')
BIN_NAME := tinyauth-$(GOARCH)
LDFLAGS := -s -w
# Development vars
DEV_COMPOSE := $(shell test -f "docker-compose.test.yml" && echo "docker-compose.test.yml" || echo "docker-compose.dev.yml" )
@@ -36,7 +37,7 @@ webui: clean-webui
# Build the binary
binary: webui
CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "-s -w \
CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "${LDFLAGS} \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${TAG_NAME} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" \
@@ -61,6 +62,15 @@ binary-linux-arm64:
test:
go test -v ./...
# Go vet
.PHONY: vet
vet:
go vet ./...
# Go race
test-race:
go test -race ./...
# Development
dev:
docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build
+2 -1
View File
@@ -12,9 +12,11 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/go-querystring v1.2.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/mdp/qrterminal/v3 v3.2.1
github.com/pquerna/otp v1.5.0
github.com/rs/zerolog v1.35.1
github.com/steveiliop56/ding v0.2.0
github.com/stretchr/testify v1.11.1
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
github.com/weppos/publicsuffix-go v0.50.3
@@ -93,7 +95,6 @@ require (
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+4
View File
@@ -143,6 +143,8 @@ github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbww
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@@ -400,6 +402,8 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/steveiliop56/ding v0.2.0 h1:m/Fj99wBpVVLHlpqb2RDJkWubOc5cWJ11ZYCHya3Sk0=
github.com/steveiliop56/ding v0.2.0/go.mod h1:bE2u2XH7CjhPzbb/0Ems+D8YZlf2Ae+eKhj00UR1iAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+30 -17
View File
@@ -13,11 +13,11 @@ import (
"os/signal"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository"
@@ -26,6 +26,12 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
// Shutdown order for go routines
// 1. Janitor routines (e.g. database cleanup, heartbeat) - ding.RingMinor
// 2. HTTP server listeners - ding.RingNormal
// 3. Networking layers, user and label providers (e.g. ailscale service, kubernetes service) - ding.RingMajor
// 4. Database connection - ding.RingCritical
type Services struct {
accessControlService *service.AccessControlsService
authService *service.AuthService
@@ -48,7 +54,7 @@ type BootstrapApp struct {
queries repository.Store
router *gin.Engine
db *sql.DB
wg sync.WaitGroup
ding *ding.Ding
listeners []Listener
}
@@ -64,6 +70,10 @@ func (app *BootstrapApp) Setup() error {
app.ctx = ctx
app.cancel = cancel
// Create a ding instance
dg := ding.New(ctx)
app.ding = dg
// setup logger
log := logger.NewLogger().WithConfig(app.config.Log)
log.Init()
@@ -186,15 +196,17 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to setup database: %w", err)
}
// after this point, we start initializing dependencies so it's a good time to setup a defer
// to ensure that resources are cleaned up properly in case of an error during initialization
defer func() {
app.cancel()
app.wg.Wait()
if app.db != nil {
app.db.Close()
app.ding.Go(func(ctx context.Context) {
<-ctx.Done()
app.log.App.Debug().Msg("Shutting down database connection")
if app.db == nil {
// using memory store, no db instance
return
}
}()
if err := app.db.Close(); err != nil {
app.log.App.Error().Err(err).Msg("Failed to close database connection")
}
}, ding.RingCritical)
// store
app.queries = store
@@ -261,12 +273,12 @@ func (app *BootstrapApp) Setup() error {
// start db cleanup routine
app.log.App.Debug().Msg("Starting database cleanup routine")
app.wg.Go(app.dbCleanupRoutine)
app.ding.Go(app.dbCleanupRoutine, ding.RingMinor)
// if analytics are not disabled, start heartbeat
if app.config.Analytics.Enabled {
app.log.App.Debug().Msg("Starting heartbeat routine")
app.wg.Go(app.heartbeatRoutine)
app.ding.Go(app.heartbeatRoutine, ding.RingMinor)
}
// setup listeners
@@ -287,6 +299,7 @@ func (app *BootstrapApp) Setup() error {
for {
select {
case <-app.ctx.Done():
app.ding.Wait()
app.log.App.Info().Msg("Oh, it's time for me to go, bye!")
return nil
case err := <-lec:
@@ -297,7 +310,7 @@ func (app *BootstrapApp) Setup() error {
}
}
func (app *BootstrapApp) heartbeatRoutine() {
func (app *BootstrapApp) heartbeatRoutine(ctx context.Context) {
ticker := time.NewTicker(time.Duration(12) * time.Hour)
defer ticker.Stop()
@@ -350,7 +363,7 @@ func (app *BootstrapApp) heartbeatRoutine() {
if res.StatusCode != 200 && res.StatusCode != 201 {
app.log.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
}
case <-app.ctx.Done():
case <-ctx.Done():
app.log.App.Debug().Msg("Stopping heartbeat routine")
ticker.Stop()
return
@@ -358,7 +371,7 @@ func (app *BootstrapApp) heartbeatRoutine() {
}
}
func (app *BootstrapApp) dbCleanupRoutine() {
func (app *BootstrapApp) dbCleanupRoutine(ctx context.Context) {
ticker := time.NewTicker(time.Duration(30) * time.Minute)
defer ticker.Stop()
@@ -367,14 +380,14 @@ func (app *BootstrapApp) dbCleanupRoutine() {
case <-ticker.C:
app.log.App.Debug().Msg("Running database cleanup")
err := app.queries.DeleteExpiredSessions(app.ctx, time.Now().Unix())
err := app.queries.DeleteExpiredSessions(ctx, time.Now().Unix())
if err != nil {
app.log.App.Error().Err(err).Msg("Failed to delete expired sessions")
}
app.log.App.Debug().Msg("Database cleanup completed")
case <-app.ctx.Done():
case <-ctx.Done():
app.log.App.Debug().Msg("Stopping database cleanup routine")
ticker.Stop()
return
+17 -20
View File
@@ -9,6 +9,7 @@ import (
"os"
"time"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/middleware"
"github.com/tinyauthapp/tinyauth/internal/model"
@@ -80,9 +81,9 @@ func (app *BootstrapApp) runListeners() (chan error, error) {
return nil, fmt.Errorf("failed to get listener function: %w", err)
}
app.wg.Go(func() {
lec <- listenerFunc()
})
app.ding.Go(func(ctx context.Context) {
lec <- listenerFunc(ctx)
}, ding.RingNormal)
}
return lec, nil
@@ -125,7 +126,7 @@ func (app *BootstrapApp) calculateListenerPolicy() []Listener {
return l
}
func (app *BootstrapApp) listenerFromType(listenerType Listener) (func() error, error) {
func (app *BootstrapApp) listenerFromType(listenerType Listener) (func(ctx context.Context) error, error) {
switch listenerType {
case ListenerHTTP:
return app.serveHTTP, nil
@@ -138,7 +139,7 @@ func (app *BootstrapApp) listenerFromType(listenerType Listener) (func() error,
}
}
func (app *BootstrapApp) serveHTTP() error {
func (app *BootstrapApp) serveHTTP(ctx context.Context) error {
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
app.log.App.Info().Msgf("Starting server on %s", address)
@@ -154,10 +155,10 @@ func (app *BootstrapApp) serveHTTP() error {
Handler: app.router.Handler(),
}
return app.serve(listener, server, "http")
return app.serve(listener, server, ctx, "http")
}
func (app *BootstrapApp) serveUnix() error {
func (app *BootstrapApp) serveUnix(ctx context.Context) error {
_, err := os.Stat(app.config.Server.SocketPath)
if err == nil {
@@ -181,10 +182,10 @@ func (app *BootstrapApp) serveUnix() error {
Handler: app.router.Handler(),
}
return app.serve(listener, server, "unix socket")
return app.serve(listener, server, ctx, "unix socket")
}
func (app *BootstrapApp) serveTailscale() error {
func (app *BootstrapApp) serveTailscale(ctx context.Context) error {
app.log.App.Info().Msgf("Starting Tailscale server on %s", fmt.Sprintf("https://%s", app.services.tailscaleService.GetHostname()))
listener, err := app.services.tailscaleService.CreateListener()
@@ -197,27 +198,23 @@ func (app *BootstrapApp) serveTailscale() error {
Handler: app.router.Handler(),
}
return app.serve(listener, server, "tailscale")
return app.serve(listener, server, ctx, "tailscale")
}
func (app *BootstrapApp) serve(listener net.Listener, server *http.Server, name string) error {
func (app *BootstrapApp) serve(listener net.Listener, server *http.Server, ctx context.Context, name string) error {
shutdown := func() {
ctx, cancel := context.WithTimeout(context.Background(), model.GracefulShutdownTimeout*time.Second)
// we use a new context for the shutdown since the main one is cancelled
sctx, cancel := context.WithTimeout(context.Background(), model.GracefulShutdownTimeout*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil &&
// With tailscale, the goroutine for shutting down the tailscale connection
// runs first and causes the connection the tailscale listener is running on to close
// first so, the shutdown fails
// TODO: add priority to the goroutine shutdowns
!errors.Is(err, net.ErrClosed) {
err := server.Shutdown(sctx)
if err != nil {
app.log.App.Error().Err(err).Msgf("Failed to shutdown %s listener gracefully", name)
}
listener.Close()
}
go func() {
<-app.ctx.Done()
<-ctx.Done()
app.log.App.Debug().Msgf("Shutting down %s listener", name)
shutdown()
}()
+6 -6
View File
@@ -8,7 +8,7 @@ import (
)
func (app *BootstrapApp) setupServices() error {
ldapService, err := service.NewLdapService(app.log, app.config, app.ctx, &app.wg)
ldapService, err := service.NewLdapService(app.log, app.config, app.ding)
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to initialize LDAP connection, will continue without it")
@@ -22,7 +22,7 @@ func (app *BootstrapApp) setupServices() error {
return fmt.Errorf("failed to initialize label provider: %w", err)
}
tailscaleService, err := service.NewTailscaleService(app.log, app.config, app.ctx, &app.wg)
tailscaleService, err := service.NewTailscaleService(app.log, app.config, app.ctx, app.ding)
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to initialize Tailscale connection, will continue without it")
@@ -42,10 +42,10 @@ func (app *BootstrapApp) setupServices() error {
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
app.services.oauthBrokerService = oauthBrokerService
authService := service.NewAuthService(app.log, app.config, app.runtime, app.ctx, &app.wg, app.services.ldapService, app.queries, app.services.oauthBrokerService, app.services.tailscaleService, app.services.policyEngine)
authService := service.NewAuthService(app.log, app.config, app.runtime, app.ctx, app.ding, app.services.ldapService, app.queries, app.services.oauthBrokerService, app.services.tailscaleService, app.services.policyEngine)
app.services.authService = authService
oidcService, err := service.NewOIDCService(app.log, app.config, app.runtime, app.queries, app.ctx, &app.wg)
oidcService, err := service.NewOIDCService(app.log, app.config, app.runtime, app.queries, app.ding)
if err != nil {
return fmt.Errorf("failed to initialize oidc service: %w", err)
@@ -69,7 +69,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
if useKubernetes {
app.log.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, app.ding)
if err != nil {
return nil, fmt.Errorf("failed to initialize kubernetes service: %w", err)
@@ -81,7 +81,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
app.log.App.Debug().Msg("Using Docker label provider")
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
dockerService, err := service.NewDockerService(app.log, app.ctx, app.ding)
if err != nil {
return nil, fmt.Errorf("failed to initialize docker service: %w", err)
+91 -22
View File
@@ -16,6 +16,15 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
type authorizeErrorParams struct {
err error
reason string
reasonPublic string
callback string
callbackError string
state string
}
type OIDCController struct {
log *logger.Logger
oidc *service.OIDCService
@@ -119,34 +128,55 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
func (controller *OIDCController) Authorize(c *gin.Context) {
if controller.oidc == nil {
controller.authorizeError(c, errors.New("err_oidc_not_configured"), "OIDC not configured", "This instance is not configured for OIDC", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err_oidc_not_configured"),
reason: "OIDC not configured",
reasonPublic: "This instance is not configured for OIDC",
})
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to get user context",
reasonPublic: "User is not logged in or the session is invalid",
})
return
}
if !userContext.Authenticated {
controller.authorizeError(c, errors.New("err user not logged in"), "User not logged in", "The user is not logged in", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err user not logged in"),
reason: "User not logged in",
reasonPublic: "The user is not logged in",
})
return
}
var req service.AuthorizeRequest
err = c.BindJSON(&req)
err = c.Bind(&req)
if err != nil {
controller.authorizeError(c, err, "Failed to bind JSON", "The client provided an invalid authorization request", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to bind JSON",
reasonPublic: "The client provided an invalid authorization request",
})
return
}
client, ok := controller.oidc.GetClient(req.ClientID)
if !ok {
controller.authorizeError(c, fmt.Errorf("client not found: %s", req.ClientID), "Client not found", "The client ID is invalid", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: fmt.Errorf("client not found: %s", req.ClientID),
reason: "Client not found",
reasonPublic: "The client ID is invalid",
})
return
}
@@ -155,10 +185,21 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
if err != nil {
controller.log.App.Warn().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)
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed validate authorize params",
reasonPublic: "Invalid request parameters",
callback: req.RedirectURI,
callbackError: err.Error(),
state: req.State,
})
return
}
controller.authorizeError(c, err, "Redirect URI not trusted", "The provided redirect URI is not trusted", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Redirect URI not trusted",
reasonPublic: "The provided redirect URI is not trusted",
})
return
}
@@ -169,14 +210,28 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
// 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)
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to delete old sessions",
reasonPublic: "Failed to delete old sessions",
callback: req.RedirectURI,
callbackError: "server_error",
state: 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)
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to store code",
reasonPublic: "Failed to store code",
callback: req.RedirectURI,
callbackError: "server_error",
state: req.State,
})
return
}
@@ -186,7 +241,14 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to store user info")
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to store user info",
reasonPublic: "Failed to store user info",
callback: req.RedirectURI,
callbackError: "server_error",
state: req.State,
})
return
}
}
@@ -197,7 +259,14 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
})
if err != nil {
controller.authorizeError(c, err, "Failed to build query", "Failed to build query", req.RedirectURI, "server_error", req.State)
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to build query",
reasonPublic: "Failed to build query",
callback: req.RedirectURI,
callbackError: "server_error",
state: req.State,
})
return
}
@@ -478,20 +547,20 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
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) {
controller.log.App.Warn().Err(err).Str("reason", reason).Msg("Authorization error")
func (controller *OIDCController) authorizeError(c *gin.Context, params authorizeErrorParams) {
controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error")
if callback != "" {
if params.callback != "" {
errorQueries := CallbackError{
Error: callbackError,
Error: params.callbackError,
}
if reasonUser != "" {
errorQueries.ErrorDescription = reasonUser
if params.reasonPublic != "" {
errorQueries.ErrorDescription = params.reasonPublic
}
if state != "" {
errorQueries.State = state
if params.state != "" {
errorQueries.State = params.state
}
queries, err := query.Values(errorQueries)
@@ -503,13 +572,13 @@ func (controller *OIDCController) authorizeError(c *gin.Context, err error, reas
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": fmt.Sprintf("%s?%s", callback, queries.Encode()),
"redirect_uri": fmt.Sprintf("%s?%s", params.callback, queries.Encode()),
})
return
}
errorQueries := ErrorScreen{
Error: reasonUser,
Error: params.reasonPublic,
}
queries, err := query.Values(errorQueries)
+3 -3
View File
@@ -8,11 +8,11 @@ import (
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
@@ -840,9 +840,9 @@ func TestOIDCController(t *testing.T) {
store := memory.New()
wg := &sync.WaitGroup{}
dg := ding.New(context.TODO())
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, context.TODO(), wg)
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, dg)
require.NoError(t, err)
for _, test := range tests {
+3 -3
View File
@@ -3,10 +3,10 @@ package controller_test
import (
"context"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
@@ -353,8 +353,8 @@ func TestProxyController(t *testing.T) {
store := memory.New()
wg := &sync.WaitGroup{}
ctx := context.TODO()
dg := ding.New(ctx)
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
aclsService := service.NewAccessControlsService(log, cfg, nil)
@@ -382,7 +382,7 @@ func TestProxyController(t *testing.T) {
Log: log,
})
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker, nil, policyEngine)
authService := service.NewAuthService(log, cfg, runtime, ctx, dg, nil, store, broker, nil, policyEngine)
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
+4 -4
View File
@@ -6,12 +6,12 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
@@ -412,17 +412,17 @@ func TestUserController(t *testing.T) {
}
ctx := context.TODO()
wg := &sync.WaitGroup{}
dg := ding.New(ctx)
policyEngine, err := service.NewPolicyEngine(cfg, log)
require.NoError(t, err)
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker, nil, policyEngine)
authService := service.NewAuthService(log, cfg, runtime, ctx, dg, nil, store, broker, nil, policyEngine)
beforeEach := func() {
// Clear failed login attempts before each test
authService.ClearRateLimitsTestingOnly()
authService.ClearLoginAttempts()
}
for _, test := range tests {
@@ -5,10 +5,10 @@ import (
"encoding/json"
"fmt"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
@@ -89,11 +89,11 @@ func TestWellKnownController(t *testing.T) {
}
ctx := context.TODO()
wg := &sync.WaitGroup{}
dg := ding.New(ctx)
store := memory.New()
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, ctx, wg)
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, dg)
require.NoError(t, err)
for _, test := range tests {
@@ -326,11 +326,6 @@ func (m *ContextMiddleware) tailscaleWhois(ctx context.Context, ip string) (*mod
Name: whois.DisplayName,
},
UserID: whois.UserID,
Tags: whois.Tags,
}
if !strings.ContainsAny(uctx.Email, "@") {
uctx.Email = utils.CompileUserEmail(uctx.Email+"-tailscale", m.runtime.CookieDomain)
}
return &uctx, nil
@@ -5,11 +5,11 @@ import (
"encoding/base64"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/middleware"
@@ -250,7 +250,7 @@ func TestContextMiddleware(t *testing.T) {
}
ctx := context.TODO()
wg := &sync.WaitGroup{}
dg := ding.New(ctx)
store := memory.New()
@@ -258,12 +258,12 @@ func TestContextMiddleware(t *testing.T) {
require.NoError(t, err)
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker, nil, policyEngine)
authService := service.NewAuthService(log, cfg, runtime, ctx, dg, nil, store, broker, nil, policyEngine)
contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker, nil)
for _, test := range tests {
authService.ClearRateLimitsTestingOnly()
authService.ClearLoginAttempts()
t.Run(test.description, func(t *testing.T) {
gin.SetMode(gin.TestMode)
+3 -6
View File
@@ -62,9 +62,6 @@ func NewDefaultConfiguration() *Config {
PrivateKeyPath: "./tinyauth_oidc_key",
PublicKeyPath: "./tinyauth_oidc_key.pub",
},
Experimental: ExperimentalConfig{
ConfigFile: "",
},
Tailscale: TailscaleConfig{
Dir: "./tailscale_state",
},
@@ -88,6 +85,7 @@ type Config struct {
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider"`
Log LogConfig `description:"Logging configuration." yaml:"log"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale"`
ConfigFile string `description:"Path to config file." yaml:"-"`
}
type DatabaseConfig struct {
@@ -208,9 +206,8 @@ type LogStreamConfig struct {
Level string `description:"Log level for this stream. Use global if empty." yaml:"level"`
}
type ExperimentalConfig struct {
ConfigFile string `description:"Path to config file." yaml:"-"`
}
// no experimental features
type ExperimentalConfig struct{}
type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled"`
-2
View File
@@ -59,8 +59,6 @@ type LDAPContext struct {
type TailscaleContext struct {
BaseContext
UserID string
// for future use
Tags []string
}
func (c *UserContext) IsAuthenticated() bool {
+45 -12
View File
@@ -9,6 +9,12 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
// For LDAP and OAuth groups and IP allow/deny, we default to allow even with a deny policy.
// This is because we can't force the user to use groups in LDAP and OAuth if they would like to use
// a deny policy. As for IP checks, we can't reliably get the client IP (most of Tinyauth instances are
// behind a Docker bridge network) so to make it easier for users to use a deny policy without
// issues with IPs we allow by default.
type RuleName string
const (
@@ -25,7 +31,11 @@ type UserAllowedRule struct {
}
func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil || ctx.UserContext == nil {
if ctx.UserContext == nil {
return EffectDeny
}
if ctx.ACLs == nil {
return EffectAbstain
}
@@ -34,7 +44,7 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email)
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist")
return EffectAbstain
return EffectDeny
}
if match {
rule.Log.App.Debug().Str("email", ctx.UserContext.OAuth.Email).Msg("User is in OAuth whitelist, allowing access")
@@ -48,7 +58,7 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
match, err := utils.CheckFilter(ctx.ACLs.Users.Block, ctx.UserContext.GetUsername())
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users block list")
return EffectAbstain
return EffectDeny
}
if match {
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is in users block list, denying access")
@@ -62,8 +72,11 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
if err != nil {
if err == utils.ErrFilterEmpty {
return EffectAbstain
}
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
return EffectAbstain
return EffectDeny
}
if match {
@@ -80,13 +93,22 @@ type OAuthGroupRule struct {
}
func (rule *OAuthGroupRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil || ctx.UserContext == nil {
return EffectAbstain
if ctx.UserContext == nil {
return EffectDeny
}
if ctx.ACLs == nil {
return EffectAllow
}
if !ctx.UserContext.IsOAuth() {
rule.Log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return EffectAbstain
return EffectAllow
}
if len(ctx.ACLs.OAuth.Groups) == 0 {
rule.Log.App.Debug().Msg("No OAuth groups specified in ACLs, allowing access")
return EffectAllow
}
if _, ok := model.OverrideProviders[ctx.UserContext.OAuth.ID]; ok {
@@ -97,7 +119,8 @@ func (rule *OAuthGroupRule) Evaluate(ctx *ACLContext) Effect {
for _, group := range ctx.UserContext.OAuth.Groups {
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Groups, strings.TrimSpace(group))
if err != nil {
return EffectAbstain
rule.Log.App.Warn().Err(err).Str("item", group).Msg("Invalid entry in OAuth groups ACL")
return EffectDeny
}
if match {
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.OAuth.Groups).Msg("User group matched, allowing access")
@@ -114,19 +137,29 @@ type LDAPGroupRule struct {
}
func (rule *LDAPGroupRule) Evaluate(ctx *ACLContext) Effect {
if ctx == nil || ctx.UserContext == nil || ctx.ACLs == nil {
return EffectAbstain
if ctx.UserContext == nil {
return EffectDeny
}
if ctx.ACLs == nil {
return EffectAllow
}
if !ctx.UserContext.IsLDAP() {
rule.Log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
return EffectAbstain
return EffectAllow
}
if len(ctx.ACLs.LDAP.Groups) == 0 {
rule.Log.App.Debug().Msg("No LDAP groups specified in ACLs, allowing access")
return EffectAllow
}
for _, group := range ctx.UserContext.LDAP.Groups {
match, err := utils.CheckFilter(ctx.ACLs.LDAP.Groups, strings.TrimSpace(group))
if err != nil {
return EffectAbstain
rule.Log.App.Warn().Err(err).Str("item", group).Msg("Invalid entry in LDAP groups ACL")
return EffectDeny
}
if match {
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.LDAP.Groups).Msg("User group matched, allowing access")
+95 -42
View File
@@ -21,6 +21,16 @@ func TestUserAllowedRule(t *testing.T) {
ctx *ACLContext
expected Effect
}{
{
name: "denies when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectDeny,
},
{
name: "abstains when ACLs are nil",
ctx: &ACLContext{
@@ -34,16 +44,6 @@ func TestUserAllowedRule(t *testing.T) {
},
expected: EffectAbstain,
},
{
name: "abstains when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
},
{
name: "allows OAuth user when email matches whitelist",
ctx: &ACLContext{
@@ -78,7 +78,7 @@ func TestUserAllowedRule(t *testing.T) {
expected: EffectDeny,
},
{
name: "abstains for OAuth user when whitelist filter is invalid",
name: "denies for OAuth user when whitelist filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "/[/"},
@@ -90,7 +90,7 @@ func TestUserAllowedRule(t *testing.T) {
},
},
},
expected: EffectAbstain,
expected: EffectDeny,
},
{
name: "denies local user when username matches block list",
@@ -123,7 +123,7 @@ func TestUserAllowedRule(t *testing.T) {
expected: EffectAllow,
},
{
name: "abstains when block list filter is invalid",
name: "denies when block list filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Block: "/[/"},
@@ -135,6 +135,21 @@ func TestUserAllowedRule(t *testing.T) {
},
},
},
expected: EffectDeny,
},
{
name: "abstains when allow list is empty",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Allow: ""},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAbstain,
},
{
@@ -168,7 +183,7 @@ func TestUserAllowedRule(t *testing.T) {
expected: EffectDeny,
},
{
name: "abstains when allow list filter is invalid",
name: "denies when allow list filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Allow: "/[/"},
@@ -180,7 +195,7 @@ func TestUserAllowedRule(t *testing.T) {
},
},
},
expected: EffectAbstain,
expected: EffectDeny,
},
}
@@ -203,7 +218,17 @@ func TestOAuthGroupRule(t *testing.T) {
expected Effect
}{
{
name: "abstains when ACLs are nil",
name: "denies when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectDeny,
},
{
name: "allows when ACLs are nil",
ctx: &ACLContext{
ACLs: nil,
UserContext: &model.UserContext{
@@ -213,20 +238,10 @@ func TestOAuthGroupRule(t *testing.T) {
},
},
},
expected: EffectAbstain,
expected: EffectAllow,
},
{
name: "abstains when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
},
{
name: "abstains when user is not OAuth",
name: "allows when user is not OAuth",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: "admins"},
@@ -238,7 +253,22 @@ func TestOAuthGroupRule(t *testing.T) {
},
},
},
expected: EffectAbstain,
expected: EffectAllow,
},
{
name: "allows when group filter is empty",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: ""},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAllow,
},
{
name: "allows when provider is an override provider regardless of groups",
@@ -305,7 +335,7 @@ func TestOAuthGroupRule(t *testing.T) {
expected: EffectDeny,
},
{
name: "abstains when groups filter is invalid",
name: "denies when groups filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: "/[/"},
@@ -318,7 +348,7 @@ func TestOAuthGroupRule(t *testing.T) {
},
},
},
expected: EffectAbstain,
expected: EffectDeny,
},
}
@@ -341,22 +371,30 @@ func TestLDAPGroupRule(t *testing.T) {
expected Effect
}{
{
name: "abstains when context is nil",
ctx: nil,
expected: EffectAbstain,
},
{
name: "abstains when user context is nil",
name: "denies when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
expected: EffectDeny,
},
{
name: "abstains when user is not LDAP",
name: "allows when acls are nil",
ctx: &ACLContext{
ACLs: nil,
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAllow,
},
{
name: "allows when user is not LDAP",
ctx: &ACLContext{
ACLs: &model.App{
LDAP: model.AppLDAP{Groups: "admins"},
@@ -368,7 +406,22 @@ func TestLDAPGroupRule(t *testing.T) {
},
},
},
expected: EffectAbstain,
expected: EffectAllow,
},
{
name: "allows when group filter is empty",
ctx: &ACLContext{
ACLs: &model.App{
LDAP: model.AppLDAP{Groups: ""},
},
UserContext: &model.UserContext{
Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAllow,
},
{
name: "allows LDAP user when a group matches",
@@ -416,7 +469,7 @@ func TestLDAPGroupRule(t *testing.T) {
expected: EffectDeny,
},
{
name: "abstains when groups filter is invalid",
name: "denies when groups filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
LDAP: model.AppLDAP{Groups: "/[/"},
@@ -428,7 +481,7 @@ func TestLDAPGroupRule(t *testing.T) {
},
},
},
expected: EffectAbstain,
expected: EffectDeny,
},
}
+132 -192
View File
@@ -9,13 +9,12 @@ import (
"sync"
"time"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"slices"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
@@ -53,27 +52,17 @@ type OAuthPendingSession struct {
CallbackParams OAuthURLParams
}
type LdapGroupsCache struct {
Groups []string
Expires time.Time
}
type LoginAttempt struct {
FailedAttempts int
LastAttempt time.Time
LockedUntil time.Time
}
type Lockdown struct {
Active bool
ActiveUntil time.Time
}
type AuthService struct {
log *logger.Logger
config model.Config
runtime model.RuntimeConfig
context context.Context
ctx context.Context
ldap *LdapService
queries repository.Store
@@ -81,15 +70,19 @@ type AuthService struct {
tailscale *TailscaleService
policyEngine *PolicyEngine
loginAttempts map[string]*LoginAttempt
ldapGroupsCache map[string]*LdapGroupsCache
oauthPendingSessions map[string]*OAuthPendingSession
oauthMutex sync.RWMutex
loginMutex sync.RWMutex
ldapGroupsMutex sync.RWMutex
lockdown *Lockdown
lockdownCtx context.Context
lockdownCancelFunc context.CancelFunc
lockdown struct {
active bool
until time.Time
ctx context.Context
cancelFunc context.CancelFunc
mu sync.RWMutex
}
caches struct {
login *CacheStore[LoginAttempt]
oauth *CacheStore[OAuthPendingSession]
ldap *CacheStore[[]string]
}
}
func NewAuthService(
@@ -97,7 +90,7 @@ func NewAuthService(
config model.Config,
runtime model.RuntimeConfig,
ctx context.Context,
wg *sync.WaitGroup,
dg *ding.Ding,
ldap *LdapService,
queries repository.Store,
oauthBroker *OAuthBrokerService,
@@ -105,21 +98,41 @@ func NewAuthService(
policy *PolicyEngine,
) *AuthService {
service := &AuthService{
log: log,
runtime: runtime,
context: ctx,
config: config,
loginAttempts: make(map[string]*LoginAttempt),
ldapGroupsCache: make(map[string]*LdapGroupsCache),
oauthPendingSessions: make(map[string]*OAuthPendingSession),
ldap: ldap,
queries: queries,
oauthBroker: oauthBroker,
tailscale: tailscale,
policyEngine: policy,
log: log,
runtime: runtime,
ctx: ctx,
config: config,
ldap: ldap,
queries: queries,
oauthBroker: oauthBroker,
tailscale: tailscale,
policyEngine: policy,
}
wg.Go(service.CleanupOAuthSessionsRoutine)
// caches setup
oauthCache := NewCacheStore[OAuthPendingSession](256)
loginCache := NewCacheStore[LoginAttempt](1024)
ldapCache := NewCacheStore[[]string](1024)
service.caches.oauth = oauthCache
service.caches.login = loginCache
service.caches.ldap = ldapCache
dg.Go(func(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
service.caches.oauth.Sweep()
service.caches.login.Sweep()
service.caches.ldap.Sweep()
case <-ctx.Done():
return
}
}
}, ding.RingMinor)
return service
}
@@ -194,14 +207,12 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
return nil, errors.New("ldap service not configured")
}
auth.ldapGroupsMutex.RLock()
entry, exists := auth.ldapGroupsCache[userDN]
auth.ldapGroupsMutex.RUnlock()
entry, exists := auth.caches.ldap.Get(userDN)
if exists && time.Now().Before(entry.Expires) {
if exists {
return &model.LDAPUser{
DN: userDN,
Groups: entry.Groups,
Groups: entry,
}, nil
}
@@ -211,12 +222,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
return nil, fmt.Errorf("failed to get ldap groups: %w", err)
}
auth.ldapGroupsMutex.Lock()
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
Groups: groups,
Expires: time.Now().Add(time.Duration(auth.config.LDAP.GroupCacheTTL) * time.Second),
}
auth.ldapGroupsMutex.Unlock()
auth.caches.ldap.Set(userDN, groups, time.Duration(auth.config.LDAP.GroupCacheTTL)*time.Second)
return &model.LDAPUser{
DN: userDN,
@@ -225,11 +231,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
}
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
auth.loginMutex.RLock()
defer auth.loginMutex.RUnlock()
if auth.lockdown != nil && auth.lockdown.Active {
remaining := int(time.Until(auth.lockdown.ActiveUntil).Seconds())
if locked, remaining := auth.IsInLockdown(); locked {
return true, remaining
}
@@ -237,7 +239,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
return false, 0
}
attempt, exists := auth.loginAttempts[identifier]
attempt, exists := auth.caches.login.Get(identifier)
if !exists {
return false, 0
}
@@ -255,37 +257,49 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
return
}
auth.loginMutex.Lock()
defer auth.loginMutex.Unlock()
if len(auth.loginAttempts) >= MaxLoginAttemptRecords {
if auth.lockdown != nil && auth.lockdown.Active {
if auth.caches.login.Size() >= MaxLoginAttemptRecords {
if locked, _ := auth.IsInLockdown(); locked {
return
}
go auth.lockdownMode()
return
}
attempt, exists := auth.loginAttempts[identifier]
if !exists {
attempt = &LoginAttempt{}
auth.loginAttempts[identifier] = attempt
}
auth.caches.login.WithLock(func(actions CacheStoreActions[LoginAttempt]) {
entry, ok := actions.Get(identifier)
attempt.LastAttempt = time.Now()
if !ok {
attempt := LoginAttempt{
LastAttempt: time.Now(),
}
if !success {
attempt.FailedAttempts = 1
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
}
}
// match current tinyauth behavior which doesn't expire rate limits
actions.Set(identifier, attempt, 0)
return
}
if success {
attempt.FailedAttempts = 0
attempt.LockedUntil = time.Time{} // Reset lock time
return
}
entry.LastAttempt = time.Now()
attempt.FailedAttempts++
if success {
entry.FailedAttempts = 0
entry.LockedUntil = time.Time{}
} else {
entry.FailedAttempts++
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
}
if entry.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
entry.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", entry.FailedAttempts).Msg("Account locked due to too many failed login attempts")
}
}
actions.Set(identifier, entry, 0)
})
}
// We could also directly access the policyEngine.effectToAccess but
@@ -503,8 +517,6 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
}
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
auth.ensureOAuthSessionLimit()
service, ok := auth.oauthBroker.GetService(serviceName)
if !ok {
@@ -528,9 +540,7 @@ func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLPara
CallbackParams: params,
}
auth.oauthMutex.Lock()
auth.oauthPendingSessions[sessionId.String()] = &session
auth.oauthMutex.Unlock()
auth.caches.oauth.Set(sessionId.String(), session, time.Minute*10)
return sessionId.String(), session, nil
}
@@ -546,10 +556,10 @@ func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
}
func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) {
session, err := auth.GetOAuthPendingSession(sessionId)
session, ok := auth.caches.oauth.Get(sessionId)
if err != nil {
return nil, err
if !ok {
return nil, fmt.Errorf("oauth session not found: %s", sessionId)
}
token, err := (*session.Service).GetToken(code, session.Verifier)
@@ -558,9 +568,14 @@ func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.T
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
}
auth.oauthMutex.Lock()
session.Token = token
auth.oauthMutex.Unlock()
// ttl 0 means keep current expiration
ok = auth.caches.oauth.Update(sessionId, session, 0)
if !ok {
return nil, fmt.Errorf("failed to update oauth session with token: %s", sessionId)
}
return token, nil
}
@@ -596,123 +611,39 @@ func (auth *AuthService) GetOAuthService(sessionId string) (OAuthServiceImpl, er
}
func (auth *AuthService) EndOAuthSession(sessionId string) {
auth.oauthMutex.Lock()
delete(auth.oauthPendingSessions, sessionId)
auth.oauthMutex.Unlock()
}
func (auth *AuthService) CleanupOAuthSessionsRoutine() {
auth.log.App.Debug().Msg("Starting OAuth session cleanup routine")
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
auth.log.App.Debug().Msg("Running OAuth session cleanup")
auth.oauthMutex.Lock()
now := time.Now()
for sessionId, session := range auth.oauthPendingSessions {
if now.After(session.ExpiresAt) {
delete(auth.oauthPendingSessions, sessionId)
}
}
auth.oauthMutex.Unlock()
auth.log.App.Debug().Msg("OAuth session cleanup completed")
case <-auth.context.Done():
auth.log.App.Debug().Msg("Stopping OAuth session cleanup routine")
return
}
}
auth.caches.oauth.Delete(sessionId)
}
func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPendingSession, error) {
auth.ensureOAuthSessionLimit()
auth.oauthMutex.RLock()
session, exists := auth.oauthPendingSessions[sessionId]
auth.oauthMutex.RUnlock()
session, exists := auth.caches.oauth.Get(sessionId)
if !exists {
return &OAuthPendingSession{}, fmt.Errorf("oauth session not found: %s", sessionId)
}
if time.Now().After(session.ExpiresAt) {
auth.oauthMutex.Lock()
delete(auth.oauthPendingSessions, sessionId)
auth.oauthMutex.Unlock()
return &OAuthPendingSession{}, fmt.Errorf("oauth session expired: %s", sessionId)
}
return session, nil
}
func (auth *AuthService) ensureOAuthSessionLimit() {
auth.oauthMutex.Lock()
defer auth.oauthMutex.Unlock()
if len(auth.oauthPendingSessions) <= MaxOAuthPendingSessions {
return
}
type entry struct {
id string
expiresAt int64
}
entries := make([]entry, 0, len(auth.oauthPendingSessions))
for id, session := range auth.oauthPendingSessions {
entries = append(entries, entry{id, session.ExpiresAt.Unix()})
}
slices.SortFunc(entries, func(a, b entry) int {
if a.expiresAt < b.expiresAt {
return -1
}
if a.expiresAt > b.expiresAt {
return 1
}
return 0
})
for _, e := range entries[:OAuthCleanupCount] {
delete(auth.oauthPendingSessions, e.id)
}
return &session, nil
}
func (auth *AuthService) lockdownMode() {
ctx, cancel := context.WithCancel(context.Background())
auth.lockdown.mu.Lock()
auth.loginMutex.Lock()
if auth.lockdown != nil && auth.lockdown.Active {
auth.loginMutex.Unlock()
cancel()
if auth.lockdown.active {
auth.lockdown.mu.Unlock()
return
}
auth.lockdownCtx = ctx
auth.lockdownCancelFunc = cancel
ctx, cancel := context.WithCancel(context.Background())
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
auth.lockdown = &Lockdown{
Active: true,
ActiveUntil: time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second),
}
auth.lockdown.active = true
auth.lockdown.ctx = ctx
auth.lockdown.cancelFunc = cancel
auth.lockdown.until = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
// At this point all login attemps will also expire so,
// we might as well clear them to free up memory
auth.loginAttempts = make(map[string]*LoginAttempt)
timer := time.NewTimer(time.Until(auth.lockdown.until))
timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil))
auth.loginMutex.Unlock()
auth.lockdown.mu.Unlock()
defer cancel()
defer timer.Stop()
@@ -722,24 +653,33 @@ func (auth *AuthService) lockdownMode() {
// Timer expired, end lockdown
case <-ctx.Done():
// Context cancelled, end lockdown
case <-auth.context.Done():
case <-auth.ctx.Done():
// Service is shutting down, end lockdown
}
auth.loginMutex.Lock()
auth.lockdown.mu.Lock()
auth.log.App.Info().Msg("Exiting lockdown mode")
auth.lockdown = nil
auth.loginMutex.Unlock()
auth.lockdown.active = false
auth.lockdown.until = time.Time{}
auth.lockdown.ctx = nil
auth.lockdown.cancelFunc = nil
auth.lockdown.mu.Unlock()
}
// Function only used for testing - do not use in prod!
func (auth *AuthService) ClearRateLimitsTestingOnly() {
auth.loginMutex.Lock()
auth.loginAttempts = make(map[string]*LoginAttempt)
if auth.lockdown != nil {
auth.lockdownCancelFunc()
func (auth *AuthService) IsInLockdown() (bool, int) {
auth.lockdown.mu.RLock()
defer auth.lockdown.mu.RUnlock()
if auth.lockdown.active {
remaining := int(time.Until(auth.lockdown.until).Seconds())
return true, remaining
}
auth.loginMutex.Unlock()
return false, 0
}
// mostly a testing function, not useful for anything else
func (auth *AuthService) ClearLoginAttempts() {
auth.caches.login.Clear()
}
+197
View File
@@ -0,0 +1,197 @@
package service
import (
"slices"
"sync"
"time"
)
type CacheStoreActions[T any] struct {
Set func(key string, value T, ttl time.Duration)
Get func(key string) (T, bool)
Delete func(key string)
Update func(key string, value T, ttl time.Duration) bool
}
type cacheEntry[T any] struct {
value T
expiresAt *time.Time
}
type CacheStore[T any] struct {
cache map[string]cacheEntry[T]
order []string
mu sync.RWMutex
maxSize int
}
func NewCacheStore[T any](maxSize int) *CacheStore[T] {
return &CacheStore[T]{
cache: make(map[string]cacheEntry[T]),
order: make([]string, 0),
maxSize: maxSize,
}
}
// With lock allows performing multiple operations on the cache store atomically.
// The provided mutate function receives a set of actions (Set, Get, Delete) that
// can be used to manipulate the cache store within the locked context.
func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) {
cs.mu.Lock()
defer cs.mu.Unlock()
actions := CacheStoreActions[T]{
Set: cs.setCallback,
Get: cs.getCallback,
Delete: cs.deleteCallback,
Update: cs.updateCallback,
}
mutate(actions)
}
func (cs *CacheStore[T]) updateCallback(key string, value T, ttl time.Duration) bool {
if currentEntry, exists := cs.cache[key]; exists {
if currentEntry.expiresAt != nil && time.Now().After(*currentEntry.expiresAt) {
return false
}
entry := cacheEntry[T]{
value: value,
expiresAt: currentEntry.expiresAt,
}
if ttl > 0 {
expiration := time.Now().Add(ttl)
entry.expiresAt = &expiration
}
cs.cache[key] = entry
return true
}
return false
}
func (cs *CacheStore[T]) Update(key string, value T, ttl time.Duration) bool {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.updateCallback(key, value, ttl)
}
func (cs *CacheStore[T]) setCallback(key string, value T, ttl time.Duration) {
if cs.maxSize > 0 {
if _, exists := cs.cache[key]; !exists && len(cs.cache) >= cs.maxSize {
cs.evictOne()
}
}
var expiresAt *time.Time
if ttl > 0 {
expiration := time.Now().Add(ttl)
expiresAt = &expiration
}
cs.cache[key] = cacheEntry[T]{
value: value,
expiresAt: expiresAt,
}
if !slices.Contains(cs.order, key) {
cs.order = append(cs.order, key)
}
}
func (cs *CacheStore[T]) Set(key string, value T, ttl time.Duration) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.setCallback(key, value, ttl)
}
func (cs *CacheStore[T]) getCallback(key string) (T, bool) {
entry, exists := cs.cache[key]
if !exists {
var zero T
return zero, false
}
if entry.expiresAt != nil && time.Now().After(*entry.expiresAt) {
var zero T
return zero, false
}
return entry.value, true
}
func (cs *CacheStore[T]) Get(key string) (T, bool) {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.getCallback(key)
}
func (cs *CacheStore[T]) deleteCallback(key string) {
delete(cs.cache, key)
keyIdx := slices.Index(cs.order, key)
if keyIdx != -1 {
cs.order = append(cs.order[:keyIdx], cs.order[keyIdx+1:]...)
}
}
func (cs *CacheStore[T]) Delete(key string) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.deleteCallback(key)
}
func (cs *CacheStore[T]) Sweep() {
cs.mu.Lock()
for key, entry := range cs.cache {
if entry.expiresAt != nil && time.Now().After(*entry.expiresAt) {
cs.deleteCallback(key)
}
}
cs.mu.Unlock()
}
func (cs *CacheStore[T]) evictOne() bool {
now := time.Now()
var oldestKey string
var oldestExp *time.Time
for k, e := range cs.cache {
if e.expiresAt != nil && now.After(*e.expiresAt) {
cs.deleteCallback(k)
return true
}
if e.expiresAt != nil && (oldestExp == nil || e.expiresAt.Before(*oldestExp)) {
oldestKey, oldestExp = k, e.expiresAt
}
}
// If we found an oldest key, evict it else we delete the first key in the order list
if oldestKey != "" {
cs.deleteCallback(oldestKey)
return true
} else {
if len(cs.order) > 0 {
cs.deleteCallback(cs.order[0])
return true
}
}
return false
}
func (cs *CacheStore[T]) Size() int {
cs.mu.RLock()
defer cs.mu.RUnlock()
return len(cs.cache)
}
func (cs *CacheStore[T]) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.cache = make(map[string]cacheEntry[T])
cs.order = make([]string, 0)
}
+383
View File
@@ -0,0 +1,383 @@
package service
import (
"strconv"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestCacheStoreGet(t *testing.T) {
tests := []struct {
name string
setup func(cs *CacheStore[string])
wantValue string
wantOk bool
}{
{
name: "returns a stored value",
setup: func(cs *CacheStore[string]) { cs.Set("key", "value", 0) },
wantValue: "value",
wantOk: true,
},
{
name: "reports a missing key",
setup: func(cs *CacheStore[string]) {},
wantOk: false,
},
{
name: "returns the latest value after an overwrite",
setup: func(cs *CacheStore[string]) {
cs.Set("key", "first", 0)
cs.Set("key", "second", 0)
},
wantValue: "second",
wantOk: true,
},
{
name: "returns a non-expired entry",
setup: func(cs *CacheStore[string]) { cs.Set("key", "value", time.Minute) },
wantValue: "value",
wantOk: true,
},
{
name: "treats an expired entry as missing",
setup: func(cs *CacheStore[string]) {
cs.Set("key", "value", 10*time.Millisecond)
time.Sleep(20 * time.Millisecond)
},
wantOk: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := NewCacheStore[string](0)
tt.setup(cs)
value, ok := cs.Get("key")
assert.Equal(t, tt.wantOk, ok)
if tt.wantOk {
assert.Equal(t, tt.wantValue, value)
}
})
}
}
func TestCacheStoreUpdate(t *testing.T) {
tests := []struct {
name string
setup func(cs *CacheStore[string])
ttl time.Duration
wantOk bool
afterWait time.Duration
wantPresent bool
wantValue string
}{
{
name: "updates an existing entry",
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 0) },
ttl: 0,
wantOk: true,
wantPresent: true,
wantValue: "new",
},
{
name: "does not create a missing entry",
setup: func(cs *CacheStore[string]) {},
ttl: 0,
wantOk: false,
wantPresent: false,
},
{
name: "preserves the existing expiry when ttl is zero",
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 30*time.Millisecond) },
ttl: 0,
wantOk: true,
afterWait: 40 * time.Millisecond,
wantPresent: false,
},
{
name: "refreshes the expiry when ttl is provided",
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 10*time.Millisecond) },
ttl: time.Minute,
wantOk: true,
afterWait: 20 * time.Millisecond,
wantPresent: true,
wantValue: "new",
},
{
name: "does not update an expired entry",
setup: func(cs *CacheStore[string]) {
cs.Set("key", "old", 10*time.Millisecond)
time.Sleep(20 * time.Millisecond)
},
ttl: time.Minute,
wantOk: false,
wantPresent: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := NewCacheStore[string](0)
tt.setup(cs)
ok := cs.Update("key", "new", tt.ttl)
assert.Equal(t, tt.wantOk, ok)
time.Sleep(tt.afterWait)
value, present := cs.Get("key")
assert.Equal(t, tt.wantPresent, present)
if tt.wantPresent {
assert.Equal(t, tt.wantValue, value)
}
})
}
}
func TestCacheStoreDelete(t *testing.T) {
tests := []struct {
name string
setup func(cs *CacheStore[string])
key string
wantSize int
}{
{
name: "removes an existing key",
setup: func(cs *CacheStore[string]) {
cs.Set("a", "1", 0)
cs.Set("b", "2", 0)
},
key: "a",
wantSize: 1,
},
{
name: "is a no-op for a missing key",
setup: func(cs *CacheStore[string]) { cs.Set("a", "1", 0) },
key: "missing",
wantSize: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := NewCacheStore[string](0)
tt.setup(cs)
cs.Delete(tt.key)
_, ok := cs.Get(tt.key)
assert.False(t, ok)
assert.Equal(t, tt.wantSize, cs.Size())
})
}
}
func TestCacheStoreSweep(t *testing.T) {
tests := []struct {
name string
setup func(cs *CacheStore[string])
present []string
absent []string
wantSize int
}{
{
name: "removes expired entries and keeps the rest",
setup: func(cs *CacheStore[string]) {
cs.Set("permanent", "value", 0)
cs.Set("expired", "value", 10*time.Millisecond)
time.Sleep(20 * time.Millisecond)
},
present: []string{"permanent"},
absent: []string{"expired"},
wantSize: 1,
},
{
name: "keeps all live entries",
setup: func(cs *CacheStore[string]) {
cs.Set("a", "value", 0)
cs.Set("b", "value", time.Minute)
},
present: []string{"a", "b"},
wantSize: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := NewCacheStore[string](0)
tt.setup(cs)
cs.Sweep()
for _, key := range tt.present {
_, ok := cs.Get(key)
assert.True(t, ok)
}
for _, key := range tt.absent {
_, ok := cs.Get(key)
assert.False(t, ok)
}
assert.Equal(t, tt.wantSize, cs.Size())
})
}
}
func TestCacheStoreEviction(t *testing.T) {
// Every case uses a cache with maxSize 2; the final Set in setup is the
// insertion that overflows the cache and triggers an eviction.
tests := []struct {
name string
setup func(cs *CacheStore[string])
present []string
absent []string
wantSize int
}{
{
name: "evicts an already expired entry first",
setup: func(cs *CacheStore[string]) {
cs.Set("expired", "value", 10*time.Millisecond)
cs.Set("fresh", "value", time.Minute)
time.Sleep(20 * time.Millisecond)
cs.Set("new", "value", time.Minute)
},
present: []string{"fresh", "new"},
absent: []string{"expired"},
wantSize: 2,
},
{
name: "evicts the entry expiring soonest",
setup: func(cs *CacheStore[string]) {
cs.Set("soon", "value", 50*time.Millisecond)
cs.Set("later", "value", time.Hour)
cs.Set("new", "value", time.Hour)
},
present: []string{"later", "new"},
absent: []string{"soon"},
wantSize: 2,
},
{
name: "evicts the oldest inserted entry when none have a ttl",
setup: func(cs *CacheStore[string]) {
cs.Set("first", "value", 0)
cs.Set("second", "value", 0)
cs.Set("third", "value", 0)
},
present: []string{"second", "third"},
absent: []string{"first"},
wantSize: 2,
},
{
name: "overwriting an existing key does not trigger eviction",
setup: func(cs *CacheStore[string]) {
cs.Set("a", "1", 0)
cs.Set("b", "2", 0)
cs.Set("a", "updated", 0)
},
present: []string{"a", "b"},
wantSize: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := NewCacheStore[string](2)
tt.setup(cs)
for _, key := range tt.present {
_, ok := cs.Get(key)
assert.True(t, ok)
}
for _, key := range tt.absent {
_, ok := cs.Get(key)
assert.False(t, ok)
}
assert.Equal(t, tt.wantSize, cs.Size())
})
}
}
func TestCacheStoreSizeAndClear(t *testing.T) {
cs := NewCacheStore[string](0)
assert.Equal(t, 0, cs.Size())
cs.Set("a", "1", 0)
cs.Set("b", "2", 0)
assert.Equal(t, 2, cs.Size())
cs.Clear()
assert.Equal(t, 0, cs.Size())
_, ok := cs.Get("a")
assert.False(t, ok)
}
func TestCacheStoreWithLock(t *testing.T) {
cs := NewCacheStore[int](0)
cs.Set("counter", 1, 0)
// All four actions run atomically under a single lock.
cs.WithLock(func(actions CacheStoreActions[int]) {
current, ok := actions.Get("counter")
assert.True(t, ok)
actions.Set("counter", current+1, 0)
actions.Set("other", 100, 0)
actions.Delete("counter")
updated := actions.Update("other", 200, 0)
assert.True(t, updated)
})
_, ok := cs.Get("counter")
assert.False(t, ok)
value, ok := cs.Get("other")
assert.True(t, ok)
assert.Equal(t, 200, value)
}
// TestCacheStoreConcurrency exercises every locking path concurrently so the
// race detector (go test -race) can flag unsynchronised access.
func TestCacheStoreConcurrency(t *testing.T) {
cs := NewCacheStore[int](64)
const goroutines = 16
const iterations = 200
var wg sync.WaitGroup
wg.Add(goroutines)
for g := range goroutines {
go func(g int) {
defer wg.Done()
for i := range iterations {
key := strconv.Itoa((g*iterations + i) % 32)
switch i % 6 {
case 0:
cs.Set(key, i, time.Minute)
case 1:
cs.Get(key)
case 2:
cs.Update(key, i, time.Minute)
case 3:
cs.Delete(key)
case 4:
cs.Size()
case 5:
cs.WithLock(func(actions CacheStoreActions[int]) {
if v, ok := actions.Get(key); ok {
actions.Set(key, v+1, time.Minute)
}
})
}
}
}(g)
}
wg.Wait()
}
+5 -5
View File
@@ -3,8 +3,8 @@ package service
import (
"context"
"strings"
"sync"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
@@ -24,7 +24,7 @@ type DockerService struct {
func NewDockerService(
log *logger.Logger,
ctx context.Context,
wg *sync.WaitGroup,
dg *ding.Ding,
) (*DockerService, error) {
client, err := client.NewClientWithOpts(client.FromEnv)
@@ -50,7 +50,7 @@ func NewDockerService(
service.isConnected = true
service.log.App.Debug().Msg("Docker connected successfully")
wg.Go(service.watchAndClose)
dg.Go(service.watchAndClose, ding.RingMajor)
return service, nil
}
@@ -108,8 +108,8 @@ func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
return nil, nil
}
func (docker *DockerService) watchAndClose() {
<-docker.context.Done()
func (docker *DockerService) watchAndClose(ctx context.Context) {
<-ctx.Done()
docker.log.App.Debug().Msg("Closing Docker client")
if docker.client != nil {
err := docker.client.Close()
+16 -17
View File
@@ -8,6 +8,7 @@ import (
"sync"
"time"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
@@ -38,7 +39,6 @@ type ingressApp struct {
type KubernetesService struct {
log *logger.Logger
ctx context.Context
client dynamic.Interface
started bool
@@ -51,7 +51,7 @@ type KubernetesService struct {
func NewKubernetesService(
log *logger.Logger,
ctx context.Context,
wg *sync.WaitGroup,
dg *ding.Ding,
) (*KubernetesService, error) {
cfg, err := rest.InClusterConfig()
if err != nil {
@@ -82,16 +82,15 @@ func NewKubernetesService(
service := &KubernetesService{
log: log,
ctx: ctx,
client: client,
ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
}
wg.Go(func() {
service.watchGVR(gvr)
})
dg.Go(func(ctx context.Context) {
service.watchGVR(gvr, ctx)
}, ding.RingMajor)
service.started = true
log.App.Debug().Msg("Kubernetes label provider started successfully")
@@ -271,8 +270,8 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
}
}
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
ctx, cancel := context.WithTimeout(k.ctx, 30*time.Second)
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
@@ -289,10 +288,10 @@ func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
// runWatcher drains events from an active watcher until it closes or the context is done.
// Returns true if the caller should restart the watcher, false if it should exit.
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker) bool {
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool {
for {
select {
case <-k.ctx.Done():
case <-ctx.Done():
w.Stop()
return false
case event, ok := <-w.ResultChan():
@@ -314,33 +313,33 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
k.removeIngress(item.GetNamespace(), item.GetName())
}
case <-resyncTicker.C:
if err := k.resyncGVR(gvr); err != nil {
if err := k.resyncGVR(gvr, ctx); err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run")
}
}
}
}
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx context.Context) {
resyncTicker := time.NewTicker(5 * time.Minute)
defer resyncTicker.Stop()
if err := k.resyncGVR(gvr); err != nil {
if err := k.resyncGVR(gvr, ctx); err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry")
time.Sleep(30 * time.Second)
}
for {
select {
case <-k.ctx.Done():
case <-ctx.Done():
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher")
return
case <-resyncTicker.C:
if err := k.resyncGVR(gvr); err != nil {
if err := k.resyncGVR(gvr, ctx); err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry")
}
default:
ctx, cancel := context.WithCancel(k.ctx)
ctx, cancel := context.WithCancel(ctx)
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
if err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry")
@@ -349,7 +348,7 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
continue
}
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully")
if !k.runWatcher(gvr, watcher, resyncTicker) {
if !k.runWatcher(gvr, watcher, resyncTicker, ctx) {
cancel()
return
}
+9 -11
View File
@@ -9,14 +9,14 @@ import (
"github.com/cenkalti/backoff/v5"
ldapgo "github.com/go-ldap/ldap/v3"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
type LdapService struct {
log *logger.Logger
config model.Config
context context.Context
log *logger.Logger
config model.Config
conn *ldapgo.Conn
mutex sync.RWMutex
@@ -26,17 +26,15 @@ type LdapService struct {
func NewLdapService(
log *logger.Logger,
config model.Config,
ctx context.Context,
wg *sync.WaitGroup,
dg *ding.Ding,
) (*LdapService, error) {
if config.LDAP.Address == "" {
return nil, nil
}
ldap := &LdapService{
log: log,
config: config,
context: ctx,
log: log,
config: config,
}
// Check whether authentication with client certificate is possible
@@ -69,7 +67,7 @@ func NewLdapService(
return nil, fmt.Errorf("failed to connect to ldap server: %w", err)
}
wg.Go(func() {
dg.Go(func(ctx context.Context) {
ldap.log.App.Debug().Msg("Starting LDAP connection heartbeat routine")
ticker := time.NewTicker(5 * time.Minute)
@@ -87,12 +85,12 @@ func NewLdapService(
}
ldap.log.App.Info().Msg("Successfully reconnected to LDAP server")
}
case <-ldap.context.Done():
case <-ctx.Done():
ldap.log.App.Debug().Msg("LDAP service context cancelled, stopping heartbeat")
return
}
}
})
}, ding.RingMajor)
return ldap, nil
}
+11 -14
View File
@@ -15,13 +15,13 @@ import (
"net/url"
"os"
"strings"
"sync"
"time"
"slices"
"github.com/gin-gonic/gin"
"github.com/go-jose/go-jose/v4"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils"
@@ -116,7 +116,6 @@ type OIDCService struct {
config model.Config
runtime model.RuntimeConfig
queries repository.Store
context context.Context
clients map[string]model.OIDCClientConfig
privateKey *rsa.PrivateKey
@@ -129,8 +128,7 @@ func NewOIDCService(
config model.Config,
runtime model.RuntimeConfig,
queries repository.Store,
ctx context.Context,
wg *sync.WaitGroup) (*OIDCService, error) {
dg *ding.Ding) (*OIDCService, error) {
// If not configured, skip init
if len(runtime.OIDCClients) == 0 {
return nil, nil
@@ -276,7 +274,6 @@ func NewOIDCService(
config: config,
runtime: runtime,
queries: queries,
context: ctx,
clients: clients,
privateKey: privateKey,
@@ -285,7 +282,7 @@ func NewOIDCService(
}
// Start cleanup routine
wg.Go(service.cleanupRoutine)
dg.Go(service.cleanupRoutine, ding.RingMinor)
return service, nil
}
@@ -759,7 +756,7 @@ func (service *OIDCService) DeleteOldSession(ctx context.Context, sub string) er
}
// Cleanup routine - Resource heavy due to the linked tables
func (service *OIDCService) cleanupRoutine() {
func (service *OIDCService) cleanupRoutine(ctx context.Context) {
service.log.App.Debug().Msg("Starting OIDC cleanup routine")
ticker := time.NewTicker(time.Duration(30) * time.Minute)
defer ticker.Stop()
@@ -772,7 +769,7 @@ func (service *OIDCService) cleanupRoutine() {
currentTime := time.Now().Unix()
// For the OIDC tokens, if they are expired we delete the userinfo and codes
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(service.context, repository.DeleteExpiredOidcTokensParams{
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{
TokenExpiresAt: currentTime,
RefreshTokenExpiresAt: currentTime,
})
@@ -782,21 +779,21 @@ func (service *OIDCService) cleanupRoutine() {
}
for _, expiredToken := range expiredTokens {
err := service.DeleteOldSession(service.context, expiredToken.Sub)
err := service.DeleteOldSession(ctx, expiredToken.Sub)
if err != nil {
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired token")
}
}
// 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(service.context, currentTime)
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(ctx, currentTime)
if err != nil {
service.log.App.Warn().Err(err).Msg("Failed to delete expired codes")
service.log.App.Warn().Err(err).Msg("Failed to delete expired codes")
}
for _, expiredCode := range expiredCodes {
token, err := service.queries.GetOidcTokenBySub(service.context, expiredCode.Sub)
token, err := service.queries.GetOidcTokenBySub(ctx, expiredCode.Sub)
if err != nil {
if !errors.Is(err, repository.ErrNotFound) {
@@ -806,7 +803,7 @@ func (service *OIDCService) cleanupRoutine() {
}
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
err := service.DeleteOldSession(service.context, expiredCode.Sub)
err := service.DeleteOldSession(ctx, expiredCode.Sub)
if err != nil {
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired code")
}
@@ -814,7 +811,7 @@ func (service *OIDCService) cleanupRoutine() {
}
service.log.App.Debug().Msg("Finished OIDC cleanup routine")
case <-service.context.Done():
case <-ctx.Done():
service.log.App.Debug().Msg("Stopping OIDC cleanup routine")
return
}
+3 -3
View File
@@ -3,9 +3,9 @@ package service_test
import (
"context"
"encoding/json"
"sync"
"testing"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -70,9 +70,9 @@ func TestCompileUserinfo(t *testing.T) {
log.Init()
ctx := context.TODO()
wg := &sync.WaitGroup{}
dg := ding.New(ctx)
svc, err := service.NewOIDCService(log, cfg, runtime, nil, ctx, wg)
svc, err := service.NewOIDCService(log, cfg, runtime, nil, dg)
require.NoError(t, err)
type testCase struct {
+15 -9
View File
@@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"tailscale.com/client/local"
@@ -20,12 +21,10 @@ type TailscaleWhoisResponse struct {
LoginName string
DisplayName string
NodeName string
Tags []string
}
type TailscaleService struct {
log *logger.Logger
wg *sync.WaitGroup
config model.Config
ctx context.Context
@@ -35,7 +34,7 @@ type TailscaleService struct {
mu sync.Mutex
}
func NewTailscaleService(log *logger.Logger, config model.Config, ctx context.Context, wg *sync.WaitGroup) (*TailscaleService, error) {
func NewTailscaleService(log *logger.Logger, config model.Config, ctx context.Context, dg *ding.Ding) (*TailscaleService, error) {
if !config.Tailscale.Enabled {
return nil, nil
}
@@ -67,7 +66,6 @@ func NewTailscaleService(log *logger.Logger, config model.Config, ctx context.Co
service := &TailscaleService{
log: log,
wg: wg,
config: config,
ctx: ctx,
srv: srv,
@@ -84,13 +82,13 @@ func NewTailscaleService(log *logger.Logger, config model.Config, ctx context.Co
return nil, fmt.Errorf("failed to connect to tailscale network: %w", err)
}
wg.Go(service.watchAndClose)
dg.Go(service.watchAndClose, ding.RingMajor)
return service, nil
}
func (ts *TailscaleService) watchAndClose() {
<-ts.ctx.Done()
func (ts *TailscaleService) watchAndClose(ctx context.Context) {
<-ctx.Done()
ts.log.App.Debug().Msg("Shutting down Tailscale service")
ts.mu.Lock()
srv := ts.srv
@@ -116,14 +114,22 @@ func (ts *TailscaleService) Whois(ctx context.Context, addr string) (*TailscaleW
return nil, fmt.Errorf("failed to get client whois: %w", err)
}
if who.Node.IsTagged() {
ts.log.App.Debug().Msgf("Skipping whois for tagged node %s", who.Node.Name)
return nil, nil
}
uid := strings.TrimPrefix(who.UserProfile.ID.String(), "userid:")
res := TailscaleWhoisResponse{
UserID: who.UserProfile.ID.String(),
UserID: uid,
LoginName: who.UserProfile.LoginName,
DisplayName: who.UserProfile.DisplayName,
NodeName: strings.TrimSuffix(who.Node.Name, "."),
Tags: who.Node.Tags,
}
ts.log.App.Debug().Interface("res", res).Msg("tailscale")
return &res, nil
}
+2 -5
View File
@@ -3,7 +3,6 @@ package loaders
import (
"os"
"github.com/rs/zerolog/log"
"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/paerser/file"
"github.com/tinyauthapp/paerser/flag"
@@ -19,8 +18,8 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
}
// I guess we are using traefik as the root name (we can't change it)
configFileFlag := "traefik.experimental.configfile"
envVar := "TINYAUTH_EXPERIMENTAL_CONFIGFILE"
configFileFlag := "traefik.configfile"
envVar := "TINYAUTH_CONFIGFILE"
if _, ok := flags[configFileFlag]; !ok {
if value := os.Getenv(envVar); value != "" {
@@ -30,8 +29,6 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
}
}
log.Warn().Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases")
err = file.Decode(flags[configFileFlag], cmd.Configuration)
if err != nil {