Skip to content

Fix Two OAuth token refresh bugs#155

Open
a-lavis wants to merge 2 commits intoascorbic:mainfrom
a-lavis:fix/dpop-refresh-challenge
Open

Fix Two OAuth token refresh bugs#155
a-lavis wants to merge 2 commits intoascorbic:mainfrom
a-lavis:fix/dpop-refresh-challenge

Conversation

@a-lavis
Copy link
Copy Markdown

@a-lavis a-lavis commented Apr 12, 2026

The two bugs:

The user-facing bug I observed

After I set up my PDS using cirrus, I logged into tangled.org using OAuth. Everything worked great for a while, but the next morning I was unable to edit my profile even though I was still logged into the site. Logging out and then back in fixed the issue, but I repeatedly saw this issue. It would occur 60 minutes after logging in.

OAuth token expiry

I realized: although every row in the oauth_tokens table has an access_token and a refresh_token, it only has a single expires_at column which represents when the access_token expires. This expires_at column is used to determine whether or not a row in the oauth_tokens table should be deleted in the cleanup() function.
If the cleanup() function runs before the third party application (in this case tangled.org) attempts to use the refresh_token to refresh, then refreshing will fail because the corresponding row in the oauth_tokens table is gone.

WWW-Authenticate header

Also, when a third party application makes a request to the PDS with an expired access_token, we respond with a 401, but it's not clear from the response why the request is unauthorized. According to the AT Protocol spec:

401 Unauthorized: Authentication is required for this endpoint. There should be a WWW-Authenticate header.

tangled.org uses indigo as its OAuth client; here's how it handles 401 responses from a PDS:

  1. If the WWW-Authenticate header is blank, simply return the 401 response. Otherwise, continue. (code link)
  2. Determine if the access token is expired by parsing the WWW-Authenticate header for error=invalid_token. (code link)
  3. If the access token is indeed expired, refresh it. (code link)

That means tangled.org (and other spec-compliant apps) will only attempt to refresh a token if we attach the WWW-Authenticate header with error=invalid_token to the 401 response.

My changes:

8616521

Add WWW-Authenticate header with error=invalid_token to 401 responses when the token is invalid.

091b469

Replace oauth_tokens.expires_at with access_expires_at and refresh_expires_at.
When cleanup() runs, determine which rows in oauth_tokens to delete based on refresh_expires_at, not access_expires_at.

Also, a mostly unrelated change that I think makes sense, but would be happy to undo: delete expired oauth_tokens even if they have been revoked.

Demo

This version is running right now at https://pds.aidanlavis.dev/

Before:

Screen.Recording.2026-04-12.at.12.35.05.AM.mov

After:

Screen.Recording.2026-04-12.at.12.22.46.PM.mov

scope,
dpopJkt,
accessTokenTtl = ACCESS_TOKEN_TTL,
refreshTokenTtl = REFRESH_TOKEN_TTL,
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly, REFRESH_TOKEN_TTL was completely unused before this PR (it was just exported by the package).

Comment on lines +149 to +151
refreshExpiresAt: rotateRefreshToken
? now + refreshTokenTtl
: existingData.refreshExpiresAt,
Copy link
Copy Markdown
Author

@a-lavis a-lavis Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could simply do:

Suggested change
refreshExpiresAt: rotateRefreshToken
? now + refreshTokenTtl
: existingData.refreshExpiresAt,
refreshExpiresAt: existingData.refreshExpiresAt,

That way, even though the refresh token is rotated, it still expires 90 days from the original auth. That way, at some point, we force users to re-auth, rather than the token being infinitely refreshable.

I'm curious to know what you think would be best!

Comment on lines +90 to +93
## Post-Completion Updates

- ✅ PDS auth middleware now returns DPoP `WWW-Authenticate` invalid_token challenges on 401 responses so OAuth clients can trigger automatic refresh.
- ✅ OAuth token storage now uses separate access/refresh expiries, and cleanup prunes by refresh expiry so refresh remains possible after access expiry.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't sure where the most appropriate place to document these changes was - happy to put this information somewhere else if you prefer!

this.sql.exec("DELETE FROM oauth_auth_codes WHERE expires_at < ?", now);
this.sql.exec(
"DELETE FROM oauth_tokens WHERE expires_at < ? AND revoked = 0",
"DELETE FROM oauth_tokens WHERE refresh_expires_at < ?",
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the PR description:

When cleanup() runs, determine which rows in oauth_tokens to delete based on refresh_expires_at, not access_expires_at.

Also, a mostly unrelated change that I think makes sense, but would be happy to undo: delete expired oauth_tokens even if they have been revoked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant