From d887e15865599e1d087bf2212ecac73b63ec0a3b Mon Sep 17 00:00:00 2001 From: opeyem1a Date: Fri, 18 Sep 2026 02:47:09 -0600 Subject: [PATCH 1/2] fix: recover from rejected Canvas refresh tokens and add token admin When Canvas rejects a refresh token with invalid_grant, delete the stored token and restart the OAuth flow instead of rendering a 403 on every launch. The OAuth callback now uses update_or_create so re-authorizing can't collide with an existing row. Also registers CanvasOAuth2Token in the Django admin for viewing and deleting tokens. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NxgCeX8Y1zSgrACqveBrBm --- src/canvas_oauth/admin.py | 15 ++++++++++++++ src/canvas_oauth/canvas.py | 11 +++++++++- src/canvas_oauth/exceptions.py | 6 ++++++ src/canvas_oauth/oauth.py | 37 +++++++++++++++++++++++----------- 4 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 src/canvas_oauth/admin.py diff --git a/src/canvas_oauth/admin.py b/src/canvas_oauth/admin.py new file mode 100644 index 0000000..e45595b --- /dev/null +++ b/src/canvas_oauth/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from canvas_oauth.models import CanvasOAuth2Token + + +@admin.register(CanvasOAuth2Token) +class CanvasOAuth2TokenAdmin(admin.ModelAdmin): + list_display = ["user", "created_on", "updated_on", "expires"] + search_fields = ["user__username", "user__email"] + # Tokens only come from the OAuth flow; the admin is for viewing and deleting + exclude = ["access_token", "refresh_token"] + readonly_fields = ["user", "expires", "created_on", "updated_on"] + + def has_add_permission(self, request): + return False diff --git a/src/canvas_oauth/canvas.py b/src/canvas_oauth/canvas.py index 466751d..975c845 100755 --- a/src/canvas_oauth/canvas.py +++ b/src/canvas_oauth/canvas.py @@ -4,7 +4,7 @@ import requests from django.utils import timezone -from canvas_oauth.exceptions import InvalidOAuthReturnError +from canvas_oauth.exceptions import InvalidOAuthReturnError, InvalidGrantError from canvas_oauth import settings logger = logging.getLogger(__name__) @@ -85,6 +85,8 @@ def get_access_token( r = requests.post(oauth_token_url, post_params) logger.info("%s POST response from Canvas is %s", grant_type, r.text) if r.status_code != 200: + if r.status_code in (400, 401) and _error_code(r) == "invalid_grant": + raise InvalidGrantError("%s grant was rejected: %s" % (grant_type, r.text)) raise InvalidOAuthReturnError( "%s request failed to get a token: %s" % (grant_type, r.text) ) @@ -104,3 +106,10 @@ def get_access_token( refresh_token = response_data["refresh_token"] return (access_token, expires, refresh_token) + + +def _error_code(response): + try: + return response.json().get("error") + except ValueError: + return None diff --git a/src/canvas_oauth/exceptions.py b/src/canvas_oauth/exceptions.py index a5fecd9..ad572cf 100755 --- a/src/canvas_oauth/exceptions.py +++ b/src/canvas_oauth/exceptions.py @@ -12,3 +12,9 @@ class InvalidOAuthStateError(CanvasOAuthError): class InvalidOAuthReturnError(CanvasOAuthError): pass + + +class InvalidGrantError(InvalidOAuthReturnError): + """Canvas no longer recognizes the grant (e.g. the refresh token was revoked)""" + + pass diff --git a/src/canvas_oauth/oauth.py b/src/canvas_oauth/oauth.py index a634af6..c8b75ce 100755 --- a/src/canvas_oauth/oauth.py +++ b/src/canvas_oauth/oauth.py @@ -11,7 +11,11 @@ from canvas_oauth import canvas, settings from canvas_oauth.models import CanvasOAuth2Token -from canvas_oauth.exceptions import MissingTokenError, InvalidOAuthStateError +from canvas_oauth.exceptions import ( + MissingTokenError, + InvalidOAuthStateError, + InvalidGrantError, +) logger = logging.getLogger(__name__) @@ -96,11 +100,13 @@ def oauth_callback(request): code=code, ) - CanvasOAuth2Token.objects.create( + CanvasOAuth2Token.objects.update_or_create( user=request.user, - access_token=access_token, - expires=expires, - refresh_token=refresh_token, + defaults={ + "access_token": access_token, + "expires": expires, + "refresh_token": refresh_token, + }, ) return redirect(request.session["canvas_oauth_initial_uri"]) @@ -134,13 +140,20 @@ def refresh_oauth_token(request): # Get the new access token and expiration date via # a refresh token grant - oauth_token.access_token, oauth_token.expires, _ = canvas.get_access_token( - grant_type="refresh_token", - client_id=settings.CANVAS_OAUTH_CLIENT_ID, - client_secret=settings.CANVAS_OAUTH_CLIENT_SECRET, - redirect_uri=request.build_absolute_uri(reverse("canvas-oauth-callback")), - refresh_token=oauth_token.refresh_token, - ) + try: + oauth_token.access_token, oauth_token.expires, _ = canvas.get_access_token( + grant_type="refresh_token", + client_id=settings.CANVAS_OAUTH_CLIENT_ID, + client_secret=settings.CANVAS_OAUTH_CLIENT_SECRET, + redirect_uri=request.build_absolute_uri(reverse("canvas-oauth-callback")), + refresh_token=oauth_token.refresh_token, + ) + except InvalidGrantError: + # Canvas no longer recognizes the refresh token (revoked, key reset, etc.), + # so drop it and send the user back through authorization + logger.warning("Refresh token rejected for user %s", request.user.pk) + oauth_token.delete() + raise MissingTokenError("Refresh token rejected for user %s" % request.user.pk) # Update the model with new token and expiration oauth_token.save() From 353e618ac24c83d2efc338c3ba8642becce10f3b Mon Sep 17 00:00:00 2001 From: opeyem1a Date: Fri, 18 Sep 2026 02:49:25 -0600 Subject: [PATCH 2/2] chore: stop tracking .DS_Store It was already in .gitignore but had been committed before that rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NxgCeX8Y1zSgrACqveBrBm --- .DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 2fc7061ea1839875b5290bd9960612fa9437bed1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHML2uJA6n^f?maYP-F{ECQB5@sMn+9m&Qc8E=z-2{n092YLbYW>ynsjAMlX`~# zz+d3XFX3l!=LFxgy(w`wY1#oz!k+BUb>8=${n<_WG9nVKiN8TqCn5)xu~tQMM&avR z=SoSB+yV;li9#AYi;}(m1*`&A0jq#j;3_D9XEqn-oaer}YFn#-Rp7r= zfbS13Dq~aQOrgAWpphv6u#90@&_^C1Ij+X0#+gD%g-?||2u&3_#Sn^)>n@uEn;K^d z6`h2llhBcc&QOGm4xTH`Ni-GO)+%5Xm{)*v_eJVcLNUEJ@^}6!Jwa{!kwuah$tRB* z6u|xd{a(T`YIDNU& z5l1p2uzq>#1z3-%U$m0y&0Cpg1?NiK$mvssuMG3eu-d*ni9Bm#27JN_tX0?ta+N)t zN@jHBX*-0sF4PL}=W{L2k+r&NwC@qw)EiO5ZQ+3{U_L6BQ z>t&O))f>O&PcM=r@=p3a?`0TFyS3G)Nu2eC&>6>367x5L{nan3H_qEIb8Q#(9V!>Ln<DR)K%2z+WB0NT>h+