Open hadley opened 3 years ago
Some more notes
Optional auth:
oauth_flow_auth_code_url()
redirect_uri
to {my_url}/login
/login
with code in query string
# What needs to go outside?
# * registering login and logout endpoints
# * capturing token into userData?
library(shiny)
# Not reactive because it can't change within a session; cookies have to
# change which requires a new connection
token <- oauth_session_token()
# Shortcut for getDefaultReactiveDomain()$userData$httr2_token
# with appropriate error handling
# Could parse from ...$request$COOKIE_HEADER but that's not available on shinyapps
# Dynamic UI - in principle could also do this from ui() function since
# cookie header will indicate whether or not its available
input$tweet <- renderUI({
if (is.null(token())) {
actionButton("login", "Log in with twitter to tweet about this")
} else {
activeButton("save", "Send tweet")
}
})
observeEvent(input$save, {
# How does re-auth work? Don't want to redirect user away if that loses state
# Would it be better to do via js in a child window?
request() %>% req_oauth_shiny_auth_code()
# could call oauth_session_token() or could make that explicit
})
observeEvent(input$login, {
# how to redirect?
})
token_from_cookies <- function(req) {
cookies <- parse_cookies(req[["HTTP_COOKIE"]])
secret_unserialize(cookies$token, obfuscate_key())
}
response_login <- function(redirect, state, cookie_opts) {
headers <- list(
"Cache-Control" = "no-store",
`Set-Cookie` = cookie_set("httr2_state", state, cookie_opts),
)
response_redirect(redirect, headers)
}
response_oauth_callback <- function(redirect_url, token, cookie_opts) {
token <- secret_serialize(token, obfuscate_key())
headers <- list(
"Cache-Control" = "no-store",
`Set-Cookie` = cookie_del("httr2_state", cookie_opts),
`Set-Cookie` = cookie_set("httr2_token", token, cookie_opts),
)
# But maybe this doesn't work - because it adds an extra redirect
response_redirect("./", headers)
}
response_logout <- function(cookie_opts) {
headers <- list2(
`Cache-Control` = "no-store",
`Set-Cookie` = cookie_del("httr2_token", cookie_opts),
)
response_redirect("./", header)
}
response_redirect <- function(url, headers) {
shiny::httpResponse(
status = 307L,
content_type = NULL,
headers = c(list(Location = url), headers)
)
}
I recently had to implement something similar to your second scenario (not using OAuth as gate to access app, but to retrieve an access token to fetch data inside app, e.g. from Github). I don't know if it's helpful, but I'm leaving my notes here.
I opted to not go for the uifunc
-approach (passing ui as a function as shown in the gargle PR), but instead doing everything from the server side using cookies, which had some gotchas:
Localhost Redirect URI and cookies: Shiny needs to run at a valid IP4 or IP6-address, which means you can't actually pass host = 'localhost'
. I naively assumed 127.0.0.1 and localhost were equivalent, but the browser treats them as different domains. So if your redirect URI is set as localhost, a cookie set at 127.0.0.1 would not be available after redirection to localhost. The solution is to set redirect URI to 127.0.0.1, and never use localhost. This took me hours.
Setting session cookies: I could not find a good way to set this from the server side at first (see #3524). I figured I could do this by "abusing" the session$registerDataObj
which can pass a shiny::httpResponse
, but I could not find a way to actually trigger this until I discovered the trick of just passing the endpoint to InsertUI. Works great, but feels a bit hacky. After the redirect, the cookies are available in session$request$HTTP_COOKIE
Redirecting from server side: I tried to use the same method as for cookies (session$registerDataObj
) and passing status 307 and location to shiny::httpResponse
similarly to the gargle PR . This kept giving CORS-errors which I believe is due to the XHR having rich headers which I was unable to remove. In the end, I went for a custom message using Shiny.addCustomMessageHandler...
and window.location
which seems to be the recommended way to go, but I would have preferred using httpResponse
. Maybe @jcheng5 could comment.
Splitting up the oauth flow: Compared to the family of req_oauth_*
functions, this approach requires the logic to be splitted into two parts. I am sure better abstractions could be found here, and I'm still trying to find the best approach for tying it together, anyways:
observeEvent(input$login_btn)
, set cookies and redirectobserveEvent(session$clientData$url_search)
, retrieve cookie, verify state and PKCE and fetch tokenRequires launch.browser=TRUE: Due to the redirect.
Here is a minimal app where I'm just verifying state. This was easy to extend to PKCE by just adding an encrypted PKCE_COOKIE
and verifying the same way.
I used some wrappers for the cookie handling, in addition to the cookie functions in the gargle PR
Thanks for httr2! It's an awesome package and you can tell a lot of thought has gone into making great APIs for users 👍
Thanks a lot @thohan88 for the minimal example you shared, it is super helpful and provides a practical approach to tackle this issue. My question is regarding the scenario with PKCE, say I used the httr2::oauth_flow_auth_code_pkce()
function to generate code verifier, method, and challenge PKCE components, then I used cookies (e.g., PKCE_COOKIE
) to save/retrieve them. How should we alter the get token function call to work in this scenario? I tried the following with no success :-(
token <- httr2:::oauth_client_get_token(client = client,
grant_type = "authorization_code",
code = query$code,
state = query$state,
code_verifier = pkce$verifier)
It is not clear to me what other token_params
I have to pass in this function parameters. I believe both code_challenge
and code_challenge_method
PKCE components are belongs to the auth_params
list, not token_params
(httr2/R/oauth-flow-auth-code.R source code). I will highly appreciate it if you can help me find what I miss in this puzzle :-)
NOTE: When I set the
grant_type = "authorization_code_with_pkce"
, I get an OAuth failure [unsupported_grant_type]
Your intuition is right, I will see if I can come up with a better structure now that I have gotten my head around it.
Meanwhile, I think this should work:
0) Set a key for encrypting the verifier for PKCE that does not vary by session (e.g. don't use secret_make_key()
):
Sys.setenv("MY_KEY" = "VERY_SECRET_KEY")
1) Set a cookie for the encrypted pkce_verifier at the same place you set the state cookie, e.g:
oauth_state <- httr2:::base64_url_rand()
+pkce <- oauth_flow_auth_code_pkce()
set_cookie(session, "oauth_state", oauth_state)
+set_cookie(session, "pkce_verifier", secret_encrypt(pkce$verifier, "MY_KEY"))
2) Now, modify the auth_url to include PKCE challenge and method:
auth_url <- oauth_flow_auth_code_url(
client = client,
auth_url = authorize_url,
redirect_uri = redirect_uri,
state = oauth_state,
auth_params = list(
scope = scopes,
+ code_challenge = pkce$challenge,
+ code_challenge_method = pkce$method
)
)
3) Retrieve the PKCE verifier and decrypt the same place as you retrieve state
state <- get_cookie(session, "oauth_state")
+pkce_verifier <- get_cookie(session, "pkce_verifier") |> secret_decrypt("MY_KEY")
4) Include it when you ask for a token
token <- httr2:::oauth_client_get_token(
client,
code = code,
grant_type = "authorization_code",
redirect_uri = redirect_uri,
+ code_verifier = pkce_verifier
)
If it does not work, set a browser()
right before oauth_client_get_token()
and observe your input. Good luck!
https://github.com/r-lib/gargle/pull/157
Code in PR currently uses OAuth as gate to access app; might also want to use it as optional feature (i.e. log in to save this file to your google drive), so will also need to work out that flow.