Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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 @@ -31,6 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- When opening the full deployment log record, it opens as a new unsaved file. (#2996)

### 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
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ export class SnowflakeConnections {
// Returned connections include the validated server URL they were
// successfully tested against.
//
// If apiKey is provided, connections will be tested with both the
// Snowflake token and the Connect API key for SPCS OIDC authentication.
//
// Returns:
// 200 - ok
// 500 - internal server error
list(serverUrl: string) {
list(serverUrl: string, apiKey?: string) {
return this.client.get<SnowflakeConnection[]>(`snowflake-connections`, {
params: {
serverUrl,
apiKey,
},
});
}
Expand Down
10 changes: 8 additions & 2 deletions extensions/vscode/src/multiStepInputs/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,19 @@ export const platformList: QuickPickItem[] = [
];

// Fetch the list of all available snowflake connections
export const fetchSnowflakeConnections = async (serverUrl: string) => {
export const fetchSnowflakeConnections = async (
serverUrl: string,
apiKey?: string,
) => {
let connections: SnowflakeConnection[] = [];
let connectionQuickPicks: QuickPickItemWithIndex[];

try {
const api = await useApi();
const connsResponse = await api.snowflakeConnections.list(serverUrl);
const connsResponse = await api.snowflakeConnections.list(
serverUrl,
apiKey,
);
connections = connsResponse.data;
connectionQuickPicks = connections.map((connection, i) => ({
label: connection.name,
Expand Down
97 changes: 84 additions & 13 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 @@ -254,8 +260,21 @@ export async function newConnectCredential(
severity: InputBoxValidationSeverity.Error,
});
}
// Check and set serverType first, even if there's an error
// This is important for Snowflake URLs where auth will fail
// without credentials, but we still need to detect the server type
if (testResult.data.serverType) {
serverType = testResult.data.serverType;
}

const err = testResult.data.error;
if (err) {
// For Snowflake, we expect auth to fail without credentials
// So we allow the flow to continue to prompt for credentials
if (isSnowflake(serverType)) {
return Promise.resolve(undefined);
}

if (err.code === "errorCertificateVerification") {
return Promise.resolve({
message: `Error: URL Not Accessible - ${err.msg}. If applicable, consider disabling [Verify TLS Certificates](${openConfigurationCommand}).`,
Expand All @@ -267,11 +286,6 @@ export async function newConnectCredential(
severity: InputBoxValidationSeverity.Error,
});
}

if (testResult.data.serverType) {
// serverType will be overwritten if it is snowflake
serverType = testResult.data.serverType;
}
} catch (e) {
return Promise.resolve({
message: `Error: Invalid URL (unable to validate connectivity with Server URL - ${getMessageFromError(e)}).`,
Expand All @@ -287,10 +301,12 @@ export async function newConnectCredential(
state.data.url = formatURL(resp.trim());

if (isSnowflake(serverType)) {
// For Snowflake SPCS with OIDC, ask for API key first
// Then we can test connections with both credentials
return {
name: step.INPUT_SNOWFLAKE_CONN,
name: step.INPUT_SNOWFLAKE_API_KEY,
step: (input: MultiStepInput) =>
steps[step.INPUT_SNOWFLAKE_CONN](input, state),
steps[step.INPUT_SNOWFLAKE_API_KEY](input, state),
};
}

Expand Down Expand Up @@ -490,15 +506,17 @@ export async function newConnectCredential(
input: MultiStepInput,
state: MultiStepState,
) {
// url should always be defined by the time we get to this step
// but we have to type guard it for the API
// url and apiKey should always be defined by the time we get to this step
// but we have to type guard them for the API
const serverUrl = typeof state.data.url === "string" ? state.data.url : "";
const apiKey =
typeof state.data.apiKey === "string" ? state.data.apiKey : "";
let connections: SnowflakeConnection[] = [];
let connectionQuickPicks: QuickPickItemWithIndex[] = [];

try {
await showProgress("Reading Snowflake connections", viewId, async () => {
const resp = await fetchSnowflakeConnections(serverUrl);
await showProgress("Testing Snowflake connections", viewId, async () => {
const resp = await fetchSnowflakeConnections(serverUrl, apiKey);
connections = resp.connections;
connectionQuickPicks = resp.connectionQuickPicks;
});
Expand Down Expand Up @@ -532,6 +550,59 @@ export async function newConnectCredential(
};
}

// ***************************************************************
// 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();

// Now that we have the API key, fetch Snowflake connections
// and test them with both the Snowflake token and API key
return {
name: step.INPUT_SNOWFLAKE_CONN,
step: (input: MultiStepInput) =>
steps[step.INPUT_SNOWFLAKE_CONN](input, state),
};
}

// ***************************************************************
// Step: Name the credential (used for all platforms)
// ***************************************************************
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)
}
Loading
Loading