Files
coder/cli/externalauth.go
T
Bobby Ho b169f4d8cb 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>
2026-07-07 12:38:37 -07:00

139 lines
3.7 KiB
Go

package cli
import (
"encoding/json"
"github.com/tidwall/gjson"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/serpent"
)
func externalAuth() *serpent.Command {
return &serpent.Command{
Use: "external-auth",
Short: "Manage external authentication",
Long: "Authenticate with external services inside of a workspace.",
Handler: func(i *serpent.Invocation) error {
return i.Command.HelpHandler(i)
},
Children: []*serpent.Command{
externalAuthAccessToken(),
},
}
}
func externalAuthAccessToken() *serpent.Command {
var (
extra string
outputFormat string
)
agentAuth := &AgentAuth{}
cmd := &serpent.Command{
Use: "access-token <provider>",
Short: "Print auth for an external provider",
Long: "Print an access-token for an external auth provider. " +
"The access-token will be validated and sent to stdout with exit code 0. " +
"If a valid access-token cannot be obtained, the URL to authenticate will be sent to stdout with exit code 1\n" + FormatExamples(
Example{
Description: "Ensure that the user is authenticated with GitHub before cloning.",
Command: `#!/usr/bin/env sh
OUTPUT=$(coder external-auth access-token github)
if [ $? -eq 0 ]; then
echo "Authenticated with GitHub"
else
echo "Please authenticate with GitHub:"
echo $OUTPUT
fi
`,
},
Example{
Description: "Obtain an extra property of an access token for additional metadata.",
Command: "coder external-auth access-token slack --extra \"authed_user.id\"",
},
Example{
Description: "Print the full token response as JSON.",
Command: "coder external-auth access-token github --output json",
},
),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{
{
Name: "Extra",
Flag: "extra",
Description: "Extract a field from the \"extra\" properties of the OAuth token.",
Value: serpent.StringOf(&extra),
},
{
Name: "Output",
Flag: "output",
Description: "Output format. Available formats: text, json.",
Value: serpent.EnumOf(&outputFormat, "text", "json"),
Default: "text",
},
},
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...)
defer stop()
client, err := agentAuth.CreateClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
extAuth, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
ID: inv.Args[0],
})
if err != nil {
return xerrors.Errorf("get external auth token: %w", err)
}
switch {
case outputFormat == "json":
data, err := json.MarshalIndent(extAuth, "", " ")
if err != nil {
return xerrors.Errorf("marshal external auth response: %w", err)
}
if _, err := inv.Stdout.Write(data); err != nil {
return err
}
case extAuth.URL != "":
if _, err := inv.Stdout.Write([]byte(extAuth.URL)); err != nil {
return err
}
case extra != "":
if extAuth.TokenExtra == nil {
return xerrors.Errorf("no extra properties found for token")
}
data, err := json.Marshal(extAuth.TokenExtra)
if err != nil {
return xerrors.Errorf("marshal extra properties: %w", err)
}
result := gjson.GetBytes(data, extra)
if _, err := inv.Stdout.Write([]byte(result.String())); err != nil {
return err
}
default:
if _, err := inv.Stdout.Write([]byte(extAuth.AccessToken)); err != nil {
return err
}
}
if extAuth.URL != "" {
return cliui.ErrCanceled
}
return nil
},
}
agentAuth.AttachOptions(cmd, false)
return cmd
}