Last active
February 8, 2026 13:22
-
-
Save alanhamlett/4cb06da553438f0d5c3dd7ac218c9711 to your computer and use it in GitHub Desktop.
Claude Opus 4.6 Real World Not There Yet
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original Plan Prompt: implement backchannel-logout in the wakatime/sso/views.py file | |
| Claude Code v2.1.37 | |
| ▐▛███▜▌ Opus 4.6 · API Usage Billing | |
| ▝▜█████▛▘ ~/git/wakatime | |
| ▘▘ ▝▝ Opus 4.6 is here · Most capable for ambitious work | |
| ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ | |
| │ Plan to implement │ | |
| │ │ | |
| │ Implement OIDC Backchannel Logout │ | |
| │ │ | |
| │ Context │ | |
| │ │ | |
| │ The SSO login flow already stores sid (session ID) from the OIDC provider in the UserSSOLogin table (added in commit f2d5a4a22). The backchannel logout endpoint at POST /sso/oidc/backchannel-logout is currently a stub │ | |
| │ returning an error. This needs to be implemented so identity providers can notify WakaTime to terminate a user's session server-to-server. │ | |
| │ │ | |
| │ Changes │ | |
| │ │ | |
| │ 1. wakatime/sso/views.py — Implement oidc_backchannel_logout() │ | |
| │ │ | |
| │ Add @csrf.exempt decorator (import csrf from wakatime.auth) since this is a server-to-server POST from the identity provider with no browser/cookie context. │ | |
| │ │ | |
| │ Implementation of the function: │ | |
| │ │ | |
| │ 1. Extract logout token from POST body (request.form.get("logout_token")) — per OIDC spec, it's sent as application/x-www-form-urlencoded │ | |
| │ 2. Decode JWT without verification to extract aud (client_id) for org lookup │ | |
| │ 3. Find the org via Org.query.filter_by(sso_client_id=aud) — reject if not found │ | |
| │ 4. Validate the JWT using the existing sso_utils.oidc_validate_id_token() but without nonce verification (pass expected_nonce=None) │ | |
| │ 5. Validate logout-token-specific claims: │ | |
| │ - Must have events claim containing key http://schemas.openid.net/event/backchannel-logout │ | |
| │ - Must have sid claim (since that's what we store) │ | |
| │ 6. Look up UserSSOLogin by sid where is_expired is not True │ | |
| │ 7. Mark as expired: set is_expired = True on matching records │ | |
| │ 8. Invalidate user's web session by rotating user.session_id (same pattern as admin force-logout in wakatime/admin/views.py:1319) │ | |
| │ 9. Return 200 OK with empty JSON body (OIDC spec requires 200 on success) │ | |
| │ │ | |
| │ Add import uuid to imports (needed for uuid.uuid4() when rotating session_id). │ | |
| │ │ | |
| │ 2. wakatime/sso/utils.py — No changes needed │ | |
| │ │ | |
| │ The existing oidc_validate_id_token() already handles expected_nonce=None correctly (the nonce check at line 127 is skipped when expected_nonce is falsy). │ | |
| │ │ | |
| │ Key files │ | |
| │ │ | |
| │ - wakatime/sso/views.py — primary file to modify (lines 336-339) │ | |
| │ - wakatime/sso/utils.py — reuse oidc_validate_id_token() and oidc_get_claim() │ | |
| │ - wakatime/auth.py — import csrf for @csrf.exempt │ | |
| │ - wakatime/models/users.py:5268 — UserSSOLogin model (has is_expired column) │ | |
| │ - wakatime/models/users.py:162 — User.session_id for session invalidation │ | |
| │ │ | |
| │ Verification │ | |
| │ │ | |
| │ - Test with a mock POST request containing a valid JWT logout token with sid and events claims │ | |
| │ - Verify CSRF is not enforced on the endpoint │ | |
| │ - Verify that after backchannel logout, the user's session_id is rotated and UserSSOLogin.is_expired is set to True │ | |
| │ - Verify invalid/missing tokens return 400 errors │ | |
| ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ | |
| ⏺ Let me start by reading the relevant files to understand the current code structure. | |
| ⏺ Read 3 files (ctrl+o to expand) | |
| ⏺ Now let me check the admin force-logout pattern and the csrf import from wakatime.auth. | |
| ⏺ Searched for 1 pattern, read 2 files (ctrl+o to expand) | |
| ⏺ I also need to check if there's a sub column on UserSSOLogin and how csrf.exempt is used elsewhere. | |
| ⏺ Searched for 2 patterns (ctrl+o to expand) | |
| ⏺ Let me check how csrf is imported in those files that use csrf.exempt. | |
| ⏺ Searched for 2 patterns, read 1 file (ctrl+o to expand) | |
| ⏺ So csrf is imported from wakatime directly (it's a module-level attribute on wakatime.auth exposed via wakatime.__init__). Let me verify. | |
| ⏺ Searched for 1 pattern (ctrl+o to expand) | |
| ⏺ Good. csrf is importable from wakatime directly. Now I have everything I need. Let me implement the backchannel logout function. | |
| ⏺ Update(wakatime/sso/views.py) | |
| ⎿ Added 1 line | |
| 8 | |
| 9 import secrets | |
| 10 import traceback | |
| 11 +import uuid | |
| 12 from datetime import datetime | |
| 13 | |
| 14 import requests | |
| ⎿ Running PostToolUse hooks… | |
| ⏺ Update(wakatime/sso/views.py) | |
| ⎿ Added 3 lines, removed 1 line | |
| 16 from psycopg2.errors import QueryCanceled | |
| 17 from sqlalchemy.exc import IntegrityError, OperationalError | |
| 18 | |
| 19 -from wakatime import api_utils, app, auth, json, tasks, utils | |
| 19 +import jwt | |
| 20 + | |
| 21 +from wakatime import api_utils, app, auth, csrf, json, tasks, utils | |
| 22 from wakatime.constants import ( | |
| 23 DEFAULT_INTEGRATION_TIMEOUT, | |
| 24 QUEUE_WEB_BACKGROUND, | |
| ⎿ Running PostToolUse hooks… | |
| ⏺ Now replace the stub backchannel logout function: | |
| ⏺ Update(wakatime/sso/views.py) | |
| ⎿ Added 47 lines, removed 1 line | |
| 337 | |
| 338 | |
| 339 @blueprint.route("/oidc/backchannel-logout", methods=["POST"]) | |
| 340 +@csrf.exempt | |
| 341 @utils.nocache | |
| 342 def oidc_backchannel_logout(): | |
| 342 - return sso_utils.oidc_error() | |
| 343 + logout_token = request.form.get("logout_token") | |
| 344 + if not logout_token: | |
| 345 + return json.jsonify({"error": "missing logout_token"}), 400 | |
| 346 + | |
| 347 + try: | |
| 348 + unverified = jwt.decode(logout_token, options={"verify_signature": False, "verify_aud": False, "verify_iss": False}) | |
| 349 + except Exception: | |
| 350 + return json.jsonify({"error": "invalid logout_token"}), 400 | |
| 351 + | |
| 352 + aud = unverified.get("aud") | |
| 353 + if isinstance(aud, list): | |
| 354 + aud = aud[0] if aud else None | |
| 355 + if not aud: | |
| 356 + return json.jsonify({"error": "missing aud claim"}), 400 | |
| 357 + | |
| 358 + org = Org.query.filter_by(sso_client_id=aud).first() | |
| 359 + if not org: | |
| 360 + return json.jsonify({"error": "unknown client_id"}), 400 | |
| 361 + | |
| 362 + metadata = org.sso_discovery_data | |
| 363 + claims, error = sso_utils.oidc_validate_id_token(logout_token, org, metadata, expected_nonce=None) | |
| 364 + if error: | |
| 365 + return json.jsonify({"error": error}), 400 | |
| 366 + | |
| 367 + events = claims.get("events") | |
| 368 + if not isinstance(events, dict) or "http://schemas.openid.net/event/backchannel-logout" not in events: | |
| 369 + return json.jsonify({"error": "invalid logout token events claim"}), 400 | |
| 370 + | |
| 371 + sid = claims.get("sid") | |
| 372 + if not sid: | |
| 373 + return json.jsonify({"error": "missing sid claim"}), 400 | |
| 374 + | |
| 375 + sso_logins = UserSSOLogin.query.filter( | |
| 376 + UserSSOLogin.sid == sid, | |
| 377 + UserSSOLogin.is_expired.isnot(True), | |
| 378 + ).all() | |
| 379 + | |
| 380 + for sso_login in sso_logins: | |
| 381 + sso_login.is_expired = True | |
| 382 + user = User.query.filter_by(id=sso_login.user_id).first() | |
| 383 + if user: | |
| 384 + user.session_id = uuid.uuid4() | |
| 385 + | |
| 386 + db.session.commit() | |
| 387 + | |
| 388 + return json.jsonify({}), 200 | |
| ⎿ Running PostToolUse hooks… | |
| ⏺ Let me verify the final file looks correct. | |
| ⏺ Reading 1 file… (ctrl+o to expand) | |
| ✻ Processing… (53s · ↓ 1.2k tokens) | |
| Resume this session with: | |
| claude --resume 13549861-e2fd-4186-a456-df2cec2ce2f1 | |
| Total cost: $1.20 | |
| Total duration (API): 4m 56s | |
| Total duration (wall): 40m 39s | |
| Total code changes: 98 lines added, 2 lines removed | |
| Usage by model: | |
| claude-haiku-4-5: 32.1k input, 14.1k output, 1.2m cache read, 115.3k cache write ($0.3673) | |
| claude-opus-4-6: 2.8k input, 6.8k output, 625.8k cache read, 53.5k cache write ($0.83) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/wakatime/models/orgs.py b/wakatime/models/orgs.py | |
| index 9d07e889f..83a5d349d 100644 | |
| --- a/wakatime/models/orgs.py | |
| +++ b/wakatime/models/orgs.py | |
| @@ -1580,13 +1580,16 @@ def remove_person(self, person, _skip_audit_log=False): | |
| }, | |
| } | |
| if not _skip_audit_log: | |
| - session.add(OrgAuditLog(event=ORG_AUDIT_LOG_EVENT_PERSON_REMOVED, org=self, event_data=data)) | |
| - if str(person.id) == str(app.current_user.id): | |
| - data["org"] = { | |
| - "id": self.id, | |
| - "name": self.name, | |
| - } | |
| - session.add(UserAuditLog(event=USER_AUDIT_LOG_EVENT_ORG_LEFT, event_data=data)) | |
| + if not app.current_user.is_authenticated: | |
| + session.add(OrgAuditLog(event=ORG_AUDIT_LOG_EVENT_PERSON_REMOVED, org=self, user_id=self.main_owner, event_data=data)) | |
| + else: | |
| + session.add(OrgAuditLog(event=ORG_AUDIT_LOG_EVENT_PERSON_REMOVED, org=self, event_data=data)) | |
| + if str(person.id) == str(app.current_user.id): | |
| + data["org"] = { | |
| + "id": self.id, | |
| + "name": self.name, | |
| + } | |
| + session.add(UserAuditLog(event=USER_AUDIT_LOG_EVENT_ORG_LEFT, event_data=data)) | |
| if len(self.get_people_with_permissions([ORG_PERMISSION_MANAGE_GROUPS], _cache=False)) == 0: | |
| session.rollback() | |
| diff --git a/wakatime/sso/utils.py b/wakatime/sso/utils.py | |
| index f3599db83..138d2876c 100644 | |
| --- a/wakatime/sso/utils.py | |
| +++ b/wakatime/sso/utils.py | |
| @@ -63,18 +63,25 @@ def oidc_extract_picture(claims): | |
| return None | |
| -def oidc_validate_id_token(id_token, org, metadata, expected_nonce): | |
| - if not id_token: | |
| - return None, "Missing id_token." | |
| +def oidc_get_unverified_header_from_token(token): | |
| + if not token: | |
| + return None, "Missing token." | |
| try: | |
| - header = jwt.get_unverified_header(id_token) | |
| - jwt.decode(id_token, options={"verify_signature": False, "verify_aud": False, "verify_iss": False}) | |
| + header = jwt.get_unverified_header(token) | |
| + jwt.decode(token, options={"verify_signature": False, "verify_aud": False, "verify_iss": False}) | |
| except: | |
| - return None, "Invalid id_token format." | |
| + return None, "Invalid token format." | |
| + return header, None | |
| + | |
| + | |
| +def oidc_validate_id_token(id_token, org, metadata, expected_nonce=None): | |
| + header, err = oidc_get_unverified_header_from_token(id_token) | |
| + if err: | |
| + return None, err | |
| alg = header.get("alg") | |
| if not alg: | |
| - return None, "OIDC id_token missing signing algorithm." | |
| + return None, "OIDC token missing signing algorithm." | |
| issuer = metadata.get("issuer") | |
| if not issuer: | |
| @@ -83,7 +90,7 @@ def oidc_validate_id_token(id_token, org, metadata, expected_nonce): | |
| key = None | |
| if alg and alg.startswith("HS"): | |
| if not org.sso_secret: | |
| - return None, "OIDC client secret missing for HS id_token." | |
| + return None, "OIDC client secret missing for HS token." | |
| key = org.sso_secret | |
| else: | |
| jwks = None | |
| @@ -122,7 +129,7 @@ def oidc_validate_id_token(id_token, org, metadata, expected_nonce): | |
| leeway=60, | |
| ) | |
| except: | |
| - return None, "OIDC id_token validation failed." | |
| + return None, "OIDC token validation failed." | |
| if expected_nonce and claims.get("nonce") != expected_nonce: | |
| return None, "OIDC nonce mismatch." | |
| @@ -130,6 +137,19 @@ def oidc_validate_id_token(id_token, org, metadata, expected_nonce): | |
| return claims, None | |
| +def oidc_get_unverified_aud_from_token(token): | |
| + header, err = oidc_get_unverified_header_from_token(token) | |
| + if err: | |
| + return None, err | |
| + | |
| + aud = header.get("aud") | |
| + if isinstance(aud, list): | |
| + aud = aud[0] if aud else None | |
| + if not aud: | |
| + return None, "missing aud claim" | |
| + return aud | |
| + | |
| + | |
| def oidc_error(code=None, msg=None, description=None, user=None): | |
| code = code if isinstance(code, int) else 400 | |
| context = { | |
| diff --git a/wakatime/sso/views.py b/wakatime/sso/views.py | |
| index 254443e13..034a39fed 100644 | |
| --- a/wakatime/sso/views.py | |
| +++ b/wakatime/sso/views.py | |
| @@ -8,20 +8,21 @@ | |
| import secrets | |
| import traceback | |
| +import uuid | |
| from datetime import datetime | |
| import requests | |
| -from flask import Blueprint, abort, redirect, request, session | |
| +from flask import Blueprint, abort, jsonify, redirect, request, session | |
| from psycopg2.errors import QueryCanceled | |
| from sqlalchemy.exc import IntegrityError, OperationalError | |
| -from wakatime import api_utils, app, auth, json, tasks, utils | |
| +from wakatime import api_utils, app, auth, csrf, json, tasks, utils | |
| from wakatime.constants import ( | |
| DEFAULT_INTEGRATION_TIMEOUT, | |
| QUEUE_WEB_BACKGROUND, | |
| USER_AUDIT_LOG_EVENT_LOGIN, | |
| ) | |
| -from wakatime.exceptions import EmailInvalidError | |
| +from wakatime.exceptions import EmailInvalidError, InvalidModificationError | |
| from wakatime.forms import EmailForm | |
| from wakatime.models import ( | |
| ExperimentBehavior, | |
| @@ -331,3 +332,52 @@ def oidc_callback(org_id=None): | |
| app.logger.error(f"SSO Error: Unable to login User({user.id})") | |
| return sso_utils.oidc_error(msg="Oops, something went wrong when logging in", code=500) | |
| + | |
| + | |
| +@blueprint.route("/oidc/backchannel-logout", methods=["POST"]) | |
| +@csrf.exempt | |
| +@utils.nocache | |
| +def oidc_backchannel_logout(): | |
| + token = request.form.get("logout_token") | |
| + | |
| + aud, error = sso_utils.oidc_get_unverified_aud_from_token(token) | |
| + if error: | |
| + app.logger.error(f"SSO Backchannel-Logout Error {error}: {token}") | |
| + return jsonify(error=error), 400 | |
| + | |
| + org = Org.query.filter_by(sso_client_id=utils.remove_nulls(aud)).first() | |
| + if not org or org.people_count == 0: | |
| + return jsonify(error="unknown client_id"), 400 | |
| + | |
| + metadata = org.sso_discovery_data | |
| + claims, error = sso_utils.oidc_validate_id_token(token, org, metadata, expected_nonce=None) | |
| + if error: | |
| + return jsonify(error=error), 400 | |
| + | |
| + app.logger.error(json.dumps(claims)) | |
| + | |
| + events = claims.get("events") | |
| + if not isinstance(events, dict) or "http://schemas.openid.net/event/backchannel-logout" not in events: | |
| + return jsonify(error="invalid logout token events claim"), 400 | |
| + | |
| + sid = claims.get("sid") | |
| + if not sid: | |
| + return jsonify(error="missing sid claim"), 400 | |
| + | |
| + sso_login = UserSSOLogin.query.filter(UserSSOLogin.sid == sid, UserSSOLogin.is_expired.isnot(True)).first() | |
| + if not sso_login: | |
| + return jsonify(error="invalid sid claim"), 400 | |
| + | |
| + user = sso_login.user | |
| + if not org.is_user_member(user): | |
| + return jsonify(error="invalid sid claim"), 400 | |
| + | |
| + user.session_id = uuid.uuid4() | |
| + UserSSOLogin.query.filter(UserSSOLogin.sid == sid, UserSSOLogin.is_expired.isnot(True)).update({"is_expired": True}) | |
| + try: | |
| + org.remove_person(user) | |
| + except InvalidModificationError as e: | |
| + return jsonify(error=str(e)), 400 | |
| + db.session.commit() | |
| + | |
| + return jsonify(success=True), 200 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment