Skip to content

Commit 9f0099f

Browse files
authored
Logout the user when the refresh token is no longer valid (#60781)
1 parent e7efeed commit 9f0099f

7 files changed

Lines changed: 99 additions & 28 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
from __future__ import annotations
19+
20+
21+
class AuthManagerRefreshTokenExpiredException(Exception):
22+
"""Exception to throw when the user refresh token is expired."""

airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from airflow.api_fastapi.app import get_auth_manager
2525
from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
26+
from airflow.api_fastapi.auth.managers.exceptions import AuthManagerRefreshTokenExpiredException
2627
from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
2728
from airflow.api_fastapi.core_api.security import resolve_user_from_token
2829
from airflow.configuration import conf
@@ -40,26 +41,34 @@ class JWTRefreshMiddleware(BaseHTTPMiddleware):
4041
"""
4142

4243
async def dispatch(self, request: Request, call_next):
43-
new_user = None
44+
new_token = None
4445
current_token = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
4546
try:
46-
if current_token:
47-
new_user, current_user = await self._refresh_user(current_token)
48-
if user := (new_user or current_user):
49-
request.state.user = user
47+
if current_token is not None:
48+
try:
49+
new_user, current_user = await self._refresh_user(current_token)
50+
if user := (new_user or current_user):
51+
request.state.user = user
52+
if new_user:
53+
# If we created a new user, serialize it and set it as a cookie
54+
new_token = get_auth_manager().generate_jwt(new_user)
55+
except (HTTPException, AuthManagerRefreshTokenExpiredException):
56+
# Receive a HTTPException when the Airflow token is expired
57+
# Receive a AuthManagerRefreshTokenExpiredException when the potential underlying refresh
58+
# token used by the auth manager is expired
59+
new_token = ""
5060

5161
response = await call_next(request)
5262

53-
if new_user:
54-
# If we created a new user, serialize it and set it as a cookie
55-
new_token = get_auth_manager().generate_jwt(new_user)
63+
if new_token is not None:
5664
secure = bool(conf.get("api", "ssl_cert", fallback=""))
5765
response.set_cookie(
5866
COOKIE_NAME_JWT_TOKEN,
5967
new_token,
6068
httponly=True,
6169
secure=secure,
6270
samesite="lax",
71+
max_age=0 if new_token == "" else None,
6372
)
6473
except HTTPException as exc:
6574
# If any HTTPException is raised during user resolution or refresh, return it as response
@@ -68,9 +77,5 @@ async def dispatch(self, request: Request, call_next):
6877

6978
@staticmethod
7079
async def _refresh_user(current_token: str) -> tuple[BaseUser | None, BaseUser | None]:
71-
try:
72-
user = await resolve_user_from_token(current_token)
73-
except HTTPException:
74-
return None, None
75-
80+
user = await resolve_user_from_token(current_token)
7681
return get_auth_manager().refresh_user(user=user), user

airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ async def test_dispatch_no_token(self, mock_refresh_user, middleware, mock_reque
6161
@pytest.mark.asyncio
6262
async def test_dispatch_invalid_token(self, mock_refresh_user, middleware, mock_request):
6363
mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "valid_token"}
64-
call_next = AsyncMock(return_value=Response())
64+
call_next = AsyncMock(return_value=Response(status_code=401))
6565

6666
response = await middleware.dispatch(mock_request, call_next)
67-
assert response.status_code == 403
68-
assert response.body == b'{"detail":"Invalid JWT token"}'
67+
assert response.status_code == 401
68+
assert '_token=""; HttpOnly; Max-Age=0; Path=/; SameSite=lax' in response.headers.get("set-cookie")
6969

7070
@patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_auth_manager")
7171
@patch("airflow.api_fastapi.auth.middlewares.refresh_token.resolve_user_from_token")

providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525

2626
import requests
2727
from fastapi import FastAPI
28-
from keycloak import KeycloakOpenID, KeycloakPostError
28+
from keycloak import KeycloakOpenID
29+
from keycloak.exceptions import KeycloakPostError
2930
from requests.adapters import HTTPAdapter
3031
from urllib3.util import Retry
3132

@@ -162,13 +163,14 @@ def refresh_tokens(self, *, user: KeycloakAuthManagerUser) -> dict[str, str]:
162163
client = self.get_keycloak_client()
163164
return client.refresh_token(user.refresh_token)
164165
except KeycloakPostError as exc:
165-
log.warning(
166-
"KeycloakPostError encountered during token refresh. "
167-
"Suppressing the exception and returning None.",
168-
exc_info=exc,
169-
)
170-
171-
return {}
166+
try:
167+
from airflow.api_fastapi.auth.managers.exceptions import (
168+
AuthManagerRefreshTokenExpiredException,
169+
)
170+
except ImportError:
171+
return {}
172+
else:
173+
raise AuthManagerRefreshTokenExpiredException(exc)
172174

173175
def is_authorized_configuration(
174176
self,
@@ -396,6 +398,9 @@ def _is_batch_authorized(
396398

397399
if resp.status_code == 200:
398400
return {(perm["scopes"][0], perm["rsname"]) for perm in resp.json()}
401+
if resp.status_code == 401:
402+
log.debug("Received 401 from Keycloak: %s", resp.text)
403+
return set()
399404
if resp.status_code == 403:
400405
return set()
401406
if resp.status_code == 400:

providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,29 @@
2020
import logging
2121
from typing import Annotated, cast
2222

23-
from fastapi import Depends, Request
23+
from fastapi import Depends, HTTPException, Request
2424
from fastapi.responses import HTMLResponse, RedirectResponse
25+
from starlette.status import HTTP_401_UNAUTHORIZED
2526

2627
from airflow.api_fastapi.app import get_auth_manager
2728
from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
29+
from airflow.providers.keycloak.version_compat import AIRFLOW_V_3_1_1_PLUS
30+
31+
try:
32+
from airflow.api_fastapi.auth.managers.exceptions import AuthManagerRefreshTokenExpiredException
33+
except ImportError:
34+
35+
class AuthManagerRefreshTokenExpiredException(Exception): # type: ignore[no-redef]
36+
"""In case it is using a version of Airflow without ``AuthManagerRefreshTokenExpiredException``."""
37+
38+
pass
39+
40+
2841
from airflow.api_fastapi.common.router import AirflowRouter
2942
from airflow.api_fastapi.core_api.security import get_user
3043
from airflow.providers.common.compat.sdk import conf
3144
from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import KeycloakAuthManager
3245
from airflow.providers.keycloak.auth_manager.user import KeycloakAuthManagerUser
33-
from airflow.providers.keycloak.version_compat import AIRFLOW_V_3_1_1_PLUS
3446

3547
log = logging.getLogger(__name__)
3648
login_router = AirflowRouter(tags=["KeycloakAuthManagerLogin"])
@@ -134,7 +146,10 @@ def refresh(
134146
) -> RedirectResponse:
135147
"""Refresh the token."""
136148
auth_manager = cast("KeycloakAuthManager", get_auth_manager())
137-
refreshed_user = auth_manager.refresh_user(user=user)
149+
try:
150+
refreshed_user = auth_manager.refresh_user(user=user)
151+
except AuthManagerRefreshTokenExpiredException:
152+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Refresh token Expired")
138153
redirect_url = request.query_params.get("next", conf.get("api", "base_url", fallback="/"))
139154
response = RedirectResponse(url=redirect_url, status_code=303)
140155

providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
2424
from airflow.providers.keycloak.auth_manager.user import KeycloakAuthManagerUser
2525

26+
from tests_common.test_utils.version_compat import AIRFLOW_V_3_2_PLUS
27+
2628

2729
class TestLoginRouter:
2830
@patch("airflow.providers.keycloak.auth_manager.routes.login.KeycloakAuthManager.get_keycloak_client")
@@ -124,3 +126,17 @@ def test_refresh_token(self, mock_get_auth_manager, client):
124126
assert response.cookies["_token"] == "token"
125127
mock_auth_manager.refresh_user.assert_called_once()
126128
mock_auth_manager.generate_jwt.assert_called_once()
129+
130+
@pytest.mark.skipif(
131+
not AIRFLOW_V_3_2_PLUS, reason="``AuthManagerRefreshTokenExpiredException`` has been added in 3.2.0"
132+
)
133+
@patch("airflow.providers.keycloak.auth_manager.routes.login.get_auth_manager")
134+
def test_refresh_token_expired(self, mock_get_auth_manager, client):
135+
from airflow.api_fastapi.auth.managers.exceptions import AuthManagerRefreshTokenExpiredException
136+
137+
mock_auth_manager = Mock()
138+
mock_auth_manager.refresh_user.side_effect = AuthManagerRefreshTokenExpiredException()
139+
mock_get_auth_manager.return_value = mock_auth_manager
140+
141+
response = client.get(AUTH_MANAGER_FASTAPI_APP_PREFIX + "/refresh", follow_redirects=False)
142+
assert response.status_code == 401

providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from airflow.providers.keycloak.auth_manager.user import KeycloakAuthManagerUser
5252

5353
from tests_common.test_utils.config import conf_vars
54+
from tests_common.test_utils.version_compat import AIRFLOW_V_3_2_PLUS
5455

5556

5657
@pytest.fixture
@@ -160,7 +161,13 @@ def test_refresh_user_expired_with_invalid_token(
160161

161162
mock_get_keycloak_client.return_value = keycloak_client
162163

163-
assert auth_manager.refresh_user(user=user) is None
164+
if AIRFLOW_V_3_2_PLUS:
165+
from airflow.api_fastapi.auth.managers.exceptions import AuthManagerRefreshTokenExpiredException
166+
167+
with pytest.raises(AuthManagerRefreshTokenExpiredException):
168+
auth_manager.refresh_user(user=user)
169+
else:
170+
auth_manager.refresh_user(user=user)
164171

165172
keycloak_client.refresh_token.assert_called_with("refresh_token")
166173

@@ -449,6 +456,7 @@ def test_is_authorized_custom_view(
449456
],
450457
[200, [{"scopes": ["MENU"], "rsname": "Assets"}], {MenuItem.ASSETS}],
451458
[200, [], set()],
459+
[401, [{"scopes": ["MENU"], "rsname": "Assets"}], set()],
452460
[403, [{"scopes": ["MENU"], "rsname": "Assets"}], set()],
453461
],
454462
)

0 commit comments

Comments
 (0)