From f2520898d97ab0ed7b999d9d042d8f338ba23be0 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Wed, 8 May 2024 09:22:53 -0500 Subject: [PATCH] add support for GetAccessToken This adds support for the Identity service's GetAccessToken rpc call. To ease in local testing a number of sources have been included: - command: allows you to execute a command to retrieve a token. - client-credentials: lets you specify an issuer with client creds to retrieve a token. - generated: generates a token (used by default if no other source is defined) Additionally, a token exchange is supported so you can exchange a source token with another issuing service. Signed-off-by: Mike Mason --- .gitignore | 3 +- README.md | 4 + cmd/serve.go | 18 ++- config.example.yaml | 19 +++ go.mod | 24 ++-- go.sum | 63 +++++++--- internal/accesstoken/config.go | 70 +++++++++++ internal/accesstoken/generatedtokensource.go | 97 ++++++++++++++ internal/accesstoken/tokenexchange.go | 125 +++++++++++++++++++ internal/cmdtokensource/config.go | 67 ++++++++++ internal/cmdtokensource/tokensource.go | 125 +++++++++++++++++++ internal/jwt/config.go | 17 +++ internal/jwt/doc.go | 2 + internal/jwt/jwks.go | 51 ++++++++ internal/jwt/validator.go | 81 ++++++++++++ internal/server/server.go | 35 +++++- internal/server/server_test.go | 24 +++- 17 files changed, 789 insertions(+), 36 deletions(-) create mode 100644 config.example.yaml create mode 100644 internal/accesstoken/config.go create mode 100644 internal/accesstoken/generatedtokensource.go create mode 100644 internal/accesstoken/tokenexchange.go create mode 100644 internal/cmdtokensource/config.go create mode 100644 internal/cmdtokensource/tokensource.go create mode 100644 internal/jwt/config.go create mode 100644 internal/jwt/doc.go create mode 100644 internal/jwt/jwks.go create mode 100644 internal/jwt/validator.go diff --git a/.gitignore b/.gitignore index 71553f0..21edda2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *~ bin/* coverage.out -.tools/* \ No newline at end of file +.tools/* +config.yaml diff --git a/README.md b/README.md index 9975669..c9bedbc 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,8 @@ $ ALICE_TOKEN=a1ic3 BOB_TOKEN=B0b ./bin/iam-runtime-static serve --policy policy To configure iam-runtime-static, you must define the static tokens that correspond to subjects and the resources those subjects have access to. An [example policy][example-policy] is available in this repository. +Additionally you may configure the Identity service by providing a config file with additional access token configuration. +An [example config][example-config] is available in this repository. + [example-policy]: ./policy.example.yaml +[example-config]: ./config.example.yaml diff --git a/cmd/serve.go b/cmd/serve.go index 02d24fc..e874fd9 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -7,10 +7,12 @@ import ( "os/signal" "syscall" + "github.com/metal-toolbox/iam-runtime-static/internal/accesstoken" "github.com/metal-toolbox/iam-runtime-static/internal/server" "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" "github.com/spf13/cobra" "github.com/spf13/viper" "google.golang.org/grpc" @@ -36,7 +38,7 @@ func init() { viperBindFlag("policy", serveCmd.Flags().Lookup("policy")) } -func serve(_ context.Context, v *viper.Viper) error { +func serve(ctx context.Context, v *viper.Viper) error { c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) @@ -51,7 +53,18 @@ func serve(_ context.Context, v *viper.Viper) error { } } - iamSrv, err := server.NewServer(policyPath, logger) + var accessTokenConfig accesstoken.Config + + if err := viper.UnmarshalKey("accesstoken", &accessTokenConfig); err != nil { + logger.Fatalw("failed to unmarshal access token config", "error", err) + } + + tokenSource, err := accesstoken.NewTokenSource(ctx, accessTokenConfig) + if err != nil { + logger.Fatalw("failed to create new token source", "error", err) + } + + iamSrv, err := server.NewServer(policyPath, logger, tokenSource) if err != nil { logger.Fatalw("failed to create server", "error", err) } @@ -59,6 +72,7 @@ func serve(_ context.Context, v *viper.Viper) error { grpcSrv := grpc.NewServer() authorization.RegisterAuthorizationServer(grpcSrv, iamSrv) authentication.RegisterAuthenticationServer(grpcSrv, iamSrv) + identity.RegisterIdentityServer(grpcSrv, iamSrv) listener, err := net.Listen("unix", socketPath) if err != nil { diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..964a776 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,19 @@ +--- +accessToken: + source: + command: + shell: "" + command: "" + noReuseToken: false + clientCredentials: + issuer: "" + clientID: "" + clientSecret: "" + generate: + issuer: "" + subject: "" + expiry: "" + exchange: + issuer: "" + grantType: "" + tokenType: "" diff --git a/go.mod b/go.mod index 8ad50b7..68e3cf3 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,26 @@ module github.com/metal-toolbox/iam-runtime-static go 1.21.6 require ( - github.com/metal-toolbox/iam-runtime v0.3.0 + github.com/MicahParks/jwkset v0.5.17 + github.com/MicahParks/keyfunc/v3 v3.3.2 + github.com/go-jose/go-jose/v4 v4.0.1 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/metal-toolbox/iam-runtime v0.4.1 github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.17.0 github.com/stretchr/testify v1.8.4 go.uber.org/zap v1.26.0 - google.golang.org/grpc v1.58.3 + golang.org/x/oauth2 v0.17.0 + google.golang.org/grpc v1.63.2 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -31,12 +36,15 @@ require ( github.com/spf13/cast v1.5.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect - google.golang.org/protobuf v1.31.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index db65266..44480ef 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,10 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/MicahParks/jwkset v0.5.17 h1:DrcwyKwSP5adD0G2XJTvDulnWXjD6gbjROMgMXDbkKA= +github.com/MicahParks/jwkset v0.5.17/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY= +github.com/MicahParks/keyfunc/v3 v3.3.2 h1:YTtwc4dxalBZKFqHhqctBWN6VhbLdGhywmne9u5RQVM= +github.com/MicahParks/keyfunc/v3 v3.3.2/go.mod h1:GJBeEjnv25OnD9y2OYQa7ELU6gYahEMBNXINZb+qm34= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -64,6 +68,10 @@ github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbS github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -90,8 +98,9 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -104,8 +113,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -146,8 +155,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/metal-toolbox/iam-runtime v0.3.0 h1:O8L0U2FFZgRVMCqa4rg5euGN9TpiwC8dTrHEerLLfVM= -github.com/metal-toolbox/iam-runtime v0.3.0/go.mod h1:O0Tay8IBHlW4KSv3GpqhVwa9MLKRg8sBHSiPTPN45Ik= +github.com/metal-toolbox/iam-runtime v0.4.1 h1:xeUSB9gnc2e4MYhoWXAwBHGJDQFlPEFDcid5PEzR7lA= +github.com/metal-toolbox/iam-runtime v0.4.1/go.mod h1:tZZ1qJy1Rc/onvsX9TRdEu5IYCa9H5WnFlM1EviFqP8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -196,6 +205,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -214,7 +224,10 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U 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.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 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= @@ -250,6 +263,7 @@ 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.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -282,8 +296,9 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 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= @@ -293,6 +308,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.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= 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= @@ -303,6 +320,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/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.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -338,10 +356,13 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -350,11 +371,14 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -402,6 +426,7 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 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= @@ -432,6 +457,8 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -468,8 +495,8 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -486,8 +513,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= -google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -500,8 +527,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/accesstoken/config.go b/internal/accesstoken/config.go new file mode 100644 index 0000000..024d654 --- /dev/null +++ b/internal/accesstoken/config.go @@ -0,0 +1,70 @@ +package accesstoken + +import ( + "context" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + "github.com/metal-toolbox/iam-runtime-static/internal/cmdtokensource" + "github.com/metal-toolbox/iam-runtime-static/internal/jwt" +) + +// Config defines the access token config. +type Config struct { + Source struct { + Command cmdtokensource.Config + ClientCredentials struct { + Issuer string + ClientID string + ClientSecret string + } + Generate GeneratedConfig + } + Exchange ExchangeConfig +} + +// ExchangeConfig defines the exchange config. +type ExchangeConfig struct { + Issuer string + GrantType string + TokenType string +} + +// NewTokenSource creates a new token source from the access token config. +func NewTokenSource(ctx context.Context, cfg Config) (oauth2.TokenSource, error) { + var ( + tokenSource oauth2.TokenSource + err error + ) + + switch { + case cfg.Source.Command.Command != "": + tokenSource = cmdtokensource.NewTokenSource(cfg.Source.Command) + case cfg.Source.ClientCredentials.Issuer != "": + tokenEndpoint, err := jwt.FetchIssuerTokenEndpoint(ctx, cfg.Source.ClientCredentials.Issuer) + if err != nil { + return nil, err + } + + tokenSource = (&clientcredentials.Config{ + TokenURL: tokenEndpoint, + ClientID: cfg.Source.ClientCredentials.ClientID, + ClientSecret: cfg.Source.ClientCredentials.ClientSecret, + }).TokenSource(ctx) + default: + tokenSource, err = newGeneratedTokenSource(cfg.Source.Generate) + if err != nil { + return nil, err + } + } + + if cfg.Exchange.Issuer != "" { + tokenSource, err = newExchangeTokenSource(ctx, cfg.Exchange, tokenSource) + if err != nil { + return nil, err + } + } + + return tokenSource, nil +} diff --git a/internal/accesstoken/generatedtokensource.go b/internal/accesstoken/generatedtokensource.go new file mode 100644 index 0000000..2923685 --- /dev/null +++ b/internal/accesstoken/generatedtokensource.go @@ -0,0 +1,97 @@ +package accesstoken + +import ( + "crypto/rand" + "crypto/rsa" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + gojwt "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" +) + +const keySize = 2048 + +// GeneratedConfig defines the configuration for a generated token source. +type GeneratedConfig struct { + Issuer string + Subject string + Expiration time.Duration +} + +type generatedTokenSource struct { + signer jose.Signer + cfg GeneratedConfig +} + +func (s generatedTokenSource) Token() (*oauth2.Token, error) { + var tokExpiry *jwt.NumericDate + + if s.cfg.Expiration != 0 { + tokExpiry = jwt.NewNumericDate(time.Now().Add(s.cfg.Expiration)) + } + + claims := jwt.Claims{ + Issuer: s.cfg.Issuer, + Subject: s.cfg.Subject, + Expiry: tokExpiry, + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Second)), + } + + token, err := jwt.Signed(s.signer).Claims(claims).Serialize() + if err != nil { + return nil, err + } + + jwt, _, err := gojwt.NewParser().ParseUnverified(token, gojwt.MapClaims{}) + if err != nil { + return nil, err + } + + expiry, err := jwt.Claims.GetExpirationTime() + if err != nil { + return nil, err + } + + var expiryTime time.Time + + if expiry != nil { + expiryTime = expiry.Time + } + + return &oauth2.Token{ + AccessToken: token, + TokenType: "Bearer", + Expiry: expiryTime, + }, nil +} + +func newGeneratedTokenSource(cfg GeneratedConfig) (oauth2.TokenSource, error) { + if cfg.Subject == "" { + cfg.Subject = "some subject" + } + + key, err := rsa.GenerateKey(rand.Reader, keySize) + if err != nil { + return nil, err + } + + signer, err := jose.NewSigner( + jose.SigningKey{ + Algorithm: jose.RS256, + Key: key, + }, (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "generated"), + ) + + if err != nil { + return nil, err + } + + tokenSource := generatedTokenSource{ + signer: signer, + cfg: cfg, + } + + return oauth2.ReuseTokenSource(nil, tokenSource), nil +} diff --git a/internal/accesstoken/tokenexchange.go b/internal/accesstoken/tokenexchange.go new file mode 100644 index 0000000..4c3ab8b --- /dev/null +++ b/internal/accesstoken/tokenexchange.go @@ -0,0 +1,125 @@ +// Package accesstoken builds a token source used for the GetAccessToken rpc call. +package accesstoken + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + + "golang.org/x/oauth2" + + "github.com/metal-toolbox/iam-runtime-static/internal/jwt" +) + +var ( + // TokenExchangeError is a root error for all other token exchange errors. + TokenExchangeError = errors.New("failed to exchange token") //nolint:revive,stylecheck // not returned directly, but used as a root error. + + // ErrUpstreamTokenRequestFailed is returned when the upstream token provider returns an error. + ErrUpstreamTokenRequestFailed = fmt.Errorf("%w, upstream token request failed", TokenExchangeError) + + // ErrInvalidTokenExchangeRequest is returned when the request returns a status 400 BadRequest. + ErrInvalidTokenExchangeRequest = fmt.Errorf("%w, invalid request", TokenExchangeError) + + // ErrTokenExchangeRequestFailed is returned when an error is generated while exchanging the token. + ErrTokenExchangeRequestFailed = fmt.Errorf("%w, failed request", TokenExchangeError) +) + +const ( + defaultGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" + defaultTokenType = "urn:ietf:params:oauth:token-type:jwt" +) + +type exchangeTokenSource struct { + cfg ExchangeConfig + ctx context.Context + mu sync.Mutex + upstream oauth2.TokenSource + upstreamToken *oauth2.Token + exchangeConfig oauth2.Config + token *oauth2.Token +} + +// Token retrieves an OAuth 2.0 access token from the configured issuer using token exchange. +// Tokens are reused as long as they are valid. +// Upstream tokens used as the source for the exchange are reused as long as they are valid. +func (s *exchangeTokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.token != nil && s.token.Valid() { + return s.token, nil + } + + if err := s.refreshUpstream(); err != nil { + return s.token, err + } + + if err := s.exchange(); err != nil { + return s.token, err + } + + return s.token, nil +} + +func (s *exchangeTokenSource) refreshUpstream() error { + if s.upstreamToken == nil || !s.upstreamToken.Valid() { + token, err := s.upstream.Token() + if err != nil { + return fmt.Errorf("%w: %w", ErrUpstreamTokenRequestFailed, err) + } + + s.upstreamToken = token + } + + return nil +} + +func (s *exchangeTokenSource) exchange() error { + token, err := s.exchangeConfig.Exchange(s.ctx, "", + oauth2.SetAuthURLParam("grant_type", s.cfg.GrantType), + oauth2.SetAuthURLParam("subject_token", s.upstreamToken.AccessToken), + oauth2.SetAuthURLParam("subject_token_type", s.cfg.TokenType), + ) + if err != nil { + if rErr, ok := err.(*oauth2.RetrieveError); ok { + if rErr.Response.StatusCode == http.StatusBadRequest { + return fmt.Errorf("%w: %w", ErrInvalidTokenExchangeRequest, rErr) + } + } + + return fmt.Errorf("%w: %w", ErrTokenExchangeRequestFailed, err) + } + + s.token = token + + return nil +} + +func newExchangeTokenSource(ctx context.Context, cfg ExchangeConfig, upstream oauth2.TokenSource) (oauth2.TokenSource, error) { + tokenEndpoint, err := jwt.FetchIssuerTokenEndpoint(ctx, cfg.Issuer) + if err != nil { + return nil, fmt.Errorf("failed to fetch exchange issuer token endpoint: %w", err) + } + + if cfg.GrantType == "" { + cfg.GrantType = defaultGrantType + } + + if cfg.TokenType == "" { + cfg.TokenType = defaultTokenType + } + + return &exchangeTokenSource{ + cfg: cfg, + ctx: ctx, + upstream: upstream, + exchangeConfig: oauth2.Config{ + Endpoint: oauth2.Endpoint{ + TokenURL: tokenEndpoint, + }, + }, + }, nil +} diff --git a/internal/cmdtokensource/config.go b/internal/cmdtokensource/config.go new file mode 100644 index 0000000..1b2519e --- /dev/null +++ b/internal/cmdtokensource/config.go @@ -0,0 +1,67 @@ +package cmdtokensource + +import ( + "errors" +) + +// ErrCommandRequired is returned when the Config.Command is not configured. +var ErrCommandRequired = errors.New("file token source: Command required") + +// Config describes the configuration for the token source. +type Config struct { + // Shell specifies the shell to execute the command with. + // Default `$SHELL -ec` + Shell string + + // Command is the command executed to return a token. + Command string + + // NoReuseToken if enabled disables reusing of tokens while they're still valid. + // Each request to [TokenSource.Token] will result in the latest token being loaded. + NoReuseToken bool +} + +// WithCommand returns a new Config with the provided command defined. +func (c Config) WithCommand(cmd string) Config { + c.Command = cmd + + return c +} + +// ReuseToken returns a new Config with NoReuseToken defined. +func (c Config) ReuseToken(reuse bool) Config { + c.NoReuseToken = !reuse + + return c +} + +// Configured returns true when Command is defined. +func (c Config) Configured() bool { + return c.Command != "" +} + +// Validate ensures the config has been configured properly. +func (c Config) Validate() error { + if c.Command == "" { + return ErrCommandRequired + } + + return nil +} + +// ToTokenSource initializes a new [TokenSource] with the defined config. +func (c Config) ToTokenSource() (*TokenSource, error) { + if c.Command == "" { + return nil, ErrCommandRequired + } + + tokenSource := &TokenSource{ + cfg: c, + } + + if _, err := tokenSource.Token(); err != nil { + return nil, err + } + + return tokenSource, nil +} diff --git a/internal/cmdtokensource/tokensource.go b/internal/cmdtokensource/tokensource.go new file mode 100644 index 0000000..249d3d3 --- /dev/null +++ b/internal/cmdtokensource/tokensource.go @@ -0,0 +1,125 @@ +// Package cmdtokensource executes a command to retrieve a token. +package cmdtokensource + +import ( + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" +) + +// TokenSource implemenets oauth2.TokenSource returning the token from the provided command. +// Loaded tokens are reused. +type TokenSource struct { + mu sync.Mutex + cfg Config + token *oauth2.Token +} + +// Token returns the latest token from the configured command. +// Unless Config.NoReuseToken is true, tokens are reused while they're still valid. +func (s *TokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.cfg.NoReuseToken && s.token != nil && s.token.Valid() { + return s.token, nil + } + + tokenb, err := runShellCommand(s.cfg) + if err != nil { + return nil, err + } + + newToken := string(tokenb) + + // Token signature is not validated here because we only need the expiry time from the claims. + token, _, err := jwt.NewParser().ParseUnverified(newToken, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("error parsing jwt: %w", err) + } + + expiry, err := token.Claims.GetExpirationTime() + if err != nil { + return nil, fmt.Errorf("error getting expiration time from jwt: %w", err) + } + + var expiryTime time.Time + + if expiry != nil { + expiryTime = expiry.Time + } + + s.token = &oauth2.Token{ + AccessToken: newToken, + TokenType: "Bearer", + Expiry: expiryTime, + } + + return s.token, nil +} + +func runShellCommand(cfg Config) ([]byte, error) { + var ( + shellCmd string + shellArgs []string + ) + + shellParts := strings.Split(cfg.Shell, " ") + if len(shellParts) != 0 { + if shellParts[0] != "" { + if !strings.HasPrefix(shellParts[0], "-") { + shellCmd = shellParts[0] + + if len(shellParts) > 1 { + shellArgs = shellParts[1:] + } + } else { + shellArgs = shellParts + } + } + } + + if shellCmd == "" { + shellCmd = os.Getenv("SHELL") + } + + if len(shellArgs) == 0 { + shellArgs = append(shellArgs, "-ec") + } + + if shellCmd == "" { + shellCmd = "/usr/bin/env" + + shellArgs = append([]string{"sh"}, shellArgs...) + } + + shellArgs = append(shellArgs, cfg.Command) + + cmd := exec.Command(shellCmd, shellArgs...) + + out, err := cmd.Output() + if err != nil { + var stderr string + + if eErr, ok := err.(*exec.ExitError); ok { + stderr = ": stderr: " + string(eErr.Stderr) + } + + return nil, fmt.Errorf("failed to execute command %s %q: %w%s", shellCmd, shellArgs, err, stderr) + } + + return out, nil +} + +// NewTokenSource creates a new command token source. +func NewTokenSource(cfg Config) oauth2.TokenSource { + return &TokenSource{ + cfg: cfg, + } +} diff --git a/internal/jwt/config.go b/internal/jwt/config.go new file mode 100644 index 0000000..09c377b --- /dev/null +++ b/internal/jwt/config.go @@ -0,0 +1,17 @@ +package jwt + +import ( + "github.com/spf13/pflag" +) + +// Config represents the configuration for a JWT validator. +type Config struct { + Issuer string + JWKSURI string +} + +// AddFlags sets the command line flags for JWT validation. +func AddFlags(flags *pflag.FlagSet) { + flags.String("jwt.issuer", "", "Issuer to use for JWT validation") + flags.String("jwt.jwksuri", "", "JWKS URI to use for JWT validation") +} diff --git a/internal/jwt/doc.go b/internal/jwt/doc.go new file mode 100644 index 0000000..1099173 --- /dev/null +++ b/internal/jwt/doc.go @@ -0,0 +1,2 @@ +// Package jwt contains functions and data for validating JSON Web Tokens (JWTs). +package jwt diff --git a/internal/jwt/jwks.go b/internal/jwt/jwks.go new file mode 100644 index 0000000..5ecefeb --- /dev/null +++ b/internal/jwt/jwks.go @@ -0,0 +1,51 @@ +package jwt + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" +) + +var ( + tokenEndpointClient = &http.Client{ + Timeout: 5 * time.Second, // nolint:gomnd // clear and unexported + Transport: http.DefaultTransport, + } + + // ErrTokenEndpointMissing is returned when the issuers .well-known/openid-configuration is missing the token_endpoint key. + ErrTokenEndpointMissing = errors.New("token endpoint missing from issuer well-known openid-configuration") +) + +type jwksTokenEndpoint struct { + TokenEndpoint string `json:"token_endpoint"` +} + +// FetchIssuerTokenEndpoint returns the token endpoint for the provided issuer. +func FetchIssuerTokenEndpoint(ctx context.Context, issuer string) (string, error) { + uri, err := url.JoinPath(issuer, ".well-known", "openid-configuration") + if err != nil { + return "", fmt.Errorf("invalid issuer: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return "", err + } + + res, err := tokenEndpointClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() //nolint:errcheck // no need to check + + var jwks jwksTokenEndpoint + if err := json.NewDecoder(res.Body).Decode(&jwks); err != nil { + return "", err + } + + return jwks.TokenEndpoint, nil +} diff --git a/internal/jwt/validator.go b/internal/jwt/validator.go new file mode 100644 index 0000000..31e13d4 --- /dev/null +++ b/internal/jwt/validator.go @@ -0,0 +1,81 @@ +package jwt + +import ( + "net/http" + "net/url" + + "github.com/MicahParks/jwkset" + "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" +) + +// Validator represents a JWT validator. +type Validator interface { + // ValidateToken checks that the given token is valid (i.e., is well-formed with a valid + // signature and future expiry). On success, it returns a map of claims describing the subject. + ValidateToken(string) (string, map[string]any, error) +} + +type validator struct { + kf jwt.Keyfunc + parser *jwt.Parser +} + +// NewValidator creates a validator with the given configuration. +func NewValidator(config Config) (Validator, error) { + client := &http.Client{ + Transport: http.DefaultTransport, + } + + jwksURL, err := url.Parse(config.JWKSURI) + if err != nil { + return nil, err + } + + storageOpts := jwkset.HTTPClientStorageOptions{ + Client: client, + } + + storage, err := jwkset.NewStorageFromHTTP(jwksURL, storageOpts) + if err != nil { + return nil, err + } + + keyfuncOpts := keyfunc.Options{ + Storage: storage, + } + + kf, err := keyfunc.New(keyfuncOpts) + if err != nil { + return nil, err + } + + parser := jwt.NewParser( + jwt.WithIssuedAt(), + jwt.WithExpirationRequired(), + jwt.WithIssuer(config.Issuer), + ) + + out := &validator{ + kf: kf.Keyfunc, + parser: parser, + } + + return out, nil +} + +func (v *validator) ValidateToken(tokenString string) (string, map[string]any, error) { + mapClaims := jwt.MapClaims{} + + _, err := v.parser.ParseWithClaims(tokenString, mapClaims, v.kf) + if err != nil { + return "", nil, err + } + + sub, err := mapClaims.GetSubject() + if err != nil { + return "", nil, err + } + + return sub, mapClaims, nil +} diff --git a/internal/server/server.go b/internal/server/server.go index 2b64a29..6535fa5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,7 +7,9 @@ import ( "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" "go.uber.org/zap" + "golang.org/x/oauth2" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -42,20 +44,23 @@ func checkAccess(sub policySubject, action, resourceID string) bool { type Server interface { authentication.AuthenticationServer authorization.AuthorizationServer + identity.IdentityServer } type server struct { // Map from tokens to subjects tokens map[string]policySubject - logger *zap.SugaredLogger + logger *zap.SugaredLogger + tokenSource oauth2.TokenSource authentication.UnimplementedAuthenticationServer authorization.UnimplementedAuthorizationServer + identity.UnimplementedIdentityServer } // NewServer creates a new static runtime server. -func NewServer(policyPath string, logger *zap.SugaredLogger) (Server, error) { +func NewServer(policyPath string, logger *zap.SugaredLogger, tokenSource oauth2.TokenSource) (Server, error) { f, err := os.Open(policyPath) if err != nil { return nil, err @@ -68,10 +73,10 @@ func NewServer(policyPath string, logger *zap.SugaredLogger) (Server, error) { return nil, err } - return newFromPolicy(policy, logger) + return newFromPolicy(policy, logger, tokenSource) } -func newFromPolicy(c policy, logger *zap.SugaredLogger) (*server, error) { +func newFromPolicy(c policy, logger *zap.SugaredLogger, tokenSource oauth2.TokenSource) (*server, error) { tokens := make(map[string]policySubject) for _, sub := range c.Subjects { @@ -92,8 +97,9 @@ func newFromPolicy(c policy, logger *zap.SugaredLogger) (*server, error) { } out := &server{ - tokens: tokens, - logger: logger, + tokens: tokens, + logger: logger, + tokenSource: tokenSource, } return out, nil @@ -143,3 +149,20 @@ func (s *server) CheckAccess(_ context.Context, req *authorization.CheckAccessRe return out, nil } + +func (s *server) GetAccessToken(_ context.Context, _ *identity.GetAccessTokenRequest) (*identity.GetAccessTokenResponse, error) { + s.logger.Infow("received GetAccessToken request") + + token, err := s.tokenSource.Token() + if err != nil { + s.logger.Errorw("failed to fetch token token from token source", err) + + return nil, status.Error(codes.Internal, err.Error()) + } + + resp := &identity.GetAccessTokenResponse{ + Token: token.AccessToken, + } + + return resp, nil +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index af3de48..3da8299 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,6 +7,8 @@ import ( "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" + "golang.org/x/oauth2" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -14,6 +16,15 @@ import ( "google.golang.org/grpc/status" ) +type mockedTokenSource struct { + token string + err error +} + +func (s mockedTokenSource) Token() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: s.token}, s.err +} + func TestServer(t *testing.T) { // Run everything in parallel t.Parallel() @@ -70,7 +81,7 @@ func TestServer(t *testing.T) { logger := zap.NewNop().Sugar() - srv, err := newFromPolicy(authPolicy, logger) + srv, err := newFromPolicy(authPolicy, logger, mockedTokenSource{token: "some access token"}) require.NoError(t, err) @@ -156,4 +167,15 @@ func TestServer(t *testing.T) { require.NoError(t, err) require.Equal(t, authorization.CheckAccessResponse_RESULT_DENIED, resp.Result) }) + + t.Run("GetAccessToken", func(t *testing.T) { + t.Parallel() + + req := &identity.GetAccessTokenRequest{} + + resp, err := srv.GetAccessToken(context.Background(), req) + + require.NoError(t, err) + require.Equal(t, "some access token", resp.Token) + }) }