Skip to content
Draft
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
4 changes: 2 additions & 2 deletions docs/tutorial/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func main() {
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(repo.Reference.Registry, auth.Credential{
CredentialFunc: auth.StaticCredential(repo.Reference.Registry, properties.Credential{
Username: "username",
Password: "password",
}),
Expand Down Expand Up @@ -287,7 +287,7 @@ func main() {
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(repo.Reference.Registry, auth.Credential{
CredentialFunc: auth.StaticCredential(repo.Reference.Registry, properties.Credential{
Username: "username",
Password: "password",
}),
Expand Down
4 changes: 3 additions & 1 deletion example_copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import (
"github.com/oras-project/oras-go/v3/internal/spec"
"github.com/oras-project/oras-go/v3/registry/remote"
"github.com/oras-project/oras-go/v3/registry/remote/auth"
"github.com/oras-project/oras-go/v3/registry/remote/credentials"
"github.com/oras-project/oras-go/v3/registry/remote/properties"
"github.com/oras-project/oras-go/v3/registry/remote/retry"
)

Expand Down Expand Up @@ -451,7 +453,7 @@ func Example_extendedCopyArtifactAndReferrersToRepository() {
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(registry, auth.Credential{
CredentialFunc: credentials.StaticCredentialFunc(registry, properties.Credential{
Username: "username",
Password: "password",
}),
Expand Down
15 changes: 8 additions & 7 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/oras-project/oras-go/v3/registry/remote"
"github.com/oras-project/oras-go/v3/registry/remote/auth"
"github.com/oras-project/oras-go/v3/registry/remote/credentials"
"github.com/oras-project/oras-go/v3/registry/remote/properties"
"github.com/oras-project/oras-go/v3/registry/remote/retry"
)

Expand All @@ -50,7 +51,7 @@ func Example_pullFilesFromRemoteRepository() {
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(reg, auth.Credential{
CredentialFunc: credentials.StaticCredentialFunc(reg, properties.Credential{
Username: "username",
Password: "password",
}),
Expand Down Expand Up @@ -85,7 +86,7 @@ func Example_pullImageFromRemoteRepository() {
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(reg, auth.Credential{
CredentialFunc: credentials.StaticCredentialFunc(reg, properties.Credential{
Username: "username",
Password: "password",
}),
Expand Down Expand Up @@ -125,9 +126,9 @@ func Example_pullImageUsingDockerCredentials() {
panic(err)
}
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: credentials.Credential(credStore), // Use the credentials store
Client: retry.DefaultClient,
Cache: auth.NewCache(),
CredentialFunc: remote.GetCredentialFunc(credStore), // Use the credentials store
}

// 2. Copy from the remote repository to the OCI layout store
Expand Down Expand Up @@ -190,7 +191,7 @@ func Example_pushFilesToRemoteRepository() {
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(reg, auth.Credential{
CredentialFunc: credentials.StaticCredentialFunc(reg, properties.Credential{
Username: "username",
Password: "password",
}),
Expand Down Expand Up @@ -218,7 +219,7 @@ func Example_attachBlobToRemoteRepository() {
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(registry, auth.Credential{
CredentialFunc: credentials.StaticCredentialFunc(registry, properties.Credential{
Username: "username",
Password: "password",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

package credentials
package remote

import (
"context"
"errors"
"fmt"

"github.com/oras-project/oras-go/v3/registry/remote"
"github.com/oras-project/oras-go/v3/registry/remote/auth"
"github.com/oras-project/oras-go/v3/registry/remote/credentials"
"github.com/oras-project/oras-go/v3/registry/remote/properties"
)

// ErrClientTypeUnsupported is thrown by Login() when the registry's client type
Expand All @@ -32,7 +33,7 @@ var ErrClientTypeUnsupported = errors.New("client type not supported")
// registry's client should be nil or of type *auth.Client. Login uses
// a client local to the function and will not modify the original client of
// the registry.
func Login(ctx context.Context, store Store, reg *remote.Registry, cred auth.Credential) error {
func Login(ctx context.Context, store credentials.Store, reg *Registry, cred properties.Credential) error {
// create a clone of the original registry for login purpose
regClone := *reg
// we use the original client if applicable, otherwise use a default client
Expand All @@ -47,7 +48,7 @@ func Login(ctx context.Context, store Store, reg *remote.Registry, cred auth.Cre
}
regClone.Client = &authClient
// update credentials with the client
authClient.Credential = auth.StaticCredential(reg.Reference.Registry, cred)
authClient.CredentialFunc = credentials.StaticCredentialFunc(reg.Reference.Registry, cred)
// validate and store the credential
if err := regClone.Ping(ctx); err != nil {
return fmt.Errorf("failed to validate the credentials for %s: %w", regClone.Reference.Registry, err)
Expand All @@ -60,20 +61,20 @@ func Login(ctx context.Context, store Store, reg *remote.Registry, cred auth.Cre
}

// Logout provides the logout functionality given the registry name.
func Logout(ctx context.Context, store Store, registryName string) error {
func Logout(ctx context.Context, store credentials.Store, registryName string) error {
registryName = ServerAddressFromRegistry(registryName)
if err := store.Delete(ctx, registryName); err != nil {
return fmt.Errorf("failed to delete the credential for %s: %w", registryName, err)
}
return nil
}

// Credential returns a Credential() function that can be used by auth.Client.
func Credential(store Store) auth.CredentialFunc {
return func(ctx context.Context, hostport string) (auth.Credential, error) {
// GetCredentialFunc returns a GetCredentialFunc() function that can be used by auth.Client.
func GetCredentialFunc(store credentials.Store) credentials.CredentialFunc {
return func(ctx context.Context, hostport string) (properties.Credential, error) {
hostport = ServerAddressFromHostname(hostport)
if hostport == "" {
return auth.EmptyCredential, nil
return properties.EmptyCredential, nil
}
return store.Get(ctx, hostport)
}
Expand Down
102 changes: 65 additions & 37 deletions registry/remote/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import (
"net/url"
"strings"

"github.com/oras-project/oras-go/v3/registry/remote/credentials"
"github.com/oras-project/oras-go/v3/registry/remote/internal/errutil"
"github.com/oras-project/oras-go/v3/registry/remote/properties"
"github.com/oras-project/oras-go/v3/registry/remote/retry"
)

Expand Down Expand Up @@ -66,29 +68,6 @@ var maxResponseBytes int64 = 128 * 1024 // 128 KiB
// See also ClientID.
var defaultClientID = "oras-go"

// CredentialFunc represents a function that resolves the credential for the
// given registry (i.e. host:port).
//
// [EmptyCredential] is a valid return value and should not be considered as
// an error.
type CredentialFunc func(ctx context.Context, hostport string) (Credential, error)

// StaticCredential specifies static credentials for the given host.
func StaticCredential(registry string, cred Credential) CredentialFunc {
if registry == "docker.io" {
// it is expected that traffic targeting "docker.io" will be redirected
// to "registry-1.docker.io"
// reference: https://github.com/moby/moby/blob/v24.0.0-beta.2/registry/config.go#L25-L48
registry = "registry-1.docker.io"
}
return func(_ context.Context, hostport string) (Credential, error) {
if hostport == registry {
return cred, nil
}
return EmptyCredential, nil
}
}

// Client is an auth-decorated HTTP client.
// Its zero value is a usable client that uses http.DefaultClient with no cache.
type Client struct {
Expand All @@ -105,12 +84,12 @@ type Client struct {
// Header contains the custom headers to be added to each request.
Header http.Header

// Credential specifies the function for resolving the credential for the
// CredentialFunc specifies the function for resolving the credential for the
// given registry (i.e. host:port).
// EmptyCredential is a valid return value and should not be considered as
// an error.
// If nil, the credential is always resolved to EmptyCredential.
Credential CredentialFunc
CredentialFunc credentials.CredentialFunc

// Cache caches credentials for direct accessing the remote registry.
// If nil, no cache is used.
Expand Down Expand Up @@ -148,11 +127,11 @@ func (c *Client) send(req *http.Request) (*http.Response, error) {
}

// credential resolves the credential for the given registry.
func (c *Client) credential(ctx context.Context, reg string) (Credential, error) {
if c.Credential == nil {
return EmptyCredential, nil
func (c *Client) credential(ctx context.Context, reg string) (properties.Credential, error) {
if c.CredentialFunc == nil {
return properties.EmptyCredential, nil
}
return c.Credential(ctx, reg)
return c.CredentialFunc(ctx, reg)
}

// cache resolves the cache.
Expand All @@ -175,9 +154,15 @@ func (c *Client) SetUserAgent(userAgent string) {
// Do sends the request to the remote server, attempting to resolve
// authentication if 'Authorization' header is not set.
//
// When a registry indicates bearer auth support, the client first attempts
// bearer authentication. If bearer auth fails with a 401 Unauthorized or 403
// Forbidden response, the client falls back to basic authentication. This
// fallback behavior allows compatibility with registries that advertise bearer
// auth but may only accept basic auth as a fallback mechanism.
//
// On authentication failure due to bad credential,
// - Do returns error if it fails to fetch token for bearer auth.
// - Do returns the registry response without error for basic auth.
// - Do attempts basic auth as a fallback when bearer auth fails.
// - Do returns the registry response without error for basic auth failures.
func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
if auth := originalReq.Header.Get(headerAuthorization); auth != "" {
return c.send(originalReq)
Expand Down Expand Up @@ -212,7 +197,7 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized {
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden {
return resp, nil
}

Expand Down Expand Up @@ -256,7 +241,7 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized {
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden {
return resp, nil
}
resp.Body.Close()
Expand All @@ -282,7 +267,50 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
return nil, err
}

return c.send(req)
resp, err = c.send(req)
if err != nil {
return nil, err
}

// If bearer auth was attempted but failed with 401 or 403, try basic auth as fallback
if scheme == SchemeBearer && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
resp.Body.Close()

// Attempt basic auth fallback
token, err := cache.Set(ctx, host, SchemeBasic, "", func(ctx context.Context) (string, error) {
return c.fetchBasicAuth(ctx, host)
})
// If basic auth credentials are not available, skip fallback and return bearer failure
if err != nil {
// Check if basic credentials are not available or incomplete
if errors.Is(err, ErrBasicCredentialNotFound) ||
strings.Contains(err.Error(), "missing username or password") {
// No basic credentials available, return without authorization
// This will result in a 401 which is expected when no credentials are available
req = originalReq.Clone(ctx)
if err := rewindRequestBody(req); err != nil {
return nil, err
}
return c.send(req)
}
// Other errors in fetching basic auth should be reported
return nil, fmt.Errorf("%s %q: basic auth fallback failed: %w", resp.Request.Method, resp.Request.URL, err)
}

// Try request with basic auth
req = originalReq.Clone(ctx)
req.Header.Set(headerAuthorization, "Basic "+token)
if err := rewindRequestBody(req); err != nil {
return nil, err
}

resp, err = c.send(req)
if err != nil {
return nil, err
}
}

return resp, nil
}

// fetchBasicAuth fetches a basic auth token for the basic challenge.
Expand All @@ -291,7 +319,7 @@ func (c *Client) fetchBasicAuth(ctx context.Context, registry string) (string, e
if err != nil {
return "", fmt.Errorf("failed to resolve credential: %w", err)
}
if cred == EmptyCredential {
if cred == properties.EmptyCredential {
return "", ErrBasicCredentialNotFound
}
if cred.Username == "" || cred.Password == "" {
Expand All @@ -310,7 +338,7 @@ func (c *Client) fetchBearerToken(ctx context.Context, registry, realm, service
if cred.AccessToken != "" {
return cred.AccessToken, nil
}
if cred == EmptyCredential || (cred.RefreshToken == "" && !c.ForceAttemptOAuth2) {
if cred == properties.EmptyCredential || (cred.RefreshToken == "" && !c.ForceAttemptOAuth2) {
return c.fetchDistributionToken(ctx, realm, service, scopes, cred.Username, cred.Password)
}
return c.fetchOAuth2Token(ctx, realm, service, scopes, cred)
Expand Down Expand Up @@ -370,7 +398,7 @@ func (c *Client) fetchDistributionToken(ctx context.Context, realm, service stri

// fetchOAuth2Token fetches an OAuth2 access token.
// Reference: https://distribution.github.io/distribution/spec/auth/oauth/
func (c *Client) fetchOAuth2Token(ctx context.Context, realm, service string, scopes []string, cred Credential) (string, error) {
func (c *Client) fetchOAuth2Token(ctx context.Context, realm, service string, scopes []string, cred properties.Credential) (string, error) {
form := url.Values{}
if cred.RefreshToken != "" {
form.Set("grant_type", "refresh_token")
Expand Down
Loading
Loading