]> git.ipfire.org Git - thirdparty/git.git/commitdiff
credential: new attribute oauth_refresh_token
authorM Hickford <mirth.hickford@gmail.com>
Fri, 21 Apr 2023 09:47:59 +0000 (09:47 +0000)
committerJunio C Hamano <gitster@pobox.com>
Fri, 21 Apr 2023 16:38:30 +0000 (09:38 -0700)
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>
Documentation/git-credential.txt
builtin/credential-cache--daemon.c
credential.c
credential.h
t/lib-credential.sh
t/t0300-credentials.sh
t/t0301-credential-cache.sh

index 29d184ab82420c3177b11df875e38efa016a01d9..3677d68ce619c3ae28e3524e1bf2ad845293c63e 100644 (file)
@@ -150,6 +150,12 @@ Git understands the following attributes:
        When reading credentials from helpers, `git credential fill` ignores expired
        passwords. Represented as Unix time UTC, seconds since 1970.
 
+`oauth_refresh_token`::
+
+       An OAuth refresh token may accompany a password that is an OAuth access
+       token. Helpers must treat this attribute as confidential like the password
+       attribute. Git itself has no special behaviour for this attribute.
+
 `url`::
 
        When this special attribute is read by `git credential`, the
index 338058be7f943759619965911a7ad18f19263e48..f253a8278dccdcc81d6115a5c3ae65f05176eef1 100644 (file)
@@ -130,6 +130,9 @@ static void serve_one_client(FILE *in, FILE *out)
                        if (e->item.password_expiry_utc != TIME_MAX)
                                fprintf(out, "password_expiry_utc=%"PRItime"\n",
                                        e->item.password_expiry_utc);
+                       if (e->item.oauth_refresh_token)
+                               fprintf(out, "oauth_refresh_token=%s\n",
+                                       e->item.oauth_refresh_token);
                }
        }
        else if (!strcmp(action.buf, "exit")) {
index f32011343f9400a80cb9999fe75e0eeefde48146..3b5d3d058cf92d3059412d082c60369e9c5e257f 100644 (file)
@@ -22,6 +22,7 @@ void credential_clear(struct credential *c)
        free(c->path);
        free(c->username);
        free(c->password);
+       free(c->oauth_refresh_token);
        string_list_clear(&c->helpers, 0);
 
        credential_init(c);
@@ -240,6 +241,9 @@ int credential_read(struct credential *c, FILE *fp)
                        c->password_expiry_utc = parse_timestamp(value, NULL, 10);
                        if (c->password_expiry_utc == 0 || errno == ERANGE)
                                c->password_expiry_utc = TIME_MAX;
+               } else if (!strcmp(key, "oauth_refresh_token")) {
+                       free(c->oauth_refresh_token);
+                       c->oauth_refresh_token = xstrdup(value);
                } else if (!strcmp(key, "url")) {
                        credential_from_url(c, value);
                } else if (!strcmp(key, "quit")) {
@@ -275,6 +279,7 @@ void credential_write(const struct credential *c, FILE *fp)
        credential_write_item(fp, "path", c->path, 0);
        credential_write_item(fp, "username", c->username, 0);
        credential_write_item(fp, "password", c->password, 0);
+       credential_write_item(fp, "oauth_refresh_token", c->oauth_refresh_token, 0);
        if (c->password_expiry_utc != TIME_MAX) {
                char *s = xstrfmt("%"PRItime, c->password_expiry_utc);
                credential_write_item(fp, "password_expiry_utc", s, 0);
@@ -398,6 +403,7 @@ void credential_reject(struct credential *c)
 
        FREE_AND_NULL(c->username);
        FREE_AND_NULL(c->password);
+       FREE_AND_NULL(c->oauth_refresh_token);
        c->password_expiry_utc = TIME_MAX;
        c->approved = 0;
 }
index 935b28a70f16ec788e9df0126ccfb00fa86de7cd..b2eda372461210ad698f2e208d058338b585e2bb 100644 (file)
@@ -126,6 +126,7 @@ struct credential {
        char *protocol;
        char *host;
        char *path;
+       char *oauth_refresh_token;
        timestamp_t password_expiry_utc;
 };
 
index 5ea8bc9f1dcfe644da4b9ce720f2a523ff172475..33666406d92727f84a0a86a1211825d966d3d141 100644 (file)
@@ -43,6 +43,7 @@ helper_test_clean() {
        reject $1 https example.com store-user
        reject $1 https example.com user1
        reject $1 https example.com user2
+       reject $1 https example.com user4
        reject $1 http path.tld user
        reject $1 https timeout.tld user
        reject $1 https sso.tld
@@ -298,6 +299,35 @@ helper_test_timeout() {
        '
 }
 
+helper_test_oauth_refresh_token() {
+       HELPER=$1
+
+       test_expect_success "helper ($HELPER) stores oauth_refresh_token" '
+               check approve $HELPER <<-\EOF
+               protocol=https
+               host=example.com
+               username=user4
+               password=pass
+               oauth_refresh_token=xyzzy
+               EOF
+       '
+
+       test_expect_success "helper ($HELPER) gets oauth_refresh_token" '
+               check fill $HELPER <<-\EOF
+               protocol=https
+               host=example.com
+               username=user4
+               --
+               protocol=https
+               host=example.com
+               username=user4
+               password=pass
+               oauth_refresh_token=xyzzy
+               --
+               EOF
+       '
+}
+
 write_script askpass <<\EOF
 echo >&2 askpass: $*
 what=$(echo $1 | cut -d" " -f1 | tr A-Z a-z | tr -cd a-z)
index c66d91e82d8bc737bc6b909c9427f163aef5dfea..b49fc14a2bd8f125869efe86009ad620b660da85 100755 (executable)
@@ -214,6 +214,24 @@ test_expect_success 'credential_approve stores password expiry' '
        EOF
 '
 
+test_expect_success 'credential_approve stores oauth refresh token' '
+       check approve useless <<-\EOF
+       protocol=http
+       host=example.com
+       username=foo
+       password=bar
+       oauth_refresh_token=xyzzy
+       --
+       --
+       useless: store
+       useless: protocol=http
+       useless: host=example.com
+       useless: username=foo
+       useless: password=bar
+       useless: oauth_refresh_token=xyzzy
+       EOF
+'
+
 test_expect_success 'do not bother storing password-less credential' '
        check approve useless <<-\EOF
        protocol=http
index 698b7159f030905fb63ce4f6856d7ecf49f50b02..c02a3b5969c3d2265d1552132464df7135cd522b 100755 (executable)
@@ -29,6 +29,7 @@ test_atexit 'git credential-cache exit'
 
 # test that the daemon works with no special setup
 helper_test cache
+helper_test_oauth_refresh_token cache
 
 test_expect_success 'socket defaults to ~/.cache/git/credential/socket' '
        test_when_finished "