From bfd468e9608d03b5c5b4b887c43668d49cb109d7 Mon Sep 17 00:00:00 2001 From: Chris Ostrouchov Date: Sat, 25 Oct 2025 09:55:45 -0400 Subject: [PATCH 1/5] feat: Update credential model to support API key for Snowflake SPCS OIDC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snowflake SPCS deployments with OIDC now require both a Snowflake connection name and a Connect API key for authentication. This change updates the credential validation logic and account authentication type detection to support this new requirement. Changes: - credentials.go: Updated validation to require both SnowflakeConnection and ApiKey for ServerTypeSnowflake credentials - account.go: Modified AuthType() to prioritize Snowflake connection detection since it's the most specific case, and added documentation about the dual authentication requirement This aligns with changes in Snowflake SPCS where proxied authentication headers no longer carry sufficient user identification information, necessitating the use of Connect API keys in addition to Snowflake tokens. Related: https://github.com/posit-dev/rsconnect-python/pull/715 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/accounts/account.go | 9 +++++---- internal/credentials/credentials.go | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/accounts/account.go b/internal/accounts/account.go index 27f5d09c6..744dbcd86 100644 --- a/internal/accounts/account.go +++ b/internal/accounts/account.go @@ -29,11 +29,12 @@ type Account struct { // AuthType returns the detected AccountAuthType based on the properties of the // Account. func (acct *Account) AuthType() AccountAuthType { - // An account should have one of: API key, Snowflake connection name, or token+private key - if acct.ApiKey != "" { - return AuthTypeAPIKey - } else if acct.SnowflakeConnection != "" { + // Snowflake SPCS with OIDC requires both SnowflakeConnection AND ApiKey + // Check for Snowflake first since it's the most specific case + if acct.SnowflakeConnection != "" { return AuthTypeSnowflake + } else if acct.ApiKey != "" { + return AuthTypeAPIKey } else if acct.Token != "" && acct.PrivateKey != "" { return AuthTypeToken } diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 93e4223ae..1ffec53d7 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -272,7 +272,8 @@ func (details CreateCredentialDetails) ToCredential() (*Credential, error) { return nil, NewIncompleteCredentialError() } case server_type.ServerTypeSnowflake: - if !snowflakePresent || connectPresent || connectCloudPresent || tokenAuthPresent { + // Snowflake SPCS now requires both SnowflakeConnection AND ApiKey for OIDC authentication + if !snowflakePresent || !connectPresent || connectCloudPresent || tokenAuthPresent { return nil, NewIncompleteCredentialError() } case server_type.ServerTypeConnectCloud: From fedd60864f70bbbebec4a5629beca48de4b899bb Mon Sep 17 00:00:00 2001 From: Chris Ostrouchov Date: Sat, 25 Oct 2025 09:55:57 -0400 Subject: [PATCH 2/5] feat: Implement dual-header authentication for Snowflake SPCS OIDC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the authentication mechanism for Snowflake SPCS with OIDC support by sending both Snowflake tokens and Connect API keys in separate headers. Changes: - snowflake.go: - Added apiKey field to snowflakeAuthenticator struct - Updated NewSnowflakeAuthenticator to accept apiKey parameter - Modified AddAuthHeaders to set both Authorization (Snowflake token) and X-RSC-Authorization (Connect API key) headers - Enhanced documentation to explain the dual-header OIDC authentication - auth.go: - Updated NewClientAuth to pass the API key when creating Snowflake authenticators The Authorization header contains the Snowflake token for proxied authentication, while the X-RSC-Authorization header contains the Connect API key for OIDC authentication. This dual-header approach ensures proper authentication with Connect servers deployed in Snowflake SPCS. Related: https://github.com/posit-dev/rsconnect-python/pull/715 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/api_client/auth/auth.go | 2 ++ internal/api_client/auth/snowflake.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/internal/api_client/auth/auth.go b/internal/api_client/auth/auth.go index 18e843238..89602da62 100644 --- a/internal/api_client/auth/auth.go +++ b/internal/api_client/auth/auth.go @@ -28,9 +28,11 @@ func (af AuthFactory) NewClientAuth(acct *accounts.Account) (AuthMethod, error) case accounts.AuthTypeAPIKey: return NewApiKeyAuthenticator(acct.ApiKey, ""), nil case accounts.AuthTypeSnowflake: + // Snowflake SPCS with OIDC requires both Snowflake token and Connect API key auth, err := NewSnowflakeAuthenticator( af.connections, acct.SnowflakeConnection, + acct.ApiKey, ) if err != nil { return nil, err diff --git a/internal/api_client/auth/snowflake.go b/internal/api_client/auth/snowflake.go index 2245ec8f3..eb9b38f65 100644 --- a/internal/api_client/auth/snowflake.go +++ b/internal/api_client/auth/snowflake.go @@ -14,6 +14,7 @@ const headerName = "Authorization" type snowflakeAuthenticator struct { tokenProvider snowflake.TokenProvider + apiKey string } var _ AuthMethod = &snowflakeAuthenticator{} @@ -22,6 +23,10 @@ var _ AuthMethod = &snowflakeAuthenticator{} // from the system Snowflake configuration and returns an authenticator that // will add auth headers to requests. // +// For Snowflake SPCS with OIDC, this sets both: +// - Authorization header with the Snowflake token (for proxied auth) +// - X-RSC-Authorization header with the Connect API key (for Connect authentication) +// // Only supports keypair authentication. // // Errs if the named connection cannot be found, or if the connection does not @@ -29,6 +34,7 @@ var _ AuthMethod = &snowflakeAuthenticator{} func NewSnowflakeAuthenticator( connections snowflake.Connections, connectionName string, + apiKey string, ) (AuthMethod, error) { conn, err := connections.Get(connectionName) if err != nil { @@ -59,6 +65,7 @@ func NewSnowflakeAuthenticator( return &snowflakeAuthenticator{ tokenProvider: tokenProvider, + apiKey: apiKey, }, nil } @@ -68,7 +75,14 @@ func (a *snowflakeAuthenticator) AddAuthHeaders(req *http.Request) error { if err != nil { return err } + // Set Authorization header with Snowflake token for proxied authentication header := fmt.Sprintf(`Snowflake Token="%s"`, token) req.Header.Set(headerName, header) + + // Set X-RSC-Authorization header with Connect API key for OIDC authentication + if a.apiKey != "" { + apiKeyHeader := fmt.Sprintf("Key %s", a.apiKey) + req.Header.Set("X-RSC-Authorization", apiKeyHeader) + } return nil } From f7bf61c52f40a614545c9f16148abdee66f97f1e Mon Sep 17 00:00:00 2001 From: Chris Ostrouchov Date: Sat, 25 Oct 2025 09:56:08 -0400 Subject: [PATCH 3/5] test: Update tests for Snowflake SPCS OIDC authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates all tests to reflect the new dual-credential requirement for Snowflake SPCS authentication with OIDC support. Changes: - snowflake_test.go: - Updated all NewSnowflakeAuthenticator calls to include API key parameter - Added assertions to verify API key is properly stored in authenticator - Enhanced TestAddAuthHeaders to verify both Authorization and X-RSC-Authorization headers are set correctly - Added test case for authenticator without API key to ensure the header is only set when an API key is provided - file_test.go & keyring_test.go: - Updated Snowflake credential creation tests to include API key - Changed expected API key assertions from empty string to test API key All tests pass, confirming that the OIDC authentication changes work correctly while maintaining backward compatibility. Related: https://github.com/posit-dev/rsconnect-python/pull/715 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/api_client/auth/snowflake_test.go | 35 ++++++++++++++++++---- internal/credentials/file_test.go | 8 ++--- internal/credentials/keyring_test.go | 4 +-- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/internal/api_client/auth/snowflake_test.go b/internal/api_client/auth/snowflake_test.go index 60ad2445b..c16537b66 100644 --- a/internal/api_client/auth/snowflake_test.go +++ b/internal/api_client/auth/snowflake_test.go @@ -33,7 +33,7 @@ func (s *SnowflakeAuthSuite) TestNewSnowflakeAuthenticator() { connections := &snowflake.MockConnections{} connections.On("Get", ":name:").Return(&snowflake.Connection{}, errors.New("connection error")).Once() - _, err := NewSnowflakeAuthenticator(connections, ":name:") + _, err := NewSnowflakeAuthenticator(connections, ":name:", "test-api-key") s.ErrorContains(err, "connection error") // unsupported authenticator type @@ -41,7 +41,7 @@ func (s *SnowflakeAuthSuite) TestNewSnowflakeAuthenticator() { Authenticator: "fake", }, nil).Once() - _, err = NewSnowflakeAuthenticator(connections, ":name:") + _, err = NewSnowflakeAuthenticator(connections, ":name:", "test-api-key") s.EqualError(err, "unsupported authenticator type: fake") // errors from implementation are bubbled up @@ -50,7 +50,7 @@ func (s *SnowflakeAuthSuite) TestNewSnowflakeAuthenticator() { Authenticator: "snowflake_jwt", }, nil).Once() - _, err = NewSnowflakeAuthenticator(connections, ":name:") + _, err = NewSnowflakeAuthenticator(connections, ":name:", "test-api-key") s.ErrorContains(err, "error loading private key file: ") // JWT token provider @@ -61,12 +61,13 @@ func (s *SnowflakeAuthSuite) TestNewSnowflakeAuthenticator() { Authenticator: "snowflake_jwt", }, nil).Once() - auth, err := NewSnowflakeAuthenticator(connections, ":name:") + auth, err := NewSnowflakeAuthenticator(connections, ":name:", "test-api-key") s.NoError(err) sfauth, ok := auth.(*snowflakeAuthenticator) s.True(ok) s.NotNil(sfauth.tokenProvider) s.IsType(&snowflake.JWTTokenProvider{}, sfauth.tokenProvider) + s.Equal("test-api-key", sfauth.apiKey) // oauth token provider connections.On("Get", ":name:").Return(&snowflake.Connection{ @@ -75,12 +76,13 @@ func (s *SnowflakeAuthSuite) TestNewSnowflakeAuthenticator() { Authenticator: "oauth", }, nil).Once() - auth, err = NewSnowflakeAuthenticator(connections, ":name:") + auth, err = NewSnowflakeAuthenticator(connections, ":name:", "test-api-key") s.NoError(err) sfauth, ok = auth.(*snowflakeAuthenticator) s.True(ok) s.NotNil(sfauth.tokenProvider) s.IsType(&snowflake.OAuthTokenProvider{}, sfauth.tokenProvider) + s.Equal("test-api-key", sfauth.apiKey) } func (s *SnowflakeAuthSuite) TestAddAuthHeaders() { @@ -89,6 +91,7 @@ func (s *SnowflakeAuthSuite) TestAddAuthHeaders() { auth := &snowflakeAuthenticator{ tokenProvider: tokenProvider, + apiKey: "test-api-key", } req, err := http.NewRequest("GET", "https://example.snowflakecomputing.app/connect/#/content", nil) @@ -103,5 +106,27 @@ func (s *SnowflakeAuthSuite) TestAddAuthHeaders() { "Authorization": []string{ "Snowflake Token=\":atoken:\"", }, + "X-Rsc-Authorization": []string{ + "Key test-api-key", + }, }, req.Header) + + // Test without API key + tokenProvider.On("GetToken", "example.snowflakecomputing.app").Return(":atoken:", nil).Once() + authNoKey := &snowflakeAuthenticator{ + tokenProvider: tokenProvider, + apiKey: "", + } + + req2, err := http.NewRequest("GET", "https://example.snowflakecomputing.app/connect/#/content", nil) + s.NoError(err) + + err = authNoKey.AddAuthHeaders(req2) + s.NoError(err) + + s.Equal(http.Header{ + "Authorization": []string{ + "Snowflake Token=\":atoken:\"", + }, + }, req2.Header) } diff --git a/internal/credentials/file_test.go b/internal/credentials/file_test.go index c1a5bea40..2ee4dd1f7 100644 --- a/internal/credentials/file_test.go +++ b/internal/credentials/file_test.go @@ -486,13 +486,13 @@ func (s *FileCredentialsServiceSuite) TestSet() { ServerType: server_type.ServerTypeSnowflake, Name: "snowcred", URL: "https://example.snowflakecomputing.app/connect", - ApiKey: "", + ApiKey: "test-snowflake-api-key", SnowflakeConnection: "snowy"}) s.NoError(err) s.Equal(newcred3.Name, "snowcred") s.Equal(newcred3.URL, "https://example.snowflakecomputing.app/connect") - s.Equal(newcred3.ApiKey, "") + s.Equal(newcred3.ApiKey, "test-snowflake-api-key") s.Equal(newcred3.SnowflakeConnection, "snowy") creds, err = cs.load() @@ -529,7 +529,7 @@ func (s *FileCredentialsServiceSuite) TestSet() { Version: 3, ServerType: server_type.ServerTypeSnowflake, URL: "https://example.snowflakecomputing.app/connect", - ApiKey: "", + ApiKey: "test-snowflake-api-key", SnowflakeConnection: "snowy", }, }, @@ -587,7 +587,7 @@ func (s *FileCredentialsServiceSuite) TestSet() { Version: 3, ServerType: server_type.ServerTypeSnowflake, URL: "https://example.snowflakecomputing.app/connect", - ApiKey: "", + ApiKey: "test-snowflake-api-key", SnowflakeConnection: "snowy", }, "cloudy": { diff --git a/internal/credentials/keyring_test.go b/internal/credentials/keyring_test.go index 1e4084f41..c4481213f 100644 --- a/internal/credentials/keyring_test.go +++ b/internal/credentials/keyring_test.go @@ -171,13 +171,13 @@ func (s *KeyringCredentialsTestSuite) TestSet() { ServerType: server_type.ServerTypeSnowflake, Name: "sfexample", URL: "https://example.snowflakecomputing.app", - ApiKey: "", + ApiKey: "test-snowflake-api-key", SnowflakeConnection: "snow"}) s.NoError(err) s.NotNil(cred.GUID) s.Equal(cred.Name, "sfexample") s.Equal(cred.URL, "https://example.snowflakecomputing.app") - s.Equal(cred.ApiKey, "") + s.Equal(cred.ApiKey, "test-snowflake-api-key") s.Equal(cred.SnowflakeConnection, "snow") cred, err = cs.Set(CreateCredentialDetails{ From 82095a80d90a98e22aaf522ed7557cbb0928ed79 Mon Sep 17 00:00:00 2001 From: Chris Ostrouchov Date: Sat, 25 Oct 2025 10:09:15 -0400 Subject: [PATCH 4/5] feat: Add API key input step for Snowflake SPCS credentials in VSCode extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new input step in the VSCode extension credential creation flow to prompt users for a Connect API key when creating Snowflake SPCS credentials. Changes: - Added INPUT_SNOWFLAKE_API_KEY step to the credential creation flow - Implemented inputSnowflakeAPIKey() function that: - Prompts users for the Connect API key with password masking - Validates API key syntax using existing validation logic - Provides clear messaging about OIDC authentication requirements - Updated isValidSnowflakeAuth() to require both snowflakeConnection and apiKey - Modified inputSnowflakeConnection() to navigate to the API key input step before proceeding to credential naming The new flow for Snowflake SPCS credentials is: 1. Enter server URL 2. Select Snowflake connection 3. Enter Connect API key (NEW) 4. Name the credential This ensures users provide both authentication components needed for Snowflake SPCS deployments with OIDC authentication. Related: https://github.com/posit-dev/rsconnect-python/pull/715 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../multiStepInputs/newConnectCredential.ts | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/extensions/vscode/src/multiStepInputs/newConnectCredential.ts b/extensions/vscode/src/multiStepInputs/newConnectCredential.ts index d3b2c61c2..a58ea1052 100644 --- a/extensions/vscode/src/multiStepInputs/newConnectCredential.ts +++ b/extensions/vscode/src/multiStepInputs/newConnectCredential.ts @@ -81,6 +81,7 @@ export async function newConnectCredential( INPUT_SERVER_URL = "inputServerUrl", INPUT_API_KEY = "inputAPIKey", INPUT_SNOWFLAKE_CONN = "inputSnowflakeConnection", + INPUT_SNOWFLAKE_API_KEY = "inputSnowflakeAPIKey", INPUT_CRED_NAME = "inputCredentialName", INPUT_AUTH_METHOD = "inputAuthMethod", INPUT_TOKEN = "inputToken", @@ -93,6 +94,7 @@ export async function newConnectCredential( [step.INPUT_SERVER_URL]: inputServerUrl, [step.INPUT_API_KEY]: inputAPIKey, [step.INPUT_SNOWFLAKE_CONN]: inputSnowflakeConnection, + [step.INPUT_SNOWFLAKE_API_KEY]: inputSnowflakeAPIKey, [step.INPUT_CRED_NAME]: inputCredentialName, [step.INPUT_AUTH_METHOD]: inputAuthMethod, [step.INPUT_TOKEN]: inputToken, @@ -126,8 +128,12 @@ export async function newConnectCredential( }; const isValidSnowflakeAuth = () => { - // for Snowflake, require snowflakeConnection - return isSnowflake(serverType) && isString(state.data.snowflakeConnection); + // for Snowflake SPCS with OIDC, require both snowflakeConnection and apiKey + return ( + isSnowflake(serverType) && + isString(state.data.snowflakeConnection) && + isString(state.data.apiKey) + ); }; // *************************************************************** @@ -522,6 +528,57 @@ export async function newConnectCredential( state.data.snowflakeConnection = connections[pick.index].name; state.data.url = connections[pick.index].serverUrl; + return { + name: step.INPUT_SNOWFLAKE_API_KEY, + step: (input: MultiStepInput) => + steps[step.INPUT_SNOWFLAKE_API_KEY](input, state), + }; + } + + // *************************************************************** + // Step: Enter the API Key for Snowflake SPCS (Snowflake only) + // *************************************************************** + async function inputSnowflakeAPIKey( + input: MultiStepInput, + state: MultiStepState, + ) { + const currentAPIKey = + typeof state.data.apiKey === "string" ? state.data.apiKey : ""; + + const resp = await input.showInputBox({ + title: state.title, + step: 0, + totalSteps: 0, + password: true, + value: currentAPIKey, + prompt: `The Posit Connect API key for Snowflake SPCS OIDC authentication. + This is required in addition to the Snowflake connection for authentication with Connect deployed in Snowflake SPCS.`, + validate: (input: string) => { + if (input.includes(" ")) { + return Promise.resolve({ + message: "Error: Invalid API Key (spaces are not allowed).", + severity: InputBoxValidationSeverity.Error, + }); + } + return Promise.resolve(undefined); + }, + finalValidation: (input: string) => { + // validate that the API key is formed correctly + const errorMsg = checkSyntaxApiKey(input); + if (errorMsg) { + return Promise.resolve({ + message: `Error: Invalid API Key (${errorMsg}).`, + severity: InputBoxValidationSeverity.Error, + }); + } + return Promise.resolve(undefined); + }, + shouldResume: () => Promise.resolve(false), + ignoreFocusOut: true, + }); + + state.data.apiKey = resp.trim(); + return { name: step.INPUT_CRED_NAME, step: (input: MultiStepInput) => From bb0fc20ac7693864a91f586a4f35a950d9058bd5 Mon Sep 17 00:00:00 2001 From: Chris Ostrouchov Date: Sat, 25 Oct 2025 10:09:30 -0400 Subject: [PATCH 5/5] docs: Update changelogs for Snowflake SPCS OIDC authentication changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the Snowflake SPCS OIDC authentication changes in both the main repository and VSCode extension changelogs. Changes: - Added entries to "Unreleased > Fixed" sections explaining that Snowflake SPCS authentication now requires both a Snowflake connection name and a Connect API key - Documented the dual-header authentication approach (Authorization for Snowflake token, X-RSC-Authorization for Connect API key) - Explained the reason for the change: proxied authentication headers in Snowflake SPCS no longer carry sufficient user identification information with the move to OIDC Related: https://github.com/posit-dev/rsconnect-python/pull/715 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 4 ++++ extensions/vscode/CHANGELOG.md | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0996dbc1..53e75c85a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Snowflake SPCS (Snowpark Container Services) authentication now properly handles OIDC + by requiring both a Snowflake connection name and a Connect API key. The Connect API + key is sent via the X-RSC-Authorization header while the Snowflake token is sent via + the Authorization header for proxied authentication. - The "R Packages" section no longer shows you an alert if you aren't using renv. (#3095) - When `renv.lock` contains packages installed from GitHub or Bitbucket the deploy process should respect `RemoteHost`, `RemoteRepo`, `RemoteUsername` and diff --git a/extensions/vscode/CHANGELOG.md b/extensions/vscode/CHANGELOG.md index e9b880863..dc8f1349a 100644 --- a/extensions/vscode/CHANGELOG.md +++ b/extensions/vscode/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Snowflake SPCS (Snowpark Container Services) authentication now properly handles OIDC + by requiring both a Snowflake connection name and a Connect API key. This aligns with + changes in Snowflake SPCS where proxied authentication headers no longer carry sufficient + user identification information. + ## [1.22.0] ### Fixed