mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
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:
committed by
GitHub
parent
f123ab37f4
commit
98bfc45c9d
19
.github/workflows/ci-outpost.yml
vendored
19
.github/workflows/ci-outpost.yml
vendored
@@ -79,7 +79,6 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
type:
|
type:
|
||||||
- proxy
|
|
||||||
- ldap
|
- ldap
|
||||||
- radius
|
- radius
|
||||||
- rac
|
- rac
|
||||||
@@ -92,24 +91,6 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
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
|
- name: Build outpost
|
||||||
run: |
|
run: |
|
||||||
set -x
|
set -x
|
||||||
|
|||||||
@@ -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 |
|
| 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. | — |
|
| **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. | — |
|
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, RAC, RADIUS). | — |
|
||||||
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
|
| **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) |
|
| **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) |
|
| **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)
|
authentik/ # Django core — the IdP itself (see "The authentik Django package" below)
|
||||||
lifecycle/ # Boot/runtime: migrations, gunicorn config, the `ak` CLI, container + AWS entrypoints
|
lifecycle/ # Boot/runtime: migrations, gunicorn config, the `ak` CLI, container + AWS entrypoints
|
||||||
cmd/ # Go entrypoints: ldap/ proxy/ rac/ radius/ outposts + server/ (front reverse-proxy)
|
cmd/ # Go entrypoints: ldap/ rac/ radius/ outposts
|
||||||
internal/ # Shared Go: outpost implementations, config, web proxy, gounicorn process manager
|
internal/ # Shared Go: outpost implementations, config
|
||||||
src/ # Rust server/worker (ak-axum based; gated behind cargo features)
|
src/ # Rust server/worker (ak-axum based; gated behind cargo features)
|
||||||
packages/ # Shared workspace packages, polyglot:
|
packages/ # Shared workspace packages, polyglot:
|
||||||
# client-go / client-rust / client-ts — GENERATED API clients (do not hand-edit)
|
# client-go / client-rust / client-ts — GENERATED API clients (do not hand-edit)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "os"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
err := rootCmd.Execute()
|
|
||||||
if err != nil {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
18
go.mod
@@ -6,20 +6,15 @@ require (
|
|||||||
beryju.io/ldap v0.2.2
|
beryju.io/ldap v0.2.2
|
||||||
beryju.io/radius-eap v0.1.1
|
beryju.io/radius-eap v0.1.1
|
||||||
github.com/avast/retry-go/v4 v4.7.0
|
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/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-ldap/ldap/v3 v3.4.14
|
||||||
github.com/go-openapi/runtime v0.33.0
|
github.com/go-openapi/runtime v0.33.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/handlers v1.5.2
|
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/gorilla/sessions v1.4.0
|
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/grafana/pyroscope-go v1.4.2
|
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/jellydator/ttlcache/v3 v3.4.1
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||||
@@ -30,12 +25,8 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.12.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/wwt/guac v1.3.2
|
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
|
golang.org/x/sync v0.22.0
|
||||||
gopkg.in/yaml.v2 v2.4.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
|
layeh.com/radius v0.0.0-20231213012653-1006025d24f8
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,11 +34,7 @@ require (
|
|||||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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-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/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/analysis v0.25.5 // 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/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect
|
github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // 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/klauspost/compress v1.19.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||||
|
|||||||
45
go.sum
45
go.sum
@@ -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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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/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/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 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
|
||||||
github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
|
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 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
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 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
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 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
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=
|
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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
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 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
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.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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
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=
|
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/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 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
|
||||||
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
|
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 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
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=
|
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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
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=
|
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/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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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.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 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
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.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 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
|
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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
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.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/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=
|
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.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 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/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/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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
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 h1:orYXpi6BJZdvgytfHH4ybOe4wHnLbbS71Cmd8mWdZjs=
|
||||||
layeh.com/radius v0.0.0-20231213012653-1006025d24f8/go.mod h1:QRf+8aRqXc019kHkpcs/CTgyWXFzf+bxlsyuo2nAl1o=
|
layeh.com/radius v0.0.0-20231213012653-1006025d24f8/go.mod h1:QRf+8aRqXc019kHkpcs/CTgyWXFzf+bxlsyuo2nAl1o=
|
||||||
|
|||||||
@@ -191,44 +191,6 @@ func (c *Config) parseScheme(rawVal string) string {
|
|||||||
return rawVal
|
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() {
|
func (c *Config) configureLogger() {
|
||||||
switch strings.ToLower(c.LogLevel) {
|
switch strings.ToLower(c.LogLevel) {
|
||||||
case "trace":
|
case "trace":
|
||||||
|
|||||||
@@ -10,22 +10,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigEnv(t *testing.T) {
|
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
|
cfg = nil
|
||||||
if err := Get().fromEnv(); err != nil {
|
if err := Get().fromEnv(); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
assert.Equal(t, "bar", Get().SecretKey)
|
assert.Equal(t, "bar", Get().LogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigEnv_Scheme(t *testing.T) {
|
func TestConfigEnv_Scheme(t *testing.T) {
|
||||||
assert.NoError(t, os.Setenv("foo", "bar"))
|
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
|
cfg = nil
|
||||||
if err := Get().fromEnv(); err != nil {
|
if err := Get().fromEnv(); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
assert.Equal(t, "bar", Get().SecretKey)
|
assert.Equal(t, "bar", Get().LogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigEnv_File(t *testing.T) {
|
func TestConfigEnv_File(t *testing.T) {
|
||||||
@@ -41,10 +41,10 @@ func TestConfigEnv_File(t *testing.T) {
|
|||||||
panic(err)
|
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
|
cfg = nil
|
||||||
if err := Get().fromEnv(); err != nil {
|
if err := Get().fromEnv(); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
assert.Equal(t, "bar", Get().SecretKey)
|
assert.Equal(t, "bar", Get().LogLevel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Core specific config
|
|
||||||
Storage StorageConfig `yaml:"storage"`
|
|
||||||
LogLevel string `yaml:"log_level" env:"AUTHENTIK_LOG_LEVEL, overwrite"`
|
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"`
|
|
||||||
|
|
||||||
// Config for both core and outposts
|
// Config for both core and outposts
|
||||||
Debug bool `yaml:"debug" env:"AUTHENTIK_DEBUG, overwrite"`
|
Debug bool `yaml:"debug" env:"AUTHENTIK_DEBUG, overwrite"`
|
||||||
Listen ListenConfig `yaml:"listen" env:", prefix=AUTHENTIK_LISTEN__"`
|
Listen ListenConfig `yaml:"listen" env:", prefix=AUTHENTIK_LISTEN__"`
|
||||||
Web WebConfig `yaml:"web" env:", prefix=AUTHENTIK_WEB__"`
|
|
||||||
Log LogConfig `yaml:"log" env:", prefix=AUTHENTIK_LOG__"`
|
Log LogConfig `yaml:"log" env:", prefix=AUTHENTIK_LOG__"`
|
||||||
LDAP LDAPConfig `yaml:"ldap" env:", prefix=AUTHENTIK_LDAP__"`
|
LDAP LDAPConfig `yaml:"ldap" env:", prefix=AUTHENTIK_LDAP__"`
|
||||||
|
|
||||||
@@ -27,32 +18,7 @@ type Config struct {
|
|||||||
AuthentikInsecure bool `env:"AUTHENTIK_INSECURE"`
|
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 {
|
type ListenConfig struct {
|
||||||
HTTP []string `yaml:"http" env:"HTTP, overwrite"`
|
|
||||||
HTTPS []string `yaml:"https" env:"HTTPS, overwrite"`
|
|
||||||
LDAP []string `yaml:"ldap" env:"LDAP, overwrite"`
|
LDAP []string `yaml:"ldap" env:"LDAP, overwrite"`
|
||||||
LDAPS []string `yaml:"ldaps" env:"LDAPS, overwrite"`
|
LDAPS []string `yaml:"ldaps" env:"LDAPS, overwrite"`
|
||||||
Radius []string `yaml:"radius" env:"RADIUS, 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"`
|
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 {
|
type LogConfig struct {
|
||||||
HttpHeaders []string `yaml:"http_headers" env:"HTTP_HEADERS, overwrite"`
|
HttpHeaders []string `yaml:"http_headers" env:"HTTP_HEADERS, overwrite"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"])
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package constants
|
|
||||||
|
|
||||||
const (
|
|
||||||
SessionOAuthState = "oauth_state"
|
|
||||||
SessionClaims = "claims"
|
|
||||||
)
|
|
||||||
|
|
||||||
const SessionRedirect = "redirect"
|
|
||||||
|
|
||||||
const HeaderAuthorization = "Authorization"
|
|
||||||
|
|
||||||
const AuthBearer = "Bearer "
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"})
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 ""
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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{}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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`.
|
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).
|
||||||
|
|||||||
Reference in New Issue
Block a user