internal: remove unused go code (#25244)

Co-authored-by: Jens L. <jens@goauthentik.io>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Marc 'risson' Schmitt
2026-08-21 16:28:32 +02:00
committed by GitHub
parent f123ab37f4
commit 98bfc45c9d
71 changed files with 13 additions and 10103 deletions

View File

@@ -79,7 +79,6 @@ jobs:
fail-fast: false
matrix:
type:
- proxy
- ldap
- radius
- rac
@@ -92,24 +91,6 @@ jobs:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Pin pnpm store directory
run: |
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
pnpm --dir web install --frozen-lockfile
- name: Build web
run: pnpm --dir web run build-proxy
- name: Build outpost
run: |
set -x

View File

@@ -7,8 +7,8 @@ It is a **polyglot monorepo**. Most work lands in one of the subtrees below; whe
| Language | Where | What it is | Deeper guide |
| -------------- | -------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- |
| **Python** | `authentik/`, `lifecycle/` | The core server — a Django + Django REST Framework app. The source of truth for the IdP. | — |
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, proxy, RAC, RADIUS) and the front reverse-proxy that fronts Django. | — |
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, RAC, RADIUS). | — |
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker/proxy outpost components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
| **TypeScript** | `web/` | The web UI — three Lit + PatternFly apps (Admin, User, Flow). | [`web/AGENTS.md`](web/AGENTS.md) |
| **Docs** | `website/` | The documentation, integrations, and API sites (Docusaurus). | [`website/AGENTS.md`](website/AGENTS.md) |
@@ -19,8 +19,8 @@ The Python core and the web UI talk through a **generated OpenAPI client** — n
```
authentik/ # Django core — the IdP itself (see "The authentik Django package" below)
lifecycle/ # Boot/runtime: migrations, gunicorn config, the `ak` CLI, container + AWS entrypoints
cmd/ # Go entrypoints: ldap/ proxy/ rac/ radius/ outposts + server/ (front reverse-proxy)
internal/ # Shared Go: outpost implementations, config, web proxy, gounicorn process manager
cmd/ # Go entrypoints: ldap/ rac/ radius/ outposts
internal/ # Shared Go: outpost implementations, config
src/ # Rust server/worker (ak-axum based; gated behind cargo features)
packages/ # Shared workspace packages, polyglot:
# client-go / client-rust / client-ts — GENERATED API clients (do not hand-edit)

View File

@@ -1,45 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"goauthentik.io/internal/common"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ak/entrypoint"
"goauthentik.io/internal/outpost/ak/healthcheck"
"goauthentik.io/internal/outpost/proxyv2"
)
const helpMessage = `authentik proxy
Required environment variables:
- AUTHENTIK_HOST: URL to connect to (format "http://authentik.company")
- AUTHENTIK_TOKEN: Token to authenticate with
- AUTHENTIK_INSECURE: Skip SSL Certificate verification
Optionally, you can set these:
- AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST`
var rootCmd = &cobra.Command{
Long: helpMessage,
Version: constants.FullVersion(),
PersistentPreRun: common.PreRun,
RunE: func(cmd *cobra.Command, args []string) error {
err := entrypoint.OutpostMain("authentik.outpost.proxy", proxyv2.NewProxyServer)
if err != nil {
fmt.Println(helpMessage)
}
return err
},
}
func main() {
rootCmd.AddCommand(healthcheck.Command)
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}

View File

@@ -1,10 +0,0 @@
package main
import "os"
func main() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}

View File

@@ -1,111 +0,0 @@
package main
import (
"fmt"
"net/http"
"net/url"
"os"
"time"
"github.com/getsentry/sentry-go"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"goauthentik.io/internal/common"
"goauthentik.io/internal/config"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2"
sentryutils "goauthentik.io/internal/utils/sentry"
webutils "goauthentik.io/internal/utils/web"
"goauthentik.io/internal/web"
)
var rootCmd = &cobra.Command{
Use: "authentik",
Short: "Start authentik instance",
Version: constants.FullVersion(),
PersistentPreRun: common.PreRun,
Run: func(cmd *cobra.Command, args []string) {
debug.EnableDebugServer("authentik.core")
l := log.WithField("logger", "authentik.root")
if config.Get().ErrorReporting.Enabled {
err := sentry.Init(sentry.ClientOptions{
Dsn: config.Get().ErrorReporting.SentryDSN,
AttachStacktrace: true,
EnableTracing: true,
TracesSampler: sentryutils.SamplerFunc(config.Get().ErrorReporting.SampleRate),
Release: fmt.Sprintf("authentik@%s", constants.VERSION()),
Environment: config.Get().ErrorReporting.Environment,
HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgent(), http.DefaultTransport),
IgnoreErrors: []string{
http.ErrAbortHandler.Error(),
},
})
if err != nil {
l.WithError(err).Warning("failed to init sentry")
}
}
ex := common.Init()
defer common.Defer()
u := url.URL{
Scheme: "unix",
Host: fmt.Sprintf("%s/%s", os.TempDir(), web.SocketName),
Path: config.Get().Web.Path,
}
ws := web.NewWebServer()
ws.Core().AddHealthyCallback(func() {
if config.Get().Outposts.DisableEmbeddedOutpost {
return
}
go attemptProxyStart(ws, u)
})
ws.Start()
<-ex
l.Info("shutting down webserver")
go ws.Shutdown()
},
}
func attemptProxyStart(ws *web.WebServer, u url.URL) {
maxTries := 100
attempt := 0
l := log.WithField("logger", "authentik.server")
for {
l.Debug("attempting to init outpost")
ac := ak.NewAPIController(u, config.Get().SecretKey)
if ac == nil {
attempt += 1
time.Sleep(1 * time.Second)
if attempt > maxTries {
break
}
continue
}
ac.AddRefreshHandler(func() {
ws.BrandTLS.Check()
})
srv := proxyv2.NewProxyServer(ac)
ws.ProxyServer = srv.(*proxyv2.ProxyServer)
ac.Server = srv
l.Debug("attempting to start outpost")
err := ac.StartBackgroundTasks()
if err != nil {
l.WithError(err).Warning("outpost failed to start")
attempt += 1
time.Sleep(15 * time.Second)
if attempt > maxTries {
break
}
continue
} else {
select {}
}
}
}

18
go.mod
View File

@@ -6,20 +6,15 @@ require (
beryju.io/ldap v0.2.2
beryju.io/radius-eap v0.1.1
github.com/avast/retry-go/v4 v4.7.0
github.com/coreos/go-oidc/v3 v3.20.0
github.com/getsentry/sentry-go v0.48.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-openapi/runtime v0.33.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.3
github.com/grafana/pyroscope-go v1.4.2
github.com/jackc/pgx/v5 v5.10.0
github.com/jellydator/ttlcache/v3 v3.4.1
github.com/mitchellh/mapstructure v1.5.0
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
@@ -30,12 +25,8 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.1
github.com/wwt/guac v1.3.2
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/postgres v1.6.2
gorm.io/gorm v1.31.2
layeh.com/radius v0.0.0-20231213012653-1006025d24f8
)
@@ -43,11 +34,7 @@ require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.25.5 // indirect
@@ -71,11 +58,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect

45
go.sum
View File

@@ -12,27 +12,14 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 h1:zga7zaRE8HCbWjcXMDlfvmQtH0/kMVLo7cQ48dy6kWg=
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1/go.mod h1:PumS+5d59wmAGsZo6IfRpVNaJUq+6xjC4Utt/k8GO6Q=
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 h1:O6yi4xa9b2DMosGsXzlMe2E9qXgXCVkRLCoRX+5amxI=
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27/go.mod h1:AYvN8omj7nKLmbcXS2dyABYU6JB1Lz1bHmkkq1kf4I4=
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9yue4+QkG/HQ/W67wvtQmWJ4SDo9aK/GIno=
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -95,14 +82,10 @@ github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -114,14 +97,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -136,10 +111,6 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -149,8 +120,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -187,13 +156,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
@@ -221,8 +187,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab h1:628ME69lBm9C6JY2wXhAph/yjN3jezx1z7BIDLUwxjo=
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -232,8 +196,6 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -275,12 +237,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
layeh.com/radius v0.0.0-20231213012653-1006025d24f8 h1:orYXpi6BJZdvgytfHH4ybOe4wHnLbbS71Cmd8mWdZjs=
layeh.com/radius v0.0.0-20231213012653-1006025d24f8/go.mod h1:QRf+8aRqXc019kHkpcs/CTgyWXFzf+bxlsyuo2nAl1o=

View File

@@ -191,44 +191,6 @@ func (c *Config) parseScheme(rawVal string) string {
return rawVal
}
// RefreshPostgreSQLConfig re-reads PostgreSQL configuration from file:// and env:// URIs
// This enables hot-reloading when credentials are rotated by updating the referenced files.
// Note: Plain environment variables (without file:// or env:// prefixes) are read from the
// process environment and will not change unless the process is restarted or os.Setenv is called.
func (c *Config) RefreshPostgreSQLConfig() PostgreSQLConfig {
// Start with current config as base
refreshed := c.PostgreSQL
// Manually read from environment variables with proper prefix
// We can't use env.Process directly on PostgreSQLConfig because it loses the AUTHENTIK_POSTGRESQL__ prefix
// Map of environment variable suffix to config field pointer
envVars := map[string]*string{
"HOST": &refreshed.Host,
"PORT": &refreshed.Port,
"USER": &refreshed.User,
"PASSWORD": &refreshed.Password,
"NAME": &refreshed.Name,
"SSLMODE": &refreshed.SSLMode,
"SSLROOTCERT": &refreshed.SSLRootCert,
"SSLCERT": &refreshed.SSLCert,
"SSLKEY": &refreshed.SSLKey,
"DEFAULT_SCHEMA": &refreshed.DefaultSchema,
"CONN_OPTIONS": &refreshed.ConnOptions,
}
// Read each environment variable if it exists
for suffix, field := range envVars {
if val, ok := os.LookupEnv("AUTHENTIK_POSTGRESQL__" + suffix); ok {
*field = val
}
}
// Process file:// and env:// URI schemes
c.walkScheme(&refreshed)
return refreshed
}
func (c *Config) configureLogger() {
switch strings.ToLower(c.LogLevel) {
case "trace":

View File

@@ -10,22 +10,22 @@ import (
)
func TestConfigEnv(t *testing.T) {
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", "bar"))
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", "bar"))
cfg = nil
if err := Get().fromEnv(); err != nil {
panic(err)
}
assert.Equal(t, "bar", Get().SecretKey)
assert.Equal(t, "bar", Get().LogLevel)
}
func TestConfigEnv_Scheme(t *testing.T) {
assert.NoError(t, os.Setenv("foo", "bar"))
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", "env://foo"))
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", "env://foo"))
cfg = nil
if err := Get().fromEnv(); err != nil {
panic(err)
}
assert.Equal(t, "bar", Get().SecretKey)
assert.Equal(t, "bar", Get().LogLevel)
}
func TestConfigEnv_File(t *testing.T) {
@@ -41,10 +41,10 @@ func TestConfigEnv_File(t *testing.T) {
panic(err)
}
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", fmt.Sprintf("file://%s", file.Name())))
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", fmt.Sprintf("file://%s", file.Name())))
cfg = nil
if err := Get().fromEnv(); err != nil {
panic(err)
}
assert.Equal(t, "bar", Get().SecretKey)
assert.Equal(t, "bar", Get().LogLevel)
}

View File

@@ -1,20 +1,11 @@
package config
type Config struct {
// Core specific config
Storage StorageConfig `yaml:"storage"`
LogLevel string `yaml:"log_level" env:"AUTHENTIK_LOG_LEVEL, overwrite"`
ErrorReporting ErrorReportingConfig `yaml:"error_reporting" env:", prefix=AUTHENTIK_ERROR_REPORTING__"`
PostgreSQL PostgreSQLConfig `yaml:"postgresql" env:", prefix=AUTHENTIK_POSTGRESQL__"`
Outposts OutpostConfig `yaml:"outposts" env:", prefix=AUTHENTIK_OUTPOSTS__"`
// Config for core and embedded outpost
SecretKey string `yaml:"secret_key" env:"AUTHENTIK_SECRET_KEY, overwrite"`
LogLevel string `yaml:"log_level" env:"AUTHENTIK_LOG_LEVEL, overwrite"`
// Config for both core and outposts
Debug bool `yaml:"debug" env:"AUTHENTIK_DEBUG, overwrite"`
Listen ListenConfig `yaml:"listen" env:", prefix=AUTHENTIK_LISTEN__"`
Web WebConfig `yaml:"web" env:", prefix=AUTHENTIK_WEB__"`
Log LogConfig `yaml:"log" env:", prefix=AUTHENTIK_LOG__"`
LDAP LDAPConfig `yaml:"ldap" env:", prefix=AUTHENTIK_LDAP__"`
@@ -27,32 +18,7 @@ type Config struct {
AuthentikInsecure bool `env:"AUTHENTIK_INSECURE"`
}
type PostgreSQLConfig struct {
Host string `yaml:"host" env:"HOST, overwrite"`
Port string `yaml:"port" env:"PORT, overwrite"`
User string `yaml:"user" env:"USER, overwrite"`
Password string `yaml:"password" env:"PASSWORD, overwrite"`
Name string `yaml:"name" env:"NAME, overwrite"`
// SSL/TLS settings
SSLMode string `yaml:"sslmode" env:"SSLMODE, overwrite"`
SSLRootCert string `yaml:"sslrootcert" env:"SSLROOTCERT, overwrite"`
SSLCert string `yaml:"sslcert" env:"SSLCERT, overwrite"`
SSLKey string `yaml:"sslkey" env:"SSLKEY, overwrite"`
// Connection management
ConnMaxAge int `yaml:"conn_max_age" env:"CONN_MAX_AGE, overwrite"`
ConnHealthChecks bool `yaml:"conn_health_checks" env:"CONN_HEALTH_CHECKS, overwrite"`
DisableServerSideCursors bool `yaml:"disable_server_side_cursors" env:"DISABLE_SERVER_SIDE_CURSORS, overwrite"`
// Advanced settings
DefaultSchema string `yaml:"default_schema" env:"DEFAULT_SCHEMA, overwrite"`
ConnOptions string `yaml:"conn_options" env:"CONN_OPTIONS, overwrite"`
}
type ListenConfig struct {
HTTP []string `yaml:"http" env:"HTTP, overwrite"`
HTTPS []string `yaml:"https" env:"HTTPS, overwrite"`
LDAP []string `yaml:"ldap" env:"LDAP, overwrite"`
LDAPS []string `yaml:"ldaps" env:"LDAPS, overwrite"`
Radius []string `yaml:"radius" env:"RADIUS, overwrite"`
@@ -61,57 +27,6 @@ type ListenConfig struct {
TrustedProxyCIDRs []string `yaml:"trusted_proxy_cidrs" env:"TRUSTED_PROXY_CIDRS, overwrite"`
}
type StorageConfig struct {
Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__BACKEND"`
File StorageFileConfig `yaml:"file"`
Media StorageMediaConfig `yaml:"media"`
Reports StorageReportsConfig `yaml:"reports"`
}
type StorageFileConfig struct {
Path string `yaml:"path" env:"AUTHENTIK_STORAGE__FILE__PATH, overwrite"`
}
type StorageMediaConfig struct {
Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__MEDIA__BACKEND"`
File StorageMediaFileConfig `yaml:"file"`
}
type StorageMediaFileConfig struct {
Path string `yaml:"path" env:"AUTHENTIK_STORAGE__MEDIA__FILE__PATH, overwrite"`
}
type StorageReportsConfig struct {
Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__REPORTS__BACKEND"`
File StorageReportsFileConfig `yaml:"file"`
}
type StorageReportsFileConfig struct {
Path string `yaml:"path" env:"AUTHENTIK_STORAGE__REPORTS__FILE__PATH, overwrite"`
}
type ErrorReportingConfig struct {
Enabled bool `yaml:"enabled" env:"ENABLED, overwrite"`
SentryDSN string `yaml:"sentry_dsn" env:"SENTRY_DSN, overwrite"`
Environment string `yaml:"environment" env:"ENVIRONMENT, overwrite"`
SendPII bool `yaml:"send_pii" env:"SEND_PII, overwrite"`
SampleRate float64 `yaml:"sample_rate" env:"SAMPLE_RATE, overwrite"`
}
type OutpostConfig struct {
ContainerImageBase string `yaml:"container_image_base" env:"CONTAINER_IMAGE_BASE, overwrite"`
Discover bool `yaml:"discover" env:"DISCOVER, overwrite"`
DisableEmbeddedOutpost bool `yaml:"disable_embedded_outpost" env:"DISABLE_EMBEDDED_OUTPOST, overwrite"`
}
type WebConfig struct {
Path string `yaml:"path" env:"PATH, overwrite"`
TimeoutHttpReadHeader string `yaml:"timeout_http_read_header" env:"TIMEOUT_HTTP_READ_HEADER, overwrite"`
TimeoutHttpRead string `yaml:"timeout_http_read" env:"TIMEOUT_HTTP_READ, overwrite"`
TimeoutHttpWrite string `yaml:"timeout_http_write" env:"TIMEOUT_HTTP_WRITE, overwrite"`
TimeoutHttpIdle string `yaml:"timeout_http_idle" env:"TIMEOUT_HTTP_IDLE, overwrite"`
}
type LogConfig struct {
HttpHeaders []string `yaml:"http_headers" env:"HTTP_HEADERS, overwrite"`
}

View File

@@ -1,205 +0,0 @@
package gounicorn
import (
"fmt"
"os"
"os/exec"
"os/signal"
"runtime"
"strconv"
"strings"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
"goauthentik.io/internal/utils"
)
type GoUnicorn struct {
Healthcheck func() bool
healthyCallbacks []func()
log *log.Entry
p *exec.Cmd
pidFile string
started bool
killed bool
alive bool
}
func New(healthcheck func() bool) *GoUnicorn {
logger := log.WithField("logger", "authentik.router.unicorn")
g := &GoUnicorn{
Healthcheck: healthcheck,
log: logger,
started: false,
killed: false,
alive: false,
healthyCallbacks: []func(){},
}
g.initCmd()
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP, syscall.SIGUSR2)
go func() {
for sig := range c {
switch sig {
case syscall.SIGHUP:
g.log.Info("SIGHUP received, forwarding to gunicorn")
g.Reload()
case syscall.SIGUSR2:
g.log.Info("SIGUSR2 received, restarting gunicorn")
g.Restart()
}
}
}()
return g
}
func (g *GoUnicorn) initCmd() {
command := "./manage.py"
args := []string{"dev_server"}
if !config.Get().Debug {
pidFile, err := os.CreateTemp("", "authentik-gunicorn.*.pid")
if err != nil {
panic(fmt.Errorf("failed to create temporary pid file: %v", err))
}
g.pidFile = pidFile.Name()
command = "gunicorn"
args = []string{"-c", "./lifecycle/gunicorn.conf.py", "authentik.root.asgi:application"}
if g.pidFile != "" {
args = append(args, "--pid", g.pidFile)
}
}
g.log.WithField("args", args).WithField("cmd", command).Debug("Starting gunicorn")
g.p = exec.Command(command, args...)
g.p.Env = os.Environ()
g.p.Stdout = os.Stdout
g.p.Stderr = os.Stderr
}
func (g *GoUnicorn) AddHealthyCallback(cb func()) {
g.healthyCallbacks = append(g.healthyCallbacks, cb)
}
func (g *GoUnicorn) IsRunning() bool {
return g.alive
}
func (g *GoUnicorn) Start() error {
if g.started {
g.initCmd()
}
g.killed = false
g.started = true
go g.healthcheck()
return g.p.Run()
}
func (g *GoUnicorn) healthcheck() {
g.log.Debug("starting healthcheck")
// Default healthcheck is every 1 second on startup
// once we've been healthy once, increase to 30 seconds
for range time.NewTicker(time.Second).C {
if g.Healthcheck() {
g.alive = true
g.log.Debug("backend is alive, backing off with healthchecks")
for _, cb := range g.healthyCallbacks {
cb()
}
break
}
g.log.Debug("backend not alive yet")
}
}
func (g *GoUnicorn) Reload() {
g.log.WithField("method", "reload").Info("reloading gunicorn")
err := g.p.Process.Signal(syscall.SIGHUP)
if err != nil {
g.log.WithError(err).Warning("failed to reload gunicorn")
}
}
func (g *GoUnicorn) Restart() {
g.log.WithField("method", "restart").Info("restart gunicorn")
if g.pidFile == "" {
g.log.Warning("pidfile is non existent, cannot restart")
return
}
err := g.p.Process.Signal(syscall.SIGUSR2)
if err != nil {
g.log.WithError(err).Warning("failed to restart gunicorn")
return
}
newPidFile := fmt.Sprintf("%s.2", g.pidFile)
// Wait for the new PID file to be created
for range time.NewTicker(1 * time.Second).C {
_, err = os.Stat(newPidFile)
if err == nil || !os.IsNotExist(err) {
break
}
g.log.Debugf("waiting for new gunicorn pidfile to appear at %s", newPidFile)
}
if err != nil {
g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting")
return
}
newPidB, err := os.ReadFile(newPidFile)
if err != nil {
g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting")
return
}
newPidS := strings.TrimSpace(string(newPidB[:]))
newPid, err := strconv.Atoi(newPidS)
if err != nil {
g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting")
return
}
g.log.Warningf("new gunicorn PID is %d", newPid)
newProcess, err := utils.FindProcess(newPid)
if newProcess == nil || err != nil {
g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting")
return
}
// The new process has started, let's gracefully kill the old one
g.log.Warning("killing old gunicorn")
err = g.p.Process.Signal(syscall.SIGTERM)
if err != nil {
g.log.Warning("failed to kill old instance of gunicorn")
}
g.p.Process = newProcess
// No need to close any files and the .2 pid file is deleted by Gunicorn
}
func (g *GoUnicorn) Kill() {
if !g.started {
return
}
var err error
if runtime.GOOS == "darwin" {
g.log.WithField("method", "kill").Warning("stopping gunicorn")
err = g.p.Process.Kill()
} else {
g.log.WithField("method", "sigterm").Warning("stopping gunicorn")
err = syscall.Kill(g.p.Process.Pid, syscall.SIGTERM)
}
if err != nil {
g.log.WithError(err).Warning("failed to stop gunicorn")
}
if g.pidFile != "" {
err := os.Remove(g.pidFile)
if err != nil {
g.log.WithError(err).Warning("failed to remove pidfile")
}
}
g.killed = true
}

View File

@@ -1,312 +0,0 @@
package application
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/gob"
"encoding/hex"
"fmt"
"html/template"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/getsentry/sentry-go"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jellydator/ttlcache/v3"
"github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2/hs256"
"goauthentik.io/internal/outpost/proxyv2/metrics"
"goauthentik.io/internal/outpost/proxyv2/templates"
"goauthentik.io/internal/outpost/proxyv2/types"
"goauthentik.io/internal/utils/web"
api "goauthentik.io/packages/client-go"
"golang.org/x/oauth2"
)
type Application struct {
Host string
Cert *tls.Certificate
UnauthenticatedRegex []*regexp.Regexp
endpoint OIDCEndpoint
oauthConfig oauth2.Config
tokenVerifier *oidc.IDTokenVerifier
outpostName string
sessionName string
sessions sessions.Store
proxyConfig api.ProxyOutpostConfig
httpClient *http.Client
publicHostHTTPClient *http.Client
log *log.Entry
mux *mux.Router
ak *ak.APIController
srv Server
errorTemplates *template.Template
authHeaderCache *ttlcache.Cache[string, types.Claims]
isEmbedded bool
}
type Server interface {
API() *ak.APIController
Apps() []*Application
CryptoStore() *ak.CryptoStore
SessionBackend() string
}
func init() {
gob.Register(types.Claims{})
}
func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, oldApp *Application) (*Application, error) {
muxLogger := log.WithField("logger", "authentik.outpost.proxyv2.application").WithField("name", p.Name)
externalHost, err := url.Parse(p.ExternalHost)
if err != nil {
return nil, fmt.Errorf("failed to parse URL, skipping provider")
}
var ks oidc.KeySet
if contains(p.OidcConfiguration.IdTokenSigningAlgValuesSupported, "HS256") {
ks = hs256.NewKeySet(*p.ClientSecret)
} else {
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c)
ks = oidc.NewRemoteKeySet(ctx, p.OidcConfiguration.JwksUri)
}
redirectUri, _ := url.Parse(p.ExternalHost)
redirectUri.Path = path.Join(redirectUri.Path, "/outpost.goauthentik.io/callback")
redirectUri.RawQuery = url.Values{
CallbackSignature: []string{"true"},
}.Encode()
isEmbedded := server.API().IsEmbedded()
// Configure an OpenID Connect aware OAuth2 client.
endpoint := GetOIDCEndpoint(
p,
server.API().Outpost.Config["authentik_host"].(string),
isEmbedded,
)
verifier := oidc.NewVerifier(endpoint.Issuer, ks, &oidc.Config{
ClientID: *p.ClientId,
SupportedSigningAlgs: []string{"RS256", "HS256"},
})
oauth2Config := oauth2.Config{
ClientID: *p.ClientId,
ClientSecret: *p.ClientSecret,
RedirectURL: redirectUri.String(),
Endpoint: endpoint.Endpoint,
Scopes: p.ScopesToRequest,
}
mux := mux.NewRouter()
// Save cookie name, based on hashed client ID
hs := sha256.Sum256([]byte(*p.ClientId))
bs := hex.EncodeToString(hs[:])
sessionName := fmt.Sprintf("authentik_proxy_%s", bs[:8])
// When HOST_BROWSER is set, use that as Host header for token requests to make the issuer match
// otherwise we use the internally configured authentik_host
tokenEndpointHost := server.API().Outpost.Config["authentik_host"].(string)
if config.Get().AuthentikHostBrowser != "" {
tokenEndpointHost = config.Get().AuthentikHostBrowser
}
publicHTTPClient := web.NewHostInterceptor(c, tokenEndpointHost)
a := &Application{
Host: externalHost.Host,
log: muxLogger,
outpostName: server.API().Outpost.Name,
sessionName: sessionName,
endpoint: endpoint,
oauthConfig: oauth2Config,
tokenVerifier: verifier,
proxyConfig: p,
httpClient: c,
publicHostHTTPClient: publicHTTPClient,
mux: mux,
errorTemplates: templates.GetTemplates(),
ak: server.API(),
authHeaderCache: ttlcache.New(ttlcache.WithDisableTouchOnHit[string, types.Claims]()),
srv: server,
isEmbedded: isEmbedded,
}
go a.authHeaderCache.Start()
if oldApp != nil && oldApp.sessions != nil {
a.sessions = oldApp.sessions
muxLogger.Debug("reusing existing session store")
} else {
sess, err := a.getStore(p, externalHost)
if err != nil {
return nil, err
}
a.sessions = sess
}
mux.Use(web.NewLoggingHandler(muxLogger, func(l *log.Entry, r *http.Request) *log.Entry {
c := a.getClaimsFromSession(nil, r)
if c == nil {
return l
}
if c.PreferredUsername != "" {
return l.WithField("user", c.PreferredUsername)
}
return l.WithField("user", c.Sub)
}))
mux.Use(func(inner http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
c := a.getClaimsFromSession(nil, r)
user := ""
if c != nil {
user = c.PreferredUsername
hub := sentry.GetHubFromContext(r.Context())
if hub == nil {
hub = sentry.CurrentHub()
}
hub.Scope().SetUser(sentry.User{
Username: user,
ID: c.Sub,
IPAddress: r.RemoteAddr,
})
}
before := time.Now()
inner.ServeHTTP(rw, r)
elapsed := time.Since(before)
metrics.Requests.With(prometheus.Labels{
"outpost_name": a.outpostName,
"type": "app",
"method": r.Method,
"host": web.GetHost(r),
}).Observe(float64(elapsed) / float64(time.Second))
})
})
if server.API().GlobalConfig.ErrorReporting.Enabled {
mux.Use(sentryhttp.New(sentryhttp.Options{}).Handle)
}
mux.Use(func(inner http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.EqualFold(r.URL.Query().Get(CallbackSignature), "true") {
a.log.Debug("handling OAuth Callback from querystring signature")
a.handleAuthCallback(w, r)
} else if strings.EqualFold(r.URL.Query().Get(LogoutSignature), "true") {
a.log.Debug("handling OAuth Logout from querystring signature")
a.handleSignOut(w, r)
} else {
inner.ServeHTTP(w, r)
}
})
})
mux.HandleFunc("/outpost.goauthentik.io/start", func(w http.ResponseWriter, r *http.Request) {
fwd := ""
// This should only really be hit for nginx forward_auth
// as for that the auth start redirect URL is generated by the
// reverse proxy, and as such we won't have a request we just
// denied to reference for final URL
rd, ok := a.checkRedirectParam(r)
if ok {
a.log.WithField("rd", rd).Trace("Setting redirect")
fwd = rd
}
a.handleAuthStart(w, r, fwd)
})
mux.HandleFunc("/outpost.goauthentik.io/callback", a.handleAuthCallback)
mux.HandleFunc("/outpost.goauthentik.io/sign_out", a.handleSignOut)
switch *p.Mode {
case api.PROXYMODE_PROXY:
err = a.configureProxy()
case api.PROXYMODE_FORWARD_SINGLE:
fallthrough
case api.PROXYMODE_FORWARD_DOMAIN:
err = a.configureForward()
}
if err != nil {
return nil, fmt.Errorf("failed to configure application mode: %w", err)
}
if kp := p.Certificate.Get(); kp != nil {
err := server.CryptoStore().AddKeypair(*kp)
if err != nil {
return nil, fmt.Errorf("failed to initially fetch certificate: %w", err)
}
a.Cert = server.CryptoStore().Get(*kp)
}
if *p.SkipPathRegex != "" {
a.UnauthenticatedRegex = make([]*regexp.Regexp, 0)
for regex := range strings.SplitSeq(*p.SkipPathRegex, "\n") {
re, err := regexp.Compile(regex)
if err != nil {
// TODO: maybe create event for this?
a.log.WithError(err).Warning("failed to compile SkipPathRegex")
continue
}
a.UnauthenticatedRegex = append(a.UnauthenticatedRegex, re)
}
}
return a, nil
}
func (a *Application) Mode() api.ProxyMode {
return *a.proxyConfig.Mode
}
func (a *Application) ShouldHandleURL(r *http.Request) bool {
if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io") {
return true
}
if strings.EqualFold(r.URL.Query().Get(CallbackSignature), "true") {
return true
}
if strings.EqualFold(r.URL.Query().Get(LogoutSignature), "true") {
return true
}
return false
}
func (a *Application) ProxyConfig() api.ProxyOutpostConfig {
return a.proxyConfig
}
func (a *Application) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
a.mux.ServeHTTP(rw, r)
}
func (a *Application) Stop() {
a.authHeaderCache.Stop()
}
func (a *Application) handleSignOut(rw http.ResponseWriter, r *http.Request) {
redirect := a.endpoint.EndSessionEndpoint
cc := a.getClaimsFromSession(rw, r)
if cc == nil {
a.redirectToStart(rw, r)
return
}
uv := url.Values{
"id_token_hint": []string{cc.RawToken},
}
redirect += "?" + uv.Encode()
err := a.Logout(r.Context(), func(c types.Claims) bool {
return c.Sub == cc.Sub
})
if err != nil {
a.log.WithError(err).Warning("failed to logout of other sessions")
}
http.Redirect(rw, r, redirect, http.StatusFound)
}

