diff --git a/hscontrol/db/oauth.go b/hscontrol/db/oauth.go index 6ce757bc..07c234f3 100644 --- a/hscontrol/db/oauth.go +++ b/hscontrol/db/oauth.go @@ -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 } diff --git a/hscontrol/db/oauth_test.go b/hscontrol/db/oauth_test.go index 1bf8c72d..7d662959 100644 --- a/hscontrol/db/oauth_test.go +++ b/hscontrol/db/oauth_test.go @@ -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" diff --git a/hscontrol/types/oauth.go b/hscontrol/types/oauth.go index 66c87e83..8db2f466 100644 --- a/hscontrol/types/oauth.go +++ b/hscontrol/types/oauth.go @@ -11,6 +11,14 @@ const ( // hskey-client--. 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--. The v2 auth middleware dispatches a // scope-limited token from an all-access admin key on this prefix alone, so