Skip to content

server: re-check the user on refresh_token grants - #211

Open
gmann14 wants to merge 2 commits into
tailscale:mainfrom
gmann14:fix/refresh-recheck-identity
Open

gmann14 wants to merge 2 commits into
tailscale:mainfrom
gmann14:fix/refresh-recheck-identity

Conversation

@gmann14

@gmann14 gmann14 commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

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:

  • the lookup fails (tailscaled no longer knows the node, or can't be reached),
  • the address now belongs to a different node,
  • the node is tagged,
  • the node belongs to a different user.

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:

  • One LocalAPI WhoIs call per refresh.
  • If the lookup fails for any reason, including tailscaled being briefly unavailable, the user has to log in again. Refresh tokens are single-use and are already deleted by that point, which is how other refresh errors behave today.
  • With no LocalClient, or no node address stored with the grant, the refresh is refused rather than skipped.

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

  • TestRefreshTokenRechecksUser (new) logs in through serveAuthorize, exchanges the code, changes what WhoIs returns, then refreshes twice. Same user and node: both refreshes work. Grant changed or removed: both work and carry the new extraClaims value. WhoIs fails, node now belongs to another user, node now tagged, address now belongs to another node: refused with invalid_grant. It also checks that the lookup goes to the stored node address, not the caller's address.
  • TestRecheckRefreshUserRefusesWithoutLookup (new): no LocalClient, no stored user and no stored node address are all refused.
  • TestEditClientDeleteRevokesTokens (new): after a UI delete, the client's code, access token and refresh token are gone, another client's are kept, and the deleted client's refresh token gets invalid_grant.
  • Four existing refresh tests stored a node with no address and ran without a LocalClient. They now set an address and use newTestWhoIsClient.
  • On main, all seven TestRefreshTokenRechecksUser cases fail: no lookup happens, the refused cases get 200, and changed grants still return role=admin. Without the second commit, TestEditClientDeleteRevokesTokens fails: the tokens are still there and the refresh returns 200. With the change all pass. gofmt -l ., go vet ./..., go test ./... and go test -race ./server are clean.
  • Not run against a real tailnet. WhoIs is stubbed the same way as in server/helpers_test.go.

I used Claude Code to help write this change and the tests, and it also ran them.

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 jentfoo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thank you for the report and PR!

@jentfoo jentfoo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One case missed after looking again

Comment thread server/token.go
if who == nil || who.Node == nil || who.UserProfile == nil {
return nil, fmt.Errorf("WhoIs %v: incomplete response", addr)
}
switch {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you add the check:

  case who.Node.Expired:
      return nil, fmt.Errorf("node %v key has expired", who.Node.ID)

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.

Refresh token grant doesn't re-check the user, so refreshed tokens keep login-time identity and grants

2 participants