diff mbox series

[v3] credential: new attribute password_expiry_utc

Message ID pull.1443.v3.git.git.1675545372271.gitgitgadget@gmail.com (mailing list archive)
State Superseded
Headers show
Series [v3] credential: new attribute password_expiry_utc | expand

Commit Message

M Hickford Feb. 4, 2023, 9:16 p.m. UTC
From: M Hickford <mirth.hickford@gmail.com>

Some passwords have an expiry date known at generation. This may be
years away for a personal access token or hours for an OAuth access
token.

When multiple credential helpers are configured, `credential fill` tries
each helper in turn until it has a username and password, returning
early. If Git authentication succeeds, `credential approve`
stores the successful credential in all helpers. If authentication
fails, `credential reject` erases matching credentials in all helpers.
Helpers implement corresponding operations: get, store, erase.

The credential protocol has no expiry attribute, so helpers cannot
store expiry information. (Even if a helper returned an improvised
expiry attribute, git credential discards unrecognised attributes
between operations and between helpers.)

As a workaround, whenever monolithic helper Git Credential Manager (GCM)
retrieves an OAuth credential from its storage, it makes a HTTP request
to check whether the OAuth token has expired [1]. This complicates and
slows the authentication happy path.

Worse is the case that a storage helper and a credential-generating
helper are configured together:

	[credential]
		helper = storage  # eg. cache or osxkeychain
		helper = generate  # eg. oauth or manager

`credential approve` stores the generated credential in both helpers
without expiry information. Later `credential fill` may return an
expired credential from storage. There is no workaround, no matter how
clever the second helper.

Introduce a password expiry attribute. In `credential fill`, ignore
expired passwords and continue to query subsequent helpers.

In the example above, `credential fill` ignores the expired credential
and a fresh credential is generated. If authentication succeeds,
`credential approve` replaces the expired credential in storage.
If authentication fails, the expired credential is erased by
`credential reject`. It is unnecessary but harmless for storage
helpers to self prune expired credentials.

Add support for the new attribute to credential-cache.
Eventually, I hope to see support in other storage helpers.

Example usage in a credential-generating helper
https://github.com/hickford/git-credential-oauth/pull/16

[1] https://github.com/GitCredentialManager/git-credential-manager/blob/66b94e489ad8cc1982836355493e369770b30211/src/shared/GitLab/GitLabHostProvider.cs#L217

Signed-off-by: M Hickford <mirth.hickford@gmail.com>
---
    credential: new attribute password_expiry_utc
    
    details in commit message
    
    Changes in patch v3:
    
     * Tests
     * Simplified credential_read, moved expiry logic to credential_fill

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-1443%2Fhickford%2Fpassword-expiry-v3
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-1443/hickford/password-expiry-v3
Pull-Request: https://github.com/git/git/pull/1443

