feat: expose external auth token expiry in agent API and CLI (#26883)

Previously, \`ExternalAuthResponse\` contained no expiry information, so
workspace agents and git credential helpers had no way to know when a
cached token would stop being valid. Every git operation had to call
back to coderd via \`GIT_ASKPASS\` to get a fresh token, adding 1-2
seconds of latency.

This PR surfaces \`OAuthExpiry\` from the database as \`ExpiresAt\` in
\`ExternalAuthResponse\`, allowing agents to cache tokens with correct
eviction timing (compatible with \`git-credential-cache --timeout\` and
\`password_expiry_utc\` introduced in git 2.34).

\`ExpiresAt\` is normalized to UTC before JSON encoding to avoid
sub-minute precision loss that occurs when the PostgreSQL driver applies
historical Local Mean Time (LMT) timezone offsets to year-1 AD
timestamps.

The \`coder external-auth access-token\` CLI command gains \`--output
json\` to print the full response including \`ExpiresAt\`, enabling
scripts to consume the expiry without parsing heuristics.

Closes https://github.com/coder/coder/issues/26036

## Manual Test

<details>
<summary>Setup</summary>

1. Create a GitHub OAuth app at https://github.com/settings/developers
with:
   - Homepage URL: `http://127.0.0.1:3000`
- Authorization callback URL:
`http://127.0.0.1:3000/external-auth/github/callback`

2. Start the dev server with the GitHub provider configured:
   ```sh
CODER_EXTERNAL_AUTH_0_ID=github CODER_EXTERNAL_AUTH_0_TYPE=github
CODER_EXTERNAL_AUTH_0_CLIENT_ID=<client-id>
CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=<client-secret> ./scripts/develop.sh
   ```

3. Log in at `http://127.0.0.1:3000` (use `127.0.0.1`, not `localhost`,
so the OAuth state cookie domain matches the callback URL).

4. Go to Account > External Authentication and click **Connect** next to
GitHub. Complete the OAuth flow.

5. Create a workspace and SSH into it:
   ```sh
   coder create test-workspace
   coder ssh test-workspace
   ```

</details>

<details>
<summary>Flow 1: Token is valid — JSON output includes
<code>expires_at</code></summary>

Inside the workspace, run:

```sh
coder external-auth access-token github --output json
echo "Exit code: $?"
```

Expected output (GitHub tokens have no expiry, so \`expires_at\` is the
zero value):

```json
{
  "access_token": "<redacted>",
  "token_extra": null,
  "url": "",
  "type": "github",
  "expires_at": "0001-01-01T00:00:00Z",
  "username": "<redacted>",
  "password": ""
}
```

```
Exit code: 0
```

</details>

<details>
<summary>Flow 2: Token missing — JSON output includes auth URL, exit
code 1</summary>

Disconnect GitHub in the Coder UI (Account > External Authentication >
Disconnect), then inside the workspace run:

```sh
coder external-auth access-token github --output json
echo "Exit code: $?"
```

Expected output:

```json
{
  "access_token": "",
  "token_extra": null,
  "url": "http://127.0.0.1:3000/external-auth/github",
  "type": "",
  "expires_at": "0001-01-01T00:00:00Z",
  "username": "",
  "password": ""
}
```

```
Exit code: 1
```

</details>
This commit is contained in:
Bobby Ho
2026-07-07 12:38:37 -07:00
committed by GitHub
parent b3766d62be
commit b169f4d8cb
12 changed files with 359 additions and 30 deletions
+104
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
@@ -25,6 +26,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/timestamppb"
"tailscale.com/tailcfg"
@@ -3698,3 +3700,105 @@ func (p *pubsubReinitSpy) Subscribe(event string, listener pubsub.Listener) (can
p.Unlock()
return cancel, err
}
// TestWorkspaceAgentsExternalAuthExpiresAt verifies that the expiry stored on
// an ExternalAuthLink is returned in ExternalAuthResponse.ExpiresAt via the
// full HTTP round-trip, covering both a non-zero and zero expiry.
func TestWorkspaceAgentsExternalAuthExpiresAt(t *testing.T) {
t.Parallel()
const providerID = "test-provider"
// seedToken is both the access token value stored in the DB and the one
// the fake OAuth2 provider returns. When they match, RefreshToken detects
// no change and skips the DB update, preserving the seeded OAuthExpiry.
const seedToken = "seed-token"
// newSetup creates a coderdtest server with a minimal external-auth
// provider that has no ValidateURL (all tokens accepted as valid) and
// returns seedToken so that RefreshToken does not overwrite the link.
newSetup := func(t *testing.T) (agentToken string, agentClient *agentsdk.Client, db database.Store, ownerID uuid.UUID) {
t.Helper()
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{{
InstrumentedOAuth2Config: &testutil.OAuth2Config{
// Return seedToken so token.AccessToken == originalAccessToken
// in RefreshToken, preventing a DB update that would overwrite
// the seeded OAuthExpiry.
Token: &oauth2.Token{
AccessToken: seedToken,
RefreshToken: "refresh-token",
Expiry: dbtime.Now().Add(24 * time.Hour),
},
},
ID: providerID,
Regex: regexp.MustCompile(`.*`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
// ValidateURL intentionally omitted: tokens are always valid.
}},
})
first := coderdtest.CreateFirstUser(t, ownerClient)
_, user := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID)
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: first.OrganizationID,
OwnerID: user.ID,
}).WithAgent().Do()
ac := agentsdk.New(ownerClient.URL, agentsdk.WithFixedToken(r.AgentToken))
return r.AgentToken, ac, db, user.ID
}
t.Run("NonZeroExpiry", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, agentClient, db, userID := newSetup(t)
// Seed a link with an 8-hour expiry and verify the response carries it.
want := dbtime.Now().Add(8 * time.Hour).UTC().Truncate(time.Second)
dbgen.ExternalAuthLink(t, db, database.ExternalAuthLink{
ProviderID: providerID,
UserID: userID,
OAuthAccessToken: seedToken,
OAuthExpiry: want,
})
resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
ID: providerID,
})
require.NoError(t, err)
require.Empty(t, resp.URL, "token should be valid, no redirect URL expected")
require.Equal(t, want, resp.ExpiresAt.UTC().Truncate(time.Second),
"ExpiresAt should match the expiry stored in the database")
})
t.Run("ZeroExpiry", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, agentClient, db, userID := newSetup(t)
// dbgen.ExternalAuthLink uses takeFirst which skips zero time.Time
// values and fills in a 24-hour default. Insert the link directly to
// store an explicit zero OAuthExpiry (token never expires).
_, err := db.InsertExternalAuthLink(dbauthz.AsSystemRestricted(ctx), database.InsertExternalAuthLinkParams{
ProviderID: providerID,
UserID: userID,
OAuthAccessToken: seedToken,
OAuthExpiry: time.Time{},
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
resp, err := agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
ID: providerID,
})
require.NoError(t, err)
require.Empty(t, resp.URL)
require.True(t, resp.ExpiresAt.IsZero(),
"ExpiresAt should be zero when the token has no expiry")
})
}