feat(openvpn): add OIDC browser authentication and setup guide
This commit is contained in:
1 parent
5107f134dd
commit
d6a9a92ad0
13 files changed
+817
-14
No files matched your search
@@ -2,15 +2,153 @@ package openvpn
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestOIDCConfigAndSecrets(t *testing.T) {
|
||||
t.Setenv("VPN_DATA_DIR", t.TempDir())
|
||||
tunnel := &model.Tunnel{ID: 42, Protocol: "openvpn"}
|
||||
input := map[string]interface{}{
|
||||
"auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/team",
|
||||
"oidc_base_url": "https://vpn-login.example.com", "oidc_client_id": "vpn-client",
|
||||
"oidc_client_secret": "secret-\nadmin: true", "oidc_http_port": 19242,
|
||||
}
|
||||
cfg, err := New().Validate(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := prepareOIDC(tunnel, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := New().serverConfig(tunnel, cfg)
|
||||
for _, required := range []string{"management-client-auth", "auth-user-pass-optional", "verify-client-cert none"} {
|
||||
if !strings.Contains(conf, required) {
|
||||
t.Fatalf("OIDC server config missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(conf, "auth-user-pass-verify") || strings.Contains(conf, "secret-") {
|
||||
t.Fatal("OIDC server config must not use password helper or leak secrets")
|
||||
}
|
||||
path := filepath.Join(service.InstanceDir(tunnel), "openvpn-auth-oauth2.yaml")
|
||||
bytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(bytes), `secret: "secret-\nadmin: true"`) || strings.Contains(string(bytes), "\nadmin: true\n") {
|
||||
t.Fatal("OIDC client secret must remain one quoted YAML value")
|
||||
}
|
||||
if stat, err := os.Stat(path); err != nil || stat.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("auth configuration must be 0600: %v %v", stat, err)
|
||||
}
|
||||
passwordPath := filepath.Join(service.InstanceDir(tunnel), "management-password.txt")
|
||||
if stat, err := os.Stat(passwordPath); err != nil || stat.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("management password must be 0600: %v %v", stat, err)
|
||||
}
|
||||
public, secrets := service.MergeSecrets(cfg, "oidc_client_secret", "oidc_http_secret", "oidc_management_password")
|
||||
if public["oidc_client_secret"] != nil || secrets["oidc_client_secret"] == nil {
|
||||
t.Fatal("OIDC credentials must not be kept in public config")
|
||||
}
|
||||
tunnel.Config = service.ConfigJSON(public)
|
||||
dir := service.InstanceDir(tunnel)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "pki"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, content := range map[string]string{"pki/ca.crt": "test-ca\n", "ta.key": "test-tls-key\n"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
client, err := New().GenerateClientConfig(tunnel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(client, "auth-user-pass") || strings.Contains(client, "<cert>") || !strings.Contains(client, "<tls-crypt>") {
|
||||
t.Fatal("OIDC client profile must use WebAuth without password or client certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCRejectsUnsafeURLs(t *testing.T) {
|
||||
base := map[string]interface{}{
|
||||
"auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/a",
|
||||
"oidc_base_url": "https://login.example.com", "oidc_client_id": "vpn",
|
||||
}
|
||||
for _, value := range []string{"http://login.example.com", "https://login.example.com/extra", "https://user@login.example.com", "https://login.example.com/?code=abc"} {
|
||||
cfg := service.CloneConfig(base)
|
||||
cfg["oidc_base_url"] = value
|
||||
if _, err := New().Validate(cfg); err == nil {
|
||||
t.Errorf("expected invalid OIDC callback base URL: %q", value)
|
||||
}
|
||||
}
|
||||
if _, err := New().Validate(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base["auth_mode"] = "password"
|
||||
base["oidc_client_secret"] = "must-not-survive"
|
||||
cfg, err := New().Validate(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := cfg["oidc_client_secret"]; exists {
|
||||
t.Fatal("switching away from OIDC must remove its credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCRequiresOpenVPN262(t *testing.T) {
|
||||
for _, version := range []string{"OpenVPN 2.5.11 x86_64", "OpenVPN 2.6.1 x86_64", "unknown"} {
|
||||
if err := validateOIDCOpenVPNVersion([]byte(version)); err == nil {
|
||||
t.Errorf("expected %q to fail minimum version check", version)
|
||||
}
|
||||
}
|
||||
for _, version := range []string{"OpenVPN 2.6.2 x86_64", "OpenVPN 2.7.0 x86_64", "OpenVPN 3.0.0 x86_64"} {
|
||||
if err := validateOIDCOpenVPNVersion([]byte(version)); err != nil {
|
||||
t.Errorf("expected %q to pass: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCTunnelsNeedDistinctCallbackAndPort(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldDB := store.DB
|
||||
store.DB = db
|
||||
t.Cleanup(func() { store.DB = oldDB })
|
||||
if err := db.AutoMigrate(&model.Tunnel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := model.Tunnel{Name: "first", Protocol: "openvpn", Config: service.ConfigJSON(map[string]interface{}{
|
||||
"auth_mode": "oidc", "oidc_base_url": "https://login.example.com", "oidc_http_port": 19090,
|
||||
})}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := &model.Tunnel{ID: first.ID + 1, Protocol: "openvpn"}
|
||||
for _, cfg := range []map[string]interface{}{
|
||||
{"oidc_base_url": "https://login.example.com/", "oidc_http_port": 19091},
|
||||
{"oidc_base_url": "https://another.example.com", "oidc_http_port": 19090},
|
||||
} {
|
||||
if err := checkOIDCConflicts(other, cfg); err == nil {
|
||||
t.Errorf("expected conflict: %#v", cfg)
|
||||
}
|
||||
}
|
||||
if err := checkOIDCConflicts(other, map[string]interface{}{"oidc_base_url": "https://another.example.com", "oidc_http_port": 19091}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOpenVPNModes(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{"auth_mode": "password", "subnet": "10.8.0.0 255.255.255.0"})
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user