Skip to content

Instantly share code, notes, and snippets.

@alanhamlett
Last active February 8, 2026 13:22
Show Gist options
  • Select an option

  • Save alanhamlett/4cb06da553438f0d5c3dd7ac218c9711 to your computer and use it in GitHub Desktop.

Select an option

Save alanhamlett/4cb06da553438f0d5c3dd7ac218c9711 to your computer and use it in GitHub Desktop.
Claude Opus 4.6 Real World Not There Yet
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)
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