Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions extensions/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 59 additions & 2 deletions extensions/vscode/src/multiStepInputs/newConnectCredential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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)
);
};

// ***************************************************************
Expand Down Expand Up @@ -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) =>
Expand Down
9 changes: 5 additions & 4 deletions internal/accounts/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions internal/api_client/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions internal/api_client/auth/snowflake.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const headerName = "Authorization"

type snowflakeAuthenticator struct {
tokenProvider snowflake.TokenProvider
apiKey string
}

var _ AuthMethod = &snowflakeAuthenticator{}
Expand All @@ -22,13 +23,18 @@ 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
// include a valid private key.
func NewSnowflakeAuthenticator(
connections snowflake.Connections,
connectionName string,
apiKey string,
) (AuthMethod, error) {
conn, err := connections.Get(connectionName)
if err != nil {
Expand Down Expand Up @@ -59,6 +65,7 @@ func NewSnowflakeAuthenticator(

return &snowflakeAuthenticator{
tokenProvider: tokenProvider,
apiKey: apiKey,
}, nil
}

Expand All @@ -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
}
35 changes: 30 additions & 5 deletions internal/api_client/auth/snowflake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ 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
connections.On("Get", ":name:").Return(&snowflake.Connection{
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
Expand All @@ -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
Expand All @@ -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{
Expand All @@ -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() {
Expand All @@ -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)
Expand All @@ -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)
}
3 changes: 2 additions & 1 deletion internal/credentials/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions internal/credentials/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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",
},
},
Expand Down Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions internal/credentials/keyring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading