Skip to content

Commit a5c7656

Browse files
hickfordgitster
authored andcommitted
credential: new attribute oauth_refresh_token
Git authentication with OAuth access token is supported by every popular Git host including GitHub, GitLab and BitBucket [1][2][3]. Credential helpers Git Credential Manager (GCM) and git-credential-oauth generate OAuth credentials [4][5]. Following RFC 6749, the application prints a link for the user to authorize access in browser. A loopback redirect communicates the response including access token to the application. For security, RFC 6749 recommends that OAuth response also includes expiry date and refresh token [6]. After expiry, applications can use the refresh token to generate a new access token without user reauthorization in browser. GitLab and BitBucket set the expiry at two hours [2][3]. (GitHub doesn't populate expiry or refresh token.) However the Git credential protocol has no attribute to store the OAuth refresh token (unrecognised attributes are silently discarded). This means that the user has to regularly reauthorize the helper in browser. On a browserless system, this is particularly intrusive, requiring a second device. Introduce a new attribute oauth_refresh_token. This is especially useful when a storage helper and a read-only OAuth helper are configured together. Recall that `credential fill` calls each helper until it has a non-expired password. ``` [credential] helper = storage # eg. cache or osxkeychain helper = oauth ``` The OAuth helper can use the stored refresh token forwarded by `credential fill` to generate a fresh access token without opening the browser. See https://github.com/hickford/git-credential-oauth/pull/3/files for an implementation tested with this patch. Add support for the new attribute to credential-cache. Eventually, I hope to see support in other popular storage helpers. Alternatives considered: ask helpers to store all unrecognised attributes. This seems excessively complex for no obvious gain. Helpers would also need extra information to distinguish between confidential and non-confidential attributes. Workarounds: GCM abuses the helper get/store/erase contract to store the refresh token during credential *get* as the password for a fictitious host [7] (I wrote this hack). This workaround is only feasible for a monolithic helper with its own storage. [1] https://github.blog/2012-09-21-easier-builds-and-deployments-using-git-over-https-and-oauth/ [2] https://docs.gitlab.com/ee/api/oauth2.html#access-git-over-https-with-access-token [3] https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/#Cloning-a-repository-with-an-access-token [4] https://github.com/GitCredentialManager/git-credential-manager [5] https://github.com/hickford/git-credential-oauth [6] https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 [7] https://github.com/GitCredentialManager/git-credential-manager/blob/66b94e489ad8cc1982836355493e369770b30211/src/shared/GitLab/GitLabHostProvider.cs#L207 Signed-off-by: M Hickford <[email protected]> Signed-off-by: Junio C Hamano <[email protected]>
1 parent 73876f4 commit a5c7656

File tree

7 files changed

+65
-0
lines changed

7 files changed

+65
-0
lines changed

Documentation/git-credential.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ Git understands the following attributes:
150150
When reading credentials from helpers, `git credential fill` ignores expired
151151
passwords. Represented as Unix time UTC, seconds since 1970.
152152

153+
`oauth_refresh_token`::
154+
155+
An OAuth refresh token may accompany a password that is an OAuth access
156+
token. Helpers must treat this attribute as confidential like the password
157+
attribute. Git itself has no special behaviour for this attribute.
158+
153159
`url`::
154160

155161
When this special attribute is read by `git credential`, the

builtin/credential-cache--daemon.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ static void serve_one_client(FILE *in, FILE *out)
130130
if (e->item.password_expiry_utc != TIME_MAX)
131131
fprintf(out, "password_expiry_utc=%"PRItime"\n",
132132
e->item.password_expiry_utc);
133+
if (e->item.oauth_refresh_token)
134+
fprintf(out, "oauth_refresh_token=%s\n",
135+
e->item.oauth_refresh_token);
133136
}
134137
}
135138
else if (!strcmp(action.buf, "exit")) {

credential.c

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ void credential_clear(struct credential *c)
2222
free(c->path);
2323
free(c->username);
2424
free(c->password);
25+
free(c->oauth_refresh_token);
2526
string_list_clear(&c->helpers, 0);
2627

2728
credential_init(c);
@@ -240,6 +241,9 @@ int credential_read(struct credential *c, FILE *fp)
240241
c->password_expiry_utc = parse_timestamp(value, NULL, 10);
241242
if (c->password_expiry_utc == 0 || errno == ERANGE)
242243
c->password_expiry_utc = TIME_MAX;
244+
} else if (!strcmp(key, "oauth_refresh_token")) {
245+
free(c->oauth_refresh_token);
246+
c->oauth_refresh_token = xstrdup(value);
243247
} else if (!strcmp(key, "url")) {
244248
credential_from_url(c, value);
245249
} else if (!strcmp(key, "quit")) {
@@ -275,6 +279,7 @@ void credential_write(const struct credential *c, FILE *fp)
275279
credential_write_item(fp, "path", c->path, 0);
276280
credential_write_item(fp, "username", c->username, 0);
277281
credential_write_item(fp, "password", c->password, 0);
282+
credential_write_item(fp, "oauth_refresh_token", c->oauth_refresh_token, 0);
278283
if (c->password_expiry_utc != TIME_MAX) {
279284
char *s = xstrfmt("%"PRItime, c->password_expiry_utc);
280285
credential_write_item(fp, "password_expiry_utc", s, 0);
@@ -398,6 +403,7 @@ void credential_reject(struct credential *c)
398403

399404
FREE_AND_NULL(c->username);
400405
FREE_AND_NULL(c->password);
406+
FREE_AND_NULL(c->oauth_refresh_token);
401407
c->password_expiry_utc = TIME_MAX;
402408
c->approved = 0;
403409
}

credential.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ struct credential {
126126
char *protocol;
127127
char *host;
128128
char *path;
129+
char *oauth_refresh_token;
129130
timestamp_t password_expiry_utc;
130131
};
131132

t/lib-credential.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ helper_test_clean() {
4343
reject $1 https example.com store-user
4444
reject $1 https example.com user1
4545
reject $1 https example.com user2
46+
reject $1 https example.com user4
4647
reject $1 http path.tld user
4748
reject $1 https timeout.tld user
4849
reject $1 https sso.tld
@@ -298,6 +299,35 @@ helper_test_timeout() {
298299
'
299300
}
300301

302+
helper_test_oauth_refresh_token() {
303+
HELPER=$1
304+
305+
test_expect_success "helper ($HELPER) stores oauth_refresh_token" '
306+
check approve $HELPER <<-\EOF
307+
protocol=https
308+
host=example.com
309+
username=user4
310+
password=pass
311+
oauth_refresh_token=xyzzy
312+
EOF
313+
'
314+
315+
test_expect_success "helper ($HELPER) gets oauth_refresh_token" '
316+
check fill $HELPER <<-\EOF
317+
protocol=https
318+
host=example.com
319+
username=user4
320+
--
321+
protocol=https
322+
host=example.com
323+
username=user4
324+
password=pass
325+
oauth_refresh_token=xyzzy
326+
--
327+
EOF
328+
'
329+
}
330+
301331
write_script askpass <<\EOF
302332
echo >&2 askpass: $*
303333
what=$(echo $1 | cut -d" " -f1 | tr A-Z a-z | tr -cd a-z)

t/t0300-credentials.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,24 @@ test_expect_success 'credential_approve stores password expiry' '
214214
EOF
215215
'
216216

217+
test_expect_success 'credential_approve stores oauth refresh token' '
218+
check approve useless <<-\EOF
219+
protocol=http
220+
host=example.com
221+
username=foo
222+
password=bar
223+
oauth_refresh_token=xyzzy
224+
--
225+
--
226+
useless: store
227+
useless: protocol=http
228+
useless: host=example.com
229+
useless: username=foo
230+
useless: password=bar
231+
useless: oauth_refresh_token=xyzzy
232+
EOF
233+
'
234+
217235
test_expect_success 'do not bother storing password-less credential' '
218236
check approve useless <<-\EOF
219237
protocol=http

t/t0301-credential-cache.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ test_atexit 'git credential-cache exit'
2929

3030
# test that the daemon works with no special setup
3131
helper_test cache
32+
helper_test_oauth_refresh_token cache
3233

3334
test_expect_success 'socket defaults to ~/.cache/git/credential/socket' '
3435
test_when_finished "

0 commit comments

Comments
 (0)