Compare commits

...

1 Commits

Author SHA1 Message Date
Jeff McCune
954dbd1ec8 (#126) Refactor id token acquisition to token package
And add a logout command that deletes the token cache.

The token package is intended for subcommands that need to make API
calls to the holos api server, getting a token should be a simple matter
of calling the token.Get() method, which takes minimal dependencies.
2024-04-12 13:15:03 -07:00
6 changed files with 334 additions and 243 deletions

294
internal/token/token.go Normal file
View File

@@ -0,0 +1,294 @@
// Package token obtains, caches, and provides an ID token to authenticate to the holos api server.
package token
import (
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/holos-run/holos/pkg/errors"
"github.com/int128/kubelogin/pkg/infrastructure/browser"
"github.com/int128/kubelogin/pkg/infrastructure/clock"
"github.com/int128/kubelogin/pkg/infrastructure/logger"
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/oidc/client"
"github.com/int128/kubelogin/pkg/tlsclientconfig"
"github.com/int128/kubelogin/pkg/tlsclientconfig/loader"
"github.com/int128/kubelogin/pkg/tokencache"
"github.com/int128/kubelogin/pkg/tokencache/repository"
"github.com/int128/kubelogin/pkg/usecases/authentication"
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
"github.com/int128/kubelogin/pkg/usecases/authentication/devicecode"
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
"github.com/spf13/pflag"
"k8s.io/client-go/util/homedir"
)
var CacheDir = expandHomedir(filepath.Join("~", ".holos", "cache"))
// Token represents an authorization bearer token. Token is useful as an output
// dto of the Tokener service use case.
type Token struct {
// Bearer is the oidc token for the authorization: bearer header
Bearer string
// Expiry is the expiration time of the id token
Expiry time.Time
// Pretty is the JSON encoding of the token claims
Pretty string
// claims represent decoded claims
claims *Claims
}
func (t Token) Claims() *Claims {
if t.claims == nil {
json.Unmarshal([]byte(t.Pretty), &t.claims)
}
return t.claims
}
type Claims struct {
Email string `json:"email"`
Name string `json:"name"`
}
// NewConfig returns a Config with default values.
func NewConfig() Config {
return Config{
Issuer: "https://login.ois.run",
ClientID: "262479925313799528@holos_platform",
Scopes: []string{"openid", "email", "profile", "groups", "offline_access"},
ExtraScopes: []string{"urn:zitadel:iam:org:domain:primary:openinfrastructure.co"},
}
}
type Config struct {
Issuer string
ClientID string
Scopes stringSlice
ExtraScopes stringSlice
ForceRefresh bool
flagSet *flag.FlagSet
}
func (c *Config) FlagSet() *flag.FlagSet {
if c.flagSet != nil {
return c.flagSet
}
flags := flag.NewFlagSet("", flag.ContinueOnError)
flags.StringVar(&c.Issuer, "oidc-issuer", c.Issuer, "oidc token issuer url.")
flags.StringVar(&c.ClientID, "oidc-client-id", c.ClientID, "oidc client id.")
flags.Var(&c.Scopes, "oidc-scopes", "required oidc scopes")
flags.Var(&c.ExtraScopes, "oidc-extra-scopes", "optional oidc scopes")
flags.BoolVar(&c.ForceRefresh, "oidc-force-refresh", c.ForceRefresh, "force refresh")
c.flagSet = flags
return flags
}
// Get returns an oidc token for use as an authorization bearer http header.
func Get(ctx context.Context, log *slog.Logger, cfg Config) (*Token, error) {
var scopes []string
scopes = append(scopes, cfg.Scopes...)
scopes = append(scopes, cfg.ExtraScopes...)
provider := oidc.Provider{
IssuerURL: cfg.Issuer,
ClientID: cfg.ClientID,
UsePKCE: true,
ExtraScopes: scopes,
}
authenticationOptions := authenticationOptions{
GrantType: "auto",
ListenAddress: defaultListenAddress,
AuthenticationTimeoutSec: 180,
RedirectURLHostname: "localhost",
RedirectURLAuthCodeKeyboard: oobRedirectURI,
}
grantOptionSet, err := authenticationOptions.grantOptionSet()
if err != nil {
return nil, errors.Wrap(fmt.Errorf("could not login: %w", err))
}
tlsClientConfig := tlsclientconfig.Config{}
tokenCacheKey := tokencache.Key{
IssuerURL: provider.IssuerURL,
ClientID: provider.ClientID,
ClientSecret: provider.ClientSecret,
ExtraScopes: provider.ExtraScopes,
CACertFilename: strings.Join(tlsClientConfig.CACertFilename, ","),
CACertData: strings.Join(tlsClientConfig.CACertData, ","),
SkipTLSVerify: tlsClientConfig.SkipTLSVerify,
}
if grantOptionSet.ROPCOption != nil {
tokenCacheKey.Username = grantOptionSet.ROPCOption.Username
}
tokenCacheRepository := &repository.Repository{}
cachedTokenSet, err := tokenCacheRepository.FindByKey(CacheDir, tokenCacheKey)
if err != nil {
slog.Debug("could not find a token cache (continuing)", "err", err, "handled", true)
}
// Construct input for the Authentication service use case
authenticationInput := authentication.Input{
Provider: provider,
GrantOptionSet: grantOptionSet,
CachedTokenSet: cachedTokenSet,
TLSClientConfig: tlsClientConfig,
ForceRefresh: cfg.ForceRefresh,
}
var slogger logger.Interface = &holosLogger{log: log}
clock := &clock.Real{}
auth := &authentication.Authentication{
ClientFactory: &client.Factory{
Loader: loader.Loader{},
Clock: clock,
Logger: slogger,
},
Logger: slogger,
Clock: clock,
AuthCodeBrowser: &authcode.Browser{
Browser: &browser.Browser{},
Logger: slogger,
},
}
authenticationOutput, err := auth.Do(ctx, authenticationInput)
if err != nil {
return nil, fmt.Errorf("authentication error: %w", err)
}
idTokenClaims, err := authenticationOutput.TokenSet.DecodeWithoutVerify()
if err != nil {
slog.Debug("could not get token claims", "err", err, "handled", false)
return nil, fmt.Errorf("could not get token claims: %w", err)
}
if authenticationOutput.AlreadyHasValidIDToken {
slog.Debug("existing token valid", "refreshed", 0, "exp", idTokenClaims.Expiry)
} else {
slog.Debug("new token valid", "refreshed", 1, "exp", idTokenClaims.Expiry)
if err := tokenCacheRepository.Save(CacheDir, tokenCacheKey, authenticationOutput.TokenSet); err != nil {
slog.Debug("could not save token cache", "err", err, "handled", 0)
return nil, fmt.Errorf("could not save token cache: %w", err)
}
}
token := &Token{
Bearer: authenticationOutput.TokenSet.IDToken,
Expiry: idTokenClaims.Expiry,
Pretty: idTokenClaims.Pretty,
}
return token, nil
}
var defaultListenAddress = []string{"127.0.0.1:8000", "127.0.0.1:18000"}
var allGrantType = strings.Join([]string{
"auto",
"authcode",
"authcode-keyboard",
"password",
"device-code",
}, "|")
const oobRedirectURI = "urn:ietf:wg:oauth:2.0:oob"
func expandHomedir(s string) string {
if !strings.HasPrefix(s, "~") {
return s
}
return filepath.Join(homedir.HomeDir(), strings.TrimPrefix(s, "~"))
}
type authenticationOptions struct {
GrantType string
ListenAddress []string
AuthenticationTimeoutSec int
SkipOpenBrowser bool
BrowserCommand string
LocalServerCertFile string
LocalServerKeyFile string
OpenURLAfterAuthentication string
RedirectURLHostname string
RedirectURLAuthCodeKeyboard string
AuthRequestExtraParams map[string]string
Username string
Password string
}
func (o *authenticationOptions) grantOptionSet() (s authentication.GrantOptionSet, err error) {
switch {
case o.GrantType == "authcode" || (o.GrantType == "auto" && o.Username == ""):
s.AuthCodeBrowserOption = &authcode.BrowserOption{
BindAddress: o.ListenAddress,
SkipOpenBrowser: o.SkipOpenBrowser,
BrowserCommand: o.BrowserCommand,
AuthenticationTimeout: time.Duration(o.AuthenticationTimeoutSec) * time.Second,
LocalServerCertFile: o.LocalServerCertFile,
LocalServerKeyFile: o.LocalServerKeyFile,
OpenURLAfterAuthentication: o.OpenURLAfterAuthentication,
RedirectURLHostname: o.RedirectURLHostname,
AuthRequestExtraParams: o.AuthRequestExtraParams,
}
case o.GrantType == "authcode-keyboard":
s.AuthCodeKeyboardOption = &authcode.KeyboardOption{
AuthRequestExtraParams: o.AuthRequestExtraParams,
RedirectURL: o.RedirectURLAuthCodeKeyboard,
}
case o.GrantType == "password" || (o.GrantType == "auto" && o.Username != ""):
s.ROPCOption = &ropc.Option{
Username: o.Username,
Password: o.Password,
}
case o.GrantType == "device-code":
s.DeviceCodeOption = &devicecode.Option{
SkipOpenBrowser: o.SkipOpenBrowser,
BrowserCommand: o.BrowserCommand,
}
default:
err = fmt.Errorf("grant-type must be one of (%s)", allGrantType)
}
return
}
// holosLogger implements the int128/kubelogin logger.Interface
type holosLogger struct {
log *slog.Logger
}
func (*holosLogger) AddFlags(f *pflag.FlagSet) {}
func (l *holosLogger) Printf(format string, args ...interface{}) {
l.log.Debug(fmt.Sprintf(format, args...))
}
func (l *holosLogger) Infof(format string, args ...interface{}) {
l.Printf(format, args...)
}
func (l *holosLogger) V(level int) logger.Verbose {
return l
}
func (*holosLogger) IsEnabled(level int) bool {
return true
}
// stringSlice is a comma separated list of string values
type stringSlice []string
func (s *stringSlice) String() string {
return strings.Join((*s)[:], ",")
}
func (s *stringSlice) Set(value string) error {
*s = append(*s, strings.Split(value, ",")...)
return nil
}

View File

@@ -26,5 +26,6 @@ func New(name string) *cobra.Command {
SilenceUsage: true,
SilenceErrors: true,
}
cmd.Flags().SortFlags = false
return cmd
}

