Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add explicit refresh token callable. #1230 #1555

Merged
merged 11 commits into from
Aug 3, 2022
1 change: 1 addition & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func (app *App) send(clientID string, session *Session, data []byte) error {
if session.subject != anon {
req.Header.Set("Wave-Access-Token", session.token.AccessToken)
req.Header.Set("Wave-Refresh-Token", session.token.RefreshToken)
req.Header.Set("Wave-Session-ID", session.id)
}

resp, err := app.client.Do(req)
Expand Down
39 changes: 39 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (

"github.com/coreos/go-oidc"
"github.com/google/uuid"
"github.com/h2oai/wave/pkg/keychain"
"golang.org/x/oauth2"
)

Expand Down Expand Up @@ -459,3 +460,41 @@ func (h *LogoutHandler) redirect(w http.ResponseWriter, r *http.Request, idToken

http.Redirect(w, r, redirectURL.String(), http.StatusFound)
}

// Handles token refreshes.
type RefreshHandler struct {
auth *Auth
keychain *keychain.Keychain
}

func newRefreshHandler(auth *Auth, keychain *keychain.Keychain) http.Handler {
return &RefreshHandler{auth, keychain}
}

func (h *RefreshHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !h.keychain.Guard(w, r) { // API
return
}

sessionID := r.Header.Get("Wave-Session-ID")
session, ok := h.auth.get(sessionID)

if !ok {
echo(Log{"t": "refresh_session", "error": "session unavailable"})
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}

token, err := h.auth.ensureValidOAuth2Token(r.Context(), session.token)
if err != nil {
// Purge session and reload clients if refresh not successful?
echo(Log{"t": "refresh_session", "error": err.Error()})
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
mturoci marked this conversation as resolved.
Show resolved Hide resolved
return
}

session.token = token
mturoci marked this conversation as resolved.
Show resolved Hide resolved
w.WriteHeader(http.StatusOK)
w.Header().Set("Wave-Access-Token", token.AccessToken)
w.Header().Set("Wave-Refresh-Token", token.RefreshToken)
}
20 changes: 18 additions & 2 deletions py/h2o_wave/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class Auth:
Represents authentication information for a given query context. Carries valid information only if single sign on is enabled.
"""

def __init__(self, username: str, subject: str, access_token: str, refresh_token: str):
def __init__(self, username: str, subject: str, access_token: str, refresh_token: str, session_id: str):
self.username = username
"""The username of the user."""
self.subject = subject
Expand All @@ -73,6 +73,21 @@ def __init__(self, username: str, subject: str, access_token: str, refresh_token
"""The access token of the user."""
self.refresh_token = refresh_token
"""The refresh token of the user."""
self._session_id = session_id
"""Session identifier. Do not access, internal use only."""
mturoci marked this conversation as resolved.
Show resolved Hide resolved

async def force_token_refresh(self):
mturoci marked this conversation as resolved.
Show resolved Hide resolved
mturoci marked this conversation as resolved.
Show resolved Hide resolved
"""
Implictly refresh OIDC tokens when needed, e.g. during long-running background jobs.
mturoci marked this conversation as resolved.
Show resolved Hide resolved
"""
async with httpx.AsyncClient(auth=(_config.hub_access_key_id, _config.hub_access_key_secret), verify=False) as http:
res = await http.get(_config.hub_address + '_auth/refresh', headers={'Wave-Session-ID': self._session_id})

access_token = res.headers.get('Wave-Access-Token', None)
refresh_token = res.headers.get('Wave-Refresh-Token', None)
if access_token and refresh_token:
self.access_token = access_token
self.refresh_token = refresh_token


class Query:
Expand Down Expand Up @@ -290,7 +305,8 @@ async def _receive(self, req: Request):
username = req.headers.get('Wave-Username')
access_token = req.headers.get('Wave-Access-Token')
refresh_token = req.headers.get('Wave-Refresh-Token')
auth = Auth(username, subject, access_token, refresh_token)
session_id = req.headers.get('Wave-Session-ID')
auth = Auth(username, subject, access_token, refresh_token, session_id)
args = await req.json()

return PlainTextResponse('', background=BackgroundTask(self._process, client_id, auth, args))
Expand Down
1 change: 1 addition & 0 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func Run(conf ServerConf) {
handle("_auth/init", newLoginHandler(auth))
handle("_auth/callback", newAuthHandler(auth))
handle("_auth/logout", newLogoutHandler(auth, broker))
handle("_auth/refresh", newRefreshHandler(auth, conf.Keychain))
}

handle("_s/", newSocketServer(broker, auth, conf.Editable, conf.BaseURL)) // XXX terminate sockets when logged out
Expand Down
15 changes: 9 additions & 6 deletions website/docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ To enable OpenID Connect, pass the following flags when starting the Wave server

Once authenticated, you can access user's authentication and authorization information from your app using `q.auth` (see the [Auth](api/server#auth) class for details):


```py
from h2o_wave import Q, main, app

Expand All @@ -127,12 +126,16 @@ async def serve(q: Q):
print(q.auth.access_token)
```

:::caution
Note that access token is not refreshed automatically and it's not suited for long running jobs. The lifespan of a token
depends on a provider settings but usually it's short. Access token is refreshed each time user performs an action i.e.
the query handler `serve()` is called.
:::
Note that access token is not refreshed automatically and it's not suited for long running jobs. The lifespan of a token depends on a provider settings but usually it's short. Access token is refreshed each time user performs an action i.e. the query handler `serve()` is called. However, if your UI is blocked (no user interacitons that could automatically refresh the token) and you are performing a long-running job, and still need fresh access token, you can call `force_token_refresh` function that refreshes and sets the token explicitly.

```py
from h2o_wave import Q, main, app

@app('/example')
async def serve(q: Q):
# Refreshes the token and makes it available in q.auth.access_token.
q.auth.force_token_refresh()
```

## App Server API Access Keys

Expand Down