feat: added oidc support

This commit is contained in:
d34dscene
2025-06-02 07:24:00 +02:00
parent 3cb8c8058a
commit 65ccaa6a88
29 changed files with 1498 additions and 1451 deletions

49
go.mod
View File

@@ -9,9 +9,10 @@ require (
github.com/aws/aws-sdk-go-v2 v1.36.3
github.com/aws/aws-sdk-go-v2/config v1.29.14
github.com/aws/aws-sdk-go-v2/credentials v1.17.67
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.0
github.com/caarlos0/env/v11 v11.3.1
github.com/cloudflare/cloudflare-go v0.115.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/docker/docker v27.4.1+incompatible
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/golang-jwt/jwt/v5 v5.2.2
@@ -19,12 +20,13 @@ require (
github.com/joeig/go-powerdns/v3 v3.16.0
github.com/pressly/goose/v3 v3.24.3
github.com/traefik/paerser v0.2.2
github.com/traefik/traefik/v3 v3.4.0
github.com/traefik/traefik/v3 v3.4.1
golang.org/x/crypto v0.38.0
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b
golang.org/x/net v0.40.0
golang.org/x/oauth2 v0.30.0
google.golang.org/protobuf v1.36.6
modernc.org/sqlite v1.37.0
modernc.org/sqlite v1.37.1
sigs.k8s.io/yaml v1.4.0
)
@@ -37,7 +39,7 @@ require (
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
@@ -45,6 +47,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
@@ -55,7 +58,7 @@ require (
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@@ -81,18 +84,18 @@ require (
github.com/unrolled/render v1.7.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.11.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
go.opentelemetry.io/otel/log v0.11.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.11.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
go.opentelemetry.io/otel/log v0.12.2 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.12.2 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.14.0 // indirect
@@ -100,11 +103,11 @@ require (
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect
google.golang.org/grpc v1.72.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/grpc v1.72.2 // indirect
gotest.tools/v3 v3.5.1 // indirect
modernc.org/libc v1.65.6 // indirect
modernc.org/libc v1.65.8 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/memory v1.11.0 // indirect
)

100
go.sum
View File

@@ -30,14 +30,14 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcu
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 h1:4nm2G6A4pV9rdlWzGMPv4BNtQp22v1hg3yrtkYpeLl8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 h1:BCG7DCXEXpNCcpwCxg1oi9pkJWH2+eZzTn9MY56MbVw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 h1:BRXS0U76Z8wfF+bnkilA2QwpIch6URlm++yPUt9QPmQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3/go.mod h1:bNXKFFyaiVvWuR6O16h/I1724+aXe/tAkA9/QS01t5k=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.0 h1:fV4XIU5sn/x8gjRouoJpDVHj+ExJaUk4prYF+eb6qTs=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.0/go.mod h1:qbn305Je/IofWBJ4bJz/Q7pDEtnnoInw/dGt71v6rHE=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
@@ -50,10 +50,14 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -82,8 +86,8 @@ github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBj
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -179,8 +183,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ=
github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24=
github.com/traefik/traefik/v3 v3.4.0 h1:DkHTzki3o1Fohgh0948JsFaeadZYOn8D8+9pZsQEOn0=
github.com/traefik/traefik/v3 v3.4.0/go.mod h1:qZMqfdT077jnnWD14jnD10imucqdPFmCTBlOHrjNCfk=
github.com/traefik/traefik/v3 v3.4.1 h1:QBO/C9ILViPVBhsmY8nEnoobTULxg6oW1jUTX8FFh8w=
github.com/traefik/traefik/v3 v3.4.1/go.mod h1:8FHoFbX5P+zMQ3UUjjfrDH87BDSbHllcUQyiI2wCP3o=
github.com/unrolled/render v1.7.0 h1:1yke01/tZiZpiXfUG+zqB+6fq3G4I+KDmnh0EhPq7So=
github.com/unrolled/render v1.7.0/go.mod h1:LwQSeDhjml8NLjIO9GJO1/1qpFJxtfVIpzxXKjfVkoI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -191,32 +195,34 @@ go.opentelemetry.io/collector/pdata v1.10.0 h1:oLyPLGvPTQrcRT64ZVruwvmH/u3SHTfNo
go.opentelemetry.io/collector/pdata v1.10.0/go.mod h1:IHxHsp+Jq/xfjORQMDJjSH6jvedOSTOyu3nbxqhWSYE=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0 h1:HMUytBT3uGhPKYY/u/G5MR9itrlSO2SMOsSD3Tk3k7A=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0/go.mod h1:hdDXsiNLmdW/9BF2jQpnHHlhFajpWCEYfM6e5m2OAZg=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.11.0 h1:C/Wi2F8wEmbxJ9Kuzw/nhP+Z9XaHYMkyDmXy6yR2cjw=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.11.0/go.mod h1:0Lr9vmGKzadCTgsiBydxr6GEZ8SsZ7Ks53LzjWG5Ar4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
go.opentelemetry.io/otel/log v0.11.0 h1:c24Hrlk5WJ8JWcwbQxdBqxZdOK7PcP/LFtOtwpDTe3Y=
go.opentelemetry.io/otel/log v0.11.0/go.mod h1:U/sxQ83FPmT29trrifhQg+Zj2lo1/IPN1PF6RTFqdwc=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/log v0.11.0 h1:7bAOpjpGglWhdEzP8z0VXc4jObOiDEwr3IYbhBnjk2c=
go.opentelemetry.io/otel/sdk/log v0.11.0/go.mod h1:dndLTxZbwBstZoqsJB3kGsRPkpAgaJrWfQg3lhlHFFY=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2/go.mod h1:DvPtKE63knkDVP88qpatBj81JxN+w1bqfVbsbCbj1WY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 h1:tPLwQlXbJ8NSOfZc4OkgU5h2A38M4c9kfHSVc4PFQGs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2/go.mod h1:QTnxBwT/1rBIgAG1goq6xMydfYOBKU6KTiYF4fp5zL8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 h1:JgtbA0xkWHnTmYk7YusopJFX6uleBmAuZ8n05NEh8nQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc=
go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/log v0.12.2 h1:yNoETvTByVKi7wHvYS6HMcZrN5hFLD7I++1xIZ/k6W0=
go.opentelemetry.io/otel/sdk/log v0.12.2/go.mod h1:DcpdmUXHJgSqN/dh+XMWa7Vf89u9ap0/AAk/XGLnEzY=
go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc h1:uqxdywfHqqCl6LmZzI3pUnXT1RGFYyUgxj0AkWPFxi0=
go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc/go.mod h1:TY/N/FT7dmFrP/r5ym3g0yysP1DefqGpAZr4f82P0dE=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -226,8 +232,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
@@ -240,6 +246,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -271,12 +279,12 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 h1:WvBuA5rjZx9SNIzgcU53OohgZy6lKSus++uY4xLaWKc=
google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:W3S/3np0/dPWsWLi1h/UymYctGXaGBM2StwzD0y140U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 h1:IkAfh6J/yllPtpYFU0zZN1hUPYdT0ogkBT/9hMxHjvg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -294,18 +302,18 @@ modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q=
modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
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/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -85,7 +85,7 @@ func CreateAgent(a *config.App) http.HandlerFunc {
claims := &agent.AgentClaims{
AgentID: uuid.New().String(),
ProfileID: profileID,
ServerURL: serverUrl.String("http://localhost:3000"),
ServerURL: serverUrl.String("http://127.0.0.1:3000"),
}
// Generate a JWT for the agent and let it expire based on the cleanup interval
@@ -184,7 +184,7 @@ func RotateAgentToken(a *config.App) http.HandlerFunc {
claims := &agent.AgentClaims{
AgentID: dbAgent.ID,
ProfileID: dbAgent.ProfileID,
ServerURL: serverUrl.String("http://localhost:3000"),
ServerURL: serverUrl.String("http://127.0.0.1:3000"),
}
agentInterval, err := a.SM.Get(r.Context(), settings.KeyAgentCleanupInterval)

View File

@@ -4,9 +4,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/MizuchiLabs/mantrae/internal/api/middlewares"
"github.com/MizuchiLabs/mantrae/internal/config"
"github.com/MizuchiLabs/mantrae/internal/db"
"github.com/MizuchiLabs/mantrae/internal/mail"
@@ -45,64 +45,51 @@ func Login(a *config.App) http.HandlerFunc {
return
}
remember := r.URL.Query().Get("remember") == "true"
expirationTime := time.Now().Add(24 * time.Hour)
if r.URL.Query().Get("remember") == "true" {
expirationTime = time.Now().Add(7 * 24 * time.Hour)
if remember {
expirationTime = time.Now().Add(30 * 24 * time.Hour)
}
token, err := util.EncodeUserJWT(request.Username, a.Config.Secret, expirationTime)
jwt, err := util.EncodeUserJWT(request.Username, a.Config.Secret, expirationTime)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := q.UpdateUserLastLogin(r.Context(), user.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
fmt.Printf("Failed to update last login for user %s: %v\n", user.Username, err)
}
response := map[string]any{
"token": token,
"user": user,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: util.CookieName,
Value: jwt,
Path: "/",
MaxAge: int(expirationTime.Unix() - time.Now().Unix()),
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
}
// VerifyJWT checks the validity of a JWT token provided in cookies or Authorization header.
func VerifyJWT(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := a.Conn.GetQuery()
var token string
func Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: util.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}
// Check for token in cookies and Authorization header
if cookie, err := r.Cookie("token"); err == nil {
token = cookie.Value
} else {
authHeader := r.Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
} else {
http.Error(w, "Token cannot be empty", http.StatusBadRequest)
return
}
}
data, err := util.DecodeUserJWT(token, a.Config.Secret)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid token: %s", err.Error()), http.StatusUnauthorized)
return
}
user, err := q.GetUserByUsername(r.Context(), data.Username)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Verify returns the currently logged in user
func Verify(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middlewares.AuthUserKey).(db.GetUserByUsernameRow)
response := map[string]any{"user": user}
w.Header().Set("Content-Type", "application/json")
@@ -110,7 +97,6 @@ func VerifyJWT(a *config.App) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
// VerifyOTP allows users to login using an OTP token, to reset their password
@@ -149,7 +135,7 @@ func VerifyOTP(a *config.App) http.HandlerFunc {
}
expirationTime := time.Now().Add(1 * time.Hour)
token, err := util.EncodeUserJWT(user.Username, a.Config.Secret, expirationTime)
jwt, err := util.EncodeUserJWT(user.Username, a.Config.Secret, expirationTime)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -160,10 +146,17 @@ func VerifyOTP(a *config.App) http.HandlerFunc {
return
}
response := map[string]any{
"token": token,
"user": user,
}
http.SetCookie(w, &http.Cookie{
Name: util.CookieName,
Value: jwt,
Path: "/",
MaxAge: int(expirationTime.Unix() - time.Now().Unix()),
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
response := map[string]any{"user": user}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@@ -0,0 +1,429 @@
package handler
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/MizuchiLabs/mantrae/internal/config"
"github.com/MizuchiLabs/mantrae/internal/db"
"github.com/MizuchiLabs/mantrae/internal/settings"
"github.com/MizuchiLabs/mantrae/internal/util"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type OIDCConfig struct {
Enabled bool `json:"enabled"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
IssuerURL string `json:"issuer_url"`
Scopes []string `json:"scopes"`
Provider string `json:"provider"`
UsePKCE bool `json:"use_pkce"`
}
type OIDCUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
func OIDCLogin(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
oidcConfig, oauth2Config, _, err := setupOIDCConfig(r.Context(), a)
if err != nil {
http.Error(w, "OIDC not configured: "+err.Error(), http.StatusServiceUnavailable)
return
}
if !oidcConfig.Enabled {
http.Error(w, "OIDC disabled", http.StatusServiceUnavailable)
return
}
// Generate state for CSRF protection
state, err := generateRandomState()
if err != nil {
http.Error(w, "Failed to generate state", http.StatusInternalServerError)
return
}
// Store state in cookie
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/",
MaxAge: 600,
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
// Add PKCE if enabled
if oidcConfig.UsePKCE {
verifier := oauth2.GenerateVerifier()
http.SetCookie(w, &http.Cookie{
Name: "pkce_verifier",
Value: verifier,
Path: "/",
MaxAge: 600,
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
opts = append(opts, oauth2.S256ChallengeOption(verifier))
}
authURL := oauth2Config.AuthCodeURL(state, opts...)
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}
}
func OIDCCallback(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
oidcConfig, oauth2Config, verifier, err := setupOIDCConfig(r.Context(), a)
if err != nil {
http.Error(w, "OIDC not configured: "+err.Error(), http.StatusServiceUnavailable)
return
}
// Verify state
stateCookie, err := r.Cookie("oauth_state")
if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
return
}
// Clear state cookie
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "No authorization code received", http.StatusBadRequest)
return
}
opts := []oauth2.AuthCodeOption{}
// Handle PKCE
if oidcConfig.UsePKCE {
verifierCookie, err := r.Cookie("pkce_verifier")
if err != nil {
http.Error(w, "PKCE verifier not found", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: "pkce_verifier",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
opts = append(opts, oauth2.VerifierOption(verifierCookie.Value))
}
// Exchange code for token
token, err := oauth2Config.Exchange(r.Context(), code, opts...)
if err != nil {
http.Error(
w,
fmt.Sprintf("Token exchange failed: %v", err),
http.StatusInternalServerError,
)
return
}
// Verify ID token
idToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "No id_token in response", http.StatusInternalServerError)
return
}
verifiedToken, err := verifier.Verify(r.Context(), idToken)
if err != nil {
http.Error(
w,
fmt.Sprintf("Token verification failed: %v", err),
http.StatusInternalServerError,
)
return
}
// Extract user info from verified token
var userInfo OIDCUserInfo
if err := verifiedToken.Claims(&userInfo); err != nil {
http.Error(
w,
fmt.Sprintf("Failed to parse claims: %v", err),
http.StatusInternalServerError,
)
return
}
// Find or create user
q := a.Conn.GetQuery()
user, err := findOrCreateOIDCUser(r.Context(), q, &userInfo)
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to process user: %v", err),
http.StatusInternalServerError,
)
return
}
// Generate JWT and set cookie
expirationTime := time.Now().Add(24 * time.Hour)
jwtToken, err := util.EncodeUserJWT(user.Username, a.Config.Secret, expirationTime)
if err != nil {
http.Error(w, "Failed to generate JWT", http.StatusInternalServerError)
return
}
if err := q.UpdateUserLastLogin(r.Context(), user.ID); err != nil {
fmt.Printf("Failed to update last login for user %s: %v\n", user.Username, err)
}
http.SetCookie(w, &http.Cookie{
Name: util.CookieName,
Value: jwtToken,
Path: "/",
MaxAge: int(expirationTime.Unix() - time.Now().Unix()),
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
}
func OIDCStatus(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
oidcConfig, _, _, err := setupOIDCConfig(r.Context(), a)
if err != nil {
slog.Error("Failed to get OIDC config", "error", err)
}
response := map[string]interface{}{
"enabled": false,
"provider": "",
}
if err == nil && oidcConfig != nil {
providerName, _ := a.SM.Get(r.Context(), settings.KeyOIDCProviderName)
response["enabled"] = oidcConfig.Enabled
response["provider"] = providerName.String("")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// Simplified helper function that handles both config and OIDC setup
func setupOIDCConfig(
ctx context.Context,
a *config.App,
) (*OIDCConfig, *oauth2.Config, *oidc.IDTokenVerifier, error) {
config, err := getOIDCConfig(ctx, a)
if err != nil {
return nil, nil, nil, err
}
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, strings.TrimSuffix(config.IssuerURL, "/"))
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to create OIDC provider: %w", err)
}
// Create OAuth2 config
oauth2Config := &oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
RedirectURL: config.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: config.Scopes,
}
// For PKCE, don't include client secret
if config.UsePKCE {
oauth2Config.ClientSecret = ""
}
// Create ID token verifier
verifier := provider.Verifier(&oidc.Config{
ClientID: config.ClientID,
})
return config, oauth2Config, verifier, nil
}
func getOIDCConfig(ctx context.Context, a *config.App) (*OIDCConfig, error) {
config := &OIDCConfig{Scopes: []string{"openid", "email", "profile"}}
sets, err := a.SM.GetAll(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get settings: %w", err)
}
// Parse settings (same as before but simplified validation)
if enabled, exists := sets[settings.KeyOIDCEnabled]; exists {
config.Enabled = enabled.Bool(false)
}
if !config.Enabled {
return config, nil // Return early if disabled
}
if pkce, exists := sets[settings.KeyOIDCPKCE]; exists {
config.UsePKCE = pkce.Bool(false)
}
if clientID, exists := sets[settings.KeyOIDCClientID]; exists {
config.ClientID = clientID.String("")
}
if !config.UsePKCE {
if clientSecret, exists := sets[settings.KeyOIDCClientSecret]; exists {
config.ClientSecret = clientSecret.String("")
}
}
if serverURL, exists := sets[settings.KeyServerURL]; exists {
if parsed, err := url.Parse(serverURL.String("")); err == nil {
config.RedirectURL = strings.TrimSuffix(parsed.String(), "/") + "/api/oidc/callback"
}
}
if issuerURL, exists := sets[settings.KeyOIDCIssuerURL]; exists {
config.IssuerURL = issuerURL.String("")
}
if scopes, exists := sets["oauth_scopes"]; exists && scopes.String("") != "" {
config.Scopes = strings.Split(scopes.String(""), ",")
for i := range config.Scopes {
config.Scopes[i] = strings.TrimSpace(config.Scopes[i])
}
}
return config, nil
}
func findOrCreateOIDCUser(
ctx context.Context,
q *db.Queries,
userInfo *OIDCUserInfo,
) (*db.User, error) {
var user *db.User
// Try to find existing user by email or username
if userInfo.Email != "" {
// First try to find by email
if existingUser, emailErr := q.GetUserByEmail(ctx, &userInfo.Email); emailErr == nil {
user = &db.User{
ID: existingUser.ID,
Username: existingUser.Username,
Email: existingUser.Email,
IsAdmin: existingUser.IsAdmin,
}
}
}
if user == nil && userInfo.PreferredUsername != "" {
// Try to find by username
if existingUser, usernameErr := q.GetUserByUsername(ctx, userInfo.PreferredUsername); usernameErr == nil {
user = &db.User{
ID: existingUser.ID,
Username: existingUser.Username,
Email: existingUser.Email,
IsAdmin: existingUser.IsAdmin,
}
}
}
if user == nil {
// Create new user
username := userInfo.PreferredUsername
if username == "" {
// Fallback to email prefix if no preferred username
if userInfo.Email != "" {
username = strings.Split(userInfo.Email, "@")[0]
} else {
username = fmt.Sprintf("oidc_user_%s", userInfo.Sub)
}
}
// Ensure username is unique
originalUsername := username
counter := 1
for {
if _, err := q.GetUserByUsername(ctx, username); err != nil {
break // Username is available
}
username = fmt.Sprintf("%s_%d", originalUsername, counter)
counter++
}
params := db.CreateUserParams{
Username: username,
Email: &userInfo.Email,
IsAdmin: false,
}
newUserID, err := q.CreateUser(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC user: %w", err)
}
user = &db.User{
ID: newUserID,
Username: username,
Email: &userInfo.Email,
IsAdmin: false,
}
} else {
// Update existing user's email if needed and not set
if user.Email == nil && userInfo.Email != "" {
if err := q.UpdateUser(ctx, db.UpdateUserParams{
Username: user.Username,
Email: &userInfo.Email,
IsAdmin: user.IsAdmin,
ID: user.ID,
}); err != nil {
return nil, fmt.Errorf("failed to update user email: %w", err)
}
user.Email = &userInfo.Email
}
}
return user, nil
}
func generateRandomState() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}

View File

@@ -61,7 +61,7 @@ func CreateUser(a *config.App) http.HandlerFunc {
return
}
user.Password = hash
if err := q.CreateUser(r.Context(), user); err != nil {
if _, err := q.CreateUser(r.Context(), user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -60,24 +60,29 @@ func (h *MiddlewareHandler) BasicAuth(next http.Handler) http.Handler {
// JWT authentication middleware
func (h *MiddlewareHandler) JWT(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
cookie, err := r.Cookie(util.CookieName)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
http.Error(w, "Invalid authorization format", http.StatusUnauthorized)
token := cookie.Value
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
claims, err := util.DecodeUserJWT(tokenString, h.app.Config.Secret)
claims, err := util.DecodeUserJWT(token, h.app.Config.Secret)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
if claims.Username == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify user exists in database
q := h.app.Conn.GetQuery()
user, err := q.GetUserByUsername(r.Context(), claims.Username)

View File

@@ -2,15 +2,40 @@ package middlewares
import (
"net/http"
"strings"
)
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
const (
defaultDevOrigin = "http://127.0.0.1:5173"
)
func CORS(allowedOrigins ...string) func(http.Handler) http.Handler {
// Default to dev origin if none provided
if len(allowedOrigins) == 0 {
allowedOrigins = []string{defaultDevOrigin}
}
// Create a map for faster origin lookup
originMap := make(map[string]bool)
for _, origin := range allowedOrigins {
originMap[strings.TrimSpace(origin)] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Set CORS headers
if origin != "" && originMap[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().
Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
@@ -18,4 +43,5 @@ func CORS(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
}

View File

@@ -39,9 +39,13 @@ func (s *Server) routes() {
// Auth
register("POST", "/login", logChain, handler.Login(s.app))
register("POST", "/verify", logChain, handler.VerifyJWT(s.app))
register("POST", "/logout", logChain, handler.Logout)
register("GET", "/verify", jwtChain, handler.Verify)
register("POST", "/verify/otp", logChain, handler.VerifyOTP(s.app))
register("POST", "/reset/{name}", logChain, handler.SendResetEmail(s.app))
register("GET", "/oidc/callback", logChain, handler.OIDCCallback(s.app))
register("GET", "/oidc/login", logChain, handler.OIDCLogin(s.app))
register("GET", "/oidc/status", logChain, handler.OIDCStatus(s.app))
// Events
register("GET", "/events", logChain, handler.GetEvents)

View File

@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"runtime/debug"
"strings"
"time"
"connectrpc.com/connect"
@@ -41,10 +42,11 @@ func (s *Server) Start(ctx context.Context) error {
defer s.app.Conn.Close()
host := s.app.Config.Server.Host
port := s.app.Config.Server.Port
allowedOrigins := s.getAllowedOrigins(ctx)
server := &http.Server{
Addr: host + ":" + port,
Handler: middlewares.CORS(s.mux),
Handler: middlewares.CORS(allowedOrigins...)(s.mux),
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 5 * time.Minute,
WriteTimeout: 5 * time.Minute,
@@ -79,6 +81,34 @@ func (s *Server) Start(ctx context.Context) error {
}
}
func (s *Server) getAllowedOrigins(ctx context.Context) []string {
var origins []string
// Always allow development frontend
devOrigin := "http://127.0.0.1:5173"
origins = append(origins, devOrigin)
// Get server URL from settings for production
if serverURL, err := s.app.SM.Get(ctx, "server_url"); err == nil {
if url := serverURL.String(""); url != "" && url != devOrigin {
origins = append(origins, strings.TrimSuffix(url, "/"))
}
}
// Remove duplicates
seen := make(map[string]bool)
var uniqueOrigins []string
for _, origin := range origins {
if !seen[origin] {
uniqueOrigins = append(uniqueOrigins, origin)
seen[origin] = true
}
}
slog.Debug("CORS allowed origins", "origins", uniqueOrigins)
return uniqueOrigins
}
func (s *Server) registerServices() {
// Common interceptors
opts := []connect.HandlerOption{

View File

@@ -96,7 +96,7 @@ func (a *App) setDefaultAdminUser(ctx context.Context) error {
// If admin doesn't exist, create new admin
if err != nil {
adminMail := "admin@mantrae"
if err = q.CreateUser(ctx, db.CreateUserParams{
if _, err = q.CreateUser(ctx, db.CreateUserParams{
Username: "admin",
Email: &adminMail,
Password: hash,

View File

@@ -11,15 +11,16 @@ import (
"github.com/MizuchiLabs/mantrae/internal/util"
)
// setupBackgroundJobs initiates essential background operations for the application.
func (a *App) setupBackgroundJobs(ctx context.Context) {
slog.Info("Starting background tasks...")
go a.traefikSync(ctx)
go a.syncTraefik(ctx)
go a.syncDNS(ctx)
go a.cleanupAgents(ctx)
}
// traefikSync periodically syncs the Traefik configuration
func (a *App) traefikSync(ctx context.Context) {
// syncTraefik periodically syncs the Traefik configuration
func (a *App) syncTraefik(ctx context.Context) {
ticker := time.NewTicker(time.Second * time.Duration(a.Config.Background.Traefik))
defer ticker.Stop()

View File

@@ -117,6 +117,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.getUserStmt, err = db.PrepareContext(ctx, getUser); err != nil {
return nil, fmt.Errorf("error preparing query GetUser: %w", err)
}
if q.getUserByEmailStmt, err = db.PrepareContext(ctx, getUserByEmail); err != nil {
return nil, fmt.Errorf("error preparing query GetUserByEmail: %w", err)
}
if q.getUserByUsernameStmt, err = db.PrepareContext(ctx, getUserByUsername); err != nil {
return nil, fmt.Errorf("error preparing query GetUserByUsername: %w", err)
}
@@ -349,6 +352,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing getUserStmt: %w", cerr)
}
}
if q.getUserByEmailStmt != nil {
if cerr := q.getUserByEmailStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing getUserByEmailStmt: %w", cerr)
}
}
if q.getUserByUsernameStmt != nil {
if cerr := q.getUserByUsernameStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing getUserByUsernameStmt: %w", cerr)
@@ -539,6 +547,7 @@ type Queries struct {
getTraefikConfigByIDStmt *sql.Stmt
getTraefikConfigBySourceStmt *sql.Stmt
getUserStmt *sql.Stmt
getUserByEmailStmt *sql.Stmt
getUserByUsernameStmt *sql.Stmt
getUserPasswordStmt *sql.Stmt
listAgentsStmt *sql.Stmt
@@ -600,6 +609,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
getTraefikConfigByIDStmt: q.getTraefikConfigByIDStmt,
getTraefikConfigBySourceStmt: q.getTraefikConfigBySourceStmt,
getUserStmt: q.getUserStmt,
getUserByEmailStmt: q.getUserByEmailStmt,
getUserByUsernameStmt: q.getUserByUsernameStmt,
getUserPasswordStmt: q.getUserPasswordStmt,
listAgentsStmt: q.listAgentsStmt,

View File

@@ -13,7 +13,7 @@ type Querier interface {
CreateAgent(ctx context.Context, arg CreateAgentParams) error
CreateDNSProvider(ctx context.Context, arg CreateDNSProviderParams) error
CreateProfile(ctx context.Context, arg CreateProfileParams) (int64, error)
CreateUser(ctx context.Context, arg CreateUserParams) error
CreateUser(ctx context.Context, arg CreateUserParams) (int64, error)
DeleteAgent(ctx context.Context, id string) error
DeleteDNSProvider(ctx context.Context, id int64) error
DeleteErrorById(ctx context.Context, id int64) error
@@ -40,6 +40,7 @@ type Querier interface {
GetTraefikConfigByID(ctx context.Context, id int64) (Traefik, error)
GetTraefikConfigBySource(ctx context.Context, arg GetTraefikConfigBySourceParams) ([]Traefik, error)
GetUser(ctx context.Context, id int64) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email *string) (GetUserByEmailRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
GetUserPassword(ctx context.Context, id int64) (string, error)
ListAgents(ctx context.Context) ([]Agent, error)

View File

@@ -1,8 +1,8 @@
-- name: CreateUser :exec
-- name: CreateUser :one
INSERT INTO
users (username, password, email, is_admin)
VALUES
(?, ?, ?, ?);
(?, ?, ?, ?) RETURNING id;
-- name: GetUser :one
SELECT
@@ -36,6 +36,22 @@ FROM
WHERE
username = ?;
-- name: GetUserByEmail :one
SELECT
id,
username,
email,
is_admin,
otp,
otp_expiry,
last_login,
created_at,
updated_at
FROM
users
WHERE
email = ?;
-- name: GetUserPassword :one
SELECT
password

View File

@@ -10,11 +10,11 @@ import (
"time"
)
const createUser = `-- name: CreateUser :exec
const createUser = `-- name: CreateUser :one
INSERT INTO
users (username, password, email, is_admin)
VALUES
(?, ?, ?, ?)
(?, ?, ?, ?) RETURNING id
`
type CreateUserParams struct {
@@ -24,14 +24,16 @@ type CreateUserParams struct {
IsAdmin bool `json:"isAdmin"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
_, err := q.exec(ctx, q.createUserStmt, createUser,
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
row := q.queryRow(ctx, q.createUserStmt, createUser,
arg.Username,
arg.Password,
arg.Email,
arg.IsAdmin,
)
return err
var id int64
err := row.Scan(&id)
return id, err
}
const deleteUser = `-- name: DeleteUser :exec
@@ -91,6 +93,52 @@ func (q *Queries) GetUser(ctx context.Context, id int64) (GetUserRow, error) {
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT
id,
username,
email,
is_admin,
otp,
otp_expiry,
last_login,
created_at,
updated_at
FROM
users
WHERE
email = ?
`
type GetUserByEmailRow struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
IsAdmin bool `json:"isAdmin"`
Otp *string `json:"otp"`
OtpExpiry *time.Time `json:"otpExpiry"`
LastLogin *time.Time `json:"lastLogin"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
}
func (q *Queries) GetUserByEmail(ctx context.Context, email *string) (GetUserByEmailRow, error) {
row := q.queryRow(ctx, q.getUserByEmailStmt, getUserByEmail, email)
var i GetUserByEmailRow
err := row.Scan(
&i.ID,
&i.Username,
&i.Email,
&i.IsAdmin,
&i.Otp,
&i.OtpExpiry,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT
id,

View File

@@ -27,6 +27,15 @@ const (
KeyEmailPassword = "email_password"
KeyEmailFrom = "email_from"
// OAuth settings
KeyOIDCEnabled = "oidc_enabled"
KeyOIDCClientID = "oidc_client_id"
KeyOIDCClientSecret = "oidc_client_secret"
KeyOIDCProviderName = "oidc_provider_name"
KeyOIDCIssuerURL = "oidc_issuer_url"
KeyOIDCScopes = "oidc_scopes"
KeyOIDCPKCE = "oidc_pkce"
// Agent settings
KeyAgentCleanupEnabled = "agent_cleanup_enabled"
KeyAgentCleanupInterval = "agent_cleanup_interval"

View File

@@ -18,7 +18,7 @@ type SettingWithDescription struct {
// Settings defines all application settings
type Settings struct {
ServerURL string `setting:"server_url" default:"http://localhost:3000" description:"Base URL for the server"`
ServerURL string `setting:"server_url" default:"http://127.0.0.1:3000" description:"Base URL for the server"`
BackupEnabled bool `setting:"backup_enabled" default:"true" description:"Enable scheduled backups"`
BackupInterval time.Duration `setting:"backup_interval" default:"24h" description:"Time between each backup"`
BackupKeep int `setting:"backup_keep" default:"3" description:"How many backups to keep"`
@@ -35,6 +35,13 @@ type Settings struct {
EmailUser string `setting:"email_user" default:"" description:"SMTP login username"`
EmailPassword string `setting:"email_password" default:"" description:"SMTP login password"`
EmailFrom string `setting:"email_from" default:"mantrae@localhost" description:"Default sender email address"`
OIDCEnabled bool `setting:"oidc_enabled" default:"false" description:"Enable OAuth authentication"`
OIDCClientID string `setting:"oidc_client_id" default:"" description:"OIDC client ID"`
OIDCClientSecret string `setting:"oidc_client_secret" default:"" description:"OIDC client secret"`
OIDCIssuerURL string `setting:"oidc_issuer_url" default:"" description:"OIDC issuer URL"`
OIDCProviderName string `setting:"oidc_provider_name" default:"" description:"Display name of the OIDC provider shown on the login button (e.g., 'Google', 'Keycloak')"`
OIDCScopes string `setting:"oidc_scopes" default:"" description:"OIDC scopes (comma-separated)"`
OIDCPKCE bool `setting:"oidc_pkce" default:"false" description:"Enable PKCE for OAuth"`
AgentCleanupEnabled bool `setting:"agent_cleanup_enabled" default:"true" description:"Enable automatic cleanup of agents"`
AgentCleanupInterval time.Duration `setting:"agent_cleanup_interval" default:"24h" description:"Maximum duration an agent can remain offline before being removed. Also used as the expiration duration for agent tokens."`
}

View File

@@ -30,7 +30,7 @@ func UpdateTraefikAPI(DB *sql.DB, profile db.Profile) error {
// Clear api data
if err = ClearTraefikAPI(DB, profile.ID); err != nil {
slog.Error("Failed to update api data", "error", err)
slog.Error("Failed to clear API data", "error", err)
}
return err
}
@@ -98,7 +98,7 @@ func ClearTraefikAPI(DB *sql.DB, profileID int64) error {
Version: nil,
Config: nil,
}); err != nil {
return fmt.Errorf("failed to update api data: %w", err)
return fmt.Errorf("Failed to clear API data: %w", err)
}
return nil
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/golang-jwt/jwt/v5"
)
const CookieName = "auth_token"
type UserClaims struct {
Username string `json:"username,omitempty"`
jwt.RegisteredClaims

View File

@@ -11,39 +11,39 @@
"format": "prettier --write ."
},
"devDependencies": {
"@eslint/js": "^9.26.0",
"@eslint/js": "^9.28.0",
"@lucide/svelte": "^0.488.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/postcss": "^4.1.6",
"@tailwindcss/postcss": "^4.1.8",
"@types/eslint": "^9.6.1",
"@types/node": "^22.15.17",
"@types/node": "^22.15.29",
"bits-ui": "1.0.0-next.98",
"clsx": "^2.1.1",
"eslint": "^9.26.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.6.0",
"eslint-plugin-svelte": "^3.9.0",
"formsnap": "^2.0.1",
"globals": "^16.1.0",
"globals": "^16.2.0",
"mode-watcher": "^0.5.1",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.28.6",
"svelte-check": "^4.1.7",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.12",
"svelte": "^5.33.11",
"svelte-check": "^4.2.1",
"svelte-highlight": "^7.8.3",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.25.0",
"tailwind-merge": "^3.3.0",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.1.6",
"tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"typescript-eslint": "^8.33.0",
"vite": "^6.3.5",
"yaml": "^2.7.1",
"zod": "^3.24.4"
"yaml": "^2.8.0",
"zod": "^3.25.46"
},
"type": "module",
"dependencies": {

1788
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@ import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { get, writable, type Writable } from 'svelte/store';
import YAML from 'yaml';
import { token } from './stores/common';
import { profile } from './stores/profile';
import { source } from './stores/source';
import { user } from './stores/user';
@@ -11,6 +10,7 @@ import {
type Agent,
type BackupFile,
type DNSProvider,
type OAuthStatus,
type Plugin,
type Profile,
type PublicIP,
@@ -87,9 +87,7 @@ async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof w
// Custom fetch function that adds the Authorization header
const customFetch: typeof window.fetch = async (url, options) => {
const headers = new Headers(options?.headers); // Get existing headers
if (token.value) {
headers.set('Authorization', 'Bearer ' + token.value); // Add the Authorization header
}
// Don't set Content-Type for FormData
const isFormData = options?.body instanceof FormData;
if (!isFormData) {
@@ -98,6 +96,7 @@ async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof w
const customOptions = {
...options,
headers,
credentials: 'include' as RequestCredentials, // Include cookies
body: isFormData ? options?.body : options?.body ? JSON.stringify(options.body) : undefined
};
return fetch ? fetch(url, customOptions) : window.fetch(url, customOptions); // Use custom fetch or default
@@ -108,7 +107,8 @@ async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof w
const response = await customFetch(`${BASE_URL}${endpoint}`, {
method: options.method || 'GET',
body: options.body,
headers: options.headers
headers: options.headers,
credentials: 'include'
});
if (!response.ok) {
@@ -135,32 +135,21 @@ export const api = {
method: 'POST',
body: { username, password }
});
if (data.token) {
token.value = data.token;
if (data.user) {
user.value = data.user;
await api.load();
goto('/');
}
goto('/');
},
async verify(fetch: typeof window.fetch = window.fetch) {
try {
const data = await send(
'/verify',
{
method: 'POST',
body: token.value
},
fetch
);
const data = await send('/verify', {}, fetch);
if (data.user) {
user.value = data.user;
}
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);
toast.error('Session expired', { description: error });
api.logout();
return;
return true;
} catch (_) {
return false;
}
},
@@ -174,12 +163,18 @@ export const api = {
body: { username, token: otp }
});
if (data.token) {
token.value = data.token;
if (data.user) {
user.value = data.user;
await api.load();
goto('/');
}
goto('/');
},
async oauthStatus() {
const data: OAuthStatus = await send('/oidc/status');
if (!data) {
throw new Error('Failed to fetch OAuth status');
}
return data;
},
async load() {
@@ -197,8 +192,8 @@ export const api = {
}
},
logout() {
token.value = null;
async logout() {
await send('/logout', { method: 'POST' });
user.clear();
goto('/login');
},

View File

@@ -1,6 +1,5 @@
import { createLocalStorage } from '$lib/storage.svelte';
export const token = createLocalStorage('auth_token', null);
export const limit = createLocalStorage('limit', '10');
export const routerColumns = createLocalStorage('router_columns', []);
export const middlewareColumns = createLocalStorage('middleware_columns', []);

View File

@@ -139,6 +139,11 @@ export interface Plugin {
createdAt: string;
}
export interface OAuthStatus {
enabled: boolean;
provider: string;
}
export interface BackupFile {
name: string;
size: number;

View File

@@ -1,48 +1,46 @@
import type { LayoutLoad } from './$types';
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { user } from '$lib/stores/user';
import { token } from '$lib/stores/common';
export const ssr = false;
export const prerender = true;
export const trailingSlash = 'always';
const isPublicRoute = (path: string) => path.startsWith('/login/');
const isPublicRoute = (path: string) => {
return path.startsWith('/login') || path === '/login';
};
export const load: LayoutLoad = async ({ url, fetch }) => {
// Case 1: No token and accessing protected route
if (!token.value && !isPublicRoute(url.pathname)) {
await goto('/login/');
user.clear();
return;
}
const currentPath = url.pathname;
const isPublic = isPublicRoute(currentPath);
// Case 2: Has token, verify it
if (token.value) {
// Try to verify authentication via cookie
try {
await api.verify(fetch);
const isVerified = await api.verify(fetch);
// Trying to access public route
if (isPublicRoute(url.pathname)) {
if (isVerified) {
// User is authenticated
if (isPublic) {
// Authenticated user trying to access login page - redirect to home
await goto('/');
}
return;
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
// Token verification failed
api.logout();
if (!isPublicRoute(url.pathname)) {
}
// Continue to protected route
return;
} else {
// Verification failed but no exception thrown
throw new Error('Authentication failed');
}
} catch (_) {
// Authentication failed
user.clear();
if (!isPublic) {
// User trying to access protected route without auth - redirect to login
await goto('/login');
}
user.clear();
toast.error('Session expired', { description: error.message });
// If already on public route, stay there
return;
}
}
// Case 3: No token and accessing public route
user.clear();
return;
};

View File

@@ -10,10 +10,13 @@
import Separator from '$lib/components/ui/separator/separator.svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores/user';
import type { OAuthStatus } from '$lib/types';
import { onMount } from 'svelte';
let username = $state('');
let password = $state('');
let remember = $state(false);
let oauthStatus: OAuthStatus = $state({ enabled: false, provider: '' });
const handleReset = async () => {
if (username.length > 0) {
const resetPromise = api
@@ -51,6 +54,12 @@
error: (error) => (error as Error).message
});
};
const handleOIDCLogin = () => {
window.location.href = '/api/oidc/login';
};
onMount(async () => {
oauthStatus = await api.oauthStatus();
});
</script>
{#if !user.isLoggedIn()}
@@ -85,6 +94,12 @@
<Separator />
<Button type="submit" class="w-full" disabled={$loading}>Login</Button>
{#if oauthStatus.enabled}
<Button variant="outline" class="w-full" onclick={handleOIDCLogin}>
Login with {oauthStatus.provider || 'OIDC'}
</Button>
{/if}
</form>
</Card.Content>
</Card.Root>

View File

@@ -34,40 +34,62 @@
general: {
title: 'General Settings',
description: 'Basic application configuration',
keys: ['server_url']
keys: [{ key: 'server_url', label: 'Server URL' }]
},
backup: {
title: 'Backup Settings',
description: 'Database backup configuration',
keys: [
'backup_enabled',
'backup_interval',
'backup_keep',
'backup_path',
'backup_storage_select'
{ key: 'backup_enabled', label: 'Enable Backups' },
{ key: 'backup_interval', label: 'Backup Interval' },
{ key: 'backup_keep', label: 'Number of Backups to Keep' },
{ key: 'backup_path', label: 'Backup Path' },
{ key: 'backup_storage_select', label: 'Backup Storage Type' }
]
},
s3: {
title: 'S3 Storage Settings',
description: 'Amazon S3 or compatible storage configuration',
keys: [
's3_endpoint',
's3_bucket',
's3_region',
's3_access_key',
's3_secret_key',
's3_use_path_style'
{ key: 's3_endpoint', label: 'Endpoint' },
{ key: 's3_bucket', label: 'Bucket Name' },
{ key: 's3_region', label: 'Region' },
{ key: 's3_access_key', label: 'Access Key' },
{ key: 's3_secret_key', label: 'Secret Key' },
{ key: 's3_use_path_style', label: 'Use Path Style' }
]
},
email: {
title: 'Email Settings',
description: 'SMTP server configuration for sending emails',
keys: ['email_host', 'email_port', 'email_user', 'email_password', 'email_from']
keys: [
{ key: 'email_host', label: 'Host' },
{ key: 'email_port', label: 'Port' },
{ key: 'email_user', label: 'Username' },
{ key: 'email_password', label: 'Password' },
{ key: 'email_from', label: 'From Email Address' }
]
},
oauth: {
title: 'OIDC Settings',
description: 'OIDC provider configuration',
keys: [
{ key: 'oidc_enabled', label: 'Enable OIDC' },
{ key: 'oidc_client_id', label: 'Client ID' },
{ key: 'oidc_client_secret', label: 'Client Secret' },
{ key: 'oidc_issuer_url', label: 'Issuer URL' },
{ key: 'oidc_provider_name', label: 'Provider Name' },
{ key: 'oidc_scopes', label: 'Scopes' },
{ key: 'oidc_pkce', label: 'Use PKCE' }
]
},
agents: {
title: 'Agent Settings',
description: 'Agent management configuration',
keys: ['agent_cleanup_enabled', 'agent_cleanup_interval']
keys: [
{ key: 'agent_cleanup_enabled', label: 'Enable Cleanup' },
{ key: 'agent_cleanup_interval', label: 'Cleanup Interval' }
]
}
};
@@ -96,14 +118,6 @@
}
}
// Helper to convert camelCase/snake_case to Title Case
const formatSettingName = (key: string) => {
return key
.split(/[_\s]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
// Determine the input type based on the setting value
const getInputType = (key: string, value: Setting['value']) => {
if (typeof value === 'boolean') return 'boolean';
@@ -311,12 +325,12 @@
<Separator class="mb-4" />
<!-- Loop through settings in this group -->
{#each group.keys as key (key)}
{#each group.keys as { key, label } (key)}
{#if $settings[key]}
<div class="mb-4 flex flex-col justify-start gap-4 sm:flex-row sm:justify-between">
<div class="border-muted-foreground border-l-2 pl-4">
<Label>
{formatSettingName(key)}
{label}
{#if $settings[key].description}
<p class="text-muted-foreground text-sm">{$settings[key].description}</p>
{/if}
@@ -337,12 +351,14 @@
onValueChange={(value) => handleChange(key, value)}
>
<Select.Trigger>
{changedValues[key] || $settings[key].value || 'Select storage type'}
{changedValues[key] || $settings[key].value || 'Select...'}
</Select.Trigger>
<Select.Content>
{#if key === 'backup_storage_select'}
{#each storageTypes as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{:else if getInputType(key, $settings[key].value) === 'password'}

View File

@@ -3,7 +3,8 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
define: {
'process.env': process.env
define: {},
server: {
host: '127.0.0.1'
}
});