Skip to content

server: accept offline_access scope - #209

Merged
mostlygeek merged 2 commits into
tailscale:mainfrom
gmann14:offline-access-scope
Nov 10, 2026
Merged

mostlygeek merged 2 commits into
tailscale:mainfrom
gmann14:offline-access-scope

Conversation

@gmann14

@gmann14 gmann14 commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #109

@mostlygeek, this is the PR you asked for on #109.

Clients that ask for a refresh token the standard way, by adding offline_access to the scope (OIDC Core 1.0 section 11), get error=invalid_scope back from /authorize, so the login fails before it reaches the token endpoint. BookLore is one of these clients.

This adds offline_access to openIDSupportedScopes. /authorize now accepts it, and both /.well-known/openid-configuration and /.well-known/oauth-authorization-server list it in scopes_supported. The scope is stored with the request and shows up in introspection like any other scope. Nothing else reads it.

What doesn't change

Refresh token issuance. The token endpoint already returns a refresh token for every authorization code grant, whether or not the client asks for offline_access (30-day lifetime, single use, held in memory). After this change, a client that asks for offline_access gets exactly what a client that doesn't ask has always got. TestOfflineAccessScope runs authorize, token and refresh with and without the scope and checks that both get a working refresh token, so a future change to when refresh tokens are issued will have to update that test on purpose.

Client authentication, Funnel rejection at /authorize, and scope case-sensitivity are also unchanged. OFFLINE_ACCESS in upper case is still rejected.

Spec note on consent

Section 11 says the OP must obtain consent before returning a refresh token for offline access, unless other conditions permit it, and that refresh tokens may also be issued in other contexts. tsidp has no consent step and already issues refresh tokens to every registered client. I read the admin's decision to register the client, plus the user being signed in to the tailnet, as those other conditions. That matches how tsidp behaves today; this PR only stops rejecting clients that ask. If you'd prefer tsidp to issue refresh tokens only when offline_access is requested, that would break clients that rely on getting one without asking, so I've left it out of this change.

Two smaller notes:

On the configurable extraScopes idea from the issue and #176: I kept this to the one scope the OIDC spec defines, since every client means the same thing by it. A configurable list could sit on top of this default.

Testing

  • TestValidateScopes: openid offline_access and openid email profile offline_access are accepted; OFFLINE_ACCESS is rejected.
  • TestScopeHandling: an offline_access case through the code exchange.
  • TestOfflineAccessScope (new): authorize, token and refresh through serveAuthorize and serveToken, for openid, openid offline_access and all four scopes. Checks the code is issued, the scopes are stored, a refresh token is returned, and the refreshed token keeps the scopes.
  • TestScopesSupportedMetadata (new): both discovery documents list openid email profile offline_access.
  • The cases that request offline_access fail on main and pass with the change. gofmt -l ., go vet ./..., go test ./... and go test -race ./server are clean.

Claude Code also ran the real server handler on a loopback port, with the LocalAPI WhoIs call stubbed the same way server/helpers_test.go does it, and went through the flow with curl. On main, /authorize with scope=openid offline_access redirected to the client with error=invalid_scope. With this change it returned a code, /token returned an ID token, access token and refresh token, introspection showed scope: "openid offline_access", the refresh grant worked, and reusing the old refresh token returned invalid_grant.

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

Clients that ask for a refresh token the standard way, with the
offline_access scope from OpenID Connect Core 1.0 section 11, were
rejected at /authorize with invalid_scope. BookLore is one such client.

Add offline_access to the supported scopes so /authorize accepts it and
both discovery documents advertise it. This does not change what tsidp
issues: the token endpoint already returns a refresh token for every
authorization code grant, whether or not the client asks for
offline_access. A new test covers the flow with and without the scope,
so a later change to when refresh tokens are issued has to be
deliberate.

Fixes tailscale#109

Signed-off-by: Graham Mann <graham.mann14@gmail.com>
Real tailnet nodes always have one. Without it the test would fail once
refresh grants look the user up again by node address.

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.

Looks reasonable to me, I don't think this needs a config flag.

@mostlygeek
mostlygeek merged commit 3884b71 into tailscale:main Nov 10, 2026
2 checks passed
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.

Add "offline_access" scope

3 participants