diff --git a/README.md b/README.md index 60cba76..bf2f9ef 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ git-credential-azure is a Git credential helper that authenticates to [Azure Rep This is alpha-release software early in development: * Untested with work and school Microsoft accounts. -* Tokens expire after 1 hour so you have to reauthenticate regularly. A mature alternative is [Git Credential Manager](https://github.com/GitCredentialManager/git-credential-manager). @@ -34,6 +33,7 @@ This assumes you already have a storage helper configured such as cache or wincr ```sh git config --global --add credential.helper azure +git config --global credential.https://dev.azure.com.useHttpPath true ``` ### Unconfiguration diff --git a/main.go b/main.go index 8c7b4d0..122cd4e 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,19 @@ package main import ( + "bytes" "context" + "encoding/json" + "errors" "flag" "fmt" "io" "log" + "net/http" "os" "runtime/debug" "strings" + "time" "github.com/AzureAD/microsoft-authentication-library-for-go/apps/public" ) @@ -82,18 +87,35 @@ func main() { if verbose { fmt.Fprintln(os.Stderr, "result:", result) } + organization := strings.SplitN(pairs["path"], "/", 2)[0] + var pt PatToken + if organization != "" { + pt, err = getPAT(organization, result.AccessToken) + if err != nil { + fmt.Fprintln(os.Stderr, "error acquiring Personal Access Token", err) + } + } var username string if pairs["username"] == "" { + // TODO: check correctness username = "oauth2" } - output := map[string]string{ - "password": result.AccessToken, - } + output := map[string]string{} if username != "" { output["username"] = username } - if !result.ExpiresOn.IsZero() { - output["password_expiry_utc"] = fmt.Sprintf("%d", result.ExpiresOn.UTC().Unix()) + var password string + var expiry time.Time + if pt.Token != "" { + password = pt.Token + expiry = pt.ValidTo + } else { + password = result.AccessToken + expiry = result.ExpiresOn + } + output["password"] = password + if !expiry.IsZero() { + output["password_expiry_utc"] = fmt.Sprintf("%d", expiry.UTC().Unix()) } if verbose { fmt.Fprintln(os.Stderr, "output:", output) @@ -106,11 +128,65 @@ func main() { func authenticate() (public.AuthResult, error) { client, err := public.New( + // https://github.com/git-ecosystem/git-credential-manager/blob/8c430c9484c90433ab30c25df7fc1005fe2f4ba4/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs#L15 + // magic https://developercommunity.visualstudio.com/t/non-interactive-aad-auth-works-for-visual-studio-a/387853 "872cd9fa-d31f-45e0-9eab-6e460a02d1f1", public.WithAuthority("https://login.microsoftonline.com/organizations")) if err != nil { return public.AuthResult{}, err } + // https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/manage-personal-access-tokens-via-api?view=azure-devops scopes := []string{"499b84ac-1321-427f-aa17-267ca6975798/.default"} return client.AcquireTokenInteractive(context.Background(), scopes) } + +func getPAT(organization, accessToken string) (PatToken, error) { + // https://learn.microsoft.com/en-us/rest/api/azure/devops/tokens/pats/create?view=azure-devops-rest-7.1&tabs=HTTP + // sadly https://github.com/microsoft/azure-devops-go-api doesn't have this function + url := fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/tokens/pats?api-version=7.1-preview.1", organization) + j := map[string]any{ + "scopes": "vso.code_write vso.packaging", + } + body, err := json.Marshal(j) + if err != nil { + return PatToken{}, err + } + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return PatToken{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + response, err := http.DefaultClient.Do(req) + if err != nil { + return PatToken{}, err + } + body, err = io.ReadAll(response.Body) + if err != nil { + return PatToken{}, err + } + if verbose { + fmt.Fprintln(os.Stderr, string(body)) + } + ptr := PatTokenResult{} + err = json.Unmarshal(body, &ptr) + if err != nil { + return PatToken{}, err + } + if ptr.PatTokenError != "" && ptr.PatTokenError != "none" { + return PatToken{}, errors.New(ptr.PatTokenError) + } + return ptr.PatToken, nil +} + +// https://learn.microsoft.com/en-us/rest/api/azure/devops/tokens/pats/create?view=azure-devops-rest-7.1&tabs=HTTP#pattokenresult +type PatTokenResult struct { + PatToken PatToken `json:"patToken"` + PatTokenError string `json:"patTokenError"` +} + +// https://learn.microsoft.com/en-us/rest/api/azure/devops/tokens/pats/create?view=azure-devops-rest-7.1&tabs=HTTP#pattoken +type PatToken struct { + Token string `json:"token"` + ValidTo time.Time `json:"validTo"` +}