View File

@@ -1,120 +0,0 @@
package application
import (
"fmt"
"net/http"
"time"
"github.com/mitchellh/mapstructure"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
)
// checkAuth Get claims which are currently in session
// Returns an error if the session can't be loaded or the claims can't be parsed/type-cast
func (a *Application) checkAuth(rw http.ResponseWriter, r *http.Request) (*types.Claims, error) {
c := a.getClaimsFromSession(rw, r)
if c != nil {
return c, nil
}
if rw == nil {
return nil, fmt.Errorf("no response writer")
}
// Check TTL cache
c = a.getClaimsFromCache(r)
if c != nil {
return c, nil
}
// Check bearer token if set
bearer := a.checkAuthHeaderBearer(r)
if bearer != "" {
a.log.Trace("checking bearer token")
tc := a.attemptBearerAuth(bearer)
if tc != nil {
return a.saveAndCacheClaims(rw, r, tc.Claims)
}
a.log.Trace("no/invalid bearer token")
}
// Check basic auth if set
username, password, basicSet := r.BasicAuth()
if basicSet {
a.log.Trace("checking basic auth")
tc := a.attemptBasicAuth(username, password)
if tc != nil {
return a.saveAndCacheClaims(rw, r, *tc)
}
a.log.Trace("no/invalid basic auth")
}
return nil, fmt.Errorf("failed to get claims from session")
}
func (a *Application) getClaimsFromSession(rw http.ResponseWriter, r *http.Request) *types.Claims {
s, err := a.sessions.Get(r, a.SessionName())
if err != nil {
// err == user has no session/session is not valid
// Delete the stale session cookie if it exists
if rw != nil {
s.Options.MaxAge = -1
if saveErr := s.Save(r, rw); saveErr != nil {
a.log.WithError(saveErr).Warning("failed to delete stale session cookie")
}
}
return nil
}
claims, ok := s.Values[constants.SessionClaims]
if claims == nil || !ok {
// no claims saved, reject
return nil
}
// Claims are always stored as types.Claims but may be deserialized differently:
// - Filesystem store (gob): preserves struct type as types.Claims
// - PostgreSQL store (JSON): deserializes as map[string]any
// Handle struct type (filesystem store)
if c, ok := claims.(types.Claims); ok {
return &c
}
// Handle map type (PostgreSQL store)
if claimsMap, ok := claims.(map[string]any); ok {
var c types.Claims
if err := mapstructure.Decode(claimsMap, &c); err != nil {
return nil
}
return &c
}
return nil
}
func (a *Application) getClaimsFromCache(r *http.Request) *types.Claims {
key := r.Header.Get(constants.HeaderAuthorization)
item := a.authHeaderCache.Get(key)
if item != nil && !item.IsExpired() {
v := item.Value()
return &v
}
return nil
}
func (a *Application) saveAndCacheClaims(rw http.ResponseWriter, r *http.Request, claims types.Claims) (*types.Claims, error) {
s, _ := a.sessions.Get(r, a.SessionName())
s.Values[constants.SessionClaims] = claims
err := s.Save(r, rw)
if err != nil {
return nil, err
}
key := r.Header.Get(constants.HeaderAuthorization)
item := a.authHeaderCache.Get(key)
// Don't set when the key is already found
if item == nil {
a.authHeaderCache.Set(key, claims, time.Second*60)
}
r.Header.Del(constants.HeaderAuthorization)
return &claims, nil
}

View File

@@ -1,87 +0,0 @@
package application
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"goauthentik.io/internal/outpost/proxyv2/types"
)
type TokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
}
const JWTUsername = "goauthentik.io/token"
func (a *Application) attemptBasicAuth(username, password string) *types.Claims {
if username == JWTUsername {
res := a.attemptBearerAuth(password)
if res != nil {
return &res.Claims
}
}
values := url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{a.oauthConfig.ClientID},
"username": []string{username},
"password": []string{password},
"scope": []string{strings.Join(a.oauthConfig.Scopes, " ")},
}
req, err := http.NewRequest("POST", a.endpoint.TokenURL, strings.NewReader(values.Encode()))
if err != nil {
a.log.WithError(err).Warning("failed to create token request")
return nil
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := a.publicHostHTTPClient.Do(req)
if err != nil {
a.log.WithError(err).Warning("failed to send token request")
return nil
}
defer func() {
if err := res.Body.Close(); err != nil {
a.log.WithError(err).Warning("failed to close response body")
}
}()
if res.StatusCode > 200 {
b, readErr := io.ReadAll(res.Body)
if readErr != nil {
b = []byte(readErr.Error())
a.log.WithError(readErr).WithField("body", string(b)).Warning("failed to read error response body")
} else {
a.log.WithField("body", string(b)).Warning("failed to send token request")
}
return nil
}
var token TokenResponse
err = json.NewDecoder(res.Body).Decode(&token)
if err != nil {
a.log.WithError(err).Warning("failed to parse token response")
return nil
}
// Parse and verify ID Token payload.
idToken, err := a.tokenVerifier.Verify(context.Background(), token.IDToken)
if err != nil {
a.log.WithError(err).Warning("failed to verify token")
return nil
}
// Extract custom claims
var claims *types.Claims
if err := idToken.Claims(&claims); err != nil {
a.log.WithError(err).Warning("failed to convert token to claims")
return nil
}
if claims.Proxy == nil {
claims.Proxy = &types.ProxyClaims{}
}
claims.RawToken = token.IDToken
return claims
}

View File

@@ -1,61 +0,0 @@
package application
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
)
func (a *Application) checkAuthHeaderBearer(r *http.Request) string {
auth := r.Header.Get(constants.HeaderAuthorization)
if auth == "" {
return ""
}
if len(auth) < len(constants.AuthBearer) || !strings.EqualFold(auth[:len(constants.AuthBearer)], constants.AuthBearer) {
return ""
}
return auth[len(constants.AuthBearer):]
}
type TokenIntrospectionResponse struct {
types.Claims
Scope string `json:"scope"`
Active bool `json:"active"`
ClientID string `json:"client_id"`
}
func (a *Application) attemptBearerAuth(token string) *TokenIntrospectionResponse {
values := url.Values{
"client_id": []string{a.oauthConfig.ClientID},
"client_secret": []string{a.oauthConfig.ClientSecret},
"token": []string{token},
}
req, err := http.NewRequest("POST", a.endpoint.TokenIntrospection, strings.NewReader(values.Encode()))
if err != nil {
a.log.WithError(err).Warning("failed to create introspection request")
return nil
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := a.publicHostHTTPClient.Do(req)
if err != nil || res.StatusCode > 200 {
a.log.WithError(err).Warning("failed to send introspection request")
return nil
}
intro := TokenIntrospectionResponse{}
err = json.NewDecoder(res.Body).Decode(&intro)
if err != nil {
a.log.WithError(err).Warning("failed to parse introspection response")
return nil
}
if !intro.Active {
a.log.Warning("token is not active")
return nil
}
intro.RawToken = token
a.log.Trace("successfully introspected bearer token")
return &intro
}

View File

@@ -1,363 +0,0 @@
package application
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/sessions"
"github.com/mitchellh/mapstructure"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
)
// TestClaimsJSONSerialization tests that Claims can be serialized to JSON and back
func TestClaimsJSONSerialization(t *testing.T) {
claims := types.Claims{
Sub: "user-id-123",
Exp: 1234567890,
Email: "test@example.com",
Verified: true,
Name: "Test User",
PreferredUsername: "testuser",
Groups: []string{"admin", "user"},
Entitlements: []string{"read", "write"},
Sid: "session-id-456",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"custom_field": "custom_value",
"department": "engineering",
},
BackendOverride: "custom-backend",
HostHeader: "example.com",
IsSuperuser: true,
},
RawToken: "raw.jwt.token",
}
// Serialize to JSON
jsonData, err := json.Marshal(claims)
require.NoError(t, err)
// Deserialize back
var parsedClaims types.Claims
err = json.Unmarshal(jsonData, &parsedClaims)
require.NoError(t, err)
// Verify all fields
assert.Equal(t, claims.Sub, parsedClaims.Sub)
assert.Equal(t, claims.Exp, parsedClaims.Exp)
assert.Equal(t, claims.Email, parsedClaims.Email)
assert.Equal(t, claims.Verified, parsedClaims.Verified)
assert.Equal(t, claims.Name, parsedClaims.Name)
assert.Equal(t, claims.PreferredUsername, parsedClaims.PreferredUsername)
assert.Equal(t, claims.Groups, parsedClaims.Groups)
assert.Equal(t, claims.Entitlements, parsedClaims.Entitlements)
assert.Equal(t, claims.Sid, parsedClaims.Sid)
// RawToken has no json tag, so it's serialized using the field name
assert.Equal(t, claims.RawToken, parsedClaims.RawToken)
// Verify proxy claims
require.NotNil(t, parsedClaims.Proxy)
assert.Equal(t, claims.Proxy.BackendOverride, parsedClaims.Proxy.BackendOverride)
assert.Equal(t, claims.Proxy.HostHeader, parsedClaims.Proxy.HostHeader)
assert.Equal(t, claims.Proxy.IsSuperuser, parsedClaims.Proxy.IsSuperuser)
assert.Equal(t, "custom_value", parsedClaims.Proxy.UserAttributes["custom_field"])
assert.Equal(t, "engineering", parsedClaims.Proxy.UserAttributes["department"])
}
// TestClaimsMapSerialization tests that Claims stored as map[string]any can be converted back
func TestClaimsMapSerialization(t *testing.T) {
// Simulate how claims are stored in session as map (like from PostgreSQL JSONB)
claimsMap := map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890), // json numbers become float64
"email": "test@example.com",
"email_verified": true,
"name": "Test User",
"preferred_username": "testuser",
"groups": []any{"admin", "user"},
"entitlements": []any{"read", "write"},
"sid": "session-id-456",
"ak_proxy": map[string]any{
"user_attributes": map[string]any{
"custom_field": "custom_value",
},
"backend_override": "custom-backend",
"host_header": "example.com",
"is_superuser": true,
},
"raw_token": "not-a-real-token",
}
// Convert map to Claims using mapstructure marshaling (like getClaimsFromSession does)
var claims types.Claims
err := mapstructure.Decode(claimsMap, &claims)
require.NoError(t, err)
// Verify fields
assert.Equal(t, "user-id-123", claims.Sub)
assert.Equal(t, 1234567890, claims.Exp)
assert.Equal(t, "test@example.com", claims.Email)
assert.True(t, claims.Verified)
assert.Equal(t, "Test User", claims.Name)
assert.Equal(t, "testuser", claims.PreferredUsername)
assert.Equal(t, []string{"admin", "user"}, claims.Groups)
assert.Equal(t, []string{"read", "write"}, claims.Entitlements)
assert.Equal(t, "session-id-456", claims.Sid)
assert.Equal(t, "not-a-real-token", claims.RawToken)
// Verify proxy claims
require.NotNil(t, claims.Proxy)
assert.Equal(t, "custom-backend", claims.Proxy.BackendOverride)
assert.Equal(t, "example.com", claims.Proxy.HostHeader)
assert.True(t, claims.Proxy.IsSuperuser)
assert.Equal(t, "custom_value", claims.Proxy.UserAttributes["custom_field"])
}
// TestClaimsMinimalFields tests that Claims work with minimal required fields
func TestClaimsMinimalFields(t *testing.T) {
claimsMap := map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890),
}
jsonData, err := json.Marshal(claimsMap)
require.NoError(t, err)
var claims types.Claims
err = json.Unmarshal(jsonData, &claims)
require.NoError(t, err)
assert.Equal(t, "user-id-123", claims.Sub)
assert.Equal(t, 1234567890, claims.Exp)
assert.Empty(t, claims.Email)
assert.Empty(t, claims.Name)
assert.Empty(t, claims.Groups)
assert.Nil(t, claims.Proxy)
}
// TestClaimsWithEmptyArrays tests that empty arrays are handled correctly
func TestClaimsWithEmptyArrays(t *testing.T) {
claimsMap := map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890),
"groups": []any{},
"entitlements": []any{},
}
jsonData, err := json.Marshal(claimsMap)
require.NoError(t, err)
var claims types.Claims
err = json.Unmarshal(jsonData, &claims)
require.NoError(t, err)
assert.Equal(t, "user-id-123", claims.Sub)
assert.NotNil(t, claims.Groups)
assert.NotNil(t, claims.Entitlements)
assert.Len(t, claims.Groups, 0)
assert.Len(t, claims.Entitlements, 0)
}
// TestClaimsWithNullProxyClaims tests that null proxy claims don't cause issues
func TestClaimsWithNullProxyClaims(t *testing.T) {
claimsMap := map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890),
"ak_proxy": nil,
}
jsonData, err := json.Marshal(claimsMap)
require.NoError(t, err)
var claims types.Claims
err = json.Unmarshal(jsonData, &claims)
require.NoError(t, err)
assert.Equal(t, "user-id-123", claims.Sub)
assert.Nil(t, claims.Proxy)
}
// TestGetClaimsFromSession_Success tests successful retrieval of claims from session
// uses a mock session that returns claims as map[string]any to simulate
// how PostgreSQL storage deserializes JSONB data
func TestGetClaimsFromSession_Success(t *testing.T) {
// Create a custom mock store that returns claims as map
store := &mockMapSessionStore{
claimsMap: map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890),
"email": "test@example.com",
"email_verified": true,
"preferred_username": "testuser",
"groups": []any{"admin", "user"},
},
}
app := &Application{
sessions: store,
}
req := httptest.NewRequest("GET", "/", nil)
// Test getClaimsFromSession
claims := app.getClaimsFromSession(nil, req)
require.NotNil(t, claims)
assert.Equal(t, "user-id-123", claims.Sub)
assert.Equal(t, 1234567890, claims.Exp)
assert.Equal(t, "test@example.com", claims.Email)
assert.True(t, claims.Verified)
assert.Equal(t, "testuser", claims.PreferredUsername)
assert.Equal(t, []string{"admin", "user"}, claims.Groups)
}
// mockMapSessionStore is a mock session store that returns claims as map[string]any
type mockMapSessionStore struct {
claimsMap map[string]any
}
func (m *mockMapSessionStore) Get(r *http.Request, name string) (*sessions.Session, error) {
session := sessions.NewSession(m, name)
if m.claimsMap != nil {
session.Values[constants.SessionClaims] = m.claimsMap
}
return session, nil
}
func (m *mockMapSessionStore) New(r *http.Request, name string) (*sessions.Session, error) {
return m.Get(r, name)
}
func (m *mockMapSessionStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error {
return nil
}
// TestGetClaimsFromSession_NoSession tests behavior when no session exists
func TestGetClaimsFromSession_NoSession(t *testing.T) {
store := &mockMapSessionStore{
claimsMap: nil, // No claims
}
app := &Application{
sessions: store,
}
req := httptest.NewRequest("GET", "/", nil)
claims := app.getClaimsFromSession(nil, req)
assert.Nil(t, claims)
}
// TestGetClaimsFromSession_NoClaims tests behavior when session exists but has no claims
func TestGetClaimsFromSession_NoClaims(t *testing.T) {
store := &mockMapSessionStore{
claimsMap: nil, // No claims in session
}
app := &Application{
sessions: store,
}
req := httptest.NewRequest("GET", "/", nil)
claims := app.getClaimsFromSession(nil, req)
assert.Nil(t, claims)
}
// TestGetClaimsFromSession_InvalidClaimsType tests behavior when claims have wrong type
func TestGetClaimsFromSession_InvalidClaimsType(t *testing.T) {
store := &mockInvalidClaimsStore{}
app := &Application{
sessions: store,
}
req := httptest.NewRequest("GET", "/", nil)
claims := app.getClaimsFromSession(nil, req)
assert.Nil(t, claims)
}
// mockInvalidClaimsStore returns claims as invalid type (string)
type mockInvalidClaimsStore struct{}
func (m *mockInvalidClaimsStore) Get(r *http.Request, name string) (*sessions.Session, error) {
session := sessions.NewSession(m, name)
session.Values[constants.SessionClaims] = "invalid-string-value"
return session, nil
}
func (m *mockInvalidClaimsStore) New(r *http.Request, name string) (*sessions.Session, error) {
return m.Get(r, name)
}
func (m *mockInvalidClaimsStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error {
return nil
}
// TestClaimsRoundTrip tests full round trip: save Claims, retrieve as map, convert back to Claims
func TestClaimsRoundTrip(t *testing.T) {
originalClaims := types.Claims{
Sub: "user-id-789",
Exp: 1234567890,
Email: "roundtrip@example.com",
Verified: true,
Name: "Round Trip User",
PreferredUsername: "roundtripuser",
Groups: []string{"group1", "group2", "group3"},
Entitlements: []string{"ent1", "ent2"},
Sid: "session-789",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"attr1": "value1",
"attr2": float64(42),
"attr3": true,
},
BackendOverride: "backend",
HostHeader: "host.example.com",
IsSuperuser: false,
},
}
// Step 1: Serialize Claims to JSON (simulating storage)
jsonData, err := json.Marshal(originalClaims)
require.NoError(t, err)
// Step 2: Deserialize to map[string]any (simulating PostgreSQL load)
var claimsMap map[string]any
err = json.Unmarshal(jsonData, &claimsMap)
require.NoError(t, err)
// Step 3: Convert map back to Claims (simulating getClaimsFromSession)
jsonData2, err := json.Marshal(claimsMap)
require.NoError(t, err)
var retrievedClaims types.Claims
err = json.Unmarshal(jsonData2, &retrievedClaims)
require.NoError(t, err)
// Verify all fields match
assert.Equal(t, originalClaims.Sub, retrievedClaims.Sub)
assert.Equal(t, originalClaims.Exp, retrievedClaims.Exp)
assert.Equal(t, originalClaims.Email, retrievedClaims.Email)
assert.Equal(t, originalClaims.Verified, retrievedClaims.Verified)
assert.Equal(t, originalClaims.Name, retrievedClaims.Name)
assert.Equal(t, originalClaims.PreferredUsername, retrievedClaims.PreferredUsername)
assert.Equal(t, originalClaims.Groups, retrievedClaims.Groups)
assert.Equal(t, originalClaims.Entitlements, retrievedClaims.Entitlements)
assert.Equal(t, originalClaims.Sid, retrievedClaims.Sid)
require.NotNil(t, retrievedClaims.Proxy)
assert.Equal(t, originalClaims.Proxy.BackendOverride, retrievedClaims.Proxy.BackendOverride)
assert.Equal(t, originalClaims.Proxy.HostHeader, retrievedClaims.Proxy.HostHeader)
assert.Equal(t, originalClaims.Proxy.IsSuperuser, retrievedClaims.Proxy.IsSuperuser)
assert.Equal(t, "value1", retrievedClaims.Proxy.UserAttributes["attr1"])
assert.Equal(t, float64(42), retrievedClaims.Proxy.UserAttributes["attr2"])
assert.Equal(t, true, retrievedClaims.Proxy.UserAttributes["attr3"])
}

View File

@@ -1,90 +0,0 @@
package application
import (
"net/url"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
api "goauthentik.io/packages/client-go"
"golang.org/x/oauth2"
)
type OIDCEndpoint struct {
oauth2.Endpoint
TokenIntrospection string
EndSessionEndpoint string
JwksUri string
Issuer string
}
func updateURL(rawUrl string, scheme string, host string) string {
u, err := url.Parse(rawUrl)
if err != nil {
return rawUrl
}
u.Host = host
u.Scheme = scheme
return u.String()
}
func GetOIDCEndpoint(p api.ProxyOutpostConfig, authentikHost string, embedded bool) OIDCEndpoint {
authUrl := p.OidcConfiguration.AuthorizationEndpoint
endUrl := p.OidcConfiguration.EndSessionEndpoint
jwksUri := p.OidcConfiguration.JwksUri
issuer := p.OidcConfiguration.Issuer
ep := OIDCEndpoint{
Endpoint: oauth2.Endpoint{
AuthURL: authUrl,
TokenURL: p.OidcConfiguration.TokenEndpoint,
AuthStyle: oauth2.AuthStyleInParams,
},
EndSessionEndpoint: endUrl,
JwksUri: jwksUri,
TokenIntrospection: p.OidcConfiguration.IntrospectionEndpoint,
Issuer: issuer,
}
aku, err := url.Parse(authentikHost)
if err != nil {
return ep
}
// For the embedded outpost, we use the configure `authentik_host` for the browser URLs
// and localhost (which is what we've got from the API) for backchannel URLs
//
// For other outposts, when `AUTHENTIK_HOST_BROWSER` is set, we use that for the browser URLs
// and use what we got from the API for backchannel
hostBrowser := config.Get().AuthentikHostBrowser
if !embedded && hostBrowser == "" {
return ep
}
var newHost = aku
var newBrowserHost *url.URL
if embedded {
if authentikHost == "" {
log.Warning("Outpost has localhost/blank API Connection but no authentik_host is configured.")
return ep
}
newBrowserHost = aku
} else if hostBrowser != "" {
browser, err := url.Parse(hostBrowser)
if err != nil {
return ep
}
newBrowserHost = browser
}
// Update all browser-accessed URLs to use the new host and scheme
ep.AuthURL = updateURL(authUrl, newBrowserHost.Scheme, newBrowserHost.Host)
ep.EndSessionEndpoint = updateURL(endUrl, newBrowserHost.Scheme, newBrowserHost.Host)
// Update issuer to use the same host and scheme, which would normally break as we don't
// change the token URL here, but the token HTTP transport overwrites the Host header
//
// This is only used in embedded outposts as there we can guarantee that the request
// is routed correctly
if embedded {
ep.Issuer = updateURL(ep.Issuer, newHost.Scheme, newHost.Host)
ep.JwksUri = updateURL(jwksUri, newHost.Scheme, newHost.Host)
} else {
// Fixes: https://github.com/goauthentik/authentik/issues/9622 / ep.Issuer must be the HostBrowser URL
ep.Issuer = updateURL(ep.Issuer, newBrowserHost.Scheme, newBrowserHost.Host)
}
return ep
}

View File

@@ -1,88 +0,0 @@
package application
import (
"testing"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/config"
api "goauthentik.io/packages/client-go"
)
func TestEndpointDefault(t *testing.T) {
pc := api.ProxyOutpostConfig{
OidcConfiguration: api.OpenIDConnectConfiguration{
AuthorizationEndpoint: "https://test.goauthentik.io/application/o/authorize/",
EndSessionEndpoint: "https://test.goauthentik.io/application/o/test-app/end-session/",
IntrospectionEndpoint: "https://test.goauthentik.io/application/o/introspect/",
Issuer: "https://test.goauthentik.io/application/o/test-app/",
JwksUri: "https://test.goauthentik.io/application/o/test-app/jwks/",
TokenEndpoint: "https://test.goauthentik.io/application/o/token/",
},
}
ep := GetOIDCEndpoint(pc, "https://authentik-host.test.goauthentik.io", false)
// Standard outpost, non embedded
// All URLs should use the host that they get from the config
assert.Equal(t, "https://test.goauthentik.io/application/o/authorize/", ep.AuthURL)
assert.Equal(t, "https://test.goauthentik.io/application/o/token/", ep.TokenURL)
assert.Equal(t, "https://test.goauthentik.io/application/o/test-app/", ep.Issuer)
assert.Equal(t, "https://test.goauthentik.io/application/o/test-app/jwks/", ep.JwksUri)
assert.Equal(t, "https://test.goauthentik.io/application/o/test-app/end-session/", ep.EndSessionEndpoint)
assert.Equal(t, "https://test.goauthentik.io/application/o/introspect/", ep.TokenIntrospection)
}
func TestEndpointAuthentikHostBrowser(t *testing.T) {
c := config.Get()
c.AuthentikHostBrowser = "https://browser.test.goauthentik.io"
defer func() {
c.AuthentikHostBrowser = ""
}()
pc := api.ProxyOutpostConfig{
OidcConfiguration: api.OpenIDConnectConfiguration{
AuthorizationEndpoint: "https://test.goauthentik.io/application/o/authorize/",
EndSessionEndpoint: "https://test.goauthentik.io/application/o/test-app/end-session/",
IntrospectionEndpoint: "https://test.goauthentik.io/application/o/introspect/",
Issuer: "https://test.goauthentik.io/application/o/test-app/",
JwksUri: "https://test.goauthentik.io/application/o/test-app/jwks/",
TokenEndpoint: "https://test.goauthentik.io/application/o/token/",
UserinfoEndpoint: "https://test.goauthentik.io/application/o/userinfo/",
},
}
ep := GetOIDCEndpoint(pc, "https://authentik-host.test.goauthentik.io", false)
// Standard outpost, with AUTHENTIK_HOST_BROWSER set
// Only the authorize/end session URLs should be changed
assert.Equal(t, "https://browser.test.goauthentik.io/application/o/authorize/", ep.AuthURL)
assert.Equal(t, "https://browser.test.goauthentik.io/application/o/test-app/end-session/", ep.EndSessionEndpoint)
assert.Equal(t, "https://test.goauthentik.io/application/o/token/", ep.TokenURL)
assert.Equal(t, "https://browser.test.goauthentik.io/application/o/test-app/", ep.Issuer)
assert.Equal(t, "https://test.goauthentik.io/application/o/test-app/jwks/", ep.JwksUri)
assert.Equal(t, "https://test.goauthentik.io/application/o/introspect/", ep.TokenIntrospection)
}
func TestEndpointEmbedded(t *testing.T) {
pc := api.ProxyOutpostConfig{
OidcConfiguration: api.OpenIDConnectConfiguration{
AuthorizationEndpoint: "https://test.goauthentik.io/application/o/authorize/",
EndSessionEndpoint: "https://test.goauthentik.io/application/o/test-app/end-session/",
IntrospectionEndpoint: "https://test.goauthentik.io/application/o/introspect/",
Issuer: "https://test.goauthentik.io/application/o/test-app/",
JwksUri: "https://test.goauthentik.io/application/o/test-app/jwks/",
TokenEndpoint: "https://test.goauthentik.io/application/o/token/",
UserinfoEndpoint: "https://test.goauthentik.io/application/o/userinfo/",
},
}
ep := GetOIDCEndpoint(pc, "https://authentik-host.test.goauthentik.io", true)
// Embedded outpost
// Browser URLs should use the config of "authentik_host", everything else can use what's
// received from the API endpoint
// Token URL is an exception since it's sent via a special HTTP transport that overrides the
// HTTP Host header, to make sure it's the same value as the issuer
assert.Equal(t, "https://authentik-host.test.goauthentik.io/application/o/authorize/", ep.AuthURL)
assert.Equal(t, "https://authentik-host.test.goauthentik.io/application/o/test-app/", ep.Issuer)
assert.Equal(t, "https://test.goauthentik.io/application/o/token/", ep.TokenURL)
assert.Equal(t, "https://authentik-host.test.goauthentik.io/application/o/test-app/jwks/", ep.JwksUri)
assert.Equal(t, "https://authentik-host.test.goauthentik.io/application/o/test-app/end-session/", ep.EndSessionEndpoint)
assert.Equal(t, "https://test.goauthentik.io/application/o/introspect/", ep.TokenIntrospection)
}

View File

@@ -1,41 +0,0 @@
package application
import (
"fmt"
"net/http"
log "github.com/sirupsen/logrus"
)
type ErrorPageData struct {
Title string
Message string
ProxyPrefix string
}
func (a *Application) ErrorPage(rw http.ResponseWriter, r *http.Request, err string) {
claims, _ := a.checkAuth(rw, r)
data := ErrorPageData{
Title: "Bad Gateway",
Message: "Error proxying to upstream server",
ProxyPrefix: "/outpost.goauthentik.io",
}
if claims != nil && claims.Proxy != nil && claims.Proxy.IsSuperuser {
data.Message = err
} else {
data.Message = "Failed to connect to backend."
}
er := a.errorTemplates.Execute(rw, data)
if er != nil {
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
}
}
// NewProxyErrorHandler creates a ProxyErrorHandler using the template given.
func (a *Application) newProxyErrorHandler() func(http.ResponseWriter, *http.Request, error) {
return func(rw http.ResponseWriter, req *http.Request, proxyErr error) {
log.WithError(proxyErr).Warning("Error proxying to upstream server")
rw.WriteHeader(http.StatusBadGateway)
a.ErrorPage(rw, req, fmt.Sprintf("Error proxying to upstream server: %v", proxyErr))
}
}

View File

