feat: Monadical SSO as replacement of Fief (#393)

* sso: first pass for integrating SSO

still have issue on refreshing
maybe customize the login page, or completely avoid it
make 100% to understand how session server/client are working
need to test with different configuration option (features flags and
requireLogin)

* sso: correctly handle refresh token, with pro-active refresh

Going on interceptors make extra calls to reflector when 401.
We need then to circle back with NextJS backend to update the jwt,
session, then retry the failed request.

I prefered to go pro-active, and ensure the session AND jwt are always
up to date.

A minute before the expiration, we'll try to refresh it. useEffect() of
NextJS cannot be asynchronous, so we cannot wait for the token to be
refreshed.

Every 20s, a minute before the expiration (so 3x in total max) we'll try
to renew. When the accessToken is renewed, the session is updated, and
dispatching up to the client, which updates the useApi().

Therefore, no component will left without a incorrect token.

* fixes: issue with missing key on react-select-search because the default value is undefined

* sso: fixes login/logout button, and avoid seeing the login with authentik page when clicking

* sso: ensure /transcripts/new is not behind protected page, and feature flags page are honored

* sso: fixes user sub->id

* fixes: remove old layout not used

* fixes: set default NEXT_PUBLIC_SITE_URL as localhost

* fixes: removing fief again due to merge with main

* sso: ensure session is always ready before doing any action

* sso: add migration from fief to jwt in server, only from transcripts list

* fixes: user tests

* fixes: compilation issues
This commit is contained in:
2024-09-03 19:27:15 +02:00
committed by GitHub
parent 28fe6c11f7
commit 03561453c5
30 changed files with 707 additions and 280 deletions

View File

@@ -0,0 +1,89 @@
from typing import Annotated, Optional
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import BaseModel
from reflector.logger import logger
from reflector.settings import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read()
jwt_algorithm = settings.AUTH_JWT_ALGORITHM
jwt_audience = settings.AUTH_JWT_AUDIENCE
class JWTException(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
def __str__(self):
return f"<JWTException {self.status_code}: {self.detail}>"
class UserInfo(BaseModel):
sub: str
email: str
def __getitem__(self, key):
return getattr(self, key)
class AccessTokenInfo(BaseModel):
exp: Optional[int] = None
sub: Optional[str] = None
class JWTAuth:
def verify_token(self, token: str):
try:
payload = jwt.decode(
token,
jwt_public_key,
algorithms=[jwt_algorithm],
audience=jwt_audience,
)
return payload
except JWTError as e:
logger.error(f"JWT error: {e}")
raise
def authenticated(token: Annotated[str, Depends(oauth2_scheme)]):
if token is None:
raise JWTException(status_code=401, detail="Not authenticated")
return None
def current_user(
token: Annotated[Optional[str], Depends(oauth2_scheme)],
jwtauth: JWTAuth = Depends(),
):
if token is None:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
payload = jwtauth.verify_token(token)
sub = payload["sub"]
return UserInfo(sub=sub)
except JWTError as e:
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication")
def current_user_optional(
token: Annotated[Optional[str], Depends(oauth2_scheme)],
jwtauth: JWTAuth = Depends(),
):
# we accept no token, but if one is provided, it must be a valid one.
if token is None:
return None
try:
payload = jwtauth.verify_token(token)
sub = payload["sub"]
email = payload["email"]
return UserInfo(sub=sub, email=email)
except JWTError as e:
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication")