Skip to content

Commit

Permalink
Inline dependency on (Apache licensed) auth challenge
Browse files Browse the repository at this point in the history
This code was made internal in distribution/distribution#4126,
because it was not intended to be consumed externally.

As it is Apache-licensed, start by inlining the dependency; we can
then enhance test coverage and address any shortcomings.
  • Loading branch information
justinsb committed Apr 1, 2024
1 parent 8b3c303 commit bd30472
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 6 deletions.
2 changes: 1 addition & 1 deletion go.mod
Expand Up @@ -5,7 +5,6 @@ go 1.18
require (
github.com/containerd/stargz-snapshotter/estargz v0.14.3
github.com/docker/cli v24.0.0+incompatible
github.com/docker/distribution v2.8.2+incompatible
github.com/docker/docker v24.0.0+incompatible
github.com/google/go-cmp v0.5.9
github.com/klauspost/compress v1.16.5
Expand All @@ -23,6 +22,7 @@ require (
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/docker/distribution v2.8.2+incompatible // indirect
github.com/docker/docker-credential-helpers v0.7.0 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
Expand Down
3 changes: 1 addition & 2 deletions pkg/v1/remote/transport/bearer.go
Expand Up @@ -25,7 +25,6 @@ import (
"net/url"
"strings"

authchallenge "github.com/docker/distribution/registry/client/auth/challenge"
"github.com/google/go-containerregistry/internal/redact"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/logs"
Expand Down Expand Up @@ -151,7 +150,7 @@ func (bt *bearerTransport) RoundTrip(in *http.Request) (*http.Response, error) {
}

// If we hit a WWW-Authenticate challenge, it might be due to expired tokens or insufficient scope.
if challenges := authchallenge.ResponseChallenges(res); len(challenges) != 0 {
if challenges := authResponseChallenges(res); len(challenges) != 0 {
// close out old response, since we will not return it.
res.Body.Close()

Expand Down
5 changes: 2 additions & 3 deletions pkg/v1/remote/transport/ping.go
Expand Up @@ -23,7 +23,6 @@ import (
"strings"
"time"

authchallenge "github.com/docker/distribution/registry/client/auth/challenge"
"github.com/google/go-containerregistry/pkg/logs"
"github.com/google/go-containerregistry/pkg/name"
)
Expand Down Expand Up @@ -84,7 +83,7 @@ func pingSingle(ctx context.Context, reg name.Registry, t http.RoundTripper, sch
Insecure: insecure,
}, nil
case http.StatusUnauthorized:
if challenges := authchallenge.ResponseChallenges(resp); len(challenges) != 0 {
if challenges := authResponseChallenges(resp); len(challenges) != 0 {
// If we hit more than one, let's try to find one that we know how to handle.
wac := pickFromMultipleChallenges(challenges)
return &Challenge{
Expand Down Expand Up @@ -165,7 +164,7 @@ func pingParallel(ctx context.Context, reg name.Registry, t http.RoundTripper, s
}
}

func pickFromMultipleChallenges(challenges []authchallenge.Challenge) authchallenge.Challenge {
func pickFromMultipleChallenges(challenges []authChallenge) authChallenge {
// It might happen there are multiple www-authenticate headers, e.g. `Negotiate` and `Basic`.
// Picking simply the first one could result eventually in `unrecognized challenge` error,
// that's why we're looping through the challenges in search for one that can be handled.
Expand Down
169 changes: 169 additions & 0 deletions pkg/v1/remote/transport/response_challenge.go
@@ -0,0 +1,169 @@
package transport

Check failure on line 1 in pkg/v1/remote/transport/response_challenge.go

View workflow job for this annotation

GitHub Actions / Boilerplate Check (go)

[Go headers] reported by reviewdog 🐶 missing boilerplate: Raw Output: pkg/v1/remote/transport/response_challenge.go:1: missing boilerplate: // Copyright 2024 Google LLC All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License.

// This code is copy-paste imported from the Apache-licensed https://github.com/distribution/distribution,
// as the dependency has been made internal upstream.
// There is an alternative implementation in https://fuchsia.googlesource.com/tools/+/efc566f8f0dcc061dac3d57989b24f496b109ecb/net/digest/digest.go

import (
"net/http"
"strings"
)

// Octet types from RFC 2616.
type octetType byte

var octetTypes [256]octetType

const (
isToken octetType = 1 << iota
isSpace
)

func init() {
// OCTET = <any 8-bit sequence of data>
// CHAR = <any US-ASCII character (octets 0 - 127)>
// CTL = <any US-ASCII control character (octets 0 - 31) and DEL (127)>
// CR = <US-ASCII CR, carriage return (13)>
// LF = <US-ASCII LF, linefeed (10)>
// SP = <US-ASCII SP, space (32)>
// HT = <US-ASCII HT, horizontal-tab (9)>
// <"> = <US-ASCII double-quote mark (34)>
// CRLF = CR LF
// LWS = [CRLF] 1*( SP | HT )
// TEXT = <any OCTET except CTLs, but including LWS>
// separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <">
// | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
// token = 1*<any CHAR except CTLs or separators>
// qdtext = <any TEXT except <">>

for c := 0; c < 256; c++ {
var t octetType
isCtl := c <= 31 || c == 127
isChar := 0 <= c && c <= 127
isSeparator := strings.ContainsRune(" \t\"(),/:;<=>?@[]\\{}", rune(c))
if strings.ContainsRune(" \t\r\n", rune(c)) {
t |= isSpace
}
if isChar && !isCtl && !isSeparator {
t |= isToken
}
octetTypes[c] = t
}
}

// authResponseChallenges returns a list of authorization challenges
// for the given http Response. Challenges are only checked if
// the response status code was a 401.
func authResponseChallenges(resp *http.Response) []authChallenge {
if resp.StatusCode == http.StatusUnauthorized {
// Parse the WWW-Authenticate Header and store the challenges
// on this endpoint object.
return parseAuthHeader(resp.Header)
}

return nil
}

func parseAuthHeader(header http.Header) []authChallenge {
challenges := []authChallenge{}
for _, h := range header[http.CanonicalHeaderKey("WWW-Authenticate")] {
v, p := parseValueAndParams(h)
if v != "" {
challenges = append(challenges, authChallenge{Scheme: v, Parameters: p})
}
}
return challenges
}

// Note: we may be able to combine with Challenge here
type authChallenge struct {
Scheme string

// Following the challenge there are often key/value pairs
// e.g. Bearer service="gcr.io",realm="https://auth.gcr.io/v36/tokenz"
Parameters map[string]string
}

func parseValueAndParams(header string) (value string, params map[string]string) {
params = make(map[string]string)
value, s := expectToken(header)
if value == "" {
return
}
value = strings.ToLower(value)
s = "," + skipSpace(s)
for strings.HasPrefix(s, ",") {
var pkey string
pkey, s = expectToken(skipSpace(s[1:]))
if pkey == "" {
return
}
if !strings.HasPrefix(s, "=") {
return
}
var pvalue string
pvalue, s = expectTokenOrQuoted(s[1:])
if pvalue == "" {
return
}
pkey = strings.ToLower(pkey)
params[pkey] = pvalue
s = skipSpace(s)
}
return
}

func skipSpace(s string) (rest string) {
i := 0
for ; i < len(s); i++ {
if octetTypes[s[i]]&isSpace == 0 {
break
}
}
return s[i:]
}

func expectToken(s string) (token, rest string) {
i := 0
for ; i < len(s); i++ {
if octetTypes[s[i]]&isToken == 0 {
break
}
}
return s[:i], s[i:]
}

func expectTokenOrQuoted(s string) (value string, rest string) {
if !strings.HasPrefix(s, "\"") {
return expectToken(s)
}
s = s[1:]
for i := 0; i < len(s); i++ {
switch s[i] {
case '"':
return s[:i], s[i+1:]
case '\\':
p := make([]byte, len(s)-1)
j := copy(p, s[:i])
escape := true
for i = i + 1; i < len(s); i++ {
b := s[i]
switch {
case escape:
escape = false
p[j] = b
j++
case b == '\\':
escape = true
case b == '"':
return string(p[:j]), s[i+1:]
default:
p[j] = b
j++
}
}
return "", ""
}
}
return "", ""
}
40 changes: 40 additions & 0 deletions pkg/v1/remote/transport/response_challenge_test.go
@@ -0,0 +1,40 @@
package transport

Check failure on line 1 in pkg/v1/remote/transport/response_challenge_test.go

View workflow job for this annotation

GitHub Actions / Boilerplate Check (go)

[Go headers] reported by reviewdog 🐶 missing boilerplate: Raw Output: pkg/v1/remote/transport/response_challenge_test.go:1: missing boilerplate: // Copyright 2024 Google LLC All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License.

// This code is copy-paste imported from the Apache-licensed https://github.com/distribution/distribution,
// as the dependency has been made internal upstream.

import (
"net/http"
"testing"
)

func TestAuthChallengeParse(t *testing.T) {
header := http.Header{}
header.Add("WWW-Authenticate", `Bearer realm="https://auth.example.com/token",service="registry.example.com",other=fun,slashed="he\"\l\lo"`)

challenges := parseAuthHeader(header)
if len(challenges) != 1 {
t.Fatalf("Unexpected number of auth challenges: %d, expected 1", len(challenges))
}
challenge := challenges[0]

if expected := "bearer"; challenge.Scheme != expected {
t.Fatalf("Unexpected scheme: %s, expected: %s", challenge.Scheme, expected)
}

if expected := "https://auth.example.com/token"; challenge.Parameters["realm"] != expected {
t.Fatalf("Unexpected param: %s, expected: %s", challenge.Parameters["realm"], expected)
}

if expected := "registry.example.com"; challenge.Parameters["service"] != expected {
t.Fatalf("Unexpected param: %s, expected: %s", challenge.Parameters["service"], expected)
}

if expected := "fun"; challenge.Parameters["other"] != expected {
t.Fatalf("Unexpected param: %s, expected: %s", challenge.Parameters["other"], expected)
}

if expected := "he\"llo"; challenge.Parameters["slashed"] != expected {
t.Fatalf("Unexpected param: %s, expected: %s", challenge.Parameters["slashed"], expected)
}
}

0 comments on commit bd30472

Please sign in to comment.