Skip to content

Commit f0938ee

Browse files
aronatkinscostroucclaude
authored
feat: SPCS authentication includes API key (#1248)
* Add API key support for SPCS authentication SPCS (Snowpark Container Services) deployments require a dual authentication model: - Snowflake tokens provide proxied authentication to reach the server - API keys identify the user to the Connect server itself Changes: - Updated authHeaders() to include X-RSC-Authorization header when both snowflakeToken and apiKey are present - Added apiKey parameter to connectSPCSUser() function - Updated getSPCSAuthedUser() to accept and use apiKey - Store apiKey in account registration alongside snowflakeConnectionName - Updated function documentation to explain the dual authentication model - Added comprehensive test coverage for API key handling This aligns with updated Connect server requirements for Snowflake SPCS deployments and mirrors the authentication pattern in rsconnect-python. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update NEWS.md for SPCS API key support Document the addition of API key support for SPCS authentication, including the breaking change to connectSPCSUser() which now requires an apiKey parameter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * api keys are secrets and not directly comparable * spcs uses double auth; handle prior to api key handling --------- Co-authored-by: Chris Ostrouchov <chris.ostrouchov@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0cab135 commit f0938ee

File tree

6 files changed

+70
-24
lines changed

6 files changed

+70
-24
lines changed

NEWS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# rsconnect (development version)
22

3+
* SPCS/Snowflake authentication supports Connect API keys for user
4+
identification. The `connectSPCSUser()` function now requires an `apiKey`
5+
parameter, and the API key is included in the `X-RSC-Authorization` header
6+
alongside Snowflake token authentication. This aligns with updated Connect
7+
server requirements where Snowflake tokens provide proxied authentication
8+
while API keys identify users to the Connect server itself.
9+
310
# rsconnect 1.6.0
411

512
* Support deploying to Posit Connect Cloud. Use `connectCloudUser()` to add

R/accounts.R

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ connectApiUser <- function(
9595
#' [`connections.toml` file](https://docs.snowflake.com/en/developer-guide/snowflake-cli/connecting/configure-cli#location-of-the-toml-configuration-fil)
9696
#' in the appropriate location.
9797
#'
98+
#' SPCS deployments require both Snowflake authentication (via the connection
99+
#' name) and a Posit Connect API key. The Snowflake token provides proxied
100+
#' authentication to reach the Connect server, while the API key identifies
101+
#' the user to Connect itself.
102+
#'
98103
#' Supported servers: Posit Connect servers
99104
#'
100105
#' @inheritParams connectApiUser
@@ -104,18 +109,20 @@ connectApiUser <- function(
104109
connectSPCSUser <- function(
105110
account = NULL,
106111
server = NULL,
112+
apiKey,
107113
snowflakeConnectionName,
108114
quiet = FALSE
109115
) {
110116
server <- findServer(server)
111117
checkConnectServer(server)
112118

113-
user <- getSPCSAuthedUser(server, snowflakeConnectionName)
119+
user <- getSPCSAuthedUser(server, apiKey, snowflakeConnectionName)
114120

115121
registerAccount(
116122
serverName = server,
117123
accountName = account %||% user$username,
118124
accountId = user$id,
125+
apiKey = apiKey,
119126
snowflakeConnectionName = snowflakeConnectionName
120127
)
121128

@@ -127,10 +134,11 @@ connectSPCSUser <- function(
127134
invisible()
128135
}
129136

130-
getSPCSAuthedUser <- function(server, snowflakeConnectionName) {
137+
getSPCSAuthedUser <- function(server, apiKey, snowflakeConnectionName) {
131138
serverAddress <- serverInfo(server)
132139
account <- list(
133140
server = server,
141+
apiKey = apiKey,
134142
snowflakeConnectionName = snowflakeConnectionName
135143
)
136144

R/http.R

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -541,15 +541,21 @@ requestCertificate <- function(protocol, certificate = NULL) {
541541
}
542542

543543
authHeaders <- function(authInfo, method, path, file = NULL) {
544-
if (!is.null(authInfo$secret) || !is.null(authInfo$private_key)) {
544+
if (!is.null(authInfo$snowflakeToken)) {
545+
# snowflakeauth returns a list of named header values
546+
headers <- authInfo$snowflakeToken
547+
# The SPCS/Snowflake token is in the Authorization header and the Connect API key is passed
548+
# using X-RSC-Authorization.
549+
if (!is.null(authInfo$apiKey)) {
550+
headers$`X-RSC-Authorization` <- paste("Key", authInfo$apiKey)
551+
}
552+
headers
553+
} else if (!is.null(authInfo$secret) || !is.null(authInfo$private_key)) {
545554
signatureHeaders(authInfo, method, path, file)
546555
} else if (!is.null(authInfo$apiKey)) {
547556
list(`Authorization` = paste("Key", authInfo$apiKey))
548557
} else if (!is.null(authInfo$accessToken)) {
549558
list(`Authorization` = paste("Bearer", authInfo$accessToken))
550-
} else if (!is.null(authInfo$snowflakeToken)) {
551-
# snowflakeauth returns a list of named header values
552-
authInfo$snowflakeToken
553559
} else {
554560
# The value doesn't actually matter here, but the header needs to be set.
555561
list(`X-Auth-Token` = "anonymous-access")

man/connectSPCSUser.Rd

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-http.R

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -255,20 +255,3 @@ test_that("rcf2616 returns an ASCII date and undoes changes to the locale", {
255255
expect_equal(date, "Mon, 01 Jan 2024 06:02:03 GMT")
256256
expect_equal(Sys.getlocale("LC_TIME"), old)
257257
})
258-
259-
test_that("authHeaders handles snowflakeToken", {
260-
# Create mock authInfo with snowflakeToken
261-
authInfo <- list(
262-
snowflakeToken = list(
263-
Authorization = "Snowflake Token=\"mock_token\"",
264-
`X-Custom-Header` = "custom-value"
265-
)
266-
)
267-
268-
# Test authHeaders
269-
headers <- authHeaders(authInfo, "GET", "/path")
270-
271-
# Verify snowflakeToken headers were used
272-
expect_equal(headers$Authorization, "Snowflake Token=\"mock_token\"")
273-
expect_equal(headers$`X-Custom-Header`, "custom-value")
274-
})

tests/testthat/test-spcs.R

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ test_that("isSPCSServer correctly identifies Snowpark Container Services servers
2222
})
2323

2424
test_that("authHeaders handles snowflakeToken", {
25-
# mock authInfo with snowflakeToken
2625
authInfo <- list(
2726
snowflakeToken = list(
2827
Authorization = "Snowflake Token=\"mock_token\"",
@@ -32,8 +31,25 @@ test_that("authHeaders handles snowflakeToken", {
3231

3332
headers <- authHeaders(authInfo, "GET", "/path")
3433

34+
expect_equal(headers$Authorization, "Snowflake Token=\"mock_token\"")
35+
expect_equal(headers$`X-Custom-Header`, "custom-value")
36+
})
37+
38+
test_that("authHeaders handles snowflakeToken with API key", {
39+
authInfo <- list(
40+
apiKey = secret("the-api-key"),
41+
snowflakeToken = list(
42+
Authorization = "Snowflake Token=\"mock_token\"",
43+
`X-Custom-Header` = "custom-value"
44+
)
45+
)
46+
47+
# Test authHeaders
48+
headers <- authHeaders(authInfo, "GET", "/path")
49+
3550
# Verify snowflakeToken headers were used
3651
expect_equal(headers$Authorization, "Snowflake Token=\"mock_token\"")
52+
expect_equal(headers$`X-RSC-Authorization`, "Key the-api-key")
3753
expect_equal(headers$`X-Custom-Header`, "custom-value")
3854
})
3955

@@ -52,3 +68,21 @@ test_that("registerAccount stores snowflakeConnectionName", {
5268
info <- accountInfo("testuser", "example.com")
5369
expect_equal(info$snowflakeConnectionName, "test_connection")
5470
})
71+
72+
test_that("registerAccount stores both apiKey and snowflakeConnectionName for SPCS accounts", {
73+
local_temp_config()
74+
75+
# Register an SPCS account with both apiKey and snowflakeConnectionName
76+
registerAccount(
77+
serverName = "spcs.example.com",
78+
accountName = "spcsuser",
79+
accountId = "user456",
80+
apiKey = "test-api-key-789",
81+
snowflakeConnectionName = "spcs_connection"
82+
)
83+
84+
# Check the account info has both fields
85+
info <- accountInfo("spcsuser", "spcs.example.com")
86+
expect_equal(info$snowflakeConnectionName, "spcs_connection")
87+
expect_equal(as.character(info$apiKey), "test-api-key-789")
88+
})

0 commit comments

Comments
 (0)