Skip to content

Commit ce1a30e

Browse files
committed
acquire personal access token with longer expiry
1 parent d772f34 commit ce1a30e

File tree

2 files changed

+82
-6
lines changed

2 files changed

+82
-6
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ git-credential-azure is a Git credential helper that authenticates to [Azure Rep
88
This is alpha-release software early in development:
99

1010
* Untested with work and school Microsoft accounts.
11-
* Tokens expire after 1 hour so you have to reauthenticate regularly.
1211

1312
A mature alternative is [Git Credential Manager](https://github.com/GitCredentialManager/git-credential-manager).
1413

@@ -34,6 +33,7 @@ This assumes you already have a storage helper configured such as cache or wincr
3433

3534
```sh
3635
git config --global --add credential.helper azure
36+
git config --global credential.https://dev.azure.com.useHttpPath true
3737
```
3838

3939
### Unconfiguration

main.go

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package main
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
7+
"errors"
58
"flag"
69
"fmt"
710
"io"
811
"log"
12+
"net/http"
913
"os"
1014
"runtime/debug"
1115
"strings"
16+
"time"
1217

1318
"github.com/AzureAD/microsoft-authentication-library-for-go/apps/public"
1419
)
@@ -82,18 +87,35 @@ func main() {
8287
if verbose {
8388
fmt.Fprintln(os.Stderr, "result:", result)
8489
}
90+
organization := strings.SplitN(pairs["path"], "/", 2)[0]
91+
var pt PatToken
92+
if organization != "" {
93+
pt, err = getPAT(organization, result.AccessToken)
94+
if err != nil {
95+
fmt.Fprintln(os.Stderr, "error acquiring Personal Access Token", err)
96+
}
97+
}
8598
var username string
8699
if pairs["username"] == "" {
100+
// TODO: check correctness
87101
username = "oauth2"
88102
}
89-
output := map[string]string{
90-
"password": result.AccessToken,
91-
}
103+
output := map[string]string{}
92104
if username != "" {
93105
output["username"] = username
94106
}
95-
if !result.ExpiresOn.IsZero() {
96-
output["password_expiry_utc"] = fmt.Sprintf("%d", result.ExpiresOn.UTC().Unix())
107+
var password string
108+
var expiry time.Time
109+
if pt.Token != "" {
110+
password = pt.Token
111+
expiry = pt.ValidTo
112+
} else {
113+
password = result.AccessToken
114+
expiry = result.ExpiresOn
115+
}
116+
output["password"] = password
117+
if !expiry.IsZero() {
118+
output["password_expiry_utc"] = fmt.Sprintf("%d", expiry.UTC().Unix())
97119
}
98120
if verbose {
99121
fmt.Fprintln(os.Stderr, "output:", output)
@@ -106,11 +128,65 @@ func main() {
106128

107129
func authenticate() (public.AuthResult, error) {
108130
client, err := public.New(
131+
// https://github.com/git-ecosystem/git-credential-manager/blob/8c430c9484c90433ab30c25df7fc1005fe2f4ba4/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs#L15
132+
// magic https://developercommunity.visualstudio.com/t/non-interactive-aad-auth-works-for-visual-studio-a/387853
109133
"872cd9fa-d31f-45e0-9eab-6e460a02d1f1",
110134
public.WithAuthority("https://login.microsoftonline.com/organizations"))
111135
if err != nil {
112136
return public.AuthResult{}, err
113137
}
138+
// https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/manage-personal-access-tokens-via-api?view=azure-devops
114139
scopes := []string{"499b84ac-1321-427f-aa17-267ca6975798/.default"}
115140
return client.AcquireTokenInteractive(context.Background(), scopes)
116141
}
142+
143+
func getPAT(organization, accessToken string) (PatToken, error) {
144+
// https://learn.microsoft.com/en-us/rest/api/azure/devops/tokens/pats/create?view=azure-devops-rest-7.1&tabs=HTTP
145+
// sadly https://github.com/microsoft/azure-devops-go-api doesn't have this function
146+
url := fmt.Sprintf("https://vssps.dev.azure.com/%s/_apis/tokens/pats?api-version=7.1-preview.1", organization)
147+
j := map[string]any{
148+
"scopes": "vso.code_write vso.packaging",
149+
}
150+
body, err := json.Marshal(j)
151+
if err != nil {
152+
return PatToken{}, err
153+
}
154+
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
155+
if err != nil {
156+
return PatToken{}, err
157+
}
158+
req.Header.Set("Content-Type", "application/json")
159+
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
160+
response, err := http.DefaultClient.Do(req)
161+
if err != nil {
162+
return PatToken{}, err
163+
}
164+
body, err = io.ReadAll(response.Body)
165+
if err != nil {
166+
return PatToken{}, err
167+
}
168+
if verbose {
169+
fmt.Fprintln(os.Stderr, string(body))
170+
}
171+
ptr := PatTokenResult{}
172+
err = json.Unmarshal(body, &ptr)
173+
if err != nil {
174+
return PatToken{}, err
175+
}
176+
if ptr.PatTokenError != "" && ptr.PatTokenError != "none" {
177+
return PatToken{}, errors.New(ptr.PatTokenError)
178+
}
179+
return ptr.PatToken, nil
180+
}
181+
182+
// https://learn.microsoft.com/en-us/rest/api/azure/devops/tokens/pats/create?view=azure-devops-rest-7.1&tabs=HTTP#pattokenresult
183+
type PatTokenResult struct {
184+
PatToken PatToken `json:"patToken"`
185+
PatTokenError string `json:"patTokenError"`
186+
}
187+
188+
// https://learn.microsoft.com/en-us/rest/api/azure/devops/tokens/pats/create?view=azure-devops-rest-7.1&tabs=HTTP#pattoken
189+
type PatToken struct {
190+
Token string `json:"token"`
191+
ValidTo time.Time `json:"validTo"`
192+
}

0 commit comments

Comments
 (0)