| // Copyright 2015 The LUCI Authors. |
| // |
| // 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. |
| |
| // Package authcli implements authentication related flags parsing and CLI |
| // subcommands. |
| // |
| // It can be used from CLI tools that want customize authentication |
| // configuration from the command line. |
| // |
| // Minimal example of using flags parsing: |
| // |
| // |
| // authFlags := authcli.Flags{} |
| // defaults := ... // prepare default auth.Options |
| // authFlags.Register(flag.CommandLine, defaults) |
| // flag.Parse() |
| // opts, err := authFlags.Options() |
| // if err != nil { |
| // // handle error |
| // } |
| // authenticator := auth.NewAuthenticator(ctx, auth.SilentLogin, opts) |
| // httpClient, err := authenticator.Client() |
| // if err != nil { |
| // // handle error |
| // } |
| // |
| // |
| // This assumes that either a service account credentials are used (passed via |
| // -service-account-json), or the user has previously ran "login" subcommand and |
| // their refresh token is already cached. In any case, there will be no |
| // interaction with the user (this is what auth.SilentLogin means): if there |
| // are no cached token, authenticator.Client will return auth.ErrLoginRequired. |
| // |
| // Interaction with the user happens only in "login" subcommand. This subcommand |
| // (as well as a bunch of other related commands) can be added to any |
| // subcommands.Application. |
| // |
| // While it will work with any subcommand.Application, it uses |
| // luci-go/common/cli.GetContext() to grab a context for logging, so callers |
| // should prefer using cli.Application for hosting auth subcommands and making |
| // the context. This ensures consistent logging style between all subcommands |
| // of a CLI application: |
| // |
| // |
| // import ( |
| // ... |
| // "github.com/luci/luci-go/client/authcli" |
| // "github.com/luci/luci-go/common/cli" |
| // ) |
| // |
| // func GetApplication(defaultAuthOpts auth.Options) *cli.Application { |
| // return &cli.Application{ |
| // Name: "app_name", |
| // |
| // Context: func(ctx context.Context) context.Context { |
| // ... configure logging, etc. ... |
| // return ctx |
| // }, |
| // |
| // Commands: []*subcommands.Command{ |
| // authcli.SubcommandInfo(defaultAuthOpts, "auth-info", false), |
| // authcli.SubcommandLogin(defaultAuthOpts, "auth-login", false), |
| // authcli.SubcommandLogout(defaultAuthOpts, "auth-logout", false), |
| // ... |
| // }, |
| // } |
| // } |
| // |
| // func main() { |
| // defaultAuthOpts := ... |
| // app := GetApplication(defaultAuthOpts) |
| // os.Exit(subcommands.Run(app, nil)) |
| // } |
| package authcli |
| |
| import ( |
| "encoding/json" |
| "flag" |
| "fmt" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "sort" |
| "strings" |
| "time" |
| |
| "github.com/maruel/subcommands" |
| "golang.org/x/net/context" |
| |
| "github.com/luci/luci-go/common/auth" |
| "github.com/luci/luci-go/common/auth/localauth" |
| "github.com/luci/luci-go/common/cli" |
| "github.com/luci/luci-go/common/gcloud/googleoauth" |
| "github.com/luci/luci-go/common/logging" |
| "github.com/luci/luci-go/common/system/exitcode" |
| "github.com/luci/luci-go/lucictx" |
| ) |
| |
| // CommandParams specifies various parameters for a subcommand. |
| type CommandParams struct { |
| Name string // name of the subcommand. |
| Advanced bool // subcommands should treat this as an 'advanced' command |
| |
| AuthOptions auth.Options // default auth options. |
| |
| // ScopesFlag specifies if -scope flag must be registered. |
| // AuthOptions.Scopes is used as a default value. |
| // If it is empty, defaults to "https://www.googleapis.com/auth/userinfo.email". |
| ScopesFlag bool |
| } |
| |
| // Flags defines command line flags related to authentication. |
| type Flags struct { |
| defaults auth.Options |
| serviceAccountJSON string |
| scopes string |
| registerScopesFlag bool |
| } |
| |
| // Register adds auth related flags to a FlagSet. |
| func (fl *Flags) Register(f *flag.FlagSet, defaults auth.Options) { |
| fl.defaults = defaults |
| f.StringVar(&fl.serviceAccountJSON, "service-account-json", fl.defaults.ServiceAccountJSONPath, "Path to JSON file with service account credentials to use.") |
| if fl.registerScopesFlag { |
| defaultScopes := strings.Join(defaults.Scopes, " ") |
| if defaultScopes == "" { |
| defaultScopes = auth.OAuthScopeEmail |
| } |
| f.StringVar(&fl.scopes, "scopes", defaultScopes, "space-separated OAuth 2.0 scopes") |
| } |
| } |
| |
| // Options return instance of auth.Options struct with values set accordingly to |
| // parsed command line flags. |
| func (fl *Flags) Options() (auth.Options, error) { |
| opts := fl.defaults |
| opts.ServiceAccountJSONPath = fl.serviceAccountJSON |
| if fl.registerScopesFlag { |
| opts.Scopes = strings.Split(fl.scopes, " ") |
| sort.Strings(opts.Scopes) |
| } |
| return opts, nil |
| } |
| |
| // Process exit codes for subcommands. |
| const ( |
| ExitCodeSuccess = iota |
| ExitCodeNoValidToken |
| ExitCodeInvalidInput |
| ExitCodeInternalError |
| ExitCodeBadLogin |
| ) |
| |
| type commandRunBase struct { |
| subcommands.CommandRunBase |
| flags Flags |
| params *CommandParams |
| } |
| |
| func (c *commandRunBase) registerBaseFlags() { |
| c.flags.registerScopesFlag = c.params.ScopesFlag |
| c.flags.Register(&c.Flags, c.params.AuthOptions) |
| } |
| |
| //////////////////////////////////////////////////////////////////////////////// |
| |
| // SubcommandLogin returns subcommands.Command that can be used to perform |
| // interactive login. |
| func SubcommandLogin(opts auth.Options, name string, advanced bool) *subcommands.Command { |
| return SubcommandLoginWithParams(CommandParams{Name: name, Advanced: advanced, AuthOptions: opts}) |
| } |
| |
| // SubcommandLoginWithParams returns subcommands.Command that can be used to |
| // perform interactive login. |
| func SubcommandLoginWithParams(params CommandParams) *subcommands.Command { |
| return &subcommands.Command{ |
| Advanced: params.Advanced, |
| UsageLine: params.Name, |
| ShortDesc: "performs interactive login flow", |
| LongDesc: "Performs interactive login flow and caches obtained credentials", |
| CommandRun: func() subcommands.CommandRun { |
| c := &loginRun{} |
| c.params = ¶ms |
| c.registerBaseFlags() |
| return c |
| }, |
| } |
| } |
| |
| type loginRun struct { |
| commandRunBase |
| } |
| |
| func (c *loginRun) Run(a subcommands.Application, _ []string, env subcommands.Env) int { |
| opts, err := c.flags.Options() |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInvalidInput |
| } |
| ctx := cli.GetContext(a, c, env) |
| authenticator := auth.NewAuthenticator(ctx, auth.InteractiveLogin, opts) |
| if err := authenticator.Login(); err != nil { |
| fmt.Fprintf(os.Stderr, "Login failed: %s\n", err.Error()) |
| return ExitCodeBadLogin |
| } |
| return checkToken(ctx, authenticator) |
| } |
| |
| //////////////////////////////////////////////////////////////////////////////// |
| |
| // SubcommandLogout returns subcommands.Command that can be used to purge cached |
| // credentials. |
| func SubcommandLogout(opts auth.Options, name string, advanced bool) *subcommands.Command { |
| return SubcommandLogoutWithParams(CommandParams{Name: name, Advanced: advanced, AuthOptions: opts}) |
| } |
| |
| // SubcommandLogoutWithParams returns subcommands.Command that can be used to purge cached |
| // credentials. |
| func SubcommandLogoutWithParams(params CommandParams) *subcommands.Command { |
| return &subcommands.Command{ |
| Advanced: params.Advanced, |
| UsageLine: params.Name, |
| ShortDesc: "removes cached credentials", |
| LongDesc: "Removes cached credentials from the disk", |
| CommandRun: func() subcommands.CommandRun { |
| c := &logoutRun{} |
| c.params = ¶ms |
| c.registerBaseFlags() |
| return c |
| }, |
| } |
| } |
| |
| type logoutRun struct { |
| commandRunBase |
| } |
| |
| func (c *logoutRun) Run(a subcommands.Application, args []string, env subcommands.Env) int { |
| opts, err := c.flags.Options() |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInvalidInput |
| } |
| ctx := cli.GetContext(a, c, env) |
| err = auth.NewAuthenticator(ctx, auth.SilentLogin, opts).PurgeCredentialsCache() |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInternalError |
| } |
| return ExitCodeSuccess |
| } |
| |
| //////////////////////////////////////////////////////////////////////////////// |
| |
| // SubcommandInfo returns subcommand.Command that can be used to print current |
| // cached credentials. |
| func SubcommandInfo(opts auth.Options, name string, advanced bool) *subcommands.Command { |
| return SubcommandInfoWithParams(CommandParams{Name: name, Advanced: advanced, AuthOptions: opts}) |
| } |
| |
| // SubcommandInfoWithParams returns subcommand.Command that can be used to print |
| // current cached credentials. |
| func SubcommandInfoWithParams(params CommandParams) *subcommands.Command { |
| return &subcommands.Command{ |
| Advanced: params.Advanced, |
| UsageLine: params.Name, |
| ShortDesc: "prints an email address associated with currently cached token", |
| LongDesc: "Prints an email address associated with currently cached token", |
| CommandRun: func() subcommands.CommandRun { |
| c := &infoRun{} |
| c.params = ¶ms |
| c.registerBaseFlags() |
| return c |
| }, |
| } |
| } |
| |
| type infoRun struct { |
| commandRunBase |
| } |
| |
| func (c *infoRun) Run(a subcommands.Application, args []string, env subcommands.Env) int { |
| opts, err := c.flags.Options() |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInvalidInput |
| } |
| ctx := cli.GetContext(a, c, env) |
| authenticator := auth.NewAuthenticator(ctx, auth.SilentLogin, opts) |
| switch _, err := authenticator.Client(); { |
| case err == auth.ErrLoginRequired: |
| fmt.Fprintln(os.Stderr, "Not logged in") |
| return ExitCodeNoValidToken |
| case err != nil: |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInternalError |
| } |
| return checkToken(ctx, authenticator) |
| } |
| |
| //////////////////////////////////////////////////////////////////////////////// |
| |
| // SubcommandToken returns subcommand.Command that can be used to print current |
| // access token. |
| func SubcommandToken(opts auth.Options, name string) *subcommands.Command { |
| return SubcommandTokenWithParams(CommandParams{Name: name, AuthOptions: opts}) |
| } |
| |
| // SubcommandTokenWithParams returns subcommand.Command that can be used to |
| // print current access token. |
| func SubcommandTokenWithParams(params CommandParams) *subcommands.Command { |
| return &subcommands.Command{ |
| Advanced: params.Advanced, |
| UsageLine: params.Name, |
| ShortDesc: "prints an access token", |
| LongDesc: "Generates an access token if requested and prints it.", |
| CommandRun: func() subcommands.CommandRun { |
| c := &tokenRun{} |
| c.params = ¶ms |
| c.registerBaseFlags() |
| c.Flags.DurationVar( |
| &c.lifetime, "lifetime", time.Minute, |
| "Minimum token lifetime. If existing token expired and refresh token or service account is not present, returns nothing.", |
| ) |
| c.Flags.StringVar( |
| &c.jsonOutput, "json-output", "", |
| "Destination file to print token and expiration time in JSON. \"-\" for standard output.") |
| return c |
| }, |
| } |
| } |
| |
| type tokenRun struct { |
| commandRunBase |
| lifetime time.Duration |
| jsonOutput string |
| } |
| |
| func (c *tokenRun) Run(a subcommands.Application, args []string, env subcommands.Env) int { |
| opts, err := c.flags.Options() |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInvalidInput |
| } |
| if c.lifetime > 45*time.Minute { |
| fmt.Fprintln(os.Stderr, "lifetime cannot exceed 45m") |
| return ExitCodeInvalidInput |
| } |
| |
| ctx := cli.GetContext(a, c, env) |
| authenticator := auth.NewAuthenticator(ctx, auth.SilentLogin, opts) |
| token, err := authenticator.GetAccessToken(c.lifetime) |
| if err != nil { |
| if err == auth.ErrLoginRequired { |
| fmt.Fprintln(os.Stderr, "Not logged in. Run 'authutil login'.") |
| } else { |
| fmt.Fprintln(os.Stderr, err) |
| } |
| return ExitCodeNoValidToken |
| } |
| if token.AccessToken == "" { |
| return ExitCodeNoValidToken |
| } |
| |
| if c.jsonOutput == "" { |
| fmt.Println(token.AccessToken) |
| } else { |
| out := os.Stdout |
| if c.jsonOutput != "-" { |
| out, err = os.Create(c.jsonOutput) |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInvalidInput |
| } |
| defer out.Close() |
| } |
| data := struct { |
| Token string `json:"token"` |
| Expiry int64 `json:"expiry"` |
| }{token.AccessToken, token.Expiry.Unix()} |
| if err = json.NewEncoder(out).Encode(data); err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInternalError |
| } |
| } |
| return ExitCodeSuccess |
| } |
| |
| //////////////////////////////////////////////////////////////////////////////// |
| |
| // SubcommandContext returns subcommand.Command that can be used to setup new |
| // LUCI authentication context for a process tree. |
| // |
| // This is an advanced command and shouldn't be usually embedded into binaries. |
| // It is primarily used by 'authutil' program. It exists to simplify development |
| // and debugging of programs that rely on LUCI authentication context. |
| func SubcommandContext(opts auth.Options, name string) *subcommands.Command { |
| return SubcommandContextWithParams(CommandParams{Name: name, AuthOptions: opts}) |
| } |
| |
| // SubcommandContextWithParams returns subcommand.Command that can be used to |
| // setup new LUCI authentication context for a process tree. |
| func SubcommandContextWithParams(params CommandParams) *subcommands.Command { |
| return &subcommands.Command{ |
| Advanced: params.Advanced, |
| UsageLine: fmt.Sprintf("%s [flags] [--] <bin> [args]", params.Name), |
| ShortDesc: "sets up new LUCI local auth context and launches a process in it", |
| LongDesc: "Starts local RPC auth server, prepares LUCI_CONTEXT, launches a process in this environment.", |
| CommandRun: func() subcommands.CommandRun { |
| c := &contextRun{} |
| c.params = ¶ms |
| c.registerBaseFlags() |
| c.Flags.StringVar( |
| &c.actAs, "act-as-service-account", "", |
| "Act as a given service account (caller must have iam.serviceAccountActor role).") |
| c.Flags.BoolVar(&c.verbose, "verbose", false, "More logging") |
| return c |
| }, |
| } |
| } |
| |
| type contextRun struct { |
| commandRunBase |
| |
| actAs string |
| verbose bool |
| } |
| |
| func (c *contextRun) Run(a subcommands.Application, args []string, env subcommands.Env) int { |
| ctx := cli.GetContext(a, c, env) |
| |
| opts, err := c.flags.Options() |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeInvalidInput |
| } |
| opts.ActAsServiceAccount = c.actAs |
| if c.verbose { |
| ctx = logging.SetLevel(ctx, logging.Debug) |
| } |
| |
| // 'args' specify a subcommand to run. Prepare *exec.Cmd. |
| if len(args) == 0 { |
| fmt.Fprintln(os.Stderr, "Specify a command to run:\n authutil context [flags] [--] <bin> [args]") |
| return ExitCodeInvalidInput |
| } |
| bin := args[0] |
| if filepath.Base(bin) == bin { |
| resolved, err := exec.LookPath(bin) |
| if err != nil { |
| fmt.Fprintf(os.Stderr, "Can't find %q in PATH\n", bin) |
| return ExitCodeInvalidInput |
| } |
| bin = resolved |
| } |
| cmd := &exec.Cmd{ |
| Path: bin, |
| Args: args, |
| Stdin: os.Stdin, |
| Stdout: os.Stdout, |
| Stderr: os.Stderr, |
| } |
| |
| // First create an authenticator for requested options and make sure we have |
| // required refresh tokens (if any) by asking user to login. |
| if opts.Method == auth.AutoSelectMethod { |
| opts.Method = auth.SelectBestMethod(ctx, opts) |
| } |
| authenticator := auth.NewAuthenticator(ctx, auth.InteractiveLogin, opts) |
| err = authenticator.CheckLoginRequired() |
| if err == auth.ErrLoginRequired { |
| fmt.Printf("Need to login first!\n\n") |
| err = authenticator.Login() |
| } |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeNoValidToken |
| } |
| |
| // Now that we have required tokens in the cache, we construct the token |
| // generator. |
| // |
| // Two cases here: |
| // 1) We are using options that specify service account private key or |
| // IAM-based authenticator (with IAM refresh token just initialized |
| // above). In this case we can mint tokens for any requested combination |
| // of scopes and can use NewFlexibleGenerator. |
| // 2) We are using options that specify some externally configured |
| // authenticator (like GCE metadata server, or a refresh token). In this |
| // case we have to use this specific authenticator for generating tokens. |
| var gen localauth.TokenGenerator |
| if auth.AllowsArbitraryScopes(ctx, opts) { |
| logging.Debugf(ctx, "Using flexible token generator: %s (acting as %q)", opts.Method, opts.ActAsServiceAccount) |
| gen, err = localauth.NewFlexibleGenerator(ctx, opts) |
| } else { |
| // An authenticator preconfigured with given list of scopes. |
| logging.Debugf(ctx, "Using rigid token generator: %s (scopes %s)", opts.Method, opts.Scopes) |
| gen, err = localauth.NewRigidGenerator(ctx, authenticator) |
| } |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| return ExitCodeNoValidToken |
| } |
| |
| // We currently always setup a context with one account (which is also |
| // default). To avoid confusion where it comes from, we name it 'authutil'. |
| // Most tools should not care how it is named, as long as it is specified as |
| // 'default_account_id' in LUCI_CONTEXT["local_auth"]. |
| srv := &localauth.Server{ |
| TokenGenerators: map[string]localauth.TokenGenerator{ |
| "authutil": gen, |
| }, |
| DefaultAccountID: "authutil", |
| } |
| |
| // Enter the environment with the local auth server. |
| err = localauth.WithLocalAuth(ctx, srv, func(ctx context.Context) error { |
| // Put the new LUCI_CONTEXT file, prepare cmd environ. |
| exported, err := lucictx.Export(ctx, "") |
| if err != nil { |
| logging.WithError(err).Errorf(ctx, "Failed to prepare LUCI_CONTEXT file") |
| return err |
| } |
| defer func() { |
| if err := exported.Close(); err != nil { |
| logging.WithError(err).Warningf(ctx, "Failed to remove LUCI_CONTEXT file") |
| } |
| }() |
| exported.SetInCmd(cmd) |
| |
| // Launch the process and wait for it to finish. |
| logging.Debugf(ctx, "Running %q", cmd.Args) |
| return cmd.Run() |
| }) |
| |
| // Return the subprocess exit code, if available. |
| switch code, hasCode := exitcode.Get(err); { |
| case err == nil: |
| return 0 |
| case hasCode: |
| return code |
| default: |
| return ExitCodeInternalError |
| } |
| } |
| |
| //////////////////////////////////////////////////////////////////////////////// |
| |
| // checkToken prints information about the token carried by the authenticator. |
| // |
| // Prints errors to stderr and returns corresponding process exit code. |
| func checkToken(ctx context.Context, a *auth.Authenticator) int { |
| // Grab the active access token. |
| tok, err := a.GetAccessToken(time.Minute) |
| if err != nil { |
| fmt.Fprintf(os.Stderr, "Can't grab an access token: %s\n", err) |
| return ExitCodeNoValidToken |
| } |
| |
| // Ask Google endpoint for details of the token. |
| info, err := googleoauth.GetTokenInfo(ctx, googleoauth.TokenInfoParams{ |
| AccessToken: tok.AccessToken, |
| }) |
| if err != nil { |
| fmt.Fprintf(os.Stderr, "Failed to call token info endpoint: %s\n", err) |
| if err == googleoauth.ErrBadToken { |
| return ExitCodeNoValidToken |
| } |
| return ExitCodeInternalError |
| } |
| |
| // Email is set only if the token has userinfo.email scope. |
| if info.Email != "" { |
| fmt.Printf("Logged in as %s.\n", info.Email) |
| } else { |
| fmt.Printf("Logged in as uid %q.\n", info.Sub) |
| } |
| fmt.Printf("OAuth token details:\n") |
| fmt.Printf(" Client ID: %s\n", info.Aud) |
| fmt.Printf(" Scopes:\n") |
| for _, scope := range strings.Split(info.Scope, " ") { |
| fmt.Printf(" %s\n", scope) |
| } |
| |
| return ExitCodeSuccess |
| } |