@@ -1,156 +0,0 @@
package application
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
api "goauthentik.io/packages/client-go"
)
func (a *Application) addHeaders(headers http.Header, c *types.Claims) {
nh := a.getHeaders(c)
for key, val := range nh {
headers.Set(key, val)
}
a.removeDuplicateUnderscoreHeader(headers)
}
func (a *Application) removeDuplicateUnderscoreHeader(h http.Header) {
for key := range h {
ush := strings.ReplaceAll(key, "_", "-")
if _, ok := h[ush]; !ok {
h.Del(key)
}
}
}
func (a *Application) getHeaders(c *types.Claims) map[string]string {
headers := map[string]string{}
// https://docs.goauthentik.io/add-secure-apps/providers/proxy
headers["X-authentik-username"] = c.PreferredUsername
headers["X-authentik-groups"] = strings.Join(c.Groups, "|")
headers["X-authentik-entitlements"] = strings.Join(c.Entitlements, "|")
headers["X-authentik-email"] = c.Email
headers["X-authentik-name"] = c.Name
headers["X-authentik-uid"] = c.Sub
headers["X-authentik-jwt"] = c.RawToken
// System headers
headers["X-authentik-meta-jwks"] = a.endpoint.JwksUri
headers["X-authentik-meta-outpost"] = a.outpostName
headers["X-authentik-meta-provider"] = a.proxyConfig.Name
headers["X-authentik-meta-app"] = a.proxyConfig.AssignedApplicationSlug
headers["X-authentik-meta-version"] = constants.UserAgentOutpost()
if c.Proxy == nil {
return headers
}
if authz := a.setAuthorizationHeader(c); authz != "" {
headers["Authorization"] = authz
}
// Check if user has additional headers set that we should sent
userAttributes := c.Proxy.UserAttributes
if additionalHeaders, ok := userAttributes["additionalHeaders"]; ok {
a.log.WithField("headers", additionalHeaders).Trace("setting additional headers")
if additionalHeaders == nil {
return headers
}
for key, value := range additionalHeaders.(map[string]any) {
headers[key] = toString(value)
}
}
return headers
}
// Attempt to set basic auth based on user's attributes
func (a *Application) setAuthorizationHeader(c *types.Claims) string {
if !*a.proxyConfig.BasicAuthEnabled {
return ""
}
userAttributes := c.Proxy.UserAttributes
var ok bool
var username string
var password string
if password, ok = userAttributes[*a.proxyConfig.BasicAuthPasswordAttribute].(string); !ok {
password = ""
}
// Check if we should use email or a custom attribute as username
if username, ok = userAttributes[*a.proxyConfig.BasicAuthUserAttribute].(string); !ok {
username = c.Email
}
if password == "" {
return ""
}
authVal := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
a.log.WithField("username", username).Trace("setting http basic auth")
return fmt.Sprintf("Basic %s", authVal)
}
// getTraefikForwardUrl See https://doc.traefik.io/traefik/middlewares/forwardauth/
func (a *Application) getTraefikForwardUrl(r *http.Request) (*url.URL, error) {
u, err := url.Parse(fmt.Sprintf(
"%s://%s%s",
r.Header.Get("X-Forwarded-Proto"),
r.Header.Get("X-Forwarded-Host"),
r.Header.Get("X-Forwarded-Uri"),
))
if err != nil {
return nil, err
}
a.log.WithField("url", u.String()).Trace("traefik forwarded url")
return u, nil
}
// getNginxForwardUrl See https://github.com/kubernetes/ingress-nginx/blob/main/rootfs/etc/nginx/template/nginx.tmpl
func (a *Application) getNginxForwardUrl(r *http.Request) (*url.URL, error) {
h := r.Header.Get("X-Original-URL")
if len(h) < 1 {
return nil, errors.New("no forward URL found")
}
u, err := url.Parse(h)
if err != nil {
a.log.WithError(err).Warning("failed to parse URL from nginx")
return nil, err
}
a.log.WithField("url", u.String()).Trace("nginx forwarded url")
return u, nil
}
func (a *Application) ReportMisconfiguration(r *http.Request, msg string, fields map[string]any) {
fields["message"] = msg
a.log.WithFields(fields).Error("Reporting configuration error")
req := api.EventRequest{
Action: api.EVENTACTIONS_CONFIGURATION_ERROR,
App: "authentik.providers.proxy", // must match python apps.py name
ClientIp: *api.NewNullableString(new(r.RemoteAddr)),
Context: fields,
}
_, _, err := a.ak.Client.EventsAPI.EventsEventsCreate(context.Background()).EventRequest(req).Execute()
if err != nil {
a.log.WithError(err).Warning("failed to report configuration error")
}
}
func (a *Application) IsAllowlisted(u *url.URL) bool {
for _, ur := range a.UnauthenticatedRegex {
var testString string
if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE {
testString = u.Path
} else {
testString = u.String()
}
match := ur.MatchString(testString)
a.log.WithField("match", match).WithField("regex", ur.String()).WithField("url", testString).Trace("Matching URL against allow list")
if match {
return true
}
}
return false
}

View File

@@ -1,185 +0,0 @@
package application
import (
"net/http"
"net/url"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
api "goauthentik.io/packages/client-go"
)
func urlMustParse(u string) *url.URL {
ur, err := url.Parse(u)
if err != nil {
panic(err)
}
return ur
}
func TestIsAllowlisted_Proxy_Single(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_PROXY.Ptr()
assert.Equal(t, false, a.IsAllowlisted(urlMustParse("")))
a.UnauthenticatedRegex = []*regexp.Regexp{
regexp.MustCompile("^/foo"),
}
assert.Equal(t, true, a.IsAllowlisted(urlMustParse("http://some-host/foo")))
}
func TestIsAllowlisted_Proxy_Domain(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
assert.Equal(t, false, a.IsAllowlisted(urlMustParse("")))
a.UnauthenticatedRegex = []*regexp.Regexp{
regexp.MustCompile("^/foo"),
}
assert.Equal(t, false, a.IsAllowlisted(urlMustParse("http://some-host/foo")))
a.UnauthenticatedRegex = []*regexp.Regexp{
regexp.MustCompile("^http://some-host/foo"),
}
assert.Equal(t, true, a.IsAllowlisted(urlMustParse("http://some-host/foo")))
a.UnauthenticatedRegex = []*regexp.Regexp{
regexp.MustCompile("https://health.domain.tld/ping/*"),
}
assert.Equal(t, false, a.IsAllowlisted(urlMustParse("http://some-host/foo")))
assert.Equal(t, false, a.IsAllowlisted(urlMustParse("https://health.domain.tld/")))
assert.Equal(t, true, a.IsAllowlisted(urlMustParse("https://health.domain.tld/ping/qq")))
}
func TestAdHeaders_Standard(t *testing.T) {
a := newTestApplication()
h := http.Header{}
a.addHeaders(h, &types.Claims{
PreferredUsername: "foo",
Groups: []string{"foo", "bar"},
Entitlements: []string{"bar", "quox"},
Email: "bar@authentik.company",
Name: "foo",
Sub: "bar",
RawToken: "baz",
})
assert.Equal(t, http.Header{
"X-Authentik-Email": []string{"bar@authentik.company"},
"X-Authentik-Entitlements": []string{"bar|quox"},
"X-Authentik-Groups": []string{"foo|bar"},
"X-Authentik-Jwt": []string{"baz"},
"X-Authentik-Meta-App": []string{""},
"X-Authentik-Meta-Jwks": []string{""},
"X-Authentik-Meta-Outpost": []string{""},
"X-Authentik-Meta-Provider": []string{a.proxyConfig.Name},
"X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()},
"X-Authentik-Name": []string{"foo"},
"X-Authentik-Uid": []string{"bar"},
"X-Authentik-Username": []string{"foo"},
}, h)
}
func TestAdHeaders_BasicAuth(t *testing.T) {
a := newTestApplication()
a.proxyConfig.BasicAuthEnabled = new(true)
a.proxyConfig.BasicAuthUserAttribute = new("user")
a.proxyConfig.BasicAuthPasswordAttribute = new("pass")
h := http.Header{}
a.addHeaders(h, &types.Claims{
PreferredUsername: "foo",
Groups: []string{"foo", "bar"},
Entitlements: []string{"bar", "quox"},
Email: "bar@authentik.company",
Name: "foo",
Sub: "bar",
RawToken: "baz",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"user": "foo",
"pass": "baz",
},
},
})
assert.Equal(t, http.Header{
"Authorization": []string{"Basic Zm9vOmJheg=="},
"X-Authentik-Email": []string{"bar@authentik.company"},
"X-Authentik-Entitlements": []string{"bar|quox"},
"X-Authentik-Groups": []string{"foo|bar"},
"X-Authentik-Jwt": []string{"baz"},
"X-Authentik-Meta-App": []string{""},
"X-Authentik-Meta-Jwks": []string{""},
"X-Authentik-Meta-Outpost": []string{""},
"X-Authentik-Meta-Provider": []string{a.proxyConfig.Name},
"X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()},
"X-Authentik-Name": []string{"foo"},
"X-Authentik-Uid": []string{"bar"},
"X-Authentik-Username": []string{"foo"},
}, h)
}
func TestAdHeaders_Extra(t *testing.T) {
a := newTestApplication()
h := http.Header{}
a.addHeaders(h, &types.Claims{
PreferredUsername: "foo",
Groups: []string{"foo", "bar"},
Entitlements: []string{"bar", "quox"},
Email: "bar@authentik.company",
Name: "foo",
Sub: "bar",
RawToken: "baz",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"additionalHeaders": map[string]any{
"foo": "bar",
},
},
},
})
assert.Equal(t, http.Header{
"Foo": []string{"bar"},
"X-Authentik-Email": []string{"bar@authentik.company"},
"X-Authentik-Entitlements": []string{"bar|quox"},
"X-Authentik-Groups": []string{"foo|bar"},
"X-Authentik-Jwt": []string{"baz"},
"X-Authentik-Meta-App": []string{""},
"X-Authentik-Meta-Jwks": []string{""},
"X-Authentik-Meta-Outpost": []string{""},
"X-Authentik-Meta-Provider": []string{a.proxyConfig.Name},
"X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()},
"X-Authentik-Name": []string{"foo"},
"X-Authentik-Uid": []string{"bar"},
"X-Authentik-Username": []string{"foo"},
}, h)
}
func TestAdHeaders_UnderscoreInitial(t *testing.T) {
a := newTestApplication()
h := http.Header{}
h.Set("X_AUTHENTIK_USERNAME", "another user")
h.Set("X-Authentik_username", "another user")
a.addHeaders(h, &types.Claims{
PreferredUsername: "foo",
Groups: []string{"foo", "bar"},
Entitlements: []string{"bar", "quox"},
Email: "bar@authentik.company",
Name: "foo",
Sub: "bar",
RawToken: "baz",
})
assert.Equal(t, http.Header{
"X-Authentik-Email": []string{"bar@authentik.company"},
"X-Authentik-Entitlements": []string{"bar|quox"},
"X-Authentik-Groups": []string{"foo|bar"},
"X-Authentik-Jwt": []string{"baz"},
"X-Authentik-Meta-App": []string{""},
"X-Authentik-Meta-Jwks": []string{""},
"X-Authentik-Meta-Outpost": []string{""},
"X-Authentik-Meta-Provider": []string{a.proxyConfig.Name},
"X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()},
"X-Authentik-Name": []string{"foo"},
"X-Authentik-Uid": []string{"bar"},
"X-Authentik-Username": []string{"foo"},
}, h)
}

View File

@@ -1,177 +0,0 @@
package application
import (
"fmt"
"net/http"
"strings"
"goauthentik.io/internal/outpost/proxyv2/constants"
)
const (
envoyPrefix = "/outpost.goauthentik.io/auth/envoy"
caddyPrefix = "/outpost.goauthentik.io/auth/caddy"
traefikPrefix = "/outpost.goauthentik.io/auth/traefik"
nginxPrefix = "/outpost.goauthentik.io/auth/nginx"
)
func (a *Application) configureForward() error {
a.mux.HandleFunc(traefikPrefix, a.forwardHandleTraefik)
a.mux.HandleFunc(caddyPrefix, a.forwardHandleCaddy)
a.mux.HandleFunc(nginxPrefix, a.forwardHandleNginx)
a.mux.PathPrefix(envoyPrefix).HandlerFunc(a.forwardHandleEnvoy)
return nil
}
func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Request) {
a.log.WithField("header", r.Header).Trace("tracing headers for debug")
// First check if we've got everything we need
fwd, err := a.getTraefikForwardUrl(r)
if err != nil {
a.ReportMisconfiguration(r, fmt.Sprintf("Outpost %s (Provider %s) failed to detect a forward URL from Traefik", a.outpostName, a.proxyConfig.Name), map[string]any{
"provider": a.proxyConfig.Name,
"outpost": a.outpostName,
"url": r.URL.String(),
"headers": cleanseHeaders(r.Header),
})
http.Error(rw, "configuration error", http.StatusInternalServerError)
return
}
tr := r.Clone(r.Context())
tr.URL = fwd
if strings.EqualFold(fwd.Query().Get(CallbackSignature), "true") {
a.log.Debug("handling OAuth Callback from querystring signature")
a.handleAuthCallback(rw, tr)
return
} else if strings.EqualFold(fwd.Query().Get(LogoutSignature), "true") {
a.log.Debug("handling OAuth Logout from querystring signature")
a.handleSignOut(rw, r)
return
}
// Check if we're authenticated, or the request path is on the allowlist
claims, err := a.checkAuth(rw, r)
if claims != nil && err == nil {
a.addHeaders(rw.Header(), claims)
rw.Header().Set("User-Agent", r.Header.Get("User-Agent"))
a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth")
return
} else if claims == nil && a.IsAllowlisted(fwd) {
a.log.Trace("path can be accessed without authentication")
return
}
// set the redirect flag to the current URL we have, since we redirect
// to a (possibly) different domain, but we want to be redirected back
// to the application
// X-Forwarded-Uri is only the path, so we need to build the entire URL
a.handleAuthStart(rw, r, fwd.String())
}
func (a *Application) forwardHandleCaddy(rw http.ResponseWriter, r *http.Request) {
a.log.WithField("header", r.Header).Trace("tracing headers for debug")
// First check if we've got everything we need
fwd, err := a.getTraefikForwardUrl(r)
if err != nil {
a.ReportMisconfiguration(r, fmt.Sprintf("Outpost %s (Provider %s) failed to detect a forward URL from Caddy", a.outpostName, a.proxyConfig.Name), map[string]any{
"provider": a.proxyConfig.Name,
"outpost": a.outpostName,
"url": r.URL.String(),
"headers": cleanseHeaders(r.Header),
})
http.Error(rw, "configuration error", http.StatusInternalServerError)
return
}
tr := r.Clone(r.Context())
tr.URL = fwd
if strings.EqualFold(fwd.Query().Get(CallbackSignature), "true") {
a.log.Debug("handling OAuth Callback from querystring signature")
a.handleAuthCallback(rw, tr)
return
} else if strings.EqualFold(fwd.Query().Get(LogoutSignature), "true") {
a.log.Debug("handling OAuth Logout from querystring signature")
a.handleSignOut(rw, r)
return
}
// Check if we're authenticated, or the request path is on the allowlist
claims, err := a.checkAuth(rw, r)
if claims != nil && err == nil {
a.addHeaders(rw.Header(), claims)
rw.Header().Set("User-Agent", r.Header.Get("User-Agent"))
a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth")
return
} else if claims == nil && a.IsAllowlisted(fwd) {
a.log.Trace("path can be accessed without authentication")
return
}
// set the redirect flag to the current URL we have, since we redirect
// to a (possibly) different domain, but we want to be redirected back
// to the application
// X-Forwarded-Uri is only the path, so we need to build the entire URL
a.handleAuthStart(rw, r, fwd.String())
}
func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request) {
a.log.WithField("header", r.Header).Trace("tracing headers for debug")
fwd, err := a.getNginxForwardUrl(r)
if err != nil {
a.ReportMisconfiguration(r, fmt.Sprintf("Outpost %s (Provider %s) failed to detect a forward URL from nginx", a.outpostName, a.proxyConfig.Name), map[string]any{
"provider": a.proxyConfig.Name,
"outpost": a.outpostName,
"url": r.URL.String(),
"headers": cleanseHeaders(r.Header),
})
http.Error(rw, "configuration error", http.StatusInternalServerError)
return
}
claims, err := a.checkAuth(rw, r)
if claims != nil && err == nil {
a.addHeaders(rw.Header(), claims)
rw.Header().Set("User-Agent", r.Header.Get("User-Agent"))
rw.WriteHeader(200)
a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth")
return
} else if claims == nil && a.IsAllowlisted(fwd) {
a.log.Trace("path can be accessed without authentication")
return
}
s, _ := a.sessions.Get(r, a.SessionName())
if _, redirectSet := s.Values[constants.SessionRedirect]; !redirectSet {
s.Values[constants.SessionRedirect] = fwd.String()
err = s.Save(r, rw)
if err != nil {
a.log.WithError(err).Warning("failed to save session before redirect")
}
}
if fwd.String() != r.URL.String() {
if strings.HasPrefix(fwd.Path, "/outpost.goauthentik.io") {
a.log.WithField("url", r.URL.String()).Trace("path begins with /outpost.goauthentik.io, allowing access")
return
}
}
http.Error(rw, "unauthorized request", http.StatusUnauthorized)
}
func (a *Application) forwardHandleEnvoy(rw http.ResponseWriter, r *http.Request) {
a.log.WithField("header", r.Header).Trace("tracing headers for debug")
r.URL.Path = strings.TrimPrefix(r.URL.Path, envoyPrefix)
r.URL.Host = r.Host
fwd := r.URL
// Check if we're authenticated, or the request path is on the allowlist
claims, err := a.checkAuth(rw, r)
if claims != nil && err == nil {
a.addHeaders(rw.Header(), claims)
rw.Header().Set("User-Agent", r.Header.Get("User-Agent"))
a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth")
return
} else if claims == nil && a.IsAllowlisted(fwd) {
a.log.Trace("path can be accessed without authentication")
return
}
// set the redirect flag to the current URL we have, since we redirect
// to a (possibly) different domain, but we want to be redirected back
// to the application
// X-Forwarded-Uri is only the path, so we need to build the entire URL
a.handleAuthStart(rw, r, fwd.String())
}

View File

@@ -1,144 +0,0 @@
package application
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
api "goauthentik.io/packages/client-go"
)
func TestForwardHandleCaddy_Single_Blank(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil)
rr := httptest.NewRecorder()
a.forwardHandleCaddy(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
}
func TestForwardHandleCaddy_Single_Skip(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
req.Header.Set("X-Forwarded-Uri", "/skip")
rr := httptest.NewRecorder()
a.forwardHandleCaddy(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestForwardHandleCaddy_Single_Headers(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
req.Header.Set("X-Forwarded-Uri", "/app")
rr := httptest.NewRecorder()
a.forwardHandleCaddy(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, st := a.assertState(t, req, rr)
shouldUrl := url.Values{
"client_id": []string{*a.proxyConfig.ClientId},
"redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"},
"response_type": []string{"code"},
}
assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String())
assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect)
}
func TestForwardHandleCaddy_Single_Claims(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
req.Header.Set("X-Forwarded-Uri", "/app")
rr := httptest.NewRecorder()
a.forwardHandleCaddy(rr, req)
s, _ := a.sessions.Get(req, a.SessionName())
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"username": "foo",
"password": "bar",
"additionalHeaders": map[string]any{
"foo": "bar",
},
},
},
}
err := a.sessions.Save(req, rr, s)
if err != nil {
panic(err)
}
rr = httptest.NewRecorder()
a.forwardHandleCaddy(rr, req)
h := rr.Result().Header
assert.Equal(t, []string{"Basic Zm9vOmJhcg=="}, h["Authorization"])
assert.Equal(t, []string{"bar"}, h["Foo"])
assert.Equal(t, []string{""}, h["User-Agent"])
assert.Equal(t, []string{""}, h["X-Authentik-Email"])
assert.Equal(t, []string{""}, h["X-Authentik-Groups"])
assert.Equal(t, []string{""}, h["X-Authentik-Jwt"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-App"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Jwks"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Outpost"])
assert.Equal(t, []string{""}, h["X-Authentik-Name"])
assert.Equal(t, []string{"foo"}, h["X-Authentik-Uid"])
assert.Equal(t, []string{""}, h["X-Authentik-Username"])
}
func TestForwardHandleCaddy_Domain_Blank(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.CookieDomain = new("foo")
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil)
rr := httptest.NewRecorder()
a.forwardHandleCaddy(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
}
func TestForwardHandleCaddy_Domain_Header(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.CookieDomain = new("foo")
a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io"
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
req.Header.Set("X-Forwarded-Uri", "/app")
rr := httptest.NewRecorder()
a.forwardHandleCaddy(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, st := a.assertState(t, req, rr)
shouldUrl := url.Values{
"client_id": []string{*a.proxyConfig.ClientId},
"redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"},
"response_type": []string{"code"},
}
assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String())
assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect)
}

View File

@@ -1,113 +0,0 @@
package application
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
api "goauthentik.io/packages/client-go"
)
func TestForwardHandleEnvoy_Single_Skip(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "http://test.goauthentik.io/skip", nil)
rr := httptest.NewRecorder()
a.forwardHandleEnvoy(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestForwardHandleEnvoy_Single_Headers(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "http:///app", nil)
req.Host = "ext.t.goauthentik.io"
rr := httptest.NewRecorder()
a.forwardHandleEnvoy(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, st := a.assertState(t, req, rr)
shouldUrl := url.Values{
"client_id": []string{*a.proxyConfig.ClientId},
"redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"},
"response_type": []string{"code"},
}
assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String())
assert.Equal(t, "http://ext.t.goauthentik.io/app", st.Redirect)
}
func TestForwardHandleEnvoy_Single_Claims(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "http://test.goauthentik.io/app", nil)
rr := httptest.NewRecorder()
a.forwardHandleEnvoy(rr, req)
s, _ := a.sessions.Get(req, a.SessionName())
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"username": "foo",
"password": "bar",
"additionalHeaders": map[string]any{
"foo": "bar",
},
},
},
}
err := a.sessions.Save(req, rr, s)
if err != nil {
panic(err)
}
rr = httptest.NewRecorder()
a.forwardHandleEnvoy(rr, req)
h := rr.Result().Header
assert.Equal(t, []string{"Basic Zm9vOmJhcg=="}, h["Authorization"])
assert.Equal(t, []string{"bar"}, h["Foo"])
assert.Equal(t, []string{""}, h["User-Agent"])
assert.Equal(t, []string{""}, h["X-Authentik-Email"])
assert.Equal(t, []string{""}, h["X-Authentik-Groups"])
assert.Equal(t, []string{""}, h["X-Authentik-Jwt"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-App"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Jwks"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Outpost"])
assert.Equal(t, []string{""}, h["X-Authentik-Name"])
assert.Equal(t, []string{"foo"}, h["X-Authentik-Uid"])
assert.Equal(t, []string{""}, h["X-Authentik-Username"])
}
func TestForwardHandleEnvoy_Domain_Header(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.CookieDomain = new("foo")
a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io"
req, _ := http.NewRequest("GET", "http:///app", nil)
req.Host = "test.goauthentik.io"
rr := httptest.NewRecorder()
a.forwardHandleEnvoy(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, st := a.assertState(t, req, rr)
shouldUrl := url.Values{
"client_id": []string{*a.proxyConfig.ClientId},
"redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"},
"response_type": []string{"code"},
}
assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String())
assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect)
}

View File

@@ -1,75 +0,0 @@
package application
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
api "goauthentik.io/packages/client-go"
)
func TestForwardHandleNginx_Single_Blank(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
rr := httptest.NewRecorder()
a.forwardHandleNginx(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
}
func TestForwardHandleNginx_Single_Skip(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
req.Header.Set("X-Original-URL", "http://test.goauthentik.io/skip")
rr := httptest.NewRecorder()
a.forwardHandleNginx(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestForwardHandleNginx_Single_Headers(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
req.Header.Set("X-Original-URL", "http://test.goauthentik.io/app")
rr := httptest.NewRecorder()
a.forwardHandleNginx(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
s, _ := a.sessions.Get(req, a.SessionName())
assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect])
}
func TestForwardHandleNginx_Domain_Blank(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.CookieDomain = new("foo")
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
rr := httptest.NewRecorder()
a.forwardHandleNginx(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
}
func TestForwardHandleNginx_Domain_Header(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.CookieDomain = new("foo")
a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io"
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
req.Header.Set("X-Original-URL", "http://test.goauthentik.io/app")
rr := httptest.NewRecorder()
a.forwardHandleNginx(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
s, _ := a.sessions.Get(req, a.SessionName())
assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect])
}

View File

@@ -1,144 +0,0 @@
package application
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
api "goauthentik.io/packages/client-go"
)
func TestForwardHandleTraefik_Single_Blank(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
rr := httptest.NewRecorder()
a.forwardHandleTraefik(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
}
func TestForwardHandleTraefik_Single_Skip(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
req.Header.Set("X-Forwarded-Uri", "/skip")
rr := httptest.NewRecorder()
a.forwardHandleTraefik(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestForwardHandleTraefik_Single_Headers(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
req.Header.Set("X-Forwarded-Uri", "/app")
rr := httptest.NewRecorder()
a.forwardHandleTraefik(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, st := a.assertState(t, req, rr)
shouldUrl := url.Values{
"client_id": []string{*a.proxyConfig.ClientId},
"redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"},
"response_type": []string{"code"},
}
assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String())
assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect)
}
func TestForwardHandleTraefik_Single_Claims(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
req.Header.Set("X-Forwarded-Uri", "/app")
rr := httptest.NewRecorder()
a.forwardHandleTraefik(rr, req)
s, _ := a.sessions.Get(req, a.SessionName())
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"username": "foo",
"password": "bar",
"additionalHeaders": map[string]any{
"foo": "bar",
},
},
},
}
err := a.sessions.Save(req, rr, s)
if err != nil {
panic(err)
}
rr = httptest.NewRecorder()
a.forwardHandleTraefik(rr, req)
h := rr.Result().Header
assert.Equal(t, []string{"Basic Zm9vOmJhcg=="}, h["Authorization"])
assert.Equal(t, []string{"bar"}, h["Foo"])
assert.Equal(t, []string{""}, h["User-Agent"])
assert.Equal(t, []string{""}, h["X-Authentik-Email"])
assert.Equal(t, []string{""}, h["X-Authentik-Groups"])
assert.Equal(t, []string{""}, h["X-Authentik-Jwt"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-App"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Jwks"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Outpost"])
assert.Equal(t, []string{""}, h["X-Authentik-Name"])
assert.Equal(t, []string{"foo"}, h["X-Authentik-Uid"])
assert.Equal(t, []string{""}, h["X-Authentik-Username"])
}
func TestForwardHandleTraefik_Domain_Blank(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.CookieDomain = new("foo")
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
rr := httptest.NewRecorder()
a.forwardHandleTraefik(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
}
func TestForwardHandleTraefik_Domain_Header(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.CookieDomain = new("foo")
a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io"
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Header.Set("X-Forwarded-Host", "test.goauthentik.io")
req.Header.Set("X-Forwarded-Uri", "/app")
rr := httptest.NewRecorder()
a.forwardHandleTraefik(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, st := a.assertState(t, req, rr)
shouldUrl := url.Values{
"client_id": []string{*a.proxyConfig.ClientId},
"redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"},
"response_type": []string{"code"},
}
assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String())
assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect)
}

View File

@@ -1,98 +0,0 @@
package application
import (
"context"
"crypto/tls"
"net/http"
"net/http/httputil"
"net/url"
"time"
"github.com/getsentry/sentry-go"
"github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/outpost/proxyv2/metrics"
"goauthentik.io/internal/utils/web"
)
func (a *Application) getUpstreamTransport() http.RoundTripper {
return &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: !*a.proxyConfig.InternalHostSslValidation},
}
}
func (a *Application) configureProxy() error {
// Reverse proxy to the application server
u, err := url.Parse(*a.proxyConfig.InternalHost)
if err != nil {
return err
}
rsp := sentry.StartSpan(context.TODO(), "authentik.outposts.proxy.application_transport")
rp := &httputil.ReverseProxy{
Director: a.proxyModifyRequest(u),
Transport: web.NewTracingTransport(rsp.Context(), a.getUpstreamTransport()),
ErrorHandler: a.newProxyErrorHandler(),
ModifyResponse: a.proxyModifyResponse,
FlushInterval: -1,
}
a.mux.PathPrefix("/").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
defer func() {
err := recover()
if err == nil || err == http.ErrAbortHandler {
return
}
log.WithError(err.(error)).Error("recover in reverse proxy")
}()
claims, err := a.checkAuth(rw, r)
if claims == nil && a.IsAllowlisted(r.URL) {
a.log.Trace("path can be accessed without authentication")
} else if claims == nil && err != nil {
a.log.WithError(err).Trace("no claims")
a.redirectToStart(rw, r)
return
} else {
a.addHeaders(r.Header, claims)
}
before := time.Now()
rp.ServeHTTP(rw, r)
elapsed := time.Since(before)
metrics.UpstreamTiming.With(prometheus.Labels{
"outpost_name": a.outpostName,
"upstream_host": r.URL.Host,
"method": r.Method,
"scheme": r.URL.Scheme,
"host": web.GetHost(r),
}).Observe(float64(elapsed) / float64(time.Second))
})
return nil
}
func (a *Application) proxyModifyRequest(ou *url.URL) func(req *http.Request) {
return func(r *http.Request) {
r.Header.Set("X-Forwarded-Host", r.Host)
r.URL.Scheme = ou.Scheme
r.URL.Host = ou.Host
claims := a.getClaimsFromSession(nil, r)
if claims != nil && claims.Proxy != nil {
if claims.Proxy.BackendOverride != "" {
u, err := url.Parse(claims.Proxy.BackendOverride)
if err != nil {
a.log.WithField("backend_override", claims.Proxy.BackendOverride).WithError(err).Warning("failed parse user backend override")
} else {
r.URL.Scheme = u.Scheme
r.URL.Host = u.Host
}
}
if claims.Proxy.HostHeader != "" {
r.Host = claims.Proxy.HostHeader
}
}
a.log.WithField("upstream_url", r.URL.String()).Trace("final upstream url")
}
}
func (a *Application) proxyModifyResponse(res *http.Response) error {
res.Header.Set("X-Powered-By", "goauthentik.io")
return nil
}

