cli/internal/oauth: move to top-level "internal"

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn
2025-05-06 20:09:28 +02:00
parent b6059af164
commit 479c7add4d
9 changed files with 5 additions and 5 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/config/configfile"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/internal/oauth/manager"
"github.com/docker/cli/internal/oauth/manager"
"github.com/docker/cli/internal/tui"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/internal/oauth/manager"
"github.com/docker/cli/internal/oauth/manager"
"github.com/docker/docker/registry"
"github.com/spf13/cobra"
)
-261
View File
@@ -1,261 +0,0 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.23
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"runtime"
"strings"
"time"
"github.com/docker/cli/cli/version"
)
type OAuthAPI interface {
GetDeviceCode(ctx context.Context, audience string) (State, error)
WaitForDeviceToken(ctx context.Context, state State) (TokenResponse, error)
RevokeToken(ctx context.Context, refreshToken string) error
GetAutoPAT(ctx context.Context, audience string, res TokenResponse) (string, error)
}
// API represents API interactions with Auth0.
type API struct {
// TenantURL is the base used for each request to Auth0.
TenantURL string
// ClientID is the client ID for the application to auth with the tenant.
ClientID string
// Scopes are the scopes that are requested during the device auth flow.
Scopes []string
}
// TokenResponse represents the response of the /oauth/token route.
type TokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
Error *string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
var ErrTimeout = errors.New("timed out waiting for device token")
// GetDeviceCode initiates the device-code auth flow with the tenant.
// The state returned contains the device code that the user must use to
// authenticate, as well as the URL to visit, etc.
func (a API) GetDeviceCode(ctx context.Context, audience string) (State, error) {
data := url.Values{
"client_id": {a.ClientID},
"audience": {audience},
"scope": {strings.Join(a.Scopes, " ")},
}
deviceCodeURL := a.TenantURL + "/oauth/device/code"
resp, err := postForm(ctx, deviceCodeURL, strings.NewReader(data.Encode()))
if err != nil {
return State{}, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return State{}, tryDecodeOAuthError(resp)
}
var state State
err = json.NewDecoder(resp.Body).Decode(&state)
if err != nil {
return state, fmt.Errorf("failed to get device code: %w", err)
}
return state, nil
}
func tryDecodeOAuthError(resp *http.Response) error {
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil {
if errorDescription, ok := body["error_description"].(string); ok {
return errors.New(errorDescription)
}
}
return errors.New("unexpected response from tenant: " + resp.Status)
}
// WaitForDeviceToken polls the tenant to get access/refresh tokens for the user.
// This should be called after GetDeviceCode, and will block until the user has
// authenticated or we have reached the time limit for authenticating (based on
// the response from GetDeviceCode).
func (a API) WaitForDeviceToken(ctx context.Context, state State) (TokenResponse, error) {
// Ticker for polling tenant for login based on the interval
// specified by the tenant response.
ticker := time.NewTimer(state.IntervalDuration())
defer ticker.Stop()
// The tenant tells us for as long as we can poll it for credentials
// while the user logs in through their browser. Timeout if we don't get
// credentials within this period.
timeout := time.NewTimer(state.ExpiryDuration())
defer timeout.Stop()
for {
resetTimer(ticker, state.IntervalDuration())
select {
case <-ctx.Done():
// user canceled login
return TokenResponse{}, ctx.Err()
case <-ticker.C:
// tick, check for user login
res, err := a.getDeviceToken(ctx, state)
if err != nil {
if errors.Is(err, context.Canceled) {
// if the caller canceled the context, continue
// and let the select hit the ctx.Done() branch
continue
}
return TokenResponse{}, err
}
if res.Error != nil {
if *res.Error == "authorization_pending" {
continue
}
return res, errors.New(res.ErrorDescription)
}
return res, nil
case <-timeout.C:
// login timed out
return TokenResponse{}, ErrTimeout
}
}
}
// resetTimer is a helper function thatstops, drains and resets the timer.
// This is necessary in go versions <1.23, since the timer isn't stopped +
// the timer's channel isn't drained on timer.Reset.
// See: https://go-review.googlesource.com/c/go/+/568341
// FIXME: remove/simplify this after we update to go1.23
func resetTimer(t *time.Timer, d time.Duration) {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(d)
}
// getDeviceToken calls the token endpoint of Auth0 and returns the response.
func (a API) getDeviceToken(ctx context.Context, state State) (TokenResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
defer cancel()
data := url.Values{
"client_id": {a.ClientID},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {state.DeviceCode},
}
oauthTokenURL := a.TenantURL + "/oauth/token"
resp, err := postForm(ctx, oauthTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return TokenResponse{}, fmt.Errorf("failed to get tokens: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
// this endpoint returns a 403 with an `authorization_pending` error until the
// user has authenticated, so we don't check the status code here and instead
// decode the response and check for the error.
var res TokenResponse
err = json.NewDecoder(resp.Body).Decode(&res)
if err != nil {
return res, fmt.Errorf("failed to decode response: %w", err)
}
return res, nil
}
// RevokeToken revokes a refresh token with the tenant so that it can no longer
// be used to get new tokens.
func (a API) RevokeToken(ctx context.Context, refreshToken string) error {
data := url.Values{
"client_id": {a.ClientID},
"token": {refreshToken},
}
revokeURL := a.TenantURL + "/oauth/revoke"
resp, err := postForm(ctx, revokeURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return tryDecodeOAuthError(resp)
}
return nil
}
func postForm(ctx context.Context, reqURL string, data io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, data)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
cliVersion := strings.ReplaceAll(version.Version, ".", "_")
req.Header.Set("User-Agent", fmt.Sprintf("docker-cli:%s:%s-%s", cliVersion, runtime.GOOS, runtime.GOARCH))
return http.DefaultClient.Do(req)
}
func (API) GetAutoPAT(ctx context.Context, audience string, res TokenResponse) (string, error) {
patURL := audience + "/v2/access-tokens/desktop-generate"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, patURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+res.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("unexpected response from Hub: %s", resp.Status)
}
var response patGenerateResponse
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return "", err
}
return response.Data.Token, nil
}
type patGenerateResponse struct {
Data struct {
Token string `json:"token"`
}
}
-428
View File
@@ -1,428 +0,0 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"gotest.tools/v3/assert"
)
func TestGetDeviceCode(t *testing.T) {
t.Parallel()
t.Run("success", func(t *testing.T) {
t.Parallel()
var clientID, audience, scope, path string
expectedState := State{
DeviceCode: "aDeviceCode",
UserCode: "aUserCode",
VerificationURI: "aVerificationURI",
ExpiresIn: 60,
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
clientID = r.FormValue("client_id")
audience = r.FormValue("audience")
scope = r.FormValue("scope")
path = r.URL.Path
jsonState, err := json.Marshal(expectedState)
assert.NilError(t, err)
_, _ = w.Write(jsonState)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
state, err := api.GetDeviceCode(context.Background(), "anAudience")
assert.NilError(t, err)
assert.DeepEqual(t, expectedState, state)
assert.Equal(t, clientID, "aClientID")
assert.Equal(t, audience, "anAudience")
assert.Equal(t, scope, "bork meow")
assert.Equal(t, path, "/oauth/device/code")
})
t.Run("error w/ description", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
jsonState, err := json.Marshal(TokenResponse{
ErrorDescription: "invalid audience",
})
assert.NilError(t, err)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(jsonState)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
_, err := api.GetDeviceCode(context.Background(), "bad_audience")
assert.ErrorContains(t, err, "invalid audience")
})
t.Run("general error", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "an error", http.StatusInternalServerError)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
_, err := api.GetDeviceCode(context.Background(), "anAudience")
assert.ErrorContains(t, err, "unexpected response from tenant: 500 Internal Server Error")
})
t.Run("canceled context", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * time.Second)
http.Error(w, "an error", http.StatusInternalServerError)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(1 * time.Second)
cancel()
}()
_, err := api.GetDeviceCode(ctx, "anAudience")
assert.ErrorContains(t, err, "context canceled")
})
}
func TestWaitForDeviceToken(t *testing.T) {
t.Parallel()
t.Run("success", func(t *testing.T) {
t.Parallel()
expectedToken := TokenResponse{
AccessToken: "a-real-token",
IDToken: "",
RefreshToken: "the-refresh-token",
Scope: "",
ExpiresIn: 3600,
TokenType: "",
}
var respond atomic.Bool
go func() {
time.Sleep(5 * time.Second)
respond.Store(true)
}()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/oauth/token", r.URL.Path)
assert.Equal(t, r.FormValue("client_id"), "aClientID")
assert.Equal(t, r.FormValue("grant_type"), "urn:ietf:params:oauth:grant-type:device_code")
assert.Equal(t, r.FormValue("device_code"), "aDeviceCode")
if respond.Load() {
jsonState, err := json.Marshal(expectedToken)
assert.NilError(t, err)
w.Write(jsonState)
} else {
pendingError := "authorization_pending"
jsonResponse, err := json.Marshal(TokenResponse{
Error: &pendingError,
})
assert.NilError(t, err)
w.Write(jsonResponse)
}
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
state := State{
DeviceCode: "aDeviceCode",
UserCode: "aUserCode",
Interval: 1,
ExpiresIn: 30,
}
token, err := api.WaitForDeviceToken(context.Background(), state)
assert.NilError(t, err)
assert.DeepEqual(t, token, expectedToken)
})
t.Run("timeout", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/oauth/token", r.URL.Path)
assert.Equal(t, r.FormValue("client_id"), "aClientID")
assert.Equal(t, r.FormValue("grant_type"), "urn:ietf:params:oauth:grant-type:device_code")
assert.Equal(t, r.FormValue("device_code"), "aDeviceCode")
pendingError := "authorization_pending"
jsonResponse, err := json.Marshal(TokenResponse{
Error: &pendingError,
})
assert.NilError(t, err)
w.Write(jsonResponse)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
state := State{
DeviceCode: "aDeviceCode",
UserCode: "aUserCode",
Interval: 5,
ExpiresIn: 1,
}
_, err := api.WaitForDeviceToken(context.Background(), state)
assert.ErrorIs(t, err, ErrTimeout)
})
t.Run("canceled context", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
pendingError := "authorization_pending"
jsonResponse, err := json.Marshal(TokenResponse{
Error: &pendingError,
})
assert.NilError(t, err)
w.Write(jsonResponse)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
state := State{
DeviceCode: "aDeviceCode",
UserCode: "aUserCode",
Interval: 1,
ExpiresIn: 5,
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(1 * time.Second)
cancel()
}()
_, err := api.WaitForDeviceToken(ctx, state)
assert.ErrorContains(t, err, "context canceled")
})
}
func TestRevoke(t *testing.T) {
t.Parallel()
t.Run("success", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/oauth/revoke", r.URL.Path)
assert.Equal(t, r.FormValue("client_id"), "aClientID")
assert.Equal(t, r.FormValue("token"), "v1.a-refresh-token")
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
err := api.RevokeToken(context.Background(), "v1.a-refresh-token")
assert.NilError(t, err)
})
t.Run("unexpected response", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/oauth/revoke", r.URL.Path)
assert.Equal(t, r.FormValue("client_id"), "aClientID")
assert.Equal(t, r.FormValue("token"), "v1.a-refresh-token")
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
err := api.RevokeToken(context.Background(), "v1.a-refresh-token")
assert.ErrorContains(t, err, "unexpected response from tenant: 404 Not Found")
})
t.Run("error w/ description", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jsonState, err := json.Marshal(TokenResponse{
ErrorDescription: "invalid client id",
})
assert.NilError(t, err)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(jsonState)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
err := api.RevokeToken(context.Background(), "v1.a-refresh-token")
assert.ErrorContains(t, err, "invalid client id")
})
t.Run("canceled context", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/oauth/revoke", r.URL.Path)
assert.Equal(t, r.FormValue("client_id"), "aClientID")
assert.Equal(t, r.FormValue("token"), "v1.a-refresh-token")
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := api.RevokeToken(ctx, "v1.a-refresh-token")
assert.ErrorContains(t, err, "context canceled")
})
}
func TestGetAutoPAT(t *testing.T) {
t.Parallel()
t.Run("success", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/v2/access-tokens/desktop-generate", r.URL.Path)
assert.Equal(t, "Bearer bork", r.Header.Get("Authorization"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
marshalledResponse, err := json.Marshal(patGenerateResponse{
Data: struct {
Token string `json:"token"`
}{
Token: "a-docker-pat",
},
})
assert.NilError(t, err)
w.WriteHeader(http.StatusCreated)
w.Write(marshalledResponse)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
pat, err := api.GetAutoPAT(context.Background(), ts.URL, TokenResponse{
AccessToken: "bork",
})
assert.NilError(t, err)
assert.Equal(t, "a-docker-pat", pat)
})
t.Run("general error", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
_, err := api.GetAutoPAT(context.Background(), ts.URL, TokenResponse{
AccessToken: "bork",
})
assert.ErrorContains(t, err, "unexpected response from Hub: 500 Internal Server Error")
})
t.Run("context canceled", func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/v2/access-tokens/desktop-generate", r.URL.Path)
assert.Equal(t, "Bearer bork", r.Header.Get("Authorization"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
marshalledResponse, err := json.Marshal(patGenerateResponse{
Data: struct {
Token string `json:"token"`
}{
Token: "a-docker-pat",
},
})
assert.NilError(t, err)
time.Sleep(500 * time.Millisecond)
w.WriteHeader(http.StatusCreated)
w.Write(marshalledResponse)
}))
defer ts.Close()
api := API{
TenantURL: ts.URL,
ClientID: "aClientID",
Scopes: []string{"bork", "meow"},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
pat, err := api.GetAutoPAT(ctx, ts.URL, TokenResponse{
AccessToken: "bork",
})
assert.ErrorContains(t, err, "context canceled")
assert.Equal(t, "", pat)
})
}
-26
View File
@@ -1,26 +0,0 @@
package api
import (
"time"
)
// State represents the state of exchange after submitting.
type State struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// IntervalDuration returns the duration that should be waited between each auth
// polling event.
func (s State) IntervalDuration() time.Duration {
return time.Second * time.Duration(s.Interval)
}
// ExpiryDuration returns the total duration for which the client should keep
// polling.
func (s State) ExpiryDuration() time.Duration {
return time.Second * time.Duration(s.ExpiresIn)
}
-93
View File
@@ -1,93 +0,0 @@
package oauth
import (
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// Claims represents standard claims along with some custom ones.
type Claims struct {
jwt.Claims
// Domain is the domain claims for the token.
Domain DomainClaims `json:"https://hub.docker.com"`
// Scope is the scopes for the claims as a string that is space delimited.
Scope string `json:"scope,omitempty"`
}
// DomainClaims represents a custom claim data set that doesn't change the spec
// payload. This is primarily introduced by Auth0 and is defined by a fully
// specified URL as it's key. e.g. "https://hub.docker.com"
type DomainClaims struct {
// UUID is the user, machine client, or organization's UUID in our database.
UUID string `json:"uuid"`
// Email is the user's email address.
Email string `json:"email"`
// Username is the user's username.
Username string `json:"username"`
// Source is the source of the JWT. This should look like
// `docker_{type}|{id}`.
Source string `json:"source"`
// SessionID is the unique ID of the token.
SessionID string `json:"session_id"`
// ClientID is the client_id that generated the token. This is filled if
// M2M.
ClientID string `json:"client_id,omitempty"`
// ClientName is the name of the client that generated the token. This is
// filled if M2M.
ClientName string `json:"client_name,omitempty"`
}
// Source represents a source of a JWT.
type Source struct {
// Type is the type of source. This could be "pat" etc.
Type string `json:"type"`
// ID is the identifier to the source type. If "pat" then this will be the
// ID of the PAT.
ID string `json:"id"`
}
// GetClaims returns claims from an access token without verification.
func GetClaims(accessToken string) (Claims, error) {
token, err := parseSigned(accessToken)
if err != nil {
return Claims{}, err
}
var claims Claims
err = token.UnsafeClaimsWithoutVerification(&claims)
if err != nil {
return Claims{}, err
}
return claims, nil
}
// allowedSignatureAlgorithms is a list of allowed signature algorithms for JWTs.
// We add all supported algorithms for Auth0, including with higher key lengths.
// See auth0 docs: https://auth0.com/docs/get-started/applications/signing-algorithms
var allowedSignatureAlgorithms = []jose.SignatureAlgorithm{
jose.HS256,
jose.HS384,
jose.HS512,
jose.RS256, // currently used for auth0
jose.RS384,
jose.RS512,
jose.PS256,
jose.PS384,
jose.PS512,
}
// parseSigned parses a JWT and returns the signature object or error. This does
// not verify the validity of the JWT.
func parseSigned(token string) (*jwt.JSONWebToken, error) {
return jwt.ParseSigned(token, allowedSignatureAlgorithms)
}
-214
View File
@@ -1,214 +0,0 @@
package manager
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/internal/oauth"
"github.com/docker/cli/cli/internal/oauth/api"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/tui"
"github.com/docker/docker/registry"
"github.com/morikuni/aec"
"github.com/sirupsen/logrus"
"github.com/pkg/browser"
)
// OAuthManager is the manager responsible for handling authentication
// flows with the oauth tenant.
type OAuthManager struct {
store credentials.Store
tenant string
audience string
clientID string
api api.OAuthAPI
openBrowser func(string) error
}
// OAuthManagerOptions are the options used for New to create a new auth manager.
type OAuthManagerOptions struct {
Store credentials.Store
Audience string
ClientID string
Scopes []string
Tenant string
DeviceName string
OpenBrowser func(string) error
}
func New(options OAuthManagerOptions) *OAuthManager {
scopes := []string{"openid", "offline_access"}
if len(options.Scopes) > 0 {
scopes = options.Scopes
}
openBrowser := options.OpenBrowser
if openBrowser == nil {
// Prevent errors from missing binaries (like xdg-open) from
// cluttering the output. We can handle errors ourselves.
browser.Stdout = io.Discard
browser.Stderr = io.Discard
openBrowser = browser.OpenURL
}
return &OAuthManager{
clientID: options.ClientID,
audience: options.Audience,
tenant: options.Tenant,
store: options.Store,
api: api.API{
TenantURL: "https://" + options.Tenant,
ClientID: options.ClientID,
Scopes: scopes,
},
openBrowser: openBrowser,
}
}
var ErrDeviceLoginStartFail = errors.New("failed to start device code flow login")
// LoginDevice launches the device authentication flow with the tenant,
// printing instructions to the provided writer and attempting to open the
// browser for the user to authenticate.
// After the user completes the browser login, LoginDevice uses the retrieved
// tokens to create a Hub PAT which is returned to the caller.
// The retrieved tokens are stored in the credentials store (under a separate
// key), and the refresh token is concatenated with the client ID.
func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.AuthConfig, error) {
state, err := m.api.GetDeviceCode(ctx, m.audience)
if err != nil {
logrus.Debugf("failed to start device code login: %v", err)
return nil, ErrDeviceLoginStartFail
}
if state.UserCode == "" {
logrus.Debugf("failed to start device code login: missing user code")
return nil, ErrDeviceLoginStartFail
}
_, _ = fmt.Fprintln(w, aec.Bold.Apply("\nUSING WEB-BASED LOGIN"))
var out tui.Output
switch stream := w.(type) {
case *streams.Out:
out = tui.NewOutput(stream)
default:
out = tui.NewOutput(streams.NewOut(w))
}
out.PrintNote("To sign in with credentials on the command line, use 'docker login -u <username>'\n")
_, _ = fmt.Fprintf(w, "\nYour one-time device confirmation code is: "+aec.Bold.Apply("%s\n"), state.UserCode)
_, _ = fmt.Fprintf(w, aec.Bold.Apply("Press ENTER")+" to open your browser or submit your device code here: "+aec.Underline.Apply("%s\n"), strings.Split(state.VerificationURI, "?")[0])
tokenResChan := make(chan api.TokenResponse)
waitForTokenErrChan := make(chan error)
go func() {
tokenRes, err := m.api.WaitForDeviceToken(ctx, state)
if err != nil {
waitForTokenErrChan <- err
return
}
tokenResChan <- tokenRes
}()
go func() {
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
_ = m.openBrowser(state.VerificationURI)
}()
_, _ = fmt.Fprint(w, "\nWaiting for authentication in the browser…\n")
var tokenRes api.TokenResponse
select {
case <-ctx.Done():
return nil, errors.New("login canceled")
case err := <-waitForTokenErrChan:
return nil, fmt.Errorf("failed waiting for authentication: %w", err)
case tokenRes = <-tokenResChan:
}
claims, err := oauth.GetClaims(tokenRes.AccessToken)
if err != nil {
return nil, fmt.Errorf("failed to parse token claims: %w", err)
}
err = m.storeTokensInStore(tokenRes, claims.Domain.Username)
if err != nil {
return nil, fmt.Errorf("failed to store tokens: %w", err)
}
pat, err := m.api.GetAutoPAT(ctx, m.audience, tokenRes)
if err != nil {
return nil, err
}
return &types.AuthConfig{
Username: claims.Domain.Username,
Password: pat,
ServerAddress: registry.IndexServer,
}, nil
}
// Logout fetches the refresh token from the store and revokes it
// with the configured oauth tenant. The stored access and refresh
// tokens are then erased from the store.
// If the refresh token is not found in the store, an error is not
// returned.
func (m *OAuthManager) Logout(ctx context.Context) error {
refreshConfig, err := m.store.Get(refreshTokenKey)
if err != nil {
return err
}
if refreshConfig.Password == "" {
return nil
}
parts := strings.Split(refreshConfig.Password, "..")
if len(parts) != 2 {
// the token wasn't stored by the CLI, so don't revoke it
// or erase it from the store/error
return nil
}
// erase the token from the store first, that way
// if the revoke fails, the user can try to logout again
if err := m.eraseTokensFromStore(); err != nil {
return fmt.Errorf("failed to erase tokens: %w", err)
}
if err := m.api.RevokeToken(ctx, parts[0]); err != nil {
return fmt.Errorf("credentials erased successfully, but there was a failure to revoke the OAuth refresh token with the tenant: %w", err)
}
return nil
}
const (
accessTokenKey = registry.IndexServer + "access-token"
refreshTokenKey = registry.IndexServer + "refresh-token"
)
func (m *OAuthManager) storeTokensInStore(tokens api.TokenResponse, username string) error {
return errors.Join(
m.store.Store(types.AuthConfig{
Username: username,
Password: tokens.AccessToken,
ServerAddress: accessTokenKey,
}),
m.store.Store(types.AuthConfig{
Username: username,
Password: tokens.RefreshToken + ".." + m.clientID,
ServerAddress: refreshTokenKey,
}),
)
}
func (m *OAuthManager) eraseTokensFromStore() error {
return errors.Join(
m.store.Erase(accessTokenKey),
m.store.Erase(refreshTokenKey),
)
}
-363
View File
@@ -1,363 +0,0 @@
package manager
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/internal/oauth/api"
"gotest.tools/v3/assert"
)
const (
//nolint:revive // ignore line-length-limit
validToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InhYa3BCdDNyV3MyRy11YjlscEpncSJ9.eyJodHRwczovL2h1Yi5kb2NrZXIuY29tIjp7ImVtYWlsIjoiYm9ya0Bkb2NrZXIuY29tIiwic2Vzc2lvbl9pZCI6ImEtc2Vzc2lvbi1pZCIsInNvdXJjZSI6InNhbWxwIiwidXNlcm5hbWUiOiJib3JrISIsInV1aWQiOiIwMTIzLTQ1Njc4OSJ9LCJpc3MiOiJodHRwczovL2xvZ2luLmRvY2tlci5jb20vIiwic3ViIjoic2FtbHB8c2FtbHAtZG9ja2VyfGJvcmtAZG9ja2VyLmNvbSIsImF1ZCI6WyJodHRwczovL2F1ZGllbmNlLmNvbSJdLCJpYXQiOjE3MTk1MDI5MzksImV4cCI6MTcxOTUwNjUzOSwic2NvcGUiOiJvcGVuaWQgb2ZmbGluZV9hY2Nlc3MifQ.VUSp-9_SOvMPWJPRrSh7p4kSPoye4DA3kyd2I0TW0QtxYSRq7xCzNj0NC_ywlPlKBFBeXKm4mh93d1vBSh79I9Heq5tj0Fr4KH77U5xJRMEpjHqoT5jxMEU1hYXX92xctnagBMXxDvzUfu3Yf0tvYSA0RRoGbGTHfdYYRwOrGbwQ75Qg1dyIxUkwsG053eYX2XkmLGxymEMgIq_gWksgAamOc40_0OCdGr-MmDeD2HyGUa309aGltzQUw7Z0zG1AKSXy3WwfMHdWNFioTAvQphwEyY3US8ybSJi78upSFTjwUcryMeHUwQ3uV9PxwPMyPoYxo1izVB-OUJxM8RqEbg"
)
// parsed token:
// {
// "https://hub.docker.com": {
// "email": "bork@docker.com",
// "session_id": "a-session-id",
// "source": "samlp",
// "username": "bork!",
// "uuid": "0123-456789"
// },
// "iss": "https://login.docker.com/",
// "sub": "samlp|samlp-docker|bork@docker.com",
// "aud": [
// "https://audience.com"
// ],
// "iat": 1719502939,
// "exp": 1719506539,
// "scope": "openid offline_access"
// }
func TestLoginDevice(t *testing.T) {
t.Run("valid token", func(t *testing.T) {
expectedState := api.State{
DeviceCode: "device-code",
UserCode: "0123-4567",
VerificationURI: "an-url",
ExpiresIn: 300,
}
var receivedAudience string
getDeviceToken := func(audience string) (api.State, error) {
receivedAudience = audience
return expectedState, nil
}
var receivedState api.State
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
receivedState = state
return api.TokenResponse{
AccessToken: validToken,
RefreshToken: "refresh-token",
}, nil
}
var receivedAccessToken, getPatReceivedAudience string
getAutoPat := func(audience string, res api.TokenResponse) (string, error) {
receivedAccessToken = res.AccessToken
getPatReceivedAudience = audience
return "a-pat", nil
}
api := &testAPI{
getDeviceToken: getDeviceToken,
waitForDeviceToken: waitForDeviceToken,
getAutoPAT: getAutoPat,
}
store := newStore(map[string]types.AuthConfig{})
manager := OAuthManager{
store: credentials.NewFileStore(store),
audience: "https://hub.docker.com",
api: api,
openBrowser: func(url string) error {
return nil
},
}
authConfig, err := manager.LoginDevice(context.Background(), os.Stderr)
assert.NilError(t, err)
assert.Equal(t, receivedAudience, "https://hub.docker.com")
assert.Equal(t, receivedState, expectedState)
assert.DeepEqual(t, authConfig, &types.AuthConfig{
Username: "bork!",
Password: "a-pat",
ServerAddress: "https://index.docker.io/v1/",
})
assert.Equal(t, receivedAccessToken, validToken)
assert.Equal(t, getPatReceivedAudience, "https://hub.docker.com")
})
t.Run("stores in cred store", func(t *testing.T) {
getDeviceToken := func(audience string) (api.State, error) {
return api.State{
DeviceCode: "device-code",
UserCode: "0123-4567",
}, nil
}
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
return api.TokenResponse{
AccessToken: validToken,
RefreshToken: "refresh-token",
}, nil
}
getAutoPAT := func(audience string, res api.TokenResponse) (string, error) {
return "a-pat", nil
}
a := &testAPI{
getDeviceToken: getDeviceToken,
waitForDeviceToken: waitForDeviceToken,
getAutoPAT: getAutoPAT,
}
store := newStore(map[string]types.AuthConfig{})
manager := OAuthManager{
clientID: "client-id",
store: credentials.NewFileStore(store),
api: a,
openBrowser: func(url string) error {
return nil
},
}
authConfig, err := manager.LoginDevice(context.Background(), os.Stderr)
assert.NilError(t, err)
assert.Equal(t, authConfig.Password, "a-pat")
assert.Equal(t, authConfig.Username, "bork!")
assert.Equal(t, len(store.configs), 2)
assert.Equal(t, store.configs["https://index.docker.io/v1/access-token"].Password, validToken)
assert.Equal(t, store.configs["https://index.docker.io/v1/refresh-token"].Password, "refresh-token..client-id")
})
t.Run("timeout", func(t *testing.T) {
getDeviceToken := func(audience string) (api.State, error) {
return api.State{
DeviceCode: "device-code",
UserCode: "0123-4567",
VerificationURI: "an-url",
ExpiresIn: 300,
}, nil
}
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
return api.TokenResponse{}, api.ErrTimeout
}
a := &testAPI{
getDeviceToken: getDeviceToken,
waitForDeviceToken: waitForDeviceToken,
}
manager := OAuthManager{
api: a,
openBrowser: func(url string) error {
return nil
},
}
_, err := manager.LoginDevice(context.Background(), os.Stderr)
assert.ErrorContains(t, err, "failed waiting for authentication: timed out waiting for device token")
})
t.Run("canceled context", func(t *testing.T) {
getDeviceToken := func(audience string) (api.State, error) {
return api.State{
DeviceCode: "device-code",
UserCode: "0123-4567",
}, nil
}
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
// make sure that the context is cancelled before this returns
time.Sleep(500 * time.Millisecond)
return api.TokenResponse{
AccessToken: validToken,
RefreshToken: "refresh-token",
}, nil
}
a := &testAPI{
getDeviceToken: getDeviceToken,
waitForDeviceToken: waitForDeviceToken,
}
manager := OAuthManager{
api: a,
openBrowser: func(url string) error {
return nil
},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := manager.LoginDevice(ctx, os.Stderr)
assert.ErrorContains(t, err, "login canceled")
})
}
func TestLogout(t *testing.T) {
t.Run("successfully revokes token", func(t *testing.T) {
var receivedToken string
a := &testAPI{
revokeToken: func(token string) error {
receivedToken = token
return nil
},
}
store := newStore(map[string]types.AuthConfig{
"https://index.docker.io/v1/access-token": {
Password: validToken,
},
"https://index.docker.io/v1/refresh-token": {
Password: "a-refresh-token..client-id",
},
})
manager := OAuthManager{
store: credentials.NewFileStore(store),
api: a,
}
err := manager.Logout(context.Background())
assert.NilError(t, err)
assert.Equal(t, receivedToken, "a-refresh-token")
assert.Equal(t, len(store.configs), 0)
})
t.Run("error revoking token", func(t *testing.T) {
a := &testAPI{
revokeToken: func(token string) error {
return errors.New("couldn't reach tenant")
},
}
store := newStore(map[string]types.AuthConfig{
"https://index.docker.io/v1/access-token": {
Password: validToken,
},
"https://index.docker.io/v1/refresh-token": {
Password: "a-refresh-token..client-id",
},
})
manager := OAuthManager{
store: credentials.NewFileStore(store),
api: a,
}
err := manager.Logout(context.Background())
assert.ErrorContains(t, err, "credentials erased successfully, but there was a failure to revoke the OAuth refresh token with the tenant: couldn't reach tenant")
assert.Equal(t, len(store.configs), 0)
})
t.Run("invalid refresh token", func(t *testing.T) {
var triedRevoke bool
a := &testAPI{
revokeToken: func(token string) error {
triedRevoke = true
return nil
},
}
store := newStore(map[string]types.AuthConfig{
"https://index.docker.io/v1/access-token": {
Password: validToken,
},
"https://index.docker.io/v1/refresh-token": {
Password: "a-refresh-token-without-client-id",
},
})
manager := OAuthManager{
store: credentials.NewFileStore(store),
api: a,
}
err := manager.Logout(context.Background())
assert.NilError(t, err)
assert.Check(t, !triedRevoke)
})
t.Run("no refresh token", func(t *testing.T) {
a := &testAPI{}
var triedRevoke bool
revokeToken := func(token string) error {
triedRevoke = true
return nil
}
a.revokeToken = revokeToken
store := newStore(map[string]types.AuthConfig{})
manager := OAuthManager{
store: credentials.NewFileStore(store),
api: a,
}
err := manager.Logout(context.Background())
assert.NilError(t, err)
assert.Check(t, !triedRevoke)
})
}
var _ api.OAuthAPI = &testAPI{}
type testAPI struct {
getDeviceToken func(audience string) (api.State, error)
waitForDeviceToken func(state api.State) (api.TokenResponse, error)
refresh func(token string) (api.TokenResponse, error)
revokeToken func(token string) error
getAutoPAT func(audience string, res api.TokenResponse) (string, error)
}
func (t *testAPI) GetDeviceCode(_ context.Context, audience string) (api.State, error) {
if t.getDeviceToken != nil {
return t.getDeviceToken(audience)
}
return api.State{}, nil
}
func (t *testAPI) WaitForDeviceToken(_ context.Context, state api.State) (api.TokenResponse, error) {
if t.waitForDeviceToken != nil {
return t.waitForDeviceToken(state)
}
return api.TokenResponse{}, nil
}
func (t *testAPI) Refresh(_ context.Context, token string) (api.TokenResponse, error) {
if t.refresh != nil {
return t.refresh(token)
}
return api.TokenResponse{}, nil
}
func (t *testAPI) RevokeToken(_ context.Context, token string) error {
if t.revokeToken != nil {
return t.revokeToken(token)
}
return nil
}
func (t *testAPI) GetAutoPAT(_ context.Context, audience string, res api.TokenResponse) (string, error) {
if t.getAutoPAT != nil {
return t.getAutoPAT(audience, res)
}
return "", nil
}
type fakeStore struct {
configs map[string]types.AuthConfig
}
func (*fakeStore) Save() error {
return nil
}
func (f *fakeStore) GetAuthConfigs() map[string]types.AuthConfig {
return f.configs
}
func (*fakeStore) GetFilename() string {
return "/tmp/docker-fakestore"
}
func newStore(auths map[string]types.AuthConfig) *fakeStore {
return &fakeStore{configs: auths}
}
-28
View File
@@ -1,28 +0,0 @@
package manager
import (
"fmt"
"runtime"
"strings"
"github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/version"
)
const (
audience = "https://hub.docker.com"
tenant = "login.docker.com"
clientID = "L4v0dmlNBpYUjGGab0C2JtgTgXr1Qz4d"
)
func NewManager(store credentials.Store) *OAuthManager {
cliVersion := strings.ReplaceAll(version.Version, ".", "_")
options := OAuthManagerOptions{
Store: store,
Audience: audience,
ClientID: clientID,
Tenant: tenant,
DeviceName: fmt.Sprintf("docker-cli:%s:%s-%s", cliVersion, runtime.GOOS, runtime.GOARCH),
}
return New(options)
}