various: change OAuth and WIF auth key resolvers to take struct args

Change signature of OAuth and identityfederation auth key resolution
hooks to take in structs instead of lists of args as they were getting
unwieldily.

Updates https://github.com/tailscale/tailscale/issues/20339

Signed-off-by: Mario Minardi <mario@tailscale.com>
This commit is contained in:
Mario Minardi
2026-07-21 15:44:45 -06:00
committed by Mario Minardi
parent 3ccc7725a3
commit c8ae72b537
10 changed files with 133 additions and 80 deletions
@@ -29,34 +29,38 @@ func init() {
}
// resolveAuthKey uses OIDC identity federation to exchange the provided ID token and client ID for an authkey.
func resolveAuthKey(ctx context.Context, baseURL, clientID, idToken, audience string, tags []string) (string, error) {
if clientID == "" {
func resolveAuthKey(ctx context.Context, args tailscale.ResolveAuthKeyWIFArgs) (string, error) {
if args.ClientID == "" {
return "", nil // Short-circuit, no client ID means not using identity federation
}
if idToken == "" {
if audience == "" {
if args.IDToken == "" {
if args.Audience == "" {
return "", errors.New("federated identity requires either an ID token or an audience")
}
providerIdToken, err := wif.ObtainProviderToken(ctx, audience)
providerIdToken, err := wif.ObtainProviderToken(ctx, args.Audience)
if err != nil {
return "", errors.New("federated identity authkeys require --id-token")
}
idToken = providerIdToken
args.IDToken = providerIdToken
}
if len(tags) == 0 {
if len(args.Tags) == 0 {
return "", errors.New("federated identity authkeys require --advertise-tags")
}
if baseURL == "" {
baseURL = ipn.DefaultControlURL
if args.BaseURL == "" {
args.BaseURL = ipn.DefaultControlURL
}
strippedID, ephemeral, preauth, err := parseOptionalAttributes(clientID)
strippedID, ephemeral, preauth, err := parseOptionalAttributes(args.ClientID)
if err != nil {
return "", fmt.Errorf("failed to parse optional config attributes: %w", err)
}
accessToken, err := exchangeJWTForToken(ctx, baseURL, strippedID, idToken)
accessToken, err := exchangeJWTForToken(ctx, tailscale.ExchangeJWTForTokenWIFArgs{
BaseURL: args.BaseURL,
ClientID: strippedID,
IDToken: args.IDToken,
})
if err != nil {
return "", fmt.Errorf("failed to exchange JWT for access token: %w", err)
}
@@ -66,7 +70,7 @@ func resolveAuthKey(ctx context.Context, baseURL, clientID, idToken, audience st
tsClient := tailscale.NewClient("-", tailscale.APIKey(accessToken))
tsClient.UserAgent = "tailscale-cli-identity-federation"
tsClient.BaseURL = baseURL
tsClient.BaseURL = args.BaseURL
authkey, _, err := tsClient.CreateKey(ctx, tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
@@ -74,7 +78,7 @@ func resolveAuthKey(ctx context.Context, baseURL, clientID, idToken, audience st
Reusable: false,
Ephemeral: ephemeral,
Preauthorized: preauth,
Tags: tags,
Tags: args.Tags,
},
},
})
@@ -117,15 +121,15 @@ func parseOptionalAttributes(clientID string) (strippedID string, ephemeral bool
}
// exchangeJWTForToken exchanges a JWT for a Tailscale access token.
func exchangeJWTForToken(ctx context.Context, baseURL, clientID, idToken string) (string, error) {
func exchangeJWTForToken(ctx context.Context, args tailscale.ExchangeJWTForTokenWIFArgs) (string, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
token, err := (&oauth2.Config{
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token-exchange", baseURL),
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token-exchange", args.BaseURL),
},
}).Exchange(ctx, "", oauth2.SetAuthURLParam("client_id", clientID), oauth2.SetAuthURLParam("jwt", idToken))
}).Exchange(ctx, "", oauth2.SetAuthURLParam("client_id", args.ClientID), oauth2.SetAuthURLParam("jwt", args.IDToken))
if err != nil {
// Try to extract more detailed error message
if retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err); ok {
@@ -9,6 +9,8 @@ import (
"net/http/httptest"
"strings"
"testing"
"tailscale.com/internal/client/tailscale"
)
func TestResolveAuthKey(t *testing.T) {
@@ -70,7 +72,13 @@ func TestResolveAuthKey(t *testing.T) {
srv := mockedControlServer(t)
defer srv.Close()
authKey, err := resolveAuthKey(context.Background(), srv.URL, tt.clientID, tt.idToken, tt.audience, tt.tags)
authKey, err := resolveAuthKey(context.Background(), tailscale.ResolveAuthKeyWIFArgs{
BaseURL: srv.URL,
ClientID: tt.clientID,
IDToken: tt.idToken,
Audience: tt.audience,
Tags: tt.tags,
})
if tt.wantErr != "" {
if err == nil {
t.Errorf("resolveAuthKey() error = nil, want %q", tt.wantErr)
+6 -6
View File
@@ -33,15 +33,15 @@ func init() {
// false. The "baseURL" defaults to https://api.tailscale.com.
// The passed in tags are required, and must be non-empty. These will be
// set on the authkey generated by the OAuth2 dance.
func resolveAuthKey(ctx context.Context, clientSecret string, tags []string) (string, error) {
if !strings.HasPrefix(clientSecret, "tskey-client-") {
return clientSecret, nil
func resolveAuthKey(ctx context.Context, args tailscale.ResolveAuthKeyArgs) (string, error) {
if !strings.HasPrefix(args.AuthKey, "tskey-client-") {
return args.AuthKey, nil
}
if len(tags) == 0 {
if len(args.Tags) == 0 {
return "", errors.New("oauth authkeys require --advertise-tags")
}
strippedSecret, ephemeral, preauth, baseURL, err := parseOptionalAttributes(clientSecret)
strippedSecret, ephemeral, preauth, baseURL, err := parseOptionalAttributes(args.AuthKey)
if err != nil {
return "", err
}
@@ -63,7 +63,7 @@ func resolveAuthKey(ctx context.Context, clientSecret string, tags []string) (st
Reusable: false,
Ephemeral: ephemeral,
Preauthorized: preauth,
Tags: tags,
Tags: args.Tags,
},
},
}
+6 -1
View File
@@ -9,6 +9,8 @@ import (
"net/http/httptest"
"strings"
"testing"
"tailscale.com/internal/client/tailscale"
)
func TestResolveAuthKey(t *testing.T) {
@@ -80,7 +82,10 @@ func TestResolveAuthKey(t *testing.T) {
}
}
got, err := resolveAuthKey(context.Background(), tt.clientID, tt.tags)
got, err := resolveAuthKey(context.Background(), tailscale.ResolveAuthKeyArgs{
AuthKey: tt.clientID,
Tags: tt.tags,
})
if tt.wantErr {
if err == nil {