db: accept tskey-client- prefix for OAuth client auth

The upstream tailscale client only runs its OAuth client-credentials exchange
for secrets prefixed tskey-client-, so accept it as an alias for hskey-client-.
The prefix is only a label sliced off before lookup, so the same stored client
authenticates under either; lets the official client and GitHub Action mint auth
keys against headscale.
This commit is contained in:
Kristoffer Dalby
2026-06-27 10:12:08 +00:00
parent c5dc3d4385
commit 69e84c356d
3 changed files with 62 additions and 1 deletions
+6 -1
View File
@@ -192,7 +192,12 @@ func (hsdb *HSDatabase) AuthenticateOAuthClient(secretStr string) (*types.OAuthC
// used directly as an auth key; strip them before parsing.
secretStr, _, _ = strings.Cut(secretStr, "?")
_, rest, found := strings.Cut(secretStr, types.OAuthClientPrefix)
// See [types.TailscaleOAuthClientPrefix] for why the alias is accepted.
rest, found := strings.CutPrefix(secretStr, types.OAuthClientPrefix)
if !found {
rest, found = strings.CutPrefix(secretStr, types.TailscaleOAuthClientPrefix)
}
if !found {
return nil, ErrOAuthClientFailedToParse
}
+48
View File
@@ -86,6 +86,54 @@ func TestOAuthClientCreateAndAuthenticate(t *testing.T) {
require.Error(t, err)
}
// TestOAuthClientAuthenticateTailscalePrefix asserts the same stored client
// authenticates under the tskey-client- alias, and that only a leading prefix
// is recognised.
func TestOAuthClientAuthenticateTailscalePrefix(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
secret, client, err := db.CreateOAuthClient(
[]string{"auth_keys"},
[]string{"tag:ci"},
"",
nil,
)
require.NoError(t, err)
rest := strings.TrimPrefix(secret, types.OAuthClientPrefix)
tsSecret := types.TailscaleOAuthClientPrefix + rest
for _, s := range []string{
tsSecret,
// Callers may pass the raw auth-key form; ?attributes are stripped.
tsSecret + "?baseURL=http://127.0.0.1:8080&ephemeral=true",
} {
got, err := db.AuthenticateOAuthClient(s)
require.NoError(t, err, s)
assert.Equal(t, client.ClientID, got.ClientID)
}
// A wrong secret under the alias parses but fails verification.
_, err = db.AuthenticateOAuthClient(
types.TailscaleOAuthClientPrefix + client.ClientID + "-" + strings.Repeat("0", 64),
)
require.Error(t, err)
require.NotErrorIs(t, err, ErrOAuthClientFailedToParse)
for _, s := range []string{
types.TailscaleOAuthClientPrefix,
"tskey-auth-" + rest,
"tskey-" + rest,
"junk-" + tsSecret,
"junk-" + secret,
types.TailscaleOAuthClientPrefix + secret,
} {
_, err := db.AuthenticateOAuthClient(s)
require.ErrorIs(t, err, ErrOAuthClientFailedToParse, s)
}
}
func TestHashSecretRoundTrip(t *testing.T) {
const secret = "a-high-entropy-credential-secret"
+8
View File
@@ -11,6 +11,14 @@ const (
// hskey-client-<clientID>-<secret>.
OAuthClientPrefix = "hskey-client-" //nolint:gosec // prefix, not a credential
// TailscaleOAuthClientPrefix is an accepted alias for [OAuthClientPrefix].
// The tailscale client only runs its OAuth client-credentials exchange
// (feature/oauthkey) for secrets with this prefix, so accepting it lets the
// stock client and GitHub Action mint auth keys against headscale. The
// prefix is only a label, cut before lookup; the same stored client
// authenticates under either.
TailscaleOAuthClientPrefix = "tskey-client-" //nolint:gosec // prefix, not a credential
// AccessTokenPrefix prefixes an OAuth access token:
// hskey-oauthtok-<prefix>-<secret>. The v2 auth middleware dispatches a
// scope-limited token from an all-access admin key on this prefix alone, so