View File

@@ -5,67 +5,39 @@ import (
"flag"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/holos-run/holos/internal/token"
"github.com/holos-run/holos/pkg/cli/command"
"github.com/holos-run/holos/pkg/errors"
"github.com/holos-run/holos/pkg/holos"
"github.com/int128/kubelogin/pkg/infrastructure/browser"
"github.com/int128/kubelogin/pkg/infrastructure/clock"
"github.com/int128/kubelogin/pkg/infrastructure/logger"
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/oidc/client"
"github.com/int128/kubelogin/pkg/tlsclientconfig"
"github.com/int128/kubelogin/pkg/tlsclientconfig/loader"
"github.com/int128/kubelogin/pkg/tokencache"
"github.com/int128/kubelogin/pkg/tokencache/repository"
"github.com/int128/kubelogin/pkg/usecases/authentication"
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
"github.com/int128/kubelogin/pkg/usecases/authentication/devicecode"
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"k8s.io/client-go/util/homedir"
)
type Config struct {
Issuer string
ClientID string
Scopes string
PrintClaims bool
ForceRefresh bool
}
// New returns a new login command.
func New(cfg *holos.Config) *cobra.Command {
cmd := command.New("login")
var printClaims bool
config := Config{}
flags := flag.NewFlagSet("", flag.ContinueOnError)
flags.StringVar(&config.Issuer, "issuer", "https://login.ois.run", "id token issuer.")
flags.StringVar(&config.ClientID, "client-id", "262479925313799528@holos_platform", "oidc client id.")
flags.StringVar(&config.Scopes, "scopes", "openid email profile groups offline_access urn:zitadel:iam:org:domain:primary:openinfrastructure.co", "oidc scopes")
flags.BoolVar(&config.ForceRefresh, "force-refresh", false, "force refresh")
flags.BoolVar(&config.PrintClaims, "print-claims", false, "print id token claims")
config := token.NewConfig()
cmd.Flags().AddGoFlagSet(config.FlagSet())
cmd.Flags().SortFlags = false
cmd.Flags().AddGoFlagSet(flags)
fs := &flag.FlagSet{}
fs.BoolVar(&printClaims, "print-claims", false, "print id token claims")
cmd.Flags().AddGoFlagSet(fs)
cmd.RunE = func(c *cobra.Command, args []string) error {
ctx := c.Context()
if ctx == nil {
ctx = context.Background()
}
// TODO Plumb the client ID and scopes as flags.
token, err := GetToken(ctx, cfg.Logger(), config)
token, err := token.Get(ctx, cfg.Logger(), config)
if err != nil {
slog.Error("could not get token", "err", err)
return fmt.Errorf("could not get token: %w", err)
}
slog.Info("have id token", "exp", token.Expiry)
if config.PrintClaims {
claims := token.Claims()
slog.Info("logged in as "+claims.Email, "name", claims.Name, "exp", token.Expiry, "email", claims.Email)
if printClaims {
fmt.Fprintln(cmd.OutOrStdout(), token.Pretty)
}
return nil
@@ -73,205 +45,3 @@ func New(cfg *holos.Config) *cobra.Command {
return cmd
}
// Token represents an authorization bearer token. Token is useful as an output
// dto of the Tokener service use case.
type Token struct {
// Bearer is the oidc token for the authorization: bearer header
Bearer string
// Expiry is the expiration time of the id token
Expiry time.Time
// Pretty is the JSON encoding of the token claims
Pretty string
}
// GetToken returns an oidc token for use as an authorization bearer http header.
func GetToken(ctx context.Context, log *slog.Logger, cfg Config) (*Token, error) {
provider := oidc.Provider{
IssuerURL: cfg.Issuer,
ClientID: cfg.ClientID,
UsePKCE: true,
ExtraScopes: strings.Split(cfg.Scopes, " "),
}
authenticationOptions := authenticationOptions{
GrantType: "auto",
ListenAddress: defaultListenAddress,
AuthenticationTimeoutSec: 180,
RedirectURLHostname: "localhost",
RedirectURLAuthCodeKeyboard: oobRedirectURI,
}
grantOptionSet, err := authenticationOptions.grantOptionSet()
if err != nil {
return nil, errors.Wrap(fmt.Errorf("could not login: %w", err))
}
tlsClientConfig := tlsclientconfig.Config{}
tokenCacheKey := tokencache.Key{
IssuerURL: provider.IssuerURL,
ClientID: provider.ClientID,
ClientSecret: provider.ClientSecret,
ExtraScopes: provider.ExtraScopes,
CACertFilename: strings.Join(tlsClientConfig.CACertFilename, ","),
CACertData: strings.Join(tlsClientConfig.CACertData, ","),
SkipTLSVerify: tlsClientConfig.SkipTLSVerify,
}
if grantOptionSet.ROPCOption != nil {
tokenCacheKey.Username = grantOptionSet.ROPCOption.Username
}
tokenCacheRepository := &repository.Repository{}
cachedTokenSet, err := tokenCacheRepository.FindByKey(defaultTokenCacheDir, tokenCacheKey)
if err != nil {
slog.Debug("could not find a token cache (continuing)", "err", err, "handled", true)
}
// Construct input for the Authentication service use case
authenticationInput := authentication.Input{
Provider: provider,
GrantOptionSet: grantOptionSet,
CachedTokenSet: cachedTokenSet,
TLSClientConfig: tlsClientConfig,
ForceRefresh: cfg.ForceRefresh,
}
var slogger logger.Interface = &holosLogger{log: log}
clock := &clock.Real{}
auth := &authentication.Authentication{
ClientFactory: &client.Factory{
Loader: loader.Loader{},
Clock: clock,
Logger: slogger,
},
Logger: slogger,
Clock: clock,
AuthCodeBrowser: &authcode.Browser{
Browser: &browser.Browser{},
Logger: slogger,
},
}
authenticationOutput, err := auth.Do(ctx, authenticationInput)
if err != nil {
return nil, fmt.Errorf("authentication error: %w", err)
}
idTokenClaims, err := authenticationOutput.TokenSet.DecodeWithoutVerify()
if err != nil {
slog.Debug("could not get token claims", "err", err, "handled", false)
return nil, fmt.Errorf("could not get token claims: %w", err)
}
if authenticationOutput.AlreadyHasValidIDToken {
slog.Debug("existing token valid", "refreshed", 0, "exp", idTokenClaims.Expiry)
} else {
slog.Debug("new token valid", "refreshed", 1, "exp", idTokenClaims.Expiry)
if err := tokenCacheRepository.Save(defaultTokenCacheDir, tokenCacheKey, authenticationOutput.TokenSet); err != nil {
slog.Debug("could not save token cache", "err", err, "handled", 0)
return nil, fmt.Errorf("could not save token cache: %w", err)
}
}
token := &Token{
Bearer: authenticationOutput.TokenSet.IDToken,
Expiry: idTokenClaims.Expiry,
Pretty: idTokenClaims.Pretty,
}
return token, nil
}
var defaultListenAddress = []string{"127.0.0.1:8000", "127.0.0.1:18000"}
var defaultTokenCacheDir = expandHomedir(filepath.Join("~", ".holos", "cache"))
var allGrantType = strings.Join([]string{
"auto",
"authcode",
"authcode-keyboard",
"password",
"device-code",
}, "|")
const oobRedirectURI = "urn:ietf:wg:oauth:2.0:oob"
func expandHomedir(s string) string {
if !strings.HasPrefix(s, "~") {
return s
}
return filepath.Join(homedir.HomeDir(), strings.TrimPrefix(s, "~"))
}
type authenticationOptions struct {
GrantType string
ListenAddress []string
AuthenticationTimeoutSec int
SkipOpenBrowser bool
BrowserCommand string
LocalServerCertFile string
LocalServerKeyFile string
OpenURLAfterAuthentication string
RedirectURLHostname string
RedirectURLAuthCodeKeyboard string
AuthRequestExtraParams map[string]string
Username string
Password string
}
func (o *authenticationOptions) grantOptionSet() (s authentication.GrantOptionSet, err error) {
switch {
case o.GrantType == "authcode" || (o.GrantType == "auto" && o.Username == ""):
s.AuthCodeBrowserOption = &authcode.BrowserOption{
BindAddress: o.ListenAddress,
SkipOpenBrowser: o.SkipOpenBrowser,
BrowserCommand: o.BrowserCommand,
AuthenticationTimeout: time.Duration(o.AuthenticationTimeoutSec) * time.Second,
LocalServerCertFile: o.LocalServerCertFile,
LocalServerKeyFile: o.LocalServerKeyFile,
OpenURLAfterAuthentication: o.OpenURLAfterAuthentication,
RedirectURLHostname: o.RedirectURLHostname,
AuthRequestExtraParams: o.AuthRequestExtraParams,
}
case o.GrantType == "authcode-keyboard":
s.AuthCodeKeyboardOption = &authcode.KeyboardOption{
AuthRequestExtraParams: o.AuthRequestExtraParams,
RedirectURL: o.RedirectURLAuthCodeKeyboard,
}
case o.GrantType == "password" || (o.GrantType == "auto" && o.Username != ""):
s.ROPCOption = &ropc.Option{
Username: o.Username,
Password: o.Password,
}
case o.GrantType == "device-code":
s.DeviceCodeOption = &devicecode.Option{
SkipOpenBrowser: o.SkipOpenBrowser,
BrowserCommand: o.BrowserCommand,
}
default:
err = fmt.Errorf("grant-type must be one of (%s)", allGrantType)
}
return
}
// holosLogger implements the int128/kubelogin logger.Interface
type holosLogger struct {
log *slog.Logger
}
func (*holosLogger) AddFlags(f *pflag.FlagSet) {}
func (l *holosLogger) Printf(format string, args ...interface{}) {
l.log.Debug(fmt.Sprintf(format, args...))
}
func (l *holosLogger) Infof(format string, args ...interface{}) {
l.Printf(format, args...)
}
func (l *holosLogger) V(level int) logger.Verbose {
return l
}
func (*holosLogger) IsEnabled(level int) bool {
return true
}

24
pkg/cli/logout/logout.go Normal file
View File

@@ -0,0 +1,24 @@
package logout
import (
"fmt"
"os"
"github.com/holos-run/holos/internal/token"
"github.com/holos-run/holos/pkg/cli/command"
"github.com/holos-run/holos/pkg/errors"
"github.com/holos-run/holos/pkg/holos"
"github.com/spf13/cobra"
)
func New(cfg *holos.Config) *cobra.Command {
cmd := command.New("logout")
cmd.RunE = func(c *cobra.Command, args []string) error {
if err := os.RemoveAll(token.CacheDir); err != nil {
return errors.Wrap(fmt.Errorf("could not logout: %w", err))
}
cfg.Logger().Info("logged out: removed " + token.CacheDir)
return nil
}
return cmd
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/holos-run/holos/pkg/cli/get"
"github.com/holos-run/holos/pkg/cli/kv"
"github.com/holos-run/holos/pkg/cli/login"
"github.com/holos-run/holos/pkg/cli/logout"
"github.com/holos-run/holos/pkg/cli/preflight"
"github.com/holos-run/holos/pkg/cli/render"
"github.com/holos-run/holos/pkg/cli/txtar"
@@ -58,6 +59,7 @@ func New(cfg *holos.Config) *cobra.Command {
rootCmd.AddCommand(create.New(cfg))
rootCmd.AddCommand(preflight.New(cfg))
rootCmd.AddCommand(login.New(cfg))
rootCmd.AddCommand(logout.New(cfg))
// Maybe not needed?
rootCmd.AddCommand(txtar.New(cfg))

View File

@@ -1 +1 @@
0
1