View File

@@ -1,123 +0,0 @@
package application
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
)
func TestProxy_ModifyRequest(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "http://frontend/foo", nil)
u, err := url.Parse("http://backend:8012")
if err != nil {
panic(err)
}
a.proxyModifyRequest(u)(req)
assert.Equal(t, "frontend", req.Header.Get("X-Forwarded-Host"))
assert.Equal(t, "/foo", req.URL.Path)
assert.Equal(t, "backend:8012", req.URL.Host)
assert.Equal(t, "frontend", req.Host)
}
func TestProxy_Redirect(t *testing.T) {
a := newTestApplication()
_ = a.configureProxy()
req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil)
rr := httptest.NewRecorder()
a.mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, _ := rr.Result().Location()
assert.Equal(
t,
"https://ext.t.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Fext.t.goauthentik.io%2Ffoo",
loc.String(),
)
}
func TestProxy_Redirect_Subdirectory(t *testing.T) {
a := newTestApplication()
a.proxyConfig.ExternalHost = a.proxyConfig.ExternalHost + "/subdir"
_ = a.configureProxy()
req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil)
rr := httptest.NewRecorder()
a.mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, _ := rr.Result().Location()
assert.Equal(
t,
"https://ext.t.goauthentik.io/subdir/outpost.goauthentik.io/start?rd=https%3A%2F%2Fext.t.goauthentik.io%2Fsubdir%2Ffoo",
loc.String(),
)
}
func TestProxy_ModifyRequest_Claims(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "http://frontend/foo", nil)
u, err := url.Parse("http://backend:8012")
if err != nil {
panic(err)
}
rr := httptest.NewRecorder()
s, _ := a.sessions.Get(req, a.SessionName())
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
Proxy: &types.ProxyClaims{
BackendOverride: "http://other-backend:8123",
},
}
err = a.sessions.Save(req, rr, s)
if err != nil {
panic(err)
}
a.proxyModifyRequest(u)(req)
assert.Equal(t, "/foo", req.URL.Path)
assert.Equal(t, "other-backend:8123", req.URL.Host)
assert.Equal(t, "frontend", req.Host)
}
func TestProxy_ModifyRequest_Claims_Invalid(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "http://frontend/foo", nil)
u, err := url.Parse("http://backend:8012")
if err != nil {
panic(err)
}
rr := httptest.NewRecorder()
s, _ := a.sessions.Get(req, a.SessionName())
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
Proxy: &types.ProxyClaims{
BackendOverride: ":qewr",
},
}
err = a.sessions.Save(req, rr, s)
if err != nil {
panic(err)
}
a.proxyModifyRequest(u)(req)
assert.Equal(t, "/foo", req.URL.Path)
assert.Equal(t, "backend:8012", req.URL.Host)
assert.Equal(t, "frontend", req.Host)
}

View File

@@ -1,106 +0,0 @@
package application
import (
"context"
"net/http"
"net/url"
"strings"
"goauthentik.io/internal/outpost/proxyv2/constants"
api "goauthentik.io/packages/client-go"
)
const (
redirectParam = "rd"
CallbackSignature = "X-authentik-auth-callback"
LogoutSignature = "X-authentik-logout"
)
func (a *Application) handleAuthStart(rw http.ResponseWriter, r *http.Request, fwd string) {
state, err := a.createState(r, rw, fwd)
if err != nil {
a.log.WithError(err).Warning("failed to create state")
if !strings.HasPrefix(err.Error(), "failed to get session") {
rw.WriteHeader(400)
return
}
// Client has a cookie but we're unable to load the session from
// storage (TMPDIR=/dev/shm). This can happen if the session file
// was deleted due to container restart or session invalidation
// (e.g., logout on auth server).
//
// Re-save an empty session and try again.
session, err := a.sessions.Get(r, a.SessionName())
if err != nil && !strings.HasSuffix(err.Error(), "no such file or directory") {
a.log.WithError(err).Warning("failed to get session")
rw.WriteHeader(400)
return
}
err = a.sessions.Save(r, rw, session)
if err != nil {
a.log.WithError(err).Warning("failed to save session")
rw.WriteHeader(400)
return
}
// The registry caches the previous attempt to open the session so it
// needs to be cleared in order to get the session in createState().
*r = *r.WithContext(context.Background())
state, err = a.createState(r, rw, fwd)
if err != nil {
a.log.WithError(err).Warning("failed to create state on retry")
rw.WriteHeader(400)
return
}
}
http.Redirect(rw, r, a.oauthConfig.AuthCodeURL(state), http.StatusFound)
}
func (a *Application) redirectToStart(rw http.ResponseWriter, r *http.Request) {
s, err := a.sessions.Get(r, a.SessionName())
if err != nil {
a.log.WithError(err).Warning("failed to decode session")
}
if r.Header.Get(constants.HeaderAuthorization) != "" && *a.proxyConfig.InterceptHeaderAuth {
rw.WriteHeader(401)
er := a.errorTemplates.Execute(rw, ErrorPageData{
Title: "Unauthenticated",
Message: "Due to 'Receive header authentication' being set, no redirect is performed.",
ProxyPrefix: "/outpost.goauthentik.io",
})
if er != nil {
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
}
}
redirectUrl := urlJoin(a.proxyConfig.ExternalHost, r.URL.EscapedPath())
if r.URL.RawQuery != "" {
redirectUrl += "?" + r.URL.RawQuery
}
if a.Mode() == api.PROXYMODE_FORWARD_DOMAIN {
dom := strings.TrimPrefix(*a.proxyConfig.CookieDomain, ".")
// In forward_domain we only check that the current URL's host
// ends with the cookie domain (remove the leading period if set)
if !strings.HasSuffix(r.URL.Hostname(), dom) {
a.log.WithField("url", r.URL.String()).WithField("cd", dom).Warning("Invalid redirect found")
redirectUrl = a.proxyConfig.ExternalHost
}
}
if _, redirectSet := s.Values[constants.SessionRedirect]; !redirectSet {
s.Values[constants.SessionRedirect] = redirectUrl
err = s.Save(r, rw)
if err != nil {
a.log.WithError(err).Warning("failed to save session before redirect")
}
}
urlArgs := url.Values{
redirectParam: []string{redirectUrl},
}
authUrl := urlJoin(a.proxyConfig.ExternalHost, "/outpost.goauthentik.io/start")
http.Redirect(rw, r, authUrl+"?"+urlArgs.Encode(), http.StatusFound)
}

View File

@@ -1,75 +0,0 @@
package application
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
"golang.org/x/oauth2"
)
func (a *Application) handleAuthCallback(rw http.ResponseWriter, r *http.Request) {
state := a.stateFromRequest(rw, r)
if state == nil {
a.log.Warning("invalid state")
a.redirect(rw, r)
return
}
claims, err := a.redeemCallback(r.URL, r.Context())
if err != nil {
a.log.WithError(err).Warning("failed to redeem code")
a.redirect(rw, r)
return
}
s, err := a.sessions.Get(r, a.SessionName())
if err != nil {
a.log.WithError(err).Trace("failed to get session")
}
s.Options.MaxAge = int(time.Until(time.Unix(int64(claims.Exp), 0)).Seconds())
s.Values[constants.SessionClaims] = claims
err = s.Save(r, rw)
if err != nil {
a.log.WithError(err).Warning("failed to save session")
rw.WriteHeader(400)
return
}
a.redirect(rw, r)
}
func (a *Application) redeemCallback(u *url.URL, c context.Context) (*types.Claims, error) {
code := u.Query().Get("code")
if code == "" {
return nil, fmt.Errorf("blank code")
}
ctx := context.WithValue(c, oauth2.HTTPClient, a.publicHostHTTPClient)
// Verify state and errors.
oauth2Token, err := a.oauthConfig.Exchange(ctx, code)
if err != nil {
return nil, err
}
jwt := oauth2Token.AccessToken
a.log.WithField("jwt", jwt).Trace("access_token")
// Parse and verify ID Token payload.
idToken, err := a.tokenVerifier.Verify(ctx, jwt)
if err != nil {
return nil, err
}
// Extract custom claims
var claims *types.Claims
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
if claims.Proxy == nil {
claims.Proxy = &types.ProxyClaims{}
}
claims.RawToken = jwt
return claims, nil
}

View File

@@ -1,152 +0,0 @@
package application
import (
"encoding/base32"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/securecookie"
"github.com/mitchellh/mapstructure"
api "goauthentik.io/packages/client-go"
)
type OAuthState struct {
Issuer string `json:"iss" mapstructure:"iss"`
SessionID string `json:"sid" mapstructure:"sid"`
State string `json:"state" mapstructure:"state"`
Redirect string `json:"redirect" mapstructure:"redirect"`
}
func (oas *OAuthState) GetExpirationTime() (*jwt.NumericDate, error) { return nil, nil }
func (oas *OAuthState) GetIssuedAt() (*jwt.NumericDate, error) { return nil, nil }
func (oas *OAuthState) GetNotBefore() (*jwt.NumericDate, error) { return nil, nil }
func (oas *OAuthState) GetIssuer() (string, error) { return oas.Issuer, nil }
func (oas *OAuthState) GetSubject() (string, error) { return oas.State, nil }
func (oas *OAuthState) GetAudience() (jwt.ClaimStrings, error) { return nil, nil }
var base32RawStdEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
// Validate that the given redirect parameter (?rd=...) is valid and can be used
// For proxy/forward_single this checks that if the `rd` param has a Hostname (and is a full URL)
// the hostname matches what's configured, or no hostname must be given
// For forward_domain this checks if the domain of the URL in `rd` ends with the configured domain
func (a *Application) checkRedirectParam(r *http.Request) (string, bool) {
rd := r.URL.Query().Get(redirectParam)
if rd == "" {
return "", false
}
u, err := url.Parse(rd)
if err != nil {
a.log.WithError(err).Warning("Failed to parse redirect URL")
return "", false
}
// Check to make sure we only redirect to allowed places
if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE {
ext, err := url.Parse(a.proxyConfig.ExternalHost)
if err != nil {
return "", false
}
// Either hostname needs to match the configured domain, or host name must be empty for just a path
if u.Host == "" {
u.Host = ext.Host
u.Scheme = ext.Scheme
}
if u.Host != ext.Host {
a.log.WithField("url", u.String()).WithField("ext", ext.String()).Warning("redirect URI did not contain external host")
return "", false
}
} else {
if !strings.HasSuffix(u.Hostname(), *a.proxyConfig.CookieDomain) {
a.log.WithField("host", u.Hostname()).WithField("dom", *a.proxyConfig.CookieDomain).Warning("redirect URI Hostname was not included in cookie domain")
return "", false
}
}
return u.String(), true
}
func (a *Application) createState(r *http.Request, w http.ResponseWriter, fwd string) (string, error) {
s, err := a.sessions.Get(r, a.SessionName())
if err != nil {
// Session file may not exist (e.g., after outpost restart or logout)
// Delete the stale session cookie and continue with the new empty session
a.log.WithError(err).Debug("failed to get session, clearing stale cookie")
s.Options.MaxAge = -1
if saveErr := s.Save(r, w); saveErr != nil {
a.log.WithError(saveErr).Warning("failed to delete stale session cookie")
}
// Get a fresh session after clearing the stale cookie
s, _ = a.sessions.Get(r, a.SessionName())
}
if s.ID == "" {
// Ensure session has an ID
s.ID = base32RawStdEncoding.EncodeToString(securecookie.GenerateRandomKey(32))
// Save the session immediately so it persists
err := s.Save(r, w)
if err != nil {
return "", fmt.Errorf("failed to save session: %w", err)
}
}
st := &OAuthState{
Issuer: fmt.Sprintf("goauthentik.io/outpost/%s", a.proxyConfig.GetClientId()),
State: base64.RawURLEncoding.EncodeToString(securecookie.GenerateRandomKey(32)),
SessionID: s.ID,
Redirect: fwd,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, st)
tokenString, err := token.SignedString([]byte(a.proxyConfig.GetCookieSecret()))
if err != nil {
return "", err
}
return tokenString, nil
}
func (a *Application) stateFromRequest(rw http.ResponseWriter, r *http.Request) *OAuthState {
stateJwt := r.URL.Query().Get("state")
token, err := jwt.Parse(stateJwt, func(token *jwt.Token) (any, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(a.proxyConfig.GetCookieSecret()), nil
})
if err != nil {
a.log.WithError(err).Warning("failed to parse state jwt")
return nil
}
iss, err := token.Claims.GetIssuer()
if err != nil {
a.log.WithError(err).Warning("state jwt without issuer")
return nil
}
if iss != fmt.Sprintf("goauthentik.io/outpost/%s", a.proxyConfig.GetClientId()) {
a.log.WithField("issuer", iss).Warning("invalid state jwt issuer")
return nil
}
claims := &OAuthState{}
err = mapstructure.Decode(token.Claims, &claims)
if err != nil {
a.log.WithError(err).Warning("failed to mapdecode")
return nil
}
s, err := a.sessions.Get(r, a.SessionName())
if err != nil {
a.log.WithError(err).Warning("failed to get session")
// Delete the stale session cookie if it exists
if rw != nil {
s.Options.MaxAge = -1
if saveErr := s.Save(r, rw); saveErr != nil {
a.log.WithError(saveErr).Warning("failed to delete stale session cookie")
}
}
return nil
}
if claims.SessionID != s.ID {
a.log.WithField("is", claims.SessionID).WithField("should", s.ID).Warning("mismatched session ID")
return nil
}
return claims
}

View File

@@ -1,71 +0,0 @@
package application
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
api "goauthentik.io/packages/client-go"
)
func TestCheckRedirectParam_None(t *testing.T) {
a := newTestApplication()
// Test no rd param
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start", nil)
rd, ok := a.checkRedirectParam(req)
assert.Equal(t, false, ok)
assert.Equal(t, "", rd)
}
func TestCheckRedirectParam_Invalid(t *testing.T) {
a := newTestApplication()
// Test invalid rd param
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://google.com", nil)
rd, ok := a.checkRedirectParam(req)
assert.Equal(t, false, ok)
assert.Equal(t, "", rd)
}
func TestCheckRedirectParam_ValidFull(t *testing.T) {
a := newTestApplication()
// Test valid full rd param
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test?foo", nil)
rd, ok := a.checkRedirectParam(req)
assert.Equal(t, true, ok)
assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd)
}
func TestCheckRedirectParam_ValidPartial(t *testing.T) {
a := newTestApplication()
// Test valid partial rd param
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=/test?foo", nil)
rd, ok := a.checkRedirectParam(req)
assert.Equal(t, true, ok)
assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd)
}
func TestCheckRedirectParam_Domain(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.CookieDomain = new("t.goauthentik.io")
req, _ := http.NewRequest("GET", "https://a.t.goauthentik.io/outpost.goauthentik.io/auth/start", nil)
rd, ok := a.checkRedirectParam(req)
assert.Equal(t, false, ok)
assert.Equal(t, "", rd)
req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test", nil)
rd, ok = a.checkRedirectParam(req)
assert.Equal(t, true, ok)
assert.Equal(t, "https://ext.t.goauthentik.io/test", rd)
}

View File

@@ -1,143 +0,0 @@
package application
import (
"context"
"math"
"net/http"
"net/url"
"os"
"path"
"strings"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"goauthentik.io/internal/outpost/proxyv2/codecs"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/filesystemstore"
"goauthentik.io/internal/outpost/proxyv2/postgresstore"
"goauthentik.io/internal/outpost/proxyv2/types"
api "goauthentik.io/packages/client-go"
)
const PostgresKeyPrefix = "authentik_proxy_session_"
func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL) (sessions.Store, error) {
maxAge := 0
if p.AccessTokenValidity.IsSet() {
t := p.AccessTokenValidity.Get()
// Add one to the validity to ensure we don't have a session with indefinite length
maxAge = int(*t) + 1
}
sessionBackend := a.srv.SessionBackend()
switch sessionBackend {
case "postgres":
// New PostgreSQL store
ps, err := postgresstore.NewPostgresStore(a.log)
if err != nil {
return nil, err
}
ps.KeyPrefix(PostgresKeyPrefix)
ps.Options(sessions.Options{
HttpOnly: true,
Secure: strings.ToLower(externalHost.Scheme) == "https",
Domain: *p.CookieDomain,
SameSite: http.SameSiteLaxMode,
MaxAge: maxAge,
Path: "/",
})
return ps, nil
case "filesystem":
dir := os.TempDir()
cs, err := filesystemstore.GetPersistentStore(dir)
if err != nil {
return nil, err
}
cs.Codecs = codecs.CodecsFromPairs(maxAge, []byte(*p.CookieSecret))
// https://github.com/markbates/goth/commit/7276be0fdf719ddff753f3574ef0f967e4a5a5f7
// set the maxLength of the cookies stored on the disk to a larger number to prevent issues with:
// securecookie: the value is too long
// when using OpenID Connect, since this can contain a large amount of extra information in the id_token
// Note, when using the FilesystemStore only the session.ID is written to a browser cookie, so this is explicit for the storage on disk
cs.MaxLength(math.MaxInt)
cs.Options.HttpOnly = true
cs.Options.Secure = strings.ToLower(externalHost.Scheme) == "https"
cs.Options.Domain = *p.CookieDomain
cs.Options.SameSite = http.SameSiteLaxMode
cs.Options.MaxAge = maxAge
cs.Options.Path = "/"
return cs, nil
default:
a.log.WithField("backend", sessionBackend).Panic("unknown session backend type")
return nil, nil
}
}
func (a *Application) SessionName() string {
return a.sessionName
}
func (a *Application) getAllCodecs() []securecookie.Codec {
apps := a.srv.Apps()
cs := []securecookie.Codec{}
for _, app := range apps {
cs = append(cs, codecs.CodecsFromPairs(0, []byte(*app.proxyConfig.CookieSecret))...)
}
return cs
}
func (a *Application) Logout(ctx context.Context, filter func(c types.Claims) bool) error {
if _, ok := a.sessions.(*filesystemstore.Store); ok {
files, err := os.ReadDir(os.TempDir())
if err != nil {
return err
}
for _, file := range files {
s := sessions.Session{}
if !strings.HasPrefix(file.Name(), "session_") {
continue
}
fullPath := path.Join(os.TempDir(), file.Name())
data, err := os.ReadFile(fullPath)
if err != nil {
a.log.WithError(err).Warning("failed to read file")
continue
}
err = securecookie.DecodeMulti(
a.SessionName(), string(data),
&s.Values, a.getAllCodecs()...,
)
if err != nil {
a.log.WithError(err).Trace("failed to decode session")
continue
}
rc, ok := s.Values[constants.SessionClaims]
if !ok || rc == nil {
continue
}
claims := s.Values[constants.SessionClaims].(types.Claims)
if filter(claims) {
a.log.WithField("path", fullPath).Trace("deleting session")
err := os.Remove(fullPath)
if err != nil {
a.log.WithError(err).Warning("failed to delete session")
continue
}
}
}
}
if ps, ok := a.sessions.(*postgresstore.PostgresStore); ok {
err := ps.LogoutSessions(ctx, func(c types.Claims) bool {
return filter(types.Claims(c))
})
if err != nil {
a.log.WithError(err).Warning("failed to logout sessions from PostgreSQL")
return err
}
}
return nil
}

View File

@@ -1,285 +0,0 @@
package application
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"goauthentik.io/internal/config"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/postgresstore"
"goauthentik.io/internal/outpost/proxyv2/types"
)
func SetupTestDB(t *testing.T) (*gorm.DB, *postgresstore.RefreshableConnPool) {
cfg := config.Get().PostgreSQL
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
NowFunc: func() time.Time {
return time.Now().UTC()
},
}
// Use standardized setup
db, pool, err := postgresstore.SetupGORMWithRefreshablePool(cfg, gormConfig, 10, 100, time.Hour)
require.NoError(t, err)
return db, pool
}
func CleanupTestDB(t *testing.T, db *gorm.DB, pool *postgresstore.RefreshableConnPool) {
assert.NoError(t, db.Exec("DELETE FROM authentik_providers_proxy_proxysession").Error)
assert.NoError(t, pool.Close())
}
func NewTestStore(db *gorm.DB, pool *postgresstore.RefreshableConnPool) *postgresstore.PostgresStore {
return postgresstore.NewTestStore(db, pool)
}
func TestPostgresStore_SessionLifecycle(t *testing.T) {
db, pool := SetupTestDB(t)
defer CleanupTestDB(t, db, pool)
// Create sessions directly in the database for testing
userID := uuid.New()
sessionKey := "test_session_" + uuid.New().String()
sessionData := map[string]any{
constants.SessionClaims: map[string]any{
"sub": userID.String(),
"email": "test@example.com",
"preferred_username": "testuser",
"custom_claim": "custom_value",
"groups": []any{"admin", "user"},
},
}
sessionDataJSON, err := json.Marshal(sessionData)
require.NoError(t, err)
session := postgresstore.ProxySession{
UUID: uuid.New(),
SessionKey: sessionKey,
UserID: &userID,
SessionData: string(sessionDataJSON),
Expires: time.Now().Add(time.Hour),
}
err = db.Create(&session).Error
require.NoError(t, err)
// Verify session was created
var count int64
db.Model(&postgresstore.ProxySession{}).Where("session_key = ?", sessionKey).Count(&count)
assert.Equal(t, int64(1), count)
// Verify session data
var retrievedSession postgresstore.ProxySession
err = db.First(&retrievedSession, "session_key = ?", sessionKey).Error
require.NoError(t, err)
assert.Equal(t, userID, *retrievedSession.UserID)
// Parse session data
var parsedData map[string]any
err = json.Unmarshal([]byte(retrievedSession.SessionData), &parsedData)
require.NoError(t, err)
claims, ok := parsedData[constants.SessionClaims].(map[string]any)
assert.True(t, ok)
assert.Equal(t, "test@example.com", claims["email"])
assert.Equal(t, "testuser", claims["preferred_username"])
assert.Equal(t, "custom_value", claims["custom_claim"])
}
func TestPostgresStore_LogoutSessions(t *testing.T) {
db, pool := SetupTestDB(t)
defer CleanupTestDB(t, db, pool)
// Create multiple sessions for different users
user1 := uuid.New()
user2 := uuid.New()
createSessionData := func(userID uuid.UUID, email string) string {
sessionData := map[string]any{
constants.SessionClaims: map[string]any{
"sub": userID.String(),
"email": email,
},
}
sessionDataJSON, _ := json.Marshal(sessionData)
return string(sessionDataJSON)
}
sessions := []postgresstore.ProxySession{
{
UUID: uuid.New(),
SessionKey: "session_user1_1",
UserID: &user1,
SessionData: createSessionData(user1, "user1@example.com"),
Expires: time.Now().Add(time.Hour),
},
{
UUID: uuid.New(),
SessionKey: "session_user1_2",
UserID: &user1,
SessionData: createSessionData(user1, "user1@example.com"),
Expires: time.Now().Add(time.Hour),
},
{
UUID: uuid.New(),
SessionKey: "session_user2_1",
UserID: &user2,
SessionData: createSessionData(user2, "user2@example.com"),
Expires: time.Now().Add(time.Hour),
},
}
for _, session := range sessions {
err := db.Create(&session).Error
require.NoError(t, err)
}
// Verify all sessions were created
var totalCount int64
db.Model(&postgresstore.ProxySession{}).Count(&totalCount)
assert.Equal(t, int64(3), totalCount)
// Logout user1 sessions using LogoutSessions method
store := NewTestStore(db, pool)
err := store.LogoutSessions(context.Background(), func(c types.Claims) bool {
return c.Sub == user1.String()
})
require.NoError(t, err)
// Verify only user2 session remains
var remainingCount int64
db.Model(&postgresstore.ProxySession{}).Count(&remainingCount)
assert.Equal(t, int64(1), remainingCount)
var remainingSession postgresstore.ProxySession
err = db.First(&remainingSession).Error
require.NoError(t, err)
assert.Equal(t, user2, *remainingSession.UserID)
}
func TestPostgresStore_SessionExpiration(t *testing.T) {
db, pool := SetupTestDB(t)
defer CleanupTestDB(t, db, pool)
// Create expired and valid sessions
expiredSession := postgresstore.ProxySession{
UUID: uuid.New(),
SessionKey: "expired_session",
SessionData: "{}",
Expires: time.Now().Add(-time.Hour),
}
validSession := postgresstore.ProxySession{
UUID: uuid.New(),
SessionKey: "valid_session",
SessionData: "{}",
Expires: time.Now().Add(time.Hour),
}
err := db.Create(&expiredSession).Error
require.NoError(t, err)
err = db.Create(&validSession).Error
require.NoError(t, err)
// Clean up expired sessions (this is like what CleanupExpiredSessions would do)
var sessions []postgresstore.ProxySession
err = db.Find(&sessions).Error
require.NoError(t, err)
var expiredKeys []string
now := time.Now()
for _, session := range sessions {
expTime := session.Expires
if now.After(expTime) {
expiredKeys = append(expiredKeys, session.SessionKey)
}
}
result := db.Delete(&postgresstore.ProxySession{}, "session_key IN ?", expiredKeys)
require.NoError(t, result.Error)
assert.Equal(t, int64(1), result.RowsAffected)
// Verify only valid session remains
var count int64
db.Model(&postgresstore.ProxySession{}).Count(&count)
assert.Equal(t, int64(1), count)
var remaining postgresstore.ProxySession
err = db.First(&remaining).Error
require.NoError(t, err)
assert.Equal(t, "valid_session", remaining.SessionKey)
}
func TestPostgresStore_SessionClaims(t *testing.T) {
db, pool := SetupTestDB(t)
defer CleanupTestDB(t, db, pool)
// Create session with complex claims
userID := uuid.New()
sessionData := map[string]any{
constants.SessionClaims: map[string]any{
"sub": userID.String(),
"email": "test@example.com",
"preferred_username": "testuser",
"groups": []any{"admin", "user"},
"entitlements": []any{"read", "write"},
"custom_field": "custom_value",
},
}
sessionDataJSON, err := json.Marshal(sessionData)
require.NoError(t, err)
session := postgresstore.ProxySession{
UUID: uuid.New(),
SessionKey: "claims_test_session",
UserID: &userID,
SessionData: string(sessionDataJSON),
Expires: time.Now().Add(time.Hour),
}
err = db.Create(&session).Error
require.NoError(t, err)
// Retrieve and verify claims can be parsed
var retrieved postgresstore.ProxySession
err = db.First(&retrieved, "session_key = ?", "claims_test_session").Error
require.NoError(t, err)
assert.Equal(t, userID, *retrieved.UserID)
// Parse and verify session data
var parsedData map[string]any
err = json.Unmarshal([]byte(retrieved.SessionData), &parsedData)
require.NoError(t, err)
claims, ok := parsedData[constants.SessionClaims].(map[string]any)
assert.True(t, ok)
assert.Equal(t, "test@example.com", claims["email"])
assert.Equal(t, "testuser", claims["preferred_username"])
assert.Equal(t, "custom_value", claims["custom_field"])
// Verify groups array
groups, ok := claims["groups"].([]any)
assert.True(t, ok)
assert.Contains(t, groups, "admin")
assert.Contains(t, groups, "user")
// Verify entitlements array
entitlements, ok := claims["entitlements"].([]any)
assert.True(t, ok)
assert.Contains(t, entitlements, "read")
assert.Contains(t, entitlements, "write")
}

View File

@@ -1,192 +0,0 @@
package application
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
)
func TestLogout(t *testing.T) {
a := newTestApplication()
_ = a.configureProxy()
req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil)
rr := httptest.NewRecorder()
// Login once
s, _ := a.sessions.Get(req, a.SessionName())
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
}
err := a.sessions.Save(req, rr, s)
if err != nil {
panic(err)
}
a.mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadGateway, rr.Code)
// Login twice
s2, _ := a.sessions.Get(req, a.SessionName())
s2.ID = uuid.New().String()
s2.Options.MaxAge = 86400
s2.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
}
err = a.sessions.Save(req, rr, s2)
if err != nil {
panic(err)
}
a.mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadGateway, rr.Code)
// Logout
req, _ = http.NewRequest("GET", "https://ext.t.goauthentik.io/outpost.goauthentik.io/sign_out", nil)
s3, _ := a.sessions.Get(req, a.SessionName())
s3.ID = uuid.New().String()
s3.Options.MaxAge = 86400
s3.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
}
err = a.sessions.Save(req, rr, s3)
if err != nil {
panic(err)
}
rr = httptest.NewRecorder()
a.handleSignOut(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
s1Name := filepath.Join(os.TempDir(), "session_"+s.ID)
_, err = os.Stat(s1Name)
assert.True(t, errors.Is(err, os.ErrNotExist))
s2Name := filepath.Join(os.TempDir(), "session_"+s2.ID)
_, err = os.Stat(s2Name)
assert.True(t, errors.Is(err, os.ErrNotExist))
}
func TestStaleCookieDeletion(t *testing.T) {
a := newTestApplication()
_ = a.configureProxy()
// Create a request with a session cookie that references a non-existent session file
req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil)
// Set a cookie for a session that doesn't exist (simulates pod restart)
nonExistentSessionID := uuid.New().String()
req.AddCookie(&http.Cookie{
Name: a.SessionName(),
Value: "encoded_session_data_" + nonExistentSessionID,
Path: "/",
})
rr := httptest.NewRecorder()
// Call getClaimsFromSession which should delete the stale cookie
claims := a.getClaimsFromSession(rr, req)
// Verify no claims were returned (session doesn't exist)
assert.Nil(t, claims)
// Verify the response includes a Set-Cookie header to delete the stale cookie
cookies := rr.Result().Cookies()
var foundDeleteCookie bool
for _, cookie := range cookies {
if cookie.Name == a.SessionName() && cookie.MaxAge < 0 {
foundDeleteCookie = true
break
}
}
assert.True(t, foundDeleteCookie, "Expected stale session cookie to be deleted")
}
func TestStateFromRequestDeletesStaleCookie(t *testing.T) {
a := newTestApplication()
_ = a.configureProxy()
// Create a valid state JWT (from createState)
req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil)
rr := httptest.NewRecorder()
state, err := a.createState(req, rr, "/redirect")
assert.NoError(t, err)
// Create a new request with the state but a stale session cookie
req2, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/callback?state="+state, nil)
// Add a cookie for a non-existent session
nonExistentSessionID := uuid.New().String()
req2.AddCookie(&http.Cookie{
Name: a.SessionName(),
Value: "encoded_session_data_" + nonExistentSessionID,
Path: "/",
})
rr2 := httptest.NewRecorder()
// Call stateFromRequest which should fail due to missing session
// but should also delete the stale cookie
claims := a.stateFromRequest(rr2, req2)
// Verify no claims were returned
assert.Nil(t, claims)
// Verify the response includes a Set-Cookie header to delete the stale cookie
cookies := rr2.Result().Cookies()
var foundDeleteCookie bool
for _, cookie := range cookies {
if cookie.Name == a.SessionName() && cookie.MaxAge < 0 {
foundDeleteCookie = true
break
}
}
assert.True(t, foundDeleteCookie, "Expected stale session cookie to be deleted")
}
func TestCreateStateWithStaleCookie(t *testing.T) {
a := newTestApplication()
_ = a.configureProxy()
// Create a request with a stale session cookie (simulates outpost restart or user change)
req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/outpost.goauthentik.io/start", nil)
// Add a cookie for a non-existent session
nonExistentSessionID := uuid.New().String()
req.AddCookie(&http.Cookie{
Name: a.SessionName(),
Value: "encoded_session_data_" + nonExistentSessionID,
Path: "/",
})
rr := httptest.NewRecorder()
// Call createState which should succeed despite the stale cookie
state, err := a.createState(req, rr, "/redirect")
// Verify createState succeeded
assert.NoError(t, err)
assert.NotEmpty(t, state)
// Verify the response includes a Set-Cookie header to delete the stale cookie
cookies := rr.Result().Cookies()
var foundDeleteCookie bool
for _, cookie := range cookies {
if cookie.Name == a.SessionName() && cookie.MaxAge < 0 {
foundDeleteCookie = true
break
}
}
assert.True(t, foundDeleteCookie, "Expected stale session cookie to be deleted")
}

