From e714dbe455e10c525475c43f5acf6615134a068b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 22 Oct 2026 01:44:43 +0000 Subject: [PATCH] api/v2: name the token's tags in the token answer Only a token that can assign tags names them, as Tailscale's answer does. --- hscontrol/api/v2/oauth.go | 15 +++++++++++++-- hscontrol/apiv2_compat_test.go | 17 +++-------------- hscontrol/apiv2_oauth_test.go | 9 +++++++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/hscontrol/api/v2/oauth.go b/hscontrol/api/v2/oauth.go index fb6085a51..738d5c32c 100644 --- a/hscontrol/api/v2/oauth.go +++ b/hscontrol/api/v2/oauth.go @@ -35,6 +35,9 @@ type tokenResponse struct { TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope,omitempty"` + // Tags names the tags of a token that can assign them, as Tailscale's + // answer does; a reader token's answer has none. + Tags string `json:"tags,omitempty"` } // oauthTokenHandler implements the client-credentials grant (RFC 6749 §4.4): @@ -110,12 +113,20 @@ func oauthTokenHandler(b Backend) http.HandlerFunc { return } - writeJSON(w, http.StatusOK, tokenResponse{ + resp := tokenResponse{ AccessToken: tokenStr, TokenType: "Bearer", ExpiresIn: int(accessTokenTTL.Seconds()), Scope: strings.Join(token.Scopes, " "), - }) + } + + granted := scope.Parse(token.Scopes) + if scope.Grants(granted, scope.AuthKeys) || scope.Grants(granted, scope.DevicesCore) || + scope.Grants(granted, scope.OAuthKeys) { + resp.Tags = strings.Join(token.Tags, " ") + } + + writeJSON(w, http.StatusOK, resp) } } diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index 7b2a1a363..47178b19c 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -56,20 +56,9 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/O3/0/status": mintScopeRefusalStatus, "oauth/O4/0/scope": unknownScopeGranted, + "oauth/O4/0/tags": unknownScopeGranted, - "oauth/O4/0/tags": mintTagsNotEchoed, - "oauth/E1/0/tags": mintTagsNotEchoed, - "oauth/E9a/0/tags": mintTagsNotEchoed, - "oauth/T1/0/tags": mintTagsNotEchoed, - "oauth/D0/6/tags": mintTagsNotEchoed, - "oauth/B1/0/tags": mintTagsNotEchoed, - "oauth/B3/0/tags": mintTagsNotEchoed, - "oauth/B11/1/tags": mintTagsNotEchoed, - - "key-list/setup-token-keys/0/tags": mintTagsNotEchoed, - "key-list/setup-token-keys/2/tags": mintTagsNotEchoed, - "key-list/setup-token-keys/4/tags": mintTagsNotEchoed, - "key-list/setup-token-keys/6/tags": mintTagsNotEchoed, + "key-list/setup-token-keys/4/tags": mintTagsSorted, "oauth/K5/0/status": unknownKeyType, @@ -151,7 +140,7 @@ const ( // Differences no planned change removes yet. const ( mintScopeRefusalStatus = "TOKEN_SCOPE_REFUSAL_STATUS: SaaS refuses a token scope the client lacks with 403 and a message; headscale with 400 invalid_scope" - mintTagsNotEchoed = "TOKEN_TAGS_NOT_ECHOED: SaaS's token answer names the token's tags; headscale's has no tags" + mintTagsSorted = "TOKEN_TAGS_SORTED: SaaS's token answer names the tags in the order its client was made with; headscale stores a client's tags sorted" unknownKeyType = "UNKNOWN_KEY_TYPE: SaaS refuses an unknown keyType with 400 before it checks scopes; headscale checks the scope first and answers 403" tagRefusalStatus = "TAG_REFUSAL_STATUS: SaaS refuses a tag outside the token's grant with 400 requested tags are invalid or not permitted; headscale with 403" emptyKeyListNull = "EMPTY_KEY_LIST_NULL: SaaS answers a listing that holds no key with keys null; headscale with []" diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go index d7046084f..112d4f4ce 100644 --- a/hscontrol/apiv2_oauth_test.go +++ b/hscontrol/apiv2_oauth_test.go @@ -215,6 +215,15 @@ func TestAPIv2OAuth_TokenEndpoint(t *testing.T) { require.Equalf(t, http.StatusOK, statusDup, "%v", mDup) assert.Equal(t, "auth_keys", mDup["scope"]) + // It names the tags of a token that can assign them; a reader token's + // answer has no tags field, whatever its client's tags. + assert.Equal(t, "tag:ci", m["tags"]) + + _, readerSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + statusReader, mReader := mintToken(t, baseURL, "", readerSecret, "", false) + require.Equalf(t, http.StatusOK, statusReader, "%v", mReader) + assert.NotContains(t, mReader, "tags") + // Bad credentials → RFC 6749 invalid_client. statusBad, mBad := mintToken(t, baseURL, clientID, "hskey-client-deadbeefdead-"+stringOf("0", 64), "", false) assert.Equal(t, http.StatusUnauthorized, statusBad)