mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-29 06:26:31 +00:00
cmd/k8s-operator: support workload identity federation
The feature is currently in private alpha, so requires a tailnet feature
flag. Initially focuses on supporting the operator's own auth, because the
operator is the only device we maintain that uses static long-lived
credentials. All other operator-created devices use single-use auth keys.
Testing steps:
* Create a cluster with an API server accessible over public internet
* kubectl get --raw /.well-known/openid-configuration | jq '.issuer'
* Create a federated OAuth client in the Tailscale admin console with:
* The issuer from the previous step
* Subject claim `system:serviceaccount:tailscale:operator`
* Write scopes services, devices:core, auth_keys
* Tag tag:k8s-operator
* Allow the Tailscale control plane to get the public portion of
the ServiceAccount token signing key without authentication:
* kubectl create clusterrolebinding oidc-discovery \
--clusterrole=system:service-account-issuer-discovery \
--group=system:unauthenticated
* helm install --set oauth.clientId=... --set oauth.audience=...
Updates #17457
Change-Id: Ib29c85ba97b093c70b002f4f41793ffc02e6c6e9
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
This commit is contained in:
@@ -34,7 +34,9 @@ spec:
|
|||||||
securityContext:
|
securityContext:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if or .Values.oauth.clientSecret .Values.oauth.audience }}
|
||||||
volumes:
|
volumes:
|
||||||
|
{{- if .Values.oauth.clientSecret }}
|
||||||
- name: oauth
|
- name: oauth
|
||||||
{{- with .Values.oauthSecretVolume }}
|
{{- with .Values.oauthSecretVolume }}
|
||||||
{{- toYaml . | nindent 10 }}
|
{{- toYaml . | nindent 10 }}
|
||||||
@@ -42,6 +44,17 @@ spec:
|
|||||||
secret:
|
secret:
|
||||||
secretName: operator-oauth
|
secretName: operator-oauth
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- else }}
|
||||||
|
- name: oidc-jwt
|
||||||
|
projected:
|
||||||
|
defaultMode: 420
|
||||||
|
sources:
|
||||||
|
- serviceAccountToken:
|
||||||
|
audience: {{ .Values.oauth.audience }}
|
||||||
|
expirationSeconds: 3600
|
||||||
|
path: token
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: operator
|
- name: operator
|
||||||
{{- with .Values.operatorConfig.securityContext }}
|
{{- with .Values.operatorConfig.securityContext }}
|
||||||
@@ -72,10 +85,15 @@ spec:
|
|||||||
value: {{ .Values.loginServer }}
|
value: {{ .Values.loginServer }}
|
||||||
- name: OPERATOR_INGRESS_CLASS_NAME
|
- name: OPERATOR_INGRESS_CLASS_NAME
|
||||||
value: {{ .Values.ingressClass.name }}
|
value: {{ .Values.ingressClass.name }}
|
||||||
|
{{- if .Values.oauth.clientSecret }}
|
||||||
- name: CLIENT_ID_FILE
|
- name: CLIENT_ID_FILE
|
||||||
value: /oauth/client_id
|
value: /oauth/client_id
|
||||||
- name: CLIENT_SECRET_FILE
|
- name: CLIENT_SECRET_FILE
|
||||||
value: /oauth/client_secret
|
value: /oauth/client_secret
|
||||||
|
{{- else if .Values.oauth.audience }}
|
||||||
|
- name: CLIENT_ID
|
||||||
|
value: {{ .Values.oauth.clientId }}
|
||||||
|
{{- end }}
|
||||||
{{- $proxyTag := printf ":%s" ( .Values.proxyConfig.image.tag | default .Chart.AppVersion )}}
|
{{- $proxyTag := printf ":%s" ( .Values.proxyConfig.image.tag | default .Chart.AppVersion )}}
|
||||||
- name: PROXY_IMAGE
|
- name: PROXY_IMAGE
|
||||||
value: {{ coalesce .Values.proxyConfig.image.repo .Values.proxyConfig.image.repository }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }}
|
value: {{ coalesce .Values.proxyConfig.image.repo .Values.proxyConfig.image.repository }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }}
|
||||||
@@ -100,10 +118,18 @@ spec:
|
|||||||
{{- with .Values.operatorConfig.extraEnv }}
|
{{- with .Values.operatorConfig.extraEnv }}
|
||||||
{{- toYaml . | nindent 12 }}
|
{{- toYaml . | nindent 12 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if or .Values.oauth.clientSecret .Values.oauth.audience }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
|
{{- if .Values.oauth.clientSecret }}
|
||||||
- name: oauth
|
- name: oauth
|
||||||
mountPath: /oauth
|
mountPath: /oauth
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
{{- else }}
|
||||||
|
- name: oidc-jwt
|
||||||
|
mountPath: /var/run/secrets/tailscale/serviceaccount
|
||||||
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
{{- with .Values.operatorConfig.nodeSelector }}
|
{{- with .Values.operatorConfig.nodeSelector }}
|
||||||
nodeSelector:
|
nodeSelector:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Copyright (c) Tailscale Inc & AUTHORS
|
# Copyright (c) Tailscale Inc & AUTHORS
|
||||||
# SPDX-License-Identifier: BSD-3-Clause
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
{{ if and .Values.oauth .Values.oauth.clientId -}}
|
{{ if and .Values.oauth .Values.oauth.clientId .Values.oauth.clientSecret -}}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
# Copyright (c) Tailscale Inc & AUTHORS
|
# Copyright (c) Tailscale Inc & AUTHORS
|
||||||
# SPDX-License-Identifier: BSD-3-Clause
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
# Operator oauth credentials. If set a Kubernetes Secret with the provided
|
# Operator oauth credentials. If unset a Secret named operator-oauth must be
|
||||||
# values will be created in the operator namespace. If unset a Secret named
|
# precreated or oauthSecretVolume needs to be adjusted. This block will be
|
||||||
# operator-oauth must be precreated or oauthSecretVolume needs to be adjusted.
|
# overridden by oauthSecretVolume, if set.
|
||||||
# This block will be overridden by oauthSecretVolume, if set.
|
oauth:
|
||||||
oauth: {}
|
# The Client ID the operator will authenticate with.
|
||||||
# clientId: ""
|
clientId: ""
|
||||||
# clientSecret: ""
|
# If set a Kubernetes Secret with the provided value will be created in
|
||||||
|
# the operator namespace, and mounted into the operator Pod. Takes precedence
|
||||||
|
# over oauth.audience.
|
||||||
|
clientSecret: ""
|
||||||
|
# The audience for oauth.clientId if using a workload identity federation
|
||||||
|
# OAuth client. Mutually exclusive with oauth.clientSecret.
|
||||||
|
# See https://tailscale.com/kb/1581/workload-identity-federation.
|
||||||
|
audience: ""
|
||||||
|
|
||||||
# URL of the control plane to be used by all resources managed by the operator.
|
# URL of the control plane to be used by all resources managed by the operator.
|
||||||
loginServer: ""
|
loginServer: ""
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
log.Print("Templating Helm chart contents")
|
log.Print("Templating Helm chart contents")
|
||||||
helmTmplCmd := exec.Command("./tool/helm", "template", "operator", "./cmd/k8s-operator/deploy/chart",
|
helmTmplCmd := exec.Command("./tool/helm", "template", "operator", "./cmd/k8s-operator/deploy/chart",
|
||||||
"--namespace=tailscale")
|
"--namespace=tailscale", "--set=oauth.clientSecret=''")
|
||||||
helmTmplCmd.Dir = repoRoot
|
helmTmplCmd.Dir = repoRoot
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
helmTmplCmd.Stdout = &out
|
helmTmplCmd.Stdout = &out
|
||||||
|
|||||||
@@ -164,22 +164,24 @@ func main() {
|
|||||||
runReconcilers(rOpts)
|
runReconcilers(rOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
|
// initTSNet initializes the tsnet.Server and logs in to Tailscale. If CLIENT_ID
|
||||||
// CLIENT_ID_FILE and CLIENT_SECRET_FILE environment variables to authenticate
|
// is set, it authenticates to the Tailscale API using the federated OIDC workload
|
||||||
// with Tailscale.
|
// identity flow. Otherwise, it uses the CLIENT_ID_FILE and CLIENT_SECRET_FILE
|
||||||
|
// environment variables to authenticate with static credentials.
|
||||||
func initTSNet(zlog *zap.SugaredLogger, loginServer string) (*tsnet.Server, tsClient) {
|
func initTSNet(zlog *zap.SugaredLogger, loginServer string) (*tsnet.Server, tsClient) {
|
||||||
var (
|
var (
|
||||||
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
|
clientID = defaultEnv("CLIENT_ID", "") // Used for workload identity federation.
|
||||||
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
|
clientIDPath = defaultEnv("CLIENT_ID_FILE", "") // Used for static client credentials.
|
||||||
|
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "") // Used for static client credentials.
|
||||||
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
|
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
|
||||||
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
|
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
|
||||||
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
|
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
|
||||||
)
|
)
|
||||||
startlog := zlog.Named("startup")
|
startlog := zlog.Named("startup")
|
||||||
if clientIDPath == "" || clientSecretPath == "" {
|
if clientID == "" && (clientIDPath == "" || clientSecretPath == "") {
|
||||||
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set")
|
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set") // TODO(tomhjp): error message can mention WIF once it's publicly available.
|
||||||
}
|
}
|
||||||
tsc, err := newTSClient(context.Background(), clientIDPath, clientSecretPath, loginServer)
|
tsc, err := newTSClient(zlog.Named("ts-api-client"), clientID, clientIDPath, clientSecretPath, loginServer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
startlog.Fatalf("error creating Tailscale client: %v", err)
|
startlog.Fatalf("error creating Tailscale client: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
"golang.org/x/oauth2/clientcredentials"
|
||||||
"tailscale.com/internal/client/tailscale"
|
"tailscale.com/internal/client/tailscale"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
@@ -20,30 +25,53 @@ import (
|
|||||||
// call should be performed on the default tailnet for the provided credentials.
|
// call should be performed on the default tailnet for the provided credentials.
|
||||||
const (
|
const (
|
||||||
defaultTailnet = "-"
|
defaultTailnet = "-"
|
||||||
|
oidcJWTPath = "/var/run/secrets/tailscale/serviceaccount/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTSClient(ctx context.Context, clientIDPath, clientSecretPath, loginServer string) (tsClient, error) {
|
func newTSClient(logger *zap.SugaredLogger, clientID, clientIDPath, clientSecretPath, loginServer string) (*tailscale.Client, error) {
|
||||||
clientID, err := os.ReadFile(clientIDPath)
|
baseURL := ipn.DefaultControlURL
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error reading client ID %q: %w", clientIDPath, err)
|
|
||||||
}
|
|
||||||
clientSecret, err := os.ReadFile(clientSecretPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading client secret %q: %w", clientSecretPath, err)
|
|
||||||
}
|
|
||||||
const tokenURLPath = "/api/v2/oauth/token"
|
|
||||||
tokenURL := fmt.Sprintf("%s%s", ipn.DefaultControlURL, tokenURLPath)
|
|
||||||
if loginServer != "" {
|
if loginServer != "" {
|
||||||
tokenURL = fmt.Sprintf("%s%s", loginServer, tokenURLPath)
|
baseURL = loginServer
|
||||||
}
|
}
|
||||||
credentials := clientcredentials.Config{
|
|
||||||
ClientID: string(clientID),
|
var httpClient *http.Client
|
||||||
ClientSecret: string(clientSecret),
|
if clientID == "" {
|
||||||
TokenURL: tokenURL,
|
// Use static client credentials mounted to disk.
|
||||||
|
id, err := os.ReadFile(clientIDPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading client ID %q: %w", clientIDPath, err)
|
||||||
|
}
|
||||||
|
secret, err := os.ReadFile(clientSecretPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading client secret %q: %w", clientSecretPath, err)
|
||||||
|
}
|
||||||
|
credentials := clientcredentials.Config{
|
||||||
|
ClientID: string(id),
|
||||||
|
ClientSecret: string(secret),
|
||||||
|
TokenURL: fmt.Sprintf("%s%s", baseURL, "/api/v2/oauth/token"),
|
||||||
|
}
|
||||||
|
tokenSrc := credentials.TokenSource(context.Background())
|
||||||
|
httpClient = oauth2.NewClient(context.Background(), tokenSrc)
|
||||||
|
} else {
|
||||||
|
// Use workload identity federation.
|
||||||
|
tokenSrc := &jwtTokenSource{
|
||||||
|
logger: logger,
|
||||||
|
jwtPath: oidcJWTPath,
|
||||||
|
baseCfg: clientcredentials.Config{
|
||||||
|
ClientID: clientID,
|
||||||
|
TokenURL: fmt.Sprintf("%s%s", baseURL, "/api/v2/oauth/token-exchange"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
httpClient = &http.Client{
|
||||||
|
Transport: &oauth2.Transport{
|
||||||
|
Source: tokenSrc,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c := tailscale.NewClient(defaultTailnet, nil)
|
c := tailscale.NewClient(defaultTailnet, nil)
|
||||||
c.UserAgent = "tailscale-k8s-operator"
|
c.UserAgent = "tailscale-k8s-operator"
|
||||||
c.HTTPClient = credentials.Client(ctx)
|
c.HTTPClient = httpClient
|
||||||
if loginServer != "" {
|
if loginServer != "" {
|
||||||
c.BaseURL = loginServer
|
c.BaseURL = loginServer
|
||||||
}
|
}
|
||||||
@@ -63,3 +91,43 @@ type tsClient interface {
|
|||||||
// DeleteVIPService is a method for deleting a Tailscale Service.
|
// DeleteVIPService is a method for deleting a Tailscale Service.
|
||||||
DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error
|
DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jwtTokenSource implements the [oauth2.TokenSource] interface, but with the
|
||||||
|
// ability to regenerate a fresh underlying token source each time a new value
|
||||||
|
// of the JWT parameter is needed due to expiration.
|
||||||
|
type jwtTokenSource struct {
|
||||||
|
logger *zap.SugaredLogger
|
||||||
|
jwtPath string // Path to the file containing an automatically refreshed JWT.
|
||||||
|
baseCfg clientcredentials.Config // Holds config that doesn't change for the lifetime of the process.
|
||||||
|
|
||||||
|
mu sync.Mutex // Guards underlying.
|
||||||
|
underlying oauth2.TokenSource // The oauth2 client implementation. Does its own separate caching of the access token.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jwtTokenSource) Token() (*oauth2.Token, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.underlying != nil {
|
||||||
|
t, err := s.underlying.Token()
|
||||||
|
if err == nil && t != nil && t.Valid() {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Debugf("Refreshing JWT from %s", s.jwtPath)
|
||||||
|
tk, err := os.ReadFile(s.jwtPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading JWT from %q: %w", s.jwtPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shallow copy of the base config.
|
||||||
|
credentials := s.baseCfg
|
||||||
|
credentials.EndpointParams = map[string][]string{
|
||||||
|
"jwt": {string(tk)},
|
||||||
|
}
|
||||||
|
|
||||||
|
src := credentials.TokenSource(context.Background())
|
||||||
|
s.underlying = oauth2.ReuseTokenSourceWithExpiry(nil, src, time.Minute)
|
||||||
|
return s.underlying.Token()
|
||||||
|
}
|
||||||
|
|||||||
135
cmd/k8s-operator/tsclient_test.go
Normal file
135
cmd/k8s-operator/tsclient_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewStaticClient(t *testing.T) {
|
||||||
|
const (
|
||||||
|
clientIDFile = "client-id"
|
||||||
|
clientSecretFile = "client-secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
clientIDPath := filepath.Join(tmp, clientIDFile)
|
||||||
|
if err := os.WriteFile(clientIDPath, []byte("test-client-id"), 0600); err != nil {
|
||||||
|
t.Fatalf("error writing test file %q: %v", clientIDPath, err)
|
||||||
|
}
|
||||||
|
clientSecretPath := filepath.Join(tmp, clientSecretFile)
|
||||||
|
if err := os.WriteFile(clientSecretPath, []byte("test-client-secret"), 0600); err != nil {
|
||||||
|
t.Fatalf("error writing test file %q: %v", clientSecretPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := testAPI(t, 3600)
|
||||||
|
cl, err := newTSClient(zap.NewNop().Sugar(), "", clientIDPath, clientSecretPath, srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating Tailscale client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := cl.HTTPClient.Get(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error making test API call: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
got, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error reading response body: %v", err)
|
||||||
|
}
|
||||||
|
want := "Bearer " + testToken("/api/v2/oauth/token", "test-client-id", "test-client-secret", "")
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("got %q; want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWorkloadIdentityClient(t *testing.T) {
|
||||||
|
// 5 seconds is within expiryDelta leeway, so the access token will
|
||||||
|
// immediately be considered expired and get refreshed on each access.
|
||||||
|
srv := testAPI(t, 5)
|
||||||
|
cl, err := newTSClient(zap.NewNop().Sugar(), "test-client-id", "", "", srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating Tailscale client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify the path where the JWT will be read from.
|
||||||
|
oauth2Transport, ok := cl.HTTPClient.Transport.(*oauth2.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected oauth2.Transport, got %T", cl.HTTPClient.Transport)
|
||||||
|
}
|
||||||
|
jwtTokenSource, ok := oauth2Transport.Source.(*jwtTokenSource)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected jwtTokenSource, got %T", oauth2Transport.Source)
|
||||||
|
}
|
||||||
|
tmp := t.TempDir()
|
||||||
|
jwtPath := filepath.Join(tmp, "token")
|
||||||
|
jwtTokenSource.jwtPath = jwtPath
|
||||||
|
|
||||||
|
for _, jwt := range []string{"test-jwt", "updated-test-jwt"} {
|
||||||
|
if err := os.WriteFile(jwtPath, []byte(jwt), 0600); err != nil {
|
||||||
|
t.Fatalf("error writing test file %q: %v", jwtPath, err)
|
||||||
|
}
|
||||||
|
resp, err := cl.HTTPClient.Get(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error making test API call: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
got, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error reading response body: %v", err)
|
||||||
|
}
|
||||||
|
if want := "Bearer " + testToken("/api/v2/oauth/token-exchange", "test-client-id", "", jwt); string(got) != want {
|
||||||
|
t.Errorf("got %q; want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPI(t *testing.T, expirationSeconds int) *httptest.Server {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Logf("test server got request: %s %s", r.Method, r.URL.Path)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v2/oauth/token", "/api/v2/oauth/token-exchange":
|
||||||
|
id, secret, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing or invalid basic auth")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": testToken(r.URL.Path, id, secret, r.FormValue("jwt")),
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": expirationSeconds,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("error writing response: %v", err)
|
||||||
|
}
|
||||||
|
case "/":
|
||||||
|
// Echo back the authz header for test assertions.
|
||||||
|
_, err := w.Write([]byte(r.Header.Get("Authorization")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error writing response: %v", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToken(path, id, secret, jwt string) string {
|
||||||
|
return fmt.Sprintf("%s|%s|%s|%s", path, id, secret, jwt)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user