Skip to content

Commit

Permalink
feat: implement OAuth2 device flow for machine config
Browse files Browse the repository at this point in the history
Fixes #7939

See documentation in the PR for the description of the feature.

Signed-off-by: Andrey Smirnov <[email protected]>
  • Loading branch information
smira committed Nov 20, 2023
1 parent 5c8fa2a commit 27d208c
Show file tree
Hide file tree
Showing 12 changed files with 674 additions and 10 deletions.
8 changes: 5 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 10 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
6 changes: 6 additions & 0 deletions hack/release.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
21 changes: 21 additions & 0 deletions internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"log"
"os"
"path/filepath"
"time"

"github.com/cosi-project/runtime/pkg/state"
"github.com/siderolabs/gen/channel"
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 27d208c

Please sign in to comment.