diff --git a/README.md b/README.md index 3af568f..ac76dd7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This library provides a unified interface for obtaining and refreshing credentia - **K8sSecret:** Watches a Kubernetes secret which contains a token and publishes updates when the secret changes - **OAuth2AC:** Obtains access tokens through OAuth2 authorization code flow and refreshes them before expiration - **OAuth2CC:** Obtains access tokens through OAuth2 client credentials flow and refreshes them before expiration +- **Vault** Exchanges ID tokens for secrets from Vault using Vault's JWT authentication. ## Installation @@ -647,6 +648,75 @@ go func() { // wg.Wait() ``` + +### Vault Credentials Provider + +```go +import ( + "go.riptides.io/tokenex/pkg/credential" + "go.riptides.io/tokenex/pkg/token" + "go.riptides.io/tokenex/pkg/vault" +) + + +// Create a logger +logger := logr.New(logr.Discard()) + +// Create the Vault credentials provider +vaultProvider, err := vault.NewCredentialsProvider(ctx, logger, "http://localhost:8200") +if err != nil { + return err +} + +// Get credentials from Vault +dbCredsChan, err := vaultProvider.GetCredentials( + ctx, + idTokenProvider, + vault.WithJWTAuthMethodPath("jwt"), + vault.WithJWTAuthRoleName("dbuser"), + vault.WithSecretFullPath("database/creds/pg-dyn-dbuser"), + ) + if err != nil { + return err + } + +// Process dynamic credentials from the channel in a goroutine with proper context handling +wg.Add(1) +go func() { + defer wg.Done() + for { + select { + case creds, ok := <-dbCredsChan: + if !ok { + logger.Info("Database credentials channel closed") + return + } + if creds.Err != nil { + logger.Error(creds.Err, "Error receiving database credentials", errors.GetDetails(creds.Err)) + return + } + + dbSecret := creds.Credential.(*credential.VaultSecret) + + // Database secrets typically contain username and password + if username, ok := dbSecret.Data["username"].(string); ok { + logger.Info("Database username", "value", username) + } + if password, ok := dbSecret.Data["password"].(string); ok { + logger.Info("Database password", "value", password) + } + + case <-ctx.Done(): + log.Println("Context cancelled, shutting down database credentials handler") + return + } + } +}() + +// In a real application, you would wait for all goroutines to complete before exiting +// wg.Wait() +``` + ## Channel Behavior All credential providers in this library follow a consistent pattern for credential delivery: diff --git a/go.mod b/go.mod index 7f83b05..8f62fe0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module go.riptides.io/tokenex -go 1.24.3 +go 1.24.4 + +toolchain go1.24.6 require ( cloud.google.com/go/iam v1.5.2 @@ -13,6 +15,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 github.com/go-logr/logr v1.4.3 github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/openbao/openbao/api/auth/jwt/v2 v2.5.0 + github.com/openbao/openbao/api/v2 v2.5.0 github.com/werbenhu/eventbus v1.0.8 golang.org/x/oauth2 v0.30.0 google.golang.org/api v0.240.0 @@ -37,15 +41,18 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect github.com/aws/smithy-go v1.22.4 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -53,16 +60,25 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect @@ -71,13 +87,13 @@ require ( go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.26.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.73.0 // indirect diff --git a/go.sum b/go.sum index 42334dc..8f03c6d 100644 --- a/go.sum +++ b/go.sum @@ -30,10 +30,18 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/sprig/v3 v3.2.1 h1:n6EPaDyLSvCEa3frruQvAiHuNp2dhBlMSmkEr+HuzGc= +github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26FmByPp2eC2qla6E1Tw+scpcg/to= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0= @@ -64,6 +72,8 @@ github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -92,14 +102,18 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfU github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -117,6 +131,10 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -157,6 +175,29 @@ github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJr github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= @@ -183,6 +224,20 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng= +github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -200,6 +255,10 @@ github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/openbao/openbao/api/auth/jwt/v2 v2.5.0 h1:UippeG69BJnQzWSgb12y4ft1FeEO2LE0+SdbPi2Uz5c= +github.com/openbao/openbao/api/auth/jwt/v2 v2.5.0/go.mod h1:rmNKBdR7dCUQeHbweNAV8Aa4wBVvRGAHTkhclzLAeOA= +github.com/openbao/openbao/api/v2 v2.5.0 h1:+npqpuI/d8jJe26DioV2vcEOdBpW3Uud+BTIFRbnruw= +github.com/openbao/openbao/api/v2 v2.5.0/go.mod h1:NrJ/4T+Cx1AM/rqWWe4E+tXcSeH+QIPf0GDIaH8tQ9U= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -211,6 +270,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= @@ -223,6 +284,14 @@ github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhi github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= +github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -240,8 +309,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/werbenhu/eventbus v1.0.8 h1:aVZtZ3xrawJ++hB9lU2wkDya0UoN58gBGYHFyQujbM4= github.com/werbenhu/eventbus v1.0.8/go.mod h1:uAFdff5aBgwKGNTJs1sPGb7N6Hd4rqWjWRE3JUAwV+A= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -289,47 +358,47 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/credential/equal.go b/pkg/credential/equal.go index 22c42d6..529b69b 100644 --- a/pkg/credential/equal.go +++ b/pkg/credential/equal.go @@ -3,6 +3,8 @@ package credential +import "reflect" + func Equal(a, b Credential) bool { switch a := a.(type) { case *AWSCreds: @@ -16,6 +18,10 @@ func Equal(a, b Credential) bool { case *Token: b, ok := b.(*Token) + return ok && a.IsEqual(b) + case *VaultSecret: + b, ok := b.(*VaultSecret) + return ok && a.IsEqual(b) default: return false @@ -81,3 +87,7 @@ func (a *AWSCreds) IsEqual(b *AWSCreds) bool { return true } + +func (a *VaultSecret) IsEqual(b *VaultSecret) bool { + return reflect.DeepEqual(a.Data, b.Data) +} diff --git a/pkg/credential/result.go b/pkg/credential/result.go index 918dfa9..4412fc7 100644 --- a/pkg/credential/result.go +++ b/pkg/credential/result.go @@ -69,3 +69,9 @@ type Token struct { } func (*Token) isResultType() {} + +type VaultSecret struct { + Data map[string]any +} + +func (*VaultSecret) isResultType() {} diff --git a/pkg/token/static_token_provider.go b/pkg/token/static_token_provider.go index 74fa660..0e28646 100644 --- a/pkg/token/static_token_provider.go +++ b/pkg/token/static_token_provider.go @@ -7,6 +7,9 @@ import ( "context" "sync" + "emperror.dev/errors" + "github.com/golang-jwt/jwt/v5" + "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" ) @@ -34,8 +37,24 @@ func (s *StaticIdentityTokenProvider) GetToken(ctx context.Context, opts ...opti s.mu.RLock() defer s.mu.RUnlock() + jwtParser := jwt.NewParser() + t, _, err := jwtParser.ParseUnverified(s.token, jwt.MapClaims{}) + if err != nil { + return credential.Token{}, errors.WrapIf(err, "failed to parse token") + } + + if t.Claims == nil { + return credential.Token{}, errors.New("token has no claims") + } + + exp, err := t.Claims.GetExpirationTime() + if err != nil { + return credential.Token{}, errors.WrapIf(err, "failed to get token expiration time") + } + return credential.Token{ - Token: s.token, + Token: t.Raw, + ExpiresAt: exp.Time, }, nil } diff --git a/pkg/vault/creds.go b/pkg/vault/creds.go new file mode 100644 index 0000000..4acf99d --- /dev/null +++ b/pkg/vault/creds.go @@ -0,0 +1,359 @@ +// Copyright (c) 2025 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package vault + +import ( + "context" + "time" + + "emperror.dev/errors" + "github.com/go-logr/logr" + jwtauth "github.com/openbao/openbao/api/auth/jwt/v2" + "github.com/openbao/openbao/api/v2" + + "go.riptides.io/tokenex/pkg/credential" + "go.riptides.io/tokenex/pkg/option" + "go.riptides.io/tokenex/pkg/token" + "go.riptides.io/tokenex/pkg/util" +) + +// credentialsConfig holds the configuration for GetCredentials. +type credentialsConfig struct { + jwtAuthMethodPath string + jwtAuthRoleName string + secretFullPath string + pollInterval time.Duration + identityTokenProvider token.IdentityTokenProvider +} + +// credentialData holds the secret data and expiration information returned from Vault. +type credentialData struct { + // Data contains the secret data retrieved from Vault. + Data map[string]interface{} + + // ExpiresAt is the time when the credentials expire and should no longer be used. + ExpiresAt time.Time + + // RefreshOn is an optional field specifying when to refresh the credentials. + // If set, the refresh should occur at this time instead of being calculated from ExpiresAt. + RefreshOn time.Time +} + +// CredentialsProvider defines the interface for obtaining Vault credentials. +// It exchanges ID tokens for Vault tokens using Vault's JWT auth method, +// then retrieves secrets from various secret engines. +type CredentialsProvider interface { + // GetCredentials exchanges an ID token for a Vault token and retrieves secrets. + // The channel provides updates when credentials are refreshed or removed. + // For the first credential and each refresh, an Update event is sent. + // In case of errors, the Err field is populated, Credential is nil, and the refresh loop exits. + // When the refresh loop exits, the channel is closed. + // The tokenProvider is used to obtain the ID token for exchange. + // Options can be provided to configure the request (e.g., vault address, JWT role, secret path). + GetCredentials(ctx context.Context, tokenProvider token.IdentityTokenProvider, opts ...option.Option) (<-chan credential.Result, error) +} + +var _ CredentialsProvider = &credentialsProvider{} + +// credentialsProvider is the internal implementation of CredentialsProvider. +type credentialsProvider struct { + logger logr.Logger + client *api.Client +} + +func setDefaults(cfg *credentialsConfig) { + if len(cfg.jwtAuthMethodPath) == 0 { + cfg.jwtAuthMethodPath = "jwt" + } + + if cfg.pollInterval == 0 { + cfg.pollInterval = 15 * time.Minute + } +} + +// validateConfig validates the configuration and returns an error if any required field is missing. +func validateConfig(cfg *credentialsConfig) error { + if cfg.jwtAuthMethodPath == "" { + return errors.New("JWT auth method path is required") + } + + if cfg.jwtAuthRoleName == "" { + return errors.New("JWT Auth role is required") + } + + if cfg.secretFullPath == "" { + return errors.New("secret path is required") + } + + if cfg.pollInterval <= 0 { + return errors.New("poll interval must be greater than zero") + } + + if cfg.identityTokenProvider == nil { + return errors.New("identity token provider must be specified") + } + + return nil +} + +type Provider interface { + isVault() +} + +func (cp *credentialsProvider) isVault() {} + +// authenticateWithJWT exchanges an ID token for a Vault token using JWT auth method +func (cp *credentialsProvider) authenticateWithJWT(ctx context.Context, idToken credential.Token, jwtAuthMethodPath string, roleName string) error { + authMethod, err := jwtauth.New( + roleName, + jwtauth.WithMountPath(jwtAuthMethodPath), + jwtauth.WithToken(idToken.Token), + ) + if err != nil { + return errors.WrapIfWithDetails(err, "failed to create JWT auth method", "auth_path", jwtAuthMethodPath, "role", roleName) + } + + secret, err := cp.client.Auth().Login(ctx, authMethod) + if err != nil { + return errors.WrapIfWithDetails(err, "failed to authenticate with Vault using JWT", "auth_path", jwtAuthMethodPath, "role", roleName) + } + + if secret == nil || secret.Auth == nil { + return errors.NewWithDetails("no authentication data returned from Vault", "auth_path", jwtAuthMethodPath, "role", roleName) + } + + // Set the token for subsequent requests + cp.client.SetToken(secret.Auth.ClientToken) + + return nil +} + +// retrieveCredentials retrieves a secret from Vault at the specified path. +// For dynamic secrets (with a lease), expiration is based on the lease duration. +// For static secrets (no lease), expiration is based on the secret's TTL if available, +// or falls back to the poll interval to ensure periodic refresh. +func (cp *credentialsProvider) retrieveCredentials(ctx context.Context, secretPath string, pollInterval time.Duration) (*credentialData, error) { + secret, err := cp.client.Logical().ReadWithContext(ctx, secretPath) + if err != nil { + return nil, errors.WrapIfWithDetails(err, "failed to read secret", "path", secretPath) + } + if secret == nil { + return nil, errors.NewWithDetails("no data found at secret path", "path", secretPath) + } + + var expiresAt time.Time + // If LeaseID is present, this is a dynamic credential (e.g., database, cloud secret). + // Set expiration based on the lease duration returned by Vault. + if secret.LeaseID != "" { + expiresAt = time.Now().Add(time.Duration(secret.LeaseDuration) * time.Second) + + return &credentialData{ + Data: secret.Data, + ExpiresAt: expiresAt, + }, nil + } + + // No lease ID present, so this is a static credential. + // For static credentials, check if a TTL is associated with the secret. + // If a TTL is present, Vault will automatically rotate the secret after the TTL expires. + // If no TTL is present, fall back to using the poll interval to ensure the secret is periodically refreshed. + ttl, err := secret.TokenTTL() + if err != nil { + return nil, errors.WrapIfWithDetails(err, "failed to get secret TTL", "path", secretPath) + } + + // Add a small leeway to allow Vault to rotate static credentials before we attempt to refresh. + staticCredsRotationLeeway := 5 * time.Second + if ttl == 0 { + // No TTL means the secret does not expire and Vault will not rotate it automatically. + // In this case, set the expiration to the poll interval to ensure we periodically check for updates to the secret in Vault. + ttl = pollInterval + staticCredsRotationLeeway = 0 // No leeway needed since Vault won't rotate this credential. + } + expiresAt = time.Now().Add(ttl) + + return &credentialData{ + Data: secret.Data, + ExpiresAt: expiresAt, + RefreshOn: expiresAt.Add(staticCredsRotationLeeway), + }, nil +} + +// refreshCredentialsLoop handles the credential retrieval and refresh loop. +func (cp *credentialsProvider) refreshCredentialsLoop(ctx context.Context, cfg *credentialsConfig, credsChan chan credential.Result) { + for { + // Get ID token + idToken, err := cfg.identityTokenProvider.GetToken(ctx) + if err != nil { + util.SendToChannel(credsChan, credential.Result{ + Credential: nil, + Err: errors.WrapIf(err, "failed to get ID token"), + }) + + return + } + + // Authenticate with Vault using JWT + err = cp.authenticateWithJWT(ctx, idToken, cfg.jwtAuthMethodPath, cfg.jwtAuthRoleName) + if err != nil { + util.SendToChannel(credsChan, credential.Result{ + Credential: nil, + Err: errors.WrapIf(err, "failed to authenticate with Vault"), + }) + + return + } + + // Retrieve the secret + creds, err := cp.retrieveCredentials(ctx, cfg.secretFullPath, cfg.pollInterval) + if err != nil { + util.SendToChannel(credsChan, credential.Result{ + Credential: nil, + Err: errors.WrapIf(err, "failed to retrieve secret"), + }) + + return + } + + // Calculate when to refresh + timeUntilExpiry := time.Until(creds.ExpiresAt) + + // If credentials are already expired, this is an error + if timeUntilExpiry <= 0 { + util.SendToChannel(credsChan, credential.Result{ + Credential: nil, + Err: errors.NewWithDetails("received already expired credentials", "secret_path", cfg.secretFullPath, "expiresAt", creds.ExpiresAt), + }) + + return + } + + // Send credentials + util.SendToChannel(credsChan, credential.Result{ + Credential: &credential.VaultSecret{ + Data: creds.Data, + }, + Err: nil, + Event: credential.UpdateEventType, + }) + + cp.logger.V(2).Info("Published Vault secret", "secret_path", cfg.secretFullPath, "expiresAt", creds.ExpiresAt) + + // Apply refresh buffer + var refreshBuffer, refreshTime time.Duration + + if !creds.RefreshOn.IsZero() { + // if refresh time is specified in the received credentials, use that + cp.logger.V(2).Info("Using RefreshOn time from credentials", "refreshOn", creds.RefreshOn) + + refreshTime = time.Until(creds.RefreshOn) + } else { + refreshBuffer = util.CalculateRefreshBuffer(timeUntilExpiry) + refreshTime = timeUntilExpiry - refreshBuffer + } + + cp.logger.V(1).Info("Scheduling credential refresh", "refreshIn", refreshTime, "refreshBuffer", refreshBuffer, "secret_path", cfg.secretFullPath) + + select { + case <-ctx.Done(): + cp.logger.V(1).Info("Context cancelled, stopping credential refresh") + + return + case <-time.After(refreshTime): + // Continue to next iteration to refresh + cp.logger.V(2).Info("Refreshing credentials", "secret_path", cfg.secretFullPath) + } + } +} + +// NewCredentialsProvider creates a new instance of CredentialsProvider for Vault. +// The returned provider can be used to obtain secrets from Vault by exchanging ID tokens +// for Vault tokens using Vault's JWT authentication method. +// +// Parameters: +// - ctx: The context for the operation +// - logger: Logger for logging credential operations +// - vaultAddr: The Vault server address (e.g., "https://vault.example.com:8200") +// +// Returns: +// - A credential provider that can exchange ID tokens for Vault secrets +// - An error if the provider cannot be created +func NewCredentialsProvider(ctx context.Context, logger logr.Logger, vaultAddr string) (*credentialsProvider, error) { + if vaultAddr == "" { + return nil, errors.New("vault address must be provided") + } + + config := api.DefaultConfig() + config.Address = vaultAddr + + client, err := api.NewClient(config) + if err != nil { + return nil, errors.WrapIf(err, "failed to create Vault client") + } + + return &credentialsProvider{ + logger: logger.WithName("vault_credentials"), + client: client, + }, nil +} + +// GetCredentialsWithOptions returns Vault credentials using the provided options. +// It applies any credential-specific options, validates the config, and delegates to GetCredentials. +// This method implements the credential.Provider interface and is the primary entry point for obtaining Vault credentials. +// The returned channel will receive credential updates, including initial credentials and refreshed credentials before expiration. +func (cp *credentialsProvider) GetCredentialsWithOptions(ctx context.Context, opts ...option.Option) (<-chan credential.Result, error) { + cfg := &credentialsConfig{} + setDefaults(cfg) + + for _, opt := range opts { + if opt, ok := isCredentialsOption(opt); ok { + opt.Apply(cfg) + } + } + + if err := validateConfig(cfg); err != nil { + return nil, err + } + + return cp.GetCredentials(ctx, cfg.identityTokenProvider, opts...) +} + +// GetCredentials exchanges an ID token for Vault credentials and returns a channel to receive them. +func (cp *credentialsProvider) GetCredentials(ctx context.Context, tokenProvider token.IdentityTokenProvider, opts ...option.Option) (<-chan credential.Result, error) { + cfg := &credentialsConfig{} + setDefaults(cfg) + + cfg.identityTokenProvider = tokenProvider + + // Apply options + for _, opt := range opts { + if opt, ok := isCredentialsOption(opt); ok { + opt.Apply(cfg) + } + } + + // Validate mandatory configurations + if err := validateConfig(cfg); err != nil { + return nil, err + } + + // Validate that we can get an initial token + t, err := tokenProvider.GetToken(ctx) + if err != nil { + return nil, errors.WrapIf(err, "failed to get initial ID token") + } + + if t.ExpiresAt.Before(time.Now()) { + return nil, errors.NewWithDetails("initial ID token is already expired", "expiry", t.ExpiresAt) + } + + credsChan := make(chan credential.Result, 1) + + go func() { + defer close(credsChan) + cp.refreshCredentialsLoop(ctx, cfg, credsChan) + }() + + return credsChan, nil +} diff --git a/pkg/vault/option.go b/pkg/vault/option.go new file mode 100644 index 0000000..3724552 --- /dev/null +++ b/pkg/vault/option.go @@ -0,0 +1,92 @@ +// Copyright (c) 2025 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package vault + +import ( + "time" + + "go.riptides.io/tokenex/pkg/option" + "go.riptides.io/tokenex/pkg/token" +) + +// Option is a function that modifies the credentialsConfig. +type ( + CredentialsOption interface { + Apply(*credentialsConfig) + } + credentialsOption struct { + option.Option + f func(*credentialsConfig) + } +) + +func (o *credentialsOption) Apply(c *credentialsConfig) { + o.f(c) +} + +func withCredentialsOption(f func(*credentialsConfig)) option.Option { + return &credentialsOption{option.OptionImpl{}, f} +} + +func isCredentialsOption(opt any) (CredentialsOption, bool) { + if o, ok := opt.(*credentialsOption); ok { + return o, ok + } + + return nil, false +} + +// WithJWTAuthMethodPath sets the path where the JWT auth method is mounted on the API for authentication. +// This is a required option for Vault credential exchange. If not set it defaults to "jwt" . +// The path must correspond to the mount path of the JWT auth method in Vault. +func WithJWTAuthMethodPath(jwtAuthMethodPath string) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.jwtAuthMethodPath = jwtAuthMethodPath + }) +} + +// WithJWTAuthRoleName sets the JWT role name for authentication. +// This is a required option for Vault credential exchange. +// The role must be configured in Vault's JWT auth method. +func WithJWTAuthRoleName(jwtAuthRoleName string) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.jwtAuthRoleName = jwtAuthRoleName + }) +} + +// WithSecretFullPath sets the Vault full path to the secret to be retrieved. +// This option is required for Vault credential exchange. +// The path format depends on the secret engine used. For example: +// - "database/creds/gen-dyn-dbuser-role" (Dynamic secrets from database secrets engine mounted at "database" API path) +// - "database/static-creds/static-dbuser-role" (Static secrets from database secrets engine mounted at "database" API path) +// - "kv2/data/path/to/secret" (KV version 2 secrets engine mounted at "kv2" API path) +// - "kv1/path/to/secret" (KV version 1 secrets engine mounted at "kv1" API path) +// - "ns1/kv2/data/path/to/secret" (KV version 2 secrets engine in namespace "ns1" mounted at "kv2" API path) +// - "ns1/ns2/kv1/path/to/secret" (KV version 1 secrets engine in nested namespaces "ns1/ns2" mounted at "kv1" API path) +// +// The value should match the corresponding Vault API path. +func WithSecretFullPath(secretFullPath string) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.secretFullPath = secretFullPath + }) +} + +// WithIdentityTokenProvider sets an identity token provider. +// This is a required option for Vault credential exchange. +// The identity token provider supplies the ID token that will be exchanged for Vault credentials. +// The provider should handle token refreshing internally if needed. +func WithIdentityTokenProvider(idtp token.IdentityTokenProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.identityTokenProvider = idtp + }) +} + +// WithPollInterval sets the interval at which to poll Vault secrets for updates in case the secret has either no lease or TTL expiration. +// If not set, the default polling interval is 15 minutes. +// This option is useful for secrets that do not have automatic renewal mechanisms such as the secrets stored in Vault's KV secrets engine. +func WithPollInterval(d time.Duration) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.pollInterval = d + }) +}