-
Notifications
You must be signed in to change notification settings - Fork 77
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
Fix/keycloak timeout #445
base: master
Are you sure you want to change the base?
Fix/keycloak timeout #445
Changes from all commits
81bf3ef
9bd2256
b5da8fa
30293f2
1dbd7af
18a104f
28b296c
2fb6e08
f2e1dd9
6588c63
0982f89
69992d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,8 @@ | ||
from contextlib import asynccontextmanager | ||
import os | ||
|
||
import requests # type: ignore | ||
|
||
import uvicorn # type: ignore | ||
from pathlib import Path | ||
from fastapi import ( | ||
|
@@ -44,6 +46,8 @@ | |
BackendStatusDatasetsSchema, | ||
AgentSchema, | ||
ServerSchema, | ||
LoginSchema, | ||
TokenSchema, | ||
) | ||
|
||
|
||
|
@@ -70,6 +74,24 @@ def with_plugins() -> Iterable[PluginManager]: | |
plugins.cleanup() | ||
|
||
|
||
def get_new_tokens(refresh_token): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add typing (I guess |
||
data = { | ||
"grant_type": "refresh_token", | ||
"refresh_token": refresh_token, | ||
"client_id": db.config.openid_client_id, | ||
"client_secret": db.config.openid_secret, | ||
} | ||
url = "http://mquery-keycloak-1:8080/auth/realms/myrealm/protocol/openid-connect/token" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't be hardcoded - use url from config instead |
||
try: | ||
response: requests.Response = requests.post(url=url, data=data) | ||
token_data = response.json() | ||
new_refresh_token = token_data["refresh_token"] | ||
new_token = token_data["access_token"] | ||
return new_token, new_refresh_token | ||
except requests.exceptions.RequestException: | ||
return None, None | ||
|
||
|
||
class User: | ||
def __init__(self, token: Optional[Dict]) -> None: | ||
self.__token = token | ||
|
@@ -124,8 +146,11 @@ async def current_user(authorization: Optional[str] = Header(None)) -> User: | |
token_json = jwt.decode( | ||
token, public_key, algorithms=["RS256"], audience="account" # type: ignore | ||
) | ||
except jwt.ExpiredSignatureError: | ||
# token expired so user is anonymous | ||
return User(None) | ||
except jwt.InvalidTokenError: | ||
# Invalid token means invalid signature, issuer, or just expired. | ||
# Invalid token means invalid signature, issuer. | ||
raise unauthorized | ||
|
||
return User(token_json) | ||
|
@@ -584,6 +609,35 @@ def server() -> ServerSchema: | |
) | ||
|
||
|
||
@app.post("/api/login", response_model=LoginSchema, tags=["stable"]) | ||
async def login(request: Request, response: Response) -> LoginSchema: | ||
token = await request.json() | ||
if token["refresh_token"]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code intentionally doesn't use cookies and passes all parameters by the API. Please change this to keep refresh_token in local storage instead of adding a cookie. |
||
response.set_cookie( | ||
key="refresh_token", | ||
value=token["refresh_token"], | ||
httponly=True, | ||
max_age=1800, | ||
) | ||
return LoginSchema(status="OK") | ||
return LoginSchema(status="Bad Token") | ||
|
||
|
||
@app.post("/api/token/refresh", response_model=TokenSchema) | ||
def refresh_token(request: Request, response: Response) -> TokenSchema: | ||
refresh_token_value = request.cookies.get("refresh_token") | ||
if refresh_token_value: | ||
new_token, new_refresh_token = get_new_tokens(refresh_token_value) | ||
response.set_cookie( | ||
key="refresh_token", | ||
value=new_refresh_token, | ||
httponly=True, | ||
max_age=1800, | ||
) | ||
return TokenSchema(token=new_token) | ||
return TokenSchema(token=None) | ||
|
||
|
||
@app.get("/query/{path}", include_in_schema=False) | ||
def serve_index(path: str) -> FileResponse: | ||
return FileResponse(Path(__file__).parent / "mqueryfront/dist/index.html") | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,4 +1,4 @@ | ||||||
import React, { useState, useEffect } from "react"; | ||||||
import React, { useState, useRef, useEffect } from "react"; | ||||||
import { Routes, Route } from "react-router-dom"; | ||||||
import Navigation from "./Navigation"; | ||||||
import QueryPage from "./query/QueryPage"; | ||||||
|
@@ -9,6 +9,7 @@ import AboutPage from "./about/AboutPage"; | |||||
import AuthPage from "./auth/AuthPage"; | ||||||
import api, { parseJWT } from "./api"; | ||||||
import "./App.css"; | ||||||
import { refreshAccesToken, storeTokenData, clearTokenData } from "./utils"; | ||||||
|
||||||
function getCurrentTokenOrNull() { | ||||||
// This function handles missing and corrupted token in the same way. | ||||||
|
@@ -21,20 +22,32 @@ function getCurrentTokenOrNull() { | |||||
|
||||||
function App() { | ||||||
const [config, setConfig] = useState(null); | ||||||
const tokenIntervalRef = useRef(null); | ||||||
|
||||||
useEffect(() => { | ||||||
api.get("/server").then((response) => { | ||||||
setConfig(response.data); | ||||||
}); | ||||||
tokenIntervalRef.current = setInterval(() => { | ||||||
refreshAccesToken(); | ||||||
}, 900000); // refresh token every 15 minutes just in case user was idle. | ||||||
return () => clearInterval(tokenIntervalRef.current); | ||||||
}, []); | ||||||
|
||||||
const login = (rawToken) => { | ||||||
localStorage.setItem("rawToken", rawToken); | ||||||
window.location.href = "/"; | ||||||
const login = async (token_data) => { | ||||||
token_data.not_before_policy = token_data["not-before-policy"]; | ||||||
delete token_data["not-before-policy"]; | ||||||
const response = await api.post("/login", token_data); | ||||||
Comment on lines
+37
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are these steps necessary? I don't know much about OIDC but it feels weird that we have to do this. |
||||||
storeTokenData(token_data["access_token"]); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
since response is not used anywhere |
||||||
const location_href = localStorage.getItem("currentLocation"); | ||||||
if (location_href) { | ||||||
window.location.href = location_href; | ||||||
} else { | ||||||
window.location.href = "/"; | ||||||
} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick: this is technically incorrect, since mquery doesn't have to be hosted at |
||||||
}; | ||||||
|
||||||
const logout = () => { | ||||||
localStorage.removeItem("rawToken"); | ||||||
clearTokenData(tokenIntervalRef.current); | ||||||
if (config !== null) { | ||||||
const logout_url = new URL(config["openid_url"] + "/logout"); | ||||||
logout_url.searchParams.append( | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ class ConfigPage extends Component { | |
} | ||
|
||
componentDidMount() { | ||
localStorage.setItem("currentLocation", window.location.href); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you sure there is no way to solve this in a more generic way? Adding the code to set a variable on every subpage feels suboptimal and error-prone. |
||
api.get("/config") | ||
.then((response) => { | ||
this.setState({ config: response.data }); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
import axios from "axios"; | ||
import api, { parseJWT } from "./api"; | ||
export const isStatusFinished = (status) => | ||
["done", "cancelled"].includes(status); | ||
|
||
|
@@ -25,3 +27,45 @@ export const openidLoginUrl = (config) => { | |
); | ||
return login_url; | ||
}; | ||
|
||
export const storeTokenData = (token) => { | ||
localStorage.setItem("rawToken", token); | ||
const decodedToken = parseJWT(token); | ||
localStorage.setItem("expiresAt", decodedToken.exp * 1000); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please don't keep this in localStorage. It's better to avoid having two separate variables when one is necessary. Instead parse rawToken when necessary. |
||
}; | ||
|
||
export const refreshAccesToken = async () => { | ||
const rawToken = localStorage.getItem("rawToken"); | ||
const expiresAt = localStorage.getItem("expiresAt"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. unused |
||
if (rawToken) { | ||
const headers = rawToken ? { Authorization: `Bearer ${rawToken}` } : {}; | ||
const response = await axios.request("/api/token/refresh", { | ||
method: "POST", | ||
headers: headers, | ||
withCredentials: true, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nevermind, I just noticed the code uses cookies. |
||
}); | ||
if (response.data["token"]) { | ||
storeTokenData(response.data["token"]); | ||
} else { | ||
return; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this return here do anything? If not, please remove it. |
||
} | ||
} | ||
}; | ||
|
||
export const clearTokenData = (tokenInterval) => { | ||
clearInterval(tokenInterval); | ||
localStorage.removeItem("expiresAt"); | ||
localStorage.removeItem("rawToken"); | ||
}; | ||
|
||
export const tokenExpired = () => { | ||
const rawToken = localStorage.getItem("rawToken"); | ||
if (rawToken) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick - if possible prefer early exit and avoid nested ifs. So please change the flow to
|
||
const expiresAt = localStorage.getItem("expiresAt"); | ||
if (Date.now() > expiresAt) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably it would be better to check if token expires in the next ~30 seconds? To avoid situations where token is 0.1 second before expiration during the check, but server gets an expired token already. That, and clock desync. |
||
return true; | ||
} | ||
return false; | ||
} | ||
return false; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I accidentaly removed this comment now, but it still applies - unnecessary empty line.