View File

@@ -1,99 +0,0 @@
package application
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"goauthentik.io/internal/outpost/ak"
api "goauthentik.io/packages/client-go"
)
type testServer struct {
api *ak.APIController
apps []*Application
}
func newTestServer() *testServer {
return &testServer{
api: ak.MockAK(
api.Outpost{
Config: map[string]any{
"authentik_host": ak.TestSecret(),
},
},
ak.MockConfig(),
),
apps: make([]*Application, 0),
}
}
func (ts *testServer) API() *ak.APIController {
return ts.api
}
func (ts *testServer) CryptoStore() *ak.CryptoStore {
return nil
}
func (ts *testServer) Apps() []*Application {
return ts.apps
}
func (ts *testServer) SessionBackend() string {
return "filesystem"
}
func newTestApplication() *Application {
ts := newTestServer()
a, _ := NewApplication(
api.ProxyOutpostConfig{
Name: ak.TestSecret(),
ClientId: new(ak.TestSecret()),
ClientSecret: new(ak.TestSecret()),
CookieDomain: new(""),
CookieSecret: new(ak.TestSecret()),
ExternalHost: "https://ext.t.goauthentik.io",
InternalHost: new("http://backend"),
InternalHostSslValidation: new(true),
Mode: api.PROXYMODE_FORWARD_SINGLE.Ptr(),
SkipPathRegex: new("/skip.*"),
BasicAuthEnabled: new(true),
BasicAuthUserAttribute: new("username"),
BasicAuthPasswordAttribute: new("password"),
OidcConfiguration: api.OpenIDConnectConfiguration{
AuthorizationEndpoint: "http://fake-auth.t.goauthentik.io/auth",
TokenEndpoint: "http://fake-auth.t.goauthentik.io/token",
UserinfoEndpoint: "http://fake-auth.t.goauthentik.io/userinfo",
},
},
http.DefaultClient,
ts,
nil,
)
ts.apps = append(ts.apps, a)
return a
}
func (a *Application) assertState(t *testing.T, req *http.Request, response *httptest.ResponseRecorder) (*url.URL, *OAuthState) {
loc, _ := response.Result().Location()
q := loc.Query()
state := q.Get("state")
a.log.WithField("actual", state).Warning("actual state")
// modify request to set state so we can parse it
nr := req.Clone(req.Context())
nrq := nr.URL.Query()
nrq.Set("state", state)
nr.URL.RawQuery = nrq.Encode()
// parse state
parsed := a.stateFromRequest(nil, nr)
if parsed == nil {
panic("Could not parse state")
}
// Remove state from URL
q.Del("state")
loc.RawQuery = q.Encode()
return loc, parsed
}

View File

@@ -1,57 +0,0 @@
package application
import (
"net/http"
"net/url"
"slices"
"strconv"
)
func urlJoin(originalUrl string, newPath string) string {
u, err := url.JoinPath(originalUrl, newPath)
if err != nil {
return originalUrl
}
return u
}
func (a *Application) redirect(rw http.ResponseWriter, r *http.Request) {
fallbackRedirect := a.proxyConfig.ExternalHost
state := a.stateFromRequest(rw, r)
if state == nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
if state.Redirect == "" {
state.Redirect = fallbackRedirect
}
a.log.WithField("redirect", state.Redirect).Trace("final redirect")
http.Redirect(rw, r, state.Redirect, http.StatusFound)
}
// toString Generic to string function, currently supports actual strings and integers
func toString(in any) string {
switch v := in.(type) {
case string:
return v
case *string:
return *v
case int:
return strconv.Itoa(v)
}
return ""
}
func contains(s []string, e string) bool {
return slices.Contains(s, e)
}
func cleanseHeaders(headers http.Header) map[string]string {
h := make(map[string]string)
for hk, hv := range headers {
if len(hv) > 0 {
h[hk] = hv[0]
}
}
return h
}

View File

@@ -1,116 +0,0 @@
package application
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
api "goauthentik.io/packages/client-go"
)
func TestRedirectToStart_Proxy(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_PROXY.Ptr()
a.proxyConfig.ExternalHost = "https://test.goauthentik.io"
req, _ := http.NewRequest("GET", "/foo/bar/baz", nil)
rr := httptest.NewRecorder()
a.redirectToStart(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, _ := rr.Result().Location()
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io%2Ffoo%2Fbar%2Fbaz", loc.String())
s, _ := a.sessions.Get(req, a.SessionName())
assert.Equal(t, "https://test.goauthentik.io/foo/bar/baz", s.Values[constants.SessionRedirect])
}
func TestRedirectToStart_Proxy_Query(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_PROXY.Ptr()
a.proxyConfig.ExternalHost = "https://test.goauthentik.io"
req, _ := http.NewRequest("GET", "/foo/bar/baz?foo=bar&baz=qux", nil)
rr := httptest.NewRecorder()
a.redirectToStart(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, _ := rr.Result().Location()
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io%2Ffoo%2Fbar%2Fbaz%3Ffoo%3Dbar%26baz%3Dqux", loc.String())
s, _ := a.sessions.Get(req, a.SessionName())
assert.Equal(t, "https://test.goauthentik.io/foo/bar/baz?foo=bar&baz=qux", s.Values[constants.SessionRedirect])
}
func TestRedirectToStart_Proxy_EncodedSlash(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_PROXY.Ptr()
a.proxyConfig.ExternalHost = "https://test.goauthentik.io"
// %2F is a URL-encoded forward slash, used by apps like RabbitMQ in queue paths
req, _ := http.NewRequest("GET", "/api/queues/%2F/MYChannelCreated", nil)
rr := httptest.NewRecorder()
a.redirectToStart(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, _ := rr.Result().Location()
assert.Contains(t, loc.String(), "%252F", "encoded slash %2F must be preserved in redirect URL")
s, _ := a.sessions.Get(req, a.SessionName())
assert.Contains(t, s.Values[constants.SessionRedirect].(string), "%2F", "encoded slash %2F must be preserved in session redirect")
}
func TestRedirectToStart_Forward(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_SINGLE.Ptr()
a.proxyConfig.ExternalHost = "https://test.goauthentik.io"
req, _ := http.NewRequest("GET", "/foo/bar/baz", nil)
rr := httptest.NewRecorder()
a.redirectToStart(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, _ := rr.Result().Location()
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io%2Ffoo%2Fbar%2Fbaz", loc.String())
s, _ := a.sessions.Get(req, a.SessionName())
assert.Equal(t, "https://test.goauthentik.io/foo/bar/baz", s.Values[constants.SessionRedirect])
}
func TestRedirectToStart_Forward_Domain_Invalid(t *testing.T) {
a := newTestApplication()
a.proxyConfig.CookieDomain = new("foo")
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.ExternalHost = "https://test.goauthentik.io"
req, _ := http.NewRequest("GET", "/foo/bar/baz", nil)
rr := httptest.NewRecorder()
a.redirectToStart(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, _ := rr.Result().Location()
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io", loc.String())
s, _ := a.sessions.Get(req, a.SessionName())
assert.Equal(t, "https://test.goauthentik.io", s.Values[constants.SessionRedirect])
}
func TestRedirectToStart_Forward_Domain(t *testing.T) {
a := newTestApplication()
a.proxyConfig.CookieDomain = new("goauthentik.io")
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
a.proxyConfig.ExternalHost = "https://test.goauthentik.io"
req, _ := http.NewRequest("GET", "/foo/bar/baz", nil)
rr := httptest.NewRecorder()
a.redirectToStart(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
loc, _ := rr.Result().Location()
assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io", loc.String())
s, _ := a.sessions.Get(req, a.SessionName())
assert.Equal(t, "https://test.goauthentik.io", s.Values[constants.SessionRedirect])
}

View File

@@ -1,43 +0,0 @@
package codecs
import (
"math"
"github.com/gorilla/securecookie"
log "github.com/sirupsen/logrus"
)
type Codec struct {
*securecookie.SecureCookie
}
func New(maxAge int, hashKey, blockKey []byte) *Codec {
cookie := securecookie.New(hashKey, blockKey)
cookie.MaxAge(maxAge)
cookie.MaxLength(math.MaxInt)
return &Codec{
SecureCookie: cookie,
}
}
func CodecsFromPairs(maxAge int, keyPairs ...[]byte) []securecookie.Codec {
codecs := make([]securecookie.Codec, len(keyPairs)/2+len(keyPairs)%2)
for i := 0; i < len(keyPairs); i += 2 {
var blockKey []byte
if i+1 < len(keyPairs) {
blockKey = keyPairs[i+1]
}
codecs[i/2] = New(maxAge, keyPairs[i], blockKey)
}
return codecs
}
func (s *Codec) Encode(name string, value any) (string, error) {
log.Trace("cookie encode")
return s.SecureCookie.Encode("authentik_proxy", value)
}
func (s *Codec) Decode(name string, value string, dst any) error {
log.Trace("cookie decode")
return s.SecureCookie.Decode("authentik_proxy", value, dst)
}

View File

@@ -1,12 +0,0 @@
package constants
const (
SessionOAuthState = "oauth_state"
SessionClaims = "claims"
)
const SessionRedirect = "redirect"
const HeaderAuthorization = "Authorization"
const AuthBearer = "Bearer "

View File

@@ -1,206 +0,0 @@
package filesystemstore
import (
"context"
"errors"
"os"
"path"
"strings"
"sync"
"syscall"
"time"
"github.com/gorilla/sessions"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/outpost/proxyv2/sessionstore"
)
const (
SessionCleanupInterval = 5 * time.Minute
SessionCleanupLockFileName = "session-cleanup.lock"
SessionFilePrefix = "session_"
SessionTestFile = SessionFilePrefix + "write_test"
)
var (
ErrSessionCleanupAlreadyRunning = errors.New("session cleanup is already running by another instance")
ErrSessionStoreNoPermission = errors.New("path is not writable")
ErrSessionStorePathNotExist = errors.New("path does not exist")
)
type Store struct {
*sessions.FilesystemStore
storePath string
log *log.Entry
cleanupManager *sessionstore.CleanupManager
}
// NewStore checks if the specified store path exists, is writable and creates a new filesystem session store.
func NewStore(storePath string, keyPairs ...[]byte) (*Store, error) {
if storePath == "" {
storePath = os.TempDir()
}
// check if path exists
_, err := os.ReadDir(storePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrSessionStorePathNotExist
}
return nil, err
}
// check if path is writable
testPath := path.Join(storePath, SessionTestFile)
testFile, err := os.OpenFile(testPath, os.O_CREATE, 0600)
if err != nil {
if errors.Is(err, os.ErrPermission) {
return nil, ErrSessionStoreNoPermission
}
return nil, err
}
if err = testFile.Close(); err != nil {
return nil, err
}
if err = os.Remove(testPath); err != nil {
return nil, err
}
store := &Store{
FilesystemStore: sessions.NewFilesystemStore(storePath, keyPairs...),
storePath: storePath,
log: log.WithField("logger", "authentik.outpost.proxyv2.filesystemstore"),
}
return store, nil
}
// CleanupExpired implements the CleanupStore interface for use with CleanupManager
func (s *Store) CleanupExpired(ctx context.Context) error {
return s.SessionCleanup(ctx)
}
// SessionCleanup acquires a file lock to ensure only one instance runs at a time,
// then checks and deletes expired session files from the filesystem session store.
// It supports context-based cancellation to allow graceful shutdowns or timeouts.
func (s *Store) SessionCleanup(ctx context.Context) error {
s.log.Info("Starting session cleanup")
lockPath := path.Join(s.storePath, SessionCleanupLockFileName)
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return err
}
defer func() {
if closeErr := lockFile.Close(); closeErr != nil {
s.log.WithError(closeErr).Warn("failed to close lock file")
}
}()
err = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil {
if errno, ok := err.(syscall.Errno); ok && errno == syscall.EWOULDBLOCK {
return ErrSessionCleanupAlreadyRunning
}
return err
}
defer func() {
if flockErr := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); flockErr != nil {
s.log.WithError(flockErr).Warn("failed to unlock file")
}
if removeErr := os.Remove(lockPath); removeErr != nil {
s.log.WithError(removeErr).Warn("failed to remove lock file")
}
}()
return s.sessionCleanup(ctx)
}
// sessionCleanup checks the modification time of all session files and removes them
// when they reach the configured maximum age in the session store.
// Since the FilesystemStore from Gorilla does not have a session cleanup function,
// it is only necessary for the filesystem session store.
func (s *Store) sessionCleanup(ctx context.Context) error {
files, err := os.ReadDir(s.storePath)
if err != nil {
return err
}
var errs []error
for _, file := range files {
select {
case <-ctx.Done():
s.log.Warn("session cleanup interrupted during file processing")
return ctx.Err()
default:
}
if !strings.HasPrefix(file.Name(), SessionFilePrefix) {
continue
}
fullPath := path.Join(s.storePath, file.Name())
stat, err := os.Lstat(fullPath)
if err != nil {
s.log.WithError(err).WithField("path", fullPath).Warning("failed to read stats from file")
errs = append(errs, err)
continue
}
modTime := stat.ModTime()
if time.Since(modTime) <= time.Duration(s.Options.MaxAge)*time.Second {
s.log.WithField("max-age", s.Options.MaxAge).WithField("modified", modTime.String()).Debug("session still valid")
continue
}
s.log.WithField("path", fullPath).WithField("modified", modTime.String()).Info("cleanup expired session")
if err = os.Remove(fullPath); err != nil {
s.log.WithError(err).WithField("path", fullPath).Warn("failed to delete session")
errs = append(errs, err)
continue
}
}
return errors.Join(errs...)
}
var (
globalStore *Store
mu sync.Mutex
)
// GetPersistentStore creates a new filesystem store if it is the first time the function has been called,
// or if the path string has changed. It then stores this in the globalStore variable.
// If the function is called multiple times, the store from the variable is returned to ensure that only one instance is running.
func GetPersistentStore(path string) (*Store, error) {
mu.Lock()
defer mu.Unlock()
if globalStore == nil || globalStore.storePath != path {
if globalStore != nil && globalStore.cleanupManager != nil {
globalStore.cleanupManager.Stop()
}
store, err := NewStore(path)
if err != nil {
return nil, err
}
globalStore = store
// Initialize cleanup manager
globalStore.cleanupManager = sessionstore.NewCleanupManager(
globalStore,
globalStore.log,
)
globalStore.cleanupManager.Start()
}
return globalStore, nil
}
// StopPersistentStore stops the cleanup background job and clears the globalStore variable.
func StopPersistentStore() {
mu.Lock()
defer mu.Unlock()
if globalStore != nil && globalStore.cleanupManager != nil {
globalStore.cleanupManager.Stop()
}
globalStore = nil
}

View File

@@ -1,146 +0,0 @@
package filesystemstore
import (
"context"
"os"
"path"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createTempSessionFile(t *testing.T, dir string, modTime time.Time) string {
t.Helper()
path := filepath.Join(dir, "session_test")
err := os.WriteFile(path, []byte("session data"), 0600)
require.NoError(t, err)
err = os.Chtimes(path, modTime, modTime)
require.NoError(t, err)
return path
}
func TestNewStore_PathNotExist(t *testing.T) {
_, err := NewStore("/invalid_path")
assert.ErrorIs(t, err, ErrSessionStorePathNotExist)
}
func TestNewStore_PathNotWritable(t *testing.T) {
storePath := path.Join(os.TempDir(), "test")
err := os.Mkdir(storePath, 0400)
require.NoError(t, err)
_, err = NewStore(storePath)
assert.ErrorIs(t, err, ErrSessionStoreNoPermission)
_ = os.RemoveAll(storePath)
}
func TestNewStore(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewStore(tmpDir)
assert.NoError(t, err)
assert.NotEmpty(t, store)
}
func TestSessionCleanup_RemovesExpired(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewStore(tmpDir)
require.NoError(t, err)
store.Options.MaxAge = 1 // 1 second
// Create an expired session file
oldTime := time.Now().Add(-10 * time.Second)
createTempSessionFile(t, tmpDir, oldTime)
ctx := context.Background()
err = store.SessionCleanup(ctx)
assert.NoError(t, err)
// File should be deleted
files, _ := os.ReadDir(tmpDir)
assert.Empty(t, files)
}
func TestSessionCleanup_PreservesValid(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewStore(tmpDir)
require.NoError(t, err)
store.Options.MaxAge = 3600 // 1 hour
// Create a valid (non-expired) session file
modTime := time.Now().Add(-10 * time.Second)
createTempSessionFile(t, tmpDir, modTime)
ctx := context.Background()
err = store.SessionCleanup(ctx)
assert.NoError(t, err)
// File should still exist
files, _ := os.ReadDir(tmpDir)
assert.Len(t, files, 1)
}
func TestSessionCleanup_ContextCancel(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewStore(tmpDir)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
err = store.SessionCleanup(ctx)
assert.ErrorIs(t, err, context.Canceled)
}
func TestSessionCleanup_AlreadyRunning(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewStore(tmpDir)
require.NoError(t, err)
// Manually acquire the lock before calling SessionCleanup
lockPath := path.Join(tmpDir, SessionCleanupLockFileName)
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
require.NoError(t, err, "failed to create lock file")
err = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
require.NoError(t, err, "failed to acquire lock for test")
// Run SessionCleanup while lock is held
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err = store.SessionCleanup(ctx)
assert.ErrorIs(t, err, ErrSessionCleanupAlreadyRunning)
// Unlock and clean up
_ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
_ = lockFile.Close()
_ = os.Remove(lockPath)
}
func TestPersistentStore_ReusesStore(t *testing.T) {
tmpDir := t.TempDir()
store1, err := GetPersistentStore(tmpDir)
require.NoError(t, err)
assert.NotNil(t, store1)
store2, err := GetPersistentStore(tmpDir)
require.NoError(t, err)
assert.Equal(t, store1, store2)
StopPersistentStore()
}
func TestStopPersistentStore(t *testing.T) {
tmpDir := t.TempDir()
_, err := GetPersistentStore(tmpDir)
require.NoError(t, err)
StopPersistentStore()
// call again should not panic
StopPersistentStore()
}

View File

@@ -1,130 +0,0 @@
package proxyv2
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"goauthentik.io/internal/outpost/proxyv2/application"
"goauthentik.io/internal/outpost/proxyv2/metrics"
sentryutils "goauthentik.io/internal/utils/sentry"
"goauthentik.io/internal/utils/web"
api "goauthentik.io/packages/client-go"
staticWeb "goauthentik.io/web"
)
func (ps *ProxyServer) HandlePing(rw http.ResponseWriter, r *http.Request) {
before := time.Now()
rw.WriteHeader(204)
elapsed := time.Since(before)
metrics.Requests.With(prometheus.Labels{
"outpost_name": ps.akAPI.Outpost.Name,
"method": r.Method,
"host": web.GetHost(r),
"type": "ping",
}).Observe(float64(elapsed) / float64(time.Second))
}
func (ps *ProxyServer) HandleStatic(rw http.ResponseWriter, r *http.Request) {
before := time.Now()
web.DisableIndex(http.StripPrefix("/outpost.goauthentik.io/static/dist", staticWeb.StaticHandler)).ServeHTTP(rw, r)
elapsed := time.Since(before)
metrics.Requests.With(prometheus.Labels{
"outpost_name": ps.akAPI.Outpost.Name,
"method": r.Method,
"host": web.GetHost(r),
"type": "static",
}).Observe(float64(elapsed) / float64(time.Second))
}
func (ps *ProxyServer) lookupApp(r *http.Request) (*application.Application, string) {
host := web.GetHost(r)
// Try to find application by directly looking up host first (proxy, forward_auth_single)
a, ok := ps.apps[host]
if ok {
ps.log.WithField("host", host).WithField("app", a.ProxyConfig().Name).Trace("Found app based direct host match")
return a, host
}
// For forward_auth_domain, we don't have a direct app to domain relationship
// Check through all apps, and check how much of their cookie domain matches the host
// Return the application that has the longest match
var longestMatch *application.Application
longestMatchLength := 0
for _, app := range ps.apps {
if app.Mode() != api.PROXYMODE_FORWARD_DOMAIN {
continue
}
// Check if the cookie domain has a leading period for a wildcard
// This will decrease the weight of a wildcard domain, but a request to example.com
// with the cookie domain set to example.com will still be routed correctly.
cd := strings.TrimPrefix(*app.ProxyConfig().CookieDomain, ".")
if !strings.HasSuffix(host, cd) {
continue
}
if len(cd) < longestMatchLength {
continue
}
longestMatch = app
longestMatchLength = len(cd)
// Also for forward_auth_domain, we need to respond on the external domain
if app.ProxyConfig().ExternalHost == host {
ps.log.WithField("host", host).WithField("app", app.ProxyConfig().Name).Debug("Found app based on external_host")
return app, host
}
}
// Check if our longes match is 0, in which case we didn't match, so we
// manually return no app
if longestMatchLength == 0 {
return nil, host
}
ps.log.WithField("host", host).WithField("app", longestMatch.ProxyConfig().Name).Debug("Found app based on cookie domain")
return longestMatch, host
}
func (ps *ProxyServer) Handle(rw http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/static") {
ps.HandleStatic(rw, r)
return
}
if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/ping") {
sentryutils.SentryNoSample(ps.HandlePing)(rw, r)
return
}
a, host := ps.lookupApp(r)
if a == nil {
// If we only have one handler, host name switching doesn't matter
if len(ps.apps) == 1 {
ps.log.WithField("host", host).Trace("passing to single app mux")
for k := range ps.apps {
ps.apps[k].ServeHTTP(rw, r)
return
}
}
ps.log.WithField("headers", r.Header).Trace("tracing headers for no hostname match")
ps.log.WithField("host", host).Warning("no app for hostname")
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadRequest)
j := json.NewEncoder(rw)
j.SetIndent("", "\t")
err := j.Encode(struct {
Message string
Host string
Detail string
}{
Message: "no app for hostname",
Host: host,
Detail: fmt.Sprintf("Check the outpost settings and make sure '%s' is included.", host),
})
if err != nil {
ps.log.WithError(err).Warning("Failed to write error body")
}
return
}
ps.log.WithField("host", host).Trace("passing to application mux")
a.ServeHTTP(rw, r)
}

View File

@@ -1,38 +0,0 @@
package hs256
import (
"context"
"encoding/base64"
"fmt"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type KeySet struct {
m jwt.SigningMethod
secret string
}
func NewKeySet(secret string) *KeySet {
return &KeySet{
m: jwt.SigningMethodHS256,
secret: secret,
}
}
func (ks *KeySet) VerifySignature(ctx context.Context, rawJWT string) ([]byte, error) {
_, err := jwt.Parse(rawJWT, func(token *jwt.Token) (any, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(ks.secret), nil
})
if err != nil {
return nil, err
}
parts := strings.Split(rawJWT, ".")
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
return payload, err
}

View File

@@ -1,17 +0,0 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "authentik_outpost_proxy_request_duration_seconds",
Help: "Proxy request latencies in seconds",
}, []string{"outpost_name", "method", "host", "type"})
UpstreamTiming = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "authentik_outpost_proxy_upstream_response_duration_seconds",
Help: "Proxy upstream response latencies in seconds",
}, []string{"outpost_name", "method", "scheme", "host", "upstream_host"})
)

View File

@@ -1,289 +0,0 @@
package postgresstore
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync"
"time"
"github.com/jackc/pgx/v5/pgconn"
log "github.com/sirupsen/logrus"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"goauthentik.io/internal/config"
)
// RefreshableConnPool wraps sql.DB and refreshes PostgreSQL credentials on authentication errors
// This implements gorm.ConnPool interface to allow credential rotation
type RefreshableConnPool struct {
mu sync.RWMutex
db *sql.DB
log *log.Entry
currentDSN string
gormConfig *gorm.Config
// Connection pool settings (stored for reapplication after reconnection)
maxIdleConns int
maxOpenConns int
connMaxLifetime time.Duration
// Reconnection management
reconnecting sync.Mutex // Prevent concurrent reconnections
}
// NewRefreshableConnPool creates a new connection pool that refreshes credentials from config
func NewRefreshableConnPool(initialDSN string, gormConfig *gorm.Config, maxIdleConns, maxOpenConns int, connMaxLifetime time.Duration) (*RefreshableConnPool, error) {
db, err := sql.Open("pgx", initialDSN)
if err != nil {
return nil, err
}
// Apply connection pool settings
db.SetMaxIdleConns(maxIdleConns)
db.SetMaxOpenConns(maxOpenConns)
db.SetConnMaxLifetime(connMaxLifetime)
pool := &RefreshableConnPool{
db: db,
log: log.WithField("logger", "authentik.outpost.proxyv2.postgresstore.connpool"),
currentDSN: initialDSN,
gormConfig: gormConfig,
maxIdleConns: maxIdleConns,
maxOpenConns: maxOpenConns,
connMaxLifetime: connMaxLifetime,
}
return pool, nil
}
// isAuthError checks if an error is a PostgreSQL authentication error
func isAuthError(err error) bool {
if err == nil {
return false
}
// Unwrap the error to find the underlying pgconn.PgError
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// Check for any PostgreSQL error code in Class 28 (Invalid Authorization Specification)
// See https://www.postgresql.org/docs/current/errcodes-appendix.html
return len(pgErr.Code) >= 2 && pgErr.Code[:2] == "28"
}
return false
}
// refreshCredentials checks if credentials have changed and reconnects if needed
func (p *RefreshableConnPool) refreshCredentials(ctx context.Context) error {
// Prevent concurrent reconnections
p.reconnecting.Lock()
defer p.reconnecting.Unlock()
// Get fresh config
cfg := config.Get().RefreshPostgreSQLConfig()
newDSN, err := BuildDSN(cfg)
if err != nil {
p.log.WithError(err).Warn("Failed to build DSN with refreshed credentials")
return err
}
p.mu.RLock()
dsnChanged := newDSN != p.currentDSN
p.mu.RUnlock()
if !dsnChanged {
p.log.Debug("Credentials unchanged, skipping reconnection")
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
// Double-check after acquiring write lock
if newDSN == p.currentDSN {
return nil
}
p.log.Info("PostgreSQL credentials changed, reconnecting...")
// Open new connection with fresh credentials
newDB, err := sql.Open("pgx", newDSN)
if err != nil {
p.log.WithError(err).Error("Failed to open new database connection with refreshed credentials")
return err
}
// Reapply connection pool settings
newDB.SetMaxIdleConns(p.maxIdleConns)
newDB.SetMaxOpenConns(p.maxOpenConns)
newDB.SetConnMaxLifetime(p.connMaxLifetime)
// Verify the connection works BEFORE closing old connection
if err := newDB.PingContext(ctx); err != nil {
p.log.WithError(err).Error("Failed to ping database with new credentials")
_ = newDB.Close()
// Old connection remains active, pool is still functional
return err
}
// Only after successful verification, swap connections
oldDB := p.db
p.db = newDB
p.currentDSN = newDSN
// Close old connection after swap
if oldDB != nil {
if err := oldDB.Close(); err != nil {
p.log.WithError(err).Warn("Failed to close old database connection")
// Not fatal cause new connection is already active
}
}
p.log.Info("Successfully reconnected with new PostgreSQL credentials")
return nil
}
// tryWithRefresh attempts an operation, and if it fails with an auth error, refreshes credentials and retries
func (p *RefreshableConnPool) tryWithRefresh(ctx context.Context, op func() error) error {
err := op()
if err != nil && isAuthError(err) {
p.log.WithError(err).Info("Authentication error detected, attempting to refresh credentials")
if refreshErr := p.refreshCredentials(ctx); refreshErr == nil {
// Retry the operation once after successful refresh
p.log.Debug("Retrying operation after credential refresh")
return op()
} else {
p.log.WithError(refreshErr).Warn("Failed to refresh credentials, returning original error")
}
}
return err
}
// PrepareContext implements gorm.ConnPool interface
func (p *RefreshableConnPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
var stmt *sql.Stmt
err := p.tryWithRefresh(ctx, func() error {
p.mu.RLock()
defer p.mu.RUnlock()
var err error
stmt, err = p.db.PrepareContext(ctx, query)
return err
})
return stmt, err
}
// ExecContext implements gorm.ConnPool interface
func (p *RefreshableConnPool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
var result sql.Result
err := p.tryWithRefresh(ctx, func() error {
p.mu.RLock()
defer p.mu.RUnlock()
var err error
result, err = p.db.ExecContext(ctx, query, args...)
return err
})
return result, err
}
// QueryContext implements gorm.ConnPool interface
func (p *RefreshableConnPool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
var rows *sql.Rows
err := p.tryWithRefresh(ctx, func() error {
p.mu.RLock()
defer p.mu.RUnlock()
var err error
rows, err = p.db.QueryContext(ctx, query, args...)
return err
})
return rows, err
}
// QueryRowContext implements gorm.ConnPool interface
func (p *RefreshableConnPool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
// Note: sql.Row doesn't return errors until Scan() is called, so we can't detect auth errors here
// The error will be caught in higher-level GORM operations
p.mu.RLock()
defer p.mu.RUnlock()
return p.db.QueryRowContext(ctx, query, args...)
}
// BeginTx implements gorm.TxBeginner and gorm.ConnPoolBeginner interfaces
func (p *RefreshableConnPool) BeginTx(ctx context.Context, opts *sql.TxOptions) (gorm.ConnPool, error) {
var tx *sql.Tx
err := p.tryWithRefresh(ctx, func() error {
p.mu.RLock()
defer p.mu.RUnlock()
var err error
tx, err = p.db.BeginTx(ctx, opts)
return err
})
if err != nil {
return nil, err
}
return &refreshableTx{Tx: tx, pool: p}, nil
}
// refreshableTx wraps sql.Tx to implement gorm.ConnPool
type refreshableTx struct {
*sql.Tx
pool *RefreshableConnPool
}
func (tx *refreshableTx) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
return tx.Tx.PrepareContext(ctx, query)
}
func (tx *refreshableTx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return tx.Tx.ExecContext(ctx, query, args...)
}
func (tx *refreshableTx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return tx.Tx.QueryContext(ctx, query, args...)
}
func (tx *refreshableTx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return tx.Tx.QueryRowContext(ctx, query, args...)
}
// Close closes the underlying database connection
func (p *RefreshableConnPool) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.db != nil {
return p.db.Close()
}
return nil
}
// Ping verifies the connection is alive
func (p *RefreshableConnPool) Ping(ctx context.Context) error {
p.mu.RLock()
defer p.mu.RUnlock()
return p.db.PingContext(ctx)
}
// GetDB returns the underlying sql.DB for connection pool configuration
func (p *RefreshableConnPool) GetDB() *sql.DB {
p.mu.RLock()
defer p.mu.RUnlock()
return p.db
}
// NewGORMDB creates a GORM DB instance using the refreshable connection pool
func (p *RefreshableConnPool) NewGORMDB() (*gorm.DB, error) {
dialector := postgres.New(postgres.Config{
Conn: p,
})
return gorm.Open(dialector, p.gormConfig)
}
// Ensure RefreshableConnPool implements required interfaces
var (
_ gorm.ConnPool = (*RefreshableConnPool)(nil)
_ gorm.ConnPoolBeginner = (*RefreshableConnPool)(nil)
_ driver.Pinger = (*RefreshableConnPool)(nil)
)

