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 <andrey.smirnov@siderolabs.com>
This commit is contained in:
parent
5c8fa2a803
commit
27d208c26b
8
go.mod
8
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
|
||||
|
16
go.sum
16
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=
|
||||
|
@ -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]
|
||||
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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())
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
83
website/content/v1.6/advanced/machine-config-oauth.md
Normal file
83
website/content/v1.6/advanced/machine-config-oauth.md
Normal file
@ -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: "<REDACTED>"
|
||||
[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 <REDACTED>
|
||||
[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"]}
|
||||
```
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user