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 <mirth.hickford@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

M Hickford committed Apr 21, 2023 at 09:47 UTC a5c76569e798ad3656afe6b67f37cbbb2e47f28c
7 files changed +65
Documentation/git-credential.txt
+6
@@ -150,6 +150,12 @@ Git understands the following attributes:
150 When reading credentials from helpers, `git credential fill` ignores expired
151 passwords. Represented as Unix time UTC, seconds since 1970.
152
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 +
159 `url`::
160
161 When this special attribute is read by `git credential`, the
builtin/credential-cache--daemon.c
+3
@@ -130,6 +130,9 @@ static void serve_one_client(FILE *in, FILE *out)
130 if (e->item.password_expiry_utc != TIME_MAX)
131 fprintf(out, "password_expiry_utc=%"PRItime"\n",
132 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);
136 }
137 }
138 else if (!strcmp(action.buf, "exit")) {
credential.c
+6
@@ -22,6 +22,7 @@ void credential_clear(struct credential *c)
22 free(c->path);
23 free(c->username);
24 free(c->password);
25 + free(c->oauth_refresh_token);
26 string_list_clear(&c->helpers, 0);
27
28 credential_init(c);
@@ -240,6 +241,9 @@ int credential_read(struct credential *c, FILE *fp)
241 c->password_expiry_utc = parse_timestamp(value, NULL, 10);
242 if (c->password_expiry_utc == 0 || errno == ERANGE)
243 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);
247 } else if (!strcmp(key, "url")) {
248 credential_from_url(c, value);
249 } else if (!strcmp(key, "quit")) {
@@ -275,6 +279,7 @@ void credential_write(const struct credential *c, FILE *fp)
279 credential_write_item(fp, "path", c->path, 0);
280 credential_write_item(fp, "username", c->username, 0);
281 credential_write_item(fp, "password", c->password, 0);
282 + credential_write_item(fp, "oauth_refresh_token", c->oauth_refresh_token, 0);
283 if (c->password_expiry_utc != TIME_MAX) {
284 char *s = xstrfmt("%"PRItime, c->password_expiry_utc);
285 credential_write_item(fp, "password_expiry_utc", s, 0);
@@ -398,6 +403,7 @@ void credential_reject(struct credential *c)
403
404 FREE_AND_NULL(c->username);
405 FREE_AND_NULL(c->password);
406 + FREE_AND_NULL(c->oauth_refresh_token);
407 c->password_expiry_utc = TIME_MAX;
408 c->approved = 0;
409 }
credential.h
+1
@@ -126,6 +126,7 @@ struct credential {
126 char *protocol;
127 char *host;
128 char *path;
129 + char *oauth_refresh_token;
130 timestamp_t password_expiry_utc;
131 };
132
t/lib-credential.sh
+30
@@ -43,6 +43,7 @@ helper_test_clean() {
43 reject $1 https example.com store-user
44 reject $1 https example.com user1
45 reject $1 https example.com user2
46 + reject $1 https example.com user4
47 reject $1 http path.tld user
48 reject $1 https timeout.tld user
49 reject $1 https sso.tld
@@ -298,6 +299,35 @@ helper_test_timeout() {
299 '
300 }
301
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 +
331 write_script askpass <<\EOF
332 echo >&2 askpass: $*
333 what=$(echo $1 | cut -d" " -f1 | tr A-Z a-z | tr -cd a-z)
t/t0300-credentials.sh
+18
@@ -214,6 +214,24 @@ test_expect_success 'credential_approve stores password expiry' '
214 EOF
215 '
216
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 +
235 test_expect_success 'do not bother storing password-less credential' '
236 check approve useless <<-\EOF
237 protocol=http
t/t0301-credential-cache.sh
+1
@@ -29,6 +29,7 @@ test_atexit 'git credential-cache exit'
29
30 # test that the daemon works with no special setup
31 helper_test cache
32 +helper_test_oauth_refresh_token cache
33
34 test_expect_success 'socket defaults to ~/.cache/git/credential/socket' '
35 test_when_finished "