|
75 | 75 | ENABLE_OAUTH_EMAIL_FALLBACK, |
76 | 76 | OAUTH_CLIENT_INFO_ENCRYPTION_KEY, |
77 | 77 | OAUTH_MAX_SESSIONS_PER_USER, |
| 78 | + REDIS_KEY_PREFIX, |
78 | 79 | ) |
79 | 80 | from open_webui.utils.misc import parse_duration |
80 | 81 | from open_webui.utils.auth import get_password_hash, create_token |
@@ -1693,3 +1694,181 @@ async def handle_callback(self, request, provider, response, db=None): |
1693 | 1694 | log.error(f'Failed to store OAuth session server-side: {e}') |
1694 | 1695 |
|
1695 | 1696 | return response |
| 1697 | + |
| 1698 | + async def handle_backchannel_logout(self, request, db=None): |
| 1699 | + """ |
| 1700 | + Handle an OIDC Back-Channel Logout request. |
| 1701 | + Validates the logout_token, identifies the user, revokes their |
| 1702 | + sessions via Redis, and deletes their OAuth sessions. |
| 1703 | + Returns a JSONResponse per the OIDC Back-Channel Logout 1.0 spec. |
| 1704 | + """ |
| 1705 | + import jwt as pyjwt |
| 1706 | + from fastapi.responses import JSONResponse |
| 1707 | + |
| 1708 | + # 1. Extract logout_token from form body |
| 1709 | + try: |
| 1710 | + form = await request.form() |
| 1711 | + logout_token = form.get('logout_token') |
| 1712 | + except Exception: |
| 1713 | + logout_token = None |
| 1714 | + |
| 1715 | + if not logout_token: |
| 1716 | + return JSONResponse( |
| 1717 | + status_code=400, |
| 1718 | + content={'error': 'invalid_request', 'error_description': 'Missing logout_token parameter'}, |
| 1719 | + ) |
| 1720 | + |
| 1721 | + # 2. Peek at unverified issuer to match against configured providers |
| 1722 | + try: |
| 1723 | + unverified_claims = pyjwt.decode(logout_token, options={'verify_signature': False}) |
| 1724 | + token_issuer = unverified_claims.get('iss') |
| 1725 | + except Exception as e: |
| 1726 | + log.warning(f'Back-channel logout: cannot decode logout_token: {e}') |
| 1727 | + return JSONResponse( |
| 1728 | + status_code=400, |
| 1729 | + content={'error': 'invalid_request', 'error_description': 'Malformed logout_token'}, |
| 1730 | + ) |
| 1731 | + |
| 1732 | + if not token_issuer: |
| 1733 | + return JSONResponse( |
| 1734 | + status_code=400, |
| 1735 | + content={'error': 'invalid_request', 'error_description': 'logout_token missing iss claim'}, |
| 1736 | + ) |
| 1737 | + |
| 1738 | + # 3. Find the configured provider whose issuer matches the token |
| 1739 | + matched_provider = None |
| 1740 | + matched_client_id = None |
| 1741 | + matched_jwks_uri = None |
| 1742 | + matched_issuer = None |
| 1743 | + |
| 1744 | + for provider_name in OAUTH_PROVIDERS: |
| 1745 | + server_metadata_url = self.get_server_metadata_url(provider_name) |
| 1746 | + if not server_metadata_url: |
| 1747 | + continue |
| 1748 | + |
| 1749 | + try: |
| 1750 | + async with aiohttp.ClientSession(trust_env=True) as session: |
| 1751 | + async with session.get(server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: |
| 1752 | + if r.status != 200: |
| 1753 | + continue |
| 1754 | + oidc_config = await r.json() |
| 1755 | + |
| 1756 | + provider_issuer = oidc_config.get('issuer') |
| 1757 | + if provider_issuer and provider_issuer == token_issuer: |
| 1758 | + client = self.get_client(provider_name) |
| 1759 | + matched_provider = provider_name |
| 1760 | + matched_client_id = client.client_id if client else None |
| 1761 | + matched_jwks_uri = oidc_config.get('jwks_uri') |
| 1762 | + matched_issuer = provider_issuer |
| 1763 | + break |
| 1764 | + except Exception as e: |
| 1765 | + log.debug(f'Back-channel logout: error checking provider {provider_name}: {e}') |
| 1766 | + continue |
| 1767 | + |
| 1768 | + if not matched_provider or not matched_client_id or not matched_jwks_uri: |
| 1769 | + log.warning(f'Back-channel logout: no configured provider matches issuer {token_issuer}') |
| 1770 | + return JSONResponse( |
| 1771 | + status_code=400, |
| 1772 | + content={'error': 'invalid_request', 'error_description': 'No configured provider matches token issuer'}, |
| 1773 | + ) |
| 1774 | + |
| 1775 | + # 4. Validate the logout_token signature and claims |
| 1776 | + try: |
| 1777 | + jwks_client = pyjwt.PyJWKClient(matched_jwks_uri) |
| 1778 | + signing_key = jwks_client.get_signing_key_from_jwt(logout_token) |
| 1779 | + |
| 1780 | + claims = pyjwt.decode( |
| 1781 | + logout_token, |
| 1782 | + signing_key.key, |
| 1783 | + algorithms=['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'], |
| 1784 | + audience=matched_client_id, |
| 1785 | + issuer=matched_issuer, |
| 1786 | + options={ |
| 1787 | + 'require': ['iss', 'aud', 'iat', 'events'], |
| 1788 | + }, |
| 1789 | + ) |
| 1790 | + except pyjwt.InvalidTokenError as e: |
| 1791 | + log.warning(f'Back-channel logout: invalid logout_token: {e}') |
| 1792 | + return JSONResponse( |
| 1793 | + status_code=400, |
| 1794 | + content={'error': 'invalid_request', 'error_description': f'Invalid logout_token: {e}'}, |
| 1795 | + ) |
| 1796 | + except Exception as e: |
| 1797 | + log.error(f'Back-channel logout: error validating logout_token: {e}') |
| 1798 | + return JSONResponse( |
| 1799 | + status_code=400, |
| 1800 | + content={'error': 'invalid_request', 'error_description': 'Failed to validate logout_token'}, |
| 1801 | + ) |
| 1802 | + |
| 1803 | + # 5. Validate events claim per spec |
| 1804 | + events = claims.get('events', {}) |
| 1805 | + if 'http://schemas.openid.net/event/backchannel-logout' not in events: |
| 1806 | + log.warning('Back-channel logout: missing required backchannel-logout event claim') |
| 1807 | + return JSONResponse( |
| 1808 | + status_code=400, |
| 1809 | + content={'error': 'invalid_request', 'error_description': 'Missing backchannel-logout event claim'}, |
| 1810 | + ) |
| 1811 | + |
| 1812 | + # 6. Per spec, back-channel logout tokens MUST NOT contain a nonce |
| 1813 | + if 'nonce' in claims: |
| 1814 | + log.warning('Back-channel logout: logout_token contains nonce (rejected per spec)') |
| 1815 | + return JSONResponse( |
| 1816 | + status_code=400, |
| 1817 | + content={'error': 'invalid_request', 'error_description': 'logout_token must not contain nonce'}, |
| 1818 | + ) |
| 1819 | + |
| 1820 | + # 7. Extract sub and/or sid — at least one must be present |
| 1821 | + sub = claims.get('sub') |
| 1822 | + sid = claims.get('sid') |
| 1823 | + |
| 1824 | + if not sub and not sid: |
| 1825 | + log.warning('Back-channel logout: logout_token contains neither sub nor sid') |
| 1826 | + return JSONResponse( |
| 1827 | + status_code=400, |
| 1828 | + content={'error': 'invalid_request', 'error_description': 'logout_token must contain sub or sid'}, |
| 1829 | + ) |
| 1830 | + |
| 1831 | + # 8. Identify users to log out |
| 1832 | + users_to_logout = [] |
| 1833 | + if sub: |
| 1834 | + user = Users.get_user_by_oauth_sub(matched_provider, sub, db=db) |
| 1835 | + if user: |
| 1836 | + users_to_logout.append(user) |
| 1837 | + |
| 1838 | + if not users_to_logout and sid: |
| 1839 | + log.info(f'Back-channel logout: no user found by sub, sid-based lookup not yet supported (sid={sid})') |
| 1840 | + |
| 1841 | + if not users_to_logout: |
| 1842 | + log.info(f'Back-channel logout: no matching user for provider={matched_provider}, sub={sub}, sid={sid}') |
| 1843 | + return JSONResponse(status_code=200, content={}) |
| 1844 | + |
| 1845 | + # 9. Revoke tokens and delete sessions |
| 1846 | + redis = request.app.state.redis |
| 1847 | + if not redis: |
| 1848 | + log.warning( |
| 1849 | + 'Back-channel logout: Redis not configured, cannot revoke JWT tokens. ' |
| 1850 | + 'OAuth sessions will be deleted but existing JWTs will remain valid until expiry.' |
| 1851 | + ) |
| 1852 | + |
| 1853 | + revoked_count = 0 |
| 1854 | + for user in users_to_logout: |
| 1855 | + sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db) |
| 1856 | + for oauth_session in sessions: |
| 1857 | + OAuthSessions.delete_session_by_id(oauth_session.id, db=db) |
| 1858 | + |
| 1859 | + if redis: |
| 1860 | + revocation_key = f'{REDIS_KEY_PREFIX}:auth:user:{user.id}:revoked_at' |
| 1861 | + await redis.set( |
| 1862 | + revocation_key, |
| 1863 | + str(int(time.time())), |
| 1864 | + ex=60 * 60 * 24 * 30, |
| 1865 | + ) |
| 1866 | + revoked_count += 1 |
| 1867 | + |
| 1868 | + log.info( |
| 1869 | + f'Back-channel logout: revoked sessions for user {user.id} ' |
| 1870 | + f'(email={user.email}, provider={matched_provider}, sessions_deleted={len(sessions)})' |
| 1871 | + ) |
| 1872 | + |
| 1873 | + log.info(f'Back-channel logout: completed for {len(users_to_logout)} user(s), {revoked_count} revocation(s) set') |
| 1874 | + return JSONResponse(status_code=200, content={}) |
0 commit comments