From c243cb71549aed512833fa71ef2887d0b72d4f6b Mon Sep 17 00:00:00 2001 From: xb058t Date: Thu, 3 Sep 2026 11:57:29 +0200 Subject: [PATCH 1/4] [feat][db] Keep GUI session alive while OAuth session is active Extends an OAuth session silently whenever session_lifetime lapses. While the access token received at login is still valid, the session is extended locally. Once it expires, a new access token is requested with the stored refresh token, and the user is only sent back to the login page if the provider refuses it. Adds oauth_tokens.provider (model and migration) so the server knows which provider to contact, and exempts OAuth backed sessions from the unconditional expired session cleanup, which would otherwise delete their refresh token on server startup or on another login. --- .../database/config_db_model.py | 4 +- .../codechecker_server/database/db_cleanup.py | 20 +-- .../6df2e06f8cf6_keep_oauth_session_alive.py | 31 +++++ .../codechecker_server/session_manager.py | 118 ++++++++++++++++-- 4 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 web/server/codechecker_server/migrations/config/versions/6df2e06f8cf6_keep_oauth_session_alive.py diff --git a/web/server/codechecker_server/database/config_db_model.py b/web/server/codechecker_server/database/config_db_model.py index 9a27807d3a..3d461d32e5 100644 --- a/web/server/codechecker_server/database/config_db_model.py +++ b/web/server/codechecker_server/database/config_db_model.py @@ -216,6 +216,7 @@ class OAuthToken(Base): __tablename__ = 'oauth_tokens' id = Column(Integer, autoincrement=True, primary_key=True) + provider = Column(String, nullable=False) access_token = Column(String, nullable=False) expires_at = Column(DateTime) refresh_token = Column(String, nullable=False) @@ -226,8 +227,9 @@ class OAuthToken(Base): nullable=False, index=True) - def __init__(self, access_token, expires_at, refresh_token, + def __init__(self, provider, access_token, expires_at, refresh_token, auth_session_id): + self.provider = provider self.access_token = access_token self.expires_at = expires_at self.refresh_token = refresh_token diff --git a/web/server/codechecker_server/database/db_cleanup.py b/web/server/codechecker_server/database/db_cleanup.py index eb45ba87b4..646dae0201 100644 --- a/web/server/codechecker_server/database/db_cleanup.py +++ b/web/server/codechecker_server/database/db_cleanup.py @@ -28,7 +28,7 @@ File, FileContent, \ Report, ReportAnalysisInfo, ReportPathDataFile, RunHistoryAnalysisInfo, \ RunLock -from .config_db_model import Session as SessionRecord +from .config_db_model import OAuthToken, Session as SessionRecord LOG = get_logger('server') RUN_LOCK_TIMEOUT_IN_DATABASE = 30 * 60 # 30 minutes. @@ -436,15 +436,17 @@ def delete_expired_auth_sessions(config_db_sessionmaker: sessionmaker, cutoff_date = (datetime.now() - timedelta( seconds=session_lifetime)) + # OAuth backed sessions can still be renewed silently, so + # they are left to the refresh aware cleanup instead. + has_oauth_token = session.query(OAuthToken.auth_session_id) \ + .filter(OAuthToken.auth_session_id == SessionRecord.id) + + query = session.query(SessionRecord) \ + .filter(SessionRecord.last_access < cutoff_date) \ + .filter(~has_oauth_token.exists()) if user_name: - session.query(SessionRecord) \ - .filter(SessionRecord.user_name == user_name) \ - .filter(SessionRecord.last_access < cutoff_date) \ - .delete(synchronize_session=False) - else: - session.query(SessionRecord) \ - .filter(SessionRecord.last_access < cutoff_date) \ - .delete(synchronize_session=False) + query = query.filter(SessionRecord.user_name == user_name) + query.delete(synchronize_session=False) session.commit() LOG.debug("Finished cleanup of expired auth sessions.") diff --git a/web/server/codechecker_server/migrations/config/versions/6df2e06f8cf6_keep_oauth_session_alive.py b/web/server/codechecker_server/migrations/config/versions/6df2e06f8cf6_keep_oauth_session_alive.py new file mode 100644 index 0000000000..1dc3fb55c1 --- /dev/null +++ b/web/server/codechecker_server/migrations/config/versions/6df2e06f8cf6_keep_oauth_session_alive.py @@ -0,0 +1,31 @@ +""" +Keep oauth session alive + +Revision ID: 6df2e06f8cf6 +Revises: 635389f535cd +Create Date: 2026-09-02 23:04:07.756806 +""" + +from logging import getLogger + +from alembic import op +import sqlalchemy as sa + +# Revision identifiers, used by Alembic. +revision = '6df2e06f8cf6' +down_revision = '635389f535cd' +branch_labels = None +depends_on = None + + +def upgrade(): + LOG = getLogger("migration/config") + # Nullable: existing rows predate this column and have no provider. + op.add_column( + 'oauth_tokens', + sa.Column('provider', sa.String(), nullable=True)) + + +def downgrade(): + LOG = getLogger("migration/config") + op.drop_column('oauth_tokens', 'provider') diff --git a/web/server/codechecker_server/session_manager.py b/web/server/codechecker_server/session_manager.py index e644175793..fd4299221a 100644 --- a/web/server/codechecker_server/session_manager.py +++ b/web/server/codechecker_server/session_manager.py @@ -14,10 +14,11 @@ import re import uuid -from datetime import datetime +from datetime import datetime, timedelta import hashlib from typing import Optional +from authlib.integrations.requests_client import OAuth2Session from sqlalchemy.orm import sessionmaker from codechecker_common.compatibility.multiprocessing import cpu_count @@ -951,6 +952,12 @@ def create_session_oauth(self, provider: str, not providers.get(provider).get('enabled'): return False + # Perform cleanup of session memory, if neccessary. + self.__logins_since_prune += 1 + if self.__logins_since_prune >= \ + self.__auth_config['logins_until_cleanup']: + self.__cleanup_sessions() + # Generate a new token and create a local session. codechecker_session_token = generate_session_token() @@ -991,6 +998,7 @@ def create_session_oauth(self, provider: str, .first() oauth_token_session = OAuthToken( + provider=provider, access_token=access_token, expires_at=token_expires_at, refresh_token=refresh_token, @@ -1110,24 +1118,109 @@ def get_session(self, token) -> Optional[_Session]: return None for sess in self.__sessions: - if sess.is_alive and sess.token == token: - # If the session is alive but the should be re-validated. - if sess.is_refresh_time_expire: - sess.revalidate() - return sess + if sess.token == token: + if sess.is_alive: + # If the session is alive but the should be re-validated. + if sess.is_refresh_time_expire: + sess.revalidate() + return sess + + if self.__try_extend_oauth_session(token): + sess.last_access = datetime.now() + return sess + break # Try to get a local session from the database. local_session = self.__get_local_session_from_db(token) - if local_session and local_session.is_alive: - self.__sessions.append(local_session) - if local_session.is_refresh_time_expire: - local_session.revalidate() - return local_session + if local_session: + if local_session.is_alive: + self.__sessions.append(local_session) + if local_session.is_refresh_time_expire: + local_session.revalidate() + return local_session + + if self.__try_extend_oauth_session(token): + local_session.last_access = datetime.now() + self.__sessions.append(local_session) + return local_session self.invalidate(token) return None + def __try_extend_oauth_session(self, token) -> bool: + """ + Extends an OAuth session whose lifetime has lapsed: locally + while the access token is valid, then via a refresh token grant. + """ + if not self.__is_method_enabled('oauth'): + return False + + transaction = None + try: + transaction = self.__config_db_sessionmaker() + row = transaction.query(OAuthToken, SessionRecord) \ + .join(SessionRecord, + OAuthToken.auth_session_id == SessionRecord.id) \ + .filter(SessionRecord.token == token) \ + .limit(1).one_or_none() + + if not row or not row[0].provider: + return False + + oauth_token, session_record = row + now = datetime.now() + + if oauth_token.expires_at and now < oauth_token.expires_at: + session_record.last_access = now + transaction.commit() + LOG.info("Extended session %s... locally, the %s access " + "token is valid until %s.", + token[:8], oauth_token.provider, + oauth_token.expires_at) + return True + + oauth_config = self.get_oauth_config(oauth_token.provider) + if not oauth_config or not oauth_config.get('enabled'): + return False + + LOG.info("Access token of session %s... expired at %s, " + "requesting a new one (provider: %s).", + token[:8], oauth_token.expires_at, + oauth_token.provider) + + oauth2_session = OAuth2Session( + oauth_config['client_id'], + oauth_config['client_secret'], + scope=oauth_config['scope']) + + new_token = oauth2_session.refresh_token( + oauth_config['token_url'], + refresh_token=oauth_token.refresh_token) + + if not new_token.get('access_token'): + return False + + oauth_token.access_token = new_token['access_token'] + oauth_token.refresh_token = new_token.get( + 'refresh_token', oauth_token.refresh_token) + if new_token.get('expires_in') is not None: + oauth_token.expires_at = \ + now + timedelta(seconds=new_token['expires_in']) + session_record.last_access = now + + transaction.commit() + LOG.info("Refreshed session %s..., the new access token " + "expires at %s.", token[:8], oauth_token.expires_at) + return True + except Exception as e: + LOG.warning("OAuth session extension failed for %s...: %s", + token[:8], str(e)) + return False + finally: + if transaction: + transaction.close() + def invalidate_local_session(self, token): """ Remove a user's previous session from the local in memory store. @@ -1173,5 +1266,6 @@ def __cleanup_sessions(self): self.invalidate_local_session(s.token) for s in self.__sessions[:]: - if not s.is_alive: + if not s.is_alive and not self.__try_extend_oauth_session( + s.token): self.invalidate(s.token) From 5ede867c25eb1b71e9c5f26f7d3dc95246414581 Mon Sep 17 00:00:00 2001 From: xb058t Date: Thu, 3 Sep 2026 11:57:29 +0200 Subject: [PATCH 2/4] [test] Verify oauth_tokens.provider is stored after OAuth login Extends the OAuth login test to also check the persisted provider value, not just access_token. --- .../authentication/test_authentication.py | 12 ++++++++---- web/tests/libtest/env.py | 18 +++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/web/tests/functional/authentication/test_authentication.py b/web/tests/functional/authentication/test_authentication.py index a829b7426a..3934ddacef 100644 --- a/web/tests/functional/authentication/test_authentication.py +++ b/web/tests/functional/authentication/test_authentication.py @@ -298,14 +298,18 @@ def test_oauth_token_session(self): session = self.try_login("github", "admin_github", "admin") self.assertTrue(session, "Authentication failed") - result = env.validate_oauth_token_session(session_factory, "github1",) - self.assertTrue(result, "Access_token wasn't inserted in Database") + result = env.validate_oauth_token_session( + session_factory, "github1", provider="github") + self.assertTrue( + result, "Access_token/provider wasn't inserted in Database") session = self.try_login("google", "user_google", "user") self.assertTrue(session, "Authentication failed") - result = env.validate_oauth_token_session(session_factory, "google3",) - self.assertTrue(result, "Access_token wasn't inserted in Database") + result = env.validate_oauth_token_session( + session_factory, "google3", provider="google") + self.assertTrue( + result, "Access_token/provider wasn't inserted in Database") def test_oauth_regular_users(self): """ diff --git a/web/tests/libtest/env.py b/web/tests/libtest/env.py index 225f571340..842aacc9de 100644 --- a/web/tests/libtest/env.py +++ b/web/tests/libtest/env.py @@ -561,20 +561,24 @@ def create_sqlalchemy_session(workspace): raise err -def validate_oauth_token_session(session_alchemy, access_token): +def validate_oauth_token_session(session_alchemy, access_token, provider=None): """ Helper function that returns bool depending - if the OAuth token exists + if the OAuth token exists. If 'provider' is given, also checks that + the stored token's provider matches it. """ - access_token_db = None with DBSession(session_alchemy) as session: - access_token_db, *_ = \ - session.query(OAuthToken.access_token) \ + row = session.query(OAuthToken.access_token, OAuthToken.provider) \ .filter(OAuthToken.access_token == access_token) \ .first() - return access_token_db is not None \ - and access_token_db == access_token + + if row is None: + return False + + access_token_db, provider_db = row + return access_token_db == access_token \ + and (provider is None or provider_db == provider) def validate_oauth_session(session_alchemy, state): From 615e5ff4704372f9018dd2b64cf77c7f051886e2 Mon Sep 17 00:00:00 2001 From: xb058t Date: Thu, 3 Sep 2026 11:57:29 +0200 Subject: [PATCH 3/4] [test] Support refresh_token grant in the mock OAuth server Teaches the mock provider's /token endpoint to honor a grant_type=refresh_token request by issuing a new, distinct access token and rejecting unknown refresh tokens. --- .../functional/authentication/oauth_server.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/web/tests/functional/authentication/oauth_server.py b/web/tests/functional/authentication/oauth_server.py index 031867317f..e5ddf32665 100644 --- a/web/tests/functional/authentication/oauth_server.py +++ b/web/tests/functional/authentication/oauth_server.py @@ -67,6 +67,9 @@ class OauthServer(BaseHTTPRequestHandler): # Store pkce code challenge code_challenges = {} + # Refresh tokens the mock honors, grows as refreshes issue new ones. + valid_refresh_tokens = set(tokens_by_code.values()) + users_by_token = { "github1": { "login": "admin_github", @@ -165,6 +168,20 @@ def handle_user_token_request(self): for field in raw.split('&'): key, value = field.split('=') params[key] = value + # refresh_token grant: issue a new, distinct access token. + if "code" not in params and "refresh_token" in params: + refresh_token = params['refresh_token'] + if refresh_token in self.valid_refresh_tokens: + new_token = refresh_token + "_refreshed" + self.valid_refresh_tokens.add(new_token) + return self.show_json({ + 'access_token': new_token, + 'refresh_token': new_token, + 'token_type': "bearer", + 'scope': "user:email", + 'expires_in': 3600 + }) + return self.show_rejection("Invalid refresh token") if "code" in params: code = params['code'] # check for PKCE code challenge similarity From 1a37f76a36d09110a8d5ebf572225553025cb9ef Mon Sep 17 00:00:00 2001 From: xb058t Date: Fri, 9 Oct 2026 00:01:08 +0200 Subject: [PATCH 4/4] Added requested changes --- .../codechecker_server/session_manager.py | 32 +-- .../unit/test_oauth_session_extension.py | 235 ++++++++++++++++++ 2 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 web/server/tests/unit/test_oauth_session_extension.py diff --git a/web/server/codechecker_server/session_manager.py b/web/server/codechecker_server/session_manager.py index fd4299221a..f7b64229e8 100644 --- a/web/server/codechecker_server/session_manager.py +++ b/web/server/codechecker_server/session_manager.py @@ -14,10 +14,11 @@ import re import uuid -from datetime import datetime, timedelta +from datetime import datetime import hashlib from typing import Optional +from authlib.integrations.base_client import OAuthError from authlib.integrations.requests_client import OAuth2Session from sqlalchemy.orm import sessionmaker @@ -1125,8 +1126,7 @@ def get_session(self, token) -> Optional[_Session]: sess.revalidate() return sess - if self.__try_extend_oauth_session(token): - sess.last_access = datetime.now() + if self.__try_extend_oauth_session(sess): return sess break @@ -1139,8 +1139,7 @@ def get_session(self, token) -> Optional[_Session]: local_session.revalidate() return local_session - if self.__try_extend_oauth_session(token): - local_session.last_access = datetime.now() + if self.__try_extend_oauth_session(local_session): self.__sessions.append(local_session) return local_session @@ -1148,14 +1147,16 @@ def get_session(self, token) -> Optional[_Session]: return None - def __try_extend_oauth_session(self, token) -> bool: + def __try_extend_oauth_session(self, session: _Session) -> bool: """ Extends an OAuth session whose lifetime has lapsed: locally while the access token is valid, then via a refresh token grant. + Updates last_access both in the DB and in the session. """ if not self.__is_method_enabled('oauth'): return False + token = session.token transaction = None try: transaction = self.__config_db_sessionmaker() @@ -1174,6 +1175,7 @@ def __try_extend_oauth_session(self, token) -> bool: if oauth_token.expires_at and now < oauth_token.expires_at: session_record.last_access = now transaction.commit() + session.last_access = now LOG.info("Extended session %s... locally, the %s access " "token is valid until %s.", token[:8], oauth_token.provider, @@ -1198,24 +1200,26 @@ def __try_extend_oauth_session(self, token) -> bool: oauth_config['token_url'], refresh_token=oauth_token.refresh_token) - if not new_token.get('access_token'): - return False - oauth_token.access_token = new_token['access_token'] oauth_token.refresh_token = new_token.get( 'refresh_token', oauth_token.refresh_token) - if new_token.get('expires_in') is not None: - oauth_token.expires_at = \ - now + timedelta(seconds=new_token['expires_in']) + oauth_token.expires_at = \ + datetime.fromtimestamp(new_token['expires_at']) session_record.last_access = now transaction.commit() + session.last_access = now LOG.info("Refreshed session %s..., the new access token " "expires at %s.", token[:8], oauth_token.expires_at) return True + except OAuthError as e: + LOG.info("Refresh token of session %s... was rejected by %s " + "(%s), the user has to log in again.", + token[:8], oauth_token.provider, e.error) + return False except Exception as e: - LOG.warning("OAuth session extension failed for %s...: %s", - token[:8], str(e)) + LOG.error("OAuth session extension failed for %s...: %s", + token[:8], str(e)) return False finally: if transaction: diff --git a/web/server/tests/unit/test_oauth_session_extension.py b/web/server/tests/unit/test_oauth_session_extension.py new file mode 100644 index 0000000000..f4cb0985f2 --- /dev/null +++ b/web/server/tests/unit/test_oauth_session_extension.py @@ -0,0 +1,235 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +""" Tests for extending expired OAuth sessions. """ + +import json +import os +import shutil +import tempfile +import threading +import unittest +import uuid +from datetime import datetime, timedelta +from http.server import BaseHTTPRequestHandler, HTTPServer + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from codechecker_server.database.config_db_model import \ + Base, OAuthToken, Session as SessionRecord +from codechecker_server.session_manager import SessionManager + + +class _TokenHandler(BaseHTTPRequestHandler): + """ Mock OAuth token endpoint. """ + + def log_message(self, *args): + pass + + def do_POST(self): # pylint: disable=invalid-name + length = int(self.headers.get('Content-Length', 0)) + self.server.requests.append(self.rfile.read(length).decode()) + + if self.server.mode == 'ok': + code, body = 200, json.dumps({ + 'access_token': 'new_access', + 'refresh_token': 'new_refresh', + 'token_type': 'bearer', + 'scope': 'openid email profile', + 'expires_in': 3600}).encode() + content_type = 'application/json' + elif self.server.mode == 'reject': + code, body = 400, json.dumps({ + 'error': 'invalid_grant'}).encode() + content_type = 'application/json' + else: + code, body = 500, b'Internal Server Error' + content_type = 'text/plain' + + self.send_response(code) + self.send_header('Content-Type', content_type) + self.end_headers() + self.wfile.write(body) + + +class OAuthSessionExtensionTest(unittest.TestCase): + """ Tests for SessionManager OAuth session extension. """ + + def setUp(self): + self.oauth_server = HTTPServer(('localhost', 0), _TokenHandler) + self.oauth_server.mode = 'ok' + self.oauth_server.requests = [] + threading.Thread(target=self.oauth_server.serve_forever, + daemon=True).start() + oauth_host = f"http://localhost:{self.oauth_server.server_port}" + + self.workspace = tempfile.mkdtemp() + + default_cfg = os.path.join(os.environ['REPO_ROOT'], 'web', 'server', + 'config', 'server_config.json') + with open(default_cfg, encoding='utf-8') as f: + cfg = json.load(f) + + auth = cfg['authentication'] + auth['enabled'] = True + auth['session_lifetime'] = 300 + auth['refresh_time'] = 60 + auth['method_oauth'] = { + 'enabled': True, + 'shared_variables': { + 'host': 'http://localhost:8080', + 'oauth_host': oauth_host + }, + 'providers': { + 'github': { + 'enabled': True, + 'client_id': '1', + 'client_secret': '1', + 'template': 'github/v1', + 'authorization_url': '{oauth_host}/login', + 'token_url': '{oauth_host}/token', + 'user_info_url': '{oauth_host}/get_user', + 'scope': 'openid email profile', + 'user_info_mapping': {'username': 'login'} + } + } + } + + cfg_file = os.path.join(self.workspace, 'server_config.json') + with open(cfg_file, 'w', encoding='utf-8') as f: + json.dump(cfg, f) + os.chmod(cfg_file, 0o600) + + self.engine = create_engine( + 'sqlite:///' + os.path.join(self.workspace, 'config.sqlite')) + Base.metadata.create_all(self.engine) + self.db = sessionmaker(bind=self.engine) + + self.manager = SessionManager( + self.db, cfg_file, os.path.join(self.workspace, 'secrets.json')) + + self.old_access = datetime.now() - timedelta(hours=1) + + def tearDown(self): + self.oauth_server.shutdown() + self.oauth_server.server_close() + self.engine.dispose() + shutil.rmtree(self.workspace) + + def __insert_session(self, access_token_expires_at): + """ Inserts an expired session and returns its token. """ + token = uuid.uuid4().hex + with self.db() as transaction: + record = SessionRecord(token, 'admin_github', '') + record.last_access = self.old_access + transaction.add(record) + transaction.flush() + + transaction.add(OAuthToken('github', 'old_access', + access_token_expires_at, + 'old_refresh', record.id)) + transaction.commit() + return token + + def __get_db_state(self, token): + with self.db() as transaction: + record, oauth_token = transaction \ + .query(SessionRecord, OAuthToken) \ + .join(OAuthToken, + OAuthToken.auth_session_id == SessionRecord.id) \ + .filter(SessionRecord.token == token) \ + .one() + return record.last_access, oauth_token + + def __assert_recent(self, timestamp): + self.assertLess(abs((datetime.now() - timestamp).total_seconds()), 5) + + def test_valid_access_token_extends_locally(self): + """ Valid access token, no refresh needed. """ + token = self.__insert_session(datetime.now() + timedelta(hours=1)) + + session = self.manager.get_session(token) + + self.assertIsNotNone(session) + self.assertEqual(self.oauth_server.requests, []) + + last_access, oauth_token = self.__get_db_state(token) + self.__assert_recent(last_access) + self.__assert_recent(session.last_access) + self.assertEqual(oauth_token.access_token, 'old_access') + + def test_expired_access_token_is_refreshed(self): + """ Expired access token is refreshed and stored. """ + token = self.__insert_session(datetime.now() - timedelta(minutes=1)) + + session = self.manager.get_session(token) + + self.assertIsNotNone(session) + self.assertEqual(len(self.oauth_server.requests), 1) + self.assertIn('grant_type=refresh_token', + self.oauth_server.requests[0]) + self.assertIn('refresh_token=old_refresh', + self.oauth_server.requests[0]) + + last_access, oauth_token = self.__get_db_state(token) + self.__assert_recent(last_access) + self.__assert_recent(session.last_access) + self.assertEqual(oauth_token.access_token, 'new_access') + self.assertEqual(oauth_token.refresh_token, 'new_refresh') + self.assertAlmostEqual( + (oauth_token.expires_at - datetime.now()).total_seconds(), + 3600, delta=10) + + def test_cached_session_is_refreshed(self): + """ Same for a session cached in memory. """ + token = self.__insert_session(datetime.now() + timedelta(hours=1)) + session = self.manager.get_session(token) + + session.last_access = self.old_access + with self.db() as transaction: + transaction.query(OAuthToken) \ + .filter(OAuthToken.access_token == 'old_access') \ + .update({'expires_at': datetime.now() - timedelta(minutes=1)}) + transaction.commit() + + self.assertIs(self.manager.get_session(token), session) + self.assertEqual(len(self.oauth_server.requests), 1) + self.__assert_recent(session.last_access) + + _, oauth_token = self.__get_db_state(token) + self.assertEqual(oauth_token.access_token, 'new_access') + + def test_rejected_refresh_token(self): + """ Rejected refresh token ends the session, logged as info. """ + self.oauth_server.mode = 'reject' + token = self.__insert_session(datetime.now() - timedelta(minutes=1)) + + with self.assertLogs('server', level='INFO') as logs: + self.assertIsNone(self.manager.get_session(token)) + + self.assertTrue(any('invalid_grant' in line and + line.startswith('INFO') + for line in logs.output)) + self.assertFalse(any(line.startswith(('WARNING', 'ERROR')) + for line in logs.output)) + + def test_provider_error(self): + """ Provider error ends the session, logged as error. """ + self.oauth_server.mode = 'error' + token = self.__insert_session(datetime.now() - timedelta(minutes=1)) + + with self.assertLogs('server', level='ERROR') as logs: + self.assertIsNone(self.manager.get_session(token)) + + self.assertTrue(any('OAuth session extension failed' in line + for line in logs.output)) + + +if __name__ == '__main__': + unittest.main()