Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add crane auth token #1709

Merged
merged 1 commit into from
May 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion cmd/crane/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
"github.com/spf13/cobra"
)

Expand All @@ -39,7 +40,77 @@ func NewCmdAuth(options []crane.Option, argv ...string) *cobra.Command {
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Usage() },
}
cmd.AddCommand(NewCmdAuthGet(options, argv...), NewCmdAuthLogin(argv...), NewCmdAuthLogout(argv...))
cmd.AddCommand(NewCmdAuthGet(options, argv...), NewCmdAuthLogin(argv...), NewCmdAuthLogout(argv...), NewCmdAuthToken(options))
return cmd
}

func NewCmdAuthToken(options []crane.Option) *cobra.Command {
var (
header bool
push bool
mounts []string
)
cmd := &cobra.Command{
Use: "token REPO",
Short: "Retrieves a token for a remote repo",
Example: `# If you wanted to mount a blob from debian to ubuntu.
$ curl -H "$(crane auth token -H --push --mount debian ubuntu)" ...

# To get the raw list tags response
$ curl -H "$(crane auth token -H ubuntu)" https://index.docker.io/v2/library/ubuntu/tags/list
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
repo, err := name.NewRepository(args[0])
if err != nil {
return err
}
o := crane.GetOptions(options...)

t := transport.NewLogger(o.Transport)
pr, err := transport.Ping(cmd.Context(), repo.Registry, t)
if err != nil {
return err
}

auth, err := o.Keychain.Resolve(repo)
if err != nil {
return err
}

scopes := []string{repo.Scope(transport.PullScope)}
if push {
scopes[0] = repo.Scope(transport.PushScope)
}

for _, m := range mounts {
mr, err := name.NewRepository(m)
if err != nil {
return err
}
scopes = append(scopes, mr.Scope(transport.PullScope))
}

tr, err := transport.Exchange(cmd.Context(), repo.Registry, auth, t, scopes, pr)
if err != nil {
return err
}

if header {
fmt.Printf("Authorization: Bearer %s", tr.Token)
return nil
}

if err := json.NewEncoder(os.Stdout).Encode(tr); err != nil {
return err
}

return nil
},
}
cmd.Flags().StringSliceVarP(&mounts, "mount", "m", []string{}, "Scopes to mount from")
cmd.Flags().BoolVarP(&header, "header", "H", false, "Output in header format")
cmd.Flags().BoolVar(&push, "push", false, "Request push scopes")
return cmd
}

Expand Down
1 change: 1 addition & 0 deletions cmd/crane/doc/crane_auth.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions cmd/crane/doc/crane_auth_token.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions pkg/crane/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ import (

// Options hold the options that crane uses when calling other packages.
type Options struct {
Name []name.Option
Remote []remote.Option
Platform *v1.Platform
Keychain authn.Keychain
Name []name.Option
Remote []remote.Option
Platform *v1.Platform
Keychain authn.Keychain
Transport http.RoundTripper

auth authn.Authenticator
transport http.RoundTripper
insecure bool
jobs int
noclobber bool
Expand Down Expand Up @@ -64,13 +64,15 @@ func makeOptions(opts ...Option) Options {

// Allow for untrusted certificates if the user
// passed Insecure but no custom transport.
if opt.insecure && opt.transport == nil {
if opt.insecure && opt.Transport == nil {
transport := remote.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true, //nolint: gosec
}

WithTransport(transport)(&opt)
} else if opt.Transport == nil {
opt.Transport = remote.DefaultTransport
}

return opt
Expand All @@ -85,7 +87,7 @@ type Option func(*Options)
func WithTransport(t http.RoundTripper) Option {
return func(o *Options) {
o.Remote = append(o.Remote, remote.WithTransport(t))
o.transport = t
o.Transport = t
}
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/crane/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestInsecureOptionTracking(t *testing.T) {
func TestTransportSetting(t *testing.T) {
opts := GetOptions(WithTransport(remote.DefaultTransport))

if opts.transport == nil {
if opts.Transport == nil {
t.Error("expected crane transport to be set when user passes WithTransport")
}
}
Expand All @@ -44,7 +44,7 @@ func TestInsecureTransport(t *testing.T) {
opts := GetOptions(Insecure)
var transport *http.Transport
var ok bool
if transport, ok = opts.transport.(*http.Transport); !ok {
if transport, ok = opts.Transport.(*http.Transport); !ok {
t.Fatal("Unable to successfully assert default transport")
}

Expand Down
136 changes: 103 additions & 33 deletions pkg/v1/remote/transport/bearer.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,70 @@ import (
"github.com/google/go-containerregistry/pkg/name"
)

type Token struct {
Token string `json:"token"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}

// Exchange requests a registry Token with the given scopes.
func Exchange(ctx context.Context, reg name.Registry, auth authn.Authenticator, t http.RoundTripper, scopes []string, pr *Challenge) (*Token, error) {
if strings.ToLower(pr.Scheme) != "bearer" {
// TODO: Pretend token for basic?
return nil, fmt.Errorf("challenge scheme %q is not bearer", pr.Scheme)
}
bt, err := fromChallenge(reg, auth, t, pr)
if err != nil {
return nil, err
}
authcfg, err := auth.Authorization()
if err != nil {
return nil, err
}
tok, err := bt.Refresh(ctx, authcfg)
if err != nil {
return nil, err
}
return tok, nil
}

// FromToken returns a transport given a Challenge + Token.
func FromToken(reg name.Registry, auth authn.Authenticator, t http.RoundTripper, pr *Challenge, tok *Token) (http.RoundTripper, error) {
if strings.ToLower(pr.Scheme) != "bearer" {
return &Wrapper{&basicTransport{inner: t, auth: auth, target: reg.RegistryStr()}}, nil
}
bt, err := fromChallenge(reg, auth, t, pr)
if err != nil {
return nil, err
}
if tok.Token != "" {
bt.bearer.RegistryToken = tok.Token
}
return &Wrapper{bt}, nil
}

func fromChallenge(reg name.Registry, auth authn.Authenticator, t http.RoundTripper, pr *Challenge) (*bearerTransport, error) {
// We require the realm, which tells us where to send our Basic auth to turn it into Bearer auth.
realm, ok := pr.Parameters["realm"]
if !ok {
return nil, fmt.Errorf("malformed www-authenticate, missing realm: %v", pr.Parameters)
}
service := pr.Parameters["service"]
scheme := "https"
if pr.Insecure {
scheme = "http"
}
return &bearerTransport{
inner: t,
basic: auth,
realm: realm,
registry: reg,
service: service,
scheme: scheme,
}, nil
}

type bearerTransport struct {
// Wrapped by bearerTransport.
inner http.RoundTripper
Expand Down Expand Up @@ -73,7 +137,7 @@ func (bt *bearerTransport) RoundTrip(in *http.Request) (*http.Response, error) {
// we are redirected, only set it when the authorization header matches
// the registry with which we are interacting.
// In case of redirect http.Client can use an empty Host, check URL too.
if matchesHost(bt.registry, in, bt.scheme) {
if matchesHost(bt.registry.RegistryStr(), in, bt.scheme) {
hdr := fmt.Sprintf("Bearer %s", bt.bearer.RegistryToken)
in.Header.Set("Authorization", hdr)
}
Expand Down Expand Up @@ -135,7 +199,36 @@ func (bt *bearerTransport) refresh(ctx context.Context) error {
return nil
}

var content []byte
response, err := bt.Refresh(ctx, auth)
if err != nil {
return err
}

// Some registries set access_token instead of token. See #54.
if response.AccessToken != "" {
response.Token = response.AccessToken
}

// Find a token to turn into a Bearer authenticator
if response.Token != "" {
bt.bearer.RegistryToken = response.Token
}

// If we obtained a refresh token from the oauth flow, use that for refresh() now.
if response.RefreshToken != "" {
bt.basic = authn.FromConfig(authn.AuthConfig{
IdentityToken: response.RefreshToken,
})
}

return nil
}

func (bt *bearerTransport) Refresh(ctx context.Context, auth *authn.AuthConfig) (*Token, error) {
var (
content []byte
err error
)
if auth.IdentityToken != "" {
// If the secret being stored is an identity token,
// the Username should be set to <token>, which indicates
Expand All @@ -152,48 +245,25 @@ func (bt *bearerTransport) refresh(ctx context.Context) error {
content, err = bt.refreshBasic(ctx)
}
if err != nil {
return err
}

// Some registries don't have "token" in the response. See #54.
type tokenResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
// TODO: handle expiry?
return nil, err
}

var response tokenResponse
var response Token
if err := json.Unmarshal(content, &response); err != nil {
return err
}

// Some registries set access_token instead of token.
if response.AccessToken != "" {
response.Token = response.AccessToken
}

// Find a token to turn into a Bearer authenticator
if response.Token != "" {
bt.bearer.RegistryToken = response.Token
} else {
return fmt.Errorf("no token in bearer response:\n%s", content)
return nil, err
}

// If we obtained a refresh token from the oauth flow, use that for refresh() now.
if response.RefreshToken != "" {
bt.basic = authn.FromConfig(authn.AuthConfig{
IdentityToken: response.RefreshToken,
})
if response.Token == "" && response.AccessToken == "" {
return &response, fmt.Errorf("no token in bearer response:\n%s", content)
}

return nil
return &response, nil
}

func matchesHost(reg name.Registry, in *http.Request, scheme string) bool {
func matchesHost(host string, in *http.Request, scheme string) bool {
canonicalHeaderHost := canonicalAddress(in.Host, scheme)
canonicalURLHost := canonicalAddress(in.URL.Host, scheme)
canonicalRegistryHost := canonicalAddress(reg.RegistryStr(), scheme)
canonicalRegistryHost := canonicalAddress(host, scheme)
return canonicalHeaderHost == canonicalRegistryHost || canonicalURLHost == canonicalRegistryHost
}

Expand Down
Loading