Repository navigation
Conversation
The refresh_token grant reused the WhoIs result saved at /authorize and never looked the user up again. A client that kept refreshing got new tokens for the same user even after the user's node was removed, tagged or given to another user, and grant changes such as extraClaims never reached refreshed tokens. Before issuing tokens, look the node up again with WhoIs, using the Tailscale IP stored at login, since the refresh request comes from the relying party rather than the user's device. Refuse the refresh with invalid_grant if the lookup fails, the address now belongs to a different node, the node is tagged, or the node's user has changed. Otherwise build the new tokens from the fresh WhoIs result, so CapMap and extraClaims are current. The existing refresh tests stored a user with no node address and ran without a LocalClient. They now give the node an address and stub WhoIs the same way the authorize tests do. Signed-off-by: Graham Mann <graham.mann14@gmail.com>
Deleting a client through DELETE /clients/{id} removes its codes,
access tokens and refresh tokens. Deleting it in the web UI (POST
/edit/{id} with action=delete) only removed the client, and the token
endpoint checks the client copy saved with each grant, so the deleted
client could keep using its refresh tokens.
Move the cleanup into deleteClientTokensLocked and call it from both
paths. The UI path only clears tokens once the client list has been
saved, since it puts the client back if saving fails.
Signed-off-by: Graham Mann <graham.mann14@gmail.com>
jentfoo
approved these changes
Nov 10, 2026
jentfoo
left a comment
Member
There was a problem hiding this comment.
LGTM, thank you for the report and PR!
jentfoo
requested changes
Nov 10, 2026
jentfoo
left a comment
Member
There was a problem hiding this comment.
One case missed after looking again
| if who == nil || who.Node == nil || who.UserProfile == nil { | ||
| return nil, fmt.Errorf("WhoIs %v: incomplete response", addr) | ||
| } | ||
| switch { |
Member
There was a problem hiding this comment.
Can you add the check:
case who.Node.Expired:
return nil, fmt.Errorf("node %v key has expired", who.Node.ID)
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #210
The refresh_token grant reused the WhoIs result saved at /authorize and never looked the user up again. This PR makes it check the user before issuing tokens. It also makes deleting a client in the web UI revoke its tokens, the way DELETE /clients/{id} already does. Two commits.
Re-check the user on refresh
handleRefreshTokenGrant now calls a new recheckRefreshUser after client authentication. It runs WhoIs for the user's node and refuses the refresh with invalid_grant if:
Otherwise the new tokens are built from the fresh WhoIs result, so CapMap, extraClaims, profile fields and the resource checks use current data. sub can't change, because the user ID has to match.
Which identifier to look up: the refresh request comes from the relying party, so its remote address is the app, not the user. The AuthRequest already holds the user's node, so recheckRefreshUser calls WhoIs on the node's first Tailscale IP. That's the same call /authorize makes, and it needs no new field. Tailscale IPs don't change when a node's key rotates, so a routine device re-auth doesn't end the user's app sessions. If an address is ever given to a different node, the node ID check refuses it.
The alternative is WhoIsNodeKey with the stored node key. That's stricter: a refresh would also fail after the device re-authenticates and gets a new key. Happy to switch if you'd prefer that.
Behaviour changes:
Revoke tokens on UI delete
The token cleanup in serveDeleteClient moves into deleteClientTokensLocked. handleEditClient's delete action calls it once the client list has been saved, since that path puts the client back if saving fails.
Not in this PR
A cap on the total lifetime of a refresh chain. Each refresh still starts a new 30-day window. With the re-check, a chain now ends when the user loses access, but a cap from the original login would need a new field on AuthRequest and a limit you'd want to choose.
#209 (also mine) added a test that stubbed WhoIs with a node that had no address. I've pushed a commit to #209 that gives that node an address, so both PRs pass whichever merges first (checked by merging the two locally).
Testing
I used Claude Code to help write this change and the tests, and it also ran them.