From 27d208c26bd1fe5a37b127cd83cab76b5671758a Mon Sep 17 00:00:00 2001 From: Andrey Smirnov Date: Thu, 16 Nov 2023 21:52:31 +0400 Subject: [PATCH] feat: implement OAuth2 device flow for machine config Fixes #7939 See documentation in the PR for the description of the feature. Signed-off-by: Andrey Smirnov --- go.mod | 8 +- go.sum | 16 +- hack/release.toml | 6 + .../runtime/v1alpha1/platform/metal/metal.go | 21 ++ .../v1alpha1/platform/metal/oauth2/oauth2.go | 204 ++++++++++++++++++ .../platform/metal/oauth2/oauth2_test.go | 117 ++++++++++ .../v1alpha1/platform/metal/url/map.go | 85 ++++++++ .../v1alpha1/platform/metal/url/map_test.go | 107 +++++++++ pkg/download/download.go | 12 +- pkg/machinery/constants/constants.go | 21 ++ .../v1.6/advanced/machine-config-oauth.md | 83 +++++++ website/content/v1.6/reference/kernel.md | 4 + 12 files changed, 674 insertions(+), 10 deletions(-) create mode 100644 internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2/oauth2.go create mode 100644 internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2/oauth2_test.go create mode 100644 internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/map.go create mode 100644 internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/map_test.go create mode 100644 website/content/v1.6/advanced/machine-config-oauth.md diff --git a/go.mod b/go.mod index 2a2a99d947..318f246142 100644 --- a/go.mod +++ b/go.mod @@ -87,6 +87,7 @@ require ( github.com/mdlayher/genetlink v1.3.2 github.com/mdlayher/netlink v1.7.2 github.com/mdlayher/netx v0.0.0-20230430222610-7e21880baee8 + github.com/mdp/qrterminal/v3 v3.2.0 github.com/nberlee/go-netstat v0.1.2 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0-rc4 @@ -140,7 +141,8 @@ require ( go.etcd.io/etcd/etcdutl/v3 v3.5.10 go.uber.org/zap v1.26.0 go4.org/netipx v0.0.0-20230824141953-6213f710f925 - golang.org/x/net v0.17.0 + golang.org/x/net v0.18.0 + golang.org/x/oauth2 v0.14.0 golang.org/x/sync v0.5.0 golang.org/x/sys v0.14.0 golang.org/x/term v0.14.0 @@ -310,10 +312,9 @@ require ( go.opentelemetry.io/otel/trace v1.19.0 // indirect go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.14.0 // indirect + golang.org/x/crypto v0.15.0 // indirect golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 // indirect golang.org/x/mod v0.12.0 // indirect - golang.org/x/oauth2 v0.12.0 // indirect golang.org/x/tools v0.12.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20231022001213-2e0774f246fb // indirect @@ -329,6 +330,7 @@ require ( k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 // indirect + rsc.io/qr v0.2.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 // indirect sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 // indirect diff --git a/go.sum b/go.sum index 964c3e97ef..c6de6361e0 100644 --- a/go.sum +++ b/go.sum @@ -507,6 +507,8 @@ github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= +github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -826,8 +828,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -910,8 +912,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -921,8 +923,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= -golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= +golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1200,6 +1202,8 @@ kernel.org/pub/linux/libs/security/libcap/cap v1.2.69/go.mod h1:Tk5Ip2TuxaWGpccL kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 h1:IdrOs1ZgwGw5CI+BH6GgVVlOt+LAXoPyh7enr8lfaXs= kernel.org/pub/linux/libs/security/libcap/psx v1.2.69/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= diff --git a/hack/release.toml b/hack/release.toml index f9c7a9ae80..e93b247077 100644 --- a/hack/release.toml +++ b/hack/release.toml @@ -88,6 +88,12 @@ machine: net/ipv6/conf/eth0.100/disable_ipv6: "1" net.ipv6.conf.eth0/100.disable_ipv6: "1" ``` +""" + + [note.auth2] + title = "OAuth2 Machine Config Flow" + description = """\ +Talos Linux when running on the `metal` platform can be configured to authenticate the machine configuration download using [OAuth2 device flow](https://www.talos.dev/v1.6/advanced/machine-config-oauth/). """ [make_deps] diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go index d357a36dae..e6c2fcceb8 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go @@ -11,6 +11,7 @@ import ( "log" "os" "path/filepath" + "time" "github.com/cosi-project/runtime/pkg/state" "github.com/siderolabs/gen/channel" @@ -25,6 +26,7 @@ import ( "github.com/siderolabs/talos/internal/app/machined/pkg/runtime" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/errors" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/internal/netutils" + "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url" "github.com/siderolabs/talos/internal/pkg/meta" "github.com/siderolabs/talos/pkg/download" @@ -79,6 +81,24 @@ func (m *Metal) Configuration(ctx context.Context, r state.State) ([]byte, error return nil, err } + oauth2Cfg, err := oauth2.NewConfig(procfs.ProcCmdline(), *option) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to parse OAuth2 config: %w", err) + } + + var extraHeaders map[string]string + + // perform OAuth2 device auth flow first to acquire extra headers + if oauth2Cfg != nil { + if err = retry.Constant(constants.ConfigLoadTimeout, retry.WithUnits(30*time.Second)).RetryWithContext(ctx, func(ctx context.Context) error { + return oauth2Cfg.DeviceAuthFlow(ctx, r) + }); err != nil { + return nil, fmt.Errorf("OAuth2 device auth flow failed: %w", err) + } + + extraHeaders = oauth2Cfg.ExtraHeaders() + } + return download.Download( ctx, *option, @@ -88,6 +108,7 @@ func (m *Metal) Configuration(ctx context.Context, r state.State) ([]byte, error // give a timeout per attempt, max 50% of that is dedicated for URL interpolation, the rest is for the actual download retry.WithAttemptTimeout(constants.ConfigLoadAttemptTimeout), ), + download.WithHeaders(extraHeaders), ) } } diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2/oauth2.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2/oauth2.go new file mode 100644 index 0000000000..2193ab9061 --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2/oauth2.go @@ -0,0 +1,204 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package oauth2 implements OAuth2 Device Flow to authenticate machine config download. +package oauth2 + +import ( + "bytes" + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + + "github.com/cosi-project/runtime/pkg/state" + "github.com/hashicorp/go-cleanhttp" + "github.com/mdp/qrterminal/v3" + "github.com/siderolabs/go-procfs/procfs" + "golang.org/x/oauth2" + + metalurl "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url" + "github.com/siderolabs/talos/pkg/httpdefaults" + "github.com/siderolabs/talos/pkg/machinery/constants" +) + +// Config represents the OAuth2 configuration. +type Config struct { + ClientID string + ClientSecret string + Audience string + Scopes []string + + ExtraVariables []string + + DeviceAuthURL string + TokenURL string + + extraHeaders map[string]string +} + +// NewConfig returns a new Config from cmdline. +// +// If OAuth2 is not configured, it returns os.ErrNotExist. +// +//nolint:gocyclo +func NewConfig(cmdline *procfs.Cmdline, downloadURL string) (*Config, error) { + var cfg Config + + clientID := cmdline.Get(constants.KernelParamConfigOAuthClientID).First() + + if clientID == nil { + return nil, os.ErrNotExist + } + + cfg.ClientID = *clientID + + if clientSecret := cmdline.Get(constants.KernelParamConfigOAuthClientSecret).First(); clientSecret != nil { + cfg.ClientSecret = *clientSecret + } + + if audience := cmdline.Get(constants.KernelParamConfigOAuthAudience).First(); audience != nil { + cfg.Audience = *audience + } + + for i := 0; ; i++ { + scope := cmdline.Get(constants.KernelParamConfigOAuthScope).Get(i) + + if scope == nil { + break + } + + cfg.Scopes = append(cfg.Scopes, *scope) + } + + for i := 0; ; i++ { + extra := cmdline.Get(constants.KernelParamConfigOAuthExtraVariable).Get(i) + + if extra == nil { + break + } + + cfg.ExtraVariables = append(cfg.ExtraVariables, *extra) + } + + if deviceAuthURL := cmdline.Get(constants.KernelParamConfigOAuthDeviceAuthURL).First(); deviceAuthURL != nil { + cfg.DeviceAuthURL = *deviceAuthURL + } else { + u, err := url.Parse(downloadURL) + if err != nil { + return nil, err + } + + u.Path = "/device/code" + + cfg.DeviceAuthURL = u.String() + } + + if tokenURL := cmdline.Get(constants.KernelParamConfigOAuthTokenURL).First(); tokenURL != nil { + cfg.TokenURL = *tokenURL + } else { + u, err := url.Parse(downloadURL) + if err != nil { + return nil, err + } + + u.Path = "/token" + + cfg.TokenURL = u.String() + } + + return &cfg, nil +} + +// DeviceAuthFlow represents the device auth flow response. +func (c *Config) DeviceAuthFlow(ctx context.Context, st state.State) error { + transport := httpdefaults.PatchTransport(cleanhttp.DefaultTransport()) + + client := &http.Client{ + Transport: transport, + } + + // register the HTTP client with OAuth2 flow + ctx = context.WithValue(ctx, oauth2.HTTPClient, client) + + cfg := oauth2.Config{ + ClientID: c.ClientID, + Scopes: c.Scopes, + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: c.DeviceAuthURL, + TokenURL: c.TokenURL, + }, + } + + log.Printf("[OAuth] starting the authentication device flow with the following settings:") + log.Printf("[OAuth] - client ID: %q", c.ClientID) + log.Printf("[OAuth] - device auth URL: %q", c.DeviceAuthURL) + log.Printf("[OAuth] - token URL: %q", c.TokenURL) + log.Printf("[OAuth] - extra variables: %q", c.ExtraVariables) + + // acquire device variables + variables, err := c.getVariableValues(ctx, st) + if err != nil { + return fmt.Errorf("failed to get variable values: %w", err) + } + + var deviceAuthOptions []oauth2.AuthCodeOption //nolint:prealloc + + if c.Audience != "" { + deviceAuthOptions = append(deviceAuthOptions, oauth2.SetAuthURLParam("audience", c.Audience)) + } + + for k, v := range variables { + deviceAuthOptions = append(deviceAuthOptions, oauth2.SetAuthURLParam(k, v)) + } + + deviceAuthResponse, err := cfg.DeviceAuth(ctx, deviceAuthOptions...) + if err != nil { + return fmt.Errorf("failed to get device auth response: %w", err) + } + + log.Printf("[OAuth] please visit the URL %s and enter the code %s", deviceAuthResponse.VerificationURI, deviceAuthResponse.UserCode) + + if deviceAuthResponse.VerificationURIComplete != "" { + var qrBuf bytes.Buffer + + qrterminal.GenerateHalfBlock(deviceAuthResponse.VerificationURIComplete, qrterminal.L, &qrBuf) + + log.Printf("[OAuth] or scan the following QR code:\n%s", qrBuf.String()) + } + + log.Printf("[OAuth] waiting for the device to be authorized (expires at %s)...", deviceAuthResponse.Expiry.Format("15:04:05")) + + if c.ClientSecret != "" { + deviceAuthOptions = append(deviceAuthOptions, oauth2.SetAuthURLParam("client_secret", c.ClientSecret)) + } + + token, err := cfg.DeviceAccessToken(ctx, deviceAuthResponse, deviceAuthOptions...) + if err != nil { + return fmt.Errorf("failed to get device access token: %w", err) + } + + log.Printf("[OAuth] device authorized successfully") + + c.extraHeaders = map[string]string{ + "Authorization": token.Type() + " " + token.AccessToken, + } + + return nil +} + +// getVariableValues returns the variable values to include in the device auth request. +func (c *Config) getVariableValues(ctx context.Context, st state.State) (map[string]string, error) { + ctx, cancel := context.WithTimeout(ctx, constants.ConfigLoadAttemptTimeout/2) + defer cancel() + + return metalurl.MapValues(ctx, st, c.ExtraVariables) +} + +// ExtraHeaders returns the extra headers to include in the download request. +func (c *Config) ExtraHeaders() map[string]string { + return c.extraHeaders +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2/oauth2_test.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2/oauth2_test.go new file mode 100644 index 0000000000..244808db41 --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2/oauth2_test.go @@ -0,0 +1,117 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package oauth2_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/siderolabs/go-procfs/procfs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2" +) + +func TestNewConfig(t *testing.T) { //nolint:tparallel + t.Parallel() + + for _, test := range []struct { + name string + + cmdline string + expected *oauth2.Config + }{ + { + name: "no config", + }, + { + name: "only client ID", + cmdline: `talos.config.oauth.client_id=device_client_id`, + expected: &oauth2.Config{ + ClientID: "device_client_id", + TokenURL: "https://example.com/token", + DeviceAuthURL: "https://example.com/device/code", + }, + }, + { + name: "client ID and custom URLs", + cmdline: `talos.config.oauth.client_id=device_client_id talos.config.oauth.token_url=https://google.com/token talos.config.oauth.device_auth_url=https://google.com/device/code`, + expected: &oauth2.Config{ + ClientID: "device_client_id", + TokenURL: "https://google.com/token", + DeviceAuthURL: "https://google.com/device/code", + }, + }, + { + name: "complete config", + cmdline: `talos.config.oauth.client_id=device_client_id talos.config.oauth.client_secret=device_secret ` + + `talos.config.oauth.token_url=https://google.com/token talos.config.oauth.device_auth_url=https://google.com/device/code ` + + `talos.config.oauth.scope=foo talos.config.oauth.scope=bar talos.config.oauth.audience=world ` + + `talos.config.oauth.extra_variable=uuid talos.config.oauth.extra_variable=mac`, + expected: &oauth2.Config{ + ClientID: "device_client_id", + ClientSecret: "device_secret", + Audience: "world", + Scopes: []string{"foo", "bar"}, + ExtraVariables: []string{"uuid", "mac"}, + TokenURL: "https://google.com/token", + DeviceAuthURL: "https://google.com/device/code", + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + cfg, err := oauth2.NewConfig(procfs.NewCmdline(test.cmdline), "https://example.com/my/config") + if test.expected == nil { + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, test.expected, cfg) + }) + } +} + +func TestDeviceAuthFlow(t *testing.T) { + t.Parallel() + + cfg := &oauth2.Config{ + ClientID: "device_client_id", + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() //nolint:errcheck + + t.Logf("received request: %s %s", r.Method, r.RequestURI) + + switch r.Method + r.RequestURI { + case "POST/device/code": + w.Header().Add("Content-Type", "application/json") + w.Write([]byte(`{"device_code":"abcd", "user_code":"1234", "verification_uri":"https://example.com/verify","verification_uri_complete":"https://example.com/verify/1234","interval":1,"expires_in":36000}`)) //nolint:errcheck,lll + case "POST/token": + w.Header().Add("Content-Type", "application/json") + w.Write([]byte(`{"access_token":"abcd","token_type":"bearer","expires_in":3600,"refresh_token":"efgh","id_token":"ijkl"}`)) //nolint:errcheck + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(ts.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + cfg.DeviceAuthURL = ts.URL + "/device/code" + cfg.TokenURL = ts.URL + "/token" + + require.NoError(t, cfg.DeviceAuthFlow(ctx, nil)) + assert.Equal(t, map[string]string{"Authorization": "Bearer abcd"}, cfg.ExtraHeaders()) +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/map.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/map.go new file mode 100644 index 0000000000..a1ea092806 --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/map.go @@ -0,0 +1,85 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package url + +import ( + "context" + "fmt" + "log" + + "github.com/cosi-project/runtime/pkg/state" + "github.com/siderolabs/gen/maps" + "github.com/siderolabs/gen/xslices" +) + +// MapValues maps variable names to values. +// +//nolint:gocyclo +func MapValues(ctx context.Context, st state.State, variableNames []string) (map[string]string, error) { + // happy case + if len(variableNames) == 0 { + return nil, nil + } + + availableVariables := AllVariables() + activeVariables := make(map[string]*Variable, len(variableNames)) + + for _, variableName := range variableNames { + if v, ok := availableVariables[variableName]; ok { + activeVariables[variableName] = v + } else { + return nil, fmt.Errorf("unsupported variable name: %q", variableName) + } + } + + // setup watches + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + watchCh := make(chan state.Event) + + for _, variable := range activeVariables { + if err := variable.Value.RegisterWatch(ctx, st, watchCh); err != nil { + return nil, fmt.Errorf("error watching variable %q: %w", variable.Key, err) + } + } + + pendingVariables := xslices.ToSet(maps.Values(activeVariables)) + + // wait for all variables to be populated +waitLoop: + for len(pendingVariables) > 0 { + log.Printf("waiting for variables: %v", xslices.Map(maps.Keys(pendingVariables), func(v *Variable) string { return v.Key })) + + var ev state.Event + + select { + case <-ctx.Done(): + // context was canceled, return what we have + break waitLoop + case ev = <-watchCh: + } + + switch ev.Type { + case state.Errored: + return nil, fmt.Errorf("error watching variables: %w", ev.Error) + case state.Bootstrapped: + // ignored + case state.Created, state.Updated, state.Destroyed: + for _, variable := range activeVariables { + handled, err := variable.Value.EventHandler(ev) + if err != nil { + return nil, fmt.Errorf("error handling variable %q: %w", variable.Key, err) + } + + if handled { + delete(pendingVariables, variable) + } + } + } + } + + return maps.Map(activeVariables, func(k string, v *Variable) (string, string) { return k, v.Value.Get() }), nil +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/map_test.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/map_test.go new file mode 100644 index 0000000000..8508165b36 --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/map_test.go @@ -0,0 +1,107 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package url_test + +import ( + "context" + "testing" + "time" + + "github.com/cosi-project/runtime/pkg/state" + "github.com/cosi-project/runtime/pkg/state/impl/inmem" + "github.com/cosi-project/runtime/pkg/state/impl/namespaced" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url" +) + +func TestMapValues(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + variableNames []string + + preSetup []setupFunc + parallelSetup []setupFunc + + expected map[string]string + }{ + { + name: "no variables", + }, + { + name: "multiple variables", + variableNames: []string{"uuid", "mac", "hostname", "code"}, + expected: map[string]string{ + "code": "top-secret", + "hostname": "some-node", + "mac": "12:34:56:78:90:ce", + "uuid": "0000-0000", + }, + preSetup: []setupFunc{ + createSysInfo("0000-0000", "12345"), + createMac("12:34:56:78:90:ce"), + createHostname("some-node"), + createCode("top-secret"), + }, + }, + { + name: "mixed wait variables", + variableNames: []string{"uuid", "mac", "hostname", "code"}, + expected: map[string]string{ + "code": "", + "hostname": "another-node", + "mac": "12:34:56:78:90:ab", + "uuid": "0000-1234", + }, + preSetup: []setupFunc{ + createSysInfo("0000-1234", "12345"), + createMac("12:34:56:78:90:ab"), + createHostname("example-node"), + }, + parallelSetup: []setupFunc{ + sleep(time.Second), + updateHostname("another-node"), + sleep(time.Second / 2), + }, + }, + } { + test := test + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + st := state.WrapCore(namespaced.NewState(inmem.Build)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + for _, f := range test.preSetup { + f(ctx, t, st) + } + + errCh := make(chan error) + + var result map[string]string + + go func() { + var e error + + result, e = url.MapValues(ctx, st, test.variableNames) + errCh <- e + }() + + for _, f := range test.parallelSetup { + f(ctx, t, st) + } + + err := <-errCh + require.NoError(t, err) + + assert.Equal(t, test.expected, result) + }) + } +} diff --git a/pkg/download/download.go b/pkg/download/download.go index 7e16029592..7d008d9252 100644 --- a/pkg/download/download.go +++ b/pkg/download/download.go @@ -72,7 +72,17 @@ func WithFormat(format string) Option { // the config. func WithHeaders(headers map[string]string) Option { return func(d *downloadOptions) { - d.Headers = headers + if headers == nil { + return + } + + if d.Headers == nil { + d.Headers = map[string]string{} + } + + for k, v := range headers { + d.Headers[k] = v + } } } diff --git a/pkg/machinery/constants/constants.go b/pkg/machinery/constants/constants.go index d3d6e7ef63..484ae7bcb1 100644 --- a/pkg/machinery/constants/constants.go +++ b/pkg/machinery/constants/constants.go @@ -25,6 +25,27 @@ const ( // to the config. KernelParamConfig = "talos.config" + // KernelParamConfigOAuthClientID is the kernel parameter name for specifying the OAuth2 client ID. + KernelParamConfigOAuthClientID = "talos.config.oauth.client_id" + + // KernelParamConfigOAuthClientSecret is the kernel parameter name for specifying the OAuth2 client secret. + KernelParamConfigOAuthClientSecret = "talos.config.oauth.client_secret" + + // KernelParamConfigOAuthAudience is the kernel parameter name for specifying the OAuth2 audience. + KernelParamConfigOAuthAudience = "talos.config.oauth.audience" + + // KernelParamConfigOAuthScope is the kernel parameter name for specifying the OAuth2 scopes (might be repeated). + KernelParamConfigOAuthScope = "talos.config.oauth.scope" + + // KernelParamConfigOAuthDeviceAuthURL is the kernel parameter name for specifying the OAuth2 device auth URL. + KernelParamConfigOAuthDeviceAuthURL = "talos.config.oauth.device_auth_url" + + // KernelParamConfigOAuthTokenURL is the kernel parameter name for specifying the OAuth2 token URL. + KernelParamConfigOAuthTokenURL = "talos.config.oauth.token_url" + + // KernelParamConfigOAuthExtraVariable is the kernel parameter name for specifying the OAuth2 extra variable (might be repeated). + KernelParamConfigOAuthExtraVariable = "talos.config.oauth.extra_variable" + // ConfigNone indicates no config is required. ConfigNone = "none" diff --git a/website/content/v1.6/advanced/machine-config-oauth.md b/website/content/v1.6/advanced/machine-config-oauth.md new file mode 100644 index 0000000000..53be494207 --- /dev/null +++ b/website/content/v1.6/advanced/machine-config-oauth.md @@ -0,0 +1,83 @@ +--- +title: "Machine Configuration OAuth2 Authentication" +description: "How to authenticate Talos machine configuration download (`talos.config=`) on `metal` platform using OAuth." +--- + +Talos Linux when running on the `metal` platform can be configured to authenticate the machine configuration download using OAuth2 device flow. +The machine configuration is fetched from the URL specified with `talos.config` kernel argument, and by default this HTTP request is not authenticated. +When the OAuth2 authentication is enabled, Talos will authenticate the request using OAuth device flow first, and then pass the token to the machine configuration download endpoint. + +## Prerequisites + +Obtain the following information: + +* OAuth client ID (mandatory) +* OAuth client secret (optional) +* OAuth device endpoint +* OAuth token endpoint +* OAuth scopes, audience (optional) +* OAuth client secret (optional) +* extra Talos variables to send to the device auth endpoint (optional) + +## Configuration + +Set the following kernel parameters on the initial Talos boot to enable the OAuth flow: + +* `talos.config` set to the URL of the machine configuration endpoint (which will be authenticated using OAuth) +* `talos.config.oauth.client_id` set to the OAuth client ID (required) +* `talos.config.oauth.client_secret` set to the OAuth client secret (optional) +* `talos.config.oauth.scope` set to the OAuth scopes (optional, repeat the parameter for multiple scopes) +* `talos.config.oauth.audience` set to the OAuth audience (optional) +* `talos.config.oauth.device_auth_url` set to the OAuth device endpoint (if not set defaults to `talos.config` URL with the path `/device/code`) +* `talos.config.oauth.token_url` set to the OAuth token endpoint (if not set defaults to `talos.config` URL with the path `/token`) +* `talos.config.oauth.extra_variable` set to the extra Talos variables to send to the device auth endpoint (optional, repeat the parameter for multiple variables) + +The list of variables supported by the `talos.config.oauth.extra_variable` parameter is same as the [list of variables]({{< relref "../reference/kernel#talosconfig" >}}) supported by the `talos.config` parameter. + +## Flow + +On the initial Talos boot, when machine configuration is not available, Talos will print the following messages: + +```text +[talos] downloading config {"component": "controller-runtime", "controller": "config.AcquireController", "platform": "metal"} +[talos] waiting for network to be ready +[talos] [OAuth] starting the authentication device flow with the following settings: +[talos] [OAuth] - client ID: "" +[talos] [OAuth] - device auth URL: "https://oauth2.googleapis.com/device/code" +[talos] [OAuth] - token URL: "https://oauth2.googleapis.com/token" +[talos] [OAuth] - extra variables: ["uuid" "mac"] +[talos] waiting for variables: [uuid mac] +[talos] waiting for variables: [mac] +[talos] [OAuth] please visit the URL https://www.google.com/device and enter the code +[talos] [OAuth] waiting for the device to be authorized (expires at 14:46:55)... +``` + +If the OAuth service provides the complete verification URL, the QR code to scan is also printed to the console: + +```text +[talos] [OAuth] or scan the following QR code: +█████████████████████████████████ +█████████████████████████████████ +████ ▄▄▄▄▄ ██▄▀▀ ▀█ ▄▄▄▄▄ ████ +████ █ █ █▄ ▀▄██▄██ █ █ ████ +████ █▄▄▄█ ██▀▄██▄ ▀█ █▄▄▄█ ████ +████▄▄▄▄▄▄▄█ ▀ █ ▀ █▄█▄▄▄▄▄▄▄████ +████ ▀ ▄▄ ▄█ ██▄█ ███▄█▀████ +████▀█▄ ▄▄▀▄▄█▀█▄██ ▄▀▄██▄ ▄████ +████▄██▀█▄▄▄███▀ ▀█▄▄ ██ █▄ ████ +████▄▀▄▄▄ ▄███ ▄ ▀ ▀▀▄▀▄▀█▄ ▄████ +████▄█████▄█ █ ██ ▀ ▄▄▄ █▀▀████ +████ ▄▄▄▄▄ █ █ ▀█▄█▄ █▄█ █▄ ████ +████ █ █ █▄ ▄▀ ▀█▀▄▄▄ ▀█▄████ +████ █▄▄▄█ █ ██▄ ▀ ▀███ ▀█▀▄████ +████▄▄▄▄▄▄▄█▄▄█▄██▄▄▄▄█▄███▄▄████ +█████████████████████████████████ +``` + +Once the authentication flow is complete on the OAuth provider side, Talos will print the following message: + +```text +[talos] [OAuth] device authorized +[talos] fetching machine config from: "http://example.com/config.yaml" +[talos] machine config loaded successfully {"component": "controller-runtime", "controller": "config.AcquireController", "sources": ["metal"]} +``` diff --git a/website/content/v1.6/reference/kernel.md b/website/content/v1.6/reference/kernel.md index 4d453c2877..e658af4c6f 100644 --- a/website/content/v1.6/reference/kernel.md +++ b/website/content/v1.6/reference/kernel.md @@ -141,6 +141,10 @@ cp config.yaml iso/ mkisofs -joliet -rock -volid 'metal-iso' -output config.iso iso/ ``` +#### `talos.config.auth.*` + +Kernel parameters prefixed with `talos.config.auth.` are used to configure [OAuth2 authentication for the machine configuration]({{< relref "../advanced/machine-config-oauth" >}}). + #### `talos.platform` The platform name on which Talos will run.