diff --git a/.env.example b/.env.example index e7c2f36e..02962d02 100644 --- a/.env.example +++ b/.env.example @@ -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") diff --git a/cmd/server.go b/cmd/server.go index faa68523..e8dde72f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -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" @@ -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", @@ -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) @@ -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) diff --git a/docs/self-hosting/environment-variables.mdx b/docs/self-hosting/environment-variables.mdx index b6479e32..500ac51d 100644 --- a/docs/self-hosting/environment-variables.mdx +++ b/docs/self-hosting/environment-variables.mdx @@ -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. @@ -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. | - diff --git a/internal/oauth/managed.go b/internal/oauth/managed.go new file mode 100644 index 00000000..e811bf82 --- /dev/null +++ b/internal/oauth/managed.go @@ -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) + + 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 +} diff --git a/internal/oauth/managed_test.go b/internal/oauth/managed_test.go new file mode 100644 index 00000000..868a2937 --- /dev/null +++ b/internal/oauth/managed_test.go @@ -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") + } + }) + } +} diff --git a/internal/server/handle_credentials.go b/internal/server/handle_credentials.go index 4f01c4c8..17bd482d 100644 --- a/internal/server/handle_credentials.go +++ b/internal/server/handle_credentials.go @@ -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 @@ -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 diff --git a/internal/server/handle_oauth.go b/internal/server/handle_oauth.go index e1db7010..0bea28b0 100644 --- a/internal/server/handle_oauth.go +++ b/internal/server/handle_oauth.go @@ -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"` @@ -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 } @@ -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 @@ -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 != "" diff --git a/internal/server/handle_spa.go b/internal/server/handle_spa.go index 6863f5f0..2762cab4 100644 --- a/internal/server/handle_spa.go +++ b/internal/server/handle_spa.go @@ -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 diff --git a/internal/server/managed_oauth.go b/internal/server/managed_oauth.go new file mode 100644 index 00000000..99b717c0 --- /dev/null +++ b/internal/server/managed_oauth.go @@ -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 +} + +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 "" +} diff --git a/internal/server/managed_oauth_test.go b/internal/server/managed_oauth_test.go new file mode 100644 index 00000000..32a184e7 --- /dev/null +++ b/internal/server/managed_oauth_test.go @@ -0,0 +1,87 @@ +package server + +import ( + "testing" + + "github.com/Infisical/agent-vault/internal/oauth" +) + +func testManagedGoogleProvider() oauth.ManagedProvider { + return oauth.ManagedProvider{ + ID: "google", + AuthorizationURL: "https://accounts.example.com/authorize", + TokenURL: "https://accounts.example.com/token", + ClientID: "managed-client-id", + ClientSecret: "managed-client-secret", + TokenAuthMethod: "client_secret_post", + } +} + +func TestApplyManagedOAuthProvider(t *testing.T) { + srv := newTestServer() + srv.SetManagedOAuthProviders([]oauth.ManagedProvider{testManagedGoogleProvider()}) + + req := oauthConnectRequest{ + Provider: "google", + AuthorizationURL: "https://attacker.example/authorize", + TokenURL: "https://attacker.example/token", + ClientID: "attacker-client-id", + ClientSecret: "attacker-client-secret", + TokenAuthMethod: "client_secret_basic", + } + if err := srv.applyManagedOAuthProvider(&req); err != nil { + t.Fatalf("applyManagedOAuthProvider: %v", err) + } + + provider := testManagedGoogleProvider() + if req.AuthorizationURL != provider.AuthorizationURL { + t.Errorf("AuthorizationURL = %q, want %q", req.AuthorizationURL, provider.AuthorizationURL) + } + if req.TokenURL != provider.TokenURL { + t.Errorf("TokenURL = %q, want %q", req.TokenURL, provider.TokenURL) + } + if req.ClientID != provider.ClientID { + t.Errorf("ClientID = %q, want %q", req.ClientID, provider.ClientID) + } + if req.ClientSecret != provider.ClientSecret { + t.Errorf("ClientSecret = %q, want managed secret", req.ClientSecret) + } + if req.TokenAuthMethod != provider.TokenAuthMethod { + t.Errorf("TokenAuthMethod = %q, want %q", req.TokenAuthMethod, provider.TokenAuthMethod) + } +} + +func TestApplyManagedOAuthProviderRejectsUnknownProvider(t *testing.T) { + srv := newTestServer() + req := oauthConnectRequest{Provider: "google"} + + if err := srv.applyManagedOAuthProvider(&req); err == nil { + t.Fatal("applyManagedOAuthProvider succeeded for an unconfigured provider") + } +} + +func TestManagedOAuthProviderForConfig(t *testing.T) { + srv := newTestServer() + provider := testManagedGoogleProvider() + srv.SetManagedOAuthProviders([]oauth.ManagedProvider{provider}) + + if got := srv.managedOAuthProviderForConfig(provider.AuthorizationURL, provider.TokenURL, provider.ClientID); got != "google" { + t.Errorf("managedOAuthProviderForConfig = %q, want google", got) + } + if got := srv.managedOAuthProviderForConfig(provider.AuthorizationURL, "https://attacker.example/token", provider.ClientID); got != "" { + t.Errorf("managedOAuthProviderForConfig = %q for mismatched token URL, want empty", got) + } +} + +func TestManagedOAuthProviderIDsSorted(t *testing.T) { + srv := newTestServer() + google := testManagedGoogleProvider() + github := google + github.ID = "github" + srv.SetManagedOAuthProviders([]oauth.ManagedProvider{google, github}) + + got := srv.managedOAuthProviderIDs() + if len(got) != 2 || got[0] != "github" || got[1] != "google" { + t.Fatalf("managedOAuthProviderIDs = %v, want [github google]", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 3e1df30a..8e9ca0b5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -59,18 +59,18 @@ type agentVaultJSON struct { // Server is the Agent Vault HTTP server. type Server struct { - httpServer *http.Server - store Store - encKey []byte // 32-byte encryption key, held in memory while running - notifier *notify.Notifier - initialized bool // true when at least one owner account exists - lastInitCheck atomic.Int64 // unix-millis of last DB check for initialization (throttle) - baseURL string // externally-reachable base URL (e.g. "https://sb.example.com") - skillCLI []byte // embedded CLI skill content (served at GET /v1/skills/cli) - mitm *mitm.Proxy // transparent MITM proxy; nil only when --mitm-port 0 - logger *slog.Logger // structured logger for per-request observability - rateLimit *ratelimit.Registry // tiered rate limiter; shared with the MITM ingress - logSink requestlog.Sink // per-request persistence sink; never nil (Nop default) + httpServer *http.Server + store Store + encKey []byte // 32-byte encryption key, held in memory while running + notifier *notify.Notifier + initialized bool // true when at least one owner account exists + lastInitCheck atomic.Int64 // unix-millis of last DB check for initialization (throttle) + baseURL string // externally-reachable base URL (e.g. "https://sb.example.com") + skillCLI []byte // embedded CLI skill content (served at GET /v1/skills/cli) + mitm *mitm.Proxy // transparent MITM proxy; nil only when --mitm-port 0 + logger *slog.Logger // structured logger for per-request observability + rateLimit *ratelimit.Registry // tiered rate limiter; shared with the MITM ingress + logSink requestlog.Sink // per-request persistence sink; never nil (Nop default) // touchCache short-circuits per-request session-touch writes. With // db.SetMaxOpenConns(1), every UPDATE — even a no-op — opens the // single WAL writer slot. Caching the last-touch wall-clock per @@ -86,9 +86,10 @@ type Server struct { infisicalSyncer *infisical.Syncer // infisicalDynamic resolves Infisical dynamic-secret leases on demand; built // in Run alongside the syncer when a client is attached. Nil disables it. - infisicalDynamic *infisical.DynamicResolver - oauthRefresher *oauth.Refresher - telemetry *telemetry.Telemetry + infisicalDynamic *infisical.DynamicResolver + oauthRefresher *oauth.Refresher + managedOAuthProviders map[string]oauth.ManagedProvider + telemetry *telemetry.Telemetry } // lockVaultServices acquires the per-vault mutation lock via the store's diff --git a/web/src/lib/oauthProviders.ts b/web/src/lib/oauthProviders.ts index 62019ad9..a75189e3 100644 --- a/web/src/lib/oauthProviders.ts +++ b/web/src/lib/oauthProviders.ts @@ -1,5 +1,6 @@ // Built-in OAuth provider presets for the credential form. Selecting one -// prefills the endpoint fields; users always supply their own client ID/secret. +// prefills the endpoint fields. An instance operator may also manage the +// provider's client ID and secret. export interface ScopePreset { value: string; description: string; @@ -43,10 +44,15 @@ export const OAUTH_PROVIDERS: OAuthProviderPreset[] = [ { value: "openid", description: "OpenID Connect authentication" }, { value: "email", description: "View user email address" }, { value: "profile", description: "View basic profile info" }, + { value: "https://www.googleapis.com/auth/calendar.readonly", description: "View Google Calendar" }, { value: "https://www.googleapis.com/auth/calendar", description: "Manage Google Calendar" }, + { value: "https://www.googleapis.com/auth/drive.metadata.readonly", description: "View Google Drive file metadata" }, { value: "https://www.googleapis.com/auth/drive", description: "Full access to Google Drive" }, { value: "https://www.googleapis.com/auth/gmail.readonly", description: "Read Gmail messages" }, + { value: "https://www.googleapis.com/auth/gmail.modify", description: "Read and manage Gmail messages" }, { value: "https://www.googleapis.com/auth/spreadsheets", description: "Read and write Google Sheets" }, + { value: "https://www.googleapis.com/auth/documents", description: "Read and write Google Docs" }, + { value: "https://www.googleapis.com/auth/presentations", description: "Read and write Google Slides" }, ], }, { diff --git a/web/src/pages/vault/CredentialsTab.tsx b/web/src/pages/vault/CredentialsTab.tsx index 39b2884a..a4b08e64 100644 --- a/web/src/pages/vault/CredentialsTab.tsx +++ b/web/src/pages/vault/CredentialsTab.tsx @@ -17,7 +17,7 @@ import { OAUTH_PROVIDERS } from "../../lib/oauthProviders"; export default function CredentialsTab() { const router = useRouter(); - const { vaultName, vaultRole, credentialStore } = useVaultParams(); + const { vaultName, vaultRole, credentialStore, managedOAuthProviders } = useVaultParams(); const externalKind = credentialStore?.kind; const isExternal = !!externalKind; const pollSecs = credentialStore?.poll_interval_seconds; @@ -33,6 +33,7 @@ export default function CredentialsTab() { scopes?: string; client_secret?: string; token_auth_method?: string; + managed_provider?: string; access_token?: string; refresh_token?: string; unavailable?: boolean; @@ -488,6 +489,7 @@ export default function CredentialsTab() { {modalOpen && ( c.key === editingKey) : undefined} onClose={() => { @@ -512,8 +514,8 @@ interface Entry { value: string; } -function CredentialModal({ vaultName, editingKey, editingCred, onClose, onSaved }: { - vaultName: string; editingKey: string | null; editingCred?: { type?: string; authorization_url?: string; token_url?: string; client_id?: string; scopes?: string; client_secret?: string; token_auth_method?: string; access_token?: string; refresh_token?: string }; onClose: () => void; onSaved: () => void; +function CredentialModal({ vaultName, managedOAuthProviders, editingKey, editingCred, onClose, onSaved }: { + vaultName: string; managedOAuthProviders: string[]; editingKey: string | null; editingCred?: { type?: string; authorization_url?: string; token_url?: string; client_id?: string; scopes?: string; client_secret?: string; token_auth_method?: string; managed_provider?: string; access_token?: string; refresh_token?: string }; onClose: () => void; onSaved: () => void; }) { const isEdit = editingKey !== null; const editType = editingCred?.type; @@ -530,6 +532,7 @@ function CredentialModal({ vaultName, editingKey, editingCred, onClose, onSaved const [oauthClientId, setOauthClientId] = useState(editingCred?.client_id ?? ""); const [oauthClientSecret, setOauthClientSecret] = useState(editingCred?.client_secret ?? ""); const [oauthTokenAuthMethod, setOauthTokenAuthMethod] = useState(editingCred?.token_auth_method ?? (editingCred?.client_secret ? "client_secret_post" : "none")); + const [oauthProviderId, setOauthProviderId] = useState(editingCred?.managed_provider ?? ""); const [oauthScopes, setOauthScopes] = useState(editingCred?.scopes ? editingCred.scopes.split(" ").filter(Boolean) : []); const [oauthAccessToken, setOauthAccessToken] = useState(editingCred?.access_token ?? ""); const [oauthRefreshToken, setOauthRefreshToken] = useState(editingCred?.refresh_token ?? ""); @@ -545,8 +548,11 @@ function CredentialModal({ vaultName, editingKey, editingCred, onClose, onSaved const [oauthMode, setOauthMode] = useState<"connect" | "upload">(!isEdit || editingCred?.authorization_url ? "connect" : "upload"); const isTokenUpload = oauthMode === "upload"; + const currentProvider = OAUTH_PROVIDERS.find((p) => p.authorizationUrl === oauthAuthUrl || p.tokenUrl === oauthTokenUrl); + const isManagedProvider = !!oauthProviderId && managedOAuthProviders.includes(oauthProviderId); + const scopeOptions = (currentProvider?.scopes ?? []).map((s) => ({ value: s.value, description: s.description })); const canSubmitStatic = entries.every((e) => e.key.trim() && e.value.trim()); - const canSubmitOAuthConnect = !!(oauthKey.trim() && oauthTokenUrl.trim() && oauthClientId.trim() && oauthAuthUrl.trim()); + const canSubmitOAuthConnect = !!(oauthKey.trim() && oauthTokenUrl.trim() && (isManagedProvider || oauthClientId.trim()) && oauthAuthUrl.trim()); const canSubmitOAuthTokens = !!(oauthKey.trim() && (oauthAccessToken.trim() || oauthRefreshToken.trim())); const canSubmit = credType === "static" ? canSubmitStatic : isTokenUpload ? canSubmitOAuthTokens : oauthConnected; @@ -573,19 +579,25 @@ function CredentialModal({ vaultName, editingKey, editingCred, onClose, onSaved } catch (err: unknown) { setError(err instanceof Error ? err.message : "An error occurred."); } finally { setSaving(false); } } - // The "custom" pinned option intentionally falls through the guard below: - // selecting it just closes the list and leaves all fields free-form. + // Selecting the pinned custom option leaves endpoint fields free-form and + // disables any instance-managed provider. const customOption = { id: "custom", label: "Custom Provider", sublabel: "Enter provider manually", pinned: true }; - const currentProvider = OAUTH_PROVIDERS.find((p) => p.authorizationUrl === oauthAuthUrl || p.tokenUrl === oauthTokenUrl); - const scopeOptions = (currentProvider?.scopes ?? []).map((s) => ({ value: s.value, description: s.description })); - function applyProvider(id: string) { + if (id === "custom") { + setOauthProviderId(""); + return; + } const p = OAUTH_PROVIDERS.find((p) => p.id === id); if (!p) return; + setOauthProviderId(p.id); setOauthAuthUrl(p.authorizationUrl); setOauthTokenUrl(p.tokenUrl); setOauthTokenAuthMethod(p.tokenAuthMethod); + if (managedOAuthProviders.includes(p.id)) { + setOauthClientId(""); + setOauthClientSecret(""); + } if (!isEdit) setOauthKey(p.suggestedKey); setOauthScopes([]); } @@ -595,7 +607,7 @@ function CredentialModal({ vaultName, editingKey, editingCred, onClose, onSaved try { const resp = await apiFetch("/v1/credentials/oauth/connect", { method: "POST", - body: JSON.stringify({ vault: vaultName, key: oauthKey.trim(), authorization_url: oauthAuthUrl.trim(), token_url: oauthTokenUrl.trim(), client_id: oauthClientId.trim(), client_secret: oauthClientSecret.trim() || undefined, scopes: oauthScopes.join(" ") || undefined, token_auth_method: oauthTokenAuthMethod === "none" ? undefined : oauthTokenAuthMethod }), + body: JSON.stringify({ vault: vaultName, key: oauthKey.trim(), provider: isManagedProvider ? oauthProviderId : undefined, authorization_url: oauthAuthUrl.trim(), token_url: oauthTokenUrl.trim(), client_id: isManagedProvider ? undefined : oauthClientId.trim(), client_secret: isManagedProvider ? undefined : oauthClientSecret.trim() || undefined, scopes: oauthScopes.join(" ") || undefined, token_auth_method: isManagedProvider || oauthTokenAuthMethod === "none" ? undefined : oauthTokenAuthMethod }), }); if (!resp.ok) { const d = await resp.json(); throw new Error(d.error || "Failed to start OAuth."); } const data = await resp.json(); @@ -706,22 +718,26 @@ function CredentialModal({ vaultName, editingKey, editingCred, onClose, onSaved placeholder="e.g. https://accounts.google.com/o/oauth2/v2/auth" value={oauthAuthUrl} onChange={setOauthAuthUrl} - options={[...OAUTH_PROVIDERS.map((p) => ({ id: p.id, label: p.name, sublabel: p.authorizationUrl })), customOption]} + options={[...OAUTH_PROVIDERS.map((p) => ({ id: p.id, label: managedOAuthProviders.includes(p.id) ? `${p.name} (managed)` : p.name, sublabel: managedOAuthProviders.includes(p.id) ? "OAuth app configured by instance operator" : p.authorizationUrl })), customOption]} onSelect={applyProvider} /> setOauthTokenUrl(e.target.value)} /> -
-
setOauthClientId(e.target.value)} />
-
setOauthClientSecret(e.target.value)} type="password" />
-
- -
-
+ {isManagedProvider ? ( + OAuth application managed by this Agent Vault instance. Choose scopes, then connect your account. + ) : ( +
+
setOauthClientId(e.target.value)} />
+
setOauthClientSecret(e.target.value)} type="password" />
+
+ +
+
+ )}