Range-diff vs v2:

 1:  b9ee729ee4d ! 1:  1846815a5c1 credential: new attribute password_expiry_utc
     @@ Commit message
          years away for a personal access token or hours for an OAuth access
          token.
      
     -    Currently the credential protocol has no expiry attribute. When multiple
     -    helpers are configured, `credential fill` tries each helper in turn
     -    until it has a username and password, returning early.
     +    When multiple credential helpers are configured, `credential fill` tries
     +    each helper in turn until it has a username and password, returning
     +    early. If Git authentication succeeds, `credential approve`
     +    stores the successful credential in all helpers. If authentication
     +    fails, `credential reject` erases matching credentials in all helpers.
     +    Helpers implement corresponding operations: get, store, erase.
      
     -    When a storage helper and a credential-generating helper are configured
     -    together, the credential is necessarily stored without expiry, so
     -    `credential fill` may later return an expired credential from storage.
     +    The credential protocol has no expiry attribute, so helpers cannot
     +    store expiry information. (Even if a helper returned an improvised
     +    expiry attribute, git credential discards unrecognised attributes
     +    between operations and between helpers.)
      
     -    ```
     -    [credential]
     -            helper = storage  # eg. cache or osxkeychain
     -            helper = generate  # eg. oauth
     -    ```
     +    As a workaround, whenever monolithic helper Git Credential Manager (GCM)
     +    retrieves an OAuth credential from its storage, it makes a HTTP request
     +    to check whether the OAuth token has expired [1]. This complicates and
     +    slows the authentication happy path.
      
     -    An improvement is to introduce a password expiry attribute to the
     -    credential protocol. If the expiry date has passed, `credential fill`
     -    ignores the password attribute, so subsequent helpers can generate a
     -    fresh credential. This is backwards compatible -- no change in
     -    behaviour with helpers that discard the expiry attribute.
     +    Worse is the case that a storage helper and a credential-generating
     +    helper are configured together:
      
     -    Note that the expiry logic is entirely within the credential layer.
     -    Compatible helpers store and retrieve the new attribute like any other.
     -    This keeps the helper contract simple.
     +            [credential]
     +                    helper = storage  # eg. cache or osxkeychain
     +                    helper = generate  # eg. oauth or manager
      
     -    This patch adds support for the new attribute to cache.
     +    `credential approve` stores the generated credential in both helpers
     +    without expiry information. Later `credential fill` may return an
     +    expired credential from storage. There is no workaround, no matter how
     +    clever the second helper.
     +
     +    Introduce a password expiry attribute. In `credential fill`, ignore
     +    expired passwords and continue to query subsequent helpers.
     +
     +    In the example above, `credential fill` ignores the expired credential
     +    and a fresh credential is generated. If authentication succeeds,
     +    `credential approve` replaces the expired credential in storage.
     +    If authentication fails, the expired credential is erased by
     +    `credential reject`. It is unnecessary but harmless for storage
     +    helpers to self prune expired credentials.
     +
     +    Add support for the new attribute to credential-cache.
     +    Eventually, I hope to see support in other storage helpers.
      
          Example usage in a credential-generating helper
          https://github.com/hickford/git-credential-oauth/pull/16
      
     -    Future ideas: make it possible for a storage helper to provide OAuth
     -    refresh token to subsequent helpers.
     -    https://github.com/gitgitgadget/git/pull/1394
     +    [1] https://github.com/GitCredentialManager/git-credential-manager/blob/66b94e489ad8cc1982836355493e369770b30211/src/shared/GitLab/GitLabHostProvider.cs#L217
      
          Signed-off-by: M Hickford <mirth.hickford@gmail.com>
      
     @@ Documentation/git-credential.txt: Git understands the following attributes:
       
      +`password_expiry_utc`::
      +
     -+	If password is a personal access token or OAuth access token, it may have an
     -+	expiry date. When getting credentials from a helper, `git credential fill`
     -+	ignores the password attribute if the expiry date has passed. Storage
     -+	helpers should store this attribute if possible. Helpers should not
     -+	implement expiry logic themselves. Represented as Unix time UTC, seconds
     -+	since 1970.
     ++	Generated passwords such as an OAuth access token may have an expiry date.
     ++	When reading credentials from helpers, `git credential fill` ignores expired
     ++	passwords. Represented as Unix time UTC, seconds since 1970.
      +
       `url`::
       
       	When this special attribute is read by `git credential`, the
      
     + ## Documentation/gitcredentials.txt ##
     +@@ Documentation/gitcredentials.txt: helper::
     + If there are multiple instances of the `credential.helper` configuration
     + variable, each helper will be tried in turn, and may provide a username,
     + password, or nothing. Once Git has acquired both a username and a
     +-password, no more helpers will be tried.
     ++unexpired password, no more helpers will be tried.
     + +
     + If `credential.helper` is configured to the empty string, this resets
     + the helper list to empty (so you may override a helper set by a
     +
       ## builtin/credential-cache--daemon.c ##
      @@ builtin/credential-cache--daemon.c: static void serve_one_client(FILE *in, FILE *out)
       		if (e) {
     @@ credential.c
       
       void credential_init(struct credential *c)
       {
     -@@ credential.c: static void credential_getpass(struct credential *c)
     - 	if (!c->username)
     - 		c->username = credential_ask_one("Username", c,
     - 						 PROMPT_ASKPASS|PROMPT_ECHO);
     --	if (!c->password)
     -+	if (!c->password || c->password_expiry_utc < time(NULL)) {
     -+		c->password_expiry_utc = TIME_MAX;
     - 		c->password = credential_ask_one("Password", c,
     - 						 PROMPT_ASKPASS);
     -+	}
     - }
     - 
     - int credential_read(struct credential *c, FILE *fp)
     - {
     - 	struct strbuf line = STRBUF_INIT;
     - 
     -+	int password_updated = 0;
     -+	timestamp_t this_password_expiry = TIME_MAX;
     -+
     - 	while (strbuf_getline(&line, fp) != EOF) {
     - 		char *key = line.buf;
     - 		char *value = strchr(key, '=');
     -@@ credential.c: int credential_read(struct credential *c, FILE *fp)
     - 		} else if (!strcmp(key, "password")) {
     - 			free(c->password);
     - 			c->password = xstrdup(value);
     -+			password_updated = 1;
     - 		} else if (!strcmp(key, "protocol")) {
     - 			free(c->protocol);
     - 			c->protocol = xstrdup(value);
      @@ credential.c: int credential_read(struct credential *c, FILE *fp)
       		} else if (!strcmp(key, "path")) {
       			free(c->path);
       			c->path = xstrdup(value);
      +		} else if (!strcmp(key, "password_expiry_utc")) {
     -+			this_password_expiry = parse_timestamp(value, NULL, 10);
     -+			if (this_password_expiry == 0 || errno) {
     -+				this_password_expiry = TIME_MAX;
     -+			}
     ++			c->password_expiry_utc = parse_timestamp(value, NULL, 10);
     ++			if (c->password_expiry_utc == 0 || errno)
     ++				c->password_expiry_utc = TIME_MAX;
       		} else if (!strcmp(key, "url")) {
       			credential_from_url(c, value);
       		} else if (!strcmp(key, "quit")) {
     -@@ credential.c: int credential_read(struct credential *c, FILE *fp)
     - 		 */
     - 	}
     - 
     -+	if (password_updated)
     -+		c->password_expiry_utc = this_password_expiry;
     -+
     - 	strbuf_release(&line);
     - 	return 0;
     - }
      @@ credential.c: 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.c: void credential_fill(struct credential *c)
       
       	for (i = 0; i < c->helpers.nr; i++) {
       		credential_do(c, c->helpers.items[i].string, "get");
     --		if (c->username && c->password)
     -+		if (c->username && c->password && time(NULL) < c->password_expiry_utc)
     ++		if (c->password_expiry_utc < time(NULL)) {
     ++			FREE_AND_NULL(c->password);
     ++			c->password_expiry_utc = TIME_MAX;
     ++		}
     + 		if (c->username && c->password)
       			return;
       		if (c->quit)
     - 			die("credential helper '%s' told us to quit",
     +@@ credential.c: void credential_approve(struct credential *c)
     + 
     + 	if (c->approved)
     + 		return;
     +-	if (!c->username || !c->password)
     ++	if (!c->username || !c->password || c->password_expiry_utc < time(NULL))
     + 		return;
     + 
     + 	credential_apply_config(c);
     +@@ credential.c: void credential_reject(struct credential *c)
     + 
     + 	FREE_AND_NULL(c->username);
     + 	FREE_AND_NULL(c->password);
     ++	c->password_expiry_utc = TIME_MAX;
     + 	c->approved = 0;
     + }
     + 
      
       ## credential.h ##
      @@ credential.h: struct credential {
     @@ credential.h: struct credential {
       }
       
       /* Initialize a credential structure, setting all fields to empty. */
     +
     + ## t/t0300-credentials.sh ##
     +@@ t/t0300-credentials.sh: test_expect_success 'setup helper scripts' '
     + 	test -z "$pass" || echo password=$pass
     + 	EOF
     + 
     ++	write_script git-credential-verbatim-with-expiry <<-\EOF &&
     ++	user=$1; shift
     ++	pass=$1; shift
     ++	pexpiry=$1; shift
     ++	. ./dump
     ++	test -z "$user" || echo username=$user
     ++	test -z "$pass" || echo password=$pass
     ++	test -z "$pexpiry" || echo password_expiry_utc=$pexpiry
     ++	EOF
     ++
     + 	PATH="$PWD:$PATH"
     + '
     + 
     +@@ t/t0300-credentials.sh: test_expect_success 'credential_fill continues through partial response' '
     + 	EOF
     + '
     + 
     ++test_expect_success 'credential_fill populates password_expiry_utc' '
     ++	check fill "verbatim-with-expiry one two 9999999999" <<-\EOF
     ++	protocol=http
     ++	host=example.com
     ++	--
     ++	protocol=http
     ++	host=example.com
     ++	username=one
     ++	password=two
     ++	password_expiry_utc=9999999999
     ++	--
     ++	verbatim-with-expiry: get
     ++	verbatim-with-expiry: protocol=http
     ++	verbatim-with-expiry: host=example.com
     ++	EOF
     ++'
     ++
     ++test_expect_success 'credential_fill continues through expired password' '
     ++	check fill "verbatim-with-expiry one two 5" "verbatim three four" <<-\EOF
     ++	protocol=http
     ++	host=example.com
     ++	--
     ++	protocol=http
     ++	host=example.com
     ++	username=three
     ++	password=four
     ++	--
     ++	verbatim-with-expiry: get
     ++	verbatim-with-expiry: protocol=http
     ++	verbatim-with-expiry: host=example.com
     ++	verbatim: get
     ++	verbatim: protocol=http
     ++	verbatim: host=example.com
     ++	verbatim: username=one
     ++	EOF
     ++'
     ++
     + test_expect_success 'credential_fill passes along metadata' '
     + 	check fill "verbatim one two" <<-\EOF
     + 	protocol=ftp
     +@@ t/t0300-credentials.sh: test_expect_success 'credential_approve calls all helpers' '
     + 	EOF
     + '
     + 
     ++test_expect_success 'credential_approve stores password expiry' '
     ++	check approve useless <<-\EOF
     ++	protocol=http
     ++	host=example.com
     ++	username=foo
     ++	password=bar
     ++	password_expiry_utc=9999999999
     ++	--
     ++	--
     ++	useless: store
     ++	useless: protocol=http
     ++	useless: host=example.com
     ++	useless: username=foo
     ++	useless: password=bar
     ++	useless: password_expiry_utc=9999999999
     ++	EOF
     ++'
     ++
     + test_expect_success 'do not bother storing password-less credential' '
     + 	check approve useless <<-\EOF
     + 	protocol=http
     +@@ t/t0300-credentials.sh: test_expect_success 'do not bother storing password-less credential' '
     + 	EOF
     + '
     + 
     ++test_expect_success 'credential_approve does not store expired credential' '
     ++	check approve useless <<-\EOF
     ++	protocol=http
     ++	host=example.com
     ++	username=foo
     ++	password=bar
     ++	password_expiry_utc=5
     ++	--
     ++	--
     ++	EOF
     ++'
     + 
     + test_expect_success 'credential_reject calls all helpers' '
     + 	check reject useless "verbatim one two" <<-\EOF
     +@@ t/t0300-credentials.sh: test_expect_success 'credential_reject calls all helpers' '
     + 	EOF
     + '
     + 
     ++test_expect_success 'credential_reject erases expired credential' '
     ++	check reject useless <<-\EOF
     ++	protocol=http
     ++	host=example.com
     ++	username=foo
     ++	password=bar
     ++	password_expiry_utc=5
     ++	--
     ++	--
     ++	useless: erase
     ++	useless: protocol=http
     ++	useless: host=example.com
     ++	useless: username=foo
     ++	useless: password=bar
     ++	useless: password_expiry_utc=5
     ++	EOF
     ++'
     ++
     + test_expect_success 'usernames can be preserved' '
     + 	check fill "verbatim \"\" three" <<-\EOF
     + 	protocol=http


 Documentation/git-credential.txt   |  6 ++
 Documentation/gitcredentials.txt   |  2 +-
 builtin/credential-cache--daemon.c |  3 +
 credential.c                       | 17 +++++-
 credential.h                       |  2 +
 t/t0300-credentials.sh             | 94 ++++++++++++++++++++++++++++++
 6 files changed, 122 insertions(+), 2 deletions(-)


base-commit: 2fc9e9ca3c7505bc60069f11e7ef09b1aeeee473

Comments

Junio C Hamano Feb. 14, 2023, 1:59 a.m. UTC | #1
"M Hickford via GitGitGadget" <gitgitgadget@gmail.com> writes:

> From: M Hickford <mirth.hickford@gmail.com>
>
> Some passwords have an expiry date known at generation. This may be
> years away for a personal access token or hours for an OAuth access
> token.
> ...
>  t/t0300-credentials.sh             | 94 ++++++++++++++++++++++++++++++

https://github.com/git/git/actions/runs/4169057114/jobs/7217377625

Other platforms seem to be OK, but Windows test seems to be unhappy
with it when this topic gets merged to 'seen'.
Martin Ă…gren Feb. 14, 2023, 8:03 a.m. UTC | #2
On Sat, 4 Feb 2023 at 23:03, M Hickford via GitGitGadget
<gitgitgadget@gmail.com> wrote:

> --- a/Documentation/gitcredentials.txt
> +++ b/Documentation/gitcredentials.txt
> @@ -167,7 +167,7 @@ helper::
>  If there are multiple instances of the `credential.helper` configuration
>  variable, each helper will be tried in turn, and may provide a username,
>  password, or nothing. Once Git has acquired both a username and a
> -password, no more helpers will be tried.
> +unexpired password, no more helpers will be tried.

s/a unexpired/an unexpired/ (or "a non-expired", perhaps)

Martin
M Hickford Feb. 14, 2023, 10:36 p.m. UTC | #3
On Tue, 14 Feb 2023 at 01:59, Junio C Hamano <gitster@pobox.com> wrote:
>
> "M Hickford via GitGitGadget" <gitgitgadget@gmail.com> writes:
>
> > From: M Hickford <mirth.hickford@gmail.com>
> >
> > Some passwords have an expiry date known at generation. This may be
> > years away for a personal access token or hours for an OAuth access
> > token.
> > ...
> >  t/t0300-credentials.sh             | 94 ++++++++++++++++++++++++++++++
>
> https://github.com/git/git/actions/runs/4169057114/jobs/7217377625
>
> Other platforms seem to be OK, but Windows test seems to be unhappy
> with it when this topic gets merged to 'seen'.

Curious, let me take a look. I see that the tests failed on freebsd too.

I don't have a Windows machine to debug. If anyone reading has a
hypothesis, please share.

I shall try changing the default value for a password without expiry
from TIME_MAX to 0, see if that works any better [2].

[1] https://github.com/git/git/pull/1443/checks?check_run_id=11112315291
[2] https://github.com/git/git/pull/1443/checks?check_run_id=11343677685
Calvin Wan Feb. 16, 2023, 7:16 p.m. UTC | #4
>  static int run_credential_helper(struct credential *c,
> @@ -342,6 +352,10 @@ void credential_fill(struct credential *c)
>  
>  	for (i = 0; i < c->helpers.nr; i++) {
>  		credential_do(c, c->helpers.items[i].string, "get");
> +		if (c->password_expiry_utc < time(NULL)) {
> +			FREE_AND_NULL(c->password);
> +			c->password_expiry_utc = TIME_MAX;
> +		}
>  		if (c->username && c->password)
>  			return;
>  		if (c->quit)

I see you null out c->password in the expiry if block so that the
following c->password check in the following if statement fails.
While I think it's neat little trick, I wonder if others on list
think it's better to be more explicit with how the logic should
work (eg. adding the c->passowrd_expiry_utc check as an inner
block inside of the c->username && c->password block).
Lessley Dennington Feb. 17, 2023, 9:44 p.m. UTC | #5
On 2/14/23 3:36 PM, M Hickford wrote:
> Curious, let me take a look. I see that the tests failed on freebsd too.
> 
I was curious about this as well and took a look on my Windows machine. It
appears that errno will be set to 'No such file or directory' unless you
explicitly set it before checking it. This is odd, given that the helper script
I wrote worked just fine without this requirement. However, I took a look
through the rest of the codebase and noticed that `errno = 0` seems to always be
declared before this type of conditional check. That did indeed fix the issue -
tests all pass on Windows with this patch. I have not confirmed on freebsd,
though, as a heads up.

Thanks,
Lessley

---

diff --git a/credential.c b/credential.c
index d3e1bf7a67..b9a9a1d7b1 100644
--- a/credential.c
+++ b/credential.c
@@ -236,6 +236,7 @@ int credential_read(struct credential *c, FILE *fp)
                         free(c->path);
                         c->path = xstrdup(value);
                 } else if (!strcmp(key, "password_expiry_utc")) {
+                       errno = 0;
                         c->password_expiry_utc = parse_timestamp(value, NULL, 10);
                         if (c->password_expiry_utc == 0 || errno)
                                 c->password_expiry_utc = TIME_MAX;
Junio C Hamano Feb. 17, 2023, 9:59 p.m. UTC | #6
Lessley Dennington <lessleydennington@gmail.com> writes:

> diff --git a/credential.c b/credential.c
> index d3e1bf7a67..b9a9a1d7b1 100644
> --- a/credential.c
> +++ b/credential.c
> @@ -236,6 +236,7 @@ int credential_read(struct credential *c, FILE *fp)
>                         free(c->path);
>                         c->path = xstrdup(value);
>                 } else if (!strcmp(key, "password_expiry_utc")) {
> +                       errno = 0;
>                         c->password_expiry_utc = parse_timestamp(value, NULL, 10);
>                         if (c->password_expiry_utc == 0 || errno)
>                                 c->password_expiry_utc = TIME_MAX;

Ah, that is quite understandable. Successful library function calls
would not _clera_ errno, so if there were a failure before the
control reaches this codepath, errno may have been set, and then
parse_timestamp() call, which would be a strto$some_integral_type() call,
may succeed and will leave errno as-is.  Your fix is absolutely correct
as long as we want to use "errno" after the call returns.

When strtoumax() etc. wants to report overflow or underflow, the
returned value must be UINTMAX_MAX/UINTMAX_MIN and errno would be
ERANGE, so it would probably want to check that errno is that value,
and/or what c->password_expiry_utc has these overflow/underflow
values.

> ... I have not confirmed on freebsd,
> though, as a heads up.
M Hickford Feb. 18, 2023, 8 a.m. UTC | #7
On Thu, 16 Feb 2023 at 19:16, Calvin Wan <calvinwan@google.com> wrote:
>
> >  static int run_credential_helper(struct credential *c,
> > @@ -342,6 +352,10 @@ void credential_fill(struct credential *c)
> >
> >       for (i = 0; i < c->helpers.nr; i++) {
> >               credential_do(c, c->helpers.items[i].string, "get");
> > +             if (c->password_expiry_utc < time(NULL)) {
> > +                     FREE_AND_NULL(c->password);
> > +                     c->password_expiry_utc = TIME_MAX;
> > +             }
> >               if (c->username && c->password)
> >                       return;
> >               if (c->quit)
>
> I see you null out c->password in the expiry if block so that the
> following c->password check in the following if statement fails.
> While I think it's neat little trick, I wonder if others on list
> think it's better to be more explicit with how the logic should
> work (eg. adding the c->passowrd_expiry_utc check as an inner
> block inside of the c->username && c->password block).

It's important to reset the expiry date as well as discard the expired
password so that fill accepts a later password without expiry (see
test cases). I'll add a comment in patch v4.
M Hickford Feb. 18, 2023, 8 a.m. UTC | #8
On Fri, 17 Feb 2023 at 21:59, Junio C Hamano <gitster@pobox.com> wrote:
>
> Lessley Dennington <lessleydennington@gmail.com> writes:
>
> > diff --git a/credential.c b/credential.c
> > index d3e1bf7a67..b9a9a1d7b1 100644
> > --- a/credential.c
> > +++ b/credential.c
> > @@ -236,6 +236,7 @@ int credential_read(struct credential *c, FILE *fp)
> >                         free(c->path);
> >                         c->path = xstrdup(value);
> >                 } else if (!strcmp(key, "password_expiry_utc")) {
> > +                       errno = 0;
> >                         c->password_expiry_utc = parse_timestamp(value, NULL, 10);
> >                         if (c->password_expiry_utc == 0 || errno)
> >                                 c->password_expiry_utc = TIME_MAX;
>
> Ah, that is quite understandable. Successful library function calls
> would not _clera_ errno, so if there were a failure before the
> control reaches this codepath, errno may have been set, and then
> parse_timestamp() call, which would be a strto$some_integral_type() call,
> may succeed and will leave errno as-is.  Your fix is absolutely correct
> as long as we want to use "errno" after the call returns.
>
> When strtoumax() etc. wants to report overflow or underflow, the
> returned value must be UINTMAX_MAX/UINTMAX_MIN and errno would be
> ERANGE, so it would probably want to check that errno is that value,
> and/or what c->password_expiry_utc has these overflow/underflow
> values.
>
> > ... I have not confirmed on freebsd,
> > though, as a heads up.
>

That's a subtle one! Thanks Lessley very much for your help debugging.
I shall send a patch v4 with this and some minor changes discussed in
review club.
diff mbox series

Patch

diff --git a/Documentation/git-credential.txt b/Documentation/git-credential.txt
index ac2818b9f66..29d184ab824 100644
--- a/Documentation/git-credential.txt
+++ b/Documentation/git-credential.txt
@@ -144,6 +144,12 @@  Git understands the following attributes:
 
 	The credential's password, if we are asking it to be stored.
 
+`password_expiry_utc`::
+
+	Generated passwords such as an OAuth access token may have an expiry date.
+	When reading credentials from helpers, `git credential fill` ignores expired
+	passwords. Represented as Unix time UTC, seconds since 1970.
+
 `url`::
 
 	When this special attribute is read by `git credential`, the
diff --git a/Documentation/gitcredentials.txt b/Documentation/gitcredentials.txt
index 4522471c337..95636b18439 100644
--- a/Documentation/gitcredentials.txt
+++ b/Documentation/gitcredentials.txt
@@ -167,7 +167,7 @@  helper::
 If there are multiple instances of the `credential.helper` configuration
 variable, each helper will be tried in turn, and may provide a username,
 password, or nothing. Once Git has acquired both a username and a
-password, no more helpers will be tried.
+unexpired password, no more helpers will be tried.
 +
 If `credential.helper` is configured to the empty string, this resets
 the helper list to empty (so you may override a helper set by a
diff --git a/builtin/credential-cache--daemon.c b/builtin/credential-cache--daemon.c
index f3c89831d4a..338058be7f9 100644
--- a/builtin/credential-cache--daemon.c
+++ b/builtin/credential-cache--daemon.c
@@ -127,6 +127,9 @@  static void serve_one_client(FILE *in, FILE *out)
 		if (e) {
 			fprintf(out, "username=%s\n", e->item.username);
 			fprintf(out, "password=%s\n", e->item.password);
+			if (e->item.password_expiry_utc != TIME_MAX)
+				fprintf(out, "password_expiry_utc=%"PRItime"\n",
+					e->item.password_expiry_utc);
 		}
 	}
 	else if (!strcmp(action.buf, "exit")) {
diff --git a/credential.c b/credential.c
index f6389a50684..d3e1bf7a679 100644
--- a/credential.c
+++ b/credential.c
@@ -7,6 +7,7 @@ 
 #include "prompt.h"
 #include "sigchain.h"
 #include "urlmatch.h"
+#include "git-compat-util.h"
 
 void credential_init(struct credential *c)
 {
@@ -234,6 +235,10 @@  int credential_read(struct credential *c, FILE *fp)
 		} else if (!strcmp(key, "path")) {
 			free(c->path);
 			c->path = xstrdup(value);
+		} else if (!strcmp(key, "password_expiry_utc")) {
+			c->password_expiry_utc = parse_timestamp(value, NULL, 10);
+			if (c->password_expiry_utc == 0 || errno)
+				c->password_expiry_utc = TIME_MAX;
 		} else if (!strcmp(key, "url")) {
 			credential_from_url(c, value);
 		} else if (!strcmp(key, "quit")) {
@@ -269,6 +274,11 @@  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);
+	if (c->password_expiry_utc != TIME_MAX) {
+		char *s = xstrfmt("%"PRItime, c->password_expiry_utc);
+		credential_write_item(fp, "password_expiry_utc", s, 0);
+		free(s);
+	}
 }
 
 static int run_credential_helper(struct credential *c,
@@ -342,6 +352,10 @@  void credential_fill(struct credential *c)
 
 	for (i = 0; i < c->helpers.nr; i++) {
 		credential_do(c, c->helpers.items[i].string, "get");
+		if (c->password_expiry_utc < time(NULL)) {
+			FREE_AND_NULL(c->password);
+			c->password_expiry_utc = TIME_MAX;
+		}
 		if (c->username && c->password)
 			return;
 		if (c->quit)
@@ -360,7 +374,7 @@  void credential_approve(struct credential *c)
 
 	if (c->approved)
 		return;
-	if (!c->username || !c->password)
+	if (!c->username || !c->password || c->password_expiry_utc < time(NULL))
 		return;
 
 	credential_apply_config(c);
@@ -381,6 +395,7 @@  void credential_reject(struct credential *c)
 
 	FREE_AND_NULL(c->username);
 	FREE_AND_NULL(c->password);
+	c->password_expiry_utc = TIME_MAX;
 	c->approved = 0;
 }
 
diff --git a/credential.h b/credential.h
index f430e77fea4..935b28a70f1 100644
--- a/credential.h
+++ b/credential.h
@@ -126,10 +126,12 @@  struct credential {
 	char *protocol;
 	char *host;
 	char *path;
+	timestamp_t password_expiry_utc;
 };
 
 #define CREDENTIAL_INIT { \
 	.helpers = STRING_LIST_INIT_DUP, \
+	.password_expiry_utc = TIME_MAX, \
 }
 
 /* Initialize a credential structure, setting all fields to empty. */
diff --git a/t/t0300-credentials.sh b/t/t0300-credentials.sh
index 3485c0534e6..96391015af5 100755
--- a/t/t0300-credentials.sh
+++ b/t/t0300-credentials.sh
@@ -35,6 +35,16 @@  test_expect_success 'setup helper scripts' '
 	test -z "$pass" || echo password=$pass
 	EOF
 
+	write_script git-credential-verbatim-with-expiry <<-\EOF &&
+	user=$1; shift
+	pass=$1; shift
+	pexpiry=$1; shift
+	. ./dump
+	test -z "$user" || echo username=$user
+	test -z "$pass" || echo password=$pass
+	test -z "$pexpiry" || echo password_expiry_utc=$pexpiry
+	EOF
+
 	PATH="$PWD:$PATH"
 '
 
@@ -109,6 +119,43 @@  test_expect_success 'credential_fill continues through partial response' '
 	EOF
 '
 
+test_expect_success 'credential_fill populates password_expiry_utc' '
+	check fill "verbatim-with-expiry one two 9999999999" <<-\EOF
+	protocol=http
+	host=example.com
+	--
+	protocol=http
+	host=example.com
+	username=one
+	password=two
+	password_expiry_utc=9999999999
+	--
+	verbatim-with-expiry: get
+	verbatim-with-expiry: protocol=http
+	verbatim-with-expiry: host=example.com
+	EOF
+'
+
+test_expect_success 'credential_fill continues through expired password' '
+	check fill "verbatim-with-expiry one two 5" "verbatim three four" <<-\EOF
+	protocol=http
+	host=example.com
+	--
+	protocol=http
+	host=example.com
+	username=three
+	password=four
+	--
+	verbatim-with-expiry: get
+	verbatim-with-expiry: protocol=http
+	verbatim-with-expiry: host=example.com
+	verbatim: get
+	verbatim: protocol=http
+	verbatim: host=example.com
+	verbatim: username=one
+	EOF
+'
+
 test_expect_success 'credential_fill passes along metadata' '
 	check fill "verbatim one two" <<-\EOF
 	protocol=ftp
@@ -149,6 +196,24 @@  test_expect_success 'credential_approve calls all helpers' '
 	EOF
 '
 
+test_expect_success 'credential_approve stores password expiry' '
+	check approve useless <<-\EOF
+	protocol=http
+	host=example.com
+	username=foo
+	password=bar
+	password_expiry_utc=9999999999
+	--
+	--
+	useless: store
+	useless: protocol=http
+	useless: host=example.com
+	useless: username=foo
+	useless: password=bar
+	useless: password_expiry_utc=9999999999
+	EOF
+'
+
 test_expect_success 'do not bother storing password-less credential' '
 	check approve useless <<-\EOF
 	protocol=http
@@ -159,6 +224,17 @@  test_expect_success 'do not bother storing password-less credential' '
 	EOF
 '
 
+test_expect_success 'credential_approve does not store expired credential' '
+	check approve useless <<-\EOF
+	protocol=http
+	host=example.com
+	username=foo
+	password=bar
+	password_expiry_utc=5
+	--
+	--
+	EOF
+'
 
 test_expect_success 'credential_reject calls all helpers' '
 	check reject useless "verbatim one two" <<-\EOF
@@ -181,6 +257,24 @@  test_expect_success 'credential_reject calls all helpers' '
 	EOF
 '
 
+test_expect_success 'credential_reject erases expired credential' '
+	check reject useless <<-\EOF
+	protocol=http
+	host=example.com
+	username=foo
+	password=bar
+	password_expiry_utc=5
+	--
+	--
+	useless: erase
+	useless: protocol=http
+	useless: host=example.com
+	useless: username=foo
+	useless: password=bar
+	useless: password_expiry_utc=5
+	EOF
+'
+
 test_expect_success 'usernames can be preserved' '
 	check fill "verbatim \"\" three" <<-\EOF
 	protocol=http