View File

@@ -1,417 +0,0 @@
package postgresstore
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"goauthentik.io/internal/config"
)
func TestRefreshableConnPool_CredentialRefresh(t *testing.T) {
// Create a temporary file for password rotation
tmpDir := t.TempDir()
passwordFile := filepath.Join(tmpDir, "db_password")
cfg := config.Get()
initialConfig := cfg.RefreshPostgreSQLConfig()
// Determine the current database password as the baseline for the rotation test.
initialPassword := initialConfig.Password
if initialPassword == "" {
initialPassword = "postgres"
}
err := os.WriteFile(passwordFile, []byte(initialPassword), 0600)
require.NoError(t, err)
// Set up config to use file:// URI for password
originalPassword := os.Getenv("AUTHENTIK_POSTGRESQL__PASSWORD")
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "file://"+passwordFile))
defer func() {
if originalPassword != "" {
_ = os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", originalPassword)
} else {
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD")
}
}()
// Reload config
refreshedConfig := cfg.RefreshPostgreSQLConfig()
// Build initial DSN
dsn, err := BuildDSN(refreshedConfig)
require.NoError(t, err)
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
NowFunc: func() time.Time {
return time.Now().UTC()
},
}
// Create refreshable connection pool
pool, err := NewRefreshableConnPool(dsn, gormConfig, 10, 100, time.Hour)
require.NoError(t, err)
defer func() { _ = pool.Close() }()
// Test initial connection works
ctx := context.Background()
err = pool.Ping(ctx)
assert.NoError(t, err, "Initial connection should work")
// Create GORM DB
db, err := pool.NewGORMDB()
require.NoError(t, err)
// Execute a test query
var result int
err = db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error
assert.NoError(t, err, "Initial query should succeed")
assert.Equal(t, 1, result)
// Simulate password change by writing to file
// In real scenario, this would be an external process updating the file
time.Sleep(100 * time.Millisecond) // Small delay to ensure file modification time changes
err = os.WriteFile(passwordFile, []byte(initialPassword), 0600)
require.NoError(t, err)
// Execute another query - should trigger credential refresh check
err = db.WithContext(ctx).Raw("SELECT 2").Scan(&result).Error
assert.NoError(t, err, "Query after credential refresh should succeed")
assert.Equal(t, 2, result)
}
func TestRefreshableConnPool_Interfaces(t *testing.T) {
// Verify that RefreshableConnPool implements required interfaces at compile time
// This test will fail to compile if interfaces are not properly implemented
var pool *RefreshableConnPool
// Test gorm.ConnPool interface
var _ gorm.ConnPool = pool
// Test gorm.ConnPoolBeginner interface
var _ gorm.ConnPoolBeginner = pool
}
func TestRefreshableConnPool_ConcurrentAccess(t *testing.T) {
cfg := config.Get()
dsn, err := BuildDSN(cfg.PostgreSQL)
require.NoError(t, err)
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
pool, err := NewRefreshableConnPool(dsn, gormConfig, 10, 100, time.Hour)
require.NoError(t, err)
defer func() { _ = pool.Close() }()
db, err := pool.NewGORMDB()
require.NoError(t, err)
// Test that the connection is working
ctx := context.Background()
var result int
err = db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error
require.NoError(t, err, "Initial connection test should succeed")
// Test concurrent queries
numGoroutines := 10
numQueries := 5
var wg sync.WaitGroup
errChan := make(chan error, numGoroutines*numQueries)
for i := range numGoroutines {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for range numQueries {
var result int
err := db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error
if err != nil {
errChan <- err
}
}
}(i)
}
// Wait for all goroutines to complete, then close the channel
wg.Wait()
close(errChan)
// Check for any errors
for err := range errChan {
assert.NoError(t, err, "Concurrent queries should succeed")
}
}
func TestRefreshableConnPool_InvalidCredentials(t *testing.T) {
// Create a pool with invalid credentials
invalidDSN := "host=localhost port=5432 user=invalid password=invalid dbname=invalid sslmode=disable"
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
pool, err := NewRefreshableConnPool(invalidDSN, gormConfig, 10, 100, time.Hour)
if err != nil {
// sql.Open may succeed even with invalid credentials (lazy connection)
return
}
defer func() { _ = pool.Close() }()
// Ping should fail with invalid credentials
ctx := context.Background()
err = pool.Ping(ctx)
assert.Error(t, err, "Ping with invalid credentials should fail")
}
func TestConfig_RefreshPostgreSQLConfig_FileURI(t *testing.T) {
// Create temporary files for testing file:// URIs
tmpDir := t.TempDir()
passwordFile := filepath.Join(tmpDir, "password")
userFile := filepath.Join(tmpDir, "user")
hostFile := filepath.Join(tmpDir, "host")
err := os.WriteFile(passwordFile, []byte("secret_password"), 0600)
require.NoError(t, err)
err = os.WriteFile(userFile, []byte("dbuser"), 0600)
require.NoError(t, err)
err = os.WriteFile(hostFile, []byte("db.example.com"), 0600)
require.NoError(t, err)
// Set up environment variables with file:// URIs
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "file://"+passwordFile))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__USER", "file://"+userFile))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "file://"+hostFile))
defer func() {
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__USER")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__HOST")
}()
// Create and setup config
cfg := &config.Config{}
cfg.Setup()
// Test initial values are parsed correctly
assert.Equal(t, "secret_password", cfg.PostgreSQL.Password, "Initial password should be parsed from file")
assert.Equal(t, "dbuser", cfg.PostgreSQL.User, "Initial user should be parsed from file")
assert.Equal(t, "db.example.com", cfg.PostgreSQL.Host, "Initial host should be parsed from file")
// Test RefreshPostgreSQLConfig returns same values initially
refreshed := cfg.RefreshPostgreSQLConfig()
assert.Equal(t, "secret_password", refreshed.Password)
assert.Equal(t, "dbuser", refreshed.User)
assert.Equal(t, "db.example.com", refreshed.Host)
// Update password file (simulating credential rotation)
err = os.WriteFile(passwordFile, []byte("new_password"), 0600)
require.NoError(t, err)
// Update user file
err = os.WriteFile(userFile, []byte("new_dbuser"), 0600)
require.NoError(t, err)
// Refresh should pick up new values from files
refreshed = cfg.RefreshPostgreSQLConfig()
assert.Equal(t, "new_password", refreshed.Password, "Password should be refreshed from file")
assert.Equal(t, "new_dbuser", refreshed.User, "User should be refreshed from file")
// Original config struct should still have old values (not mutated)
assert.Equal(t, "secret_password", cfg.PostgreSQL.Password, "Original config should not be mutated")
}
func TestConfig_RefreshPostgreSQLConfig_EnvURI(t *testing.T) {
// Test with env:// URIs (referencing other env vars)
require.NoError(t, os.Setenv("DB_PASSWORD", "env_password"))
require.NoError(t, os.Setenv("DB_USER", "env_user"))
require.NoError(t, os.Setenv("DB_HOST", "env_host"))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "env://DB_PASSWORD"))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__USER", "env://DB_USER"))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "env://DB_HOST"))
defer func() {
_ = os.Unsetenv("DB_PASSWORD")
_ = os.Unsetenv("DB_USER")
_ = os.Unsetenv("DB_HOST")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__USER")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__HOST")
}()
cfg := &config.Config{}
cfg.Setup()
// Test initial values are parsed correctly
assert.Equal(t, "env_password", cfg.PostgreSQL.Password, "Initial password should be parsed from env")
assert.Equal(t, "env_user", cfg.PostgreSQL.User, "Initial user should be parsed from env")
assert.Equal(t, "env_host", cfg.PostgreSQL.Host, "Initial host should be parsed from env")
// Test RefreshPostgreSQLConfig
refreshed := cfg.RefreshPostgreSQLConfig()
assert.Equal(t, "env_password", refreshed.Password)
assert.Equal(t, "env_user", refreshed.User)
assert.Equal(t, "env_host", refreshed.Host)
// Change referenced environment variables (simulating credential rotation)
require.NoError(t, os.Setenv("DB_PASSWORD", "new_env_password"))
require.NoError(t, os.Setenv("DB_USER", "new_env_user"))
// Refresh should pick up new values
refreshed = cfg.RefreshPostgreSQLConfig()
assert.Equal(t, "new_env_password", refreshed.Password, "Password should be refreshed from env")
assert.Equal(t, "new_env_user", refreshed.User, "User should be refreshed from env")
// Original config struct should still have old values (not mutated)
assert.Equal(t, "env_password", cfg.PostgreSQL.Password, "Original config should not be mutated")
}
func TestConfig_RefreshPostgreSQLConfig_PlainValues(t *testing.T) {
// Test with plain values (no URI scheme)
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "plain_password"))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__USER", "plain_user"))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "localhost"))
defer func() {
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__USER")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__HOST")
}()
cfg := &config.Config{}
cfg.Setup()
// Test initial values
assert.Equal(t, "plain_password", cfg.PostgreSQL.Password)
assert.Equal(t, "plain_user", cfg.PostgreSQL.User)
assert.Equal(t, "localhost", cfg.PostgreSQL.Host)
// Test refresh returns same values
refreshed := cfg.RefreshPostgreSQLConfig()
assert.Equal(t, "plain_password", refreshed.Password)
assert.Equal(t, "plain_user", refreshed.User)
assert.Equal(t, "localhost", refreshed.Host)
// Change env vars
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "new_plain_password"))
// Refresh should pick up new plain value
refreshed = cfg.RefreshPostgreSQLConfig()
assert.Equal(t, "new_plain_password", refreshed.Password, "Plain password should be refreshed")
}
func TestConfig_RefreshPostgreSQLConfig_MixedSources(t *testing.T) {
// Test with mixed sources: file://, env://, and plain
tmpDir := t.TempDir()
passwordFile := filepath.Join(tmpDir, "password")
err := os.WriteFile(passwordFile, []byte("file_password"), 0600)
require.NoError(t, err)
require.NoError(t, os.Setenv("DB_USER_VAR", "env_user"))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "file://"+passwordFile))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__USER", "env://DB_USER_VAR"))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "plain_host"))
defer func() {
_ = os.Unsetenv("DB_USER_VAR")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__USER")
_ = os.Unsetenv("AUTHENTIK_POSTGRESQL__HOST")
}()
cfg := &config.Config{}
cfg.Setup()
// Test initial values
assert.Equal(t, "file_password", cfg.PostgreSQL.Password)
assert.Equal(t, "env_user", cfg.PostgreSQL.User)
assert.Equal(t, "plain_host", cfg.PostgreSQL.Host)
// Update all sources
err = os.WriteFile(passwordFile, []byte("new_file_password"), 0600)
require.NoError(t, err)
require.NoError(t, os.Setenv("DB_USER_VAR", "new_env_user"))
require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "new_plain_host"))
// Refresh should pick up all changes
refreshed := cfg.RefreshPostgreSQLConfig()
assert.Equal(t, "new_file_password", refreshed.Password, "File password should be refreshed")
assert.Equal(t, "new_env_user", refreshed.User, "Env user should be refreshed")
assert.Equal(t, "new_plain_host", refreshed.Host, "Plain host should be refreshed")
}
func TestIsAuthError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "nil error",
err: nil,
expected: false,
},
{
name: "generic error",
err: assert.AnError,
expected: false,
},
{
name: "postgres error code 28000 - invalid_authorization_specification",
err: &pgconn.PgError{
Code: "28000",
Message: "invalid authorization specification",
},
expected: true,
},
{
name: "postgres error code 28P01 - invalid_password",
err: &pgconn.PgError{
Code: "28P01",
Message: "password authentication failed for user",
},
expected: true,
},
{
name: "postgres error code 28P02 - invalid_password (deprecated)",
err: &pgconn.PgError{
Code: "28P02",
Message: "invalid password",
},
expected: true,
},
{
name: "postgres error code 42P01 - undefined_table (not auth error)",
err: &pgconn.PgError{
Code: "42P01",
Message: "relation does not exist",
},
expected: false,
},
{
name: "postgres error code 23505 - unique_violation (not auth error)",
err: &pgconn.PgError{
Code: "23505",
Message: "duplicate key value violates unique constraint",
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isAuthError(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -1,48 +0,0 @@
package postgresstore
import (
"context"
"time"
log "github.com/sirupsen/logrus"
gormlogger "gorm.io/gorm/logger"
)
type logrusLogger struct {
logger *log.Entry
}
func NewLogger(parent *log.Entry) *logrusLogger {
return &logrusLogger{
logger: parent,
}
}
func (l *logrusLogger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
return l
}
func (l *logrusLogger) Info(ctx context.Context, s string, args ...any) {
l.logger.WithContext(ctx).Infof(s, args...)
}
func (l *logrusLogger) Warn(ctx context.Context, s string, args ...any) {
l.logger.WithContext(ctx).Warnf(s, args...)
}
func (l *logrusLogger) Error(ctx context.Context, s string, args ...any) {
l.logger.WithContext(ctx).Errorf(s, args...)
}
func (l *logrusLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
elapsed := time.Since(begin)
sql, _ := fc()
fields := log.Fields{
"elapsed": elapsed,
}
if err != nil {
l.logger.WithContext(ctx).WithFields(fields).WithError(err).Error(sql)
return
}
l.logger.WithContext(ctx).WithFields(fields).Trace(sql)
}

View File

@@ -1,676 +0,0 @@
package postgresstore
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/sessions"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/stdlib"
"github.com/mitchellh/mapstructure"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"goauthentik.io/internal/config"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
)
// PostgresStore stores gorilla sessions in PostgreSQL using GORM
type PostgresStore struct {
db *gorm.DB
pool *RefreshableConnPool // Keep reference to pool for cleanup
// default options to use when a new session is created
options sessions.Options
// key prefix with which the session will be stored
keyPrefix string
log *log.Entry
}
// ProxySession represents the session data structure in PostgreSQL
type ProxySession struct {
UUID uuid.UUID `gorm:"type:uuid;primaryKey;column:uuid;default:gen_random_uuid()"`
SessionKey string `gorm:"column:session_key"`
UserID *uuid.UUID `gorm:"column:user_id"`
SessionData string `gorm:"type:jsonb;column:session_data"`
Expires time.Time `gorm:"column:expires"`
Expiring bool `gorm:"column:expiring"`
}
// TableName specifies the table name for GORM
func (ProxySession) TableName() string {
return "authentik_providers_proxy_proxysession"
}
// BuildConnConfig constructs a pgx.ConnConfig from PostgreSQL configuration.
func BuildConnConfig(cfg config.PostgreSQLConfig) (*pgx.ConnConfig, error) {
// Validate required fields
if cfg.Host == "" {
return nil, fmt.Errorf("PostgreSQL host is required")
}
if cfg.User == "" {
return nil, fmt.Errorf("PostgreSQL user is required")
}
if cfg.Name == "" {
return nil, fmt.Errorf("PostgreSQL database name is required")
}
if cfg.Port == "" {
return nil, fmt.Errorf("PostgreSQL port is required")
}
// Start with a default config
connConfig, err := pgx.ParseConfig("")
if err != nil {
return nil, fmt.Errorf("failed to create default config: %w", err)
}
// Parse comma-separated hosts and create fallbacks
// cfg.Host can be a comma-separated list like "host1,host2,host3"
hosts := strings.Split(cfg.Host, ",")
for i, host := range hosts {
hosts[i] = strings.TrimSpace(host)
}
// Parse and validate comma-separated ports
portStrs := strings.Split(cfg.Port, ",")
ports := make([]uint16, len(portStrs))
for i, portStr := range portStrs {
portStr = strings.TrimSpace(portStr)
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid port value %q: %w", portStr, err)
}
if port <= 0 {
return nil, fmt.Errorf("PostgreSQL port %d must be positive", port)
}
if port > 65535 {
return nil, fmt.Errorf("PostgreSQL port %d is out of valid range", port)
}
ports[i] = uint16(port)
}
// Get port for primary host
primaryHost := hosts[0]
primaryPort := ports[0]
// Set connection parameters for primary host
connConfig.Host = primaryHost
connConfig.Port = primaryPort
connConfig.User = cfg.User
connConfig.Password = cfg.Password
connConfig.Database = cfg.Name
// Configure TLS/SSL
if cfg.SSLMode != "" {
switch cfg.SSLMode {
case "disable":
connConfig.TLSConfig = nil
case "require", "verify-ca", "verify-full":
tlsConfig := &tls.Config{}
// Load root CA certificate if provided
if cfg.SSLRootCert != "" {
caCert, err := os.ReadFile(cfg.SSLRootCert)
if err != nil {
return nil, fmt.Errorf("failed to read SSL root certificate: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to parse SSL root certificate")
}
tlsConfig.RootCAs = caCertPool
}
// Load client certificate and key if provided
if cfg.SSLCert != "" && cfg.SSLKey != "" {
cert, err := tls.LoadX509KeyPair(cfg.SSLCert, cfg.SSLKey)
if err != nil {
return nil, fmt.Errorf("failed to load SSL client certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
// Set verification mode
switch cfg.SSLMode {
case "require":
// Don't verify the server certificate (just encrypt)
tlsConfig.InsecureSkipVerify = true
case "verify-ca":
// Verify the certificate is signed by a trusted CA
tlsConfig.InsecureSkipVerify = false
case "verify-full":
// Verify the certificate and hostname
tlsConfig.InsecureSkipVerify = false
tlsConfig.ServerName = primaryHost
}
connConfig.TLSConfig = tlsConfig
}
}
// Create fallback configurations for additional hosts
if len(hosts) > 1 {
connConfig.Fallbacks = make([]*pgconn.FallbackConfig, 0, len(hosts)-1)
for i, host := range hosts[1:] {
port := getPortForIndex(ports, i+1)
fallback := &pgconn.FallbackConfig{
Host: host,
Port: port,
}
// Copy TLS config to fallback if present
if connConfig.TLSConfig != nil {
fallbackTLS := connConfig.TLSConfig.Clone()
// Update ServerName for verify-full mode
if cfg.SSLMode == "verify-full" {
fallbackTLS.ServerName = host
}
fallback.TLSConfig = fallbackTLS
}
connConfig.Fallbacks = append(connConfig.Fallbacks, fallback)
}
}
// Set runtime params
if connConfig.RuntimeParams == nil {
connConfig.RuntimeParams = make(map[string]string)
}
effectiveSearchPath := cfg.DefaultSchema
// Parse and apply connection options if specified
if cfg.ConnOptions != "" {
connOpts, err := parseConnOptions(cfg.ConnOptions)
if err != nil {
return nil, fmt.Errorf("failed to parse connection options: %w", err)
}
// search_path from ConnOptions is not supported here; Django controls schema selection.
// Always remove it so it cannot end up in startup RuntimeParams via applyConnOptions.
delete(connOpts, "search_path")
if err := applyConnOptions(connConfig, connOpts); err != nil {
return nil, fmt.Errorf("failed to apply connection options: %w", err)
}
}
// search_path may already be present via pgx/libpq inherited defaults (e.g. service files).
// Always remove it from startup RuntimeParams; apply it via AfterConnect instead.
if inheritedSearchPath, hasInheritedSearchPath := connConfig.RuntimeParams["search_path"]; hasInheritedSearchPath {
if effectiveSearchPath == "" {
effectiveSearchPath = inheritedSearchPath
}
delete(connConfig.RuntimeParams, "search_path")
}
// Set search_path after connection startup to avoid startup-parameter issues with PgBouncer.
if effectiveSearchPath != "" {
connConfig.AfterConnect = func(ctx context.Context, pgConn *pgconn.PgConn) error {
result := pgConn.ExecParams(
ctx,
"select pg_catalog.set_config('search_path', $1, false)",
[][]byte{[]byte(effectiveSearchPath)},
nil,
nil,
nil,
).Read()
return result.Err
}
}
return connConfig, nil
}
// getPortForIndex returns the port for the given host index.
// If there are fewer ports than needed, returns the last port (libpq behavior).
func getPortForIndex(ports []uint16, i int) uint16 {
if i >= len(ports) {
return ports[len(ports)-1]
}
return ports[i]
}
// parseConnOptions decodes a base64-encoded JSON string into a map of connection options.
// This matches the Python behavior in authentik/lib/config.py:get_dict_from_b64_json
func parseConnOptions(encoded string) (map[string]string, error) {
// Base64 decode
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("invalid base64 encoding: %w", err)
}
// Parse JSON
var opts map[string]any
if err := json.Unmarshal(decoded, &opts); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
// Convert all values to strings
result := make(map[string]string)
for k, v := range opts {
switch val := v.(type) {
case string:
result[k] = val
case float64:
// JSON numbers are float64
if val == float64(int(val)) {
result[k] = strconv.Itoa(int(val))
} else {
result[k] = strconv.FormatFloat(val, 'f', -1, 64)
}
case bool:
result[k] = strconv.FormatBool(val)
default:
result[k] = fmt.Sprintf("%v", v)
}
}
return result, nil
}
// applyConnOptions applies parsed connection options to the pgx.ConnConfig.
func applyConnOptions(connConfig *pgx.ConnConfig, opts map[string]string) error {
for key, value := range opts {
// connect_timeout needs special handling as it's a connection-level timeout
if key == "connect_timeout" {
timeout, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid connect_timeout value: %w", err)
}
connConfig.ConnectTimeout = time.Duration(timeout) * time.Second
continue
}
// target_session_attrs needs special handling to set ValidateConnect function
if key == "target_session_attrs" {
switch value {
case "read-write":
connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsReadWrite
case "read-only":
connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsReadOnly
case "primary":
connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsPrimary
case "standby":
connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsStandby
case "prefer-standby":
connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsPreferStandby
case "any":
// "any" is the default (no validation needed)
connConfig.ValidateConnect = nil
default:
return fmt.Errorf("unknown target_session_attrs value: %s", value)
}
// Do not add target_session_attrs to RuntimeParams
continue
}
// All other options go to RuntimeParams
connConfig.RuntimeParams[key] = value
}
return nil
}
// BuildDSN constructs a PostgreSQL connection string from a ConnConfig.
func BuildDSN(cfg config.PostgreSQLConfig) (string, error) {
connConfig, err := BuildConnConfig(cfg)
if err != nil {
return "", err
}
// Register the config and get a connection string
// (This approach lets pgx handle all the escaping internally which is quite convenient for say spaces in the password)
return stdlib.RegisterConnConfig(connConfig), nil
}
// SetupGORMWithRefreshablePool creates a GORM DB with a refreshable connection pool.
// This is the standardized way to create database connections for both production and tests.
//
// The RefreshableConnPool wraps database/sql and automatically detects PostgreSQL
// authentication errors (SQLSTATE 28xxx), refreshes credentials from config sources
// (file://, env://, or plain environment variables), and reconnects without downtime.
//
// Parameters:
// - cfg: PostgreSQL configuration (host, port, user, password, etc.)
// - gormConfig: GORM configuration (logger, naming strategy, etc.)
// - maxIdleConns: Maximum number of idle connections in the pool
// - maxOpenConns: Maximum number of open connections to the database
// - connMaxLifetime: Maximum lifetime of a connection
//
// Returns:
// - *gorm.DB: GORM database instance for ORM operations
// - *RefreshableConnPool: Connection pool reference (caller must Close when done)
// - error: Any error encountered during setup
func SetupGORMWithRefreshablePool(cfg config.PostgreSQLConfig, gormConfig *gorm.Config, maxIdleConns, maxOpenConns int, connMaxLifetime time.Duration) (*gorm.DB, *RefreshableConnPool, error) {
// Build connection string
dsn, err := BuildDSN(cfg)
if err != nil {
return nil, nil, fmt.Errorf("failed to build DSN: %w", err)
}
// Create refreshable connection pool
pool, err := NewRefreshableConnPool(dsn, gormConfig, maxIdleConns, maxOpenConns, connMaxLifetime)
if err != nil {
return nil, nil, fmt.Errorf("failed to create connection pool: %w", err)
}
// Create GORM DB using the refreshable connection pool
db, err := pool.NewGORMDB()
if err != nil {
_ = pool.Close()
return nil, nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err)
}
// Test the connection with a simple query
// This will trigger the connection pool's tryWithRefresh logic if there's an auth error
ctx := context.Background()
var result int
err = db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error
if err != nil {
_ = pool.Close()
return nil, nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err)
}
return db, pool, nil
}
// NewPostgresStore returns a new PostgresStore
func NewPostgresStore(log *log.Entry) (*PostgresStore, error) {
cfg := config.Get().PostgreSQL
// Configure GORM
gormConfig := &gorm.Config{
Logger: NewLogger(log),
NowFunc: func() time.Time {
return time.Now().UTC()
},
}
// Determine connection pool settings
maxIdleConns := 4
maxOpenConns := 4
var connMaxLifetime time.Duration
if cfg.ConnMaxAge > 0 {
connMaxLifetime = time.Duration(cfg.ConnMaxAge) * time.Second
} else {
connMaxLifetime = time.Hour // Default 1 hour
}
// Use standardized setup
db, pool, err := SetupGORMWithRefreshablePool(cfg, gormConfig, maxIdleConns, maxOpenConns, connMaxLifetime)
if err != nil {
return nil, fmt.Errorf("failed to setup database: %w", err)
}
ps := &PostgresStore{
db: db,
pool: pool,
options: sessions.Options{
Path: "/",
MaxAge: 86400 * 30, // 30 days default (but overwritten in postgresstore creation based on token validation)
},
keyPrefix: "authentik_proxy_session_",
log: log.WithField("logger", "authentik.outpost.proxyv2.postgresstore"),
}
return ps, nil
}
// Get returns a session for the given name after adding it to the registry.
func (s *PostgresStore) Get(r *http.Request, name string) (*sessions.Session, error) {
return sessions.GetRegistry(r).Get(s, name)
}
// New returns a session for the given name without adding it to the registry.
func (s *PostgresStore) New(r *http.Request, name string) (*sessions.Session, error) {
session := sessions.NewSession(s, name)
opts := s.options
session.Options = &opts
session.IsNew = true
c, err := r.Cookie(name)
if err != nil {
return session, nil
}
session.ID = c.Value
err = s.load(r.Context(), session)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return session, nil
}
return session, err
}
session.IsNew = false
return session, err
}
// Save adds a single session to the response.
func (s *PostgresStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
// Delete if max-age is <= 0
if session.Options.MaxAge <= 0 {
if err := s.delete(r.Context(), session); err != nil {
return fmt.Errorf("failed to delete session: %w", err)
}
http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
return nil
}
if session.ID == "" {
// Generate new session ID
session.ID = s.keyPrefix + generateSessionID()
}
if err := s.save(r.Context(), session); err != nil {
return fmt.Errorf("failed to save session: %w", err)
}
http.SetCookie(w, sessions.NewCookie(session.Name(), session.ID, session.Options))
return nil
}
// Options set options to use when a new session is created
func (s *PostgresStore) Options(opts sessions.Options) {
s.options = opts
}
// KeyPrefix sets the key prefix to store session in PostgreSQL
func (s *PostgresStore) KeyPrefix(keyPrefix string) {
s.keyPrefix = keyPrefix
}
// Close closes the PostgreSQL store
func (s *PostgresStore) Close() error {
if s.pool != nil {
return s.pool.Close()
}
return nil
}
// save writes session to PostgreSQL
func (s *PostgresStore) save(ctx context.Context, session *sessions.Session) error {
// Convert session.Values (map[interface{}]interface{}) to map[string]interface{} for JSON marshaling
stringKeyedValues := make(map[string]any)
for k, v := range session.Values {
if key, ok := k.(string); ok {
stringKeyedValues[key] = v
}
}
// Serialize all session values to JSON
sessionData, err := json.Marshal(stringKeyedValues)
if err != nil {
return fmt.Errorf("failed to marshal session values: %w", err)
}
// Extract user ID from claims if it exists
var userID *uuid.UUID
if claims, hasClaims := session.Values[constants.SessionClaims]; hasClaims {
if claimsMap, ok := claims.(map[string]any); ok {
if sub, exists := claimsMap["sub"]; exists {
if subStr, ok := sub.(string); ok {
if parsedUUID, err := uuid.Parse(subStr); err == nil {
userID = &parsedUUID
}
}
}
}
}
proxySession := ProxySession{
UUID: uuid.New(),
SessionKey: session.ID,
UserID: userID,
SessionData: string(sessionData),
Expiring: true,
}
// Add expiration timestamp to session data
if session.Options != nil && session.Options.MaxAge > 0 {
expiresAt := time.Now().UTC().Add(time.Duration(session.Options.MaxAge) * time.Second)
proxySession.Expires = expiresAt
}
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "session_key"}},
DoUpdates: clause.AssignmentColumns([]string{"user_id", "session_data", "expires"}),
}).Create(&proxySession).Error
}
// load reads session from PostgreSQL
func (s *PostgresStore) load(ctx context.Context, session *sessions.Session) error {
var proxySession ProxySession
err := s.db.WithContext(ctx).Where("session_key = ?", session.ID).First(&proxySession).Error
if err != nil {
return fmt.Errorf("failed to load session: %w", err)
}
// Check if session is expired
if time.Now().UTC().After(proxySession.Expires) {
// Session is expired, delete it and return not found error
s.db.WithContext(ctx).Delete(&ProxySession{}, "session_key = ?", session.ID)
return gorm.ErrRecordNotFound
}
// Deserialize session data from JSON
if proxySession.SessionData != "" {
// First unmarshal to map[string]interface{}
var stringKeyedValues map[string]any
err = json.Unmarshal([]byte(proxySession.SessionData), &stringKeyedValues)
if err != nil {
return fmt.Errorf("failed to unmarshal session data: %w", err)
}
// Convert back to map[interface{}]interface{} for gorilla/sessions compatibility
session.Values = make(map[any]any)
for k, v := range stringKeyedValues {
session.Values[k] = v
}
}
return nil
}
// delete removes session from PostgreSQL
func (s *PostgresStore) delete(ctx context.Context, session *sessions.Session) error {
return s.db.WithContext(ctx).Delete(&ProxySession{}, "session_key = ?", session.ID).Error
}
// CleanupExpired removes expired sessions by checking MaxAge in session_data
func (s *PostgresStore) CleanupExpired(ctx context.Context) error {
result := s.db.WithContext(ctx).Where(`"expires" < ?`, time.Now().UTC()).Delete(&ProxySession{})
if result.Error != nil {
return fmt.Errorf("failed to delete expired sessions: %w", result.Error)
}
if result.RowsAffected > 0 {
s.log.WithField("count", result.RowsAffected).Info("Cleaned up expired sessions")
}
return nil
}
// LogoutSessions removes sessions that match the given filter criteria
// The filter function should return true for sessions that should be deleted
func (s *PostgresStore) LogoutSessions(ctx context.Context, filter func(c types.Claims) bool) error {
// First, try to use JSONB operators for common filter patterns to avoid N+1 queries
// If the filter is too complex, fall back to client-side filtering
// Pre-filter sessions using JSONB operators where possible
// Only fetch sessions that have claims (session_data->'claims' IS NOT NULL)
var sessions []ProxySession
err := s.db.WithContext(ctx).Where(fmt.Sprintf("session_data::jsonb ? '%s'", constants.SessionClaims)).Find(&sessions).Error
if err != nil {
return fmt.Errorf("failed to fetch sessions: %w", err)
}
var sessionKeysToDelete []string
for _, session := range sessions {
if session.SessionData == "" {
continue
}
var sessionData map[string]any
if err := json.Unmarshal([]byte(session.SessionData), &sessionData); err != nil {
continue
}
claimsData, hasClaims := sessionData[constants.SessionClaims]
if !hasClaims {
continue
}
claimsMap, ok := claimsData.(map[string]any)
if !ok {
continue
}
// Only decode Sub and Sid fields since those are the only ones used in filters
var claims types.Claims
if err := mapstructure.Decode(claimsMap, &claims); err != nil {
continue
}
if filter(claims) {
sessionKeysToDelete = append(sessionKeysToDelete, session.SessionKey)
}
}
if len(sessionKeysToDelete) > 0 {
err = s.db.WithContext(ctx).Delete(&ProxySession{}, "session_key IN ?", sessionKeysToDelete).Error
if err != nil {
return fmt.Errorf("failed to delete sessions: %w", err)
}
}
return nil
}
// generateSessionID generates a random session ID
func generateSessionID() string {
return uuid.New().String()
}
// NewTestStore creates a PostgresStore for testing with the given database and pool.
// The pool reference is required to properly close connections in test cleanup.
func NewTestStore(db *gorm.DB, pool *RefreshableConnPool) *PostgresStore {
return &PostgresStore{
db: db,
pool: pool,
options: sessions.Options{
Path: "/",
MaxAge: 3600,
},
keyPrefix: "test_session_",
log: log.WithField("logger", "test"),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,229 +0,0 @@
package proxyv2
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"strings"
"sync"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/gorilla/mux"
"github.com/pires/go-proxyproto"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
"goauthentik.io/internal/crypto"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2/application"
"goauthentik.io/internal/utils"
sentryutils "goauthentik.io/internal/utils/sentry"
"goauthentik.io/internal/utils/web"
api "goauthentik.io/packages/client-go"
)
type ProxyServer struct {
defaultCert tls.Certificate
stop chan struct{} // channel for waiting shutdown
cryptoStore *ak.CryptoStore
apps map[string]*application.Application
log *log.Entry
mux *mux.Router
akAPI *ak.APIController
}
func NewProxyServer(ac *ak.APIController) ak.Outpost {
l := log.WithField("logger", "authentik.outpost.proxyv2")
defaultCert, err := crypto.GenerateSelfSignedCert()
if err != nil {
l.Fatal(err)
}
rootMux := mux.NewRouter()
rootMux.Use(func(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
h.ServeHTTP(rw, r)
rw.Header().Set("X-Powered-By", "authentik_proxy2")
})
})
globalMux := rootMux.NewRoute().Subrouter()
globalMux.Use(web.NewLoggingHandler(l.WithField("logger", "authentik.outpost.proxyv2.http"), nil))
if ac.GlobalConfig.ErrorReporting.Enabled {
globalMux.Use(sentryhttp.New(sentryhttp.Options{}).Handle)
}
if ac.IsEmbedded() {
l.Info("using PostgreSQL session backend")
} else {
l.Info("using filesystem session backend")
}
s := &ProxyServer{
cryptoStore: ak.NewCryptoStore(ac.Client.CryptoAPI),
apps: make(map[string]*application.Application),
log: l,
mux: rootMux,
akAPI: ac,
defaultCert: defaultCert,
}
globalMux.PathPrefix("/outpost.goauthentik.io/static").HandlerFunc(s.HandleStatic)
globalMux.Path("/outpost.goauthentik.io/ping").HandlerFunc(sentryutils.SentryNoSample(s.HandlePing))
rootMux.PathPrefix("/").HandlerFunc(s.Handle)
ac.AddEventHandler(s.handleWSMessage)
return s
}
func (ps *ProxyServer) HandleHost(rw http.ResponseWriter, r *http.Request) bool {
// Always handle requests for outpost paths that should answer regardless of hostname
if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/ping") ||
strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/static") {
ps.mux.ServeHTTP(rw, r)
return true
}
// lookup app by hostname
a, _ := ps.lookupApp(r)
if a == nil {
return false
}
// check if the app should handle this URL, or is setup in proxy mode
if a.ShouldHandleURL(r) || a.Mode() == api.PROXYMODE_PROXY {
ps.mux.ServeHTTP(rw, r)
return true
}
return false
}
func (ps *ProxyServer) Type() string {
return "proxy"
}
func (ps *ProxyServer) TimerFlowCacheExpiry(context.Context) {}
func (ps *ProxyServer) GetCertificate(serverName string) *tls.Certificate {
app, ok := ps.apps[serverName]
if !ok {
ps.log.WithField("server-name", serverName).Debug("failed to get certificate for ServerName")
return nil
}
if app.Cert == nil {
ps.log.WithField("server-name", serverName).Debug("app does not have a certificate")
return nil
}
return app.Cert
}
func (ps *ProxyServer) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
sn := info.ServerName
if sn == "" {
return &ps.defaultCert, nil
}
appCert := ps.GetCertificate(sn)
if appCert == nil {
return &ps.defaultCert, nil
}
return appCert, nil
}
// ServeHTTP constructs a net.Listener and starts handling HTTP requests
func (ps *ProxyServer) ServeHTTP(listen string) {
listener, err := net.Listen("tcp", listen)
if err != nil {
ps.log.WithField("listen", listen).WithError(err).Warning("Failed to listen")
return
}
proxyListener := &proxyproto.Listener{Listener: listener, ConnPolicy: utils.GetProxyConnectionPolicy()}
defer func() {
err := proxyListener.Close()
if err != nil {
ps.log.WithError(err).Warning("failed to close proxy listener")
}
}()
ps.log.WithField("listen", listen).Info("Starting HTTP server")
ps.serve(proxyListener)
ps.log.WithField("listen", listen).Info("Stopping HTTP server")
}
// ServeHTTPS constructs a net.Listener and starts handling HTTPS requests
func (ps *ProxyServer) ServeHTTPS(listen string) {
tlsConfig := utils.GetTLSConfig()
tlsConfig.GetCertificate = ps.getCertificates
ln, err := net.Listen("tcp", listen)
if err != nil {
ps.log.WithError(err).Warning("Failed to listen (TLS)")
return
}
proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}, ConnPolicy: utils.GetProxyConnectionPolicy()}
defer func() {
err := proxyListener.Close()
if err != nil {
ps.log.WithError(err).Warning("failed to close proxy listener")
}
}()
tlsListener := tls.NewListener(proxyListener, tlsConfig)
ps.log.WithField("listen", listen).Info("Starting HTTPS server")
ps.serve(tlsListener)
ps.log.WithField("listen", listen).Info("Stopping HTTPS server")
}
func (ps *ProxyServer) Start() error {
listenHttp := config.Get().Listen.HTTP
listenHttps := config.Get().Listen.HTTPS
listenMetrics := config.Get().Listen.Metrics
metricsRouter := ak.MetricsRouter()
wg := sync.WaitGroup{}
wg.Add(len(listenHttp) + len(listenHttps) + 1 + len(listenMetrics))
for _, listen := range listenHttp {
go func() {
defer wg.Done()
ps.ServeHTTP(listen)
}()
}
for _, listen := range listenHttps {
go func() {
defer wg.Done()
ps.ServeHTTPS(listen)
}()
}
go func() {
defer wg.Done()
ak.RunMetricsUnix(metricsRouter)
}()
for _, listen := range listenMetrics {
go func() {
defer wg.Done()
ak.RunMetricsServer(listen, metricsRouter)
}()
}
return nil
}
func (ps *ProxyServer) Stop() error {
return nil
}
func (ps *ProxyServer) serve(listener net.Listener) {
srv := web.Server(ps.mux)
// See https://golang.org/pkg/net/http/#Server.Shutdown
idleConnsClosed := make(chan struct{})
go func() {
<-ps.stop // wait notification for stopping server
// We received an interrupt signal, shut down.
if err := srv.Shutdown(context.Background()); err != nil {
// Error from closing listeners, or context timeout:
ps.log.WithError(err).Info("HTTP server Shutdown")
}
close(idleConnsClosed)
}()
err := srv.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
ps.log.Errorf("ERROR: http.Serve() - %s", err)
}
<-idleConnsClosed
}

