feat(openvpn): add OIDC browser authentication and setup guide

This commit is contained in:
opsx committed 2026-10-20 23:31:46 +08:00
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 {