mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -2135,7 +2135,7 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
|
||||
})
|
||||
return
|
||||
}
|
||||
resp, err := createExternalAuthResponse(externalAuthConfig.Type, refreshedLink.OAuthAccessToken, refreshedLink.OAuthExtra)
|
||||
resp, err := createExternalAuthResponse(externalAuthConfig.Type, refreshedLink.OAuthAccessToken, refreshedLink.OAuthExtra, refreshedLink.OAuthExpiry)
|
||||
if err != nil {
|
||||
handleRetrying(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to create external auth response.",
|
||||
@@ -2208,7 +2208,7 @@ func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.R
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra)
|
||||
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra, externalAuthLink.OAuthExpiry)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to create external auth response.",
|
||||
@@ -2375,7 +2375,7 @@ func fillCoderDesktopTelemetry(r *http.Request, event *telemetry.UserTailnetConn
|
||||
// createExternalAuthResponse creates an ExternalAuthResponse based on the
|
||||
// provider type. This is to support legacy `/workspaceagents/me/gitauth`
|
||||
// which uses `Username` and `Password`.
|
||||
func createExternalAuthResponse(typ, token string, extra pqtype.NullRawMessage) (agentsdk.ExternalAuthResponse, error) {
|
||||
func createExternalAuthResponse(typ, token string, extra pqtype.NullRawMessage, expiry time.Time) (agentsdk.ExternalAuthResponse, error) {
|
||||
var resp agentsdk.ExternalAuthResponse
|
||||
switch typ {
|
||||
case string(codersdk.EnhancedExternalAuthProviderGitLab):
|
||||
@@ -2398,6 +2398,10 @@ func createExternalAuthResponse(typ, token string, extra pqtype.NullRawMessage)
|
||||
}
|
||||
resp.AccessToken = token
|
||||
resp.Type = typ
|
||||
// Normalize to UTC so JSON encoding always uses the "Z" suffix and
|
||||
// preserves the full timestamp without losing sub-minute precision from
|
||||
// historical timezone offsets (e.g. LMT).
|
||||
resp.ExpiresAt = expiry.UTC()
|
||||
|
||||
var err error
|
||||
if extra.Valid {
|
||||
|
||||
Reference in New Issue
Block a user