diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4be5672 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea +.DS_Store +dist +.history +pkg +vendor/*/ +main + diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..0ad6bcb --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,108 @@ +# Build customization +build: + # Path to main.go file. + # Default is `main.go` + binary: token-cli + + env: + - CGO_ENABLED=0 + + # GOOS list to build in. + # For more info refer to https://golang.org/doc/install/source#environment + # Defaults are darwin and linux + goos: + - linux + - darwin + + # GOARCH to build in. + # For more info refer to https://golang.org/doc/install/source#environment + # Defaults are 386 and amd64 + goarch: + - amd64 + + ldflags: -X github.com/imduffy15/token-cli/version.Version={{.Version}} + +release: + # Repo in which the release will be created. + # Default is extracted from the origin remote URL. + github: + owner: imduffy15 + name: token-cli + + # If set to true, will not auto-publish the release. + # Default is false. + draft: false + + # If set to true, will mark the release as not ready for production. + # Default is false. + prerelease: false + + # You can change the name of the GitHub release. + # Default is `` + name_template: "{{.ProjectName}}-v{{.Version}}" + + # You can disable this pipe in order to not upload any artifacts to + # GitHub. + # Defaults to false. + disable: false + +nfpm: + name_template: '{{ .ProjectName }}_{{ .Arch }}' + homepage: https://github.com/imduffy15/token-cli + description: OpenID token generator + maintainer: Ian Duffy + license: Apache 2.0 + vendor: imduffy15 + formats: + - deb + - rpm + recommends: + - rpm + +# Archive customization +archive: + # You can change the name of the archive. + # This is parsed with Golang template engine and the following variables. + name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}" + + # Archive format. Valid options are `tar.gz` and `zip`. + # Default is `zip` + format: tar.gz + + # Replacements for GOOS and GOARCH on the archive name. + # The keys should be valid GOOS or GOARCH values followed by your custom + # replacements. + # By default, `replacements` replace GOOS and GOARCH values with valid outputs + # of `uname -s` and `uname -m` respectively. + replacements: + amd64: amd64 + 386: 386 + darwin: macOS + linux: linux + +brew: + name: token-cli + + github: + owner: imduffy15 + name: homebrew-tap + + commit_author: + name: Ian Duffy + email: ian@ianduffy.ie + + folder: Formula + + homepage: https://github.com/imduffy15/token-cli + + description: "OpenID token generator" + + skip_upload: false + +snapcraft: + name_template: '{{ .ProjectName }}_{{ .Arch }}' + summary: OpenID token generator. + description: | + OpenID token generator. + grade: stable + confinement: classic diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a2e8d30 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: go + +go: + - 1.11.x + +# Only clone the most recent commit. +git: + depth: 1 + diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..af26a32 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,105 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + digest = "1:586992e81213a853bfe5c102709c0c92020d21b386907ceae783f13bbe899ad7" + name = "github.com/danieljoos/wincred" + packages = ["."] + pruneopts = "UT" + revision = "412b574fb496839b312a75fba146bd32a89001cf" + version = "v1.0.1" + +[[projects]] + digest = "1:865079840386857c809b72ce300be7580cb50d3d3129ce11bf9aa6ca2bc1934a" + name = "github.com/fatih/color" + packages = ["."] + pruneopts = "UT" + revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" + version = "v1.7.0" + +[[projects]] + digest = "1:b3d20bcdedab2050e6bc58e52f4fdc46f710b4c74e1a1ecee262ebec1aee7b6e" + name = "github.com/godbus/dbus" + packages = ["."] + pruneopts = "UT" + revision = "2ff6f7ffd60f0f2410b3105864bdd12c7894f844" + version = "v5.0.1" + +[[projects]] + digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" + name = "github.com/inconshreveable/mousetrap" + packages = ["."] + pruneopts = "UT" + revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" + version = "v1.0" + +[[projects]] + digest = "1:c658e84ad3916da105a761660dcaeb01e63416c8ec7bc62256a9b411a05fcd67" + name = "github.com/mattn/go-colorable" + packages = ["."] + pruneopts = "UT" + revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" + version = "v0.0.9" + +[[projects]] + digest = "1:0981502f9816113c9c8c4ac301583841855c8cf4da8c72f696b3ebedf6d0e4e5" + name = "github.com/mattn/go-isatty" + packages = ["."] + pruneopts = "UT" + revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c" + version = "v0.0.4" + +[[projects]] + branch = "master" + digest = "1:39853e1ae46a02816e2419e1f590e00682b1a6b60bb988597cf2efb84314da45" + name = "github.com/skratchdot/open-golang" + packages = ["open"] + pruneopts = "UT" + revision = "75fb7ed4208cf72d323d7d02fd1a5964a7a9073c" + +[[projects]] + digest = "1:645cabccbb4fa8aab25a956cbcbdf6a6845ca736b2c64e197ca7cbb9d210b939" + name = "github.com/spf13/cobra" + packages = ["."] + pruneopts = "UT" + revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385" + version = "v0.0.3" + +[[projects]] + digest = "1:c1b1102241e7f645bc8e0c22ae352e8f0dc6484b6cb4d132fa9f24174e0119e2" + name = "github.com/spf13/pflag" + packages = ["."] + pruneopts = "UT" + revision = "298182f68c66c05229eb03ac171abe6e309ee79a" + version = "v1.0.3" + +[[projects]] + branch = "master" + digest = "1:a013f6b28d909956020c54738137d9c62a548c6fc6764e25256ec2b73e75099f" + name = "github.com/zalando/go-keyring" + packages = [ + ".", + "secret_service", + ] + pruneopts = "UT" + revision = "6d81c293b3fbc8a9b1bcf4bc9c167c2e1d1f52cf" + +[[projects]] + branch = "master" + digest = "1:8775d8a768d9e65e8b659172804aac5db1fc8d563ba766470a6c2698c57c61a7" + name = "golang.org/x/sys" + packages = ["unix"] + pruneopts = "UT" + revision = "4ed8d59d0b35e1e29334a206d1b3f38b1e5dfb31" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = [ + "github.com/fatih/color", + "github.com/skratchdot/open-golang/open", + "github.com/spf13/cobra", + "github.com/zalando/go-keyring", + ] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..3ce33aa --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,46 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + name = "github.com/fatih/color" + version = "1.7.0" + +[[constraint]] + branch = "master" + name = "github.com/skratchdot/open-golang" + +[[constraint]] + name = "github.com/spf13/cobra" + version = "0.0.3" + +[[constraint]] + branch = "master" + name = "github.com/zalando/go-keyring" + +[prune] + go-tests = true + unused-packages = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f73c696 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 Ian Duffy + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..323bb96 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +[![GitHub license](https://img.shields.io/github/license/imduffy15/token-cli.svg)](https://github.com/imduffy15/token-cli/blob/master/LICENSE) +![GitHub release](https://img.shields.io/github/release/imduffy15/token-cli.svg) + +# TokenCLI + +_tokenCLI_ is a command line utility for generating tokens from a OpenID identity provider, such as [Keycloak](https://www.keycloak.org/). + +_tokenCLI_ uses the [Authorization Code Grant Flow](https://developer.okta.com/authentication-guide/implementing-authentication/auth-code), as such a refresh token is generated and used to automatically renew the access token without browser interaction. + +![token-cli - The OpenID token generator](images/walkthrough.gif) + +## Installation + +### OSX + +Install: + +``` +brew install imduffy15/tap/token-cli +``` + +Upgrade: + +``` +brew upgrade token-cli +``` + + +## Alternative Installs (tar.gz, RPM, deb, snap) +Check out the [releases](https://github.com/imduffy15/token-cli/releases) section on Github for alternative binaries. + +## Contribute +[Fork token-cli](https://github.com/imduffy15/token-cli) and build a custom version. We welcome any useful pull requests. + +## Usage + +Create a new target called example-realm: + +``` +$ token-cli target create example-realm -t http://localhost:8080/auth/realms/example-realm/.well-known/openid-configuration +``` + +Set example-realm as the active target: + +``` +$ token-cli target set example-realm +``` + +Get a token for the client "service-template" with redirection port 9090 + +``` +$ token-cli token get service-template -p 9090 +``` + +## Help + +```bash +$ token-cli --help +Token Command Line Interface, version + +Usage: + token-cli [command] + +Available Commands: + help Help about any command + target Configure and view OIDC targets + token Configure and view tokens + +Flags: + -h, --help help for token-cli + -v, --verbose See additional info on HTTP requests + +Use "token-cli [command] --help" for more information about a command. +``` + +```bash +$ token-cli target --help +Configure and view OIDC targets + +Usage: + token-cli target [flags] + token-cli target [command] + +Available Commands: + create Creates a new target + delete Delete the target named TARGET_NAME + get View the target named TARGET_NAME + list List all targets + set sets TARGET_NAME as active + +Flags: + -h, --help help for target + -k, --skip-ssl-validation Disable security validation on requests to this target + +Global Flags: + -v, --verbose See additional info on HTTP requests + +Use "token-cli target [command] --help" for more information about a command. +``` + +```bash +$ token-cli token --help +Configure and view tokens + +Usage: + token-cli token [command] + +Available Commands: + get Obtain a token for the specified CLIENT_ID + +Flags: + -h, --help help for token + +Global Flags: + -v, --verbose See additional info on HTTP requests + +Use "token-cli token [command] --help" for more information about a command. +``` + +## License + +Apache License 2.0 diff --git a/cli/auth_callback_server.go b/cli/auth_callback_server.go new file mode 100644 index 0000000..0bd9ab6 --- /dev/null +++ b/cli/auth_callback_server.go @@ -0,0 +1,106 @@ +package cli + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +type CallbackServer interface { + HTML() string + CSS() string + Javascript() string + Port() int + Log() Logger + Hangup(chan url.Values, url.Values) + Start(chan url.Values) +} + +type AuthCallbackServer struct { + html string + css string + javascript string + port int + log Logger + hangupFunc func(chan url.Values, url.Values) +} + +func NewAuthCallbackServer(html, css, js string, log Logger, port int) AuthCallbackServer { + acs := AuthCallbackServer{html: html, css: css, javascript: js, log: log, port: port} + acs.SetHangupFunc(func(done chan url.Values, vals url.Values) {}) + return acs +} + +func (acs AuthCallbackServer) HTML() string { + return acs.html +} +func (acs AuthCallbackServer) CSS() string { + return acs.css +} +func (acs AuthCallbackServer) Javascript() string { + return acs.javascript +} +func (acs AuthCallbackServer) Port() int { + return acs.port +} +func (acs AuthCallbackServer) Log() Logger { + return acs.log +} +func (acs AuthCallbackServer) Hangup(done chan url.Values, values url.Values) { + acs.hangupFunc(done, values) +} +func (acs *AuthCallbackServer) SetHangupFunc(hangupFunc func(chan url.Values, url.Values)) { + acs.hangupFunc = hangupFunc +} + +func (acs AuthCallbackServer) Start(done chan url.Values) { + callbackValues := make(chan url.Values) + serveMux := http.NewServeMux() + srv := &http.Server{ + Addr: fmt.Sprintf(":%v", acs.port), + Handler: serveMux, + } + + go func() { + value := <-callbackValues + close(callbackValues) + srv.Close() + done <- value + }() + + attemptHangup := func(queryParams url.Values) { + time.Sleep(10 * time.Millisecond) + acs.Hangup(callbackValues, queryParams) + } + + serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, err := io.WriteString(w, acs.css) + if err != nil { + acs.log.Errorf("Failed to serve CSS: %v", err) + } + _, err = io.WriteString(w, acs.html) + if err != nil { + acs.log.Errorf("Failed to serve HTML: %v", err) + } + _, err = io.WriteString(w, acs.javascript) + if err != nil { + acs.log.Errorf("Failed to serve CSS: %v", err) + + } + acs.log.Infof("Local server received request to %v %v", r.Method, r.RequestURI) + + // This is a goroutine because we want this handleFunc to complete before + // Server.Close is invoked by listeners on the callbackValues channel. + go attemptHangup(r.URL.Query()) + }) + + go func() { + acs.log.Infof("Starting local HTTP server on port %v", acs.port) + acs.log.Info("Waiting for authorization redirect from Identity Provider...") + if err := srv.ListenAndServe(); err != nil { + acs.log.Infof("Stopping local HTTP server on port %v", acs.port) + } + }() +} diff --git a/cli/authcode_client_impersonator.go b/cli/authcode_client_impersonator.go new file mode 100644 index 0000000..72e74f4 --- /dev/null +++ b/cli/authcode_client_impersonator.go @@ -0,0 +1,122 @@ +package cli + +import ( + "fmt" + "net/http" + "net/url" + "os" + "strings" + + "github.com/imduffy15/token-cli/client" +) + +type ClientImpersonator interface { + Start() + Authorize() + Done() chan client.Token +} + +type AuthcodeClientImpersonator struct { + httpClient *http.Client + config client.Config + ClientID string + Scope string + Port int + Log Logger + AuthCallbackServer CallbackServer + BrowserLauncher func(string) error + done chan client.Token +} + +const CallbackCSS = `` +const AuthcodeCallbackJS = `` +const AuthcodeCallbackHTML = ` +

Token successfully generated

+

The identity provider redirected you to this page with an access token.

+

The token has been added to the CLI's active context. You may close this window.

+` + +func NewAuthcodeClientImpersonator( + httpClient *http.Client, + config client.Config, + clientID string, + scope string, + port int, + log Logger, + launcher func(string) error) AuthcodeClientImpersonator { + + impersonator := AuthcodeClientImpersonator{ + httpClient: httpClient, + config: config, + ClientID: clientID, + Scope: scope, + Port: port, + BrowserLauncher: launcher, + Log: log, + done: make(chan client.Token), + } + + callbackServer := NewAuthCallbackServer(AuthcodeCallbackHTML, CallbackCSS, AuthcodeCallbackJS, log, port) + callbackServer.SetHangupFunc(func(done chan url.Values, values url.Values) { + token := values.Get("code") + if token != "" { + done <- values + } + }) + impersonator.AuthCallbackServer = callbackServer + return impersonator +} + +func (aci AuthcodeClientImpersonator) Start() { + go func() { + urlValues := make(chan url.Values) + go aci.AuthCallbackServer.Start(urlValues) + values := <-urlValues + code := values.Get("code") + tokenRequester := client.AuthorizationCodeClient{ClientID: aci.ClientID} + aci.Log.Infof("Calling token endpoint to exchange code %v for an access token", code) + resp, err := tokenRequester.RequestToken(aci.httpClient, aci.config, code, aci.redirectURI()) + if err != nil { + aci.Log.Error(err.Error()) + aci.Log.Info("Retry with --verbose for more information.") + os.Exit(1) + } + aci.Done() <- resp + }() +} +func (aci AuthcodeClientImpersonator) Authorize() { + requestValues := url.Values{} + requestValues.Add("response_type", "code") + requestValues.Add("client_id", aci.ClientID) + requestValues.Add("redirect_uri", aci.redirectURI()) + requestValues.Add("scope", strings.Replace(aci.Scope, ",", " ", -1)) + + authURL, err := url.Parse(aci.config.GetActiveTarget().AuthorizationEndpoint) + if err != nil { + aci.Log.Errorf("Something went wrong while building the authorization URL: %v", err) + os.Exit(1) + } + + authURL.RawQuery = requestValues.Encode() + + aci.Log.Info("Launching browser window to " + authURL.String() + " where the user should login and grant approvals") + err = aci.BrowserLauncher(authURL.String()) + if err != nil { + aci.Log.Errorf("Error launching the browser: %v", err) + os.Exit(1) + } +} + +func (aci AuthcodeClientImpersonator) Done() chan client.Token { + return aci.done +} + +func (aci AuthcodeClientImpersonator) redirectURI() string { + return fmt.Sprintf("http://localhost:%v", aci.Port) +} diff --git a/cli/logger.go b/cli/logger.go new file mode 100644 index 0000000..71111f9 --- /dev/null +++ b/cli/logger.go @@ -0,0 +1,71 @@ +package cli + +import ( + "fmt" + "io" + "log" + + "github.com/fatih/color" +) + +type Logger struct { + infoLog *log.Logger + // robots is like info, but should only receive machine-parsable output + robotLog *log.Logger + warnLog *log.Logger + errorLog *log.Logger + muted bool +} + +func NewLogger(infoHandle, robotsHandle, warningHandle, errorHandle io.Writer) Logger { + infoLog := log.New(infoHandle, "", 0) + robotLog := log.New(robotsHandle, "", 0) + warnLog := log.New(warningHandle, "", 0) + errorLog := log.New(errorHandle, "", 0) + return Logger{infoLog, robotLog, warnLog, errorLog, false} +} + +func (l *Logger) Info(msg string) { + if l.muted { + return + } + l.infoLog.Println(msg) +} +func (l *Logger) Infof(format string, a ...interface{}) { + l.Info(fmt.Sprintf(format, a...)) +} + +func (l *Logger) Warn(msg string) { + if l.muted { + return + } + yellow := color.New(color.FgYellow).SprintFunc() + l.warnLog.Println(yellow(msg)) +} + +func (l *Logger) Error(msg string) { + if l.muted { + return + } + red := color.New(color.FgRed).SprintFunc() + l.errorLog.Println(red(msg)) +} +func (l *Logger) Errorf(format string, a ...interface{}) { + l.Error(fmt.Sprintf(format, a...)) +} + +func (l *Logger) Robots(msg string) { + if l.muted { + return + } + l.robotLog.Println(msg) +} +func (l *Logger) Robotsf(format string, a ...interface{}) { + l.Info(fmt.Sprintf(format, a...)) +} +func (l *Logger) Mute() { + l.muted = true +} +func (l *Logger) Unmute() { + l.muted = false +} diff --git a/cli/printer.go b/cli/printer.go new file mode 100644 index 0000000..61600ce --- /dev/null +++ b/cli/printer.go @@ -0,0 +1,25 @@ +package cli + +import ( + "encoding/json" +) + +type Printer interface { + Print(interface{}) error +} + +type JSONPrinter struct { + Log Logger +} + +func NewJSONPrinter(log Logger) JSONPrinter { + return JSONPrinter{log} +} +func (jp JSONPrinter) Print(obj interface{}) error { + j, err := json.MarshalIndent(&obj, "", " ") + if err != nil { + return err + } + jp.Log.Robots(string(j)) + return nil +} diff --git a/client/context.go b/client/context.go new file mode 100644 index 0000000..9f3009e --- /dev/null +++ b/client/context.go @@ -0,0 +1,118 @@ +package client + +import ( + "encoding/json" + + "github.com/zalando/go-keyring" +) + +type Config struct { + Verbose bool + Targets map[string]*Target + ActiveTargetName string +} + +type Target struct { + AuthorizationEndpoint string + TokenEndpoint string + Name string + SkipSSLValidation bool + ActiveClientContextName string + ClientContexts map[string]struct{} +} + +type ClientContext struct { + ClientID string `json:"client_id"` + Token +} + +func NewConfig() Config { + c := Config{} + c.Targets = map[string]*Target{} + return c +} + +func (c *Config) AddTarget(newTarget Target) { + c.Targets[newTarget.Name] = &newTarget +} + +func (c *Config) GetContext(contextName string) (ClientContext, error) { + strToken, err := keyring.Get(c.ActiveTargetName, contextName) + if err != nil { + return ClientContext{}, err + } + + var token Token + err = json.Unmarshal([]byte(strToken), &token) + + if err != nil { + return ClientContext{}, err + } + + return ClientContext{ + ClientID: contextName, + Token: token, + }, nil +} + +func (c *Config) AddContext(newClientContext ClientContext) error { + return c.Targets[c.ActiveTargetName].AddClientContext(newClientContext) +} + +func (c Config) GetTarget(targetName string) *Target { + return c.Targets[targetName] +} + +func (c Config) TargetExists(targetName string) bool { + _, found := c.Targets[targetName] + return found +} + +func (c Config) DeleteTarget(targetName string) error { + for k := range c.Targets[targetName].ClientContexts { + err := keyring.Delete(targetName, k) + if err != nil { + return err + } + delete(c.Targets[targetName].ClientContexts, k) + } + delete(c.Targets, targetName) + return nil +} + +func (c Config) ListTargets() []string { + keys := make([]string, len(c.Targets)) + + i := 0 + for k := range c.Targets { + keys[i] = k + i++ + } + + return keys +} + +func (c Config) GetActiveTarget() *Target { + return c.Targets[c.ActiveTargetName] +} + +func (t Target) ClientContextExists(clientName string) bool { + _, found := t.ClientContexts[clientName] + return found +} + +func (t *Target) AddClientContext(newClientContext ClientContext) error { + payload, err := json.Marshal(newClientContext.Token) + if err != nil { + return err + } + + if err := keyring.Set(t.Name, newClientContext.ClientID, string(payload)); err != nil { + return err + } + if t.ClientContexts == nil { + t.ClientContexts = make(map[string]struct{}) + } + t.ClientContexts[newClientContext.ClientID] = struct{}{} + return nil +} diff --git a/client/http_request_factory.go b/client/http_request_factory.go new file mode 100644 index 0000000..b9ff2d8 --- /dev/null +++ b/client/http_request_factory.go @@ -0,0 +1,27 @@ +package client + +import ( + "bytes" + "net/http" + "net/url" + "strconv" +) + +type HTTPRequestFactory interface { + PostForm(Target, string, string, *url.Values) (*http.Request, error) +} + +type UnauthenticatedRequestFactory struct{} + +func (urf UnauthenticatedRequestFactory) PostForm(url string, data *url.Values) (*http.Request, error) { + bodyBytes := []byte(data.Encode()) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) + if err != nil { + return nil, err + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Content-Length", strconv.Itoa(len(bodyBytes))) + + return req, nil +} diff --git a/client/http_requester.go b/client/http_requester.go new file mode 100644 index 0000000..de9c89a --- /dev/null +++ b/client/http_requester.go @@ -0,0 +1,73 @@ +package client + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +type Requester interface { + Get(client *http.Client, config Config, path string, query string) ([]byte, error) + PostForm(client *http.Client, config Config, path string, query string, body map[string]string) ([]byte, error) +} + +type UnauthenticatedRequester struct{} + +func is2XX(status int) bool { + if status >= 200 && status < 300 { + return true + } + return false +} + +func mapToURLValues(body map[string]string) url.Values { + data := url.Values{} + for key, val := range body { + data.Add(key, val) + } + return data +} + +func doAndRead(req *http.Request, client *http.Client, config Config) ([]byte, error) { + if config.Verbose { + logRequest(req) + } + + resp, err := client.Do(req) + if err != nil { + if config.Verbose { + fmt.Printf("%v\n\n", err) + } + + return []byte{}, requestError(req.URL.String()) + } + + if config.Verbose { + logResponse(resp) + } + + bytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + if config.Verbose { + fmt.Printf("%v\n\n", err) + } + + return []byte{}, unknownError() + } + + if !is2XX(resp.StatusCode) { + return []byte{}, requestError(req.URL.String()) + } + return bytes, nil +} + +func (ug UnauthenticatedRequester) PostForm(client *http.Client, config Config, url string, body map[string]string) ([]byte, error) { + data := mapToURLValues(body) + + req, err := UnauthenticatedRequestFactory{}.PostForm(url, &data) + if err != nil { + return []byte{}, err + } + return doAndRead(req, client, config) +} diff --git a/client/oauth_token_request.go b/client/oauth_token_request.go new file mode 100644 index 0000000..dc271c0 --- /dev/null +++ b/client/oauth_token_request.go @@ -0,0 +1,79 @@ +package client + +import ( + "encoding/json" + "net/http" + "time" +) + +func postToOAuthToken(httpClient *http.Client, config Config, body map[string]string) (Token, error) { + bytes, err := UnauthenticatedRequester{}.PostForm(httpClient, config, config.GetActiveTarget().TokenEndpoint, body) + if err != nil { + return Token{}, err + } + + resp := tokenResponse{} + err = json.Unmarshal(bytes, &resp) + if err != nil { + return Token{}, parseError(config.GetActiveTarget().TokenEndpoint, bytes) + } + + return Token{ + AccessToken: resp.AccessToken, + RefreshToken: resp.RefreshToken, + TokenType: resp.TokenType, + ExpiresAt: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Unix(), + }, nil +} + +type AuthorizationCodeClient struct { + ClientID string +} + +type RefreshTokenClient struct { + ClientID string +} + +func (acc AuthorizationCodeClient) RequestToken(httpClient *http.Client, config Config, code string, redirectURI string) (Token, error) { + body := map[string]string{ + "grant_type": string(AuthCode), + "client_id": acc.ClientID, + "response_type": "token", + "redirect_uri": redirectURI, + "code": code, + } + + return postToOAuthToken(httpClient, config, body) +} + +func (rc RefreshTokenClient) RequestToken(httpClient *http.Client, config Config, refreshToken string) (Token, error) { + body := map[string]string{ + "grant_type": string(RefreshToken), + "refresh_token": refreshToken, + "client_id": rc.ClientID, + "response_type": "token", + } + + return postToOAuthToken(httpClient, config, body) +} + +type GrantType string + +const ( + RefreshToken = GrantType("refresh_token") + AuthCode = GrantType("authorization_code") +) + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +type Token struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresAt int64 `json:"expires_at"` +} diff --git a/client/request_errors.go b/client/request_errors.go new file mode 100644 index 0000000..8489378 --- /dev/null +++ b/client/request_errors.go @@ -0,0 +1,16 @@ +package client + +import "errors" + +func requestError(url string) error { + return errors.New("an unknown error occurred while calling " + url) +} + +func parseError(url string, body []byte) error { + errorMsg := "an unknown error occurred while parsing response from " + url + ". Response was " + string(body) + return errors.New(errorMsg) +} + +func unknownError() error { + return errors.New("an unknown error occurred") +} diff --git a/client/request_logging.go b/client/request_logging.go new file mode 100644 index 0000000..964762a --- /dev/null +++ b/client/request_logging.go @@ -0,0 +1,24 @@ +package client + +import ( + "fmt" + "net/http" + "net/http/httputil" + + "github.com/imduffy15/token-cli/utils" +) + +func logResponse(response *http.Response) { + dumped, _ := httputil.DumpResponse(response, true) + + if is2XX(response.StatusCode) { + fmt.Println(utils.Green(string(dumped)) + "\n") + } else { + fmt.Println(utils.Red(string(dumped)) + "\n") + } +} + +func logRequest(request *http.Request) { + dumped, _ := httputil.DumpRequest(request, true) + fmt.Println(string(dumped)) +} diff --git a/cmd/errors.go b/cmd/errors.go new file mode 100644 index 0000000..9a1efe2 --- /dev/null +++ b/cmd/errors.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/imduffy15/token-cli/cli" + "github.com/imduffy15/token-cli/client" + "github.com/spf13/cobra" +) + +const TargetDoesNotExist = "the target you specified does not exist" +const NoActiveTarget = "there is no active target set, please set one" + +func MissingArgumentError(argName string) error { + return MissingArgumentWithExplanationError(argName, "") +} + +func MissingArgumentWithExplanationError(argName string, explanation string) error { + return fmt.Errorf("Missing argument `%v` must be specified. %v", argName, explanation) +} + +func EnsureActiveTarget(cfg client.Config) error { + if cfg.ActiveTargetName == "" { + return errors.New(NoActiveTarget) + } + if !cfg.TargetExists(cfg.ActiveTargetName) { + return errors.New(TargetDoesNotExist) + } + return nil +} + +func EnsureTargetInConfig(cfg client.Config, targetName string) error { + if !cfg.TargetExists(targetName) { + return errors.New(TargetDoesNotExist) + } + return nil +} + +func NotifyValidationErrors(err error, cmd *cobra.Command, log cli.Logger) { + if err != nil { + log.Error(err.Error()) + err = cmd.Usage() + if err != nil { + log.Error(err.Error()) + } + os.Exit(1) + } +} + +func NotifyErrorsWithRetry(err error, log cli.Logger) { + if err != nil { + log.Error(err.Error()) + VerboseRetryMsg(GetSavedConfig()) + os.Exit(1) + } +} + +func VerboseRetryMsg(c client.Config) { + if !c.Verbose { + log.Info("Retry with --verbose for more information.") + } +} diff --git a/cmd/http_client.go b/cmd/http_client.go new file mode 100644 index 0000000..bf1f11f --- /dev/null +++ b/cmd/http_client.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "net/http" + "time" +) + +func HTTPClient() *http.Client { + var client = &http.Client{ + Timeout: 60 * time.Second, + } + return client +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..f500abd --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/imduffy15/token-cli/cli" + "github.com/imduffy15/token-cli/client" + "github.com/imduffy15/token-cli/config" + "github.com/imduffy15/token-cli/help" + "github.com/imduffy15/token-cli/version" + "github.com/spf13/cobra" +) + +var cfgFile client.Config +var log cli.Logger + +// Global flags +var ( + skipSSLValidation bool + verbose bool +) + +var ( + scope string + port int + force bool +) + +var RootCmd = cobra.Command{ + Use: "token-cli", + Short: "A cli for generating tokens", + Long: help.Root(version.VersionString()), +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "See additional info on HTTP requests") + log = cli.NewLogger(os.Stderr, os.Stdout, os.Stderr, os.Stderr) +} + +func initConfig() { + // Startup tasks +} + +func GetLogger() *cli.Logger { + return &log +} + +func GetSavedConfig() client.Config { + cfgFile = config.Read() + cfgFile.Verbose = verbose + return cfgFile +} diff --git a/cmd/target.go b/cmd/target.go new file mode 100644 index 0000000..d5d98bc --- /dev/null +++ b/cmd/target.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/imduffy15/token-cli/cli" + "github.com/imduffy15/token-cli/client" + "github.com/imduffy15/token-cli/config" + "github.com/imduffy15/token-cli/utils" + "github.com/spf13/cobra" +) + +type targetStatus struct { + AuthorizationEndpoint string + TokenEndpoint string + Name string + SkipSSLValidation bool +} + +type openidConfiguration struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` +} + +func printTarget(target client.Target) { + cli.NewJSONPrinter(log).Print(targetStatus{ + AuthorizationEndpoint: target.AuthorizationEndpoint, + TokenEndpoint: target.TokenEndpoint, + Name: target.Name, + SkipSSLValidation: target.SkipSSLValidation, + }) +} + +func UpdateTargetCmd(cfg client.Config, httpClient *http.Client, openIDConfigurationURL string, name string, log cli.Logger) error { + + res, err := httpClient.Get(openIDConfigurationURL) + if err != nil { + return err + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return err + } + + var openidConfiguration openidConfiguration + err = json.Unmarshal(body, &openidConfiguration) + if err != nil { + return nil + } + + target := client.Target{ + Name: name, + SkipSSLValidation: skipSSLValidation, + AuthorizationEndpoint: openidConfiguration.AuthorizationEndpoint, + TokenEndpoint: openidConfiguration.TokenEndpoint, + } + + cfg.AddTarget(target) + + err = config.Write(cfg) + if err != nil { + return err + } + log.Info("Successfully added target " + utils.Emphasize(name)) + printTarget(target) + return nil +} + +var openIDConfigurationURL string + +var targetCmd = &cobra.Command{ + Use: "target", + Short: "Configure and view OIDC targets", + PreRun: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + NotifyValidationErrors(targetCmdArgumentValidation(cfg, args), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + log.Robots(cfg.GetActiveTarget().Name) + }, +} + +var getCmd = &cobra.Command{ + Use: "get TARGET_NAME", + Short: "View the target named TARGET_NAME", + PreRun: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + NotifyValidationErrors(actionCmdArgumentValidation(cfg, args), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + printTarget(*cfg.GetTarget(args[0])) + }, +} + +var setCmd = &cobra.Command{ + Use: "set TARGET_NAME", + Short: "sets TARGET_NAME as active", + PreRun: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + NotifyValidationErrors(actionCmdArgumentValidation(cfg, args), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + cfg.ActiveTargetName = args[0] + err := config.Write(cfg) + if err != nil { + log.Error(err.Error()) + } else { + log.Info("Successfully set target to " + utils.Emphasize(args[0])) + } + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete TARGET_NAME", + Short: "Delete the target named TARGET_NAME", + PreRun: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + NotifyValidationErrors(actionCmdArgumentValidation(cfg, args), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + NotifyErrorsWithRetry(cfg.DeleteTarget(args[0]), log) + err := config.Write(cfg) + if err != nil { + log.Error(err.Error()) + } + }, +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all targets", + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + cli.NewJSONPrinter(log).Print(cfg.ListTargets()) + }, +} + +var createCmd = &cobra.Command{ + Use: "create TARGET_NAME", + Short: "Creates a new target", + PreRun: func(cmd *cobra.Command, args []string) { + NotifyValidationErrors(createCmdArgumentValidation(args), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + NotifyErrorsWithRetry(UpdateTargetCmd(cfg, HTTPClient(), openIDConfigurationURL, args[0], log), log) + }, +} + +func createCmdArgumentValidation(args []string) error { + if len(args) < 1 { + return MissingArgumentError("target_name") + } + return nil +} + +func actionCmdArgumentValidation(cfg client.Config, args []string) error { + if len(args) < 1 { + return MissingArgumentError("target_name") + } + if err := EnsureTargetInConfig(cfg, args[0]); err != nil { + return err + } + return nil +} + +func targetCmdArgumentValidation(cfg client.Config, args []string) error { + if err := EnsureActiveTarget(cfg); err != nil { + return err + } + return nil +} + +func init() { + + createCmd.Flags().StringVarP(&openIDConfigurationURL, "openid-configuration-url", "t", "", "OpenID Configuration URL") + err := createCmd.MarkFlagRequired("openid-configuration-url") + if err != nil { + log.Error(err.Error()) + } + + createCmd.Flags().BoolVarP(&skipSSLValidation, "skip-ssl-validation", "k", false, "Disable security validation on requests to this target") + + targetCmd.AddCommand(getCmd) + targetCmd.AddCommand(listCmd) + targetCmd.AddCommand(createCmd) + targetCmd.AddCommand(deleteCmd) + targetCmd.AddCommand(setCmd) + + RootCmd.AddCommand(targetCmd) +} diff --git a/cmd/token.go b/cmd/token.go new file mode 100644 index 0000000..fd4a72f --- /dev/null +++ b/cmd/token.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "time" + + "github.com/imduffy15/token-cli/cli" + "github.com/imduffy15/token-cli/client" + "github.com/imduffy15/token-cli/config" + "github.com/skratchdot/open-golang/open" + "github.com/spf13/cobra" +) + +func AuthcodeTokenArgumentValidation(cfg client.Config, args []string, port int) error { + if err := EnsureActiveTarget(cfg); err != nil { + return err + } + if len(args) < 1 { + return MissingArgumentError("client_id") + } + return nil +} + +func SaveContext(context client.ClientContext, log cli.Logger) error { + c := GetSavedConfig() + err := c.AddContext(context) + if err != nil { + return err + } + err = config.Write(c) + if err != nil { + return err + } + log.Robots(context.Token.AccessToken) + return nil +} + +func AuthcodeTokenCommandRun(doneRunning chan bool, clientID string, authCodeImp cli.ClientImpersonator, log cli.Logger) { + authCodeImp.Start() + authCodeImp.Authorize() + token := <-authCodeImp.Done() + err := SaveContext(client.ClientContext{ + ClientID: clientID, + Token: token, + }, log) + if err != nil { + log.Errorf("Failed to save context: %v", err) + } + doneRunning <- true +} + +func refreshContext(contextName string, cfg client.Config, log cli.Logger) error { + context, err := cfg.GetContext(contextName) + if err != nil { + return err + } + refreshClient := client.RefreshTokenClient{ + ClientID: context.ClientID, + } + token, err := refreshClient.RequestToken(HTTPClient(), cfg, context.Token.RefreshToken) + if err != nil { + return err + } + context.Token = token + return SaveContext(context, log) +} + +var tokenCmd = &cobra.Command{ + Use: "token", + Short: "Configure and view tokens", +} + +var getAuthcodeToken = &cobra.Command{ + Use: "get CLIENT_ID --port REDIRECT_URI_PORT", + Short: "Obtain a token for the specified CLIENT_ID", + PreRun: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + NotifyValidationErrors(AuthcodeTokenArgumentValidation(cfg, args, port), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + done := make(chan bool) + cfg := GetSavedConfig() + if exists := cfg.GetActiveTarget().ClientContextExists(args[0]); exists && !force { + if val, err := cfg.GetContext(args[0]); err != nil { + NotifyErrorsWithRetry(err, log) + } else { + if time.Unix(val.Token.ExpiresAt, 0).Sub(time.Now()) >= time.Minute*5 { + log.Robots(val.Token.AccessToken) + } else { + NotifyErrorsWithRetry(refreshContext(val.ClientID, cfg, log), log) + } + } + } else { + authCodeImp := cli.NewAuthcodeClientImpersonator(HTTPClient(), cfg, args[0], scope, port, log, open.Run) + go AuthcodeTokenCommandRun(done, args[0], authCodeImp, log) + <-done + } + }, +} + +func init() { + getAuthcodeToken.Flags().IntVarP(&port, "port", "p", 8080, "port on which to run local callback server") + getAuthcodeToken.Flags().StringVarP(&scope, "scope", "s", "openid offline_access", "comma-separated scopes to request in token") + getAuthcodeToken.Flags().BoolVarP(&force, "force", "f", false, "Forces a new token") + tokenCmd.AddCommand(getAuthcodeToken) + RootCmd.AddCommand(tokenCmd) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..716966d --- /dev/null +++ b/config/config.go @@ -0,0 +1,51 @@ +package config + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "path" + + "github.com/imduffy15/token-cli/client" +) + +func Dir() string { + return path.Join(userHomeDir(), ".token-cli") +} + +func Path() string { + return path.Join(Dir(), "config.json") +} + +func Read() client.Config { + c := client.NewConfig() + + data, err := ioutil.ReadFile(Path()) + if err != nil { + fmt.Print(fmt.Errorf(err.Error())) + return c + } + + err = json.Unmarshal(data, &c) + if err != nil { + fmt.Print(fmt.Errorf(err.Error())) + return c + } + + return c +} + +func Write(c client.Config) error { + err := makeDirectory() + if err != nil { + return err + } + + data, err := json.Marshal(c) + if err != nil { + return err + } + + path := Path() + return ioutil.WriteFile(path, data, 0600) +} diff --git a/config/config_unix.go b/config/config_unix.go new file mode 100644 index 0000000..b0165e3 --- /dev/null +++ b/config/config_unix.go @@ -0,0 +1,15 @@ +// +build !windows + +package config + +import ( + "os" +) + +func userHomeDir() string { + return os.Getenv("HOME") +} + +func makeDirectory() error { + return os.MkdirAll(Dir(), 0755) +} diff --git a/config/config_win.go b/config/config_win.go new file mode 100644 index 0000000..db8b9ac --- /dev/null +++ b/config/config_win.go @@ -0,0 +1,37 @@ +// +build windows + +package config + +import ( + "os" + "syscall" +) + +func userHomeDir() string { + home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home +} + +func makeDirectory() error { + dir := ConfigDir() + + err := os.MkdirAll(dir, 0755) + if err != nil { + return err + } + + p, err := syscall.UTF16PtrFromString(dir) + if err != nil { + return err + } + + attrs, err := syscall.GetFileAttributes(p) + if err != nil { + return err + } + + return syscall.SetFileAttributes(p, attrs|syscall.FILE_ATTRIBUTE_HIDDEN) +} diff --git a/help/context.go b/help/context.go new file mode 100644 index 0000000..5782f74 --- /dev/null +++ b/help/context.go @@ -0,0 +1,9 @@ +package help + +func Context() string { + return `A context represents a previously fetched access token and associated metadata +such as the scopes that token contains. The token CLI caches these results on a +local file so that they may be used when issuing requests that require an +Authorization header. +` +} diff --git a/help/root.go b/help/root.go new file mode 100644 index 0000000..1040944 --- /dev/null +++ b/help/root.go @@ -0,0 +1,7 @@ +package help + +import "fmt" + +func Root(version string) string { + return fmt.Sprintf(`Token Command Line Interface, version %v`, version) +} diff --git a/images/walkthrough.gif b/images/walkthrough.gif new file mode 100644 index 0000000..66748c7 Binary files /dev/null and b/images/walkthrough.gif differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..030cb23 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/imduffy15/token-cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/utils/styling.go b/utils/styling.go new file mode 100644 index 0000000..99bb999 --- /dev/null +++ b/utils/styling.go @@ -0,0 +1,7 @@ +package utils + +import "github.com/fatih/color" + +var Emphasize = color.New(color.FgCyan, color.Bold).SprintFunc() +var Red = color.New(color.FgRed).SprintFunc() +var Green = color.New(color.FgGreen).SprintFunc() diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..a3f3c87 --- /dev/null +++ b/version/version.go @@ -0,0 +1,8 @@ +package version + +var Version string +var Commit string + +func VersionString() string { + return Version + " " + Commit +}