View File

@@ -1,106 +0,0 @@
package proxyv2
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path"
"github.com/getsentry/sentry-go"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2/application"
"goauthentik.io/internal/utils/web"
"golang.org/x/exp/maps"
)
func (ps *ProxyServer) Refresh() error {
req := ps.akAPI.Client.OutpostsAPI.OutpostsProxyList(context.Background())
ps.log.WithField("outpost_pk", ps.akAPI.Outpost.Pk).Debug("Requesting providers for outpost")
providers, err := ak.Paginator(req, ak.PaginatorOptions{
PageSize: 100,
Logger: ps.log,
})
if err != nil {
ps.log.WithError(err).Error("Failed to fetch providers")
}
if err != nil {
return err
}
ps.log.WithField("count", len(providers)).Debug("Fetched providers")
if len(providers) == 0 && !ps.akAPI.IsEmbedded() {
ps.log.Warning("No providers assigned to this outpost, check outpost configuration in authentik")
}
for i, p := range providers {
ps.log.WithField("index", i).WithField("name", p.Name).WithField("external_host", p.ExternalHost).WithField("assigned_to_app", p.AssignedApplicationName).Debug("Provider details")
}
apps := make(map[string]*application.Application)
for _, provider := range providers {
rsp := sentry.StartSpan(context.Background(), "authentik.outposts.proxy.application_ss")
ua := fmt.Sprintf(" (provider=%s)", provider.Name)
var transport http.RoundTripper
if ps.akAPI.IsEmbedded() {
transport = &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", path.Join(os.TempDir(), "authentik.sock"))
},
}
} else {
transport = ak.GetTLSTransport()
}
hc := &http.Client{
Transport: web.NewUserAgentTransport(
constants.UserAgentOutpost()+ua,
web.NewTracingTransport(
rsp.Context(),
transport,
),
),
}
externalHost, err := url.Parse(provider.ExternalHost)
if err != nil {
ps.log.WithError(err).Warning("failed to parse URL, skipping provider")
continue
}
existing, ok := ps.apps[externalHost.Host]
a, err := application.NewApplication(provider, hc, ps, existing)
if ok {
existing.Stop()
}
if err != nil {
ps.log.WithError(err).Warning("failed to setup application")
continue
}
ps.log.WithField("name", provider.Name).WithField("host", externalHost.Host).Info("Loaded application")
apps[externalHost.Host] = a
}
ps.apps = apps
ps.log.Debug("Swapped maps")
return nil
}
func (ps *ProxyServer) API() *ak.APIController {
return ps.akAPI
}
func (ps *ProxyServer) CryptoStore() *ak.CryptoStore {
return ps.cryptoStore
}
func (ps *ProxyServer) Apps() []*application.Application {
return maps.Values(ps.apps)
}
func (ps *ProxyServer) SessionBackend() string {
if ps.akAPI.IsEmbedded() {
return "postgres"
}
if !ps.akAPI.IsEmbedded() {
return "filesystem"
}
ps.log.Panic("failed to determine session backend type")
return ""
}

View File

@@ -1,113 +0,0 @@
package sessionstore
import (
"context"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
const SessionCleanupInterval = 5 * time.Minute
// CleanupStore defines the interface for stores that support cleanup
type CleanupStore interface {
CleanupExpired(ctx context.Context) error
}
// CleanupManager manages periodic cleanup for session stores
type CleanupManager struct {
store CleanupStore
log *log.Entry
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
cleanupCtx context.Context
cleanupCancel context.CancelFunc
}
// NewCleanupManager creates a new cleanup manager for the given store
func NewCleanupManager(store CleanupStore, logger *log.Entry) *CleanupManager {
return &CleanupManager{
store: store,
log: logger,
}
}
// Start begins the periodic cleanup goroutine
func (cm *CleanupManager) Start() {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.cancel != nil {
return // Already running
}
ctx, cancel := context.WithCancel(context.Background())
cm.cancel = cancel
cm.done = make(chan struct{})
go func() {
defer close(cm.done)
cm.log.Info("Scheduling session cleanup job")
ticker := time.NewTicker(SessionCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
cm.log.Info("Stopping session cleanup job")
return
case <-ticker.C:
cm.runCleanup()
}
}
}()
}
// runCleanup executes a single cleanup operation
func (cm *CleanupManager) runCleanup() {
cm.mu.Lock()
if cm.cleanupCtx != nil {
cm.mu.Unlock()
cm.log.Warn("Cleanup already in progress, skipping")
return
}
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
cm.cleanupCtx = cleanupCtx
cm.cleanupCancel = cleanupCancel
cm.mu.Unlock()
defer func() {
cm.mu.Lock()
if cm.cleanupCancel != nil {
cm.cleanupCancel()
}
cm.cleanupCtx = nil
cm.cleanupCancel = nil
cm.mu.Unlock()
}()
cm.log.Debug("Running session cleanup")
if err := cm.store.CleanupExpired(cleanupCtx); err != nil {
cm.log.WithError(err).Warn("Session cleanup returned error")
} else {
cm.log.Debug("Session cleanup completed successfully")
}
}
// Stop halts the periodic cleanup goroutine
func (cm *CleanupManager) Stop() {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.cancel != nil {
cm.cancel()
if cm.done != nil {
<-cm.done
}
cm.cancel = nil
cm.done = nil
}
}

View File

@@ -1,191 +0,0 @@
package sessionstore
import (
"context"
"sync"
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
// mockSessionStore is a test implementation of SessionStore
type mockSessionStore struct {
mu sync.Mutex
cleanupCount int
shouldFailNext bool
}
func (m *mockSessionStore) CleanupExpired(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.shouldFailNext {
m.shouldFailNext = false
return assert.AnError
}
m.cleanupCount++
return nil
}
func (m *mockSessionStore) GetCleanupCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.cleanupCount
}
func (m *mockSessionStore) ResetCleanupCount() {
m.mu.Lock()
defer m.mu.Unlock()
m.cleanupCount = 0
}
func (m *mockSessionStore) SetShouldFail(shouldFail bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.shouldFailNext = shouldFail
}
func TestCleanupManager_StartStop(t *testing.T) {
store := &mockSessionStore{}
logger := log.WithField("test", "cleanup")
manager := NewCleanupManager(store, logger)
// Manager should not be running initially
manager.mu.Lock()
running := manager.cancel != nil
manager.mu.Unlock()
assert.False(t, running)
// Start the manager
manager.Start()
// Manager should be running
manager.mu.Lock()
running = manager.cancel != nil
manager.mu.Unlock()
assert.True(t, running)
// Stop the manager
manager.Stop()
// Manager should not be running
manager.mu.Lock()
running = manager.cancel != nil
manager.mu.Unlock()
assert.False(t, running)
}
func TestCleanupManager_PeriodicCleanup(t *testing.T) {
store := &mockSessionStore{}
logger := log.WithField("test", "cleanup")
// we can't easily test periodic cleanup without modifying SessionCleanupInterval
// which is a const. This test verifies the manager starts/stops correctly.
manager := NewCleanupManager(store, logger)
manager.Start()
// Verify it's running
manager.mu.Lock()
running := manager.cancel != nil
manager.mu.Unlock()
assert.True(t, running)
manager.Stop()
// Verify it stopped
manager.mu.Lock()
running = manager.cancel != nil
manager.mu.Unlock()
assert.False(t, running)
}
func TestCleanupManager_ManualCleanup(t *testing.T) {
store := &mockSessionStore{}
logger := log.WithField("test", "cleanup")
manager := NewCleanupManager(store, logger)
// Run cleanup manually
manager.runCleanup()
// Verify cleanup was called
count := store.GetCleanupCount()
assert.Equal(t, 1, count)
}
func TestCleanupManager_StopWhileRunning(t *testing.T) {
store := &mockSessionStore{}
logger := log.WithField("test", "cleanup")
manager := NewCleanupManager(store, logger)
manager.Start()
// Stop immediately
manager.Stop()
// Manager should stop cleanly
manager.mu.Lock()
running := manager.cancel != nil
manager.mu.Unlock()
assert.False(t, running)
}
func TestCleanupManager_MultipleStarts(t *testing.T) {
store := &mockSessionStore{}
logger := log.WithField("test", "cleanup")
manager := NewCleanupManager(store, logger)
// Start multiple times
manager.Start()
manager.Start() // Should be no-op
manager.Start() // Should be no-op
// Stop
manager.Stop()
// Should still stop cleanly
manager.mu.Lock()
running := manager.cancel != nil
manager.mu.Unlock()
assert.False(t, running)
}
func TestCleanupManager_MultipleStops(t *testing.T) {
store := &mockSessionStore{}
logger := log.WithField("test", "cleanup")
manager := NewCleanupManager(store, logger)
manager.Start()
// Stop multiple times
manager.Stop()
manager.Stop() // Should be no-op
manager.Stop() // Should be no-op
// Should still be stopped
manager.mu.Lock()
running := manager.cancel != nil
manager.mu.Unlock()
assert.False(t, running)
}
func TestCleanupManager_ErrorHandling(t *testing.T) {
store := &mockSessionStore{}
logger := log.WithField("test", "cleanup")
manager := NewCleanupManager(store, logger)
// Set the store to fail
store.SetShouldFail(true)
// Run cleanup manually: should handle error gracefully
manager.runCleanup()
// Should not panic and cleanup count should be 0
count := store.GetCleanupCount()
assert.Equal(t, 0, count)
}

View File

@@ -1,72 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<meta name="color-scheme" content="light dark" />
<title>{{.Title}}</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 100%;
}
body {
font-size: 16px;
font-size: clamp(16px, 3dvw, 20px);
background-color: #000;
background-color: Canvas;
color: #fff;
color: CanvasText;
font-family: system-ui, ui-sans-serif, sans-serif;
display: grid;
place-content: center;
place-items: center;
min-height: 100dvh;
margin: 0;
gap: 1rem;
}
hr {
width: 100%;
border-color: Field;
}
.logo {
height: 2.75rem;
max-width: 90dvw;
}
</style>
</head>
<body>
<svg
class="logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 144.29"
aria-label="authentik"
aria-role="heading"
aria-level="1"
preserveAspectRatio="xMidYMid meet"
>
<path
fill="currentColor"
d="M106 41.08h25.39v101.2H106v-10.7a50 50 0 0 1-14.92 10.19 41.84 41.84 0 0 1-16.21 3.11q-19.61 0-33.91-15.21T26.64 91.86q0-23.43 13.85-38.41t33.63-15a42.78 42.78 0 0 1 17.09 3.44A46.82 46.82 0 0 1 106 52.24ZM79.29 61.91a25.65 25.65 0 0 0-19.56 8.33q-7.78 8.33-7.79 21.34t7.93 21.58a25.66 25.66 0 0 0 19.51 8.47 26.15 26.15 0 0 0 19.84-8.33q7.88-8.33 7.88-21.81 0-13.2-7.88-21.39t-19.93-8.19ZM168.39 41.08h25.67v48.74q0 14.22 2 19.76a17.24 17.24 0 0 0 6.29 8.61 18.06 18.06 0 0 0 10.65 3.07 18.6 18.6 0 0 0 10.77-3 17.7 17.7 0 0 0 6.57-8.88q1.59-4.36 1.59-18.7v-49.6h25.39V84q0 26.51-4.18 36.27a39.6 39.6 0 0 1-15.07 18.28q-10 6.38-25.3 6.37-16.65 0-26.93-7.44t-14.48-20.78q-3-9.21-3-33.49ZM297.3 3.78h25.39v37.3h15.07v21.85h-15.07v79.35H297.3V62.93h-13V41.08h13ZM362.86 2h25.21v49.3a57.74 57.74 0 0 1 15-9.63 38.56 38.56 0 0 1 15.25-3.21 34.36 34.36 0 0 1 25.39 10.42q8.83 9 8.84 26.51v66.88h-25V97.91q0-17.58-1.68-23.81t-5.71-9.3a16.07 16.07 0 0 0-10-3.07 18.85 18.85 0 0 0-13.26 5.11q-5.53 5.11-7.67 14-1.12 4.56-1.12 20.84v40.65h-25.25ZM589.91 99h-81.58q1.77 10.78 9.44 17.16t19.58 6.37a33.86 33.86 0 0 0 24.46-10l21.4 10a50.54 50.54 0 0 1-19.16 16.79q-11.16 5.44-26.51 5.44-23.82 0-38.79-15t-15-37.63q0-23.16 14.93-38.46t37.44-15.3q23.91 0 38.88 15.3t15 40.42Zm-25.4-20a25.48 25.48 0 0 0-9.92-13.77A28.81 28.81 0 0 0 537.4 60a30.42 30.42 0 0 0-18.64 5.95q-5 3.72-9.31 13.12ZM621.89 41.08h25.39v10.37q8.64-7.29 15.65-10.13a37.82 37.82 0 0 1 14.35-2.85A34.77 34.77 0 0 1 702.83 49q8.82 8.94 8.82 26.42v66.88h-25.11V98q0-18.12-1.63-24.06a16.44 16.44 0 0 0-5.66-9.06 15.8 15.8 0 0 0-10-3.11 18.73 18.73 0 0 0-13.23 5.15q-5.49 5.08-7.62 14.22-1.12 4.74-1.12 20.54v40.6h-25.39ZM750.71 3.78h25.39v37.3h15.07v21.85H776.1v79.35h-25.39V62.93h-13V41.08h13ZM826.09-.6a15.55 15.55 0 0 1 11.45 4.84A16.08 16.08 0 0 1 842.31 16a15.87 15.87 0 0 1-4.72 11.58 15.34 15.34 0 0 1-11.32 4.79 15.6 15.6 0 0 1-11.55-4.88 16.35 16.35 0 0 1-4.72-11.9 15.57 15.57 0 0 1 4.73-11.44A15.53 15.53 0 0 1 826.09-.6ZM813.39 41.08h25.39v101.2h-25.39zM873.47 2h25.39v80.8l37.39-41.72h31.89l-43.59 48.5 48.81 52.7h-31.53l-43-46.64v46.64h-25.36Z"
/>
</svg>
<hr />
<div role="alert" aria-live="assertive">
<h1>{{ .Title }}</h1>
<p>{{ .Message }}</p>
</div>
</body>
</html>

View File

@@ -1,19 +0,0 @@
package templates
import (
_ "embed"
"html/template"
log "github.com/sirupsen/logrus"
)
//go:embed error.html
var ErrorTemplate string
func GetTemplates() *template.Template {
t, err := template.New("authentik.outpost.proxy.errors").Parse(ErrorTemplate)
if err != nil {
log.Fatalf("failed parsing template %s", err)
}
return t
}

View File

@@ -1,23 +0,0 @@
package types
type ProxyClaims struct {
UserAttributes map[string]any `json:"user_attributes" mapstructure:"user_attributes"`
BackendOverride string `json:"backend_override" mapstructure:"backend_override"`
HostHeader string `json:"host_header" mapstructure:"host_header"`
IsSuperuser bool `json:"is_superuser" mapstructure:"is_superuser"`
}
type Claims struct {
Sub string `json:"sub" mapstructure:"sub"`
Exp int `json:"exp" mapstructure:"exp"`
Email string `json:"email" mapstructure:"email"`
Verified bool `json:"email_verified" mapstructure:"email_verified"`
Name string `json:"name" mapstructure:"name"`
PreferredUsername string `json:"preferred_username" mapstructure:"preferred_username"`
Groups []string `json:"groups" mapstructure:"groups"`
Entitlements []string `json:"entitlements" mapstructure:"entitlements"`
Sid string `json:"sid" mapstructure:"sid"`
Proxy *ProxyClaims `json:"ak_proxy" mapstructure:"ak_proxy"`
RawToken string `json:"raw_token" mapstructure:"raw_token"`
}

View File

@@ -1,29 +0,0 @@
package proxyv2
import (
"context"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2/types"
)
func (ps *ProxyServer) handleWSMessage(ctx context.Context, msg ak.Event) error {
if msg.Instruction != ak.EventKindSessionEnd {
return nil
}
mmsg := ak.EventArgsSessionEnd{}
err := msg.ArgsAs(&mmsg)
if err != nil {
return err
}
for _, p := range ps.apps {
ps.log.WithField("provider", p.Host).Debug("Logging out")
err := p.Logout(ctx, func(c types.Claims) bool {
return c.Sid == mmsg.SessionID
})
if err != nil {
ps.log.WithField("provider", p.Host).WithError(err).Warning("failed to logout")
}
}
return nil
}

View File

@@ -1,91 +0,0 @@
// https://github.com/gorilla/handlers/issues/259#issuecomment-2671695039
package web
import (
"bufio"
"net"
"net/http"
"github.com/gorilla/handlers"
)
// compressHandler is an HTTP handler that adds the Content-Encoding header
// back to responses when removed by the http.FileServer.
//
// handlers.CompressHandler(newCompressHandler(http.FileServer(...)))
type compressHandler struct {
// handler is an HTTP handler, usually an http.FileServer.
handler http.Handler
}
var _ http.Handler = &compressHandler{}
func NewCompressHandler(handler http.Handler) http.Handler {
h := &compressHandler{
handler: handler,
}
return handlers.CompressHandler(h)
}
func (h *compressHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// The wrapped response writer saves the incoming content encoding so
// it can be restored when writing the response headers.
cw := &compressedResponseWriter{
encoding: w.Header().Get("Content-Encoding"),
fixed: false,
responseWriter: w,
}
h.handler.ServeHTTP(cw, r)
}
// compressedResponseWriter is an http.ResponseWriter that ensures that a
// previously-set Content-Encoding header is in place before writing the
// response.
type compressedResponseWriter struct {
encoding string
fixed bool
responseWriter http.ResponseWriter
}
var _ http.ResponseWriter = &compressedResponseWriter{}
func (w *compressedResponseWriter) Header() http.Header {
return w.responseWriter.Header()
}
func (w *compressedResponseWriter) fixContentEncoding() {
if w.fixed {
return
}
w.fixed = true
// The Go 1.23 http.FileServer() removes headers like Content-Encoding
// from error responses. This breaks gzip and deflate encoding.
// https://github.com/gorilla/handlers/issues/259
// https://github.com/golang/go/issues/66343
if w.encoding == "gzip" || w.encoding == "deflate" {
if w.Header().Get("Content-Encoding") == "" {
w.Header().Set("Content-Encoding", w.encoding)
}
}
}
func (w *compressedResponseWriter) Write(data []byte) (int, error) {
w.fixContentEncoding()
return w.responseWriter.Write(data)
}
func (w *compressedResponseWriter) WriteHeader(statusCode int) {
w.fixContentEncoding()
w.responseWriter.WriteHeader(statusCode)
}
func (w *compressedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := w.responseWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, http.ErrNotSupported
}
// Ensure our compressedResponseWriter implements the necessary interfaces.
var _ http.ResponseWriter = &compressedResponseWriter{}
var _ http.Hijacker = &compressedResponseWriter{}

