Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
# AGENT_VAULT_SMTP_TLS_MODE=opportunistic
# AGENT_VAULT_SMTP_TLS_SKIP_VERIFY=false

# Managed Google OAuth application (optional). When both values are set, vault
# users can connect Google accounts without supplying their own OAuth client.
# Register {AGENT_VAULT_ADDR}/v1/oauth/callback as an authorized redirect URI.
# AGENT_VAULT_OAUTH_GOOGLE_CLIENT_ID=
# AGENT_VAULT_OAUTH_GOOGLE_CLIENT_SECRET=

# Network security (optional)
# AGENT_VAULT_ALLOW_PRIVATE_RANGES=false # default false blocks RFC-1918, loopback, link-local, CGN, IPv6 ULA. Set true to allow all
# AGENT_VAULT_NETWORK_ALLOWLIST= # comma-separated CIDRs/IPs to allow when AGENT_VAULT_ALLOW_PRIVATE_RANGES=false (e.g. "10.163.0.0/16,192.168.1.1")
Expand Down
16 changes: 16 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/Infisical/agent-vault/internal/infisical"
"github.com/Infisical/agent-vault/internal/mitm"
"github.com/Infisical/agent-vault/internal/notify"
"github.com/Infisical/agent-vault/internal/oauth"
"github.com/Infisical/agent-vault/internal/pidfile"
"github.com/Infisical/agent-vault/internal/requestlog"
"github.com/Infisical/agent-vault/internal/server"
Expand Down Expand Up @@ -73,6 +74,15 @@ func buildLogger(level slog.Level) *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
}

func configureManagedOAuthProviders(srv *server.Server) error {
providers, err := oauth.LoadManagedProvidersFromEnv()
if err != nil {
return fmt.Errorf("loading managed OAuth providers: %w", err)
}
srv.SetManagedOAuthProviders(providers)
return nil
}

