diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dc5c44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Ignore hidden files except this .gitignore +.* +!.gitignore + +# Ignore built binaries +utok +bin/ + +# Ignore Postman/Hoppscotch collections: they contain +# sensitive data (i.e. tokens)! +*.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..54f3cfe --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# uTok: A microscopic OpenID connect client +uTok aims to be a micro ($\mu$) client for generating access tokens through OpenID Connect's +Device Authorization Flow. + +As hinted by its default settings, `uTok`'s main target is the +[WLCG's main Indigo IAM instance](https://wlcg.cloud.cnaf.infn.it). You can find a bit +of documentation on its APIs and such [here](https://indigo-iam.github.io/v/current/). +Even though we haven't tested it, `utok` might work with other *issuers*: we didn't really +do anything 'special' for targetting Indigo IAM when it comes to token generation. + +Bear in mind the official client for Indigo IAM is [`oidc-agent`](https://github.com/indigo-dc/oidc-agent), +but we found it a bit 'aggressive' in its pursue of `ssh-agent`'s behavior and, after a lot of digging, +we didn't manage to get it to work on newer macOS versions or on CentOS 7... + +This client's interface is rather self explanatory: running `utok` with no arguments will show some +pointers to make use of `utok`. + +## Installation +You can just download the latest build for your platform and place the binary anywhere on your `PATH`. + +Uninstalling `utok` is a matter of removing that binary! + +## Getting the first token +In order to get a token you first need to create a client: + + $ utok cli create + +This will create `~/.utok/client.json` containing the reply's contents. This reply will +also be shown on screen. + +After that, you can generate tokens with: + + $ utok token + +This instructs `utok` to read the contents of `~/.utok/client.json` to then try to generate a +token. If none have been generated previously, the Device Authorization Flow will be triggered +so that you'll need to navigate to a particular URL and input a code: all these instructions +will be shown on screen. The generated token will be stored on `~/.utok/token.json`. + +After generating the first token, `utok` will leverage the **refresh token** embedded in the +initial one to re-generate access tokens at will. However, this is completely transparent: +the user need only run `utok token`. Fresh tokens will be stored on `~/.utok/token_fresh.json`. + +After a client is no longer needed, it can be deleted with: + + $ utok cli delete + +And... that's it really! Happy tokening! diff --git a/client.go b/client.go new file mode 100644 index 0000000..90077d0 --- /dev/null +++ b/client.go @@ -0,0 +1,171 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/spf13/cobra" + "github.com/zitadel/oidc/v3/pkg/client" + httphelper "github.com/zitadel/oidc/v3/pkg/http" +) + +const CLIENT_CONF string = "client.json" + +var cliCmd = &cobra.Command{ + Use: "cli", + Short: "Create or delete the OIDC client.", +} + +var cliCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an OIDC client.", + Run: func(cmd *cobra.Command, args []string) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT) + defer stop() + createdClient, err := createClient(ctx) + if err != nil { + fmt.Printf("error creating the client: %v\n", err) + os.Exit(-1) + } + + prettyClient, err := json.MarshalIndent(createdClient, "", " ") + if err != nil { + fmt.Printf("error encoding client: %v\n", err) + os.Exit(-1) + } + + fmt.Printf("%s\n", prettyClient) + if err := writeFile(CLIENT_CONF, append(prettyClient, "\n"...)); err != nil { + fmt.Printf("error saving the client to %s: %v", CLIENT_CONF, err) + } + + os.Exit(0) + }, +} + +var cliDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete the OIDC client.", + Run: func(cmd *cobra.Command, args []string) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT) + defer stop() + if err := deleteClient(ctx); err != nil { + fmt.Printf("error deleting the client: %v\n", err) + os.Exit(-1) + } + fmt.Printf("client deleted successfully!\n") + + if err := removeFile(CLIENT_CONF); err != nil { + fmt.Printf("error removing the client configuration: %v\n", err) + } + + if err := removeFile(TOKEN_CONF); err != nil { + fmt.Printf("error removing the original token: %v\n", err) + } + + if err := removeFile(REFRESH_TOKEN_CONF); err != nil { + fmt.Printf("error removing the refreshed token: %v\n", err) + } + + os.Exit(0) + }, +} + +func createClient(ctx context.Context) (Client, error) { + var createdClient Client + + oidcConfig, err := client.Discover(ctx, issuer, httphelper.DefaultHTTPClient) + if err != nil { + return createdClient, fmt.Errorf("error discovering endpoints: %v", err) + } + + cliConf := baseClientConf + + hostname, err := os.Hostname() + if err == nil { + clientName = clientName + ":" + hostname + } + cliConf.ClientName = clientName + cliConf.Contacts = strings.Split(clientContacts, ",") + cliConf.Scope = strings.Join(strings.Split(scopes, ","), " ") + + encPayload, err := json.Marshal(cliConf) + if err != nil { + return createdClient, err + } + + req, err := http.NewRequest("POST", oidcConfig.RegistrationEndpoint, bytes.NewBuffer(encPayload)) + if err != nil { + fmt.Println(err) + return createdClient, fmt.Errorf("error creating request: %v", err) + } + req.Header.Add("Content-Type", "application/json") + + res, err := httphelper.DefaultHTTPClient.Do(req) + if err != nil { + fmt.Println(err) + return createdClient, fmt.Errorf("error making the request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + fmt.Println(err) + return createdClient, fmt.Errorf("error reading back the reply: %v", err) + } + + if err := json.Unmarshal(body, &createdClient); err != nil { + return createdClient, fmt.Errorf("error decoding reply: %v [%s]", err, body) + } + + return createdClient, nil +} + +func deleteClient(ctx context.Context) error { + cliConfRaw, err := readFile(CLIENT_CONF) + if err != nil { + return fmt.Errorf("error reading client conf: %v", err) + } + + var cli Client + if err := json.Unmarshal(cliConfRaw, &cli); err != nil { + return fmt.Errorf("error parsing client conf: %v", err) + } + + oidcConfig, err := client.Discover(ctx, issuer, httphelper.DefaultHTTPClient) + if err != nil { + return fmt.Errorf("error discovering endpoints: %v", err) + } + + req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/%s", oidcConfig.RegistrationEndpoint, cli.ClientId), nil) + if err != nil { + fmt.Println(err) + return fmt.Errorf("error creating request: %v", err) + } + req.Header.Add("Authorization", "Bearer "+cli.RegistrationAccessToken) + + res, err := httphelper.DefaultHTTPClient.Do(req) + if err != nil { + fmt.Println(err) + return fmt.Errorf("error making the request: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("couldn't open the reply's body: %v", err) + } + return fmt.Errorf("%s [%d]", body, res.StatusCode) + } + + return nil +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..939b486 --- /dev/null +++ b/config.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + "github.com/zitadel/oidc/v3/pkg/client" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Show the issuer's OIDC config.", + Run: func(cmd *cobra.Command, args []string) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT) + defer stop() + + oidcConf, err := getOIDCConfig(ctx) + if err != nil { + fmt.Printf("error getting the configuration: %v\n", err) + os.Exit(-1) + } + + prettyConf, err := json.MarshalIndent(oidcConf, "", " ") + if err != nil { + fmt.Printf("error encoding the configuration: %v\n", err) + os.Exit(-1) + } + + fmt.Printf("%s\n", prettyConf) + + os.Exit(0) + }, +} + +func getOIDCConfig(ctx context.Context) (oidc.DiscoveryConfiguration, error) { + oidcConfig, err := client.Discover(ctx, issuer, httphelper.DefaultHTTPClient) + if err != nil { + return oidc.DiscoveryConfiguration{}, fmt.Errorf("error discovering endpoints: %v", err) + } + + return *oidcConfig, nil +} diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..8daef2a --- /dev/null +++ b/flags.go @@ -0,0 +1,28 @@ +package main + +import "log" + +var ( + issuer string + clientName string + clientContacts string + scopes string + forceRecreate bool +) + +func init() { + // Disable Cobra's completions. + rootCmd.CompletionOptions.DisableDefaultCmd = true + + // Configure the main logger + log.SetFlags(log.Lshortfile) + + // Define global flags + rootCmd.PersistentFlags().StringVar(&issuer, "iss", "https://wlcg.cloud.cnaf.infn.it/", "The issuer URL.") + rootCmd.PersistentFlags().StringVar(&clientName, "cli-name", "uTok-cli", "OIDC client name.") + rootCmd.PersistentFlags().StringVar(&clientContacts, "cli-contacts", "foo@faa.com", "Comma-separated OIDC client contacts.") + rootCmd.PersistentFlags().StringVar(&scopes, "scopes", + "storage.read:/atlasdatadisk/SAM/,openid,offline_access", "Comma separated scopes to request.") + + tokenCmd.PersistentFlags().BoolVar(&forceRecreate, "recreate", false, "Force token recreation even if one is present.") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..50d5a13 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/pcolladosoto/utok + +go 1.21 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/zitadel/oidc v1.13.5 + github.com/zitadel/oidc/v3 v3.10.3 +) + +require ( + github.com/go-jose/go-jose/v3 v3.0.1 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/gorilla/schema v1.2.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/zitadel/logging v0.5.0 // indirect + github.com/zitadel/schema v1.3.0 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect + golang.org/x/oauth2 v0.16.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/square/go-jose.v2 v2.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..75f1d22 --- /dev/null +++ b/go.sum @@ -0,0 +1,123 @@ +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= +github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= +github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +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/google/go-cmp v0.5.0/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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= +github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc= +github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= +github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zitadel/logging v0.5.0 h1:Kunouvqse/efXy4UDvFw5s3vP+Z4AlHo3y8wF7stXHA= +github.com/zitadel/logging v0.5.0/go.mod h1:IzP5fzwFhzzyxHkSmfF8dsyqFsQRJLLcQmwhIBzlGsE= +github.com/zitadel/oidc v1.13.5 h1:7jhh68NGZitLqwLiVU9Dtwa4IraJPFF1vS+4UupO93U= +github.com/zitadel/oidc v1.13.5/go.mod h1:rHs1DhU3Sv3tnI6bQRVlFa3u0lCwtR7S21WHY+yXgPA= +github.com/zitadel/oidc/v3 v3.10.3 h1:3H965coNbDviMmpSpYTwEKYs6+Etqvp+f1pYA1v70LU= +github.com/zitadel/oidc/v3 v3.10.3/go.mod h1:LaT6napJ1AxXJxXnt/OLpWdUGq9wuH9Ew0jvBQvdkYY= +github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= +github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= +go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y= +go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= +go.opentelemetry.io/otel/metric v1.22.0 h1:lypMQnGyJYeuYPhOM/bgjbFM6WE44W1/T45er4d8Hhg= +go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= +go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx83XD0= +go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ= +golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/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-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-20201119102817-f84b799fce68/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.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.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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +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/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.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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..73c8ca2 --- /dev/null +++ b/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +const DEFAULT_POLLING_INTERVAL int = 5 + +func init() { + rootCmd.AddCommand(versionCmd) + + rootCmd.AddCommand(configCmd) + + rootCmd.AddCommand(cliCmd) + cliCmd.AddCommand(cliCreateCmd) + cliCmd.AddCommand(cliDeleteCmd) + + rootCmd.AddCommand(tokenCmd) +} + +var ( + builtCommit string + + rootCmd = &cobra.Command{ + Use: "utok", + Short: "A micro-client for generating tokens through the OpenID Connect Device flow.", + Long: "We should add something right?", + } + + versionCmd = &cobra.Command{ + Use: "version", + Short: "Get the tool's version.", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("built commit: %s\n", builtCommit) + os.Exit(0) + }, + } +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..a23b0c3 --- /dev/null +++ b/token.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "github.com/zitadel/oidc/v3/pkg/client" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +const TOKEN_CONF string = "token.json" +const REFRESH_TOKEN_CONF string = "token_fresh.json" + +var tokenCmd = &cobra.Command{ + Use: "token", + Short: "Generate or refresh an OIDC token with the device authorization flow.", + Run: func(cmd *cobra.Command, args []string) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT) + defer stop() + + var ( + err error + token oidc.AccessTokenResponse + ) + + if !fileExists(TOKEN_CONF) || forceRecreate { + token, err = tokenGen(ctx) + if err != nil { + fmt.Printf("error generating the token: %v\n", err) + os.Exit(-1) + } + } else { + token, err = tokenRefresh(ctx) + if err != nil { + fmt.Printf("error refreshing the token: %v\n", err) + os.Exit(-1) + } + } + + prettyToken, err := json.MarshalIndent(token, "", " ") + if err != nil { + fmt.Printf("error encoding the token: %v\n", err) + os.Exit(-1) + } + + fmt.Printf("%s\n", prettyToken) + if err := writeFile(func() string { + if !fileExists(TOKEN_CONF) || forceRecreate { + return TOKEN_CONF + } + return REFRESH_TOKEN_CONF + }(), append(prettyToken, "\n"...)); err != nil { + fmt.Printf("error saving the token to %s: %v\n", TOKEN_CONF, err) + } + + if !fileExists(TOKEN_CONF) || forceRecreate { + removeFile(REFRESH_TOKEN_CONF) + } + + os.Exit(0) + }, +} + +func tokenGen(ctx context.Context) (oidc.AccessTokenResponse, error) { + cliConfRaw, err := readFile(CLIENT_CONF) + if err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error reading client conf: %v", err) + } + + var client Client + if err := json.Unmarshal(cliConfRaw, &client); err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error parsing client conf: %v", err) + } + + provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, client.ClientId, + client.ClientSecret, "", strings.Split(scopes, ",")) + if err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error creating provider: %v", err) + } + + resp, err := rp.DeviceAuthorization(ctx, strings.Split(scopes, ","), provider, nil) + if err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error triggering the device auth flow: %v", err) + } + fmt.Printf("Please browse to %s and enter code %s\n", resp.VerificationURI, resp.UserCode) + + var intervalDelay int + if resp.Interval == 0 { + intervalDelay = DEFAULT_POLLING_INTERVAL + } else { + intervalDelay = resp.Interval + } + + fmt.Printf("Beginning polling every %d seconds\n", intervalDelay) + token, err := rp.DeviceAccessToken(ctx, resp.DeviceCode, time.Duration(intervalDelay)*time.Second, provider) + if err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error polling: %v", err) + } + + return *token, nil +} + +func tokenRefresh(ctx context.Context) (oidc.AccessTokenResponse, error) { + cliConfRaw, err := readFile(CLIENT_CONF) + if err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error reading client conf: %v", err) + } + + var clientDescr Client + if err := json.Unmarshal(cliConfRaw, &clientDescr); err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error parsing client conf: %v", err) + } + + tokenRaw, err := readFile(TOKEN_CONF) + if err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error reading client conf: %v", err) + } + + var confToken oidc.AccessTokenResponse + if err := json.Unmarshal(tokenRaw, &confToken); err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error parsing saved token: %v", err) + } + + provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientDescr.ClientId, + clientDescr.ClientSecret, "", strings.Split(scopes, ",")) + if err != nil { + return oidc.AccessTokenResponse{}, fmt.Errorf("error creating provider: %v", err) + } + + // TODO: Figure out these generics... + // newTokenG, err := rp.RefreshTokens[oidc.IDClaims](ctx, provider, confToken.RefreshToken, "", "") + // if err != nil { + // return oidc.AccessTokenResponse{}, fmt.Errorf("error on the refresh request: %v", err) + // } + + request := rp.RefreshTokenRequest{ + RefreshToken: confToken.RefreshToken, + Scopes: provider.OAuthConfig().Scopes, + ClientID: provider.OAuthConfig().ClientID, + ClientSecret: provider.OAuthConfig().ClientSecret, + ClientAssertion: "", + ClientAssertionType: "", + GrantType: oidc.GrantTypeRefreshToken, + } + + newTokenG, err := client.CallTokenEndpoint(ctx, request, tokenEndpointCaller{RelyingParty: provider}) + if err != nil { + return oidc.AccessTokenResponse{}, err + } + + confToken.AccessToken = newTokenG.AccessToken + confToken.TokenType = newTokenG.TokenType + confToken.RefreshToken = newTokenG.RefreshToken + confToken.ExpiresIn = uint64(time.Until(newTokenG.Expiry).Seconds()) + + return confToken, nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..659bdaf --- /dev/null +++ b/types.go @@ -0,0 +1,55 @@ +package main + +import "github.com/zitadel/oidc/v3/pkg/client/rp" + +type Client struct { + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` + ClientName string `json:"client_name"` + RedirectURIs []string `json:"redirect_uris"` + Contacts []string `json:"contacts"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope"` + ReuseRefreshToken bool `json:"reuse_refresh_token"` + DynamicallyRegistered bool `json:"dynamically_registered"` + ClearAccessTokensOnRefresh bool `json:"clear_access_tokens_on_refresh"` + RequireAuthTime bool `json:"require_auth_time"` + RegistrationAccessToken string `json:"registration_access_token"` + RegistrrationClientURI string `json:"registration_client_uri"` + CreatedAt int `json:"created_at"` +} + +type ClientConf struct { + ClientName string `json:"client_name"` + RedirectURIs []string `json:"redirect_uris"` + Contacts []string `json:"contacts"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` +} + +var baseClientConf = ClientConf{ + TokenEndpointAuthMethod: "client_secret_basic", + RedirectURIs: []string{ + "edu.kit.data.oidc-agent:/redirect", + "http://localhost:8080", + "http://localhost:20746", + "http://localhost:4242"}, + GrantTypes: []string{ + "refresh_token", + "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code"}, + ResponseTypes: []string{ + "code"}, +} + +type tokenEndpointCaller struct { + rp.RelyingParty +} + +func (t tokenEndpointCaller) TokenEndpoint() string { + return t.OAuthConfig().Endpoint.TokenURL +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..8602c11 --- /dev/null +++ b/utils.go @@ -0,0 +1,37 @@ +package main + +import ( + "errors" + "fmt" + "os" +) + +var CONF_DIR string + +func init() { + home, _ := os.UserHomeDir() + + CONF_DIR = fmt.Sprintf("%s/.utok", home) + + // Ignore errors: it just means the directory exists.. + os.Mkdir(CONF_DIR, 0755) +} + +func writeFile(name string, data []byte) error { + return os.WriteFile(fmt.Sprintf("%s/%s", CONF_DIR, name), data, 0600) +} + +func readFile(name string) ([]byte, error) { + return os.ReadFile(fmt.Sprintf("%s/%s", CONF_DIR, name)) +} + +func fileExists(name string) bool { + if _, err := os.Stat(fmt.Sprintf("%s/%s", CONF_DIR, name)); errors.Is(err, os.ErrNotExist) { + return false + } + return true +} + +func removeFile(name string) error { + return os.Remove(fmt.Sprintf("%s/%s", CONF_DIR, name)) +}