View File

@@ -1,53 +0,0 @@
package web
import (
"context"
"net"
"net/http"
"github.com/gorilla/handlers"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
)
type allowedProxyRequestContext string
const allowedProxyRequest allowedProxyRequestContext = ""
func IsRequestFromTrustedProxy(r *http.Request) bool {
return r.Context().Value(allowedProxyRequest) != nil
}
// ProxyHeaders Set proxy headers like X-Forwarded-For and such, but only if the direct connection
// comes from a client that's in a list of trusted CIDRs
func ProxyHeaders() func(http.Handler) http.Handler {
nets := []*net.IPNet{}
for _, rn := range config.Get().Listen.TrustedProxyCIDRs {
_, cidr, err := net.ParseCIDR(rn)
if err != nil {
continue
}
nets = append(nets, cidr)
}
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
// remoteAddr will be nil if the IP cannot be parsed
remoteAddr := net.ParseIP(host)
for _, allowedCidr := range nets {
if remoteAddr != nil && allowedCidr.Contains(remoteAddr) {
log.WithField("remoteAddr", remoteAddr).WithField("cidr", allowedCidr.String()).Trace("Setting proxy headers")
rr := r.WithContext(context.WithValue(r.Context(), allowedProxyRequest, true))
handlers.ProxyHeaders(h).ServeHTTP(w, rr)
return
}
}
}
// Request is not directly coming from a CIDR we "trust"
// so set XFF to the direct host IP
r.Header.Set("X-Forwarded-For", host)
h.ServeHTTP(w, r)
})
}
}

View File

@@ -1,36 +0,0 @@
package web
import (
"net/http"
"net/url"
log "github.com/sirupsen/logrus"
)
type hostInterceptor struct {
inner http.RoundTripper
host string
scheme string
}
func (t hostInterceptor) RoundTrip(r *http.Request) (*http.Response, error) {
if r.Host != t.host {
r.Host = t.host
r.Header.Set("X-Forwarded-Proto", t.scheme)
}
return t.inner.RoundTrip(r)
}
func NewHostInterceptor(inner *http.Client, host string) *http.Client {
aku, err := url.Parse(host)
if err != nil {
log.WithField("host", host).WithError(err).Warn("failed to parse host")
}
return &http.Client{
Transport: hostInterceptor{
inner: inner.Transport,
host: aku.Host,
scheme: aku.Scheme,
},
}
}

View File

@@ -1,32 +0,0 @@
package web
import (
"net"
"time"
log "github.com/sirupsen/logrus"
)
// tcpKeepAliveListener sets TCP keep-alive timeouts on accepted
// connections. It's used by ListenAndServe and ListenAndServeTLS so
// dead TCP connections (e.g. closing laptop mid-download) eventually
// go away.
type TCPKeepAliveListener struct {
*net.TCPListener
}
func (ln TCPKeepAliveListener) Accept() (net.Conn, error) {
tc, err := ln.AcceptTCP()
if err != nil {
return nil, err
}
err = tc.SetKeepAlive(true)
if err != nil {
log.WithError(err).Warning("Error setting Keep-Alive")
}
err = tc.SetKeepAlivePeriod(3 * time.Minute)
if err != nil {
log.WithError(err).Warning("Error setting Keep-Alive period")
}
return tc, nil
}

View File

@@ -1,28 +0,0 @@
package web
import (
"net/http"
"time"
"goauthentik.io/internal/config"
)
func durationOrFallback(raw string, fallback time.Duration) time.Duration {
p, err := time.ParseDuration(raw)
if err != nil {
return fallback
}
return p
}
func Server(h http.Handler) *http.Server {
c := config.Get()
return &http.Server{
Handler: h,
ReadHeaderTimeout: durationOrFallback(c.Web.TimeoutHttpReadHeader, 5*time.Second),
ReadTimeout: durationOrFallback(c.Web.TimeoutHttpRead, 30*time.Second),
WriteTimeout: durationOrFallback(c.Web.TimeoutHttpWrite, 60*time.Second),
IdleTimeout: durationOrFallback(c.Web.TimeoutHttpIdle, 120*time.Second),
MaxHeaderBytes: http.DefaultMaxHeaderBytes,
}
}

View File

@@ -1,17 +0,0 @@
package web
import (
"net/http"
"strings"
)
func DisableIndex(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,117 +0,0 @@
package brand_tls
import (
"context"
"crypto/tls"
"crypto/x509"
"strings"
"time"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/crypto"
"goauthentik.io/internal/outpost/ak"
api "goauthentik.io/packages/client-go"
)
type Watcher struct {
client *api.APIClient
log *log.Entry
cs *ak.CryptoStore
fallback *tls.Certificate
brands []api.Brand
}
func NewWatcher(client *api.APIClient) *Watcher {
cs := ak.NewCryptoStore(client.CryptoAPI)
l := log.WithField("logger", "authentik.router.brand_tls")
cert, err := crypto.GenerateSelfSignedCert()
if err != nil {
l.WithError(err).Error("failed to generate default cert")
}
return &Watcher{
client: client,
log: l,
cs: cs,
fallback: &cert,
}
}
func (w *Watcher) Start() {
ticker := time.NewTicker(time.Minute * 3)
w.log.Info("Starting Brand TLS Checker")
for ; true; <-ticker.C {
w.Check()
}
}
func (w *Watcher) Check() {
w.log.Info("updating brand certificates")
brands, err := ak.Paginator(w.client.CoreAPI.CoreBrandsList(context.Background()), ak.PaginatorOptions{
PageSize: 100,
Logger: w.log,
})
if err != nil {
w.log.WithError(err).Warning("failed to get brands")
return
}
for _, b := range brands {
kp := b.GetWebCertificate()
if kp != "" {
err := w.cs.AddKeypair(kp)
if err != nil {
w.log.WithError(err).WithField("kp", kp).Warning("failed to add web certificate")
}
}
for _, crt := range b.GetClientCertificates() {
if crt != "" {
err := w.cs.AddKeypair(crt)
if err != nil {
w.log.WithError(err).WithField("kp", kp).Warning("failed to add client certificate")
}
}
}
}
w.brands = brands
}
type CertificateConfig struct {
Web *tls.Certificate
Client *x509.CertPool
}
func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) *CertificateConfig {
var bestSelection *api.Brand
config := CertificateConfig{
Web: w.fallback,
}
for _, t := range w.brands {
if !t.WebCertificate.IsSet() && len(t.GetClientCertificates()) < 1 {
continue
}
if *t.Default {
bestSelection = &t
}
if strings.HasSuffix(ch.ServerName, t.Domain) {
bestSelection = &t
}
}
if bestSelection == nil {
return &config
}
if bestSelection.GetWebCertificate() != "" {
if cert := w.cs.Get(bestSelection.GetWebCertificate()); cert != nil {
config.Web = cert
}
}
if len(bestSelection.GetClientCertificates()) > 0 {
config.Client = x509.NewCertPool()
for _, kp := range bestSelection.GetClientCertificates() {
if cert := w.cs.Get(kp); cert != nil {
config.Client.AddCert(cert.Leaf)
}
}
}
return &config
}

View File

@@ -1,57 +0,0 @@
package web
import (
"fmt"
"io"
"net/http"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
"goauthentik.io/internal/utils/sentry"
)
var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "authentik_main_request_duration_seconds",
Help: "API request latencies in seconds",
}, []string{"dest"})
func (ws *WebServer) runMetricsServer(listen string) {
l := log.WithField("logger", "authentik.router.metrics")
m := mux.NewRouter()
m.Use(sentry.SentryNoSampleMiddleware)
m.Path("/metrics").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
promhttp.InstrumentMetricHandler(
prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
DisableCompression: true,
}),
).ServeHTTP(rw, r)
// Get upstream metrics
re, err := http.NewRequest("GET", fmt.Sprintf("%s%s-/metrics/", ws.upstreamURL.String(), config.Get().Web.Path), nil)
if err != nil {
l.WithError(err).Warning("failed to get upstream metrics")
return
}
res, err := ws.upstreamHttpClient().Do(re)
if err != nil {
l.WithError(err).Warning("failed to get upstream metrics")
return
}
_, err = io.Copy(rw, res.Body)
if err != nil {
l.WithError(err).Warning("failed to get upstream metrics")
return
}
})
l.WithField("listen", listen).Info("Starting Metrics server")
err := http.ListenAndServe(listen, m)
if err != nil {
l.WithError(err).Warning("Failed to start metrics server")
}
l.WithField("listen", listen).Info("Stopping Metrics server")
}

View File

@@ -1,208 +0,0 @@
package web
import (
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"goauthentik.io/internal/config"
"goauthentik.io/internal/utils/sentry"
"goauthentik.io/internal/utils/web"
staticWeb "goauthentik.io/web"
)
var ErrAuthentikStarting = errors.New("authentik starting")
const (
maxBodyBytes = 32 * 1024 * 1024
)
var djangoHTTPMethods = map[string]struct{}{
http.MethodGet: {},
http.MethodHead: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodPatch: {},
http.MethodDelete: {},
http.MethodOptions: {},
http.MethodTrace: {},
}
func handleUnsupportedHTTPMethod(rw http.ResponseWriter, r *http.Request) bool {
if _, ok := djangoHTTPMethods[r.Method]; ok {
return false
}
http.Error(rw, "Unsupported HTTP method.", http.StatusNotImplemented)
return true
}
func (ws *WebServer) configureProxy() {
// Reverse proxy to the application server
director := func(req *http.Request) {
req.URL.Scheme = ws.upstreamURL.Scheme
req.URL.Host = ws.upstreamURL.Host
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
if !web.IsRequestFromTrustedProxy(req) {
// If the request isn't coming from a trusted proxy, delete MTLS headers
req.Header.Del("SSL-Client-Cert") // nginx-ingress
req.Header.Del("X-Forwarded-TLS-Client-Cert") // traefik
req.Header.Del("X-Forwarded-Client-Cert") // envoy
}
if req.TLS != nil {
req.Header.Set("X-Forwarded-Proto", "https")
if len(req.TLS.PeerCertificates) > 0 {
pems := make([]string, len(req.TLS.PeerCertificates))
for i, crt := range req.TLS.PeerCertificates {
pem := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: crt.Raw,
})
pems[i] = "Cert=" + url.QueryEscape(string(pem))
}
req.Header.Set("X-Forwarded-Client-Cert", strings.Join(pems, ","))
}
}
ws.log.WithField("url", req.URL.String()).WithField("headers", req.Header).Trace("tracing request to backend")
}
rp := &httputil.ReverseProxy{
Director: director,
Transport: ws.upstreamHttpClient().Transport,
}
rp.ErrorHandler = ws.proxyErrorHandler
rp.ModifyResponse = ws.proxyModifyResponse
ws.mainRouter.Path("/-/health/live/").HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) {
if ws.upstreamHealthcheck() {
rw.WriteHeader(200)
} else {
rw.WriteHeader(502)
}
}))
ws.mainRouter.PathPrefix(config.Get().Web.Path).Path("/-/health/live/").HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) {
if ws.upstreamHealthcheck() {
rw.WriteHeader(200)
} else {
rw.WriteHeader(502)
}
}))
ws.mainRouter.PathPrefix(config.Get().Web.Path).HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) {
if !ws.g.IsRunning() {
ws.proxyErrorHandler(rw, r, ErrAuthentikStarting)
return
}
before := time.Now()
if ws.ProxyServer != nil && ws.ProxyServer.HandleHost(rw, r) {
elapsed := time.Since(before)
Requests.With(prometheus.Labels{
"dest": "embedded_outpost",
}).Observe(float64(elapsed) / float64(time.Second))
return
}
if handleUnsupportedHTTPMethod(rw, r) {
return
}
r.Body = http.MaxBytesReader(rw, r.Body, maxBodyBytes)
rp.ServeHTTP(rw, r)
elapsed := time.Since(before)
Requests.With(prometheus.Labels{
"dest": "core",
}).Observe(float64(elapsed) / float64(time.Second))
}))
}
func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request, err error) {
accept := req.Header.Get("Accept")
header := rw.Header()
if errors.Is(err, ErrAuthentikStarting) {
header.Set("Retry-After", "5")
if strings.Contains(accept, "application/json") {
header.Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusServiceUnavailable)
err = json.NewEncoder(rw).Encode(map[string]string{
"error": "authentik starting",
})
if err != nil {
ws.log.WithError(err).Warning("failed to write error message")
return
}
} else if strings.Contains(accept, "text/html") {
header.Set("Content-Type", "text/html")
rw.WriteHeader(http.StatusServiceUnavailable)
loadingSplashFile, err := staticWeb.StaticDir.Open("standalone/loading/startup.html")
if err != nil {
ws.log.WithError(err).Warning("failed to open startup splash screen")
return
}
loadingSplashHTML, err := io.ReadAll(loadingSplashFile)
if err != nil {
ws.log.WithError(err).Warning("failed to read startup splash screen")
return
}
_, err = rw.Write(loadingSplashHTML)
if err != nil {
ws.log.WithError(err).Warning("failed to write startup splash screen")
return
}
} else {
header.Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusServiceUnavailable)
// Fallback to just a status message
_, err = rw.Write([]byte("authentik starting"))
if err != nil {
ws.log.WithError(err).Warning("failed to write initializing HTML")
}
}
return
}
ws.log.WithError(err).Warning("failed to proxy to backend")
em := fmt.Sprintf("failed to connect to authentik backend: %v", err)
if strings.Contains(accept, "application/json") {
header.Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadGateway)
err = json.NewEncoder(rw).Encode(map[string]string{
"error": em,
})
} else {
header.Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusBadGateway)
_, err = rw.Write([]byte(em))
}
if err != nil {
ws.log.WithError(err).Warning("failed to write error message")
}
}
func (ws *WebServer) proxyModifyResponse(r *http.Response) error {
r.Header.Set("X-Powered-By", "authentik")
r.Header.Del("Server")
return nil
}

View File

@@ -1,195 +0,0 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/go-http-utils/etag"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"goauthentik.io/internal/config"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/utils/web"
staticWeb "goauthentik.io/web"
)
type StorageClaims struct {
jwt.RegisteredClaims
Path string `json:"path,omitempty"`
}
func storageTokenIsValid(usage string, r *http.Request) bool {
tokenString := r.URL.Query().Get("token")
if tokenString == "" {
return false
}
claims := &StorageClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
key := fmt.Appendf(nil, "%s:%s", config.Get().SecretKey, usage)
hash := sha256.Sum256(key)
hexDigest := hex.EncodeToString(hash[:])
return []byte(hexDigest), nil
})
if err != nil || !token.Valid {
return false
}
now := time.Now()
if claims.ExpiresAt != nil && claims.ExpiresAt.Before(now) {
return false
}
if claims.NotBefore != nil && claims.NotBefore.After(now) {
return false
}
if claims.Path != fmt.Sprintf("%s/%s", usage, r.URL.Path) {
return false
}
return true
}
func (ws *WebServer) configureStatic() {
// Setup routers
staticRouter := ws.loggingRouter.NewRoute().Subrouter()
staticRouter.Use(ws.staticHeaderMiddleware)
staticRouter.Use(web.DisableIndex)
distFs := http.FileServer(http.Dir("./web/dist"))
pathStripper := func(handler http.Handler, paths ...string) http.Handler {
h := handler
for _, path := range paths {
h = http.StripPrefix(path, h)
}
return h
}
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/static/dist/").Handler(pathStripper(
distFs,
"static/dist/",
config.Get().Web.Path,
))
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/static/authentik/").Handler(pathStripper(
http.FileServer(http.Dir("./web/authentik")),
"static/authentik/",
config.Get().Web.Path,
))
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/flow/{flow_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
pathStripper(
distFs,
"if/flow/"+vars["flow_slug"],
config.Get().Web.Path,
).ServeHTTP(rw, r)
})
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/admin/assets").Handler(http.StripPrefix(fmt.Sprintf("%sif/admin", config.Get().Web.Path), distFs))
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/user/assets").Handler(http.StripPrefix(fmt.Sprintf("%sif/user", config.Get().Web.Path), distFs))
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/rac/{app_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
pathStripper(
distFs,
"if/rac/"+vars["app_slug"],
config.Get().Web.Path,
).ServeHTTP(rw, r)
})
// Files, if backend is file
defaultBackend := config.Get().Storage.Backend
if defaultBackend == "" {
defaultBackend = "file"
}
mediaBackend := config.Get().Storage.Media.Backend
if mediaBackend == "" {
mediaBackend = defaultBackend
}
reportsBackend := config.Get().Storage.Reports.Backend
if reportsBackend == "" {
reportsBackend = defaultBackend
}
defaultStoragePath := config.Get().Storage.File.Path
if defaultStoragePath == "" {
defaultStoragePath = "./data"
}
if mediaBackend == "file" {
mediaPath := config.Get().Storage.Media.File.Path
if mediaPath == "" {
mediaPath = defaultStoragePath
}
mediaPath = mediaPath + "/media"
fsMedia := http.FileServer(http.Dir(mediaPath))
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/files/media/").Handler(pathStripper(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !storageTokenIsValid("media", r) {
http.Error(w, "404 page not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
fsMedia.ServeHTTP(w, r)
}),
"files/media/",
config.Get().Web.Path,
))
}
if reportsBackend == "file" {
reportsPath := config.Get().Storage.Reports.File.Path
if reportsPath == "" {
reportsPath = defaultStoragePath
}
reportsPath = reportsPath + "/reports"
fsReports := http.FileServer(http.Dir(reportsPath))
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/files/reports/").Handler(pathStripper(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !storageTokenIsValid("reports", r) {
http.Error(w, "404 page not found", http.StatusNotFound)
return
}
fsReports.ServeHTTP(w, r)
}),
"files/reports/",
config.Get().Web.Path,
))
}
staticRouter.PathPrefix(config.Get().Web.Path).Path("/robots.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header()["Content-Type"] = []string{"text/plain"}
rw.WriteHeader(200)
_, err := rw.Write(staticWeb.RobotsTxt)
if err != nil {
ws.log.WithError(err).Warning("failed to write response")
}
})
staticRouter.PathPrefix(config.Get().Web.Path).Path("/.well-known/security.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header()["Content-Type"] = []string{"text/plain"}
rw.WriteHeader(200)
_, err := rw.Write(staticWeb.SecurityTxt)
if err != nil {
ws.log.WithError(err).Warning("failed to write response")
}
})
}
func (ws *WebServer) staticHeaderMiddleware(h http.Handler) http.Handler {
etagHandler := etag.Handler(h, false)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, no-transform")
w.Header().Set("X-authentik-version", constants.VERSION())
etagHandler.ServeHTTP(w, r)
})
}

View File

@@ -1,288 +0,0 @@
package web
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/pires/go-proxyproto"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/gounicorn"
"goauthentik.io/internal/outpost/proxyv2"
"goauthentik.io/internal/utils"
"goauthentik.io/internal/utils/unix"
"goauthentik.io/internal/utils/web"
"goauthentik.io/internal/web/brand_tls"
api "goauthentik.io/packages/client-go"
)
const (
SocketName = "authentik.sock"
IPCKeyFile = "authentik-core-ipc.key"
CoreSocketName = "authentik-core.sock"
)
type WebServer struct {
Bind string
BindTLS bool
stop chan struct{} // channel for waiting shutdown
ProxyServer *proxyv2.ProxyServer
BrandTLS *brand_tls.Watcher
g *gounicorn.GoUnicorn
gunicornReady bool
mainRouter *mux.Router
loggingRouter *mux.Router
log *log.Entry
upstreamClient *http.Client
upstreamURL *url.URL
ipcKey string
}
func NewWebServer() *WebServer {
l := log.WithField("logger", "authentik.router")
mainHandler := mux.NewRouter()
mainHandler.Use(web.ProxyHeaders())
mainHandler.Use(web.NewCompressHandler)
loggingHandler := mainHandler.NewRoute().Subrouter()
loggingHandler.Use(web.NewLoggingHandler(l, nil))
tmp := os.TempDir()
socketPath := path.Join(tmp, CoreSocketName)
// create http client to talk to backend, normal client if we're in debug more
// and a client that connects to our socket when in non debug mode
var upstreamClient *http.Client
if config.Get().Debug {
upstreamClient = http.DefaultClient
} else {
upstreamClient = &http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
},
}
}
u, _ := url.Parse("http://localhost:8000")
ws := &WebServer{
mainRouter: mainHandler,
loggingRouter: loggingHandler,
log: l,
gunicornReady: false,
upstreamClient: upstreamClient,
upstreamURL: u,
}
ws.mainRouter.PathPrefix(config.Get().Web.Path).Path("/-/metrics/").Handler(http.NotFoundHandler())
ws.configureStatic()
ws.configureProxy()
// Redirect for sub-folder
if sp := config.Get().Web.Path; sp != "/" {
ws.mainRouter.Path("/").Handler(http.RedirectHandler(sp, http.StatusFound))
}
ws.g = gounicorn.New(func() bool {
return ws.upstreamHealthcheck()
})
return ws
}
func (ws *WebServer) upstreamHealthcheck() bool {
hcUrl := fmt.Sprintf("%s%s-/health/live/", ws.upstreamURL.String(), config.Get().Web.Path)
req, err := http.NewRequest(http.MethodGet, hcUrl, nil)
if err != nil {
ws.log.WithError(err).Warning("failed to create request for healthcheck")
return false
}
req.Header.Set("User-Agent", "goauthentik.io/router/healthcheck")
res, err := ws.upstreamHttpClient().Do(req)
if err == nil && res.StatusCode >= 200 && res.StatusCode < 300 {
return true
}
return false
}
func (ws *WebServer) prepareKeys() {
tmp := os.TempDir()
key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64))
err := os.WriteFile(path.Join(tmp, IPCKeyFile), []byte(key), 0o600)
if err != nil {
ws.log.WithError(err).Warning("failed to save ipc key")
return
}
ws.ipcKey = key
}
func (ws *WebServer) Start() {
ws.prepareKeys()
socketPath := path.Join(os.TempDir(), SocketName)
u, err := url.Parse(fmt.Sprintf("http://localhost%s", config.Get().Web.Path))
if err != nil {
panic(err)
}
apiConfig := api.NewConfiguration()
apiConfig.Host = u.Host
apiConfig.Scheme = u.Scheme
apiConfig.HTTPClient = &http.Client{
Transport: web.NewUserAgentTransport(
constants.UserAgentIPC(),
&http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
},
),
}
apiConfig.Servers = api.ServerConfigurations{
{
URL: fmt.Sprintf("%sapi/v3", u.Path),
},
}
apiConfig.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", ws.ipcKey))
// create the API client, with the transport
apiClient := api.NewAPIClient(apiConfig)
// Init brand_tls here too since it requires an API Client,
// so we just reuse the same one as the outpost uses
tw := brand_tls.NewWatcher(apiClient)
ws.BrandTLS = tw
ws.g.AddHealthyCallback(func() {
go tw.Start()
})
for _, listen := range config.Get().Listen.Metrics {
go ws.runMetricsServer(listen)
}
go ws.attemptStartBackend()
_ = os.Remove(socketPath)
go ws.listenUnix(socketPath)
for _, listen := range config.Get().Listen.HTTP {
go ws.listenPlain(listen)
}
for _, listen := range config.Get().Listen.HTTPS {
go ws.listenTLS(listen)
}
}
func (ws *WebServer) attemptStartBackend() {
for {
if ws.gunicornReady {
return
}
err := ws.g.Start()
ws.log.WithError(err).Warning("gunicorn process died, restarting")
if err != nil {
ws.log.WithError(err).Error("gunicorn failed to start, restarting")
continue
}
failedChecks := 0
for range time.NewTicker(30 * time.Second).C {
if !ws.g.IsRunning() {
ws.log.Warningf("gunicorn process failed healthcheck %d times", failedChecks)
failedChecks += 1
}
if failedChecks >= 3 {
ws.log.WithError(err).Error("gunicorn process failed healthcheck three times, restarting")
break
}
}
}
}
func (ws *WebServer) Core() *gounicorn.GoUnicorn {
return ws.g
}
func (ws *WebServer) upstreamHttpClient() *http.Client {
return ws.upstreamClient
}
func (ws *WebServer) Shutdown() {
ws.log.Info("shutting down gunicorn")
ws.g.Kill()
tmp := os.TempDir()
err := os.Remove(path.Join(tmp, IPCKeyFile))
if err != nil {
ws.log.WithError(err).Warning("failed to remove ipc key file")
}
ws.stop <- struct{}{}
}
func (ws *WebServer) listenUnix(listen string) {
ln, err := unix.Listen(listen)
if err != nil {
ws.log.WithField("listen", listen).WithError(err).Warning("failed to listen")
return
}
defer func() {
err := ln.Close()
_ = os.Remove(listen)
if err != nil {
ws.log.WithField("listen", listen).WithError(err).Warning("failed to close listener")
}
}()
ws.log.WithField("listen", listen).Info("Starting HTTP server")
ws.serve(ln)
ws.log.WithField("listen", listen).Info("Stopping HTTP server")
}
func (ws *WebServer) listenPlain(listen string) {
ln, err := net.Listen("tcp", listen)
if err != nil {
ws.log.WithField("listen", listen).WithError(err).Warning("failed to listen")
return
}
proxyListener := &proxyproto.Listener{Listener: ln, ConnPolicy: utils.GetProxyConnectionPolicy()}
defer func() {
err := proxyListener.Close()
if err != nil {
ws.log.WithField("listen", listen).WithError(err).Warning("failed to close proxy listener")
}
}()
ws.log.WithField("listen", listen).Info("Starting HTTP server")
ws.serve(proxyListener)
ws.log.WithField("listen", listen).Info("Stopping HTTP server")
}
func (ws *WebServer) serve(listener net.Listener) {
srv := web.Server(ws.mainRouter)
// See https://golang.org/pkg/net/http/#Server.Shutdown
idleConnsClosed := make(chan struct{})
go func() {
<-ws.stop // wait notification for stopping server
// We received an interrupt signal, shut down.
if err := srv.Shutdown(context.Background()); err != nil {
// Error from closing listeners, or context timeout:
ws.log.WithError(err).Warning("HTTP server Shutdown")
}
close(idleConnsClosed)
}()
err := srv.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
ws.log.WithError(err).Error("ERROR: http.Serve()")
}
<-idleConnsClosed
}

View File

@@ -1,72 +0,0 @@
package web
import (
"crypto/tls"
"net"
"github.com/pires/go-proxyproto"
"goauthentik.io/internal/crypto"
"goauthentik.io/internal/utils"
"goauthentik.io/internal/utils/web"
)
func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Config, error) {
fallback, err := crypto.GenerateSelfSignedCert()
if err != nil {
ws.log.WithError(err).Error("failed to generate default cert")
}
return func(ch *tls.ClientHelloInfo) (*tls.Config, error) {
cfg := utils.GetTLSConfig()
if ch.ServerName != "" && ws.ProxyServer != nil {
appCert := ws.ProxyServer.GetCertificate(ch.ServerName)
if appCert != nil {
cfg.Certificates = []tls.Certificate{*appCert}
return cfg, nil
}
}
if ws.BrandTLS != nil {
bcert := ws.BrandTLS.GetCertificate(ch)
cfg.Certificates = []tls.Certificate{*bcert.Web}
ws.log.Trace("using brand web Certificate")
if bcert.Client != nil {
cfg.ClientCAs = bcert.Client
cfg.ClientAuth = tls.RequestClientCert
ws.log.Trace("using brand client Certificate")
}
return cfg, nil
}
ws.log.Trace("using default, self-signed certificate")
cfg.Certificates = []tls.Certificate{fallback}
return cfg, nil
}
}
// ServeHTTPS constructs a net.Listener and starts handling HTTPS requests
func (ws *WebServer) listenTLS(listen string) {
tlsConfig := utils.GetTLSConfig()
tlsConfig.GetConfigForClient = ws.GetCertificate()
ln, err := net.Listen("tcp", listen)
if err != nil {
ws.log.WithField("listen", listen).WithError(err).Warning("failed to listen (TLS)")
return
}
proxyListener := &proxyproto.Listener{
Listener: web.TCPKeepAliveListener{
TCPListener: ln.(*net.TCPListener),
},
ConnPolicy: utils.GetProxyConnectionPolicy(),
}
defer func() {
err := proxyListener.Close()
if err != nil {
ws.log.WithError(err).Warning("failed to close proxy listener")
}
}()
tlsListener := tls.NewListener(proxyListener, tlsConfig)
ws.log.WithField("listen", listen).Info("Starting HTTPS server")
ws.serve(tlsListener)
ws.log.WithField("listen", listen).Info("Stopping HTTPS server")
}

View File

@@ -58,6 +58,6 @@ After re-creating the containers with `AUTHENTIK_DEBUGGER` set to `true` and the
If the authentik instance is running on a remote server, the `.vscode/launch.json` file needs to be adjusted to point to the IP of the remote server. Alternatively, you can forward the debug port via an SSH tunnel, using `-L 9901:127.0.0.1:9901`.
## authentik Server / Outposts (Golang)
## authentik Outposts (Golang)
Outposts, as well as some auxiliary code of the authentik server, are written in Go. These components can be debugged using standard Golang tooling, such as [Delve](https://github.com/go-delve/delve).
Outposts, except the proxy outpost, are written in Go. These components can be debugged using standard Golang tooling, such as [Delve](https://github.com/go-delve/delve).