Skip to content

Commit 8bd56ba

Browse files
committed
provider: added workload identity federation auth support
Updates #485 Signed-off-by: mcoulombe <[email protected]>
1 parent 1a175c3 commit 8bd56ba

File tree

5 files changed

+149
-16
lines changed

5 files changed

+149
-16
lines changed

docs/index.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ provider "tailscale" {
3434

3535
- `api_key` (String, Sensitive) The API key to use for authenticating requests to the API. Can be set via the TAILSCALE_API_KEY environment variable. Conflicts with 'oauth_client_id' and 'oauth_client_secret'.
3636
- `base_url` (String) The base URL of the Tailscale API. Defaults to https://api.tailscale.com. Can be set via the TAILSCALE_BASE_URL environment variable.
37-
- `oauth_client_id` (String) The OAuth application's ID when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_ID environment variable. Both 'oauth_client_id' and 'oauth_client_secret' must be set. Conflicts with 'api_key'.
38-
- `oauth_client_secret` (String, Sensitive) The OAuth application's secret when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_SECRET environment variable. Both 'oauth_client_id' and 'oauth_client_secret' must be set. Conflicts with 'api_key'.
39-
- `scopes` (List of String) The OAuth 2.0 scopes to request when for the access token generated using the supplied OAuth client credentials. See https://tailscale.com/kb/1215/oauth-clients/#scopes for available scopes. Only valid when both 'oauth_client_id' and 'oauth_client_secret' are set.
37+
- `identity_token` (String, Sensitive) The jwt identity token to exchange for a Tailscale API token when using a federated identity client. Can be set via the IDENTITY_TOKEN environment variable. Conflicts with 'api_key' and 'oauth_client_secret'.
38+
- `oauth_client_id` (String) The OAuth application's ID when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_ID environment variable. Either 'oauth_client_secret' or 'identity_token' must be set alongside 'oauth_client_id'. Conflicts with 'api_key'.
39+
- `oauth_client_secret` (String, Sensitive) The OAuth application's secret when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_SECRET environment variable. Conflicts with 'api_key' and 'identity_token'.
40+
- `scopes` (List of String) The OAuth 2.0 scopes to request for when generating the access token using the supplied OAuth client credentials. See https://tailscale.com/kb/1215/oauth-clients/#scopes for available scopes. Only valid when both 'oauth_client_id' and 'oauth_client_secret' are set.
4041
- `tailnet` (String) The organization name of the Tailnet in which to perform actions. Can be set via the TAILSCALE_TAILNET environment variable. Default is the tailnet that owns API credentials passed to the provider.
4142
- `user_agent` (String) User-Agent header for API requests.

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ require (
1212
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
1313
golang.org/x/tools v0.37.0
1414
tailscale.com v1.88.3
15-
tailscale.com/client/tailscale/v2 v2.2.0
15+
tailscale.com/client/tailscale/v2 v2.2.1-0.20251015185717-65a1d0d0deaa // TODO(maxc) use the proper version once published
1616
)
1717

1818
require github.com/pkg/errors v0.9.1
@@ -83,7 +83,7 @@ require (
8383
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
8484
golang.org/x/mod v0.28.0 // indirect
8585
golang.org/x/net v0.44.0 // indirect
86-
golang.org/x/oauth2 v0.31.0 // indirect
86+
golang.org/x/oauth2 v0.32.0 // indirect
8787
golang.org/x/sync v0.17.0 // indirect
8888
golang.org/x/sys v0.36.0 // indirect
8989
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
252252
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
253253
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
254254
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
255+
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
256+
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
255257
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
256258
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
257259
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -319,3 +321,5 @@ tailscale.com v1.88.3 h1:OiE6iVqzykhbITxmIKjH8d00cw0LsJFO3TuFd4jQVXU=
319321
tailscale.com v1.88.3/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw=
320322
tailscale.com/client/tailscale/v2 v2.2.0 h1:z0MRwojnNcuRcYFumtpymrPH97slBI/zxq0nY/RYcJQ=
321323
tailscale.com/client/tailscale/v2 v2.2.0/go.mod h1:RkAl+CyJiu437uUelFWW/2wL+EgZ6Vd15S1f+IitGr4=
324+
tailscale.com/client/tailscale/v2 v2.2.1-0.20251015185717-65a1d0d0deaa h1:/0Z4BfsHMXaauoZuYoNzsjZGRhl0MUpL31j45CWpFWw=
325+
tailscale.com/client/tailscale/v2 v2.2.1-0.20251015185717-65a1d0d0deaa/go.mod h1:FuUEY9GaimRaE7cPSzkRfUQmvbdVs9C+vLHmun8+1qQ=

tailscale/provider.go

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,31 @@ func Provider(options ...ProviderOption) *schema.Provider {
4242
Description: "The API key to use for authenticating requests to the API. Can be set via the TAILSCALE_API_KEY environment variable. Conflicts with 'oauth_client_id' and 'oauth_client_secret'.",
4343
Sensitive: true,
4444
},
45+
"identity_token": {
46+
Type: schema.TypeString,
47+
DefaultFunc: schema.EnvDefaultFunc("IDENTITY_TOKEN", ""),
48+
Optional: true,
49+
Description: "The jwt identity token to exchange for a Tailscale API token when using a federated identity client. Can be set via the IDENTITY_TOKEN environment variable. Conflicts with 'api_key' and 'oauth_client_secret'.",
50+
Sensitive: true,
51+
},
4552
"oauth_client_id": {
4653
Type: schema.TypeString,
4754
DefaultFunc: schema.MultiEnvDefaultFunc(oauthClientIDEnvVars, ""),
4855
Optional: true,
49-
Description: "The OAuth application's ID when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_ID environment variable. Both 'oauth_client_id' and 'oauth_client_secret' must be set. Conflicts with 'api_key'.",
56+
Description: "The OAuth application's ID when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_ID environment variable. Either 'oauth_client_secret' or 'identity_token' must be set alongside 'oauth_client_id'. Conflicts with 'api_key'.",
5057
},
5158
"oauth_client_secret": {
5259
Type: schema.TypeString,
5360
DefaultFunc: schema.MultiEnvDefaultFunc(oauthClientSecretEnvVars, ""),
5461
Optional: true,
55-
Description: "The OAuth application's secret when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_SECRET environment variable. Both 'oauth_client_id' and 'oauth_client_secret' must be set. Conflicts with 'api_key'.",
62+
Description: "The OAuth application's secret when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_SECRET environment variable. Conflicts with 'api_key' and 'identity_token'.",
5663
Sensitive: true,
5764
},
5865
"scopes": {
5966
Type: schema.TypeList,
6067
Optional: true,
6168
Elem: &schema.Schema{Type: schema.TypeString},
62-
Description: "The OAuth 2.0 scopes to request when for the access token generated using the supplied OAuth client credentials. See https://tailscale.com/kb/1215/oauth-clients/#scopes for available scopes. Only valid when both 'oauth_client_id' and 'oauth_client_secret' are set.",
69+
Description: "The OAuth 2.0 scopes to request for when generating the access token using the supplied OAuth client credentials. See https://tailscale.com/kb/1215/oauth-clients/#scopes for available scopes. Only valid when both 'oauth_client_id' and 'oauth_client_secret' are set.",
6370
},
6471
"tailnet": {
6572
Type: schema.TypeString,
@@ -135,15 +142,10 @@ func providerConfigure(_ context.Context, provider *schema.Provider, d *schema.R
135142
apiKey := d.Get("api_key").(string)
136143
oauthClientID := d.Get("oauth_client_id").(string)
137144
oauthClientSecret := d.Get("oauth_client_secret").(string)
145+
idToken := d.Get("identity_token").(string)
138146

139-
if apiKey == "" && oauthClientID == "" && oauthClientSecret == "" {
140-
return nil, diag.Errorf("tailscale provider credentials are empty - set `api_key` or 'oauth_client_id' and 'oauth_client_secret'")
141-
} else if apiKey != "" && (oauthClientID != "" || oauthClientSecret != "") {
142-
return nil, diag.Errorf("tailscale provider credentials are conflicting - `api_key` conflicts with 'oauth_client_id' and 'oauth_client_secret'")
143-
} else if apiKey == "" && oauthClientID == "" && oauthClientSecret != "" {
144-
return nil, diag.Errorf("tailscale provider argument 'oauth_client_id' is empty")
145-
} else if apiKey == "" && oauthClientID != "" && oauthClientSecret == "" {
146-
return nil, diag.Errorf("tailscale provider argument 'oauth_client_secret' is empty")
147+
if diags := validateProviderCreds(apiKey, oauthClientID, oauthClientSecret, idToken); diags != nil && diags.HasError() {
148+
return nil, diags
147149
}
148150

149151
userAgent := d.Get("user_agent").(string)
@@ -176,6 +178,17 @@ func providerConfigure(_ context.Context, provider *schema.Provider, d *schema.R
176178
return client, nil
177179
}
178180

181+
if oauthClientID != "" && idToken != "" {
182+
apiKey, err = tailscale.IdentityFederationConfig{
183+
ClientID: oauthClientID,
184+
IDToken: idToken,
185+
BaseURL: baseURL,
186+
}.GenerateAccessToken()
187+
if err != nil {
188+
return nil, diag.Errorf("failed to exchange identity token for API token: %s", err)
189+
}
190+
}
191+
179192
client := &tailscale.Client{
180193
BaseURL: parsedBaseURL,
181194
UserAgent: userAgent,
@@ -186,6 +199,20 @@ func providerConfigure(_ context.Context, provider *schema.Provider, d *schema.R
186199
return client, nil
187200
}
188201

202+
func validateProviderCreds(apiKey string, oauthClientID string, oauthClientSecret string, idToken string) diag.Diagnostics {
203+
if apiKey == "" && oauthClientID == "" && oauthClientSecret == "" && idToken == "" {
204+
return diag.Errorf("tailscale provider credentials are empty - set `api_key` or 'oauth_client_id' and either 'oauth_client_secret' or 'identity_token'")
205+
} else if apiKey != "" && (oauthClientID != "" || oauthClientSecret != "" || idToken != "") {
206+
return diag.Errorf("tailscale provider credentials are conflicting - `api_key` conflicts with 'oauth_client_id', 'oauth_client_secret' and 'identity_token'")
207+
} else if apiKey == "" && oauthClientID == "" {
208+
return diag.Errorf("tailscale provider argument 'oauth_client_id' is empty")
209+
} else if oauthClientID != "" && (oauthClientSecret == "" && idToken == "") {
210+
return diag.Errorf("one of tailscale provider arguments 'oauth_client_secret' or 'identity_token' are mandatory with 'oauth_client_id'")
211+
}
212+
213+
return nil
214+
}
215+
189216
func diagnosticsError(err error, message string, args ...interface{}) diag.Diagnostics {
190217
var detail string
191218
if err != nil {

tailscale/provider_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"os"
11+
"strings"
1112
"testing"
1213

1314
"github.com/google/go-cmp/cmp"
@@ -203,3 +204,103 @@ func assertEqual(want, got any, errorMessage string) error {
203204
}
204205
return nil
205206
}
207+
208+
func TestValidateProviderCreds(t *testing.T) {
209+
t.Parallel()
210+
211+
tests := []struct {
212+
name string
213+
apiKey string
214+
oauthClientID string
215+
oauthSecret string
216+
idToken string
217+
wantErr string
218+
}{
219+
{
220+
name: "valid api_key only",
221+
apiKey: "test-api-key",
222+
wantErr: "",
223+
},
224+
{
225+
name: "valid oauth with client secret",
226+
oauthClientID: "client-id",
227+
oauthSecret: "client-secret",
228+
wantErr: "",
229+
},
230+
{
231+
name: "valid oauth with identity token",
232+
oauthClientID: "client-id",
233+
idToken: "id-token",
234+
wantErr: "",
235+
},
236+
{
237+
name: "all credentials empty",
238+
wantErr: "credentials are empty",
239+
},
240+
{
241+
name: "api_key conflicts with oauth_client_id",
242+
apiKey: "test-api-key",
243+
oauthClientID: "client-id",
244+
wantErr: "credentials are conflicting",
245+
},
246+
{
247+
name: "api_key conflicts with oauth_client_secret",
248+
apiKey: "test-api-key",
249+
oauthSecret: "client-secret",
250+
wantErr: "credentials are conflicting",
251+
},
252+
{
253+
name: "api_key conflicts with identity_token",
254+
apiKey: "test-api-key",
255+
idToken: "id-token",
256+
wantErr: "credentials are conflicting",
257+
},
258+
{
259+
name: "oauth_client_id missing with only oauth_client_secret",
260+
oauthSecret: "client-secret",
261+
wantErr: "oauth_client_id' is empty",
262+
},
263+
{
264+
name: "oauth_client_id missing with only identity_token",
265+
idToken: "id-token",
266+
wantErr: "oauth_client_id' is empty",
267+
},
268+
{
269+
name: "oauth_client_id without secret or token",
270+
oauthClientID: "client-id",
271+
wantErr: "oauth_client_secret' or 'identity_token' are mandatory",
272+
},
273+
}
274+
275+
for _, tt := range tests {
276+
t.Run(tt.name, func(t *testing.T) {
277+
diags := validateProviderCreds(tt.apiKey, tt.oauthClientID, tt.oauthSecret, tt.idToken)
278+
279+
if tt.wantErr == "" && diags.HasError() {
280+
t.Errorf("unexpected error: %v", diags)
281+
282+
}
283+
284+
if tt.wantErr != "" && !diags.HasError() {
285+
t.Errorf("expected error containing %q but got none", tt.wantErr)
286+
return
287+
}
288+
289+
if tt.wantErr != "" {
290+
match := false
291+
for _, d := range diags {
292+
if d.Severity == diag.Error {
293+
errMsg := d.Summary + d.Detail
294+
if strings.Contains(errMsg, tt.wantErr) {
295+
match = true
296+
break
297+
}
298+
}
299+
}
300+
if !match {
301+
t.Errorf("expected error containing %q but got: %v", tt.wantErr, diags)
302+
}
303+
}
304+
})
305+
}
306+
}

0 commit comments

Comments
 (0)