Skip to content

Commit

Permalink
acquire personal access token with longer expiry
Browse files Browse the repository at this point in the history
  • Loading branch information
hickford committed Aug 2, 2023
1 parent d772f34 commit ce1a30e
Show file tree
Hide file tree
Showing 2 changed files with 82 additions and 6 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand Down
86 changes: 81 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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)
Expand All @@ -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"`
}

0 comments on commit ce1a30e

Please sign in to comment.