mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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>
131 lines
4.3 KiB
Go
131 lines
4.3 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/cli/clitest"
|
|
"github.com/coder/coder/v2/cli/cliui"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/coder/v2/testutil/expecter"
|
|
)
|
|
|
|
func TestExternalAuth(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("CanceledWithURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
|
|
URL: "https://github.com",
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
url := srv.URL
|
|
inv, _ := clitest.New(t, "--agent-url", url, "--agent-token", "foo", "external-auth", "access-token", "github")
|
|
stdout := expecter.NewAttachedToInvocation(t, inv)
|
|
waiter := clitest.StartWithWaiter(t, inv)
|
|
stdout.ExpectMatch(ctx, "https://github.com")
|
|
waiter.RequireIs(cliui.ErrCanceled)
|
|
})
|
|
t.Run("SuccessWithToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
|
|
AccessToken: "bananas",
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
url := srv.URL
|
|
inv, _ := clitest.New(t, "--agent-url", url, "--agent-token", "foo", "external-auth", "access-token", "github")
|
|
stdout := expecter.NewAttachedToInvocation(t, inv)
|
|
clitest.Start(t, inv)
|
|
stdout.ExpectMatch(ctx, "bananas")
|
|
})
|
|
t.Run("NoArgs", func(t *testing.T) {
|
|
t.Parallel()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
|
|
AccessToken: "bananas",
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
url := srv.URL
|
|
inv, _ := clitest.New(t, "--agent-url", url, "--agent-token", "foo", "external-auth", "access-token")
|
|
watier := clitest.StartWithWaiter(t, inv)
|
|
watier.RequireContains("wanted 1 args but got 0")
|
|
})
|
|
t.Run("SuccessWithExtra", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
|
|
AccessToken: "bananas",
|
|
TokenExtra: map[string]any{
|
|
"hey": "there",
|
|
},
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
url := srv.URL
|
|
inv, _ := clitest.New(t, "--agent-url", url, "--agent-token", "foo", "external-auth", "access-token", "github", "--extra", "hey")
|
|
stdout := expecter.NewAttachedToInvocation(t, inv)
|
|
clitest.Start(t, inv)
|
|
stdout.ExpectMatch(ctx, "there")
|
|
})
|
|
t.Run("JSONOutput", func(t *testing.T) {
|
|
t.Parallel()
|
|
expiry := time.Now().Add(8 * time.Hour).UTC().Truncate(time.Second)
|
|
|
|
tests := []struct {
|
|
name string
|
|
resp agentsdk.ExternalAuthResponse
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "WithExpiry",
|
|
resp: agentsdk.ExternalAuthResponse{AccessToken: "bananas", ExpiresAt: expiry},
|
|
},
|
|
{
|
|
name: "WithURL",
|
|
resp: agentsdk.ExternalAuthResponse{URL: "https://github.com/login"},
|
|
wantErr: cliui.ErrCanceled,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
httpapi.Write(context.Background(), w, http.StatusOK, tt.resp)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
inv, _ := clitest.New(t, "--agent-url", srv.URL, "--agent-token", "foo", "external-auth", "access-token", "github", "--output", "json")
|
|
buf := new(bytes.Buffer)
|
|
inv.Stdout = buf
|
|
waiter := clitest.StartWithWaiter(t, inv)
|
|
if tt.wantErr != nil {
|
|
waiter.RequireIs(tt.wantErr)
|
|
} else {
|
|
waiter.RequireSuccess()
|
|
}
|
|
|
|
var resp agentsdk.ExternalAuthResponse
|
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
|
|
require.Equal(t, tt.resp.AccessToken, resp.AccessToken)
|
|
require.Equal(t, tt.resp.URL, resp.URL)
|
|
require.Equal(t, tt.resp.ExpiresAt.UTC(), resp.ExpiresAt.UTC())
|
|
})
|
|
}
|
|
})
|
|
}
|