Skip to content

Commit

Permalink
Improvement: DO-3034 support seamless handling of refresh tokens (#345)
Browse files Browse the repository at this point in the history
* WIP: remove auth context in favor of global store; add token refresh endpoint, add autorefresh logic to request helper

* Http utils test

* Add tests for refresh token endpoint

* Make refresh sync

* Lint

* Fix JS tests

* Version bump to v1.14.0-alpha.0 [skip ci]

* Add automatic token refresh, pass previous session_token to refresh_token

* Version bump to v1.14.0-alpha.1 [skip ci]

* Double notify

* Update changelog

* Remove unnecessary refresh interval

* Improve global store documentation

* Notify live WS connections of token data changes to keep auth ContextVars up-to-date in WS message handlers

* Lint fix

* Version bump to v1.14.0-alpha.2 [skip ci]

* Remove backend WS connection update, use client->server message instead. Update GlobalState to wrap localStorage instead of in-memory for cross-tab syncing

* Add server-side cache to prevent concurrent token refreshes or subsequent ones within a short window

* Version bump to v1.14.0-alpha.3 [skip ci]

* Cleanup

* Revert changelogs to NEXT

* OOps

* Use direct subscription instead

* Rename arg

* Add cancellation for test CI

---------

Co-authored-by: GitHub Actions Bot <>
  • Loading branch information
krzysztof-causalens authored Oct 25, 2024
1 parent 60887f8 commit 9628b09
Show file tree
Hide file tree
Showing 68 changed files with 1,771 additions and 838 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:

jobs:
lint-and-tests:
concurrency:
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"npmClient": "pnpm",
"version": "1.13.1",
"version": "1.14.0-alpha.3",
"packages": [
"packages/*"
],
Expand Down
2 changes: 1 addition & 1 deletion packages/create-dara-app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ license = "Apache-2.0"
name = "create-dara-app"
readme = "README.md"
repository = "https://github.com/causalens/dara"
version = "1.13.1"
version = "1.14.0-alpha.3"
source = []

[tool.poetry.dependencies]
Expand Down
2 changes: 1 addition & 1 deletion packages/dara-components/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Changelog
---

# NEXT
## NEXT

- Fixed an issue where `Plotly`'s figure native `height` and `width` were not obeyed by component.

Expand Down
24 changes: 12 additions & 12 deletions packages/dara-components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@darajs/components",
"version": "1.13.1",
"version": "1.14.0-alpha.3",
"description": "Components for the Dara framework",
"main": "dist/index.js",
"module": "dist/index.js",
Expand Down Expand Up @@ -29,9 +29,9 @@
"prettier": "@darajs/prettier-config",
"devDependencies": {
"@babel/core": "^7.23.5",
"@darajs/eslint-config": "1.13.1",
"@darajs/prettier-config": "1.13.1",
"@darajs/stylelint-config": "1.13.1",
"@darajs/eslint-config": "1.14.0-alpha.3",
"@darajs/prettier-config": "1.14.0-alpha.3",
"@darajs/stylelint-config": "1.14.0-alpha.3",
"@testing-library/react-hooks": "^3.4.2",
"@types/lodash": "^4.14.155",
"@types/react": "18.2.60",
Expand All @@ -53,14 +53,14 @@
},
"dependencies": {
"@bokeh/bokehjs": "~3.1.1",
"@darajs/core": "1.13.1",
"@darajs/styled-components": "1.13.1",
"@darajs/ui-causal-graph-editor": "1.13.1",
"@darajs/ui-code-editor": "1.13.1",
"@darajs/ui-components": "1.13.1",
"@darajs/ui-hierarchy-viewer": "1.13.1",
"@darajs/ui-icons": "1.13.1",
"@darajs/ui-utils": "1.13.1",
"@darajs/core": "1.14.0-alpha.3",
"@darajs/styled-components": "1.14.0-alpha.3",
"@darajs/ui-causal-graph-editor": "1.14.0-alpha.3",
"@darajs/ui-code-editor": "1.14.0-alpha.3",
"@darajs/ui-components": "1.14.0-alpha.3",
"@darajs/ui-hierarchy-viewer": "1.14.0-alpha.3",
"@darajs/ui-icons": "1.14.0-alpha.3",
"@darajs/ui-utils": "1.14.0-alpha.3",
"@popperjs/core": "2.4.0",
"date-fns": "2.9.0",
"immer": "^10.0.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/dara-components/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ license = "Apache-2.0"
name = "dara-components"
readme = "README.md"
repository = "https://github.com/causalens/dara"
version = "1.13.1"
version = "1.14.0-alpha.3"
source = []

[[tool.poetry.packages]]
Expand All @@ -36,7 +36,7 @@ include = "dara"
bokeh = ">=3.1.0, <3.2.0"
cai-causal-graph = ">=0.3.6"
certifi = ">=2024.7.4"
dara-core = "1.13.1"
dara-core = "1.14.0-alpha.3"
dill = ">=0.3.0, <0.4.0"
matplotlib = ">=2.0.0"
pandas = ">=1.1.0, <3.0.0"
Expand Down
6 changes: 6 additions & 0 deletions packages/dara-core/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
title: Changelog
---

## NEXT

- Added support for seamless token refresh mechanism. A provided auth config can be configured to support token refresh by:
- implement the `refresh_token` method to sign a new token, reusing the previous session_id for continuity
- adding some mechanism to set a `dara_refresh_token` cookie, e.g. via custom `components_config` and endpoints such as `/sso-callback` for SSO auth

## 1.13.1

- Fixed an issue where data passed to `Table` component within a column of `dtype` `object` did not display correctly for datetime values.
Expand Down
14 changes: 13 additions & 1 deletion packages/dara-core/dara/core/auth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import abc
from typing import Any, ClassVar, Dict, Union

from fastapi import Response
from fastapi import HTTPException, Response
from pydantic import BaseModel
from typing_extensions import TypedDict

Expand Down Expand Up @@ -91,6 +91,18 @@ def verify_token(self, token: str) -> Union[Any, TokenData]:
:param token: encoded token
"""

def refresh_token(self, old_token: TokenData, refresh_token: str) -> tuple[str, str]:
"""
Create a new session token and refresh token from a refresh token.
Note: the new issued session token should include the same session_id as the old token
:param old_token: old session token data
:param refresh_token: encoded refresh token
:return: new session token, new refresh token
"""
raise HTTPException(400, f'Auth config {self.__class__.__name__} does not support token refresh')

def revoke_token(self, token: str, response: Response) -> Union[SuccessResponse, RedirectResponse]:
"""
Revoke a session token.
Expand Down
77 changes: 76 additions & 1 deletion packages/dara-core/dara/core/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@
"""

from inspect import iscoroutinefunction
from typing import Union, cast

import jwt
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import (
APIRouter,
BackgroundTasks,
Cookie,
Depends,
HTTPException,
Request,
Response,
)
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from dara.core.auth.base import BaseAuthConfig
Expand All @@ -31,6 +40,7 @@
AuthError,
SessionRequestBody,
)
from dara.core.auth.utils import cached_refresh_token, decode_token
from dara.core.logging import dev_logger

auth_router = APIRouter()
Expand Down Expand Up @@ -103,6 +113,71 @@ async def _revoke_session(response: Response, credentials: HTTPAuthorizationCred
raise HTTPException(status_code=400, detail=BAD_REQUEST_ERROR('No auth credentials passed'))


@auth_router.post('/refresh-token')
async def handle_refresh_token(
response: Response,
background_tasks: BackgroundTasks,
dara_refresh_token: Union[str, None] = Cookie(default=None),
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
):
"""
Given a refresh token, issues a new session token and refresh token cookie.
:param response: FastAPI response object
:param dara_refresh_token: refresh token cookie
:param settings: env settings object
"""
if dara_refresh_token is None:
raise HTTPException(status_code=400, detail=BAD_REQUEST_ERROR('No refresh token provided'))

# Check scheme is correct
if credentials.scheme != 'Bearer':
raise HTTPException(
status_code=400,
detail=BAD_REQUEST_ERROR(
'Invalid authentication scheme, previous Bearer token must be included in the refresh request'
),
)

from dara.core.internal.registries import auth_registry

auth_config: BaseAuthConfig = auth_registry.get('auth_config')

try:
# decode the old token ignoring expiry date
old_token_data = decode_token(credentials.credentials, options={'verify_exp': False})

# Refresh logic up to implementation - passing in old token data so session_id can be preserved
session_token, refresh_token = await cached_refresh_token(
auth_config.refresh_token, old_token_data, dara_refresh_token
)

# Using 'Strict' as it is only used for the refresh-token endpoint so cross-site requests are not expected
response.set_cookie(
key='dara_refresh_token', value=refresh_token, secure=True, httponly=True, samesite='strict'
)
return {'token': session_token}
except BaseException as e:
# Regardless of exception type, clear the refresh token cookie
response.delete_cookie('dara_refresh_token')
headers = {'set-cookie': response.headers['set-cookie']}

# If an explicit HTTPException was raised, re-raise it with the cookie header
if isinstance(e, HTTPException):
dev_logger.error('Auth Error', error=e)
e.headers = headers
raise e

# Explicitly handle expired signature error
if isinstance(e, jwt.ExpiredSignatureError):
dev_logger.error('Expired Token Signature', error=e)
raise HTTPException(status_code=401, detail=EXPIRED_TOKEN_ERROR, headers=headers)

# Otherwise show a generic invalid token error
dev_logger.error('Invalid Token', error=cast(Exception, e))
raise HTTPException(status_code=401, detail=INVALID_TOKEN_ERROR, headers=headers)


# Request to retrieve a session token from the backend. The app does this on startup.
@auth_router.post('/session')
async def _get_session(body: SessionRequestBody):
Expand Down
Loading

0 comments on commit 9628b09

Please sign in to comment.