Skip to content

Commit

Permalink
Merge pull request #8425 from cli/wm/multi-account-ux-rebase
Browse files Browse the repository at this point in the history
Support multiple accounts on a single host
  • Loading branch information
williammartin committed Dec 7, 2023
2 parents cd2a6ad + c243f31 commit 54d56ca
Show file tree
Hide file tree
Showing 40 changed files with 4,112 additions and 852 deletions.
4 changes: 2 additions & 2 deletions api/http_client.go
Expand Up @@ -13,7 +13,7 @@ import (
)

type tokenGetter interface {
Token(string) (string, string)
ActiveToken(string) (string, string)
}

type HTTPClientOptions struct {
Expand Down Expand Up @@ -99,7 +99,7 @@ func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper
// If the host has changed during a redirect do not add the authentication token header.
if !redirectHostnameChange {
hostname := ghinstance.NormalizeHostname(getHost(req))
if token, _ := cfg.Token(hostname); token != "" {
if token, _ := cfg.ActiveToken(hostname); token != "" {
req.Header.Set(authorization, fmt.Sprintf("token %s", token))
}
}
Expand Down
2 changes: 1 addition & 1 deletion api/http_client_test.go
Expand Up @@ -265,7 +265,7 @@ func TestHTTPClientSanitizeControlCharactersC1(t *testing.T) {

type tinyConfig map[string]string

func (c tinyConfig) Token(host string) (string, string) {
func (c tinyConfig) ActiveToken(host string) (string, string) {
return c[fmt.Sprintf("%s:%s", host, "oauth_token")], "oauth_token"
}

Expand Down
9 changes: 9 additions & 0 deletions cmd/gh/main.go
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/config/migration"
"github.com/cli/cli/v2/internal/update"
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmd/root"
Expand Down Expand Up @@ -56,6 +57,14 @@ func mainRun() exitCode {

ctx := context.Background()

if cfg, err := cmdFactory.Config(); err == nil {
var m migration.MultiAccount
if err := cfg.Migrate(m); err != nil {
fmt.Fprintf(stderr, "failed to migrate configuration: %s\n", err)
return exitError
}
}

updateCtx, updateCancel := context.WithCancel(ctx)
defer updateCancel()
updateMessageChan := make(chan *update.ReleaseInfo)
Expand Down
211 changes: 211 additions & 0 deletions docs/multiple-accounts.md
@@ -0,0 +1,211 @@
# Multiple Accounts with the CLI - v2.40.0

Since its creation, `gh` has enforced a mapping of one account per host. Functionally, this meant that when targeting a
single host (e.g. github.com) each `auth login` would replace the token being used for API requests, and for git
operations when `gh` was configured as a git credential manager. Removing this limitation has been a [long requested
feature](https://github.com/cli/cli/issues/326), with many community members offering workarounds for a variety of use cases.
A particular shoutout to @gabe565 and his long term community support for https://github.com/gabe565/gh-profile in this space.

With the release of `v2.40.0`, `gh` has begun supporting multiple accounts for some use cases on github.com and
in GitHub Enterprise. We recognise that there are a number of missing quality of life features, and we've opted
not to address the use case of automatic account switching based on some context (e.g. `pwd`, `git remote`, etc).
However, we hope many of those using these custom solutions will now find it easier to obtain and update tokens (via the standard
OAuth flow rather than as a PAT), and to store them securely in the system keyring managed by `gh`.

We are by no means excluding these things from ever being native to `gh` but we wanted to ship this MVP and get more
feedback so that we can iterate on it with the community.

## What is in scope for this release?

The support for multiple accounts in this release is focused around `auth login` becoming additive in behaviour.
This allows for multiple accounts to be easily switched between using the new `auth switch` command. Switching the "active"
user for a host will swap the token used by `gh` for API requests, and for git operations when `gh` was configured as a
git credential manager.

We have extended the `auth logout` command to switch account where possible if the currently active user is the target
of the `logout`. Finally we have extended `auth token`, `auth switch`, and `auth logout` with a
`--user` flag. This new flag in combination with `--hostname` can be used to disambiguate accounts when running
non-interactively.

Here's an example usage. First, we can see that I have a single account `wilmartin_microsoft` logged in, and
`auth status` reports that this is the active account:

```
➜ gh auth status
github.com
✓ Logged in to github.com account wilmartin_microsoft (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
```

Running `auth login` and proceeding through the browser based OAuth flow as `williammartin`, we can see that
`auth status` now reports two accounts under `github.com`, and our new account is now marked as active.

```
➜ gh auth login
? What account do you want to log into? GitHub.com
? What is your preferred protocol for Git operations on this host? HTTPS
? How would you like to authenticate GitHub CLI? Login with a web browser
! First copy your one-time code: A1F4-3B3C
Press Enter to open github.com in your browser...
✓ Authentication complete.
- gh config set -h github.com git_protocol https
✓ Configured git protocol
✓ Logged in as williammartin
➜ gh auth status
github.com
✓ Logged in to github.com account williammartin (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
✓ Logged in to github.com account wilmartin_microsoft (keyring)
- Active account: false
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
```

Fetching our username from the API shows that our active token correctly corresponds to `williammartin`:

```
➜ gh api /user | jq .login
"williammartin"
```

Now we can easily switch accounts using `gh auth switch`, and hitting the API shows that the active token has been
changed:

```
➜ gh auth switch
✓ Switched active account for github.com to wilmartin_microsoft
➜ gh api /user | jq .login
"wilmartin_microsoft"
```

We can use `gh auth token --user` to get a specific token for a user (which should be handy for automated switching
solutions):

```
➜ GH_TOKEN=$(gh auth token --user williammartin) gh api /user | jq .login
"williammartin"
```

Finally, running `gh auth logout` presents a prompt when there are multiple choices for logout, and switches account
if there are any remaining logged into the host:

```
➜ gh auth logout
? What account do you want to log out of? wilmartin_microsoft (github.com)
✓ Logged out of github.com account wilmartin_microsoft
✓ Switched active account for github.com to williammartin
```

## What is out of scope for this release?

As mentioned above, we know that this only addreses some of the requests around supporting multiple accounts. While
these are not out of scope forever, for this release some of the big things we have intentionally not included are:
* Automatic account switching based on some context (e.g. `pwd`, `git remote`, etc)
* Automatic configuration of git config such as `user.name` and `user.email` when switching
* User level configuration e.g. `williammartin` uses `vim` but `wilmartin_microsoft` uses `emacs`

## What are some sharp edges in this release?

As in any MVP there are going to be some sharp edges that need to be smoothed out over time. Here are a list of known
sharp edges in this release.

### Data Migration

The trickiest piece of this work was that the `hosts.yml` file only supported a mapping of one-to-one in the host to
account relationship. Having persistent data on disk that required a schema change presented a compatability challenge
both backwards for those who use [`go-gh`](https://github.com/cli/go-gh/) outside of `gh`, and forward for `gh` itself
where we try to ensure that it's possible to use older versions in case we accidentally make a breaking change for users.

As such, from this release, running any command will attempt to migrate this data into a new format, and will
additionally add a `version` field into the `config.yml` to aid in our future maintainability. While we have tried
to maintain forward compatability (except in one edge case outlined below), and in the worst case you should be able
to remove these files and start from scratch, if you are concerned about the data in these files, we advise you to take
a backup.

#### Forward Compatability Exclusion

There is one known case using `--insecure-storage` that we don't maintain complete forward and backward compatability.
This occurs if you `auth login --insecure-storage`, upgrade to this release (which performs the data migration), run
`auth login --insecure-storage` again on an older release, then at some time later use `auth switch` to make this
account active. The symptom here would be usage of an older token (which may for example have different scopes).

This occurs because we will only perform the data migration once, moving the original insecure token to a place where
it would later be used by `auth switch`.

#### Immutable Config Users

Some of our users lean on tools to manage their application configuration in an immutable manner for example using
https://github.com/nix-community/home-manager. These users will hit an error when we attempt to persist the new
`version` field to the `config.yml`. They will need to ensure that the `home-manager` configuration scripts are updated
to add `version: 1`.

See https://github.com/nix-community/home-manager/issues/4744 for more details.

### Auth Refresh

Although this has always been possible, the multi account flow increases the likelihood of doing something surprising
with `auth refresh`. This command allows for a token to be updated with additional or fewer scopes. For example,
in the following example we add the `read:project` scope to the scopes for our currently active user `williammartin`,
and proceed through the OAuth browser flow as `williammartin`:

```
➜ gh auth refresh -s read:project
? What account do you want to refresh auth for? github.com
! First copy your one-time code: E79E-5FA2
Press Enter to open github.com in your browser...
✓ Authentication complete.
➜ gh auth status
github.com
✓ Logged in to github.com account williammartin (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'read:project', 'repo', 'workflow'
✓ Logged in to github.com account wilmartin_microsoft (keyring)
✓ Logged in to github.com account wilmartin_microsoft (keyring)
- Active account: false
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
```

However, what happens if I try to remove the `workflow` scope from my active user `williammartin` but proceed through
the OAuth browser flow as `wilmartin_microsoft`?

```
➜ gh auth refresh -r workflow
! First copy your one-time code: EEA3-091C
Press Enter to open github.com in your browser...
error refreshing credentials for williammartin, received credentials for wilmartin_microsoft, did you use the correct account in the browser?
```

When adding or removing scopes for a user, the CLI gets the scopes for the current token and then requests a new token with the requested amended scopes. Unfortunately, when we go through the account switcher flow as a different user, we end up getting a token for the wrong user with surprising scopes. We don't believe that starting and ending a `refresh` as different accounts is
a use case we wish to support and has the potential for misuse. As such, we have begun erroring in this case.

Note that a token has still been minted on the platform but `gh` will refuse to store it. We are investigating
alternative approaches with the platform team to put some better guardrails in place earlier in the flow.

### Account Switcher on GitHub Enterprise

When using `auth login` with github.com, if a user has multiple accounts in the browser, they should be presented
with an interstitial page that allows for proceeding as any of their accounts. However, for Device Control Flow OAuth
flows, this feature has not yet made it into GHES.

For the moment, if you have multiple accounts on GHES that you wish to log in as, you will need to ensure that you
are authenticated as the correct user in the browser before running `auth login`.
2 changes: 1 addition & 1 deletion internal/authflow/flow.go
Expand Up @@ -109,7 +109,7 @@ type cfg struct {
token string
}

func (c cfg) Token(hostname string) (string, string) {
func (c cfg) ActiveToken(hostname string) (string, string) {
return c.token, "oauth_token"
}

Expand Down

0 comments on commit 54d56ca

Please sign in to comment.