Skip to content

Commit fcfdd7b

Browse files
committed
auth: add support for oauth device-code login
This commit adds support for the oauth [device-code](https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow) login flow when authenticating against the official registry. This is achieved by adding `cli/internal/oauth`, which contains code to manage interacting with the Docker OAuth tenant (`login.docker.com`), including launching the device-code flow, refreshing access using the refresh-token, and logging out. The `OAuthManager` introduced here is also made available through the `command.Cli` interface method `OAuthManager()`. In order to maintain compatibility with any clients manually accessing the credentials through `~/.docker/config.json` or via credential helpers, the added `OAuthManager` uses the retrieved access token to automatically generate a PAT with Hub, and store that in the credentials. Signed-off-by: Laura Brehm <laurabrehm@hey.com>
1 parent 211a540 commit fcfdd7b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+11736
-7
lines changed

cli/command/registry/login.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/docker/cli/cli/command"
1111
"github.com/docker/cli/cli/command/completion"
1212
configtypes "github.com/docker/cli/cli/config/types"
13+
"github.com/docker/cli/cli/internal/oauth/manager"
1314
registrytypes "github.com/docker/docker/api/types/registry"
1415
"github.com/docker/docker/client"
1516
"github.com/docker/docker/errdefs"
@@ -100,9 +101,19 @@ func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) err
100101
response, err = loginWithCredStoreCreds(ctx, dockerCli, &authConfig)
101102
}
102103
if err != nil || authConfig.Username == "" || authConfig.Password == "" {
103-
err = command.ConfigureAuth(ctx, dockerCli, opts.user, opts.password, &authConfig, isDefaultRegistry)
104-
if err != nil {
105-
return err
104+
if isDefaultRegistry && opts.user == "" && opts.password == "" {
105+
// todo(laurazard: clean this up
106+
store := dockerCli.ConfigFile().GetCredentialsStore(serverAddress)
107+
oauthAuthConfig, err := manager.NewManager(store).LoginDevice(ctx, dockerCli.Err())
108+
if err != nil {
109+
return err
110+
}
111+
authConfig = registrytypes.AuthConfig(*oauthAuthConfig)
112+
} else {
113+
err = command.ConfigureAuth(ctx, dockerCli, opts.user, opts.password, &authConfig, isDefaultRegistry)
114+
if err != nil {
115+
return err
116+
}
106117
}
107118

108119
response, err = clnt.RegistryLogin(ctx, authConfig)

cli/command/registry/login_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,9 @@ func TestLoginTermination(t *testing.T) {
213213

214214
runErr := make(chan error)
215215
go func() {
216-
runErr <- runLogin(ctx, cli, loginOptions{})
216+
runErr <- runLogin(ctx, cli, loginOptions{
217+
user: "test-user",
218+
})
217219
}()
218220

219221
// Let the prompt get canceled by the context

cli/command/registry/logout.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/docker/cli/cli"
88
"github.com/docker/cli/cli/command"
99
"github.com/docker/cli/cli/config/credentials"
10+
"github.com/docker/cli/cli/internal/oauth/manager"
1011
"github.com/docker/docker/registry"
1112
"github.com/spf13/cobra"
1213
)
@@ -34,7 +35,7 @@ func NewLogoutCommand(dockerCli command.Cli) *cobra.Command {
3435
return cmd
3536
}
3637

37-
func runLogout(_ context.Context, dockerCli command.Cli, serverAddress string) error {
38+
func runLogout(ctx context.Context, dockerCli command.Cli, serverAddress string) error {
3839
var isDefaultRegistry bool
3940

4041
if serverAddress == "" {
@@ -53,6 +54,13 @@ func runLogout(_ context.Context, dockerCli command.Cli, serverAddress string) e
5354
regsToLogout = append(regsToLogout, hostnameAddress, "http://"+hostnameAddress, "https://"+hostnameAddress)
5455
}
5556

57+
if isDefaultRegistry {
58+
store := dockerCli.ConfigFile().GetCredentialsStore(registry.IndexServer)
59+
if err := manager.NewManager(store).Logout(ctx); err != nil {
60+
fmt.Fprintf(dockerCli.Err(), "WARNING: %v\n", err)
61+
}
62+
}
63+
5664
fmt.Fprintf(dockerCli.Out(), "Removing login credentials for %s\n", hostnameAddress)
5765
errs := make(map[string]error)
5866
for _, r := range regsToLogout {

cli/internal/oauth/api/api.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
2+
//go:build go1.21
3+
4+
package api
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"io"
12+
"net/http"
13+
"net/url"
14+
"runtime"
15+
"strings"
16+
"time"
17+
18+
"github.com/docker/cli/cli/version"
19+
)
20+
21+
type OAuthAPI interface {
22+
GetDeviceCode(ctx context.Context, audience string) (State, error)
23+
WaitForDeviceToken(ctx context.Context, state State) (TokenResponse, error)
24+
RevokeToken(ctx context.Context, refreshToken string) error
25+
GetAutoPAT(ctx context.Context, audience string, res TokenResponse) (string, error)
26+
}
27+
28+
// API represents API interactions with Auth0.
29+
type API struct {
30+
// TenantURL is the base used for each request to Auth0.
31+
TenantURL string
32+
// ClientID is the client ID for the application to auth with the tenant.
33+
ClientID string
34+
// Scopes are the scopes that are requested during the device auth flow.
35+
Scopes []string
36+
}
37+
38+
// TokenResponse represents the response of the /oauth/token route.
39+
type TokenResponse struct {
40+
AccessToken string `json:"access_token"`
41+
IDToken string `json:"id_token"`
42+
RefreshToken string `json:"refresh_token"`
43+
Scope string `json:"scope"`
44+
ExpiresIn int `json:"expires_in"`
45+
TokenType string `json:"token_type"`
46+
Error *string `json:"error,omitempty"`
47+
ErrorDescription string `json:"error_description,omitempty"`
48+
}
49+
50+
var ErrTimeout = errors.New("timed out waiting for device token")
51+
52+
// GetDeviceCode initiates the device-code auth flow with the tenant.
53+
// The state returned contains the device code that the user must use to
54+
// authenticate, as well as the URL to visit, etc.
55+
func (a API) GetDeviceCode(ctx context.Context, audience string) (State, error) {
56+
data := url.Values{
57+
"client_id": {a.ClientID},
58+
"audience": {audience},
59+
"scope": {strings.Join(a.Scopes, " ")},
60+
}
61+
62+
deviceCodeURL := a.TenantURL + "/oauth/device/code"
63+
resp, err := postForm(ctx, deviceCodeURL, strings.NewReader(data.Encode()))
64+
if err != nil {
65+
return State{}, err
66+
}
67+
defer func() {
68+
_ = resp.Body.Close()
69+
}()
70+
71+
if resp.StatusCode != http.StatusOK {
72+
return State{}, tryDecodeOAuthError(resp)
73+
}
74+
75+
var state State
76+
err = json.NewDecoder(resp.Body).Decode(&state)
77+
if err != nil {
78+
return state, fmt.Errorf("failed to get device code: %w", err)
79+
}
80+
81+
return state, nil
82+
}
83+
84+
func tryDecodeOAuthError(resp *http.Response) error {
85+
var body map[string]any
86+
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil {
87+
if errorDescription, ok := body["error_description"].(string); ok {
88+
return errors.New(errorDescription)
89+
}
90+
}
91+
return errors.New("unexpected response from tenant: " + resp.Status)
92+
}
93+
94+
// WaitForDeviceToken polls the tenant to get access/refresh tokens for the user.
95+
// This should be called after GetDeviceCode, and will block until the user has
96+
// authenticated or we have reached the time limit for authenticating (based on
97+
// the response from GetDeviceCode).
98+
func (a API) WaitForDeviceToken(ctx context.Context, state State) (TokenResponse, error) {
99+
ticker := time.NewTicker(state.IntervalDuration())
100+
defer ticker.Stop()
101+
timeout := time.After(state.ExpiryDuration())
102+
103+
for {
104+
select {
105+
case <-ctx.Done():
106+
return TokenResponse{}, ctx.Err()
107+
case <-ticker.C:
108+
res, err := a.getDeviceToken(ctx, state)
109+
if err != nil {
110+
return res, err
111+
}
112+
113+
if res.Error != nil {
114+
if *res.Error == "authorization_pending" {
115+
continue
116+
}
117+
118+
return res, errors.New(res.ErrorDescription)
119+
}
120+
121+
return res, nil
122+
case <-timeout:
123+
return TokenResponse{}, ErrTimeout
124+
}
125+
}
126+
}
127+
128+
// getToken calls the token endpoint of Auth0 and returns the response.
129+
func (a API) getDeviceToken(ctx context.Context, state State) (TokenResponse, error) {
130+
data := url.Values{
131+
"client_id": {a.ClientID},
132+
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
133+
"device_code": {state.DeviceCode},
134+
}
135+
oauthTokenURL := a.TenantURL + "/oauth/token"
136+
137+
resp, err := postForm(ctx, oauthTokenURL, strings.NewReader(data.Encode()))
138+
if err != nil {
139+
return TokenResponse{}, fmt.Errorf("failed to get tokens: %w", err)
140+
}
141+
defer func() {
142+
_ = resp.Body.Close()
143+
}()
144+
145+
// this endpoint returns a 403 with an `authorization_pending` error until the
146+
// user has authenticated, so we don't check the status code here and instead
147+
// decode the response and check for the error.
148+
var res TokenResponse
149+
err = json.NewDecoder(resp.Body).Decode(&res)
150+
if err != nil {
151+
return res, fmt.Errorf("failed to decode response: %w", err)
152+
}
153+
154+
return res, nil
155+
}
156+
157+
// RevokeToken revokes a refresh token with the tenant so that it can no longer
158+
// be used to get new tokens.
159+
func (a API) RevokeToken(ctx context.Context, refreshToken string) error {
160+
data := url.Values{
161+
"client_id": {a.ClientID},
162+
"token": {refreshToken},
163+
}
164+
165+
revokeURL := a.TenantURL + "/oauth/revoke"
166+
resp, err := postForm(ctx, revokeURL, strings.NewReader(data.Encode()))
167+
if err != nil {
168+
return err
169+
}
170+
defer func() {
171+
_ = resp.Body.Close()
172+
}()
173+
174+
if resp.StatusCode != http.StatusOK {
175+
return tryDecodeOAuthError(resp)
176+
}
177+
178+
return nil
179+
}
180+
181+
func postForm(ctx context.Context, reqURL string, data io.Reader) (*http.Response, error) {
182+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, data)
183+
if err != nil {
184+
return nil, err
185+
}
186+
187+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
188+
cliVersion := strings.ReplaceAll(version.Version, ".", "_")
189+
req.Header.Set("User-Agent", fmt.Sprintf("docker-cli:%s:%s-%s", cliVersion, runtime.GOOS, runtime.GOARCH))
190+
191+
return http.DefaultClient.Do(req)
192+
}
193+
194+
func (a API) GetAutoPAT(ctx context.Context, audience string, res TokenResponse) (string, error) {
195+
patURL := audience + "/v2/access-tokens/desktop-generate"
196+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, patURL, nil)
197+
if err != nil {
198+
return "", err
199+
}
200+
201+
req.Header.Set("Authorization", "Bearer "+res.AccessToken)
202+
req.Header.Set("Content-Type", "application/json")
203+
resp, err := http.DefaultClient.Do(req)
204+
if err != nil {
205+
return "", err
206+
}
207+
defer func() {
208+
_ = resp.Body.Close()
209+
}()
210+
211+
if resp.StatusCode != http.StatusCreated {
212+
return "", fmt.Errorf("unexpected response from Hub: %s", resp.Status)
213+
}
214+
215+
var response patGenerateResponse
216+
err = json.NewDecoder(resp.Body).Decode(&response)
217+
if err != nil {
218+
return "", err
219+
}
220+
221+
return response.Data.Token, nil
222+
}
223+
224+
type patGenerateResponse struct {
225+
Data struct {
226+
Token string `json:"token"`
227+
}
228+
}

0 commit comments

Comments
 (0)