From 412ef2726bfa46bc146b3d586068edf42b488d53 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 22 Oct 2026 01:43:30 +0000 Subject: [PATCH] api/v2: name the join key in a device's oauthClientId For a key a token made, Tailscale names the token's client, which is not recorded, so the field is left out. --- hscontrol/api/v2/devices.go | 9 +++++++++ hscontrol/apiv2_compat_test.go | 22 +++++----------------- hscontrol/apiv2_oauth_test.go | 7 +++++++ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index 4d4490af0..10f07eb06 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -5,6 +5,7 @@ import ( "net/http" "net/netip" "slices" + "strconv" "time" "github.com/danielgtaylor/huma/v2" @@ -40,6 +41,10 @@ type Device struct { OS string `json:"os"` ClientVersion string `json:"clientVersion"` UpdateAvailable bool `json:"updateAvailable"` + // OAuthClientID names the auth key the device joined with. For a key a + // token made, Tailscale names the token's OAuth client, which headscale + // does not record, so it is left out. + OAuthClientID string `json:"oauthClientId,omitempty"` // Populated only when fields=all is requested. AdvertisedRoutes []string `json:"advertisedRoutes,omitempty"` @@ -456,6 +461,10 @@ func deviceFromView(view types.NodeView, allFields bool, baseDomain string) Devi ClientVersion: hostinfoVersion(view), } + if key := view.AuthKey(); key.Valid() && !key.CreatedByToken() { + d.OAuthClientID = strconv.FormatUint(key.ID(), util.Base10) + } + if view.Expiry().Valid() { exp := view.Expiry().Get() d.Expires = &exp diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index 6d8b8523a..76fb49991 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -96,20 +96,8 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/B7/0/message": deletedClientCredentialText, "oauth/B7/1/message": deletedClientCredentialText, - "oauth/device:before/all/device-oauth-client": deviceOAuthClientID, - "oauth/device:ci-runner/all/device-oauth-client": deviceOAuthClientID, - "oauth/device:joiner/all/device-oauth-client": deviceOAuthClientID, - "oauth/device:laptop/all/device-oauth-client": deviceOAuthClientID, - "oauth/device:managed/all/device-oauth-client": deviceOAuthClientID, - "oauth/device:mixed/all/device-oauth-client": deviceOAuthClientID, - "oauth/device:prod-db/all/device-oauth-client": deviceOAuthClientID, - "oauth/device:router/all/device-oauth-client": deviceOAuthClientID, - "oauth/device:ci-runner/default/device-oauth-client": deviceOAuthClientID, - "oauth/device:laptop/default/device-oauth-client": deviceOAuthClientID, - "oauth/device:managed/default/device-oauth-client": deviceOAuthClientID, - "oauth/device:mixed/default/device-oauth-client": deviceOAuthClientID, - "oauth/device:prod-db/default/device-oauth-client": deviceOAuthClientID, - "oauth/device:router/default/device-oauth-client": deviceOAuthClientID, + "oauth/device:before/all/device-oauth-client": deviceOAuthClientMaker, + "oauth/device:joiner/all/device-oauth-client": deviceOAuthClientMaker, "oauth/device:before/empty-route-lists": emptyRouteListsOmitted, "oauth/device:ci-runner/empty-route-lists": emptyRouteListsOmitted, @@ -178,7 +166,6 @@ const ( deletedClientGone = "DELETED_CLIENT_GONE: SaaS keeps a deleted OAuth client readable as invalid and revoked; headscale removes it" deletedClientCredentialText = "DELETED_CLIENT_CREDENTIAL_TEXT: SaaS refuses a deleted client's secret and tokens with API token invalid; headscale says invalid client credentials and unauthorized" missingScopeText = "MISSING_SCOPE_TEXT: SaaS refuses a delete outside the token's scopes with calling actor does not have enough permissions; headscale names the missing scope" - deviceOAuthClientID = "DEVICE_OAUTH_CLIENT_ID: SaaS device bodies carry oauthClientId, naming the join key or the OAuth client that made it; headscale's have no such field" emptyRouteListsOmitted = "EMPTY_ROUTE_LISTS_OMITTED: SaaS sends advertisedRoutes and enabledRoutes as [] for a device without routes under fields=all; headscale leaves both out" noV2Validate = "NO_V2_VALIDATE: headscale's v2 API has no /acl/validate" hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent" @@ -186,8 +173,9 @@ const ( // SaaS behaviour recorded as it is; following it is the maintainer's call. const ( - missingScopeNotFound = "MISSING_SCOPE_NOT_FOUND: SaaS answers a call outside the token's scopes with 404 not found; headscale answers 403" - unknownScopeGranted = "UNKNOWN_SCOPE_GRANTED: SaaS ignores an unknown requested scope and grants the client's own; headscale grants the unknown scope alone" + deviceOAuthClientMaker = "DEVICE_OAUTH_CLIENT_ID: SaaS names, in oauthClientId, the OAuth client whose token made the device's key; headscale does not record which client that was, so it leaves the field out" + missingScopeNotFound = "MISSING_SCOPE_NOT_FOUND: SaaS answers a call outside the token's scopes with 404 not found; headscale answers 403" + unknownScopeGranted = "UNKNOWN_SCOPE_GRANTED: SaaS ignores an unknown requested scope and grants the client's own; headscale grants the unknown scope alone" ) // TestAPIv2OAuthCompat replays OAuth clients, token exchanges, auth keys, diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go index 7015e2cac..d7046084f 100644 --- a/hscontrol/apiv2_oauth_test.go +++ b/hscontrol/apiv2_oauth_test.go @@ -379,6 +379,13 @@ func TestAPIv2OAuth_TaggedDeviceUser(t *testing.T) { assert.Equal(t, want, dev.User) assert.Equal(t, []string{"tag:ci"}, dev.Tags) + // oauthClientId names the join key, unless a token made it. + if bearer == tok { + assert.Empty(t, dev.OAuthClientID) + } else { + assert.Equal(t, key.ID, dev.OAuthClientID) + } + if want == "" { continue }