mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
b169f4d8cb
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>
139 lines
3.7 KiB
Go
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
|
|
}
|