Skip to content

Commit 0dd9f46

Browse files
committed
feat: oauth backchannel logout
1 parent 4dea4fd commit 0dd9f46

4 files changed

Lines changed: 226 additions & 3 deletions

File tree

backend/open_webui/env.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,12 @@ def parse_section(section):
544544
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
545545
ENABLE_OAUTH_TOKEN_EXCHANGE = os.environ.get('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true'
546546

547+
# Back-Channel Logout Configuration
548+
# When enabled, exposes POST /oauth/backchannel-logout for IdP-initiated logout
549+
# per OpenID Connect Back-Channel Logout 1.0 spec.
550+
# Requires Redis for JWT revocation.
551+
ENABLE_OAUTH_BACKCHANNEL_LOGOUT = os.environ.get('ENABLE_OAUTH_BACKCHANNEL_LOGOUT', 'False').lower() == 'true'
552+
547553
####################################
548554
# SCIM Configuration
549555
####################################

backend/open_webui/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,8 @@
511511
WEBUI_ADMIN_NAME,
512512
ENABLE_EASTER_EGGS,
513513
LOG_FORMAT,
514+
# OAuth Back-Channel Logout
515+
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
514516
)
515517

516518

@@ -2477,6 +2479,21 @@ async def oauth_login_callback(
24772479
return await oauth_manager.handle_callback(request, provider, response, db=db)
24782480

24792481

2482+
############################
2483+
# OIDC Back-Channel Logout
2484+
############################
2485+
2486+
2487+
@app.post('/oauth/backchannel-logout')
2488+
async def oauth_backchannel_logout(
2489+
request: Request,
2490+
db: Session = Depends(get_session),
2491+
):
2492+
if not ENABLE_OAUTH_BACKCHANNEL_LOGOUT:
2493+
raise HTTPException(status_code=404)
2494+
return await oauth_manager.handle_backchannel_logout(request, db=db)
2495+
2496+
24802497
@app.get('/manifest.json')
24812498
async def get_manifest_json():
24822499
if app.state.EXTERNAL_PWA_MANIFEST_URL:

backend/open_webui/utils/auth.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> st
206206
payload.update({'exp': expire})
207207

208208
jti = str(uuid.uuid4())
209-
payload.update({'jti': jti})
209+
payload.update({'jti': jti, 'iat': datetime.now(UTC)})
210210

211211
encoded_jwt = jwt.encode(payload, SESSION_SECRET, algorithm=ALGORITHM)
212212
return encoded_jwt
@@ -221,15 +221,36 @@ def decode_token(token: str) -> Optional[dict]:
221221

222222

223223
async def is_valid_token(request, decoded) -> bool:
224-
# Require Redis to check revoked tokens
224+
"""
225+
Check whether a JWT has been revoked. Two mechanisms:
226+
1. Per-token (jti) — used by user-initiated sign-out (known jti).
227+
2. Per-user (revoked_at) — used by OIDC back-channel logout when
228+
individual jti values are unknown; rejects tokens with iat <= revoked_at.
229+
"""
225230
if request.app.state.redis:
231+
# Per-token revocation
226232
jti = decoded.get('jti')
227-
228233
if jti:
229234
revoked = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked')
230235
if revoked:
231236
return False
232237

238+
# Per-user revocation (OIDC back-channel logout)
239+
user_id = decoded.get('id')
240+
if user_id:
241+
revoked_at = await request.app.state.redis.get(
242+
f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at'
243+
)
244+
if revoked_at:
245+
try:
246+
revoked_at_ts = int(revoked_at)
247+
token_iat = decoded.get('iat')
248+
# No iat means legacy token — reject since we can't verify issue time
249+
if token_iat is None or token_iat <= revoked_at_ts:
250+
return False
251+
except (ValueError, TypeError):
252+
pass
253+
233254
return True
234255

235256

backend/open_webui/utils/oauth.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
ENABLE_OAUTH_EMAIL_FALLBACK,
7676
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
7777
OAUTH_MAX_SESSIONS_PER_USER,
78+
REDIS_KEY_PREFIX,
7879
)
7980
from open_webui.utils.misc import parse_duration
8081
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):
16931694
log.error(f'Failed to store OAuth session server-side: {e}')
16941695

16951696
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

Comments
 (0)