Pass authentication types to Git credential helpers

Git recently added a new field to the credential helper, `wwwauth[]`,
which may be repeated and includes all of the `WWW-Authenticate` headers
so that the credential helper may choose an appropriate set of
credentials and extract any sort of necessary data from the field (such
as challenge).

In Git LFS, we also want to do this with the `LFS-Authenticate` headers
as well, since those are often used for the same purpose, so include
both these headers in that field when passing them to `git credential
fill`.

Note that `git credential fill` only honours this value and passes it to
the credential helper in Git 2.41 and newer (including the latest
`HEAD`).  However, just to be safe, let's add an undocumented and
experimental option (`credential.*.skipwwwauth`) that users can use to
control this, which we can remove in a few releases if it turns out it's
not needed.  Similarly, skip our new tests if we have an older version
of Git where this doesn't work, since they'll otherwise fail.
This commit is contained in:
brian m. carlson 2023-05-31 13:42:17 +00:00
parent 5d5f90e286
commit badde878a3
No known key found for this signature in database
GPG Key ID: 2D0C9BC12F82B3A1
5 changed files with 169 additions and 13 deletions

@ -74,7 +74,8 @@ type CredentialHelperContext struct {
askpassCredHelper *AskPassCredentialHelper
cachingCredHelper *credentialCacher
urlConfig *config.URLConfig
urlConfig *config.URLConfig
wwwAuthHeaders []string
}
func NewCredentialHelperContext(gitEnv config.Environment, osEnv config.Environment) *CredentialHelperContext {
@ -113,6 +114,10 @@ func NewCredentialHelperContext(gitEnv config.Environment, osEnv config.Environm
return c
}
func (ctxt *CredentialHelperContext) SetWWWAuthHeaders(headers []string) {
ctxt.wwwAuthHeaders = headers
}
// getCredentialHelper parses a 'credsConfig' from the git and OS environments,
// returning the appropriate CredentialHelper to authenticate requests with.
//
@ -127,6 +132,9 @@ func (ctxt *CredentialHelperContext) GetCredentialHelper(helper CredentialHelper
if u.Scheme == "cert" || ctxt.urlConfig.Bool("credential", rawurl, "usehttppath", false) {
input["path"] = []string{strings.TrimPrefix(u.Path, "/")}
}
if len(ctxt.wwwAuthHeaders) != 0 && !ctxt.urlConfig.Bool("credential", rawurl, "skipwwwauth", false) {
input["wwwauth[]"] = ctxt.wwwAuthHeaders
}
if helper != nil {
return CredentialHelperWrapper{CredentialHelper: helper, Input: input, Url: u}

@ -68,7 +68,7 @@ func (c *Client) doWithAuth(remote string, access creds.Access, req *http.Reques
res, err := c.doWithCreds(req, credWrapper, access, via)
if err != nil {
if errors.IsAuthError(err) {
newMode, newModes := getAuthAccess(res, access.Mode(), c.access)
newMode, newModes, headers := getAuthAccess(res, access.Mode(), c.access)
newAccess := access.Upgrade(newMode)
if newAccess.Mode() != access.Mode() {
c.Endpoints.SetAccess(newAccess)
@ -79,6 +79,7 @@ func (c *Client) doWithAuth(remote string, access creds.Access, req *http.Reques
req.Header.Del("Authorization")
credWrapper.CredentialHelper.Reject(credWrapper.Creds)
}
c.credContext.SetWWWAuthHeaders(headers)
}
}
@ -314,14 +315,18 @@ var (
authenticateHeaders = []string{"Lfs-Authenticate", "Www-Authenticate"}
)
func getAuthAccess(res *http.Response, access creds.AccessMode, modes []creds.AccessMode) (creds.AccessMode, []creds.AccessMode) {
func getAuthAccess(res *http.Response, access creds.AccessMode, modes []creds.AccessMode) (creds.AccessMode, []creds.AccessMode, []string) {
newModes := make([]creds.AccessMode, 0, len(modes))
for _, mode := range modes {
if access != mode {
newModes = append(newModes, mode)
}
}
headers := make([]string, 0)
if res != nil {
for _, headerName := range authenticateHeaders {
headers = append(headers, res.Header[headerName]...)
}
supportedModes := make(map[creds.AccessMode]struct{})
for _, headerName := range authenticateHeaders {
for _, auth := range res.Header[headerName] {
@ -338,10 +343,10 @@ func getAuthAccess(res *http.Response, access creds.AccessMode, modes []creds.Ac
}
for _, mode := range newModes {
if _, ok := supportedModes[mode]; ok {
return mode, newModes
return mode, newModes, headers
}
}
}
return creds.BasicAccess, newModes
return creds.BasicAccess, newModes, headers
}

@ -48,7 +48,7 @@ func main() {
func fill() {
scanner := bufio.NewScanner(os.Stdin)
creds := map[string]string{}
creds := map[string][]string{}
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
@ -58,7 +58,11 @@ func fill() {
}
fmt.Fprintf(os.Stderr, "CREDS RECV: %s\n", line)
creds[parts[0]] = strings.TrimSpace(parts[1])
if _, ok := creds[parts[0]]; ok {
creds[parts[0]] = append(creds[parts[0]], strings.TrimSpace(parts[1]))
} else {
creds[parts[0]] = []string{strings.TrimSpace(parts[1])}
}
}
if err := scanner.Err(); err != nil {
@ -66,8 +70,8 @@ func fill() {
os.Exit(1)
}
hostPieces := strings.SplitN(creds["host"], ":", 2)
user, pass, err := credsForHostAndPath(hostPieces[0], creds["path"])
hostPieces := strings.SplitN(firstEntryForKey(creds, "host"), ":", 2)
user, pass, err := credsForHostAndPath(hostPieces[0], firstEntryForKey(creds, "path"))
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
@ -75,17 +79,30 @@ func fill() {
if user != "skip" {
if _, ok := creds["username"]; !ok {
creds["username"] = user
creds["username"] = []string{user}
}
if _, ok := creds["password"]; !ok {
creds["password"] = pass
creds["password"] = []string{pass}
}
}
mode := os.Getenv("LFS_TEST_CREDS_WWWAUTH")
wwwauth := firstEntryForKey(creds, "wwwauth[]")
if mode == "required" && !strings.HasPrefix(wwwauth, "Basic ") {
fmt.Fprintf(os.Stderr, "Missing required 'wwwauth[]' key in credentials\n")
os.Exit(1)
} else if mode == "forbidden" && wwwauth != "" {
fmt.Fprintf(os.Stderr, "Unexpected 'wwwauth[]' key in credentials\n")
os.Exit(1)
}
delete(creds, "wwwauth[]")
for key, value := range creds {
fmt.Fprintf(os.Stderr, "CREDS SEND: %s=%s\n", key, value)
fmt.Fprintf(os.Stdout, "%s=%s\n", key, value)
for _, entry := range value {
fmt.Fprintf(os.Stderr, "CREDS SEND: %s=%s\n", key, entry)
fmt.Fprintf(os.Stdout, "%s=%s\n", key, entry)
}
}
}
@ -128,3 +145,10 @@ func credsFromFilename(file string) (string, string, error) {
func log() {
fmt.Fprintf(os.Stderr, "CREDS received command: %s (ignored)\n", os.Args[1])
}
func firstEntryForKey(input map[string][]string, key string) string {
if val, ok := input[key]; ok && len(val) > 0 {
return val[0]
}
return ""
}

@ -1589,6 +1589,7 @@ func skipIfNoCookie(w http.ResponseWriter, r *http.Request, id string) bool {
func skipIfBadAuth(w http.ResponseWriter, r *http.Request, id string) bool {
auth := r.Header.Get("Authorization")
if auth == "" {
w.Header().Add("Lfs-Authenticate", "Basic realm=\"testsuite\"")
w.WriteHeader(401)
return true
}

@ -169,6 +169,124 @@ begin_test "credentials with useHttpPath, with correct password"
)
end_test
begin_test "credentials send wwwauth[] by default"
(
set -e
ensure_git_version_isnt $VERSION_LOWER "2.41.0"
export LFS_TEST_CREDS_WWWAUTH=required
reponame="$(basename "$0" ".sh")-wwwauth-required"
setup_remote_repo "$reponame"
printf "path:$reponame" > "$CREDSDIR/127.0.0.1--$reponame"
clone_repo "$reponame" "$reponame"
git checkout -b new-branch
git lfs track "*.dat" 2>&1 | tee track.log
grep "Tracking \"\*.dat\"" track.log
# creating new branch does not re-send any objects existing on other
# remote branches anymore, generate new object, different from prev tests
contents="b"
contents_oid=$(calc_oid "$contents")
printf "%s" "$contents" > b.dat
git add b.dat
git add .gitattributes
git commit -m "add b.dat"
GIT_TERMINAL_PROMPT=0 GIT_TRACE=1 git push origin new-branch 2>&1 | tee push.log
grep "Uploading LFS objects: 100% (1/1), 1 B" push.log
echo "approvals:"
[ "1" -eq "$(cat push.log | grep "creds: git credential approve" | wc -l)" ]
echo "fills:"
[ "1" -eq "$(cat push.log | grep "creds: git credential fill" | wc -l)" ]
echo "credential calls have path:"
credcalls="$(grep "creds: git credential" push.log)"
[ "0" -eq "$(echo "$credcalls" | grep '", "")' | wc -l)" ]
expected="$(echo "$credcalls" | wc -l)"
[ "$expected" -eq "$(printf "%s" "$credcalls" | grep "t-credentials" | wc -l)" ]
)
end_test
begin_test "credentials sends wwwauth[] and fails with finicky helper"
(
set -e
ensure_git_version_isnt $VERSION_LOWER "2.41.0"
export LFS_TEST_CREDS_WWWAUTH=forbidden
reponame="$(basename "$0" ".sh")-wwwauth-forbidden-finicky"
setup_remote_repo "$reponame"
printf "path:$reponame" > "$CREDSDIR/127.0.0.1--$reponame"
clone_repo "$reponame" "$reponame"
git checkout -b new-branch
git lfs track "*.dat" 2>&1 | tee track.log
grep "Tracking \"\*.dat\"" track.log
# creating new branch does not re-send any objects existing on other
# remote branches anymore, generate new object, different from prev tests
contents="b"
contents_oid=$(calc_oid "$contents")
printf "%s" "$contents" > b.dat
git add b.dat
git add .gitattributes
git commit -m "add b.dat"
GIT_TERMINAL_PROMPT=0 GIT_TRACE=1 git push origin new-branch 2>&1 | tee push.log
echo "approvals:"
[ "0" -eq "$(cat push.log | grep "creds: git credential approve" | wc -l)" ]
echo "fills:"
[ "2" -eq "$(cat push.log | grep "creds: git credential fill" | wc -l)" ]
)
end_test
begin_test "credentials skips wwwauth[] with option"
(
set -e
ensure_git_version_isnt $VERSION_LOWER "2.41.0"
export LFS_TEST_CREDS_WWWAUTH=forbidden
reponame="$(basename "$0" ".sh")-wwwauth-skip"
setup_remote_repo "$reponame"
git config --global credential.$GITSERVER.skipwwwauth true
printf "path:$reponame" > "$CREDSDIR/127.0.0.1--$reponame"
clone_repo "$reponame" "$reponame"
git checkout -b new-branch
git lfs track "*.dat" 2>&1 | tee track.log
grep "Tracking \"\*.dat\"" track.log
# creating new branch does not re-send any objects existing on other
# remote branches anymore, generate new object, different from prev tests
contents="b"
contents_oid=$(calc_oid "$contents")
printf "%s" "$contents" > b.dat
git add b.dat
git add .gitattributes
git commit -m "add b.dat"
GIT_TERMINAL_PROMPT=0 GIT_TRACE=1 git push origin new-branch 2>&1 | tee push.log
grep "Uploading LFS objects: 100% (1/1), 1 B" push.log
echo "approvals:"
[ "1" -eq "$(cat push.log | grep "creds: git credential approve" | wc -l)" ]
echo "fills:"
[ "1" -eq "$(cat push.log | grep "creds: git credential fill" | wc -l)" ]
echo "credential calls have path:"
credcalls="$(grep "creds: git credential" push.log)"
[ "0" -eq "$(echo "$credcalls" | grep '", "")' | wc -l)" ]
expected="$(echo "$credcalls" | wc -l)"
[ "$expected" -eq "$(printf "%s" "$credcalls" | grep "t-credentials" | wc -l)" ]
)
end_test
begin_test "git credential"
(
set -e