var serverCmd = &cobra.Command{
Use: "server",
Short: "Start an Agent Vault server",
Expand Down Expand Up @@ -178,6 +188,9 @@ var serverCmd = &cobra.Command{
_ = os.Unsetenv("AGENT_VAULT_SMTP_PASSWORD")
notifier := notify.New(smtpCfg)
srv := server.New(addr, db, masterKey.Key(), notifier, initialized, baseURL, logger)
if err := configureManagedOAuthProviders(srv); err != nil {
return err
}
srv.SetSkills(skillCLI)
srv.AttachTelemetry(tel)
shutdownLogs := attachLogSink(srv, db, logger)
Expand Down Expand Up @@ -599,6 +612,9 @@ func runDetachedChild(host, addr string, mitmPort int, logger *slog.Logger, maxR
_ = os.Unsetenv("AGENT_VAULT_SMTP_PASSWORD")
notifier := notify.New(smtpCfg)
srv := server.New(addr, db, key, notifier, initialized, baseURL, logger)
if err := configureManagedOAuthProviders(srv); err != nil {
return err
}
srv.SetSkills(skillCLI)
srv.AttachTelemetry(tel)
shutdownLogs := attachLogSink(srv, db, logger)
Expand Down
12 changes: 11 additions & 1 deletion docs/self-hosting/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ description: "Configuration for deploying an instance of Agent Vault."
| `DB_MAX_IDLE_CONNS` | Optional (defaults to `10`) | Maximum number of idle Postgres connections kept in the pool per instance. Only applies when `DATABASE_URL` is set. See [connection pooling](/self-hosting/postgres#operational-notes). |
| `DB_CONN_MAX_LIFETIME` | Optional (defaults to `5m`) | Maximum lifetime of a Postgres connection before it is closed and replaced. Go duration string (e.g. `5m`, `1h`). Only applies when `DATABASE_URL` is set. See [connection pooling](/self-hosting/postgres#operational-notes). |

## Managed OAuth providers

An instance operator can configure a shared Google OAuth application. Vault users then choose **Google (managed)**, select scopes, and authorize their own Google account without creating or entering OAuth client credentials. Access and refresh tokens remain separate per vault.

Register `{AGENT_VAULT_ADDR}/v1/oauth/callback` as an authorized redirect URI on the Google OAuth Web application. Both variables are required to enable the managed provider.

| Variable | Required | Description |
|----------|----------|-------------|
| `AGENT_VAULT_OAUTH_GOOGLE_CLIENT_ID` | Conditional | Client ID for the instance-managed Google OAuth Web application. |
| `AGENT_VAULT_OAUTH_GOOGLE_CLIENT_SECRET` | Conditional | Client secret for the instance-managed Google OAuth Web application. Source it from the deployment platform's secret store. |

## Email SMTP configuration

Configure SMTP to enable Agent Vault to send emails for verification codes, vault invites, and notifications.
Expand Down Expand Up @@ -81,4 +92,3 @@ Agent Vault collects anonymous usage telemetry to help improve the product. No c
| Variable | Required | Description |
|----------|----------|-------------|
| `AGENT_VAULT_TELEMETRY` | Optional (defaults to `true`) | Set to `false` to disable anonymous usage telemetry. Also overridable with `--telemetry=false` on the CLI. |

54 changes: 54 additions & 0 deletions internal/oauth/managed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package oauth

import (
"fmt"
"os"
"strings"
)

const (
// GoogleOAuthClientIDEnv and GoogleOAuthClientSecretEnv configure the
// instance-managed Google OAuth application.
GoogleOAuthClientIDEnv = "AGENT_VAULT_OAUTH_GOOGLE_CLIENT_ID"
GoogleOAuthClientSecretEnv = "AGENT_VAULT_OAUTH_GOOGLE_CLIENT_SECRET"
)

// ManagedProvider is an OAuth application configured by the instance operator.
// Vault users authorize their own accounts, but do not need to create or supply
// an OAuth client.
type ManagedProvider struct {
ID string
AuthorizationURL string
TokenURL string
ClientID string
ClientSecret string
TokenAuthMethod string
}

// LoadManagedProvidersFromEnv loads operator-managed OAuth applications.
// A partially configured provider fails closed instead of falling back to
// user-supplied client credentials unexpectedly.
func LoadManagedProvidersFromEnv() ([]ManagedProvider, error) {
googleClientID := strings.TrimSpace(os.Getenv(GoogleOAuthClientIDEnv))
googleClientSecret := os.Getenv(GoogleOAuthClientSecretEnv)
Comment on lines +32 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 googleClientID is whitespace-trimmed (to tolerate copy-paste errors) but googleClientSecret is not. An operator who accidentally copies the secret with a trailing newline or space will get a secret stored verbatim — causing token-exchange failures that are hard to diagnose. Trim both for consistency.

Suggested change
googleClientID := strings.TrimSpace(os.Getenv(GoogleOAuthClientIDEnv))
googleClientSecret := os.Getenv(GoogleOAuthClientSecretEnv)
googleClientID := strings.TrimSpace(os.Getenv(GoogleOAuthClientIDEnv))
googleClientSecret := strings.TrimSpace(os.Getenv(GoogleOAuthClientSecretEnv))

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


if googleClientID == "" && googleClientSecret == "" {
return nil, nil
}
if googleClientID == "" || googleClientSecret == "" {
return nil, fmt.Errorf("%s and %s must be set together", GoogleOAuthClientIDEnv, GoogleOAuthClientSecretEnv)
}

// Keep the secret in process memory after startup, not in the inherited
// environment where child processes could read it.
_ = os.Unsetenv(GoogleOAuthClientSecretEnv)

return []ManagedProvider{{
ID: "google",
AuthorizationURL: "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent",
TokenURL: "https://oauth2.googleapis.com/token",
ClientID: googleClientID,
ClientSecret: googleClientSecret,
TokenAuthMethod: "client_secret_post",
}}, nil
}
72 changes: 72 additions & 0 deletions internal/oauth/managed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package oauth

import (
"os"
"strings"
"testing"
)

func TestLoadManagedProvidersFromEnvDisabled(t *testing.T) {
t.Setenv(GoogleOAuthClientIDEnv, "")
t.Setenv(GoogleOAuthClientSecretEnv, "")

providers, err := LoadManagedProvidersFromEnv()
if err != nil {
t.Fatalf("LoadManagedProvidersFromEnv: %v", err)
}
if len(providers) != 0 {
t.Fatalf("providers = %d, want 0", len(providers))
}
}

func TestLoadManagedProvidersFromEnvGoogle(t *testing.T) {
t.Setenv(GoogleOAuthClientIDEnv, " google-client-id ")
t.Setenv(GoogleOAuthClientSecretEnv, "google-client-secret")

providers, err := LoadManagedProvidersFromEnv()
if err != nil {
t.Fatalf("LoadManagedProvidersFromEnv: %v", err)
}
if len(providers) != 1 {
t.Fatalf("providers = %d, want 1", len(providers))
}

got := providers[0]
if got.ID != "google" {
t.Errorf("ID = %q, want google", got.ID)
}
if got.ClientID != "google-client-id" {
t.Errorf("ClientID = %q, want trimmed client ID", got.ClientID)
}
if got.ClientSecret != "google-client-secret" {
t.Errorf("ClientSecret = %q, want configured secret", got.ClientSecret)
}
if !strings.Contains(got.AuthorizationURL, "access_type=offline") || !strings.Contains(got.AuthorizationURL, "prompt=consent") {
t.Errorf("AuthorizationURL = %q, want offline consent parameters", got.AuthorizationURL)
}
if _, ok := os.LookupEnv(GoogleOAuthClientSecretEnv); ok {
t.Errorf("%s remained in environment", GoogleOAuthClientSecretEnv)
}
}

func TestLoadManagedProvidersFromEnvRejectsPartialConfig(t *testing.T) {
tests := []struct {
name string
clientID string
clientSecret string
}{
{name: "missing secret", clientID: "google-client-id"},
{name: "missing client ID", clientSecret: "google-client-secret"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(GoogleOAuthClientIDEnv, tt.clientID)
t.Setenv(GoogleOAuthClientSecretEnv, tt.clientSecret)

if _, err := LoadManagedProvidersFromEnv(); err == nil {
t.Fatal("LoadManagedProvidersFromEnv succeeded with partial config")
}
})
}
}
4 changes: 4 additions & 0 deletions internal/server/handle_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ type credentialEntry struct {
Scopes *string `json:"scopes,omitempty"`
ClientSecret *string `json:"client_secret,omitempty"`
TokenAuthMethod *string `json:"token_auth_method,omitempty"`
ManagedProvider *string `json:"managed_provider,omitempty"`
AccessToken *string `json:"access_token,omitempty"`
RefreshToken *string `json:"refresh_token,omitempty"`
// Unavailable marks a dynamic-secret row whose lease could not be minted
Expand Down Expand Up @@ -294,6 +295,9 @@ func (s *Server) enrichOAuthEntry(ctx context.Context, vaultID string, entry *cr
if co.TokenAuthMethod != "" {
entry.TokenAuthMethod = &co.TokenAuthMethod
}
if provider := s.managedOAuthProviderForConfig(co.AuthorizationURL, co.TokenURL, co.ClientID); provider != "" {
entry.ManagedProvider = &provider
}
if co.ConnectedAt != nil {
s := oauthSecretSentinel
entry.AccessToken = &s
Expand Down
26 changes: 15 additions & 11 deletions internal/server/handle_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const oauthSecretSentinel = "••••••••"
type oauthConnectRequest struct {
Vault string `json:"vault"`
Key string `json:"key"`
Provider string `json:"provider,omitempty"`
AuthorizationURL string `json:"authorization_url"`
TokenURL string `json:"token_url"`
ClientID string `json:"client_id"`
Expand All @@ -42,6 +43,10 @@ func (s *Server) handleOAuthConnect(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := s.applyManagedOAuthProvider(&req); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
if req.Vault == "" {
req.Vault = store.DefaultVault
}
Expand Down Expand Up @@ -118,17 +123,17 @@ func (s *Server) handleOAuthConnect(w http.ResponseWriter, r *http.Request) {
}

if err := s.store.SetCredentialOAuth(ctx, &store.CredentialOAuth{
VaultID: ns.ID,
CredentialKey: req.Key,
AuthorizationURL: req.AuthorizationURL,
TokenURL: req.TokenURL,
ClientID: req.ClientID,
ClientSecretCT: clientSecretCT,
VaultID: ns.ID,
CredentialKey: req.Key,
AuthorizationURL: req.AuthorizationURL,
TokenURL: req.TokenURL,
ClientID: req.ClientID,
ClientSecretCT: clientSecretCT,
ClientSecretNonce: clientSecretNonce,
Scopes: req.Scopes,
ScopeSeparator: scopeSep,
DisablePKCE: req.DisablePKCE,
TokenAuthMethod: tokenAuthMethod,
Scopes: req.Scopes,
ScopeSeparator: scopeSep,
DisablePKCE: req.DisablePKCE,
TokenAuthMethod: tokenAuthMethod,
}); err != nil {
jsonError(w, http.StatusInternalServerError, "Failed to save OAuth configuration")
return
Expand Down Expand Up @@ -553,7 +558,6 @@ func (s *Server) redirectOAuthComplete(w http.ResponseWriter, r *http.Request, v
http.Redirect(w, r, u, http.StatusFound)
}


func isValidHTTPURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != ""
Expand Down
5 changes: 3 additions & 2 deletions internal/server/handle_spa.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
// handleStatus returns the instance initialization status (public, no auth).
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"initialized": s.initialized,
"needs_first_user": !s.initialized,
"initialized": s.initialized,
"needs_first_user": !s.initialized,
"managed_oauth_providers": s.managedOAuthProviderIDs(),
}

// Expose base_url only when the operator has explicitly set
Expand Down
59 changes: 59 additions & 0 deletions internal/server/managed_oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package server

import (
"fmt"
"sort"

"github.com/Infisical/agent-vault/internal/oauth"
)

// SetManagedOAuthProviders configures OAuth applications supplied by the
// instance operator. It must be called before the server starts.
func (s *Server) SetManagedOAuthProviders(providers []oauth.ManagedProvider) {
s.managedOAuthProviders = make(map[string]oauth.ManagedProvider, len(providers))
for _, provider := range providers {
if provider.ID == "" {
continue
}
s.managedOAuthProviders[provider.ID] = provider
}
}

func (s *Server) managedOAuthProviderIDs() []string {
ids := make([]string, 0, len(s.managedOAuthProviders))
for id := range s.managedOAuthProviders {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}

func (s *Server) applyManagedOAuthProvider(req *oauthConnectRequest) error {
if req.Provider == "" {
return nil
}

provider, ok := s.managedOAuthProviders[req.Provider]
if !ok {
return fmt.Errorf("managed OAuth provider %q is not configured", req.Provider)
}

req.AuthorizationURL = provider.AuthorizationURL
req.TokenURL = provider.TokenURL
req.ClientID = provider.ClientID
req.ClientSecret = provider.ClientSecret
req.TokenAuthMethod = provider.TokenAuthMethod
return nil
Comment on lines +41 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The applyManagedOAuthProvider override leaves req.DisablePKCE untouched, meaning any API caller can send "disable_pkce": true and weaken the PKCE requirement for the managed Google flow. Since PKCE is a security control against authorization-code interception, its enablement should be dictated by the operator-defined provider config, not the caller.

Suggested change
req.AuthorizationURL = provider.AuthorizationURL
req.TokenURL = provider.TokenURL
req.ClientID = provider.ClientID
req.ClientSecret = provider.ClientSecret
req.TokenAuthMethod = provider.TokenAuthMethod
return nil
req.AuthorizationURL = provider.AuthorizationURL
req.TokenURL = provider.TokenURL
req.ClientID = provider.ClientID
req.ClientSecret = provider.ClientSecret
req.TokenAuthMethod = provider.TokenAuthMethod
req.DisablePKCE = false // managed providers always use PKCE
return nil

}

func (s *Server) managedOAuthProviderForConfig(authorizationURL, tokenURL, clientID string) string {
for _, id := range s.managedOAuthProviderIDs() {
provider := s.managedOAuthProviders[id]
if provider.AuthorizationURL == authorizationURL &&
provider.TokenURL == tokenURL &&
provider.ClientID == clientID {
return id
}
}
return ""
}
Loading